Home automations are code that controls physical things. When they break, the consequences aren’t a 500 error — they’re a garage door that won’t close in a snowstorm, or window shades that open at 3 AM, or a tablet that charges to 100% and stays plugged in for months until the battery swells.

I run about 20 AppDaemon automations in my homelab, all deployed as Kubernetes ConfigMaps and tested with appdaemontestframework. The testing isn’t academic — it’s caught real bugs that would have been annoying or expensive to discover in production.

Why AppDaemon over HA automations?

Home Assistant’s built-in automations are great for simple trigger-action rules. “When the sun sets, turn on the porch lights.” But anything with branching logic, state tracking, or multiple interacting conditions becomes hard to read and impossible to test.

AppDaemon lets you write automations as Python classes. Each one gets an initialize() method where you set up listeners and timers, and callback methods that fire when things happen. It’s just Python — you can use conditionals, loops, data structures, and (critically) unit tests.

The tradeoff is deployment complexity: you need to run AppDaemon as a separate service alongside Home Assistant, manage the Python files, and wire up the configuration. Kubernetes and Kustomize make this manageable.

The deployment model

Every AppDaemon automation lives as a .py file in the Git repo. Kustomize bundles them into a ConfigMap and mounts them into the AppDaemon container:

# environments/bowerhaus/apps/appdaemon/kustomization.yaml
configMapGenerator:
  - name: appdaemon-config
    files:
      - appdaemon/apps.yaml
      - appdaemon/bedroom_shades.py
      - appdaemon/garage_door_auto_close.py
      - appdaemon/tablet_charger.py
      - appdaemon/doorbell_person_frigate_snapshot.py
      # ... 15+ more

The deployment mounts this ConfigMap at /conf/apps, which is where AppDaemon looks for automation modules:

volumes:
  - name: config-volume
    configMap:
      name: appdaemon-config
containers:
  - name: appdaemon
    volumeMounts:
      - name: config-volume
        mountPath: /conf/apps

The workflow is: edit Python → push to Git → ArgoCD syncs the ConfigMap → AppDaemon picks up the changes. No SSH, no file copying, no manual restarts.

Example: temperature-aware garage doors

This one has saved me real money on heating bills. In cold weather, if a garage door is left open, the automation starts a countdown based on the current temperature:

TemperatureTimeout
Below 30°F7 minutes
Below 40°F15 minutes
Below 50°F30 minutes
50°F+No auto-close

Before closing, it flashes the garage lights to warn anyone who might be in the way:

class GarageDoorAutoClose(hass.Hass):
    def get_timeout_for_temperature(self):
        temp = float(self.get_state(self.temp_sensor, attribute="temperature"))
        if temp < 30:
            return 7 * 60
        elif temp < 40:
            return 15 * 60
        elif temp < 50:
            return 30 * 60
        return None

    def auto_close_door(self, kwargs):
        door = kwargs["door"]
        # Re-check temperature — it might have warmed up during the wait
        if self.get_timeout_for_temperature() is None:
            self.log(f"Temperature now above 50°F — skipping auto-close")
            return
        # Flash lights as warning, then close
        self.flash_lights_then_close(door, ...)

The re-check on close is important. If the door opens at 29°F and warms to 52°F during the 7-minute wait, the auto-close cancels. Temperature thresholds are checked twice — once when the timer starts and once when it fires.

Example: forecast-aware window shades

The bedroom shade automation is the most complex one. It manages motorized shades based on time of day, weather forecast, and thermostat state:

  • Morning: Open at 8:30 AM, unless the forecast high is above 85°F (solar heat gain would fight the AC all day).
  • Midday: If the thermostat starts cooling, close the shades to reduce heat gain. When cooling stops, re-open — but only if it’s before 5 PM.
  • Evening: Close at sunset or 7 PM, whichever comes first.

The same BedroomShades class is reused across rooms with different parameters:

# apps.yaml
bedroom_shades:
  module: bedroom_shades
  class: BedroomShades
  covers:
    - cover.upstairs_master_bedroom_valance
    - cover.upstairs_master_bedroom_south
  thermostat: climate.master_bedroom_thermostat
  forecast_high_threshold: 85
  morning_open_enabled: true

# Guest room: close-only (no morning open, no cooling re-open)
guest_shades:
  module: bedroom_shades
  class: BedroomShades
  covers:
    - cover.upstairs_guest_bedroom_valance
  thermostat: climate.guest_bedroom_thermostat
  morning_open_enabled: false
  forecast_high_threshold: 78

