#:########################################################################################:# # 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.12 2026-06-12 Added managed OFF mode separate from BYPASS (Remote Only). # OFF is now the normal startup/thermostat-off mode and will # take over if the heater is locally started via the physical button. # BYPASS remains a true hands-off/remote-only service mode. # V1.11 2026-04-26 Restored Heater Operation after regression; restored fast optimistic # master setpoint number with debounced MQTT publish; restored proportional # flame tuning; kept current GPIO12/13/14 relay mapping # V1.10 2026-04-25 Reworked OFF-state enforcement so non-BYPASS modes actively force the # heater fully OFF when power is intended OFF; reboot now forces heater # OFF before returning to BYPASS (Remote Only) # V1.8 2026-04-21 Physical testing, swapped GPIO12 and 14 (had the buttons/relays inverted) # 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: # - GPIO14 = ON/OFF button press # - GPIO13 = Flame UP button press # - GPIO12 = 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 (+${setpoint_step_c} °C) # - Setpoint Down (-${setpoint_step_c} °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 = force heater Power OFF and set Mode to OFF # - OFF mode is the normal thermostat-off / managed-standby mode: # - The controller will not call for heat by itself. # - If the heater is locally started using the physical heater button, the LEDs will # show that the heater is ON and ESPHome will switch to AUTO and take over. # - BYPASS (Remote Only) is a true hands-off / service mode: # - ESPHome may still observe the LEDs/status. # - ESPHome will not enforce OFF and will not take over after a local physical start. # - In AUTO or MANUAL, if the controller intends heater power OFF but either heater LED # still shows the heater ON, ESPHome will pulse the heater ON/OFF button to force it # fully OFF. # - On every ESPHome restart (including yaml upload or power loss), the controller first # forces the heater OFF, then returns to OFF mode. # - 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 and the controller intended it # to be ON, 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.12" # Passwords & Secrets api_key: !secret esp-api_key ota_pass: !secret esp-ota_pass static_ip_address: !secret esp-gasheater_ip #mqtt_local_command_topic: !secret mqtt_command_main_topic #mqtt_local_status_topic: !secret mqtt_status_main_topic # If we are changing IP addresses, you must update the current IP address here, otherwise it remains # Don't forget to switch it back when changed. current_ip_address: ${static_ip_address} # 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" setpoint_mqtt_debounce_ms: "500" power_off_verify_delay_s: "2" power_off_check_interval_s: "30" boot_force_off_attempts: "3" # 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.2" ######################################################################################## # PROPORTIONAL FLAME CONTROL # - proportional_aggression = 1.0 keeps the base thresholds unchanged # - >1.0 makes AUTO more aggressive (higher flame for the same temperature error) # - <1.0 makes AUTO softer # - individual flame_level_X_diff_c values can still be fine tuned ######################################################################################## proportional_aggression: "3.0" flame_level_7_diff_c: "3.0" flame_level_6_diff_c: "2.5" flame_level_5_diff_c: "2.0" flame_level_4_diff_c: "1.5" flame_level_3_diff_c: "1.0" flame_level_2_diff_c: "0.5" ########################################################################################## # 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}" #### WEB PORTAL #### #common_webportal: !include common/webportal_common.yaml 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: - lambda: |- // After any ESPHome restart, the controller must return to a safe OFF state. id(power_intended_on) = false; // Keep the internal float mirror aligned with the user-facing number entity. id(setpoint_c) = id(setpoint_number).state; id(last_temp_ms) = millis(); id(warmup_until_ms) = millis() + (${warmup_seconds} * 1000UL); - script.execute: recompute_powered_state - delay: 1s - script.execute: boot_force_heater_off ########################################################################################## # ESP PLATFORM ########################################################################################## esp8266: board: esp01_1m restore_from_flash: true preferences: flash_write_interval: 5min mdns: disabled: true ########################################################################################## # 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" # Controller intent for heater power in non-BYPASS modes. # false = heater should be OFF and ESPHome will enforce OFF if LEDs still show ON. - id: power_intended_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); } # ESPHome does not support Home Assistant's `choose:` action here, # so this is written as nested if/else logic. - if: condition: # OFF mode watches for local physical heater starts. # If someone presses the real heater ON button, the LEDs come on, this # powered_state turns ON, and ESPHome changes to AUTO and takes over. lambda: 'return !id(test_mode_enabled).state && id(mode_select).state == "OFF" && !id(self_power_on);' then: - script.execute: local_on_takeover else: - if: condition: # In AUTO/MANUAL, if the controller intended the heater OFF but LEDs say ON, # force the heater OFF. OFF is excluded because OFF performs local-start takeover. lambda: 'return !id(test_mode_enabled).state && id(mode_select).state != "BYPASS (Remote Only)" && id(mode_select).state != "OFF" && !id(power_intended_on) && !id(self_power_on);' then: - script.execute: enforce_heater_off else: - if: condition: # If the heater turns on externally while AUTO already intended it ON, # re-baseline after warmup and continue normal automatic control. lambda: 'return !id(test_mode_enabled).state && id(mode_select).state == "AUTO" && id(power_intended_on) && !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: GPIO14 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: GPIO12 inverted: ${relay_inverted} - platform: template id: test_mode_enabled name: "${friendly_name} Test Mode" icon: mdi:test-tube optimistic: true restore_mode: ALWAYS_OFF internal: true 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 = managed OFF mode. Heater is forced OFF, but local physical starts are monitored # and will cause ESPHome to switch back to AUTO and take over. - platform: template id: heater_operation_switch name: "Heater Operation" icon: mdi:radiator lambda: |- return id(mode_select).state == "AUTO"; turn_on_action: - if: condition: switch.is_on: test_mode_enabled then: - switch.turn_off: test_mode_enabled - select.set: id: mode_select option: "AUTO" turn_off_action: - lambda: |- id(power_intended_on) = false; - select.set: id: mode_select option: "OFF" - platform: template id: power_switch name: "${friendly_name} Activate Flame" icon: mdi:power lambda: |- return id(powered_state).state; turn_on_action: - lambda: |- if (id(mode_select).state != "BYPASS (Remote Only)" || id(test_mode_enabled).state) { id(power_intended_on) = true; } - 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: - lambda: |- if (id(mode_select).state != "BYPASS (Remote Only)" || id(test_mode_enabled).state) { id(power_intended_on) = false; } - 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}; const float step = ${setpoint_step_c}; if (v < vmin) v = vmin; if (v > vmax) v = vmax; v = roundf(v / step) * step; id(sp_suppress_mqtt) = true; - number.set: id: setpoint_number value: !lambda |- float v = x; const float vmin = ${setpoint_min_c}; const float vmax = ${setpoint_max_c}; const float step = ${setpoint_step_c}; if (v < vmin) v = vmin; if (v > vmax) v = vmax; v = roundf(v / step) * step; return v; - 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 == "OFF") { return std::string("Off (Test)"); } if (mode == "BYPASS (Remote Only)") { return std::string("Bypass (Remote Only) (Test)"); } 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); } return std::string("Manual (Off) (Test)"); } if (id(powered_state).state) { char buf[40]; snprintf(buf, sizeof(buf), "Auto (Level %d) (Test)", id(current_level)); return std::string(buf); } if (mode == "AUTO" && !isnan(effective_t) && effective_t >= at_temp_threshold) { return std::string("Auto (At Temperature) (Test)"); } return std::string("Auto (Standby) (Test)"); } if (mode == "OFF") { return std::string("Off"); } if (mode == "BYPASS (Remote Only)") { if (id(led_operating_blue).state) return std::string("Bypass (Operating)"); if (id(led_standby_red).state) return std::string("Bypass (Standby)"); return std::string("Bypass (Remote Only)"); } 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), "Auto (Level %d)", id(current_level)); return std::string(buf); } if (!isnan(effective_t) && effective_t >= at_temp_threshold) { return std::string("Auto (At Temperature)"); } if (id(led_standby_red).state) return std::string("Auto (Standby)"); return std::string("Auto (Standby)"); on_value: - script.execute: publish_status - platform: template id: setpoint_text name: "Setpoint Text" icon: mdi:thermometer update_interval: 500ms lambda: |- char buf[16]; snprintf(buf, sizeof(buf), "%.1f °C", id(setpoint_number).state); return std::string(buf); - platform: template id: test_temperature_text name: "${friendly_name} Test Mode Temp" icon: mdi:thermometer-chevron-up update_interval: 1s internal: true 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: ["OFF","AUTO","MANUAL","BYPASS (Remote Only)"] optimistic: true initial_option: "OFF" restore_value: false set_action: - lambda: |- if (x == "OFF" || x == "BYPASS (Remote Only)") { id(power_intended_on) = false; } if (x != "AUTO") { id(room_temp_lost_fault) = false; } - if: condition: lambda: 'return x == "OFF" && !id(test_mode_enabled).state && id(powered_state).state;' then: - logger.log: "Mode OFF selected: heater appears ON, pulsing power to force OFF." - script.execute: press_power - 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: |- id(power_intended_on) = true; 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: - lambda: |- id(power_intended_on) = true; - 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: - lambda: |- id(power_intended_on) = true; - 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: - number.set: id: setpoint_number value: !lambda |- const float step = ${setpoint_step_c}; float v = id(setpoint_number).state + step; const float vmax = ${setpoint_max_c}; if (v > vmax) v = vmax; v = roundf(v / step) * step; return v; - platform: template id: btn_setpoint_down name: "${friendly_name} Setpoint Down" icon: mdi:minus on_press: - number.set: id: setpoint_number value: !lambda |- const float step = ${setpoint_step_c}; float v = id(setpoint_number).state - step; const float vmin = ${setpoint_min_c}; if (v < vmin) v = vmin; v = roundf(v / step) * step; return v; ########################################################################################## # NUMBER COMPONENT ########################################################################################## number: # User-facing setpoint control. # This number is the master source of truth for the setpoint so HA/OpenHASP reacts immediately. - 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 optimistic: true restore_value: true initial_value: 21.0 set_action: - lambda: |- const float step = ${setpoint_step_c}; id(setpoint_c) = roundf(x / step) * step; - if: condition: lambda: |- return !id(sp_suppress_mqtt); then: - script.execute: publish_setpoint_mqtt # 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 internal: true 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 # Outside OFF/BYPASS, if the controller intends power OFF but LEDs still show ON, # keep checking and force the heater OFF. # OFF is excluded because it deliberately watches for local physical starts and # switches to AUTO instead of forcing the heater back off. - interval: ${power_off_check_interval_s}s then: - if: condition: lambda: 'return !id(test_mode_enabled).state && id(mode_select).state != "BYPASS (Remote Only)" && id(mode_select).state != "OFF" && !id(power_intended_on) && id(powered_state).state && !id(self_power_on);' then: - script.execute: enforce_heater_off ########################################################################################## # SCRIPT COMPONENT ########################################################################################## script: # Debounced setpoint MQTT mirror. # Keeps HA/OpenHASP setpoint changes responsive while avoiding retained-MQTT bursts. - id: publish_setpoint_mqtt mode: restart then: - delay: ${setpoint_mqtt_debounce_ms}ms - mqtt.publish: topic: "viewroad-status/lounge-gasheater/setpoint" retain: true payload: !lambda |- char buf[16]; snprintf(buf, sizeof(buf), "%.1f", id(setpoint_c)); return std::string(buf); # 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; # Recompute the helper powered state from LEDs or the Test Mode override. # Status MQTT publishing is handled by status_text on_value so we avoid duplicate publishes here. - 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); # OFF-mode local physical-start takeover. # When Mode is OFF and the heater is started from its own front panel, the LED # inputs make powered_state turn ON. This script changes to AUTO, warms up, # re-baselines the flame level, and resumes thermostat control. - id: local_on_takeover mode: restart then: - logger.log: "OFF mode: local physical heater start detected. Switching to AUTO and taking over." - lambda: |- id(power_intended_on) = true; 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; # Force heater OFF after an ESPHome reboot before returning to OFF. - id: boot_force_heater_off mode: restart then: - repeat: count: ${boot_force_off_attempts} then: - script.execute: recompute_powered_state - if: condition: lambda: 'return !id(test_mode_enabled).state && id(powered_state).state;' then: - logger.log: "Boot: heater appears ON, pulsing power to force OFF." - script.execute: press_power - script.wait: press_with_gap - delay: ${power_off_verify_delay_s}s - script.execute: recompute_powered_state - select.set: id: mode_select option: "OFF" - lambda: |- id(power_intended_on) = false; # In AUTO/MANUAL, if the controller intends power OFF but LEDs still show ON, # pulse the heater ON/OFF button to force the heater fully OFF. # OFF mode is excluded because a physical start in OFF should trigger AUTO takeover. - id: enforce_heater_off mode: restart then: - delay: ${power_off_verify_delay_s}s - script.execute: recompute_powered_state - if: condition: lambda: 'return !id(test_mode_enabled).state && id(mode_select).state != "BYPASS (Remote Only)" && id(mode_select).state != "OFF" && !id(power_intended_on) && id(powered_state).state && !id(self_power_on);' then: - logger.log: "Heater should be OFF but LEDs still show ON. Pulsing power to force OFF." - script.execute: press_power # 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(mode_select).state != "OFF" && 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: - lambda: |- id(power_intended_on) = true; - 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: |- if (id(mode_select).state != "AUTO") { ESP_LOGD("gas", "AUTO: skipping control tick because Mode is not AUTO"); return; } 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(power_intended_on) = true; 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(power_intended_on) = false; 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; // Proportional flame mapping: // - A higher proportional_aggression lowers the effective thresholds and makes // the controller choose higher flame levels sooner. // - The individual flame_level_X_diff_c substitutions still allow fine tuning. const float k = ${proportional_aggression}; if (diff >= (${flame_level_7_diff_c} / k)) target = 7; else if (diff >= (${flame_level_6_diff_c} / k)) target = 6; else if (diff >= (${flame_level_5_diff_c} / k)) target = 5; else if (diff >= (${flame_level_4_diff_c} / k)) target = 4; else if (diff >= (${flame_level_3_diff_c} / k)) target = 3; else if (diff >= (${flame_level_2_diff_c} / k)) 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