Files
blueprints/stove-activity-detector.yaml
T
rzen 591e66b208 Use the abs filter instead of an abs() call in the stove and HVAC blueprints
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
2026-09-06 07:11:26 -04:00

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