Files
zorruno-homeassistant/esphome/esp-gasheater.yaml
T

1291 lines
46 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.11 2026-04-21 Reboot now enters temporary FULL OFF hold before returning to BYPASS;
# FULL OFF is remembered across reboots if it was already active
# V1.10 2026-04-21 Added FULL OFF mode and reboot-safe shutdown to force heater OFF
# after ESPHome restart; fixed stray setpoint typo
# 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 (+0.5 °C)
# - Setpoint Down (-0.5 °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 = turn heater Power OFF and set Mode to BYPASS (Remote Only)
# - FULL OFF mode is a stronger safety state than BYPASS:
# - If either heater LED shows the heater is still on, ESPHome will pulse the power
# button after a short delay to force the heater OFF
# - On normal ESPHome startup, the controller first enters FULL OFF for a short hold time
# to verify the heater is really off, then returns to BYPASS (Remote Only).
# - If FULL OFF was already active before reboot, FULL OFF is restored and remembered.
# - 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, the controller will re-baseline
# after warmup and resume automatic control.
# - Fast-ramp mode allows faster flame stepping for a short period after turn-on.
#:########################################################################################:#
# MQTT COMMANDS:
# - Room temperature input:
# viewroad-status/lounge-environment/temperature
# - Setpoint input/output:
# viewroad-status/lounge-environment/temp-setpoint
# - Heater operating status output:
# viewroad-status/lounge-gasheater
#:########################################################################################:#
# OFFLINE NOTES:
# a) HA offline, WiFi/MQTT online:
# - Device continues to operate normally.
# - AUTO control still works if room temperature MQTT is still being published.
# - Setpoint can still be changed via MQTT.
#
# b) MQTT offline (or HA and MQTT offline):
# - No fresh room temperature or setpoint updates are received.
# - Device retains the last local setpoint.
# - If AUTO is active and temperature data becomes stale, the heater will turn OFF after
# temp_stale_timeout_min as a fail-safe.
# - Manual mode and flame tracking still work locally inside the ESP device logic.
#
# c) Entire WiFi/Network offline:
# - No MQTT temperature or setpoint updates are available.
# - Accurate time is not needed. SNTP is not required for this device.
# - If AUTO is active and the heater is ON, stale temperature fail-safe will turn it OFF
# after the configured timeout.
# - Existing heater safety systems still operate normally.
#:########################################################################################:#
##########################################################################################
# SPECIFIC DEVICE VARIABLE SUBSTITUTIONS
##########################################################################################
substitutions:
# Device Naming
device_name: "esp-gasheater"
friendly_name: "Rinnai Neo Gas Heater"
description_comment: "7 Setting controller for Rinnai Neo Gas heater :: Sonoff DEV + Relay Board"
device_area: "Lounge"
# Project Naming
project_name: "Sonoff Technologies.Sonoff DEV"
project_version: "v1.11"
# Passwords & Secrets
api_key: !secret esp-api_key
ota_pass: !secret esp-ota_pass
static_ip_address: !secret esp-gasheater_ip
current_ip_address: ${static_ip_address}
mqtt_local_command_main_topic: !secret mqtt_command_main_topic
mqtt_local_status_main_topic: !secret mqtt_status_main_topic
# 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"
safe_power_off_delay_s: "2"
full_off_check_interval_s: "5"
boot_full_off_hold_s: "10"
# 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.1"
##########################################################################################
# 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}"
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:
- script.execute: recompute_powered_state
- lambda: |-
if (id(powered_state).state) {
if (id(last_on_ms) == 0) id(last_on_ms) = millis();
} else {
if (id(last_off_ms) == 0) id(last_off_ms) = millis();
}
id(last_temp_ms) = millis();
id(warmup_until_ms) = millis() + (${warmup_seconds} * 1000UL);
- if:
condition:
lambda: 'return id(full_off_persist);'
then:
- logger.log: "Boot: FULL OFF was active before reboot; restoring FULL OFF."
- select.set:
id: mode_select
option: "FULL OFF"
- script.execute: enforce_full_off
else:
- logger.log: "Boot: entering temporary FULL OFF hold before BYPASS."
- select.set:
id: mode_select
option: "FULL OFF"
- script.execute: enforce_full_off
- delay: ${boot_full_off_hold_s}s
- script.execute: recompute_powered_state
- if:
condition:
lambda: 'return !id(powered_state).state;'
then:
- logger.log: "Boot: heater appears OFF after FULL OFF hold; switching to BYPASS."
- select.set:
id: mode_select
option: "BYPASS (Remote Only)"
else:
- logger.log: "Boot: heater still appears ON after FULL OFF hold; staying in FULL OFF."
##########################################################################################
# ESP PLATFORM
##########################################################################################
esp8266:
board: esp01_1m
restore_from_flash: true
preferences:
flash_write_interval: 5min
mdns:
disabled: false
##########################################################################################
# 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"
# 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"
# Persist whether FULL OFF was the active mode before reboot.
# If true, reboot returns to FULL OFF instead of temporary FULL OFF then BYPASS.
- id: full_off_persist
type: bool
restore_value: yes
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
- if:
condition:
lambda: 'return !id(test_mode_enabled).state && id(mode_select).state == "FULL OFF" && (id(led_operating_blue).state || id(led_standby_red).state);'
then:
- script.execute: enforce_full_off
- 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
- if:
condition:
lambda: 'return !id(test_mode_enabled).state && id(mode_select).state == "FULL OFF" && (id(led_operating_blue).state || id(led_standby_red).state);'
then:
- script.execute: enforce_full_off
- platform: template
id: powered_state
internal: true
on_press:
- lambda: |-
id(last_on_ms) = millis();
if (!id(test_mode_enabled).state && id(mode_select).state == "AUTO") {
id(warmup_until_ms) = millis() + (${warmup_seconds} * 1000UL);
}
- if:
condition:
lambda: 'return !id(test_mode_enabled).state && (id(mode_select).state == "AUTO") && (!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
turn_on_action:
- lambda: |-
// Preserve current logical power state when entering Test Mode
id(test_powered) = id(powered_state).state;
id(room_temp_lost_fault) = false;
- select.set:
id: mode_select
option: "MANUAL"
- script.execute: recompute_powered_state
turn_off_action:
- lambda: |-
id(test_powered) = false;
- script.execute: recompute_powered_state
# High-level thermostat enable helper.
# ON = thermostat logic active in AUTO
# OFF = force BYPASS and turn heater power OFF
- platform: template
id: heater_operation_switch
name: "Heater Operation"
icon: mdi:radiator
lambda: |-
return id(mode_select).state == "AUTO";
turn_on_action:
- if:
condition:
switch.is_on: test_mode_enabled
then:
- switch.turn_off: test_mode_enabled
- select.set:
id: mode_select
option: "AUTO"
turn_off_action:
- switch.turn_off: power_switch
- select.set:
id: mode_select
option: "BYPASS (Remote Only)"
- platform: template
id: power_switch
name: "${friendly_name} Power"
icon: mdi:power
lambda: |-
return id(powered_state).state;
turn_on_action:
- if:
condition:
lambda: 'return (id(mode_select).state == "BYPASS (Remote Only)" || id(mode_select).state == "FULL OFF") && !id(test_mode_enabled).state;'
then:
- logger.log: "BYPASS/FULL OFF: 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:
- 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};
if (v < vmin) v = vmin;
if (v > vmax) v = vmax;
id(sp_suppress_mqtt) = true;
const float step = ${setpoint_step_c};
id(setpoint_c) = roundf(v / step) * step;
- number.set:
id: setpoint_number
value: !lambda 'return id(setpoint_c);'
- 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 (id(calibrating)) {
if (use_test_temp) return std::string("Calibrating (Test)");
return std::string("Calibrating");
}
if (use_test_temp) {
if (mode == "MANUAL") {
if (id(powered_state).state) {
char buf[48];
snprintf(buf, sizeof(buf), "Manual (Level %d) (Test)", id(current_level));
return std::string(buf);
}
if (id(led_standby_red).state) return std::string("Manual (Standby) (Test)");
return std::string("Manual (Off) (Test)");
}
if (id(powered_state).state) {
char buf[40];
snprintf(buf, sizeof(buf), "Operating (Level %d) (Test)", id(current_level));
return std::string(buf);
}
if (mode == "AUTO" && !isnan(effective_t) && effective_t >= at_temp_threshold) {
return std::string("At Temperature (Test)");
}
return std::string("Off (Test)");
}
if (mode == "FULL OFF") {
if (id(led_operating_blue).state) return std::string("Full Off (Operating)");
if (id(led_standby_red).state) return std::string("Full Off (Standby)");
return std::string("Full Off (Off)");
}
if (mode == "BYPASS (Remote Only)") {
if (id(led_operating_blue).state) return std::string("Remote Only (Operating)");
if (id(led_standby_red).state) return std::string("Remote Only (Standby)");
return std::string("Remote Only (Off)");
}
if (!use_test_temp && id(room_temp_lost_fault)) {
return std::string("Fault (Room Temperature Lost)");
}
if (mode == "MANUAL") {
if (id(led_operating_blue).state) {
char buf[32];
snprintf(buf, sizeof(buf), "Manual (Level %d)", id(current_level));
return std::string(buf);
}
if (id(led_standby_red).state) return std::string("Manual (Standby)");
return std::string("Manual (Off)");
}
if (id(led_operating_blue).state) {
char buf[32];
snprintf(buf, sizeof(buf), "Operating (Level %d)", id(current_level));
return std::string(buf);
}
if (!isnan(effective_t) && effective_t >= at_temp_threshold) {
return std::string("At Temperature");
}
if (id(led_standby_red).state) return std::string("Standby");
return std::string("Off");
on_value:
- script.execute: publish_status
- platform: template
id: setpoint_text
name: "Setpoint Text"
icon: mdi:thermometer
update_interval: 1s
lambda: |-
char buf[16];
snprintf(buf, sizeof(buf), "%.1f °C", id(setpoint_c));
return std::string(buf);
- platform: template
id: test_temperature_text
name: "${friendly_name} Test Mode Temp"
icon: mdi:thermometer-chevron-up
update_interval: 1s
lambda: |-
char buf[20];
snprintf(buf, sizeof(buf), "%.1f °C", id(test_temp_c));
return std::string(buf);
##########################################################################################
# SELECT COMPONENT
##########################################################################################
# Mode meanings:
# - AUTO = thermostat active
# - MANUAL = manual flame control allowed
# - BYPASS (Remote Only) = ESPHome control bypassed, heater may still be used externally
# - FULL OFF = ESPHome actively forces the heater OFF if it detects either LED on
select:
- platform: template
id: mode_select
name: "Mode"
options: ["AUTO","MANUAL","BYPASS (Remote Only)","FULL OFF"]
optimistic: true
initial_option: "BYPASS (Remote Only)"
restore_value: false
set_action:
- lambda: |-
id(full_off_persist) = (x == "FULL OFF");
if (x != "AUTO") id(room_temp_lost_fault) = false;
- if:
condition:
lambda: 'return x == "FULL OFF";'
then:
- script.execute: enforce_full_off
- 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 == "AUTO" || id(mode_select).state == "MANUAL";'
then:
- select.set:
id: mode_select
option: "MANUAL"
- lambda: |-
int target = atoi(x.c_str());
if (target < 1) target = 1;
if (target > 7) target = 7;
id(desired_level) = target;
- if:
condition:
lambda: 'return !id(powered_state).state;'
then:
- 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/FULL OFF: 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 == "AUTO" || id(mode_select).state == "MANUAL";'
then:
- select.set:
id: mode_select
option: "MANUAL"
- script.execute: step_up_once
else:
- logger.log: "BYPASS/FULL OFF: 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 == "AUTO" || id(mode_select).state == "MANUAL";'
then:
- select.set:
id: mode_select
option: "MANUAL"
- script.execute: step_down_once
else:
- logger.log: "BYPASS/FULL OFF: 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:
- lambda: |-
const float step = ${setpoint_step_c};
float v = id(setpoint_c) + step;
const float vmax = ${setpoint_max_c};
if (v > vmax) v = vmax;
// Snap to the nearest configured step to avoid float drift
v = roundf(v / step) * step;
id(setpoint_c) = v;
- number.set:
id: setpoint_number
value: !lambda 'return id(setpoint_c);'
- platform: template
id: btn_setpoint_down
name: "${friendly_name} Setpoint Down"
icon: mdi:minus
on_press:
- lambda: |-
const float step = ${setpoint_step_c};
float v = id(setpoint_c) - step;
const float vmin = ${setpoint_min_c};
if (v < vmin) v = vmin;
// Snap to the nearest configured step to avoid float drift
v = roundf(v / step) * step;
id(setpoint_c) = v;
- number.set:
id: setpoint_number
value: !lambda 'return id(setpoint_c);'
##########################################################################################
# NUMBER COMPONENT
##########################################################################################
number:
- 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
lambda: |-
return id(setpoint_c);
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:
- mqtt.publish:
topic: "viewroad-status/lounge-gasheater/setpoint"
retain: true
payload: !lambda |-
char buf[16];
snprintf(buf, sizeof(buf), "%.1f", x);
return std::string(buf);
# 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
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
# In FULL OFF, keep checking that the heater is really off.
- interval: ${full_off_check_interval_s}s
then:
- if:
condition:
lambda: 'return !id(test_mode_enabled).state && id(mode_select).state == "FULL OFF" && id(powered_state).state;'
then:
- script.execute: enforce_full_off
##########################################################################################
# SCRIPT COMPONENT
##########################################################################################
script:
# 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;
- 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);
- script.execute: publish_status
# FULL OFF enforcement helper.
# After a short delay, if the heater still looks on, pulse the power button once.
- id: enforce_full_off
mode: restart
then:
- delay: ${safe_power_off_delay_s}s
- script.execute: recompute_powered_state
- if:
condition:
lambda: 'return !id(test_mode_enabled).state && id(mode_select).state == "FULL OFF" && id(powered_state).state;'
then:
- logger.log: "FULL OFF: heater still appears 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 == "AUTO" || id(mode_select).state == "MANUAL";'
then:
- script.execute:
id: press_with_gap
which: "up"
else:
- logger.log: "BYPASS/FULL OFF: ignoring UP press."
- id: press_down
then:
- if:
condition:
lambda: 'return id(mode_select).state == "AUTO" || id(mode_select).state == "MANUAL";'
then:
- script.execute:
id: press_with_gap
which: "down"
else:
- logger.log: "BYPASS/FULL OFF: ignoring DOWN press."
# Step one flame level UP and only update the visible level after the relay click finishes.
- id: step_up_once
then:
- if:
condition:
lambda: 'return id(powered_state).state;'
then:
- script.execute: press_up
- script.wait: press_with_gap
- lambda: |-
if (id(current_level) < 7) id(current_level)++;
- select.set:
id: flame_level_select
option: !lambda 'return std::to_string(id(current_level));'
- lambda: |-
id(last_adjust_ms) = millis();
else:
- logger.log: "Heater is OFF; ignoring manual step up."
# Step one flame level DOWN and only update the visible level after the relay click finishes.
- id: step_down_once
then:
- if:
condition:
lambda: 'return id(powered_state).state;'
then:
- script.execute: press_down
- script.wait: press_with_gap
- lambda: |-
if (id(current_level) > 1) id(current_level)--;
- select.set:
id: flame_level_select
option: !lambda 'return std::to_string(id(current_level));'
- lambda: |-
id(last_adjust_ms) = millis();
else:
- logger.log: "Heater is OFF; ignoring manual step down."
# Force a visible re-baseline to flame level 1.
# Six DOWN clicks guarantee that even from level 7 we end at level 1.
- id: calibrate_to_min
then:
- if:
condition:
lambda: 'return id(mode_select).state != "BYPASS (Remote Only)" && id(powered_state).state;'
then:
- lambda: |-
id(calibrating) = true;
- script.execute: publish_status
- repeat:
count: 6
then:
- script.execute: step_down_once
- script.wait: step_down_once
- lambda: |-
id(last_recal_ms) = millis();
id(calibrating) = false;
- select.set:
id: flame_level_select
option: "1"
- script.execute: publish_status
else:
- logger.log: "Skipping calibration (BYPASS or heater OFF)."
# Moves one step at a time toward desired_level.
# In Test Mode, target confirmation is bypassed so bench testing feels responsive.
- id: adjust_to_desired
mode: queued
then:
- if:
condition:
lambda: 'return millis() < id(warmup_until_ms) && !id(test_mode_enabled).state;'
then:
- logger.log:
format: "Warmup: suppressing level change."
level: DEBUG
else:
- if:
condition:
lambda: |-
const uint32_t step_ms = (millis() < id(ramp_until_ms))
? (${fast_ramp_step_s} * 1000UL)
: (${adjust_step_interval_s} * 1000UL);
return (millis() - id(last_adjust_ms)) >= step_ms;
then:
- if:
condition:
lambda: 'return !id(powered_state).state;'
then:
- logger.log:
format: "Heater is OFF; not adjusting level."
level: DEBUG
else:
- if:
condition:
lambda: 'return id(test_mode_enabled).state || id(target_confirmed) || (millis() < id(ramp_until_ms));'
then:
- if:
condition:
lambda: 'return id(current_level) < id(desired_level);'
then:
- script.execute: step_up_once
else:
- if:
condition:
lambda: 'return id(current_level) > id(desired_level);'
then:
- script.execute: step_down_once
# Called after a successful power ON request.
# Handles warmup, optional calibration, and initial AUTO control startup.
- id: after_power_on_reset_to_auto
then:
- if:
condition:
lambda: 'return id(mode_select).state == "AUTO" || id(mode_select).state == "MANUAL";'
then:
- if:
condition:
lambda: 'return id(test_mode_enabled).state;'
then:
- if:
condition:
lambda: 'return id(mode_select).state == "AUTO";'
then:
- lambda: |-
id(warmup_until_ms) = millis() + (${warmup_seconds} * 1000UL);
id(ramp_until_ms) = millis() + (${fast_ramp_seconds} * 1000UL);
- delay: ${warmup_seconds}s
- script.execute: calibrate_to_min
- script.execute: auto_control_tick
- script.execute: adjust_to_desired
- lambda: |-
id(self_power_on) = false;
else:
- lambda: |-
id(self_power_on) = false;
- logger.log: "Test Mode active: staying in MANUAL; no AUTO startup actions."
else:
- lambda: |-
id(warmup_until_ms) = millis() + (${warmup_seconds} * 1000UL);
id(ramp_until_ms) = millis() + (${fast_ramp_seconds} * 1000UL);
- select.set:
id: mode_select
option: "AUTO"
- delay: ${warmup_seconds}s
- script.execute: calibrate_to_min
- script.execute: auto_control_tick
- script.execute: adjust_to_desired
- lambda: |-
id(self_power_on) = false;
else:
- lambda: |-
id(self_power_on) = false;
- logger.log: "BYPASS (Remote Only): keeping mode; no auto-calibrate."
# Main thermostat logic.
# Uses test_temp_c whenever Test Mode is enabled, otherwise uses MQTT room_temp.
- id: auto_control_tick
mode: queued
then:
- lambda: |-
const bool use_test_temp = id(test_mode_enabled).state;
const float t = use_test_temp ? id(test_temp_c) : id(room_temp).state;
const float sp = id(setpoint_c);
const uint32_t now_ms = millis();
if (!use_test_temp) {
const bool temp_lost = (id(last_temp_ms) > 0) &&
((now_ms - id(last_temp_ms)) >= (${temp_stale_timeout_min} * 60000UL));
id(room_temp_lost_fault) = temp_lost;
if (temp_lost) {
ESP_LOGW("gas", "AUTO: room temperature lost; skipping control tick");
return;
}
} else {
id(room_temp_lost_fault) = false;
}
if (isnan(t)) {
ESP_LOGW("gas", "AUTO: temperature missing; skipping control tick");
return;
}
if (isnan(sp)) {
ESP_LOGW("gas", "AUTO: setpoint missing; skipping control tick");
return;
}
const bool powered = id(powered_state).state;
const float low = sp - ${on_below_c};
const float high = sp + ${off_above_c};
const bool can_turn_on = (!powered) && ((${min_off_minutes} == 0) || ((now_ms - id(last_off_ms)) >= (${min_off_minutes} * 60000UL)));
const bool can_turn_off = ( powered) && ((${min_on_minutes} == 0) || ((now_ms - id(last_on_ms)) >= (${min_on_minutes} * 60000UL)));
if (t <= low && can_turn_on) {
ESP_LOGI("gas", "AUTO: below setpoint -> ON");
id(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(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;
if (diff > 3.0f) target = 7;
else if (diff > 2.5f) target = 6;
else if (diff > 2.0f) target = 5;
else if (diff > 1.5f) target = 4;
else if (diff > 1.0f) target = 3;
else if (diff > 0.5f) 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