Created
August 9, 2025 03:20
-
-
Save raspberrypisig/58486a9309073fbbbfbf7f3f81167138 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
substitutions: | |
TARGET_TEMP_C: "42.0" | |
TEMP_TOL_C: "1.0" | |
MIN_RUN_S: "8" | |
MAX_RUN_S: "180" | |
HOLD_OFF_S: "20" | |
ADAPT_ALPHA: "0.2" | |
INCREMENT_S: "5" | |
K_IDLE_S_PER_MIN: "0.1" | |
PREHEAT_WINDOW_S: "90" | |
esphome: | |
name: hot-water-preheat | |
project: | |
name: user.hot_water_preheat | |
version: "1.0" | |
# Pick your board | |
esp32: | |
board: esp32dev | |
framework: | |
type: arduino | |
# --- Common bits --- | |
logger: | |
api: | |
ota: | |
wifi: | |
ssid: !secret wifi_ssid | |
password: !secret wifi_password | |
# Time used for season fallback and timestamps | |
time: | |
- platform: sntp | |
id: sntp_time | |
timezone: Australia/Sydney # change if needed | |
# Optional: Outdoor temperature via Home Assistant (bucketing) | |
homeassistant: | |
# Optional return/pipe temperature sensor (comment out if not used) | |
# Example: import from HA (easiest) | |
sensor: | |
- platform: homeassistant | |
id: return_temp | |
entity_id: sensor.hot_water_return_temp # set to your HA entity or comment out | |
internal: true | |
# Optional: Outdoor temp from HA (bucketing) | |
- platform: homeassistant | |
id: outdoor_temp | |
entity_id: sensor.outdoor_temperature # set to your HA entity or comment out | |
internal: true | |
# Debug sensors (planned seconds) | |
- platform: template | |
id: planned_s_sensor | |
name: "Preheat Planned Seconds" | |
unit_of_measurement: s | |
accuracy_decimals: 0 | |
lambda: |- | |
return id(planned_s); | |
text_sensor: | |
- platform: template | |
id: bucket_name | |
name: "Preheat Bucket" | |
icon: mdi:thermometer | |
# Pump relay (and optional solenoid) | |
switch: | |
- platform: gpio | |
id: pump | |
name: "Hot Water Pump" | |
pin: GPIO23 # TODO: set your relay pin | |
restore_mode: ALWAYS_OFF | |
# Optional solenoid; open when running | |
# - platform: gpio | |
# id: solenoid | |
# name: "Hot Water Solenoid" | |
# pin: GPIO22 | |
# restore_mode: ALWAYS_OFF | |
# User-facing buttons | |
button: | |
- platform: template | |
id: preheat_now | |
name: "Preheat Hot Water" | |
icon: mdi:water-boiler | |
on_press: | |
- script.execute: preheat | |
- platform: template | |
id: feedback_cold | |
name: "Still Cold (Adjust Up)" | |
icon: mdi:thermometer-chevron-up | |
on_press: | |
- lambda: |- | |
auto now = id(sntp_time).now(); | |
uint32_t now_ts = now.is_valid() ? now.timestamp : (uint32_t)(millis()/1000); | |
// Only accept feedback within 120s of last run end | |
if (id(last_run_end_epoch) > 0 && (now_ts - id(last_run_end_epoch) <= 120)) { | |
int b = id(last_bucket_idx); | |
float baseline = (b==0? id(baseline_hot) : b==1? id(baseline_mild) : b==2? id(baseline_cool) : id(baseline_cold)); | |
baseline = baseline + ${INCREMENT_S}; | |
if (baseline > ${MAX_RUN_S}) baseline = ${MAX_RUN_S}; | |
if (b==0) id(baseline_hot) = baseline; | |
else if (b==1) id(baseline_mild) = baseline; | |
else if (b==2) id(baseline_cool) = baseline; | |
else id(baseline_cold) = baseline; | |
ESP_LOGI("preheat", "Feedback: increased baseline for bucket %d to %.1fs", b, baseline); | |
} else { | |
ESP_LOGI("preheat", "Feedback ignored (no recent run)."); | |
} | |
- platform: template | |
id: feedback_hot | |
name: "Hot Early (Adjust Down)" | |
icon: mdi:thermometer-chevron-down | |
on_press: | |
- lambda: |- | |
auto now = id(sntp_time).now(); | |
uint32_t now_ts = now.is_valid() ? now.timestamp : (uint32_t)(millis()/1000); | |
if (id(last_run_end_epoch) > 0 && (now_ts - id(last_run_end_epoch) <= 120)) { | |
int b = id(last_bucket_idx); | |
float baseline = (b==0? id(baseline_hot) : b==1? id(baseline_mild) : b==2? id(baseline_cool) : id(baseline_cold)); | |
baseline = baseline - (${INCREMENT_S} / 2.0f); | |
if (baseline < ${MIN_RUN_S}) baseline = ${MIN_RUN_S}; | |
if (b==0) id(baseline_hot) = baseline; | |
else if (b==1) id(baseline_mild) = baseline; | |
else if (b==2) id(baseline_cool) = baseline; | |
else id(baseline_cold) = baseline; | |
ESP_LOGI("preheat", "Feedback: decreased baseline for bucket %d to %.1fs", b, baseline); | |
} else { | |
ESP_LOGI("preheat", "Feedback ignored (no recent run)."); | |
} | |
# Persistent state | |
globals: | |
# Learned baselines per bucket (hot/mild/cool/cold) in seconds | |
- id: baseline_hot | |
type: float | |
restore_value: yes | |
initial_value: "12.0" | |
- id: baseline_mild | |
type: float | |
restore_value: yes | |
initial_value: "16.0" | |
- id: baseline_cool | |
type: float | |
restore_value: yes | |
initial_value: "20.0" | |
- id: baseline_cold | |
type: float | |
restore_value: yes | |
initial_value: "26.0" | |
- id: last_run_epoch # last successful run end time (epoch) | |
type: uint32_t | |
restore_value: yes | |
initial_value: "0" | |
# Runtime scratch | |
- id: planned_s | |
type: float | |
restore_value: no | |
initial_value: "0" | |
- id: elapsed_s | |
type: int | |
restore_value: no | |
initial_value: "0" | |
- id: success | |
type: bool | |
restore_value: no | |
initial_value: "false" | |
- id: bucket_idx # 0: hot, 1: mild, 2: cool, 3: cold | |
type: int | |
restore_value: no | |
initial_value: "-1" | |
- id: should_run | |
type: bool | |
restore_value: no | |
initial_value: "false" | |
# For feedback window | |
- id: last_bucket_idx | |
type: int | |
restore_value: no | |
initial_value: "-1" | |
- id: last_run_end_epoch | |
type: uint32_t | |
restore_value: no | |
initial_value: "0" | |
script: | |
- id: preheat | |
mode: single | |
then: | |
# Compute plan (bucket, baseline, idle adjustment, planned seconds) | |
- lambda: |- | |
auto now = id(sntp_time).now(); | |
uint32_t now_ts = now.is_valid() ? now.timestamp : (uint32_t)(millis()/1000); | |
// Hold-off | |
if (id(last_run_end_epoch) > 0 && (now_ts - id(last_run_end_epoch) < ${HOLD_OFF_S})) { | |
ESP_LOGI("preheat", "Hold-off active (%u s since last run). Skipping.", (unsigned)(now_ts - id(last_run_end_epoch))); | |
id(should_run) = false; | |
return; | |
} | |
// Bucket selection: outdoor temp if available, else AU season by month | |
int b = 0; // 0 hot, 1 mild, 2 cool, 3 cold | |
bool have_out = false; | |
float Tout = NAN; | |
#ifdef ID(outdoor_temp) | |
Tout = id(outdoor_temp).state; | |
have_out = !isnan(Tout); | |
#endif | |
if (have_out) { | |
if (Tout >= 26.0f) b = 0; | |
else if (Tout >= 18.0f) b = 1; | |
else if (Tout >= 10.0f) b = 2; | |
else b = 3; | |
} else { | |
int m = now.month; // 1..12 (may be 0 if time invalid) | |
if (m == 0) { | |
// time invalid: treat as mild | |
b = 1; | |
} else if (m==12 || m==1 || m==2) b = 0; // summer -> hot | |
else if (m==3 || m==4 || m==5) b = 1; // autumn -> mild | |
else if (m==6 || m==7 || m==8) b = 3; // winter -> cold | |
else b = 1; // spring -> mild | |
} | |
id(bucket_idx) = b; | |
const char* bname = (b==0? "hot" : b==1? "mild" : b==2? "cool" : "cold"); | |
id(bucket_name).publish_state(bname); | |
float baseline = (b==0? id(baseline_hot) : b==1? id(baseline_mild) : b==2? id(baseline_cool) : id(baseline_cold)); | |
// Idle-time adjustment | |
float idle_min = (id(last_run_end_epoch) > 0) ? (float)(now_ts - id(last_run_end_epoch)) / 60.0f : 999.0f; | |
float capped_idle_min = idle_min; | |
float max_idle_min = |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment