additional esphome devices and fixes
This commit is contained in:
+212
-161
@@ -6,10 +6,12 @@
|
||||
# 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.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
|
||||
@@ -54,8 +56,8 @@
|
||||
# - Flame Down step button
|
||||
# - Setpoint helper entities are available via:
|
||||
# - Setpoint Text
|
||||
# - Setpoint Up (+0.5 °C)
|
||||
# - Setpoint Down (-0.5 °C)
|
||||
# - 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
|
||||
@@ -63,19 +65,18 @@
|
||||
# - 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.
|
||||
# - OFF = force heater Power OFF and set Mode to BYPASS (Remote Only)
|
||||
# - In any mode except BYPASS (Remote Only), if the controller intends heater power OFF
|
||||
# but either heater LED still shows the heater ON, ESPHome will pulse the heater ON/OFF
|
||||
# button to force it fully OFF.
|
||||
# - On every ESPHome restart (including yaml upload or power loss), the controller first
|
||||
# forces the heater OFF, then returns to BYPASS (Remote Only).
|
||||
# - On power ON in AUTO, the controller:
|
||||
# - Applies a warmup lockout
|
||||
# - Calibrates to level 1 using 6x DOWN
|
||||
# - Begins auto level adjustment
|
||||
# - If the heater is turned on externally while in AUTO, the controller will re-baseline
|
||||
# after warmup and resume automatic control.
|
||||
# - 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:
|
||||
@@ -125,9 +126,12 @@ substitutions:
|
||||
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}
|
||||
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"
|
||||
@@ -145,9 +149,10 @@ substitutions:
|
||||
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"
|
||||
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"
|
||||
@@ -171,7 +176,23 @@ substitutions:
|
||||
########################################################################################
|
||||
setpoint_min_c: "6.0"
|
||||
setpoint_max_c: "32.0"
|
||||
setpoint_step_c: "0.1"
|
||||
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
|
||||
@@ -195,6 +216,9 @@ packages:
|
||||
vars:
|
||||
local_device_name: "${device_name}"
|
||||
|
||||
#### WEB PORTAL ####
|
||||
#common_webportal: !include common/webportal_common.yaml
|
||||
|
||||
common_sntp: !include
|
||||
file: common/sntp_common.yaml
|
||||
|
||||
@@ -216,43 +240,19 @@ esphome:
|
||||
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();
|
||||
}
|
||||
// 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);
|
||||
- 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."
|
||||
- script.execute: recompute_powered_state
|
||||
- delay: 1s
|
||||
- script.execute: boot_force_heater_off
|
||||
|
||||
##########################################################################################
|
||||
# ESP PLATFORM
|
||||
##########################################################################################
|
||||
@@ -340,6 +340,13 @@ globals:
|
||||
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
|
||||
@@ -364,13 +371,6 @@ globals:
|
||||
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
|
||||
##########################################################################################
|
||||
@@ -393,14 +393,9 @@ binary_sensor:
|
||||
inverted: ${red_led_inverted}
|
||||
filters:
|
||||
- delayed_on_off: 50ms
|
||||
entity_category: diagnostic
|
||||
#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
|
||||
@@ -413,14 +408,9 @@ binary_sensor:
|
||||
inverted: ${blue_led_inverted}
|
||||
filters:
|
||||
- delayed_on_off: 50ms
|
||||
entity_category: diagnostic
|
||||
#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
|
||||
@@ -433,7 +423,12 @@ binary_sensor:
|
||||
}
|
||||
- if:
|
||||
condition:
|
||||
lambda: 'return !id(test_mode_enabled).state && (id(mode_select).state == "AUTO") && (!id(self_power_on));'
|
||||
lambda: 'return !id(test_mode_enabled).state && id(mode_select).state != "BYPASS (Remote Only)" && !id(power_intended_on) && !id(self_power_on);'
|
||||
then:
|
||||
- script.execute: enforce_heater_off
|
||||
- if:
|
||||
condition:
|
||||
lambda: 'return !id(test_mode_enabled).state && (id(mode_select).state == "AUTO") && id(power_intended_on) && (!id(self_power_on));'
|
||||
then:
|
||||
- lambda: |-
|
||||
id(ramp_until_ms) = millis() + (${fast_ramp_seconds} * 1000UL);
|
||||
@@ -481,6 +476,7 @@ switch:
|
||||
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
|
||||
@@ -516,6 +512,8 @@ switch:
|
||||
option: "AUTO"
|
||||
|
||||
turn_off_action:
|
||||
- lambda: |-
|
||||
id(power_intended_on) = false;
|
||||
- switch.turn_off: power_switch
|
||||
- select.set:
|
||||
id: mode_select
|
||||
@@ -523,16 +521,20 @@ switch:
|
||||
|
||||
- platform: template
|
||||
id: power_switch
|
||||
name: "${friendly_name} Power"
|
||||
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(mode_select).state == "FULL OFF") && !id(test_mode_enabled).state;'
|
||||
lambda: 'return id(mode_select).state == "BYPASS (Remote Only)" && !id(test_mode_enabled).state;'
|
||||
then:
|
||||
- logger.log: "BYPASS/FULL OFF: ignoring Power ON."
|
||||
- logger.log: "BYPASS (Remote Only): ignoring Power ON."
|
||||
else:
|
||||
- if:
|
||||
condition:
|
||||
@@ -550,6 +552,10 @@ switch:
|
||||
- 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;'
|
||||
@@ -600,15 +606,23 @@ sensor:
|
||||
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;
|
||||
const float step = ${setpoint_step_c};
|
||||
id(setpoint_c) = roundf(v / step) * step;
|
||||
- number.set:
|
||||
id: setpoint_number
|
||||
value: !lambda 'return id(setpoint_c);'
|
||||
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;
|
||||
|
||||
@@ -626,6 +640,10 @@ text_sensor:
|
||||
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");
|
||||
@@ -653,22 +671,12 @@ text_sensor:
|
||||
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];
|
||||
@@ -696,10 +704,10 @@ text_sensor:
|
||||
id: setpoint_text
|
||||
name: "Setpoint Text"
|
||||
icon: mdi:thermometer
|
||||
update_interval: 1s
|
||||
update_interval: 500ms
|
||||
lambda: |-
|
||||
char buf[16];
|
||||
snprintf(buf, sizeof(buf), "%.1f °C", id(setpoint_c));
|
||||
snprintf(buf, sizeof(buf), "%.1f °C", id(setpoint_number).state);
|
||||
return std::string(buf);
|
||||
|
||||
- platform: template
|
||||
@@ -707,6 +715,7 @@ text_sensor:
|
||||
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));
|
||||
@@ -715,28 +724,22 @@ text_sensor:
|
||||
##########################################################################################
|
||||
# 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"]
|
||||
options: ["AUTO","MANUAL","BYPASS (Remote Only)"]
|
||||
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
|
||||
if (x == "BYPASS (Remote Only)") {
|
||||
id(power_intended_on) = false;
|
||||
}
|
||||
if (x != "AUTO") {
|
||||
id(room_temp_lost_fault) = false;
|
||||
}
|
||||
|
||||
- platform: template
|
||||
id: flame_level_select
|
||||
@@ -755,12 +758,13 @@ select:
|
||||
else:
|
||||
- if:
|
||||
condition:
|
||||
lambda: 'return id(mode_select).state == "AUTO" || id(mode_select).state == "MANUAL";'
|
||||
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;
|
||||
@@ -793,7 +797,7 @@ select:
|
||||
else:
|
||||
- script.execute: adjust_to_desired
|
||||
else:
|
||||
- logger.log: "BYPASS/FULL OFF: ignoring Flame Level selection."
|
||||
- logger.log: "BYPASS (Remote Only): ignoring Flame Level selection."
|
||||
|
||||
##########################################################################################
|
||||
# BUTTON COMPONENT
|
||||
@@ -805,14 +809,16 @@ button:
|
||||
on_press:
|
||||
- if:
|
||||
condition:
|
||||
lambda: 'return id(mode_select).state == "AUTO" || id(mode_select).state == "MANUAL";'
|
||||
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/FULL OFF: ignoring Flame Up (step)."
|
||||
- logger.log: "BYPASS (Remote Only): ignoring Flame Up (step)."
|
||||
|
||||
- platform: template
|
||||
id: btn_flame_down
|
||||
@@ -820,14 +826,16 @@ button:
|
||||
on_press:
|
||||
- if:
|
||||
condition:
|
||||
lambda: 'return id(mode_select).state == "AUTO" || id(mode_select).state == "MANUAL";'
|
||||
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/FULL OFF: ignoring Flame Down (step)."
|
||||
- logger.log: "BYPASS (Remote Only): ignoring Flame Down (step)."
|
||||
|
||||
- platform: template
|
||||
id: btn_calibrate_min
|
||||
@@ -841,39 +849,37 @@ button:
|
||||
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);'
|
||||
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:
|
||||
- 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);'
|
||||
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"
|
||||
@@ -883,8 +889,9 @@ number:
|
||||
max_value: ${setpoint_max_c}
|
||||
step: ${setpoint_step_c}
|
||||
mode: slider
|
||||
lambda: |-
|
||||
return id(setpoint_c);
|
||||
optimistic: true
|
||||
restore_value: true
|
||||
initial_value: 21.0
|
||||
set_action:
|
||||
- lambda: |-
|
||||
const float step = ${setpoint_step_c};
|
||||
@@ -894,13 +901,7 @@ number:
|
||||
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);
|
||||
- script.execute: publish_setpoint_mqtt
|
||||
|
||||
# Bench-test room temperature slider used by AUTO whenever Test Mode is ON.
|
||||
- platform: template
|
||||
@@ -912,6 +913,7 @@ number:
|
||||
max_value: 40.0
|
||||
step: 0.1
|
||||
mode: slider
|
||||
internal: true
|
||||
lambda: |-
|
||||
return id(test_temp_c);
|
||||
set_action:
|
||||
@@ -930,19 +932,34 @@ interval:
|
||||
then:
|
||||
- script.execute: auto_control_tick
|
||||
|
||||
# In FULL OFF, keep checking that the heater is really off.
|
||||
- interval: ${full_off_check_interval_s}s
|
||||
# Outside BYPASS, if the controller intends power OFF but LEDs still show ON,
|
||||
# keep checking and force the heater OFF.
|
||||
- interval: ${power_off_check_interval_s}s
|
||||
then:
|
||||
- if:
|
||||
condition:
|
||||
lambda: 'return !id(test_mode_enabled).state && id(mode_select).state == "FULL OFF" && id(powered_state).state;'
|
||||
lambda: 'return !id(test_mode_enabled).state && id(mode_select).state != "BYPASS (Remote Only)" && !id(power_intended_on) && id(powered_state).state && !id(self_power_on);'
|
||||
then:
|
||||
- script.execute: enforce_full_off
|
||||
- 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
|
||||
@@ -954,6 +971,8 @@ script:
|
||||
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: |-
|
||||
@@ -964,20 +983,42 @@ script:
|
||||
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
|
||||
# Force heater OFF after an ESPHome reboot before returning to BYPASS.
|
||||
- id: boot_force_heater_off
|
||||
mode: restart
|
||||
then:
|
||||
- delay: ${safe_power_off_delay_s}s
|
||||
- repeat:
|
||||
count: ${boot_force_off_attempts}
|
||||
then:
|
||||
- script.execute: recompute_powered_state
|
||||
- if:
|
||||
condition:
|
||||
lambda: 'return !id(test_mode_enabled).state && id(powered_state).state;'
|
||||
then:
|
||||
- logger.log: "Boot: heater appears ON, pulsing power to force OFF."
|
||||
- script.execute: press_power
|
||||
- script.wait: press_with_gap
|
||||
- delay: ${power_off_verify_delay_s}s
|
||||
- script.execute: recompute_powered_state
|
||||
- select.set:
|
||||
id: mode_select
|
||||
option: "BYPASS (Remote Only)"
|
||||
- lambda: |-
|
||||
id(power_intended_on) = false;
|
||||
|
||||
# In non-BYPASS modes, if the controller intends power OFF but LEDs still show ON,
|
||||
# pulse the heater ON/OFF button to force the heater fully OFF.
|
||||
- id: enforce_heater_off
|
||||
mode: restart
|
||||
then:
|
||||
- delay: ${power_off_verify_delay_s}s
|
||||
- script.execute: recompute_powered_state
|
||||
- if:
|
||||
condition:
|
||||
lambda: 'return !id(test_mode_enabled).state && id(mode_select).state == "FULL OFF" && id(powered_state).state;'
|
||||
lambda: 'return !id(test_mode_enabled).state && id(mode_select).state != "BYPASS (Remote Only)" && !id(power_intended_on) && id(powered_state).state && !id(self_power_on);'
|
||||
then:
|
||||
- logger.log: "FULL OFF: heater still appears ON, pulsing power to force OFF."
|
||||
- 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.
|
||||
@@ -1020,25 +1061,25 @@ script:
|
||||
then:
|
||||
- if:
|
||||
condition:
|
||||
lambda: 'return id(mode_select).state == "AUTO" || id(mode_select).state == "MANUAL";'
|
||||
lambda: 'return id(mode_select).state != "BYPASS (Remote Only)";'
|
||||
then:
|
||||
- script.execute:
|
||||
id: press_with_gap
|
||||
which: "up"
|
||||
else:
|
||||
- logger.log: "BYPASS/FULL OFF: ignoring UP press."
|
||||
- logger.log: "BYPASS (Remote Only): ignoring UP press."
|
||||
|
||||
- id: press_down
|
||||
then:
|
||||
- if:
|
||||
condition:
|
||||
lambda: 'return id(mode_select).state == "AUTO" || id(mode_select).state == "MANUAL";'
|
||||
lambda: 'return id(mode_select).state != "BYPASS (Remote Only)";'
|
||||
then:
|
||||
- script.execute:
|
||||
id: press_with_gap
|
||||
which: "down"
|
||||
else:
|
||||
- logger.log: "BYPASS/FULL OFF: ignoring DOWN press."
|
||||
- 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
|
||||
@@ -1155,8 +1196,10 @@ script:
|
||||
then:
|
||||
- if:
|
||||
condition:
|
||||
lambda: 'return id(mode_select).state == "AUTO" || id(mode_select).state == "MANUAL";'
|
||||
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;'
|
||||
@@ -1238,6 +1281,7 @@ script:
|
||||
|
||||
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;
|
||||
@@ -1250,6 +1294,7 @@ script:
|
||||
|
||||
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;
|
||||
@@ -1263,13 +1308,19 @@ script:
|
||||
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;
|
||||
// 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));
|
||||
|
||||
Reference in New Issue
Block a user