#:########################################################################################:#
# TITLE:
# RINNAI NEO GAS HEATER CONTROLLER
#:########################################################################################:#
# REPO:
# https://home.fox.co.nz/gitea/zorruno/zorruno-homeassistant/src/branch/master/esphome/esp-gasheater.yaml
#:########################################################################################:#
# VERSIONS:
# V1.12 2026-06-12 Added managed OFF mode separate from BYPASS (Remote Only).
#                  OFF is now the normal startup/thermostat-off mode and will
#                  take over if the heater is locally started via the physical button.
#                  BYPASS remains a true hands-off/remote-only service mode.
# V1.11 2026-04-26 Restored Heater Operation after regression; restored fast optimistic
#                  master setpoint number with debounced MQTT publish; restored proportional
#                  flame tuning; kept current GPIO12/13/14 relay mapping
# V1.10 2026-04-25 Reworked OFF-state enforcement so non-BYPASS modes actively force the
#                  heater fully OFF when power is intended OFF; reboot now forces heater
#                  OFF before returning to BYPASS (Remote Only)
# V1.8 2026-04-21 Physical testing, swapped GPIO12 and 14 (had the buttons/relays inverted)
# V1.7 2026-03-26 Added Heater Operation switch; MQTT status now mirrors the Status Text
#                 entity; preserved Test Mode AUTO test temperature and visible 6-step calibration
# V1.6 2026-03-22 Added richer status text: At Temperature, Operating (Level X),
#                 Calibrating, and Fault (Room Temperature Lost)
# V1.5 2026-03-22 Added Test Temperature Value slider for Test Mode AUTO; status text
#                 shows Test Mode state; temperature units set to °C
# V1.4 2026-03-22 Updated yaml to zorruno layout V1.1; boot into BYPASS (Remote Only);
#                 renamed remote-only mode; refined Test Mode to click real power relay;
#                 added setpoint text and setpoint up/down buttons
# V1.3 2026-03-22 Refined Test Mode so power relay still clicks; added setpoint text plus
#                 setpoint up/down buttons; Test Mode can now be used for MANUAL or AUTO testing
# V1.2 2026-03-22 Added Test Mode bench testing override
# V1.1 2026-03-22 Updated yaml to zorruno layout V1.1; forced AUTO on reboot; initialized
#                 stale-temp timer at boot; cleared Remote Control Only self-power flag
# V1.0 2025-07-21 First setup (replacement of Tasmota)
#:########################################################################################:#
# HARDWARE:
# - Sonoff DEV (ESP8266)
# - 3-relay daughterboard
# - Rinnai Neo gas heater
# - Relay outputs:
#   - GPIO14 = ON/OFF button press
#   - GPIO13 = Flame UP button press
#   - GPIO12 = Flame DOWN button press
# - LED inputs:
#   - GPIO4 = Red standby LED
#   - GPIO5 = Blue operating LED
#:########################################################################################:#
# OPERATION NOTES:
# - Replaces the RF remote by pressing the heater panel buttons via relays.
# - Heater manages its own ignition, flame safety, and fan control.
# - AUTO mode uses:
#   - Local HA setpoint number entity
#   - External room temperature via MQTT
# - In Test Mode, AUTO uses the local Test Temperature Value slider instead of MQTT room
#   temperature.
# - Setpoint is locally adjustable and also supports two-way MQTT sync.
# - Manual flame control is available via:
#   - Flame Level select (1..7)
#   - Flame Up step button
#   - Flame Down step button
# - Setpoint helper entities are available via:
#   - Setpoint Text
#   - Setpoint Up (+${setpoint_step_c} °C)
#   - Setpoint Down (-${setpoint_step_c} °C)
# - Test Mode is for bench testing before connection to the heater:
#   - Forces Mode to MANUAL when enabled
#   - Uses an internal powered flag instead of LED monitoring
#   - Still clicks the real power relay when Power is turned ON or OFF
#   - AUTO may still be selected manually later for testing
# - Heater Operation is a high-level thermostat enable helper:
#   - ON  = turn Test Mode OFF (if needed) and set Mode to AUTO
#   - OFF = force heater Power OFF and set Mode to OFF
# - OFF mode is the normal thermostat-off / managed-standby mode:
#   - The controller will not call for heat by itself.
#   - If the heater is locally started using the physical heater button, the LEDs will
#     show that the heater is ON and ESPHome will switch to AUTO and take over.
# - BYPASS (Remote Only) is a true hands-off / service mode:
#   - ESPHome may still observe the LEDs/status.
#   - ESPHome will not enforce OFF and will not take over after a local physical start.
# - In AUTO or MANUAL, if the controller intends heater power OFF but either heater LED
#   still shows the heater ON, ESPHome will pulse the heater ON/OFF button to force it
#   fully OFF.
# - On every ESPHome restart (including yaml upload or power loss), the controller first
#   forces the heater OFF, then returns to OFF mode.
# - On power ON in AUTO, the controller:
#   - Applies a warmup lockout
#   - Calibrates to level 1 using 6x DOWN
#   - Begins auto level adjustment
# - If the heater is turned on externally while in AUTO and the controller intended it
#   to be ON, the controller will re-baseline after warmup and resume automatic control.
# - Fast-ramp mode allows faster flame stepping for a short period after turn-on.
#:########################################################################################:#
# MQTT COMMANDS:
# - Room temperature input:
#   viewroad-status/lounge-environment/temperature
# - Setpoint input/output:
#   viewroad-status/lounge-environment/temp-setpoint
# - Heater operating status output:
#   viewroad-status/lounge-gasheater
#:########################################################################################:#
# OFFLINE NOTES:
# a) HA offline, WiFi/MQTT online:
#    - Device continues to operate normally.
#    - AUTO control still works if room temperature MQTT is still being published.
#    - Setpoint can still be changed via MQTT.
#
# b) MQTT offline (or HA and MQTT offline):
#    - No fresh room temperature or setpoint updates are received.
#    - Device retains the last local setpoint.
#    - If AUTO is active and temperature data becomes stale, the heater will turn OFF after
#      temp_stale_timeout_min as a fail-safe.
#    - Manual mode and flame tracking still work locally inside the ESP device logic.
#
# c) Entire WiFi/Network offline:
#    - No MQTT temperature or setpoint updates are available.
#    - Accurate time is not needed. SNTP is not required for this device.
#    - If AUTO is active and the heater is ON, stale temperature fail-safe will turn it OFF
#      after the configured timeout.
#    - Existing heater safety systems still operate normally.
#:########################################################################################:#

