Home Assistant's template engine has no abs() function, so every run of
Stove On Detection failed at its first variable ("'abs' is undefined") and
the HVAC failure monitor would fail the same way. Deployment note now points
at labctl blueprints:pull ha.
Claude-Session: https://claude.ai/code/session_01JBPyi1vyBbme5DKykW3Z25
434 lines
20 KiB
YAML
434 lines
20 KiB
YAML
blueprint:
|
|
name: Stove Activity Detector (Split-Phase Power Heuristic)
|
|
description: >
|
|
Infers electric range / stove activity from whole-panel split-phase power alone, for
|
|
setups where a North American Aeotec Home Energy Monitor (or similar HEM) reports only
|
|
the two aggregate mains legs and there is NO dedicated CT clamp on the range circuit.
|
|
|
|
|
|
How it works: a North American electric range is a 240V load, so it draws current from
|
|
BOTH legs of split-phase power symmetrically. Almost every other appliance in the same
|
|
wattage band (microwave, kettle, toaster, coffee maker, air fryer) is a 120V load and
|
|
only draws from ONE leg. The primary discriminator here is therefore phase BALANCE - does
|
|
a power step appear roughly equally on both legs? - which is far more reliable than
|
|
wattage magnitude alone, since many 120V kitchen appliances overlap the same 900-4000W
|
|
range as stove elements. A secondary discriminator checks that the step magnitude falls
|
|
inside a plausible single-element / oven-element window. Because stove elements and oven
|
|
thermostats duty-cycle on and off, when a qualifying rise is seen its magnitude is
|
|
remembered, and a later matching fall is treated as that same element cycling off
|
|
(probably_on) rather than an unrelated event or a confirmed shutdown.
|
|
|
|
|
|
SETUP - before using this blueprint you MUST pre-create two helper entities:
|
|
|
|
* An input_select helper for the stove state, with EXACTLY these three options
|
|
(spelled and cased exactly like this): off, active, probably_on
|
|
|
|
* An input_number helper used as internal scratch space for the last detected
|
|
signature wattage. Give it a wide range (e.g. min 0, max 10000). This is internal
|
|
scratch space and is not meant for dashboards.
|
|
|
|
|
|
OUTPUT STATES (written to the input_select helper):
|
|
|
|
* off - confidently no stove activity.
|
|
|
|
* active - a balanced, in-window power step matching a plausible element
|
|
signature is currently being drawn.
|
|
|
|
* probably_on - was recently active and a matching element drop was seen; likely
|
|
between duty-cycle pulses (elements/ovens cycle on and off via
|
|
internal thermostats/simmerstats), not yet confirmed off. Reverts to
|
|
off automatically if no matching activity resumes within the cycle
|
|
timeout.
|
|
|
|
|
|
KNOWN LIMITATIONS:
|
|
|
|
* Other 240V appliances - electric dryer, electric water heater, EV charger, HVAC
|
|
electric heat strips, well pump - can FALSE-POSITIVE if their wattage lands in the
|
|
configured element window and they are also balanced across both legs.
|
|
|
|
* Near-simultaneous multi-burner, or burner + oven, startup within the settle window
|
|
may appear as one larger combined edge, so element_max_watts may need to be raised to
|
|
catch those - at the cost of more false positives from large 240V loads.
|
|
|
|
* This is a first-draft heuristic. Run it with verbose_diagnostics ON and compare the
|
|
logged edge events against manually-observed real stove usage to calibrate the numeric
|
|
thresholds BEFORE relying on it for anything automated or critical.
|
|
domain: automation
|
|
input:
|
|
phase1_power:
|
|
name: Phase 1 Power Sensor
|
|
description: One leg of the split-phase mains power (whole-panel aggregate, watts).
|
|
selector:
|
|
entity:
|
|
domain: sensor
|
|
|
|
phase2_power:
|
|
name: Phase 2 Power Sensor
|
|
description: The other leg of the split-phase mains power (whole-panel aggregate, watts).
|
|
selector:
|
|
entity:
|
|
domain: sensor
|
|
|
|
state_helper:
|
|
name: Stove State Helper
|
|
description: >
|
|
Pre-created input_select helper that this blueprint writes the inferred stove state
|
|
into. It MUST have exactly three options: off, active, probably_on.
|
|
selector:
|
|
entity:
|
|
domain: input_select
|
|
|
|
signature_helper:
|
|
name: Signature Scratch Helper
|
|
description: >
|
|
Pre-created input_number helper used as internal scratch space to remember the last
|
|
detected element signature wattage. Not meant for dashboards. Give it a wide range,
|
|
e.g. min 0 / max 10000.
|
|
selector:
|
|
entity:
|
|
domain: input_number
|
|
|
|
element_min_watts:
|
|
name: Element Minimum Watts
|
|
description: Lower bound of a plausible single stove/oven element power step.
|
|
default: 900
|
|
selector:
|
|
number:
|
|
min: 200
|
|
max: 3000
|
|
unit_of_measurement: W
|
|
mode: box
|
|
|
|
element_max_watts:
|
|
name: Element Maximum Watts
|
|
description: >
|
|
Upper bound of a plausible element power step. Raise this if simultaneous
|
|
multi-burner startups are being missed (at the cost of more false positives).
|
|
default: 4000
|
|
selector:
|
|
number:
|
|
min: 1000
|
|
max: 8000
|
|
unit_of_measurement: W
|
|
mode: box
|
|
|
|
balance_tolerance_pct:
|
|
name: Phase Balance Tolerance (%)
|
|
description: >
|
|
How closely the two legs must move together for a step to count as a 240V load.
|
|
Smaller is stricter (fewer false positives, more false negatives).
|
|
default: 20
|
|
selector:
|
|
number:
|
|
min: 5
|
|
max: 50
|
|
unit_of_measurement: "%"
|
|
|
|
signature_match_tolerance_pct:
|
|
name: Signature Match Tolerance (%)
|
|
description: >
|
|
How closely a falling step must match the remembered rising signature to be treated
|
|
as the same element cycling off.
|
|
default: 25
|
|
selector:
|
|
number:
|
|
min: 5
|
|
max: 60
|
|
unit_of_measurement: "%"
|
|
|
|
settle_delay_seconds:
|
|
name: Settle Delay (seconds)
|
|
description: >
|
|
Time to wait after one leg reports so the paired leg's report can land before
|
|
resampling both. Handles the fact that HA reports the two legs as separate,
|
|
non-atomic state-changed events even for one physical 240V event.
|
|
default: 3
|
|
selector:
|
|
number:
|
|
min: 1
|
|
max: 15
|
|
unit_of_measurement: seconds
|
|
|
|
cycle_timeout_minutes:
|
|
name: Cycle Timeout (minutes)
|
|
description: >
|
|
How long to remain in probably_on with no matching activity before reverting to off.
|
|
Should comfortably exceed a normal element/oven off-pulse duration.
|
|
default: 12
|
|
selector:
|
|
number:
|
|
min: 1
|
|
max: 60
|
|
unit_of_measurement: minutes
|
|
|
|
verbose_diagnostics:
|
|
name: Verbose Diagnostics
|
|
description: >
|
|
Emits a notification for every considered power edge (not just state transitions),
|
|
intended for calibrating thresholds against real observed stove usage. Turn off once
|
|
trusted.
|
|
default: true
|
|
selector:
|
|
boolean:
|
|
|
|
notify_target:
|
|
name: Notification Target
|
|
description: >
|
|
Where to send diagnostic and transition notifications (e.g. a mobile app notify
|
|
service). If left empty, a persistent notification is created instead.
|
|
default: []
|
|
selector:
|
|
target:
|
|
entity:
|
|
domain: notify
|
|
|
|
# Serialized queued execution (not restart) is what makes the snapshot-then-settle-delay
|
|
# resample technique safe: near-simultaneous phase1/phase2 trigger firings are processed one
|
|
# after another instead of restarting each other mid-delay or being dropped.
|
|
mode: queued
|
|
max: 10
|
|
|
|
trigger:
|
|
- platform: state
|
|
id: power_change
|
|
entity_id:
|
|
- !input phase1_power
|
|
- !input phase2_power
|
|
# Fires only if the state helper has stayed in probably_on continuously for the timeout.
|
|
# This self-cancels if the state leaves probably_on (e.g. back to active) before the timer
|
|
# elapses - no extra bookkeeping needed.
|
|
- platform: state
|
|
id: cycle_timeout
|
|
entity_id: !input state_helper
|
|
to: "probably_on"
|
|
for:
|
|
minutes: !input cycle_timeout_minutes
|
|
|
|
variables:
|
|
phase1_power: !input phase1_power
|
|
phase2_power: !input phase2_power
|
|
state_helper: !input state_helper
|
|
signature_helper: !input signature_helper
|
|
element_min_watts: !input element_min_watts
|
|
element_max_watts: !input element_max_watts
|
|
balance_tolerance_pct: !input balance_tolerance_pct
|
|
signature_match_tolerance_pct: !input signature_match_tolerance_pct
|
|
cycle_timeout_minutes: !input cycle_timeout_minutes
|
|
verbose_diagnostics: !input verbose_diagnostics
|
|
notify_target: !input notify_target
|
|
|
|
action:
|
|
- choose:
|
|
# ------------------------------------------------------------------
|
|
# POWER CHANGE: settle-delay resample, then state-machine evaluation.
|
|
# ------------------------------------------------------------------
|
|
- conditions: "{{ trigger.id == 'power_change' }}"
|
|
sequence:
|
|
# Availability guard at snapshot point 1 - abort the run rather than compute garbage.
|
|
- condition: template
|
|
value_template: >
|
|
{{ states(phase1_power) not in ['unavailable', 'unknown']
|
|
and states(phase2_power) not in ['unavailable', 'unknown'] }}
|
|
|
|
# Snapshot both legs immediately, before the paired leg's report lands.
|
|
- variables:
|
|
phase1_before: "{{ states(phase1_power) | float(0) }}"
|
|
phase2_before: "{{ states(phase2_power) | float(0) }}"
|
|
|
|
# Let the paired leg's separate state-changed event arrive.
|
|
- delay:
|
|
seconds: !input settle_delay_seconds
|
|
|
|
# Availability guard at snapshot point 2.
|
|
- condition: template
|
|
value_template: >
|
|
{{ states(phase1_power) not in ['unavailable', 'unknown']
|
|
and states(phase2_power) not in ['unavailable', 'unknown'] }}
|
|
|
|
# Resample and compute the combined split-phase edge.
|
|
- variables:
|
|
phase1_after: "{{ states(phase1_power) | float(0) }}"
|
|
phase2_after: "{{ states(phase2_power) | float(0) }}"
|
|
delta1: "{{ phase1_after - phase1_before }}"
|
|
delta2: "{{ phase2_after - phase2_before }}"
|
|
combined_delta: "{{ delta1 + delta2 }}"
|
|
magnitude: "{{ combined_delta | abs }}"
|
|
balance_pct: >
|
|
{{ ((delta1 - delta2) | abs) / max(delta1 | abs, delta2 | abs, 1) * 100 }}
|
|
is_balanced: "{{ balance_pct <= balance_tolerance_pct }}"
|
|
in_window: >
|
|
{{ magnitude >= element_min_watts and magnitude <= element_max_watts }}
|
|
current_state: "{{ states(state_helper) }}"
|
|
|
|
- choose:
|
|
# ---- RISE: balanced, in-window step up -> ACTIVE ----
|
|
- conditions: "{{ combined_delta > 0 and in_window and is_balanced }}"
|
|
sequence:
|
|
# Remember this element's signature magnitude for later fall-matching.
|
|
- service: input_number.set_value
|
|
target:
|
|
entity_id: "{{ signature_helper }}"
|
|
data:
|
|
value: "{{ magnitude | round(1) }}"
|
|
- choose:
|
|
- conditions: "{{ current_state != 'active' }}"
|
|
sequence:
|
|
- service: input_select.select_option
|
|
target:
|
|
entity_id: "{{ state_helper }}"
|
|
data:
|
|
option: active
|
|
- choose:
|
|
- conditions: "{{ verbose_diagnostics }}"
|
|
sequence:
|
|
- choose:
|
|
- conditions: "{{ notify_target | length > 0 }}"
|
|
sequence:
|
|
- service: notify.{{ notify_target }}
|
|
data:
|
|
message: >
|
|
Stove signature detected: +{{ magnitude | round(0) }}W
|
|
(phase balance {{ balance_pct | round(1) }}%, delta1
|
|
{{ delta1 | round(0) }}W / delta2 {{ delta2 | round(0) }}W)
|
|
-> ACTIVE
|
|
default:
|
|
- service: persistent_notification.create
|
|
data:
|
|
title: Stove Activity Detector
|
|
message: >
|
|
Stove signature detected: +{{ magnitude | round(0) }}W
|
|
(phase balance {{ balance_pct | round(1) }}%, delta1
|
|
{{ delta1 | round(0) }}W / delta2 {{ delta2 | round(0) }}W)
|
|
-> ACTIVE
|
|
|
|
# ---- FALL: balanced, in-window step down -> possible cycle-off ----
|
|
- conditions: "{{ combined_delta < 0 and in_window and is_balanced }}"
|
|
sequence:
|
|
- variables:
|
|
signature: "{{ states(signature_helper) | float(0) }}"
|
|
match_pct: >
|
|
{{ ((magnitude - signature) | abs) / max(signature, 1) * 100 }}
|
|
- choose:
|
|
# Fall matches the remembered rising signature: same element cycling off.
|
|
- conditions: >
|
|
{{ signature > 0 and match_pct <= signature_match_tolerance_pct }}
|
|
sequence:
|
|
- choose:
|
|
- conditions: "{{ current_state == 'active' }}"
|
|
sequence:
|
|
- service: input_select.select_option
|
|
target:
|
|
entity_id: "{{ state_helper }}"
|
|
data:
|
|
option: probably_on
|
|
- choose:
|
|
- conditions: "{{ verbose_diagnostics }}"
|
|
sequence:
|
|
- choose:
|
|
- conditions: "{{ notify_target | length > 0 }}"
|
|
sequence:
|
|
- service: notify.{{ notify_target }}
|
|
data:
|
|
message: >
|
|
Matching element drop: -{{ magnitude | round(0) }}W
|
|
vs remembered {{ signature | round(0) }}W (match
|
|
{{ match_pct | round(1) }}%). {{ 'ACTIVE ->
|
|
PROBABLY_ON' if current_state == 'active' else
|
|
'state unchanged (' ~ current_state ~ ')' }}
|
|
default:
|
|
- service: persistent_notification.create
|
|
data:
|
|
title: Stove Activity Detector
|
|
message: >
|
|
Matching element drop: -{{ magnitude | round(0) }}W
|
|
vs remembered {{ signature | round(0) }}W (match
|
|
{{ match_pct | round(1) }}%). {{ 'ACTIVE ->
|
|
PROBABLY_ON' if current_state == 'active' else
|
|
'state unchanged (' ~ current_state ~ ')' }}
|
|
default:
|
|
# Ambiguous / unrelated drop - do NOT change state. Log only if it is
|
|
# a meaningful edge (skip sub-50W noise to avoid spam).
|
|
- choose:
|
|
- conditions: "{{ verbose_diagnostics and magnitude >= 50 }}"
|
|
sequence:
|
|
- choose:
|
|
- conditions: "{{ notify_target | length > 0 }}"
|
|
sequence:
|
|
- service: notify.{{ notify_target }}
|
|
data:
|
|
message: >
|
|
Ignored non-matching drop: -{{ magnitude | round(0) }}W
|
|
vs remembered {{ signature | round(0) }}W (match
|
|
{{ match_pct | round(1) }}%) - no state change.
|
|
default:
|
|
- service: persistent_notification.create
|
|
data:
|
|
title: Stove Activity Detector
|
|
message: >
|
|
Ignored non-matching drop: -{{ magnitude | round(0) }}W
|
|
vs remembered {{ signature | round(0) }}W (match
|
|
{{ match_pct | round(1) }}%) - no state change.
|
|
|
|
# ---- ELSE: imbalanced or out-of-window edge -> no state change ----
|
|
default:
|
|
- variables:
|
|
reason: >
|
|
{% if not in_window and not is_balanced %}out-of-window and imbalanced
|
|
{% elif not in_window %}out-of-window magnitude
|
|
{% elif not is_balanced %}imbalanced across legs
|
|
{% else %}no net change{% endif %}
|
|
- choose:
|
|
- conditions: "{{ verbose_diagnostics and magnitude >= 50 }}"
|
|
sequence:
|
|
- choose:
|
|
- conditions: "{{ notify_target | length > 0 }}"
|
|
sequence:
|
|
- service: notify.{{ notify_target }}
|
|
data:
|
|
message: >
|
|
Ignored power edge: delta1 {{ delta1 | round(0) }}W / delta2
|
|
{{ delta2 | round(0) }}W, combined {{ combined_delta | round(0) }}W,
|
|
magnitude {{ magnitude | round(0) }}W, balance
|
|
{{ balance_pct | round(1) }}% - {{ reason | trim }}; no state
|
|
change.
|
|
default:
|
|
- service: persistent_notification.create
|
|
data:
|
|
title: Stove Activity Detector
|
|
message: >
|
|
Ignored power edge: delta1 {{ delta1 | round(0) }}W / delta2
|
|
{{ delta2 | round(0) }}W, combined {{ combined_delta | round(0) }}W,
|
|
magnitude {{ magnitude | round(0) }}W, balance
|
|
{{ balance_pct | round(1) }}% - {{ reason | trim }}; no state
|
|
change.
|
|
|
|
# ------------------------------------------------------------------
|
|
# CYCLE TIMEOUT: stayed in probably_on with no matching activity -> OFF.
|
|
# ------------------------------------------------------------------
|
|
- conditions: "{{ trigger.id == 'cycle_timeout' }}"
|
|
sequence:
|
|
- service: input_select.select_option
|
|
target:
|
|
entity_id: "{{ state_helper }}"
|
|
data:
|
|
option: "off"
|
|
- choose:
|
|
- conditions: "{{ verbose_diagnostics }}"
|
|
sequence:
|
|
- choose:
|
|
- conditions: "{{ notify_target | length > 0 }}"
|
|
sequence:
|
|
- service: notify.{{ notify_target }}
|
|
data:
|
|
message: >
|
|
No matching stove activity for {{ cycle_timeout_minutes }}m -> OFF
|
|
default:
|
|
- service: persistent_notification.create
|
|
data:
|
|
title: Stove Activity Detector
|
|
message: >
|
|
No matching stove activity for {{ cycle_timeout_minutes }}m -> OFF
|