Files
blueprints/stove-activity-detector.yaml
rzen bc2c10e2b7 Add stove activity detector blueprint using split-phase power balance heuristic
Experimental first draft: infers electric range activity (off/active/probably_on)
from whole-panel split-phase power alone, since there's no dedicated CT clamp on
the stove circuit. Uses phase-balance (240V loads both legs symmetrically) as the
primary discriminator against 120V kitchen appliances in the same wattage range.
2026-07-02 06:07:22 -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: "{{ abs(combined_delta) }}"
balance_pct: >
{{ abs(delta1 - delta2) / max(abs(delta1), abs(delta2), 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: >
{{ abs(magnitude - signature) / 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