From a839326029a283346d3fe1599765f235499a2a65 Mon Sep 17 00:00:00 2001 From: ESPHome Device Builder Date: Fri, 12 Jun 2026 17:32:53 +1200 Subject: [PATCH] Delete esp-gasheater copy.yaml --- esphome/esp-gasheater copy.yaml | 1342 ------------------------------- 1 file changed, 1342 deletions(-) delete mode 100644 esphome/esp-gasheater copy.yaml diff --git a/esphome/esp-gasheater copy.yaml b/esphome/esp-gasheater copy.yaml deleted file mode 100644 index 2d045e7..0000000 --- a/esphome/esp-gasheater copy.yaml +++ /dev/null @@ -1,1342 +0,0 @@ -#:########################################################################################:# -# 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