937 lines
35 KiB
YAML
937 lines
35 KiB
YAML
##########################################################################################
|
|
##########################################################################################
|
|
# RINNAI NEO - GAS HEATER CONTROLLER (BUTTON PRESS VIA RELAYS)
|
|
#
|
|
# Device: Sonoff DEV (ESP8266) + 3-Relay daughterboard
|
|
# Project: esp-gasheater Friendly Name: "Rinnai Neo Gas Heater"
|
|
#
|
|
# Purpose:
|
|
# - Replace RF remote. Press panel buttons with relays:
|
|
# ON/OFF = GPIO12 Flame UP = GPIO13 Flame DOWN = GPIO14
|
|
# - Sense panel LEDs as inputs:
|
|
# RED Standby = GPIO4 BLUE Operating = GPIO5
|
|
# - AUTO thermostat uses local setpoint entity (adjustable in HA) and room temp via MQTT.
|
|
# Setpoint also listens to MQTT and will adopt an external value when published.
|
|
# - MANUAL override with level select and step UP/DOWN buttons.
|
|
# - On power ON or boot (if already powered), wait warmup then force level 1 (7x DOWN).
|
|
# - Publish status text to /viewroad-status/gasfire -> "Operating" | "Standby" | "Off"
|
|
#
|
|
# Notes:
|
|
# - Heater manages safety, ignition and fan. We only press buttons and watch LEDs.
|
|
# - LEDs pull to 0V when ON. Inputs default to inverted=true. Flip if your Tasmota shows
|
|
# opposite logic. Pull-ups enabled on inputs.
|
|
# - To reduce click noise: press pulses are short and spaced; optional min on/off timers.
|
|
##########################################################################################
|
|
##########################################################################################
|
|
# V1.0 - 2025-07-21 First Setup (and replacement of Tasmota)
|
|
#
|
|
# NOTES:
|
|
# Command the fan with MQTT
|
|
# ${mqtt_local_command_full_topic}/speed/set 1,2,3,0,+,-
|
|
# ${mqtt_local_command_full_topic}/light/set ON,OFF
|
|
# Status of the fan is in MQTT
|
|
# ${mqtt_local_command_full_topic}/speed/state 1,2,3,0
|
|
# ${mqtt_local_command_full_topic}/light/state ON,OFF
|
|
#
|
|
##########################################################################################
|
|
##########################################################################################
|
|
|
|
##########################################################################################
|
|
# SPECIFIC DEVICE VARIABLE SUBSTITUTIONS
|
|
# If NOT using a secrets file, just replace these with the passwords etc (in quotes)
|
|
##########################################################################################
|
|
substitutions:
|
|
# Device Naming
|
|
device_name: "esp-gasheater"
|
|
friendly_name: "Rinnai Neo Gas Heater"
|
|
description_comment: "7 Setting controller for Rinnai Neo Gas heater :: Sonoff DEV + Relay Board"
|
|
device_area: "Lounge" # Allows ESP device to be automatically linked to an 'Area' in Home Assistant.
|
|
|
|
# Project Naming
|
|
project_name: "Sonoff Technologies.Sonoff DEV" # Project Details
|
|
project_version: "v1.0" # Project V denotes release of yaml file, allowing checking of deployed vs latest version
|
|
|
|
# Passwords & Secrets (unfortunately you can't use substitutions inside secrets names)
|
|
api_key: !secret esp-api_key
|
|
ota_pass: !secret esp-ota_pass
|
|
static_ip_address: !secret esp-gasheater_ip
|
|
mqtt_local_command_main_topic: !secret mqtt_local_command_main_topic
|
|
mqtt_local_status_main_topic: !secret mqtt_local_status_main_topic
|
|
|
|
# Device Settings
|
|
log_level: "INFO" # Define logging level: NONE, ERROR, WARN, INFO, DEBUG (Default), VERBOSE, VERY_VERBOSE
|
|
update_interval: "60s" # update time for for general sensors etc
|
|
|
|
# MQTT LOCAL Controls
|
|
#mqtt_device_name: "bedroom3-ceilingfan"
|
|
#mqtt_local_command_topic: "${mqtt_local_command_main_topic}/${mqtt_device_name}" # Topic we will use to command this locally without HA
|
|
#mqtt_local_status_topic: "${mqtt_local_status_main_topic}/${mqtt_device_name}" # Topic we will use to view status locally without HA
|
|
|
|
# MQTT REMOTE Controls
|
|
|
|
# Button Naming & Icons
|
|
|
|
# Switch/Relay Naming & Icons
|
|
#relay_icon: "mdi:lightbulb-group"
|
|
#light_1_name: "Fan Light"
|
|
#switch_2_name: "Fan 2 Relay"
|
|
#switch_3_name: "Fan 3 Relay"
|
|
#switch_4_name: "Fan 4 Relay"
|
|
|
|
########################################################################################
|
|
# TIMING TUNABLES (string values as required by substitutions)
|
|
########################################################################################
|
|
relay_pulse_ms: "250" # length of each "button press"
|
|
min_click_gap_ms: "1200" # minimum gap between any two clicks
|
|
warmup_seconds: "60" # wait after ON before calibration/stepping
|
|
control_interval_s: "30" # AUTO control loop cadence
|
|
adjust_step_interval_s: "30" # gap between level steps in AUTO
|
|
recalibrate_interval_min: "180" # while at level 1, re-baseline every X min
|
|
temp_stale_timeout_min: "20" # turn OFF if no valid temp for this long (AUTO only)
|
|
fast_ramp_seconds: "180" # fast stepping window after turn-on
|
|
fast_ramp_step_s: "8" # step every 8s during fast-ramp
|
|
|
|
|
|
# Optional anti short-cycle (set to "0" to disable either one)
|
|
min_on_minutes: "5"
|
|
min_off_minutes: "5"
|
|
|
|
########################################################################################
|
|
# ELECTRICAL POLARITY
|
|
########################################################################################
|
|
relay_inverted: "true" # many relay boards are active-low
|
|
red_led_inverted: "true" # LEDs pull to 0V when ON -> invert input
|
|
blue_led_inverted: "true"
|
|
|
|
########################################################################################
|
|
# THERMOSTAT HYSTERESIS (in C)
|
|
########################################################################################
|
|
on_below_c: "0.5" # turn ON when temp <= setpoint - on_below_c
|
|
off_above_c: "0.3" # turn OFF when temp >= setpoint + off_above_c
|
|
|
|
########################################################################################
|
|
# SETPOINT LIMITS FOR HA NUMBER (in C)
|
|
########################################################################################
|
|
setpoint_min_c: "6.0"
|
|
setpoint_max_c: "32.0"
|
|
setpoint_step_c: "0.1"
|
|
|
|
##########################################################################################
|
|
# PACKAGES: Included Common Packages
|
|
# https://esphome.io/components/packages.html
|
|
##########################################################################################
|
|
packages:
|
|
common_wifi: !include
|
|
file: common/network_common.yaml
|
|
vars:
|
|
local_device_name: "${device_name}"
|
|
local_static_ip_address: "${static_ip_address}"
|
|
local_ota_pass: "${ota_pass}"
|
|
common_api: !include
|
|
file: common/api_common.yaml
|
|
vars:
|
|
local_api_key: "${api_key}"
|
|
#common_webportal: !include
|
|
# file: common/webportal_common.yaml
|
|
common_mqtt: !include
|
|
file: common/mqtt_common.yaml
|
|
vars:
|
|
local_device_name: "${device_name}"
|
|
common_sntp: !include
|
|
file: common/sntp_common.yaml
|
|
common_general_sensors: !include
|
|
file: common/sensors_common.yaml
|
|
vars:
|
|
local_friendly_name: "${friendly_name}"
|
|
local_update_interval: "${update_interval}"
|
|
|
|
##########################################################################################
|
|
# ESPHome
|
|
# https://esphome.io/components/esphome.html
|
|
##########################################################################################
|
|
esphome:
|
|
name: "${device_name}"
|
|
friendly_name: "${friendly_name}"
|
|
comment: "${description_comment}" # Appears on the esphome page in HA
|
|
area: "${device_area}"
|
|
# On boot: if heater is already powered, warm up then calibrate to level 1.
|
|
# This protects against unknown state after power outages.
|
|
on_boot:
|
|
priority: -100
|
|
then:
|
|
- script.execute: recompute_powered_state
|
|
- lambda: |-
|
|
// Initialize min on/off timers according to current state
|
|
if (id(powered_state).state) {
|
|
if (id(last_on_ms) == 0) id(last_on_ms) = millis();
|
|
} else {
|
|
if (id(last_off_ms) == 0) id(last_off_ms) = millis();
|
|
}
|
|
// Suppress level adjustments during boot warmup window
|
|
id(warmup_until_ms) = millis() + (${warmup_seconds} * 1000UL);
|
|
- delay: ${warmup_seconds}s
|
|
- if:
|
|
condition:
|
|
lambda: 'return id(powered_state).state;'
|
|
then:
|
|
- if:
|
|
condition:
|
|
lambda: 'return id(mode_select).state != "Remote Control Only";'
|
|
then:
|
|
- logger.log: "Boot: heater already powered. Calibrating to level 1."
|
|
- script.execute: calibrate_to_min
|
|
else:
|
|
- logger.log: "Boot: heater already powered. Remote Control Only -> skipping calibration."
|
|
|
|
# platformio_options:
|
|
# build_flags:
|
|
# - "-Os"
|
|
# - "-Wl,--gc-sections"
|
|
# - "-fno-exceptions"
|
|
# - "-fno-rtti"
|
|
|
|
|
|
|
|
##########################################################################################
|
|
# ESP Platform and Framework
|
|
# https://esphome.io/components/esp32.html
|
|
##########################################################################################
|
|
esp8266:
|
|
board: esp01_1m # The original sonoff basic
|
|
restore_from_flash: true # restore some values on reboot
|
|
|
|
preferences:
|
|
flash_write_interval: 5min
|
|
|
|
mdns:
|
|
disabled: false # Disabling will make the build file smaller (and it is still available via static IP)
|
|
|
|
##########################################################################################
|
|
# Global Variables for use in automations etc
|
|
# https://esphome.io/guides/automations.html?highlight=globals#global-variables
|
|
##########################################################################################
|
|
globals:
|
|
# Current flame level we believe the heater is at (1..7). Kept across reboots.
|
|
- id: current_level
|
|
type: int
|
|
restore_value: yes
|
|
initial_value: "1"
|
|
|
|
# Target flame level we want to reach (set by AUTO or manual selection). Not persisted.
|
|
- id: desired_level
|
|
type: int
|
|
restore_value: no
|
|
initial_value: "1"
|
|
|
|
# Timestamp (ms since boot) of the last level step (up/down). Used for rate limiting.
|
|
- id: last_adjust_ms
|
|
type: uint32_t
|
|
restore_value: no
|
|
initial_value: "0"
|
|
|
|
# Timestamp (ms) of the last calibration-to-min action. Used for periodic re-baseline.
|
|
- id: last_recal_ms
|
|
type: uint32_t
|
|
restore_value: no
|
|
initial_value: "0"
|
|
|
|
# Timestamp (ms) when powered_state last went ON. Used to enforce min_on_minutes.
|
|
- id: last_on_ms
|
|
type: uint32_t
|
|
restore_value: no
|
|
initial_value: "0"
|
|
|
|
# Timestamp (ms) when powered_state last went OFF. Used to enforce min_off_minutes.
|
|
- id: last_off_ms
|
|
type: uint32_t
|
|
restore_value: no
|
|
initial_value: "0"
|
|
|
|
# Local thermostat setpoint (°C). This is the authority; mirrored to MQTT with guard.
|
|
- id: setpoint_c
|
|
type: float
|
|
restore_value: yes
|
|
initial_value: "21.0"
|
|
|
|
# Guard to avoid MQTT echo: true while applying a setpoint that came from MQTT.
|
|
- id: sp_suppress_mqtt
|
|
type: bool
|
|
restore_value: no
|
|
initial_value: "false"
|
|
|
|
# Timestamp (ms) of last received room temperature via MQTT. Detects stale sensor.
|
|
- id: last_temp_ms
|
|
type: uint32_t
|
|
restore_value: no
|
|
initial_value: "0"
|
|
|
|
# Until this ms timestamp we suppress any level changes (post-ON warmup window).
|
|
- id: warmup_until_ms
|
|
type: uint32_t
|
|
restore_value: no
|
|
initial_value: "0"
|
|
|
|
# The level chosen on the previous AUTO tick. Used to confirm stable targets.
|
|
- id: last_level_target
|
|
type: int
|
|
restore_value: no
|
|
initial_value: "1"
|
|
|
|
# True if desired_level matched last_level_target on consecutive ticks.
|
|
- id: target_confirmed
|
|
type: bool
|
|
restore_value: no
|
|
initial_value: "true"
|
|
|
|
# End of the fast-ramp window (ms since boot). Allows faster stepping right after ON.
|
|
- id: ramp_until_ms
|
|
type: uint32_t
|
|
restore_value: no
|
|
initial_value: "0"
|
|
|
|
# True when we initiated power ON ourselves (not RF/panel). Helps skip duplicate actions.
|
|
- id: self_power_on
|
|
type: bool
|
|
restore_value: no
|
|
initial_value: "false"
|
|
|
|
##########################################################################################
|
|
# ESPHome Logging Enable
|
|
# https://esphome.io/components/logger.html
|
|
##########################################################################################
|
|
logger:
|
|
level: "${log_level}" # INFO Level suggested, or DEBUG for testing
|
|
baud_rate: 0 # set to 0 for no logging via UART, needed if you are using it for other serial things (eg PZEM, Serial control)
|
|
#esp8266_store_log_strings_in_flash: false
|
|
#tx_buffer_size: 64
|
|
|
|
##########################################################################################
|
|
# BINARY SENSORS
|
|
# https://esphome.io/components/binary_sensor/
|
|
##########################################################################################
|
|
# GPIO MAP (PHYSICAL)
|
|
#
|
|
# INPUTS:
|
|
# GPIO4 -> RED Standby LED (binary_sensor.led_standby_red)
|
|
# GPIO5 -> BLUE Operating LED (binary_sensor.led_operating_blue)
|
|
#
|
|
# OUTPUTS (RELAY COILS - INTERNAL):
|
|
# GPIO12 -> ON/OFF button (switch.sw_power_raw)
|
|
# GPIO13 -> Flame UP button (switch.sw_up_raw)
|
|
# GPIO14 -> Flame DOWN button (switch.sw_down_raw)
|
|
##########################################################################################
|
|
binary_sensor:
|
|
- platform: gpio
|
|
id: led_standby_red
|
|
name: "${friendly_name} Standby LED"
|
|
pin:
|
|
number: GPIO4
|
|
mode:
|
|
input: true
|
|
pullup: true
|
|
inverted: ${red_led_inverted}
|
|
filters:
|
|
- delayed_on_off: 50ms
|
|
entity_category: diagnostic
|
|
on_state:
|
|
- script.execute: publish_status
|
|
- script.execute: recompute_powered_state
|
|
|
|
- platform: gpio
|
|
id: led_operating_blue
|
|
name: "${friendly_name} Operating LED"
|
|
pin:
|
|
number: GPIO5
|
|
mode:
|
|
input: true
|
|
pullup: true
|
|
inverted: ${blue_led_inverted}
|
|
filters:
|
|
- delayed_on_off: 50ms
|
|
entity_category: diagnostic
|
|
on_state:
|
|
- script.execute: publish_status
|
|
- script.execute: recompute_powered_state
|
|
|
|
# Derived powered state (any LED on). Internal helper for min on/off timing.
|
|
# NOTE: No lambda or update_interval here — state is pushed by a script.
|
|
- platform: template
|
|
id: powered_state
|
|
internal: true
|
|
on_press:
|
|
- lambda: |-
|
|
id(last_on_ms) = millis();
|
|
// If AUTO, enforce warmup lockout on RF/panel ON too
|
|
if (id(mode_select).state == "AUTO") {
|
|
id(warmup_until_ms) = millis() + (${warmup_seconds} * 1000UL);
|
|
}
|
|
# If heater was powered ON externally (RF/panel) while in AUTO, baseline after warmup
|
|
- if:
|
|
condition:
|
|
lambda: 'return (id(mode_select).state == "AUTO") && (!id(self_power_on));'
|
|
then:
|
|
- lambda: |-
|
|
// Start fast-ramp window as well
|
|
id(ramp_until_ms) = millis() + (${fast_ramp_seconds} * 1000UL);
|
|
- delay: ${warmup_seconds}s
|
|
- script.execute: calibrate_to_min
|
|
- script.execute: auto_control_tick
|
|
- script.execute: adjust_to_desired
|
|
on_release:
|
|
- lambda: |-
|
|
id(last_off_ms) = millis();
|
|
// Clear self-power flag on any OFF edge so the next RF ON is recognized
|
|
id(self_power_on) = false;
|
|
|
|
|
|
##########################################################################################
|
|
# SWITCH COMPONENT
|
|
# https://esphome.io/components/switch/
|
|
##########################################################################################
|
|
switch:
|
|
# Raw coils (do not expose these)
|
|
- platform: gpio
|
|
id: sw_power_raw
|
|
internal: true
|
|
restore_mode: ALWAYS_OFF
|
|
pin:
|
|
number: GPIO12
|
|
inverted: ${relay_inverted}
|
|
|
|
- platform: gpio
|
|
id: sw_up_raw
|
|
internal: true
|
|
restore_mode: ALWAYS_OFF
|
|
pin:
|
|
number: GPIO13
|
|
inverted: ${relay_inverted}
|
|
|
|
- platform: gpio
|
|
id: sw_down_raw
|
|
internal: true
|
|
restore_mode: ALWAYS_OFF
|
|
pin:
|
|
number: GPIO14
|
|
inverted: ${relay_inverted}
|
|
|
|
# Power as a logical switch (we press ON/OFF only if a change is needed).
|
|
- platform: template
|
|
id: power_switch
|
|
name: "${friendly_name} Power"
|
|
icon: mdi:power
|
|
lambda: |-
|
|
return (id(led_operating_blue).state || id(led_standby_red).state);
|
|
turn_on_action:
|
|
- if:
|
|
condition:
|
|
lambda: |-
|
|
return !(id(led_operating_blue).state || id(led_standby_red).state);
|
|
then:
|
|
- script.execute: press_power
|
|
- script.execute: after_power_on_reset_to_auto
|
|
turn_off_action:
|
|
- if:
|
|
condition:
|
|
lambda: |-
|
|
return (id(led_operating_blue).state || id(led_standby_red).state);
|
|
then:
|
|
- script.execute: press_power
|
|
|
|
##########################################################################################
|
|
# Sensors
|
|
# https://esphome.io/components/sensor/index.html
|
|
##########################################################################################
|
|
# MQTT I/O (ROOM TEMP IN, SETPOINT IN/OUT, STATUS TEXT OUT)
|
|
sensor:
|
|
- platform: mqtt_subscribe
|
|
id: room_temp
|
|
name: "${friendly_name} Room Temperature (MQTT)"
|
|
topic: /viewroad-status/lounge-environment/temperature
|
|
unit_of_measurement: "C"
|
|
accuracy_decimals: 1
|
|
on_value:
|
|
- lambda: |-
|
|
id(last_temp_ms) = millis();
|
|
float v = x;
|
|
if (v < -10.0f) v = -10.0f;
|
|
if (v > 50.0f) v = 50.0f;
|
|
if (v != id(room_temp).state) id(room_temp).publish_state(v);
|
|
|
|
# Setpoint mirror from MQTT -> adopt into local number without echoing back
|
|
- platform: mqtt_subscribe
|
|
id: setpoint_from_mqtt
|
|
internal: true
|
|
topic: /viewroad-status/lounge-environment/temp-setpoint
|
|
unit_of_measurement: "C"
|
|
accuracy_decimals: 1
|
|
on_value:
|
|
- lambda: |-
|
|
// Clamp to substitution-defined range
|
|
float v = x;
|
|
const float vmin = ${setpoint_min_c};
|
|
const float vmax = ${setpoint_max_c};
|
|
if (v < vmin) v = vmin;
|
|
if (v > vmax) v = vmax;
|
|
|
|
// Guard: applying an MQTT-driven update -> do not re-publish
|
|
id(sp_suppress_mqtt) = true;
|
|
id(setpoint_c) = v;
|
|
- number.set:
|
|
id: setpoint_number
|
|
value: !lambda 'return id(setpoint_c);'
|
|
- lambda: |-
|
|
id(sp_suppress_mqtt) = false;
|
|
|
|
|
|
##########################################################################################
|
|
# TEXT SENSOR COMPONENT
|
|
# https://esphome.io/components/text_sensor/index.html
|
|
##########################################################################################
|
|
text_sensor:
|
|
- platform: template
|
|
id: status_text
|
|
name: "${friendly_name} Status Text"
|
|
update_interval: 1s
|
|
lambda: |-
|
|
if (id(led_operating_blue).state) return std::string("Operating");
|
|
if (id(led_standby_red).state) return std::string("Standby");
|
|
return std::string("Off");
|
|
|
|
##########################################################################################
|
|
# SELECT COMPONENT
|
|
# https://esphome.io/components/select/index.html
|
|
##########################################################################################
|
|
select:
|
|
# AUTO or MANUAL (MANUAL is sticky until next power ON)
|
|
- platform: template
|
|
id: mode_select
|
|
name: "${friendly_name} Mode"
|
|
options: ["AUTO","MANUAL","Remote Control Only"]
|
|
optimistic: true
|
|
initial_option: "AUTO"
|
|
restore_value: true
|
|
|
|
# Flame level (1..7). Selecting forces MANUAL and adjusts to the chosen level.
|
|
- platform: template
|
|
id: flame_level_select
|
|
name: "${friendly_name} Flame Level"
|
|
options: ["1","2","3","4","5","6","7"]
|
|
optimistic: true
|
|
restore_value: true
|
|
set_action:
|
|
# If selection equals our known current level, ignore to avoid loops.
|
|
- if:
|
|
condition:
|
|
lambda: |-
|
|
int sel = atoi(x.c_str());
|
|
return sel == id(current_level);
|
|
then:
|
|
- logger.log: "Flame Level: no change; ignoring selection."
|
|
else:
|
|
- if:
|
|
condition:
|
|
lambda: 'return id(mode_select).state != "Remote Control Only";'
|
|
then:
|
|
- select.set: { id: mode_select, option: "MANUAL" }
|
|
- lambda: |-
|
|
int target = atoi(x.c_str());
|
|
if (target < 1) target = 1;
|
|
if (target > 7) target = 7;
|
|
id(desired_level) = target;
|
|
- if:
|
|
condition:
|
|
lambda: 'return !id(powered_state).state;'
|
|
then:
|
|
# Power on, respect warmup, calibrate to level 1, then adjust toward desired (still MANUAL)
|
|
- script.execute: press_power
|
|
- lambda: |-
|
|
id(warmup_until_ms) = millis() + (${warmup_seconds} * 1000UL);
|
|
id(ramp_until_ms) = millis() + (${fast_ramp_seconds} * 1000UL); // fast ramp window
|
|
- delay: ${warmup_seconds}s
|
|
- script.execute: calibrate_to_min
|
|
- script.execute: adjust_to_desired
|
|
else:
|
|
- script.execute: adjust_to_desired
|
|
else:
|
|
- logger.log: "Remote Control Only: ignoring Flame Level selection."
|
|
|
|
|
|
#################################################################################################
|
|
# BUTTON COMPONENT
|
|
# https://esphome.io/components/button/index.html
|
|
#################################################################################################
|
|
button:
|
|
# Manual step buttons (each press forces MANUAL) - button up
|
|
- platform: template
|
|
id: btn_flame_up
|
|
name: "${friendly_name} Flame Up (step)"
|
|
on_press:
|
|
- if:
|
|
condition:
|
|
lambda: 'return id(mode_select).state != "Remote Control Only";'
|
|
then:
|
|
- select.set: { id: mode_select, option: "MANUAL" }
|
|
- script.execute: step_up_once
|
|
else:
|
|
- logger.log: "Remote Control Only: ignoring Flame Up (step)."
|
|
|
|
# Manual step buttons (each press forces MANUAL) - button down
|
|
- platform: template
|
|
id: btn_flame_down
|
|
name: "${friendly_name} Flame Down (step)"
|
|
on_press:
|
|
- if:
|
|
condition:
|
|
lambda: 'return id(mode_select).state != "Remote Control Only";'
|
|
then:
|
|
- select.set: { id: mode_select, option: "MANUAL" }
|
|
- script.execute: step_down_once
|
|
else:
|
|
- logger.log: "Remote Control Only: ignoring Flame Down (step)."
|
|
|
|
# Calibrate
|
|
- platform: template
|
|
id: btn_calibrate_min
|
|
name: "${friendly_name} Calibrate: Force Level 1 (7x down)"
|
|
entity_category: config
|
|
on_press:
|
|
- script.execute: calibrate_to_min
|
|
|
|
|
|
#################################################################################################
|
|
# NUMBER COMPONENT
|
|
# https://esphome.io/components/number/
|
|
#################################################################################################
|
|
number:
|
|
# Setpoint number (primary authority). Two-way MQTT sync with loop guard.
|
|
- platform: template
|
|
id: setpoint_number
|
|
name: "${friendly_name} Setpoint"
|
|
icon: mdi:thermometer
|
|
min_value: ${setpoint_min_c}
|
|
max_value: ${setpoint_max_c}
|
|
step: ${setpoint_step_c}
|
|
mode: slider
|
|
lambda: |-
|
|
return id(setpoint_c);
|
|
set_action:
|
|
# 1) adopt the new value locally
|
|
- lambda: |-
|
|
id(setpoint_c) = x;
|
|
# 2) unless we are processing an incoming MQTT update, publish out
|
|
- if:
|
|
condition:
|
|
lambda: |-
|
|
return !id(sp_suppress_mqtt);
|
|
then:
|
|
- mqtt.publish:
|
|
topic: /viewroad-status/lounge-environment/temp-setpoint
|
|
retain: true
|
|
payload: !lambda |-
|
|
char buf[16];
|
|
snprintf(buf, sizeof(buf), "%.1f", x);
|
|
return std::string(buf);
|
|
|
|
##########################################################################################
|
|
# INTERVAL COMPONENT
|
|
# https://esphome.io/components/interval/
|
|
##########################################################################################
|
|
interval:
|
|
# AUTO loop tick (only when AUTO is enabled)
|
|
- interval: ${control_interval_s}s
|
|
then:
|
|
- if:
|
|
condition:
|
|
lambda: 'return id(mode_select).state == "AUTO";'
|
|
then:
|
|
- script.execute: auto_control_tick
|
|
|
|
|
|
#################################################################################################
|
|
# SCRIPT COMPONENT
|
|
# https://esphome.io/components/script.html
|
|
#################################################################################################
|
|
script:
|
|
# Publish textual status to MQTT
|
|
- id: publish_status
|
|
mode: restart
|
|
then:
|
|
- mqtt.publish:
|
|
topic: /viewroad-status/gasfire
|
|
payload: !lambda |-
|
|
if (id(led_operating_blue).state) return std::string("Operating");
|
|
if (id(led_standby_red).state) return std::string("Standby");
|
|
return std::string("Off");
|
|
|
|
- id: recompute_powered_state
|
|
then:
|
|
- lambda: |-
|
|
const bool new_state = id(led_operating_blue).state || id(led_standby_red).state;
|
|
id(powered_state).publish_state(new_state);
|
|
|
|
# Low-level press with enforced pulse and gap
|
|
- id: press_with_gap
|
|
mode: queued
|
|
parameters:
|
|
which: string
|
|
then:
|
|
- if:
|
|
condition:
|
|
lambda: 'return which == "power";'
|
|
then:
|
|
- switch.turn_on: sw_power_raw
|
|
- delay: ${relay_pulse_ms}ms
|
|
- switch.turn_off: sw_power_raw
|
|
else:
|
|
- if:
|
|
condition:
|
|
lambda: 'return which == "up";'
|
|
then:
|
|
- switch.turn_on: sw_up_raw
|
|
- delay: ${relay_pulse_ms}ms
|
|
- switch.turn_off: sw_up_raw
|
|
else:
|
|
- switch.turn_on: sw_down_raw
|
|
- delay: ${relay_pulse_ms}ms
|
|
- switch.turn_off: sw_down_raw
|
|
- delay: ${min_click_gap_ms}ms
|
|
|
|
|
|
- id: press_power
|
|
then:
|
|
- lambda: |-
|
|
id(self_power_on) = true;
|
|
- script.execute: { id: press_with_gap, which: "power" }
|
|
|
|
|
|
- id: press_up
|
|
then:
|
|
- if:
|
|
condition:
|
|
lambda: 'return id(mode_select).state != "Remote Control Only";'
|
|
then:
|
|
- script.execute: { id: press_with_gap, which: "up" }
|
|
else:
|
|
- logger.log: "Remote Control Only: ignoring UP press."
|
|
|
|
- id: press_down
|
|
then:
|
|
- if:
|
|
condition:
|
|
lambda: 'return id(mode_select).state != "Remote Control Only";'
|
|
then:
|
|
- script.execute: { id: press_with_gap, which: "down" }
|
|
else:
|
|
- logger.log: "Remote Control Only: ignoring DOWN press."
|
|
|
|
# Single-step level adjusters (track current_level and update the select)
|
|
- id: step_up_once
|
|
then:
|
|
- if:
|
|
condition:
|
|
lambda: 'return id(powered_state).state;'
|
|
then:
|
|
- lambda: |-
|
|
if (id(current_level) < 7) id(current_level)++;
|
|
- script.execute: press_up
|
|
- select.set:
|
|
id: flame_level_select
|
|
option: !lambda 'return std::to_string(id(current_level));'
|
|
- lambda: |-
|
|
id(last_adjust_ms) = millis();
|
|
else:
|
|
- logger.log: "Heater is OFF; ignoring manual step up."
|
|
|
|
- id: step_down_once
|
|
then:
|
|
- if:
|
|
condition:
|
|
lambda: 'return id(powered_state).state;'
|
|
then:
|
|
- lambda: |-
|
|
if (id(current_level) > 1) id(current_level)--;
|
|
- script.execute: press_down
|
|
- select.set:
|
|
id: flame_level_select
|
|
option: !lambda 'return std::to_string(id(current_level));'
|
|
- lambda: |-
|
|
id(last_adjust_ms) = millis();
|
|
else:
|
|
- logger.log: "Heater is OFF; ignoring manual step down."
|
|
|
|
|
|
# Force level 1 certainty (7x DOWN) - skipped in Remote Control Only and if OFF
|
|
- id: calibrate_to_min
|
|
then:
|
|
- if:
|
|
condition:
|
|
lambda: 'return id(mode_select).state != "Remote Control Only" && id(powered_state).state;'
|
|
then:
|
|
- repeat:
|
|
count: 7
|
|
then:
|
|
- script.execute: press_down
|
|
- lambda: |-
|
|
id(current_level) = 1;
|
|
id(last_recal_ms) = millis();
|
|
- select.set:
|
|
id: flame_level_select
|
|
option: "1"
|
|
else:
|
|
- logger.log: "Skipping calibration (Remote Control Only or heater OFF)."
|
|
|
|
# Move smoothly toward desired_level (rate-limited)
|
|
- id: adjust_to_desired
|
|
mode: queued
|
|
then:
|
|
- if:
|
|
condition:
|
|
lambda: 'return millis() < id(warmup_until_ms);'
|
|
then:
|
|
- logger.log:
|
|
format: "Warmup: suppressing level change."
|
|
level: DEBUG
|
|
else:
|
|
- if:
|
|
condition:
|
|
lambda: |-
|
|
// Faster stepping during ramp window, normal otherwise
|
|
const uint32_t step_ms = (millis() < id(ramp_until_ms))
|
|
? (${fast_ramp_step_s} * 1000UL)
|
|
: (${adjust_step_interval_s} * 1000UL);
|
|
return (millis() - id(last_adjust_ms)) >= step_ms;
|
|
then:
|
|
- if:
|
|
condition:
|
|
lambda: 'return !id(powered_state).state;'
|
|
then:
|
|
- logger.log:
|
|
format: "Heater is OFF; not adjusting level."
|
|
level: DEBUG
|
|
else:
|
|
# Proceed if target is confirmed OR we are still in the ramp window
|
|
- if:
|
|
condition:
|
|
lambda: 'return id(target_confirmed) || (millis() < id(ramp_until_ms));'
|
|
then:
|
|
- if:
|
|
condition:
|
|
lambda: 'return id(current_level) < id(desired_level);'
|
|
then:
|
|
- script.execute: step_up_once
|
|
else:
|
|
- if:
|
|
condition:
|
|
lambda: 'return id(current_level) > id(desired_level);'
|
|
then:
|
|
- script.execute: step_down_once
|
|
|
|
|
|
- id: after_power_on_reset_to_auto
|
|
then:
|
|
- if:
|
|
condition:
|
|
lambda: 'return id(mode_select).state != "Remote Control Only";'
|
|
then:
|
|
- lambda: |-
|
|
// Suppress level adjustments during post-power-on warmup
|
|
id(warmup_until_ms) = millis() + (${warmup_seconds} * 1000UL);
|
|
// Enable a short fast-ramp window for quicker heat-up
|
|
id(ramp_until_ms) = millis() + (${fast_ramp_seconds} * 1000UL);
|
|
- select.set: { id: mode_select, option: "AUTO" }
|
|
- delay: ${warmup_seconds}s
|
|
- script.execute: calibrate_to_min
|
|
# Immediately compute a level and move toward it (no extra 30s wait)
|
|
- script.execute: auto_control_tick
|
|
- script.execute: adjust_to_desired
|
|
- lambda: |-
|
|
// Done with our own ON sequence
|
|
id(self_power_on) = false;
|
|
else:
|
|
- logger.log: "Remote Control Only: keeping mode; no auto-calibrate."
|
|
|
|
|
|
# AUTO thermostat logic (hysteresis + level mapping)
|
|
- id: auto_control_tick
|
|
mode: queued
|
|
then:
|
|
- lambda: |-
|
|
const float t = id(room_temp).state;
|
|
const float sp = id(setpoint_c);
|
|
const uint32_t now_ms = millis();
|
|
|
|
// Fail-safe: if temperature is missing (NAN), optionally turn OFF after timeout.
|
|
if (isnan(t)) {
|
|
const bool powered = id(powered_state).state;
|
|
const bool stale = (id(last_temp_ms) > 0) &&
|
|
((now_ms - id(last_temp_ms)) >= (${temp_stale_timeout_min} * 60000UL));
|
|
if (powered && stale) {
|
|
ESP_LOGW("gas", "AUTO: temperature stale -> turning OFF as fail-safe");
|
|
id(press_power).execute();
|
|
} else {
|
|
ESP_LOGW("gas", "AUTO: temperature missing; skipping control tick");
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (isnan(sp)) {
|
|
ESP_LOGW("gas", "AUTO: setpoint missing; skipping control tick");
|
|
return;
|
|
}
|
|
|
|
const bool powered = id(powered_state).state;
|
|
const float low = sp - ${on_below_c};
|
|
const float high = sp + ${off_above_c};
|
|
|
|
// Honour minimum on/off durations
|
|
const bool can_turn_on = (!powered) && ((${min_off_minutes} == 0) || ((now_ms - id(last_off_ms)) >= (${min_off_minutes} * 60000UL)));
|
|
const bool can_turn_off = ( powered) && ((${min_on_minutes} == 0) || ((now_ms - id(last_on_ms)) >= (${min_on_minutes} * 60000UL)));
|
|
|
|
// Hysteresis-based ON/OFF
|
|
if (t <= low && can_turn_on) {
|
|
ESP_LOGI("gas", "AUTO: below setpoint -> ON");
|
|
id(press_power).execute();
|
|
id(after_power_on_reset_to_auto).execute();
|
|
return;
|
|
}
|
|
if (t >= high && can_turn_off) {
|
|
ESP_LOGI("gas", "AUTO: above setpoint -> OFF");
|
|
id(press_power).execute();
|
|
return;
|
|
}
|
|
|
|
// Map error to desired flame level when powered.
|
|
// Always set desired_level; use target_confirmed to gate normal (non-ramp) steps.
|
|
if (powered) {
|
|
float diff = sp - t; // positive => need more heat
|
|
int target = 1;
|
|
if (diff > 3.0f) target = 7;
|
|
else if (diff > 2.0f) target = 6;
|
|
else if (diff > 1.5f) target = 5;
|
|
else if (diff > 1.0f) target = 4;
|
|
else if (diff > 0.6f) target = 3;
|
|
else if (diff > 0.2f) target = 2;
|
|
else target = 1;
|
|
|
|
id(desired_level) = target;
|
|
id(target_confirmed) = (target == id(last_level_target));
|
|
id(last_level_target) = target;
|
|
}
|
|
|
|
|
|
- if:
|
|
condition:
|
|
lambda: 'return id(powered_state).state;'
|
|
then:
|
|
- if:
|
|
condition:
|
|
lambda: |-
|
|
return (id(desired_level) == 1) &&
|
|
((millis() - id(last_recal_ms)) >= (${recalibrate_interval_min} * 60000UL));
|
|
then:
|
|
- logger.log: "Re-baseline at level 1 (7x DOWN)."
|
|
- script.execute: calibrate_to_min
|
|
- script.execute: adjust_to_desired
|
|
|
|
|