Skip to content

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 in automations.yaml (see §1 Leak Detection).
  • Zone map (exact): attic_air_handler_leak_detectorclimate.office ("Office"); 3rd_floor_furnace_leak_detectorclimate.bedroom ("Master Bedroom"); crawl_space_furnace_leak_detectorclimate.dining_room ("Dining Room"). Helper slug = thermostat object_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 (7200 s) 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), one action: 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/alias on every automation (the suite enforces uniqueness).
  • Deploy: touches configuration.yaml (helpers) → CI does a full ha core restart on push to main. 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: off on a zone's Ecobee when its air-handler condensate pan leaks (binary_sensor.{attic_air_handler,3rd_floor_furnace,crawl_space_furnace}_leak_detectorclimate.{office,bedroom,dining_room}), then auto-restore to heat_cool once dry (2h cooldown). This writes hvac_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), restore parallel (a zone's ≤2h cooldown wait must not block another zone — queued would serialize them), override queued.
  • The self-trigger loop is intentional and safe: restore's final set_hvac_mode: heat_cool fires 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_detector starts 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_detector isn'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 main deploys (full ha core restart because configuration.yaml changed). Deploy + make snapshot (to fold the 6 helpers into entities.txt) happen at branch-finish, with the user.