diff --git a/stove-activity-detector.yaml b/stove-activity-detector.yaml new file mode 100644 index 0000000..16b4a76 --- /dev/null +++ b/stove-activity-detector.yaml @@ -0,0 +1,433 @@ +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