##########################################################################################
# SPECIFIC DEVICE VARIABLE SUBSTITUTIONS
##########################################################################################
substitutions:
  # Device Naming
  device_name: "esp-gasheater"
  friendly_name: "Rinnai Neo Gas Heater"
  description_comment: "7 Setting controller for Rinnai Neo Gas heater :: Sonoff DEV + Relay Board"
  device_area: "Lounge"

  # Project Naming
  project_name: "Sonoff Technologies.Sonoff DEV"
  project_version: "v1.12"

  # Passwords & Secrets
  api_key: !secret esp-api_key
  ota_pass: !secret esp-ota_pass
  static_ip_address: !secret esp-gasheater_ip
  #mqtt_local_command_topic: !secret mqtt_command_main_topic
  #mqtt_local_status_topic: !secret mqtt_status_main_topic

  # If we are changing IP addresses, you must update the current IP address here, otherwise it remains
  # Don't forget to switch it back when changed.
  current_ip_address: ${static_ip_address}

  # Device Settings
  log_level: "INFO"
  update_interval: "60s"

  ########################################################################################
  # TIMING TUNABLES
  ########################################################################################
  relay_pulse_ms:           "250"
  min_click_gap_ms:         "1200"
  warmup_seconds:           "30"
  control_interval_s:       "10"
  adjust_step_interval_s:   "10"
  recalibrate_interval_min: "180"
  temp_stale_timeout_min:   "20"
  fast_ramp_seconds:        "240"
  fast_ramp_step_s:         "4"
  setpoint_mqtt_debounce_ms: "500"
  power_off_verify_delay_s: "2"
  power_off_check_interval_s: "30"
  boot_force_off_attempts:  "3"

  # Optional anti short-cycle (set to "0" to disable either one)
  min_on_minutes:  "1"
  min_off_minutes: "0.5"

  ########################################################################################
  # ELECTRICAL POLARITY
  ########################################################################################
  relay_inverted:    "true"
  red_led_inverted:  "true"
  blue_led_inverted: "true"

  ########################################################################################
  # THERMOSTAT HYSTERESIS (°C)
  ########################################################################################
  on_below_c:  "0.2"
  off_above_c: "0.3"

  ########################################################################################
  # SETPOINT LIMITS FOR HA NUMBER (°C)
  ########################################################################################
  setpoint_min_c:  "6.0"
  setpoint_max_c:  "32.0"
  setpoint_step_c: "0.2"


  ########################################################################################
  # PROPORTIONAL FLAME CONTROL
  # - proportional_aggression = 1.0 keeps the base thresholds unchanged
  # - >1.0 makes AUTO more aggressive (higher flame for the same temperature error)
  # - <1.0 makes AUTO softer
  # - individual flame_level_X_diff_c values can still be fine tuned
  ########################################################################################
  proportional_aggression: "3.0"
  flame_level_7_diff_c: "3.0"
  flame_level_6_diff_c: "2.5"
  flame_level_5_diff_c: "2.0"
  flame_level_4_diff_c: "1.5"
  flame_level_3_diff_c: "1.0"
  flame_level_2_diff_c: "0.5"

##########################################################################################
# PACKAGES: Included Common Packages
##########################################################################################
packages:
  common_wifi: !include
    file: common/network_common.yaml
    vars:
      local_device_name: "${device_name}"
      local_static_ip_address: "${static_ip_address}"
      local_current_ip_address: "${current_ip_address}"
      local_ota_pass: "${ota_pass}"

  common_api: !include
    file: common/api_common.yaml
    vars:
      local_api_key: "${api_key}"

  common_mqtt: !include
    file: common/mqtt_common.yaml
    vars:
      local_device_name: "${device_name}"

  #### WEB PORTAL ####
  #common_webportal: !include common/webportal_common.yaml

  common_sntp: !include
    file: common/sntp_common.yaml

  common_general_sensors: !include
    file: common/sensors_common.yaml
    vars:
      local_friendly_name: "${friendly_name}"
      local_update_interval: "${update_interval}"

##########################################################################################
# ESPHOME
##########################################################################################
esphome:
  name: "${device_name}"
  friendly_name: "${friendly_name}"
  comment: "${description_comment}"
  area: "${device_area}"

  on_boot:
    priority: -100
    then:
      - lambda: |-
          // After any ESPHome restart, the controller must return to a safe OFF state.
          id(power_intended_on) = false;

          // Keep the internal float mirror aligned with the user-facing number entity.
          id(setpoint_c) = id(setpoint_number).state;

          id(last_temp_ms) = millis();
          id(warmup_until_ms) = millis() + (${warmup_seconds} * 1000UL);
      - script.execute: recompute_powered_state
      - delay: 1s
      - script.execute: boot_force_heater_off

##########################################################################################
# ESP PLATFORM
##########################################################################################
esp8266:
  board: esp01_1m
  restore_from_flash: true

preferences:
  flash_write_interval: 5min

mdns:
  disabled: true

##########################################################################################
# GLOBAL VARIABLES
##########################################################################################
globals:
  - id: current_level
    type: int
    restore_value: yes
    initial_value: "1"

  - id: desired_level
    type: int
    restore_value: no
    initial_value: "1"

  - id: last_adjust_ms
    type: uint32_t
    restore_value: no
    initial_value: "0"

  - id: last_recal_ms
    type: uint32_t
    restore_value: no
    initial_value: "0"

  - id: last_on_ms
    type: uint32_t
    restore_value: no
    initial_value: "0"

  - id: last_off_ms
    type: uint32_t
    restore_value: no
    initial_value: "0"

  - id: setpoint_c
    type: float
    restore_value: yes
    initial_value: "21.0"

  - id: sp_suppress_mqtt
    type: bool
    restore_value: no
    initial_value: "false"

  - id: last_temp_ms
    type: uint32_t
    restore_value: no
    initial_value: "0"

  - id: warmup_until_ms
    type: uint32_t
    restore_value: no
    initial_value: "0"

  - id: last_level_target
    type: int
    restore_value: no
    initial_value: "1"

  - id: target_confirmed
    type: bool
    restore_value: no
    initial_value: "true"

  - id: ramp_until_ms
    type: uint32_t
    restore_value: no
    initial_value: "0"

  - id: self_power_on
    type: bool
    restore_value: no
    initial_value: "false"

  # Controller intent for heater power in non-BYPASS modes.
  # false = heater should be OFF and ESPHome will enforce OFF if LEDs still show ON.
  - id: power_intended_on
    type: bool
    restore_value: no
    initial_value: "false"

  # Bench-test logical power state used only when Test Mode is enabled
  - id: test_powered
    type: bool
    restore_value: no
    initial_value: "false"

  # Bench-test room temperature used only when Test Mode is enabled
  - id: test_temp_c
    type: float
    restore_value: yes
    initial_value: "20.0"

  # True while a calibration sequence is actively stepping down to level 1
  - id: calibrating
    type: bool
    restore_value: no
    initial_value: "false"

  # True when remote MQTT room temperature has gone stale in normal (non-test) mode
  - id: room_temp_lost_fault
    type: bool
    restore_value: no
    initial_value: "false"

##########################################################################################
# ESPHOME LOGGING ENABLE
##########################################################################################
logger:
  level: "${log_level}"
  baud_rate: 0

##########################################################################################
# BINARY SENSORS
##########################################################################################
binary_sensor:
  - platform: gpio
    id: led_standby_red
    name: "${friendly_name} Standby LED"
    pin:
      number: GPIO4
      mode:
        input: true
        pullup: true
      inverted: ${red_led_inverted}
    filters:
      - delayed_on_off: 50ms
    #entity_category: diagnostic
    on_state:
      - script.execute: recompute_powered_state

  - platform: gpio
    id: led_operating_blue
    name: "${friendly_name} Operating LED"
    pin:
      number: GPIO5
      mode:
        input: true
        pullup: true
      inverted: ${blue_led_inverted}
    filters:
      - delayed_on_off: 50ms
    #entity_category: diagnostic
    on_state:
      - script.execute: recompute_powered_state

  - platform: template
    id: powered_state
    internal: true
    on_press:
      - lambda: |-
          id(last_on_ms) = millis();
          if (!id(test_mode_enabled).state && id(mode_select).state == "AUTO") {
            id(warmup_until_ms) = millis() + (${warmup_seconds} * 1000UL);
          }
      # ESPHome does not support Home Assistant's `choose:` action here,
      # so this is written as nested if/else logic.
      - if:
          condition:
            # OFF mode watches for local physical heater starts.
            # If someone presses the real heater ON button, the LEDs come on, this
            # powered_state turns ON, and ESPHome changes to AUTO and takes over.
            lambda: 'return !id(test_mode_enabled).state && id(mode_select).state == "OFF" && !id(self_power_on);'
          then:
            - script.execute: local_on_takeover
          else:
            - if:
                condition:
                  # In AUTO/MANUAL, if the controller intended the heater OFF but LEDs say ON,
                  # force the heater OFF. OFF is excluded because OFF performs local-start takeover.
                  lambda: 'return !id(test_mode_enabled).state && id(mode_select).state != "BYPASS (Remote Only)" && id(mode_select).state != "OFF" && !id(power_intended_on) && !id(self_power_on);'
                then:
                  - script.execute: enforce_heater_off
                else:
                  - if:
                      condition:
                        # If the heater turns on externally while AUTO already intended it ON,
                        # re-baseline after warmup and continue normal automatic control.
                        lambda: 'return !id(test_mode_enabled).state && id(mode_select).state == "AUTO" && id(power_intended_on) && !id(self_power_on);'
                      then:
                        - lambda: |-
                            id(ramp_until_ms) = millis() + (${fast_ramp_seconds} * 1000UL);
                        - delay: ${warmup_seconds}s
                        - script.execute: calibrate_to_min
                        - script.execute: auto_control_tick
                        - script.execute: adjust_to_desired

    on_release:
      - lambda: |-
          id(last_off_ms) = millis();
          id(self_power_on) = false;

