Skip to content

Instantly share code, notes, and snippets.

@raspberrypisig
Created August 9, 2025 03:20
Show Gist options
  • Save raspberrypisig/58486a9309073fbbbfbf7f3f81167138 to your computer and use it in GitHub Desktop.
Save raspberrypisig/58486a9309073fbbbfbf7f3f81167138 to your computer and use it in GitHub Desktop.
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