From a9ce83fcc43e8b57916476e4b6cb849054e43419 Mon Sep 17 00:00:00 2001 From: ESPHome Device Builder Date: Fri, 12 Jun 2026 17:32:06 +1200 Subject: [PATCH] Add esp-gasheater copy.yaml --- esphome/esp-gasheater copy.yaml | 1342 +++++++++++++++++++++++++++++++ 1 file changed, 1342 insertions(+) create mode 100644 esphome/esp-gasheater copy.yaml diff --git a/esphome/esp-gasheater copy.yaml b/esphome/esp-gasheater copy.yaml new file mode 100644 index 0000000..2d045e7 --- /dev/null +++ b/esphome/esp-gasheater copy.yaml @@ -0,0 +1,1342 @@ +#:########################################################################################:# +# 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.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 BYPASS (Remote Only) +# - In any mode except BYPASS (Remote Only), 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 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 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.11" + + # 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); + } + - if: + condition: + lambda: 'return !id(test_mode_enabled).state && id(mode_select).state != "BYPASS (Remote Only)" && !id(power_intended_on) && !id(self_power_on);' + then: + - script.execute: enforce_heater_off + - if: + condition: + 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 = 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: + - 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; + - switch.turn_off: power_switch + - select.set: + id: mode_select + option: "BYPASS (Remote Only)" + + - 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 == "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("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: ["AUTO","MANUAL","BYPASS (Remote Only)"] + optimistic: true + initial_option: "BYPASS (Remote Only)" + restore_value: false + set_action: + - lambda: |- + if (x == "BYPASS (Remote Only)") { + id(power_intended_on) = false; + } + if (x != "AUTO") { + id(room_temp_lost_fault) = 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: |- + 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 BYPASS, if the controller intends power OFF but LEDs still show ON, + # keep checking and force the heater 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(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); + + # Force heater OFF after an ESPHome reboot before returning to BYPASS. + - 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: "BYPASS (Remote Only)" + - lambda: |- + id(power_intended_on) = false; + + # In non-BYPASS modes, if the controller intends power OFF but LEDs still show ON, + # pulse the heater ON/OFF button to force the heater fully OFF. + - 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(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(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: |- + 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 \ No newline at end of file