##########################################################################################
# SWITCH COMPONENT
##########################################################################################
switch:
  - platform: gpio
    id: sw_power_raw
    internal: true
    restore_mode: ALWAYS_OFF
    pin:
      number: GPIO14
      inverted: ${relay_inverted}

  - platform: gpio
    id: sw_up_raw
    internal: true
    restore_mode: ALWAYS_OFF
    pin:
      number: GPIO13
      inverted: ${relay_inverted}

  - platform: gpio
    id: sw_down_raw
    internal: true
    restore_mode: ALWAYS_OFF
    pin:
      number: GPIO12
      inverted: ${relay_inverted}

  - platform: template
    id: test_mode_enabled
    name: "${friendly_name} Test Mode"
    icon: mdi:test-tube
    optimistic: true
    restore_mode: ALWAYS_OFF
    internal: true
    turn_on_action:
      - lambda: |-
          // Preserve current logical power state when entering Test Mode
          id(test_powered) = id(powered_state).state;
          id(room_temp_lost_fault) = false;
      - select.set:
          id: mode_select
          option: "MANUAL"
      - script.execute: recompute_powered_state
    turn_off_action:
      - lambda: |-
          id(test_powered) = false;
      - script.execute: recompute_powered_state

  # High-level thermostat enable helper.
  # ON  = thermostat logic active in AUTO
  # OFF = managed OFF mode. Heater is forced OFF, but local physical starts are monitored
  #       and will cause ESPHome to switch back to AUTO and take over.
  - platform: template
    id: heater_operation_switch
    name: "Heater Operation"
    icon: mdi:radiator
    lambda: |-
      return id(mode_select).state == "AUTO";

    turn_on_action:
      - if:
          condition:
            switch.is_on: test_mode_enabled
          then:
            - switch.turn_off: test_mode_enabled
      - select.set:
          id: mode_select
          option: "AUTO"

    turn_off_action:
      - lambda: |-
          id(power_intended_on) = false;
      - select.set:
          id: mode_select
          option: "OFF"

  - platform: template
    id: power_switch
    name: "${friendly_name} Activate Flame"
    icon: mdi:power
    lambda: |-
      return id(powered_state).state;
    turn_on_action:
      - lambda: |-
          if (id(mode_select).state != "BYPASS (Remote Only)" || id(test_mode_enabled).state) {
            id(power_intended_on) = true;
          }
      - if:
          condition:
            lambda: 'return id(mode_select).state == "BYPASS (Remote Only)" && !id(test_mode_enabled).state;'
          then:
            - logger.log: "BYPASS (Remote Only): ignoring Power ON."
          else:
            - if:
                condition:
                  lambda: |-
                    return !id(powered_state).state;
                then:
                  - script.execute: press_power
                  - if:
                      condition:
                        lambda: 'return id(test_mode_enabled).state;'
                      then:
                        - lambda: |-
                            id(test_powered) = true;
                        - script.execute: recompute_powered_state
                  - script.execute: after_power_on_reset_to_auto

    turn_off_action:
      - lambda: |-
          if (id(mode_select).state != "BYPASS (Remote Only)" || id(test_mode_enabled).state) {
            id(power_intended_on) = false;
          }
      - if:
          condition:
            lambda: 'return id(mode_select).state == "BYPASS (Remote Only)" && !id(test_mode_enabled).state;'
          then:
            - logger.log: "BYPASS (Remote Only): ignoring Power OFF."
          else:
            - if:
                condition:
                  lambda: |-
                    return id(powered_state).state;
                then:
                  - script.execute: press_power
                  - if:
                      condition:
                        lambda: 'return id(test_mode_enabled).state;'
                      then:
                        - lambda: |-
                            id(test_powered) = false;
                        - script.execute: recompute_powered_state

##########################################################################################
# SENSORS
##########################################################################################
sensor:
  - platform: mqtt_subscribe
    id: room_temp
    name: "${friendly_name} Room Temperature (MQTT)"
    topic: "viewroad-status/lounge-environment/temperature"
    unit_of_measurement: "°C"
    accuracy_decimals: 1
    on_value:
      - lambda: |-
          id(last_temp_ms) = millis();
          id(room_temp_lost_fault) = false;
          float v = x;
          if (v < -10.0f) v = -10.0f;
          if (v >  50.0f) v =  50.0f;
          if (v != id(room_temp).state) id(room_temp).publish_state(v);

  - platform: mqtt_subscribe
    id: setpoint_from_mqtt
    internal: true
    topic: "viewroad-command/lounge-gasheater/setpoint"
    unit_of_measurement: "°C"
    accuracy_decimals: 1
    on_value:
      - lambda: |-
          float v = x;
          const float vmin = ${setpoint_min_c};
          const float vmax = ${setpoint_max_c};
          const float step = ${setpoint_step_c};
          if (v < vmin) v = vmin;
          if (v > vmax) v = vmax;
          v = roundf(v / step) * step;

          id(sp_suppress_mqtt) = true;
      - number.set:
          id: setpoint_number
          value: !lambda |-
            float v = x;
            const float vmin = ${setpoint_min_c};
            const float vmax = ${setpoint_max_c};
            const float step = ${setpoint_step_c};
            if (v < vmin) v = vmin;
            if (v > vmax) v = vmax;
            v = roundf(v / step) * step;
            return v;
      - lambda: |-
          id(sp_suppress_mqtt) = false;

