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