That morning_open_enabled: false flag exists because of a bug. Some rooms’ shades were opening unexpectedly when the AC cycling stopped — the “cooling stopped → re-open” path didn’t check whether the shades were supposed to be auto-opened in the first place. The test that caught this:

@automation_fixture(
    (BedroomShades, {
        "covers": ["cover.guest_south"],
        "thermostat": "climate.guest",
        "morning_open_enabled": False,
    })
)
def guest_shades_app():
    pass

def test_no_reopen_when_morning_open_disabled(given_that, guest_shades_app):
    opens = []
    guest_shades_app.open_shades = lambda reason: opens.append(reason)
    guest_shades_app.sun_down = lambda: False
    guest_shades_app.is_before_reopen_cutoff = lambda: True
    guest_shades_app.closed_for_cooling = True

    guest_shades_app.thermostat_changed(
        "climate.guest", "hvac_action", "cooling", "idle", {}
    )

    assert opens == []  # Shades should NOT have opened

The @automation_fixture decorator from appdaemontestframework instantiates the real app class with mock Home Assistant services injected. You can call any method directly and assert on the side effects. The test above monkey-patches open_shades to record calls instead of actually calling the cover service — pure behavioral testing.

Example: tablet battery management

Three Fire tablets run Fully Kiosk Browser as Home Assistant dashboards around the house. Each one sits on a Shelly smart plug. The automation keeps batteries between 25% and 80%:

class TabletCharger(hass.Hass):
    def battery_check(self, entity, attribute, old, new, kwargs):
        battery = int(float(new))
        plug_on = self.get_state(cfg["plug"]) == "on"

        if battery <= 25 and not plug_on:
            self.turn_on(plug)
        elif battery >= 80 and plug_on:
            self.turn_off(plug)

    def periodic_check(self, kwargs):
        # Every 5 minutes, catch edge cases the state listener might miss
        for name, cfg in self.tablets.items():
            battery = int(float(self.get_state(cfg["battery"])))
            if battery >= 100 and self.get_state(cfg["plug"]) == "on":
                self.turn_off(cfg["plug"])  # Hard cutoff at 100%

The state listener handles the normal case. The periodic check is a safety net — if a state change event is missed (HA restart, network hiccup), the 5-minute sweep catches it. The 100% hard cutoff is separate from the 80% target because a tablet that jumps from 79% to 100% between state reports would otherwise stay charging indefinitely.

Testing with time travel

Some automations are timer-based — they do something after N hours. The test framework has a time_travel helper that fast-forwards timers without actually waiting:

def test_projector_sends_notification_after_6_hours(
    given_that, theatre_projector_app, time_travel, assert_that
):
    given_that.state_of("media_player.theater").is_set_to("on")
    theatre_projector_app.projector_turned_on(
        "media_player.theater", "state", "off", "on", {}
    )

    time_travel.fast_forward(21600)  # 6 hours in seconds
    theatre_projector_app.check_projector_6_hours(
        {"entity": "media_player.theater"}
    )

    assert_that().notification().was_sent_with_message(
        "Projector has been on for 6 hours"
    )

This tests that the projector automation sends a push notification after 6 hours and force-powers-off the projector after 12. Without time travel, you’d have to wait 12 actual hours to verify the behavior.

The testing payoff

The tests have caught three classes of bugs:

  1. Flag interactions. The morning_open_enabled bug above — a flag meant to disable one behavior accidentally gated a different behavior.
  2. Timer state leaks. A cancelled timer’s callback still firing because the handle wasn’t properly cleared.
  3. Edge case arithmetic. Temperature thresholds that didn’t handle None (sensor unavailable) or string values from HA.

None of these would have been caught by “deploy it and watch what happens” — they’re the kind of bugs that only manifest under specific conditions (cold snap + garage door + recently restarted HA) and are nearly impossible to reproduce manually.

Running the tests is just pytest:

cd environments/bowerhaus/apps/appdaemon
pip install appdaemontestframework
pytest tests/

No Kubernetes cluster needed, no Home Assistant instance, no real hardware. The tests run in CI alongside kustomize build validation.

Is it over-engineered?

Maybe. Plenty of people run Home Assistant automations from the UI and never look back. But I manage 20+ automations across six thermostats, three garage doors, five window shade motors, three tablets, and a projector. At that scale, “edit YAML in the HA UI and hope for the best” stops working. Automations interact with each other in ways that are hard to predict without tests, and bugs in physical-world automations have real consequences.

The ConfigMap deployment model means every change goes through Git, gets reviewed in a diff, and can be rolled back. The tests mean I can refactor an automation without manually verifying every scenario. It’s the same discipline we apply to production services — it just happens to control window shades instead of API endpoints.