HVAC Condensate-Leak Auto-Shutoff (all 3 zones) Implementation Plan¶
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: When an HVAC air-handler condensate pan goes wet, turn off that zone's Ecobee, notify both phones, and auto-restore once dry (2h cooldown) — generalized across all 3 zones via a sensor→thermostat map.
Architecture: Three generalized automations in automations.yaml (shutoff / restore / owner-override), each resolving the target zone from the trigger via a zone_map variable; per-zone state lives in 6 helpers in configuration.yaml whose ids are derived in-template from the thermostat slug. A new structural test covers the helper/zone-map wiring that the template indirection hides from the Tier-3 entity scan.
Tech Stack: Home Assistant modern automation schema (triggers:/conditions:/actions:/action:), Jinja templates, input_boolean/input_datetime helpers, pytest.
Global Constraints¶
- Modern HA schema only:
triggers:(with- trigger: state),conditions:,actions:(with- action: <domain.service>),if:/then:. Match the style already inautomations.yaml(see §1 Leak Detection). - Zone map (exact):
attic_air_handler_leak_detector→climate.office("Office");3rd_floor_furnace_leak_detector→climate.bedroom("Master Bedroom");crawl_space_furnace_leak_detector→climate.dining_room("Dining Room"). Helper slug = thermostatobject_id(office,bedroom,dining_room). - Helpers (6, no
initial:):input_boolean.<slug>_ac_condensate_shutoff,input_datetime.<slug>_ac_condensate_last_restore(has_date: true, has_time: true). - Cooldown: 2h (
7200s) between restores, per zone. Dry debounce: 10 min. FLAG threshold n/a here. - Race-safety invariant: in restore, clear the latch before calling
climate.set_hvac_mode: heat_cool, so the owner-override automation (which keys on the latch being on) does not fire on our own restore. After the cooldown wait, re-check "pan still dry AND latch still on" before restoring. - Notifications: both phones (
notify.mobile_app_louis_iphone+notify.mobile_app_lindsays_iphone), oneaction:each — no notify group (broken in 2026.6). Message names the zone. - Sanctioned HVAC-write exception #2: this is the 2nd place HA controls HVAC (after §9b precool). Must be documented in CLAUDE.md +
projects/climate_system.md, not removed as a rule violation. - Unique
id/aliason every automation (the suite enforces uniqueness). - Deploy: touches
configuration.yaml(helpers) → CI does a fullha core restarton push tomain. Do NOT push during task execution; deploy is handled at branch-finish.
Task 1: Six condensate helpers + helper structural test¶
Files: - Modify: configuration.yaml (append to the input_boolean: block @ line ~75 and the input_datetime: block @ line ~607) - Create: tests/test_condensate_safety.py
Interfaces: - Produces (used by Task 2 automations + the app): input_boolean.{office,bedroom,dining_room}_ac_condensate_shutoff, input_datetime.{office,bedroom,dining_room}_ac_condensate_last_restore.
- [ ] Step 1: Write the failing structural test
Create tests/test_condensate_safety.py:
"""Tier 2 — Condensate Safety helpers + automations (#71).
The per-zone helpers are referenced in automations.yaml only via Jinja (derived from the
thermostat slug), so the Tier-3 literal entity-ref scan can't see them. These structural
checks are the compensating coverage. Uses the `configuration`/`automations` fixtures
from conftest.py.
"""
ZONE_SLUGS = ("office", "bedroom", "dining_room")
SENSOR_TO_CLIMATE = {
"attic_air_handler_leak_detector": "climate.office",
"3rd_floor_furnace_leak_detector": "climate.bedroom",
"crawl_space_furnace_leak_detector": "climate.dining_room",
}
def test_condensate_helpers_defined(configuration):
ib = configuration.get("input_boolean", {}) or {}
idt = configuration.get("input_datetime", {}) or {}
for slug in ZONE_SLUGS:
assert f"{slug}_ac_condensate_shutoff" in ib, f"missing input_boolean for {slug}"
assert f"{slug}_ac_condensate_last_restore" in idt, f"missing input_datetime for {slug}"
assert idt[f"{slug}_ac_condensate_last_restore"].get("has_time") is True
- [ ] Step 2: Run the test to verify it fails
Run: python3 -m pytest tests/test_condensate_safety.py -v Expected: FAIL — missing input_boolean for office.
- [ ] Step 3: Add the 3 input_boolean helpers
Append inside the existing input_boolean: block in configuration.yaml (keep 2-space indent consistent with the block):
# Condensate Safety (#71) — ON while an automation is holding a zone's Ecobee OFF after an
# air-handler condensate leak; cleared on auto-restore or a manual turn-on. No initial: (persists).
office_ac_condensate_shutoff:
name: "Office AC — Condensate Shutoff"
icon: mdi:air-conditioner
bedroom_ac_condensate_shutoff:
name: "Master Bedroom AC — Condensate Shutoff"
icon: mdi:air-conditioner
dining_room_ac_condensate_shutoff:
name: "Dining Room AC — Condensate Shutoff"
icon: mdi:air-conditioner
- [ ] Step 4: Add the 3 input_datetime helpers
Append inside the existing input_datetime: block in configuration.yaml:
# Condensate Safety (#71) — timestamp of each zone's last auto-restore, for the 2h cooldown.
office_ac_condensate_last_restore:
name: "Office AC — Last Condensate Restore"
has_date: true
has_time: true
bedroom_ac_condensate_last_restore:
name: "Master Bedroom AC — Last Condensate Restore"
has_date: true
has_time: true
dining_room_ac_condensate_last_restore:
name: "Dining Room AC — Last Condensate Restore"
has_date: true
has_time: true
- [ ] Step 5: Run the test to verify it passes
Run: python3 -m pytest tests/test_condensate_safety.py -v Expected: PASS (1 passed).
- [ ] Step 6: Lint + commit
Run: make lint (yamllint must stay clean).
git add configuration.yaml tests/test_condensate_safety.py
git commit -m "feat(climate): add 6 condensate-safety helpers + structural test (#71)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
Task 2: The three generalized automations + automation/zone-map tests¶
Files: - Modify: automations.yaml (add a "Condensate Safety" subsection right after automation #2 "Leak Cleared — All Clear Notification", before the next section header) - Modify: tests/test_condensate_safety.py (add automation + zone-map assertions)
Interfaces: - Consumes: the 6 helpers from Task 1. - Produces: automations condensate_leak_hvac_shutoff, condensate_leak_hvac_restore, condensate_leak_hvac_override, each with a zone_map variable (shutoff + restore).
- [ ] Step 1: Add the failing automation-coverage tests
Append to tests/test_condensate_safety.py:
def test_condensate_automations_present(automations):
by_id = {a.get("id"): a for a in automations}
for aid in ("condensate_leak_hvac_shutoff",
"condensate_leak_hvac_restore",
"condensate_leak_hvac_override"):
assert aid in by_id, f"missing automation {aid}"
def test_condensate_zone_map_covers_all_sensors(automations):
by_id = {a.get("id"): a for a in automations}
for aid in ("condensate_leak_hvac_shutoff", "condensate_leak_hvac_restore"):
zmap = by_id[aid]["variables"]["zone_map"]
assert set(zmap.keys()) == set(SENSOR_TO_CLIMATE), f"{aid} zone_map keys mismatch"
for sensor, clim in SENSOR_TO_CLIMATE.items():
assert zmap[sensor]["climate"] == clim, f"{aid}: {sensor} -> wrong climate"
- [ ] Step 2: Run to verify the new tests fail
Run: python3 -m pytest tests/test_condensate_safety.py -v Expected: test_condensate_automations_present and test_condensate_zone_map_covers_all_sensors FAIL (automations absent); test_condensate_helpers_defined still passes.
- [ ] Step 3: Add the three automations
Insert into automations.yaml immediately after the "Leak Cleared — All Clear Notification" automation (id leak_cleared_all_clear), before the next # section header. Paste verbatim:
# ── Condensate Safety ───────────────────────────────────────────────────────────
# Auto-shutoff each HVAC zone's Ecobee on an air-handler condensate leak, with
# dry-detect auto-restore (2h cooldown per zone). Generalized across all 3 zones via
# zone_map. SANCTIONED HVAC-write exception #2 (see projects/climate_system.md).
- alias: "Condensate Leak — HVAC Shutoff"
id: condensate_leak_hvac_shutoff
description: >
An air-handler/furnace condensate pan went wet → turn OFF that zone's Ecobee so it stops
making more condensate, and push both phones once per shutoff episode. Restore is handled
by "Condensate Leak — HVAC Restore". Sanctioned HVAC-write exception.
triggers:
- trigger: state
entity_id:
- binary_sensor.attic_air_handler_leak_detector
- binary_sensor.3rd_floor_furnace_leak_detector
- binary_sensor.crawl_space_furnace_leak_detector
to: "on"
variables:
zone_map:
attic_air_handler_leak_detector: { climate: climate.office, name: "Office" }
3rd_floor_furnace_leak_detector: { climate: climate.bedroom, name: "Master Bedroom" }
crawl_space_furnace_leak_detector: { climate: climate.dining_room, name: "Dining Room" }
zone: "{{ zone_map[trigger.to_state.object_id] }}"
clim: "{{ zone.climate }}"
zname: "{{ zone.name }}"
slug: "{{ clim.split('.')[1] }}"
latch: "{{ 'input_boolean.' ~ slug ~ '_ac_condensate_shutoff' }}"
actions:
- alias: "Turn the zone's Ecobee off (idempotent)"
action: climate.set_hvac_mode
target:
entity_id: "{{ clim }}"
data:
hvac_mode: "off"
- alias: "Latch + notify once per episode"
if:
- condition: template
value_template: "{{ is_state(latch, 'off') }}"
then:
- action: input_boolean.turn_on
target:
entity_id: "{{ latch }}"
- alias: "Notify Louis"
action: notify.mobile_app_louis_iphone
data:
title: "🌡️ {{ zname }} AC turned off"
message: >
Condensate leak at the {{ zname }} air handler — turned the AC OFF so it stops
making water. It'll auto-restore once the pan is dry (≥2h between restarts).
Worth checking the drain line.
data:
tag: "condensate_{{ slug }}"
- alias: "Notify Lindsay"
action: notify.mobile_app_lindsays_iphone
data:
title: "🌡️ {{ zname }} AC turned off"
message: >
Condensate leak at the {{ zname }} air handler — turned the AC OFF so it stops
making water. It'll auto-restore once the pan is dry (≥2h between restarts).
Worth checking the drain line.
data:
tag: "condensate_{{ slug }}"
mode: queued
max: 10
- alias: "Condensate Leak — HVAC Restore"
id: condensate_leak_hvac_restore
description: >
That zone's condensate pan has been dry for 10 min → after a ≥2h cooldown since the zone's
last restore, put the Ecobee back to heat_cool. Aborts if the pan re-flooded or the owner
already turned it back on during the wait. Sanctioned HVAC-write exception.
triggers:
- trigger: state
entity_id:
- binary_sensor.attic_air_handler_leak_detector
- binary_sensor.3rd_floor_furnace_leak_detector
- binary_sensor.crawl_space_furnace_leak_detector
to: "off"
for:
minutes: 10
variables:
zone_map:
attic_air_handler_leak_detector: { climate: climate.office, name: "Office" }
3rd_floor_furnace_leak_detector: { climate: climate.bedroom, name: "Master Bedroom" }
crawl_space_furnace_leak_detector: { climate: climate.dining_room, name: "Dining Room" }
zone: "{{ zone_map[trigger.to_state.object_id] }}"
clim: "{{ zone.climate }}"
zname: "{{ zone.name }}"
slug: "{{ clim.split('.')[1] }}"
latch: "{{ 'input_boolean.' ~ slug ~ '_ac_condensate_shutoff' }}"
ts: "{{ 'input_datetime.' ~ slug ~ '_ac_condensate_last_restore' }}"
sensor_id: "{{ trigger.to_state.entity_id }}"
conditions:
- condition: template
value_template: "{{ is_state(latch, 'on') }}"
actions:
- alias: "Wait out the 2h cooldown since this zone's last restore"
wait_template: "{{ now().timestamp() - (state_attr(ts, 'timestamp') | float(0)) >= 7200 }}"
- alias: "Abort if the pan re-flooded or the owner cleared the latch during the wait"
condition: template
value_template: "{{ is_state(sensor_id, 'off') and is_state(latch, 'on') }}"
- alias: "Clear the latch FIRST (so owner-override doesn't fire on our own restore)"
action: input_boolean.turn_off
target:
entity_id: "{{ latch }}"
- alias: "Stamp this restore time for the cooldown"
action: input_datetime.set_datetime
target:
entity_id: "{{ ts }}"
data:
timestamp: "{{ now().timestamp() }}"
- alias: "Put the Ecobee back to heat_cool"
action: climate.set_hvac_mode
target:
entity_id: "{{ clim }}"
data:
hvac_mode: "heat_cool"
- alias: "Notify Louis — restored"
action: notify.mobile_app_louis_iphone
data:
title: "✅ {{ zname }} AC restored"
message: "{{ zname }} condensate pan is dry again — AC back on (heat/cool)."
data:
tag: "condensate_{{ slug }}"
- alias: "Notify Lindsay — restored"
action: notify.mobile_app_lindsays_iphone
data:
title: "✅ {{ zname }} AC restored"
message: "{{ zname }} condensate pan is dry again — AC back on (heat/cool)."
data:
tag: "condensate_{{ slug }}"
mode: parallel
max: 10
- alias: "Condensate Leak — Owner Override Clear"
id: condensate_leak_hvac_override
description: >
Someone manually turned a zone's Ecobee back on while we were holding it off for a condensate
leak → clear that zone's shutoff latch so the pending auto-restore aborts (its post-wait
re-check sees the latch off). Does NOT fire on our own restore, which clears the latch before
setting heat_cool.
triggers:
- trigger: state
entity_id:
- climate.office
- climate.bedroom
- climate.dining_room
variables:
slug: "{{ trigger.to_state.object_id }}"
latch: "{{ 'input_boolean.' ~ slug ~ '_ac_condensate_shutoff' }}"
zname_map: { office: "Office", bedroom: "Master Bedroom", dining_room: "Dining Room" }
zname: "{{ zname_map[slug] }}"
conditions:
- condition: template
value_template: >
{{ trigger.to_state.state not in ['off', 'unavailable', 'unknown']
and is_state(latch, 'on') }}
actions:
- action: input_boolean.turn_off
target:
entity_id: "{{ latch }}"
- alias: "Notify Louis — override"
action: notify.mobile_app_louis_iphone
data:
title: "{{ zname }} AC back on"
message: "{{ zname }} AC was turned back on manually — condensate auto-restore cancelled."
data:
tag: "condensate_{{ slug }}"
- alias: "Notify Lindsay — override"
action: notify.mobile_app_lindsays_iphone
data:
title: "{{ zname }} AC back on"
message: "{{ zname }} AC was turned back on manually — condensate auto-restore cancelled."
data:
tag: "condensate_{{ slug }}"
mode: queued
max: 10
- [ ] Step 4: Run the structural tests + full pytest
Run: python3 -m pytest tests/test_condensate_safety.py -v Expected: PASS (all 3 tests). Then make lint && make pytest — yamllint clean, entire suite green (the Tier-3 entity-ref test is unaffected — the sensors/thermostats are real and the helpers are template-hidden).
- [ ] Step 5: Sanity-check the YAML parses as HA expects
Run: python3 -c "import yaml,sys; d=yaml.safe_load(open('automations.yaml')); ids=[a.get('id') for a in d]; assert ids.count('condensate_leak_hvac_shutoff')==1 and ids.count('condensate_leak_hvac_restore')==1 and ids.count('condensate_leak_hvac_override')==1; print('3 condensate automations, ids unique; total automations:', len(d))" Expected: prints the count, no assertion error. (Full check_config runs in CI — Tier 1 needs Docker.)
- [ ] Step 6: Commit
git add automations.yaml tests/test_condensate_safety.py
git commit -m "feat(climate): condensate-leak auto-shutoff/restore for all 3 HVAC zones (#71)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
Task 3: Docs (exception + manual/reference) + final gate¶
Files: - Modify: CLAUDE.md (Climate System: record HVAC-write exception #2; Architecture/automations list: note the Condensate Safety automations) - Modify: projects/climate_system.md (document the exception, zone map, and 3 automations) - Modify: docs/notifications.md (the 3 new pushes) - Modify: docs/automations.md (technical reference for the new subsection)
Interfaces: none — documentation + final verification.
- [ ] Step 1: Update CLAUDE.md
In the Climate System section, find the line stating the precool is "the only place HA writes HVAC setpoints — do not add others" and amend it to record a second sanctioned exception. Add (match surrounding prose):
Second sanctioned HVAC-write exception (#71): the "Condensate Safety" automations set
hvac_mode: offon a zone's Ecobee when its air-handler condensate pan leaks (binary_sensor.{attic_air_handler,3rd_floor_furnace,crawl_space_furnace}_leak_detector→climate.{office,bedroom,dining_room}), then auto-restore toheat_coolonce dry (2h cooldown). This writeshvac_mode, not a setpoint. Do not remove it as a rule violation.
Also add a one-line "Condensate Safety" entry to the automations architecture list (wherever the numbered automation sections are summarized), noting the 3 zones + auto-restore.
- [ ] Step 2: Update
projects/climate_system.md
Add a "Condensate Safety" section documenting: the zone map (table), the 3 automations (shutoff/restore/override) and their modes, the 2h cooldown + 10-min dry debounce, the latch/timestamp helpers, and that it is HVAC-write exception #2. Read the existing precool documentation in that file first and mirror its depth/style.
- [ ] Step 3: Update
docs/notifications.md
Add entries (match the page's existing alert-copy style — read a nearby alert first) for the 3 new household pushes, per zone: - "{Zone} AC turned off" — condensate leak, AC turned off, will auto-restore when dry; check the drain line. - "{Zone} AC restored" — pan dry again, AC back on. - "{Zone} AC back on" — you turned it back on manually, auto-restore cancelled.
- [ ] Step 4: Update
docs/automations.md
Add a technical "Condensate Safety" subsection (mirror the file's per-section style): trigger sensors, the zone map, the shutoff→restore→override flow, cooldown/debounce, and the sanctioned-exception note.
- [ ] Step 5: Full gate
Run: make lint && make pytest Expected: yamllint clean; all pytest green (incl. tests/test_condensate_safety.py). make check-config (Tier 1) runs in CI. If docs/** feeds MkDocs strict, avoid broken cross-page links (the docs.yml build runs mkdocs build --strict on push).
- [ ] Step 6: Commit
git add CLAUDE.md projects/climate_system.md docs/notifications.md docs/automations.md
git commit -m "docs(climate): document condensate-safety automations + HVAC-write exception #2 (#71)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
Notes for the implementer¶
- Why per-zone modes differ: shutoff
queued(fast; serialize to avoid a double-notify race on sensor flap), restoreparallel(a zone's ≤2h cooldown wait must not block another zone —queuedwould serialize them), overridequeued. - The self-trigger loop is intentional and safe: restore's final
set_hvac_mode: heat_coolfires the override trigger, but restore already cleared the latch, so override's condition (latch == on) is false → no-op. Never remove the "clear latch before heat_cool" ordering. 3rd_floor_furnace_leak_detectorstarts with a digit — valid as a YAML mapping key and object_id (parses as a string). Don't quote-mangle it.- Crawl zone may be dormant: if
binary_sensor.crawl_space_furnace_leak_detectorisn't physically reporting, its zone simply never triggers — no error, no code change needed when it comes online. - Do NOT push during tasks. Pushing to
maindeploys (fullha core restartbecauseconfiguration.yamlchanged). Deploy +make snapshot(to fold the 6 helpers intoentities.txt) happen at branch-finish, with the user.