1412 lines
51 KiB
YAML
1412 lines
51 KiB
YAML
#:########################################################################################:#
|
|
# 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);
|
|
}
|
|
- choose:
|
|
# 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.
|
|
- conditions:
|
|
- lambda: 'return !id(test_mode_enabled).state && id(mode_select).state == "OFF" && !id(self_power_on);'
|
|
sequence:
|
|
- script.execute: local_on_takeover
|
|
|
|
# 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.
|
|
- conditions:
|
|
- 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);'
|
|
sequence:
|
|
- script.execute: enforce_heater_off
|
|
|
|
# If the heater turns on externally while AUTO already intended it ON,
|
|
# re-baseline after warmup and continue normal automatic control.
|
|
- conditions:
|
|
- lambda: 'return !id(test_mode_enabled).state && id(mode_select).state == "AUTO" && id(power_intended_on) && !id(self_power_on);'
|
|
sequence:
|
|
- 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 |