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