##########################################################################################
# TEXT SENSOR COMPONENT
##########################################################################################
text_sensor:
  - platform: template
    id: status_text
    name: "${friendly_name} Status Text"
    update_interval: 1s
    lambda: |-
      const bool use_test_temp = id(test_mode_enabled).state;
      const float effective_t = use_test_temp ? id(test_temp_c) : id(room_temp).state;
      const float at_temp_threshold = id(setpoint_c) + ${off_above_c};
      const std::string mode = id(mode_select).state;

      if (!use_test_temp && id(room_temp_lost_fault)) {
        return std::string("Fault (Room Temperature Lost)");
      }

      if (id(calibrating)) {
        if (use_test_temp) return std::string("Calibrating (Test)");
        return std::string("Calibrating");
      }

      if (use_test_temp) {
        if (mode == "OFF") {
          return std::string("Off (Test)");
        }

        if (mode == "BYPASS (Remote Only)") {
          return std::string("Bypass (Remote Only) (Test)");
        }

        if (mode == "MANUAL") {
          if (id(powered_state).state) {
            char buf[48];
            snprintf(buf, sizeof(buf), "Manual (Level %d) (Test)", id(current_level));
            return std::string(buf);
          }
          return std::string("Manual (Off) (Test)");
        }

        if (id(powered_state).state) {
          char buf[40];
          snprintf(buf, sizeof(buf), "Auto (Level %d) (Test)", id(current_level));
          return std::string(buf);
        }
        if (mode == "AUTO" && !isnan(effective_t) && effective_t >= at_temp_threshold) {
          return std::string("Auto (At Temperature) (Test)");
        }
        return std::string("Auto (Standby) (Test)");
      }

      if (mode == "OFF") {
        return std::string("Off");
      }

      if (mode == "BYPASS (Remote Only)") {
        if (id(led_operating_blue).state) return std::string("Bypass (Operating)");
        if (id(led_standby_red).state)   return std::string("Bypass (Standby)");
        return std::string("Bypass (Remote Only)");
      }

      if (mode == "MANUAL") {
        if (id(led_operating_blue).state) {
          char buf[32];
          snprintf(buf, sizeof(buf), "Manual (Level %d)", id(current_level));
          return std::string(buf);
        }
        if (id(led_standby_red).state) return std::string("Manual (Standby)");
        return std::string("Manual (Off)");
      }

      if (id(led_operating_blue).state) {
        char buf[32];
        snprintf(buf, sizeof(buf), "Auto (Level %d)", id(current_level));
        return std::string(buf);
      }
      if (!isnan(effective_t) && effective_t >= at_temp_threshold) {
        return std::string("Auto (At Temperature)");
      }
      if (id(led_standby_red).state) return std::string("Auto (Standby)");
      return std::string("Auto (Standby)");
    on_value:
      - script.execute: publish_status

  - platform: template
    id: setpoint_text
    name: "Setpoint Text"
    icon: mdi:thermometer
    update_interval: 500ms
    lambda: |-
      char buf[16];
      snprintf(buf, sizeof(buf), "%.1f °C", id(setpoint_number).state);
      return std::string(buf);

  - platform: template
    id: test_temperature_text
    name: "${friendly_name} Test Mode Temp"
    icon: mdi:thermometer-chevron-up
    update_interval: 1s
    internal: true
    lambda: |-
      char buf[20];
      snprintf(buf, sizeof(buf), "%.1f °C", id(test_temp_c));
      return std::string(buf);

##########################################################################################
# SELECT COMPONENT
##########################################################################################
select:
  - platform: template
    id: mode_select
    name: "Mode"
    options: ["OFF","AUTO","MANUAL","BYPASS (Remote Only)"]
    optimistic: true
    initial_option: "OFF"
    restore_value: false
    set_action:
      - lambda: |-
          if (x == "OFF" || x == "BYPASS (Remote Only)") {
            id(power_intended_on) = false;
          }
          if (x != "AUTO") {
            id(room_temp_lost_fault) = false;
          }
      - if:
          condition:
            lambda: 'return x == "OFF" && !id(test_mode_enabled).state && id(powered_state).state;'
          then:
            - logger.log: "Mode OFF selected: heater appears ON, pulsing power to force OFF."
            - script.execute: press_power

  - platform: template
    id: flame_level_select
    name: "${friendly_name} Flame Level"
    options: ["1","2","3","4","5","6","7"]
    optimistic: true
    restore_value: true
    set_action:
      - if:
          condition:
            lambda: |-
              int sel = atoi(x.c_str());
              return sel == id(current_level);
          then:
            - logger.log: "Flame Level: no change; ignoring selection."
          else:
            - if:
                condition:
                  lambda: 'return id(mode_select).state != "BYPASS (Remote Only)";'
                then:
                  - select.set:
                      id: mode_select
                      option: "MANUAL"
                  - lambda: |-
                      id(power_intended_on) = true;
                      int target = atoi(x.c_str());
                      if (target < 1) target = 1;
                      if (target > 7) target = 7;
                      id(desired_level) = target;
                  - if:
                      condition:
                        lambda: 'return !id(powered_state).state;'
                      then:
                        - script.execute: press_power
                        - if:
                            condition:
                              lambda: 'return id(test_mode_enabled).state;'
                            then:
                              - lambda: |-
                                  id(test_powered) = true;
                              - script.execute: recompute_powered_state
                        - if:
                            condition:
                              lambda: 'return !id(test_mode_enabled).state;'
                            then:
                              - lambda: |-
                                  id(warmup_until_ms) = millis() + (${warmup_seconds} * 1000UL);
                                  id(ramp_until_ms)   = millis() + (${fast_ramp_seconds} * 1000UL);
                              - delay: ${warmup_seconds}s
                              - script.execute: calibrate_to_min
                              - script.execute: adjust_to_desired
                            else:
                              - logger.log: "Test Mode active: logical power now ON."
                              - script.execute: adjust_to_desired
                      else:
                        - script.execute: adjust_to_desired
                else:
                  - logger.log: "BYPASS (Remote Only): ignoring Flame Level selection."

##########################################################################################
# BUTTON COMPONENT
##########################################################################################
button:
  - platform: template
    id: btn_flame_up
    name: "${friendly_name} Flame Up (step)"
    on_press:
      - if:
          condition:
            lambda: 'return id(mode_select).state != "BYPASS (Remote Only)";'
          then:
            - lambda: |-
                id(power_intended_on) = true;
            - select.set:
                id: mode_select
                option: "MANUAL"
            - script.execute: step_up_once
          else:
            - logger.log: "BYPASS (Remote Only): ignoring Flame Up (step)."

  - platform: template
    id: btn_flame_down
    name: "${friendly_name} Flame Down (step)"
    on_press:
      - if:
          condition:
            lambda: 'return id(mode_select).state != "BYPASS (Remote Only)";'
          then:
            - lambda: |-
                id(power_intended_on) = true;
            - select.set:
                id: mode_select
                option: "MANUAL"
            - script.execute: step_down_once
          else:
            - logger.log: "BYPASS (Remote Only): ignoring Flame Down (step)."

  - platform: template
    id: btn_calibrate_min
    name: "${friendly_name} Calibrate: Force Level 1 (6x down)"
    entity_category: config
    on_press:
      - script.execute: calibrate_to_min

  - platform: template
    id: btn_setpoint_up
    name: "${friendly_name} Setpoint Up"
    icon: mdi:plus
    on_press:
      - number.set:
          id: setpoint_number
          value: !lambda |-
            const float step = ${setpoint_step_c};
            float v = id(setpoint_number).state + step;
            const float vmax = ${setpoint_max_c};
            if (v > vmax) v = vmax;
            v = roundf(v / step) * step;
            return v;

  - platform: template
    id: btn_setpoint_down
    name: "${friendly_name} Setpoint Down"
    icon: mdi:minus
    on_press:
      - number.set:
          id: setpoint_number
          value: !lambda |-
            const float step = ${setpoint_step_c};
            float v = id(setpoint_number).state - step;
            const float vmin = ${setpoint_min_c};
            if (v < vmin) v = vmin;
            v = roundf(v / step) * step;
            return v;

