#:########################################################################################:# # TITLE: # RINNAI NEO GAS HEATER CONTROLLER #:########################################################################################:# # REPO: # https://home.fox.co.nz/gitea/zorruno/zorruno-homeassistant/src/branch/master/esphome/esp-gasheater.yaml #:########################################################################################:# # VERSIONS: # V1.7 2026-03-26 Added Heater Operation switch; MQTT status now mirrors the Status Text # entity; preserved Test Mode AUTO test temperature and visible 6-step calibration # V1.6 2026-03-22 Added richer status text: At Temperature, Operating (Level X), # Calibrating, and Fault (Room Temperature Lost) # V1.5 2026-03-22 Added Test Temperature Value slider for Test Mode AUTO; status text # shows Test Mode state; temperature units set to °C # V1.4 2026-03-22 Updated yaml to zorruno layout V1.1; boot into BYPASS (Remote Only); # renamed remote-only mode; refined Test Mode to click real power relay; # added setpoint text and setpoint up/down buttons # V1.3 2026-03-22 Refined Test Mode so power relay still clicks; added setpoint text plus # setpoint up/down buttons; Test Mode can now be used for MANUAL or AUTO testing # V1.2 2026-03-22 Added Test Mode bench testing override # V1.1 2026-03-22 Updated yaml to zorruno layout V1.1; forced AUTO on reboot; initialized # stale-temp timer at boot; cleared Remote Control Only self-power flag # V1.0 2025-07-21 First setup (replacement of Tasmota) #:########################################################################################:# # HARDWARE: # - Sonoff DEV (ESP8266) # - 3-relay daughterboard # - Rinnai Neo gas heater # - Relay outputs: # - GPIO12 = ON/OFF button press # - GPIO13 = Flame UP button press # - GPIO14 = Flame DOWN button press # - LED inputs: # - GPIO4 = Red standby LED # - GPIO5 = Blue operating LED #:########################################################################################:# # OPERATION NOTES: # - Replaces the RF remote by pressing the heater panel buttons via relays. # - Heater manages its own ignition, flame safety, and fan control. # - AUTO mode uses: # - Local HA setpoint number entity # - External room temperature via MQTT # - In Test Mode, AUTO uses the local Test Temperature Value slider instead of MQTT room # temperature. # - Setpoint is locally adjustable and also supports two-way MQTT sync. # - Manual flame control is available via: # - Flame Level select (1..7) # - Flame Up step button # - Flame Down step button # - Setpoint helper entities are available via: # - Setpoint Text # - Setpoint Up (+0.5 °C) # - Setpoint Down (-0.5 °C) # - Test Mode is for bench testing before connection to the heater: # - Forces Mode to MANUAL when enabled # - Uses an internal powered flag instead of LED monitoring # - Still clicks the real power relay when Power is turned ON or OFF # - AUTO may still be selected manually later for testing # - Heater Operation is a high-level thermostat enable helper: # - ON = turn Test Mode OFF (if needed) and set Mode to AUTO # - OFF = turn heater Power OFF and set Mode to BYPASS (Remote Only) # - On normal ESPHome startup, Mode defaults to BYPASS (Remote Only). # - On power ON in AUTO, the controller: # - Applies a warmup lockout # - Calibrates to level 1 using 6x DOWN # - Begins auto level adjustment # - If the heater is turned on externally while in AUTO, the controller will re-baseline # after warmup and resume automatic control. # - Fast-ramp mode allows faster flame stepping for a short period after turn-on. #:########################################################################################:# # MQTT COMMANDS: # - Room temperature input: # viewroad-status/lounge-environment/temperature # - Setpoint input/output: # viewroad-status/lounge-environment/temp-setpoint # - Heater operating status output: # viewroad-status/lounge-gasheater #:########################################################################################:# # OFFLINE NOTES: # a) HA offline, WiFi/MQTT online: # - Device continues to operate normally. # - AUTO control still works if room temperature MQTT is still being published. # - Setpoint can still be changed via MQTT. # # b) MQTT offline (or HA and MQTT offline): # - No fresh room temperature or setpoint updates are received. # - Device retains the last local setpoint. # - If AUTO is active and temperature data becomes stale, the heater will turn OFF after # temp_stale_timeout_min as a fail-safe. # - Manual mode and flame tracking still work locally inside the ESP device logic. # # c) Entire WiFi/Network offline: # - No MQTT temperature or setpoint updates are available. # - Accurate time is not needed. SNTP is not required for this device. # - If AUTO is active and the heater is ON, stale temperature fail-safe will turn it OFF # after the configured timeout. # - Existing heater safety systems still operate normally. #:########################################################################################:# ########################################################################################## # SPECIFIC DEVICE VARIABLE SUBSTITUTIONS ########################################################################################## 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" # Project Naming project_name: "Sonoff Technologies.Sonoff DEV" project_version: "v1.7" # Passwords & Secrets api_key: !secret esp-api_key ota_pass: !secret esp-ota_pass static_ip_address: !secret esp-gasheater_ip current_ip_address: ${static_ip_address} mqtt_local_command_main_topic: !secret mqtt_command_main_topic mqtt_local_status_main_topic: !secret mqtt_status_main_topic # Device Settings log_level: "INFO" update_interval: "60s" ######################################################################################## # TIMING TUNABLES ######################################################################################## relay_pulse_ms: "250" min_click_gap_ms: "1200" warmup_seconds: "30" control_interval_s: "10" adjust_step_interval_s: "10" recalibrate_interval_min: "180" temp_stale_timeout_min: "20" fast_ramp_seconds: "240" fast_ramp_step_s: "4" # Optional anti short-cycle (set to "0" to disable either one) min_on_minutes: "1" min_off_minutes: "0.5" ######################################################################################## # ELECTRICAL POLARITY ######################################################################################## relay_inverted: "true" red_led_inverted: "true" blue_led_inverted: "true" ######################################################################################## # THERMOSTAT HYSTERESIS (°C) ######################################################################################## on_below_c: "0.2" off_above_c: "0.3" ######################################################################################## # SETPOINT LIMITS FOR HA NUMBER (°C) ######################################################################################## setpoint_min_c: "6.0" setpoint_max_c: "32.0" setpoint_step_c: "0.1" ########################################################################################## # PACKAGES: Included Common Packages ########################################################################################## packages: common_wifi: !include file: common/network_common.yaml vars: local_device_name: "${device_name}" local_static_ip_address: "${static_ip_address}" local_current_ip_address: "${current_ip_address}" local_ota_pass: "${ota_pass}" common_api: !include file: common/api_common.yaml vars: local_api_key: "${api_key}" 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 ########################################################################################## esphome: name: "${device_name}" friendly_name: "${friendly_name}" comment: "${description_comment}" area: "${device_area}" on_boot: priority: -100 then: - script.execute: recompute_powered_state - lambda: |- 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(); } id(last_temp_ms) = millis(); 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 != "BYPASS (Remote Only)";' then: - logger.log: "Boot: heater already powered. Calibrating to level 1." - script.execute: calibrate_to_min else: - logger.log: "Boot: heater already powered. BYPASS (Remote Only) -> skipping calibration." ########################################################################################## # ESP PLATFORM ########################################################################################## esp8266: board: esp01_1m restore_from_flash: true preferences: flash_write_interval: 5min mdns: disabled: false ########################################################################################## # GLOBAL VARIABLES ########################################################################################## globals: - id: current_level type: int restore_value: yes initial_value: "1" - id: desired_level type: int restore_value: no initial_value: "1" - id: last_adjust_ms type: uint32_t restore_value: no initial_value: "0" - id: last_recal_ms type: uint32_t restore_value: no initial_value: "0" - id: last_on_ms type: uint32_t restore_value: no initial_value: "0" - id: last_off_ms type: uint32_t restore_value: no initial_value: "0" - id: setpoint_c type: float restore_value: yes initial_value: "21.0" - id: sp_suppress_mqtt type: bool restore_value: no initial_value: "false" - id: last_temp_ms type: uint32_t restore_value: no initial_value: "0" - id: warmup_until_ms type: uint32_t restore_value: no initial_value: "0" - id: last_level_target type: int restore_value: no initial_value: "1" - id: target_confirmed type: bool restore_value: no initial_value: "true" - id: ramp_until_ms type: uint32_t restore_value: no initial_value: "0" - id: self_power_on type: bool restore_value: no initial_value: "false" # Bench-test logical power state used only when Test Mode is enabled - id: test_powered type: bool restore_value: no initial_value: "false" # Bench-test room temperature used only when Test Mode is enabled - id: test_temp_c type: float restore_value: yes initial_value: "20.0" # True while a calibration sequence is actively stepping down to level 1 - id: calibrating type: bool restore_value: no initial_value: "false" # True when remote MQTT room temperature has gone stale in normal (non-test) mode - id: room_temp_lost_fault type: bool restore_value: no initial_value: "false" ########################################################################################## # ESPHOME LOGGING ENABLE ########################################################################################## logger: level: "${log_level}" baud_rate: 0 ########################################################################################## # BINARY SENSORS ########################################################################################## 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: 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: recompute_powered_state - platform: template id: powered_state internal: true on_press: - lambda: |- id(last_on_ms) = millis(); if (!id(test_mode_enabled).state && id(mode_select).state == "AUTO") { id(warmup_until_ms) = millis() + (${warmup_seconds} * 1000UL); } - if: condition: lambda: 'return !id(test_mode_enabled).state && (id(mode_select).state == "AUTO") && (!id(self_power_on));' then: - lambda: |- 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(); id(self_power_on) = false; ########################################################################################## # SWITCH COMPONENT ########################################################################################## switch: - 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} - platform: template id: test_mode_enabled name: "${friendly_name} Test Mode" icon: mdi:test-tube optimistic: true restore_mode: ALWAYS_OFF turn_on_action: - lambda: |- // Preserve current logical power state when entering Test Mode id(test_powered) = id(powered_state).state; id(room_temp_lost_fault) = false; - select.set: id: mode_select option: "MANUAL" - script.execute: recompute_powered_state turn_off_action: - lambda: |- id(test_powered) = false; - script.execute: recompute_powered_state # High-level thermostat enable helper. # ON = thermostat logic active in AUTO # OFF = force BYPASS and turn heater power OFF - platform: template id: heater_operation_switch name: "Heater Operation" icon: mdi:radiator lambda: |- return id(mode_select).state == "AUTO"; turn_on_action: - select.set: id: mode_select option: "AUTO" turn_off_action: - switch.turn_off: power_switch - select.set: id: mode_select option: "BYPASS (Remote Only)" - platform: template id: power_switch name: "${friendly_name} Power" icon: mdi:power lambda: |- return id(powered_state).state; turn_on_action: - if: condition: lambda: 'return id(mode_select).state == "BYPASS (Remote Only)" && !id(test_mode_enabled).state;' then: - logger.log: "BYPASS (Remote Only): ignoring Power ON." else: - if: condition: lambda: |- return !id(powered_state).state; then: - script.execute: press_power - if: condition: lambda: 'return id(test_mode_enabled).state;' then: - lambda: |- id(test_powered) = true; - script.execute: recompute_powered_state - script.execute: after_power_on_reset_to_auto turn_off_action: - if: condition: lambda: 'return id(mode_select).state == "BYPASS (Remote Only)" && !id(test_mode_enabled).state;' then: - logger.log: "BYPASS (Remote Only): ignoring Power OFF." else: - if: condition: lambda: |- return id(powered_state).state; then: - script.execute: press_power - if: condition: lambda: 'return id(test_mode_enabled).state;' then: - lambda: |- id(test_powered) = false; - script.execute: recompute_powered_state ########################################################################################## # SENSORS ########################################################################################## 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(); id(room_temp_lost_fault) = false; 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); - platform: mqtt_subscribe id: setpoint_from_mqtt internal: true topic: "viewroad-command/lounge-gasheater/setpoint" unit_of_measurement: "°C" accuracy_decimals: 1 on_value: - lambda: |- 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; 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 ########################################################################################## text_sensor: - platform: template id: status_text name: "${friendly_name} Status Text" update_interval: 1s lambda: |- const bool use_test_temp = id(test_mode_enabled).state; const float effective_t = use_test_temp ? id(test_temp_c) : id(room_temp).state; const float at_temp_threshold = id(setpoint_c) + ${off_above_c}; const std::string mode = id(mode_select).state; if (!use_test_temp && id(room_temp_lost_fault)) { return std::string("Fault (Room Temperature Lost)"); } if (id(calibrating)) { if (use_test_temp) return std::string("Calibrating (Test)"); return std::string("Calibrating"); } if (use_test_temp) { if (mode == "MANUAL") { if (id(powered_state).state) { char buf[48]; snprintf(buf, sizeof(buf), "Manual (Level %d) (Test)", id(current_level)); return std::string(buf); } if (id(led_standby_red).state) return std::string("Manual (Standby) (Test)"); return std::string("Manual (Off) (Test)"); } if (id(powered_state).state) { char buf[40]; snprintf(buf, sizeof(buf), "Operating (Level %d) (Test)", id(current_level)); return std::string(buf); } if (mode == "AUTO" && !isnan(effective_t) && effective_t >= at_temp_threshold) { return std::string("At Temperature (Test)"); } return std::string("Off (Test)"); } if (mode == "BYPASS (Remote Only)") { if (id(led_operating_blue).state) return std::string("Remote Only (Operating)"); if (id(led_standby_red).state) return std::string("Remote Only (Standby)"); return std::string("Remote Only (Off)"); } if (mode == "MANUAL") { if (id(led_operating_blue).state) { char buf[32]; snprintf(buf, sizeof(buf), "Manual (Level %d)", id(current_level)); return std::string(buf); } if (id(led_standby_red).state) return std::string("Manual (Standby)"); return std::string("Manual (Off)"); } if (id(led_operating_blue).state) { char buf[32]; snprintf(buf, sizeof(buf), "Operating (Level %d)", id(current_level)); return std::string(buf); } if (!isnan(effective_t) && effective_t >= at_temp_threshold) { return std::string("At Temperature"); } if (id(led_standby_red).state) return std::string("Standby"); return std::string("Off"); on_value: - script.execute: publish_status - platform: template id: setpoint_text name: "Setpoint Text" icon: mdi:thermometer update_interval: 1s lambda: |- char buf[16]; snprintf(buf, sizeof(buf), "%.1f °C", id(setpoint_c)); return std::string(buf); - platform: template id: test_temperature_text name: "${friendly_name} Test Mode Temp" icon: mdi:thermometer-chevron-up update_interval: 1s lambda: |- char buf[20]; snprintf(buf, sizeof(buf), "%.1f °C", id(test_temp_c)); return std::string(buf); ########################################################################################## # SELECT COMPONENT ########################################################################################## select: - platform: template id: mode_select name: "Mode" options: ["AUTO","MANUAL","BYPASS (Remote Only)"] optimistic: true initial_option: "BYPASS (Remote Only)" restore_value: false - 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: 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 != "BYPASS (Remote 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: - script.execute: press_power - if: condition: lambda: 'return id(test_mode_enabled).state;' then: - lambda: |- id(test_powered) = true; - script.execute: recompute_powered_state - if: condition: lambda: 'return !id(test_mode_enabled).state;' then: - lambda: |- id(warmup_until_ms) = millis() + (${warmup_seconds} * 1000UL); id(ramp_until_ms) = millis() + (${fast_ramp_seconds} * 1000UL); - delay: ${warmup_seconds}s - script.execute: calibrate_to_min - script.execute: adjust_to_desired else: - logger.log: "Test Mode active: logical power now ON." - script.execute: adjust_to_desired else: - script.execute: adjust_to_desired else: - logger.log: "BYPASS (Remote Only): ignoring Flame Level selection." ########################################################################################## # BUTTON COMPONENT ########################################################################################## button: - platform: template id: btn_flame_up name: "${friendly_name} Flame Up (step)" on_press: - if: condition: lambda: 'return id(mode_select).state != "BYPASS (Remote Only)";' then: - select.set: id: mode_select option: "MANUAL" - script.execute: step_up_once else: - logger.log: "BYPASS (Remote Only): ignoring Flame Up (step)." - platform: template id: btn_flame_down name: "${friendly_name} Flame Down (step)" on_press: - if: condition: lambda: 'return id(mode_select).state != "BYPASS (Remote Only)";' then: - select.set: id: mode_select option: "MANUAL" - script.execute: step_down_once else: - logger.log: "BYPASS (Remote Only): ignoring Flame Down (step)." - platform: template id: btn_calibrate_min name: "${friendly_name} Calibrate: Force Level 1 (6x down)" entity_category: config on_press: - script.execute: calibrate_to_min - platform: template id: btn_setpoint_up name: "${friendly_name} Setpoint Up" icon: mdi:plus on_press: - lambda: |- const float step = ${setpoint_step_c}; float v = id(setpoint_c) + step; const float vmax = ${setpoint_max_c}; if (v > vmax) v = vmax; id(setpoint_c) = v; - number.set: id: setpoint_number value: !lambda 'return id(setpoint_c);' - platform: template id: btn_setpoint_down name: "${friendly_name} Setpoint Down" icon: mdi:minus on_press: - lambda: |- const float step = ${setpoint_step_c}; float v = id(setpoint_c) - step; const float vmin = ${setpoint_min_c}; if (v < vmin) v = vmin; id(setpoint_c) = v; - number.set: id: setpoint_number value: !lambda 'return id(setpoint_c);' ########################################################################################## # NUMBER COMPONENT ########################################################################################## number: - platform: template id: setpoint_number name: "${friendly_name} Setpoint" icon: mdi:thermometer unit_of_measurement: "°C" min_value: ${setpoint_min_c} max_value: ${setpoint_max_c} step: ${setpoint_step_c} mode: slider lambda: |- return id(setpoint_c); set_action: - lambda: |- id(setpoint_c) = x; - if: condition: lambda: |- return !id(sp_suppress_mqtt); then: - mqtt.publish: topic: "viewroad-status/lounge-gasheater/setpoint" retain: true payload: !lambda |- char buf[16]; snprintf(buf, sizeof(buf), "%.1f", x); return std::string(buf); # Bench-test room temperature slider used by AUTO whenever Test Mode is ON. - platform: template id: test_temperature_value name: "${friendly_name} Test Temperature Value" icon: mdi:thermometer-chevron-up unit_of_measurement: "°C" min_value: -10.0 max_value: 40.0 step: 0.1 mode: slider lambda: |- return id(test_temp_c); set_action: - lambda: |- id(test_temp_c) = x; ########################################################################################## # INTERVAL COMPONENT ########################################################################################## interval: - interval: ${control_interval_s}s then: - if: condition: lambda: 'return id(mode_select).state == "AUTO";' then: - script.execute: auto_control_tick ########################################################################################## # SCRIPT COMPONENT ########################################################################################## script: # Mirror the already-computed Status Text entity out to MQTT. # This keeps the HA text entity and MQTT status using the same source of truth. - id: publish_status mode: restart then: - mqtt.publish: topic: "viewroad-status/lounge-gasheater" payload: !lambda |- if (id(status_text).state.size() == 0) return std::string("Unknown"); return id(status_text).state; - id: recompute_powered_state then: - lambda: |- const bool led_powered = id(led_operating_blue).state || id(led_standby_red).state; const bool new_state = id(test_mode_enabled).state ? id(test_powered) : led_powered; id(powered_state).publish_state(new_state); - script.execute: publish_status # Low-level relay click helper. # Handles the button press pulse and enforces a minimum gap between clicks. - 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 != "BYPASS (Remote Only)";' then: - script.execute: id: press_with_gap which: "up" else: - logger.log: "BYPASS (Remote Only): ignoring UP press." - id: press_down then: - if: condition: lambda: 'return id(mode_select).state != "BYPASS (Remote Only)";' then: - script.execute: id: press_with_gap which: "down" else: - logger.log: "BYPASS (Remote Only): ignoring DOWN press." # Step one flame level UP and only update the visible level after the relay click finishes. - id: step_up_once then: - if: condition: lambda: 'return id(powered_state).state;' then: - script.execute: press_up - script.wait: press_with_gap - lambda: |- if (id(current_level) < 7) id(current_level)++; - 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." # Step one flame level DOWN and only update the visible level after the relay click finishes. - id: step_down_once then: - if: condition: lambda: 'return id(powered_state).state;' then: - script.execute: press_down - script.wait: press_with_gap - lambda: |- if (id(current_level) > 1) id(current_level)--; - 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 a visible re-baseline to flame level 1. # Six DOWN clicks guarantee that even from level 7 we end at level 1. - id: calibrate_to_min then: - if: condition: lambda: 'return id(mode_select).state != "BYPASS (Remote Only)" && id(powered_state).state;' then: - lambda: |- id(calibrating) = true; - script.execute: publish_status - repeat: count: 6 then: - script.execute: step_down_once - script.wait: step_down_once - lambda: |- id(last_recal_ms) = millis(); id(calibrating) = false; - select.set: id: flame_level_select option: "1" - script.execute: publish_status else: - logger.log: "Skipping calibration (BYPASS or heater OFF)." # Moves one step at a time toward desired_level. # In Test Mode, target confirmation is bypassed so bench testing feels responsive. - id: adjust_to_desired mode: queued then: - if: condition: lambda: 'return millis() < id(warmup_until_ms) && !id(test_mode_enabled).state;' then: - logger.log: format: "Warmup: suppressing level change." level: DEBUG else: - if: condition: lambda: |- 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: - if: condition: lambda: 'return id(test_mode_enabled).state || 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 # Called after a successful power ON request. # Handles warmup, optional calibration, and initial AUTO control startup. - id: after_power_on_reset_to_auto then: - if: condition: lambda: 'return id(mode_select).state != "BYPASS (Remote Only)";' then: - if: condition: lambda: 'return id(test_mode_enabled).state;' then: - if: condition: lambda: 'return id(mode_select).state == "AUTO";' then: - lambda: |- id(warmup_until_ms) = millis() + (${warmup_seconds} * 1000UL); 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 - lambda: |- id(self_power_on) = false; else: - lambda: |- id(self_power_on) = false; - logger.log: "Test Mode active: staying in MANUAL; no AUTO startup actions." else: - lambda: |- id(warmup_until_ms) = millis() + (${warmup_seconds} * 1000UL); 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 - script.execute: auto_control_tick - script.execute: adjust_to_desired - lambda: |- id(self_power_on) = false; else: - lambda: |- id(self_power_on) = false; - logger.log: "BYPASS (Remote Only): keeping mode; no auto-calibrate." # Main thermostat logic. # Uses test_temp_c whenever Test Mode is enabled, otherwise uses MQTT room_temp. - id: auto_control_tick mode: queued then: - lambda: |- const bool use_test_temp = id(test_mode_enabled).state; const float t = use_test_temp ? id(test_temp_c) : id(room_temp).state; const float sp = id(setpoint_c); const uint32_t now_ms = millis(); if (!use_test_temp) { const bool temp_lost = (id(last_temp_ms) > 0) && ((now_ms - id(last_temp_ms)) >= (${temp_stale_timeout_min} * 60000UL)); id(room_temp_lost_fault) = temp_lost; if (temp_lost) { ESP_LOGW("gas", "AUTO: room temperature lost; skipping control tick"); return; } } else { id(room_temp_lost_fault) = false; } if (isnan(t)) { 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}; 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))); if (t <= low && can_turn_on) { ESP_LOGI("gas", "AUTO: below setpoint -> ON"); id(press_power).execute(); if (id(test_mode_enabled).state) { id(test_powered) = true; id(powered_state).publish_state(true); id(publish_status).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(); if (id(test_mode_enabled).state) { id(test_powered) = false; id(powered_state).publish_state(false); id(publish_status).execute(); } return; } if (powered) { float diff = sp - t; int target = 1; if (diff > 3.0f) target = 7; else if (diff > 2.5f) target = 6; else if (diff > 2.0f) target = 5; else if (diff > 1.5f) target = 4; else if (diff > 1.0f) target = 3; else if (diff > 0.5f) 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 (6x DOWN)." - script.execute: calibrate_to_min - script.execute: adjust_to_desired