##########################################################################################
# NUMBER COMPONENT
##########################################################################################
number:
  # User-facing setpoint control.
  # This number is the master source of truth for the setpoint so HA/OpenHASP reacts immediately.
  - platform: template
    id: setpoint_number
    name: "${friendly_name} Setpoint"
    icon: mdi:thermometer
    unit_of_measurement: "°C"
    min_value: ${setpoint_min_c}
    max_value: ${setpoint_max_c}
    step: ${setpoint_step_c}
    mode: slider
    optimistic: true
    restore_value: true
    initial_value: 21.0
    set_action:
      - lambda: |-
          const float step = ${setpoint_step_c};
          id(setpoint_c) = roundf(x / step) * step;
      - if:
          condition:
            lambda: |-
              return !id(sp_suppress_mqtt);
          then:
            - script.execute: publish_setpoint_mqtt

  # Bench-test room temperature slider used by AUTO whenever Test Mode is ON.
  - platform: template
    id: test_temperature_value
    name: "${friendly_name} Test Temperature Value"
    icon: mdi:thermometer-chevron-up
    unit_of_measurement: "°C"
    min_value: -10.0
    max_value: 40.0
    step: 0.1
    mode: slider
    internal: true
    lambda: |-
      return id(test_temp_c);
    set_action:
      - lambda: |-
          id(test_temp_c) = x;

##########################################################################################
# INTERVAL COMPONENT
##########################################################################################
interval:
  - interval: ${control_interval_s}s
    then:
      - if:
          condition:
            lambda: 'return id(mode_select).state == "AUTO";'
          then:
            - script.execute: auto_control_tick

  # Outside OFF/BYPASS, if the controller intends power OFF but LEDs still show ON,
  # keep checking and force the heater OFF.
  # OFF is excluded because it deliberately watches for local physical starts and
  # switches to AUTO instead of forcing the heater back off.
  - interval: ${power_off_check_interval_s}s
    then:
      - if:
          condition:
            lambda: 'return !id(test_mode_enabled).state && id(mode_select).state != "BYPASS (Remote Only)" && id(mode_select).state != "OFF" && !id(power_intended_on) && id(powered_state).state && !id(self_power_on);'
          then:
            - script.execute: enforce_heater_off

##########################################################################################
# SCRIPT COMPONENT
##########################################################################################
script:
  # Debounced setpoint MQTT mirror.
  # Keeps HA/OpenHASP setpoint changes responsive while avoiding retained-MQTT bursts.
  - id: publish_setpoint_mqtt
    mode: restart
    then:
      - delay: ${setpoint_mqtt_debounce_ms}ms
      - mqtt.publish:
          topic: "viewroad-status/lounge-gasheater/setpoint"
          retain: true
          payload: !lambda |-
            char buf[16];
            snprintf(buf, sizeof(buf), "%.1f", id(setpoint_c));
            return std::string(buf);

  # Mirror the already-computed Status Text entity out to MQTT.
  # This keeps the HA text entity and MQTT status using the same source of truth.
  - id: publish_status
    mode: restart
    then:
      - mqtt.publish:
          topic: "viewroad-status/lounge-gasheater"
          payload: !lambda |-
            if (id(status_text).state.size() == 0) return std::string("Unknown");
            return id(status_text).state;

  # Recompute the helper powered state from LEDs or the Test Mode override.
  # Status MQTT publishing is handled by status_text on_value so we avoid duplicate publishes here.
  - id: recompute_powered_state
    then:
      - lambda: |-
          const bool led_powered =
              id(led_operating_blue).state || id(led_standby_red).state;

          const bool new_state =
              id(test_mode_enabled).state ? id(test_powered) : led_powered;

          id(powered_state).publish_state(new_state);

  # OFF-mode local physical-start takeover.
  # When Mode is OFF and the heater is started from its own front panel, the LED
  # inputs make powered_state turn ON. This script changes to AUTO, warms up,
  # re-baselines the flame level, and resumes thermostat control.
  - id: local_on_takeover
    mode: restart
    then:
      - logger.log: "OFF mode: local physical heater start detected. Switching to AUTO and taking over."
      - lambda: |-
          id(power_intended_on) = true;
          id(warmup_until_ms) = millis() + (${warmup_seconds} * 1000UL);
          id(ramp_until_ms)   = millis() + (${fast_ramp_seconds} * 1000UL);
      - select.set:
          id: mode_select
          option: "AUTO"
      - delay: ${warmup_seconds}s
      - script.execute: calibrate_to_min
      - script.execute: auto_control_tick
      - script.execute: adjust_to_desired
      - lambda: |-
          id(self_power_on) = false;

  # Force heater OFF after an ESPHome reboot before returning to OFF.
  - id: boot_force_heater_off
    mode: restart
    then:
      - repeat:
          count: ${boot_force_off_attempts}
          then:
            - script.execute: recompute_powered_state
            - if:
                condition:
                  lambda: 'return !id(test_mode_enabled).state && id(powered_state).state;'
                then:
                  - logger.log: "Boot: heater appears ON, pulsing power to force OFF."
                  - script.execute: press_power
                  - script.wait: press_with_gap
                  - delay: ${power_off_verify_delay_s}s
      - script.execute: recompute_powered_state
      - select.set:
          id: mode_select
          option: "OFF"
      - lambda: |-
          id(power_intended_on) = false;

  # In AUTO/MANUAL, if the controller intends power OFF but LEDs still show ON,
  # pulse the heater ON/OFF button to force the heater fully OFF.
  # OFF mode is excluded because a physical start in OFF should trigger AUTO takeover.
  - id: enforce_heater_off
    mode: restart
    then:
      - delay: ${power_off_verify_delay_s}s
      - script.execute: recompute_powered_state
      - if:
          condition:
            lambda: 'return !id(test_mode_enabled).state && id(mode_select).state != "BYPASS (Remote Only)" && id(mode_select).state != "OFF" && !id(power_intended_on) && id(powered_state).state && !id(self_power_on);'
          then:
            - logger.log: "Heater should be OFF but LEDs still show ON. Pulsing power to force OFF."
            - script.execute: press_power

  # Low-level relay click helper.
  # Handles the button press pulse and enforces a minimum gap between clicks.
  - id: press_with_gap
    mode: queued
    parameters:
      which: string
    then:
      - if:
          condition:
            lambda: 'return which == "power";'
          then:
            - switch.turn_on: sw_power_raw
            - delay: ${relay_pulse_ms}ms
            - switch.turn_off: sw_power_raw
          else:
            - if:
                condition:
                  lambda: 'return which == "up";'
                then:
                  - switch.turn_on: sw_up_raw
                  - delay: ${relay_pulse_ms}ms
                  - switch.turn_off: sw_up_raw
                else:
                  - switch.turn_on: sw_down_raw
                  - delay: ${relay_pulse_ms}ms
                  - switch.turn_off: sw_down_raw
      - delay: ${min_click_gap_ms}ms

  - id: press_power
    then:
      - lambda: |-
          id(self_power_on) = true;
      - script.execute:
          id: press_with_gap
          which: "power"

  - id: press_up
    then:
      - if:
          condition:
            lambda: 'return id(mode_select).state != "BYPASS (Remote Only)";'
          then:
            - script.execute:
                id: press_with_gap
                which: "up"
          else:
            - logger.log: "BYPASS (Remote Only): ignoring UP press."

  - id: press_down
    then:
      - if:
          condition:
            lambda: 'return id(mode_select).state != "BYPASS (Remote Only)";'
          then:
            - script.execute:
                id: press_with_gap
                which: "down"
          else:
            - logger.log: "BYPASS (Remote Only): ignoring DOWN press."

  # Step one flame level UP and only update the visible level after the relay click finishes.
  - id: step_up_once
    then:
      - if:
          condition:
            lambda: 'return id(powered_state).state;'
          then:
            - script.execute: press_up
            - script.wait: press_with_gap
            - lambda: |-
                if (id(current_level) < 7) id(current_level)++;
            - select.set:
                id: flame_level_select
                option: !lambda 'return std::to_string(id(current_level));'
            - lambda: |-
                id(last_adjust_ms) = millis();
          else:
            - logger.log: "Heater is OFF; ignoring manual step up."

  # Step one flame level DOWN and only update the visible level after the relay click finishes.
  - id: step_down_once
    then:
      - if:
          condition:
            lambda: 'return id(powered_state).state;'
          then:
            - script.execute: press_down
            - script.wait: press_with_gap
            - lambda: |-
                if (id(current_level) > 1) id(current_level)--;
            - select.set:
                id: flame_level_select
                option: !lambda 'return std::to_string(id(current_level));'
            - lambda: |-
                id(last_adjust_ms) = millis();
          else:
            - logger.log: "Heater is OFF; ignoring manual step down."

  # Force a visible re-baseline to flame level 1.
  # Six DOWN clicks guarantee that even from level 7 we end at level 1.
  - id: calibrate_to_min
    then:
      - if:
          condition:
            lambda: 'return id(mode_select).state != "BYPASS (Remote Only)" && id(mode_select).state != "OFF" && id(powered_state).state;'
          then:
            - lambda: |-
                id(calibrating) = true;
            - script.execute: publish_status
            - repeat:
                count: 6
                then:
                  - script.execute: step_down_once
                  - script.wait: step_down_once
            - lambda: |-
                id(last_recal_ms) = millis();
                id(calibrating) = false;
            - select.set:
                id: flame_level_select
                option: "1"
            - script.execute: publish_status
          else:
            - logger.log: "Skipping calibration (BYPASS or heater OFF)."

  # Moves one step at a time toward desired_level.
  # In Test Mode, target confirmation is bypassed so bench testing feels responsive.
  - id: adjust_to_desired
    mode: queued
    then:
      - if:
          condition:
            lambda: 'return millis() < id(warmup_until_ms) && !id(test_mode_enabled).state;'
          then:
            - logger.log:
                format: "Warmup: suppressing level change."
                level: DEBUG
          else:
            - if:
                condition:
                  lambda: |-
                    const uint32_t step_ms = (millis() < id(ramp_until_ms))
                      ? (${fast_ramp_step_s} * 1000UL)
                      : (${adjust_step_interval_s} * 1000UL);
                    return (millis() - id(last_adjust_ms)) >= step_ms;
                then:
                  - if:
                      condition:
                        lambda: 'return !id(powered_state).state;'
                      then:
                        - logger.log:
                            format: "Heater is OFF; not adjusting level."
                            level: DEBUG
                      else:
                        - if:
                            condition:
                              lambda: 'return id(test_mode_enabled).state || id(target_confirmed) || (millis() < id(ramp_until_ms));'
                            then:
                              - if:
                                  condition:
                                    lambda: 'return id(current_level) < id(desired_level);'
                                  then:
                                    - script.execute: step_up_once
                                  else:
                                    - if:
                                        condition:
                                          lambda: 'return id(current_level) > id(desired_level);'
                                        then:
                                          - script.execute: step_down_once

  # Called after a successful power ON request.
  # Handles warmup, optional calibration, and initial AUTO control startup.
  - id: after_power_on_reset_to_auto
    then:
      - if:
          condition:
            lambda: 'return id(mode_select).state != "BYPASS (Remote Only)";'
          then:
            - lambda: |-
                id(power_intended_on) = true;
            - if:
                condition:
                  lambda: 'return id(test_mode_enabled).state;'
                then:
                  - if:
                      condition:
                        lambda: 'return id(mode_select).state == "AUTO";'
                      then:
                        - lambda: |-
                            id(warmup_until_ms) = millis() + (${warmup_seconds} * 1000UL);
                            id(ramp_until_ms)   = millis() + (${fast_ramp_seconds} * 1000UL);
                        - delay: ${warmup_seconds}s
                        - script.execute: calibrate_to_min
                        - script.execute: auto_control_tick
                        - script.execute: adjust_to_desired
                        - lambda: |-
                            id(self_power_on) = false;
                      else:
                        - lambda: |-
                            id(self_power_on) = false;
                        - logger.log: "Test Mode active: staying in MANUAL; no AUTO startup actions."
                else:
                  - lambda: |-
                      id(warmup_until_ms) = millis() + (${warmup_seconds} * 1000UL);
                      id(ramp_until_ms)   = millis() + (${fast_ramp_seconds} * 1000UL);
                  - select.set:
                      id: mode_select
                      option: "AUTO"
                  - delay: ${warmup_seconds}s
                  - script.execute: calibrate_to_min
                  - script.execute: auto_control_tick
                  - script.execute: adjust_to_desired
                  - lambda: |-
                      id(self_power_on) = false;
          else:
            - lambda: |-
                id(self_power_on) = false;
            - logger.log: "BYPASS (Remote Only): keeping mode; no auto-calibrate."

  # Main thermostat logic.
  # Uses test_temp_c whenever Test Mode is enabled, otherwise uses MQTT room_temp.
  - id: auto_control_tick
    mode: queued
    then:
      - lambda: |-
          if (id(mode_select).state != "AUTO") {
            ESP_LOGD("gas", "AUTO: skipping control tick because Mode is not AUTO");
            return;
          }

          const bool use_test_temp = id(test_mode_enabled).state;
          const float t  = use_test_temp ? id(test_temp_c) : id(room_temp).state;
          const float sp = id(setpoint_c);
          const uint32_t now_ms = millis();

          if (!use_test_temp) {
            const bool temp_lost = (id(last_temp_ms) > 0) &&
                                   ((now_ms - id(last_temp_ms)) >= (${temp_stale_timeout_min} * 60000UL));
            id(room_temp_lost_fault) = temp_lost;
            if (temp_lost) {
              ESP_LOGW("gas", "AUTO: room temperature lost; skipping control tick");
              return;
            }
          } else {
            id(room_temp_lost_fault) = false;
          }

          if (isnan(t)) {
            ESP_LOGW("gas", "AUTO: temperature missing; skipping control tick");
            return;
          }

          if (isnan(sp)) {
            ESP_LOGW("gas", "AUTO: setpoint missing; skipping control tick");
            return;
          }

          const bool powered = id(powered_state).state;
          const float low  = sp - ${on_below_c};
          const float high = sp + ${off_above_c};

          const bool can_turn_on  = (!powered) && ((${min_off_minutes} == 0) || ((now_ms - id(last_off_ms)) >= (${min_off_minutes} * 60000UL)));
          const bool can_turn_off = ( powered) && ((${min_on_minutes}  == 0) || ((now_ms - id(last_on_ms))  >= (${min_on_minutes}  * 60000UL)));

          if (t <= low && can_turn_on) {
            ESP_LOGI("gas", "AUTO: below setpoint -> ON");
            id(power_intended_on) = true;
            id(press_power).execute();
            if (id(test_mode_enabled).state) {
              id(test_powered) = true;
              id(powered_state).publish_state(true);
              id(publish_status).execute();
            }
            id(after_power_on_reset_to_auto).execute();
            return;
          }

          if (t >= high && can_turn_off) {
            ESP_LOGI("gas", "AUTO: above setpoint -> OFF");
            id(power_intended_on) = false;
            id(press_power).execute();
            if (id(test_mode_enabled).state) {
              id(test_powered) = false;
              id(powered_state).publish_state(false);
              id(publish_status).execute();
            }
            return;
          }

          if (powered) {
            float diff = sp - t;
            int target = 1;

            // Proportional flame mapping:
            // - A higher proportional_aggression lowers the effective thresholds and makes
            //   the controller choose higher flame levels sooner.
            // - The individual flame_level_X_diff_c substitutions still allow fine tuning.
            const float k = ${proportional_aggression};

            if      (diff >= (${flame_level_7_diff_c} / k)) target = 7;
            else if (diff >= (${flame_level_6_diff_c} / k)) target = 6;
            else if (diff >= (${flame_level_5_diff_c} / k)) target = 5;
            else if (diff >= (${flame_level_4_diff_c} / k)) target = 4;
            else if (diff >= (${flame_level_3_diff_c} / k)) target = 3;
            else if (diff >= (${flame_level_2_diff_c} / k)) target = 2;
            else                                             target = 1;

            id(desired_level)     = target;
            id(target_confirmed)  = (target == id(last_level_target));
            id(last_level_target) = target;
          }

      - if:
          condition:
            lambda: 'return id(powered_state).state;'
          then:
            - if:
                condition:
                  lambda: |-
                    return (id(desired_level) == 1) &&
                           ((millis() - id(last_recal_ms)) >= (${recalibrate_interval_min} * 60000UL));
                then:
                  - logger.log: "Re-baseline at level 1 (6x DOWN)."
                  - script.execute: calibrate_to_min
            - script.execute: adjust_to_desired