Automations
Automations are organized into feature packages under packages/. Each package owns its own automations, scripts, and helper entities.
Entertainment state machine
The media package implements a state machine for four entertainment modes: TV, Movie, Game, and Hygge. Only one can be active at a time, and each is represented by an input_boolean. The "Master Orchestrator" automation owns every transition between them.
Dynamic lighting during playback:
- When a video starts playing, lights dim to a minimum
- When playback pauses, lights brighten slightly so it's easy to move around — but only after sunset
- When all entertainment modes are turned off, lighting returns to the normal adaptive state
The logic tracks both the TV's device state and the Plex client state to handle edge cases reliably.
Sonos night sound is also managed here: enabled automatically at 21:00 and disabled at 07:00.
Master Orchestrator
Nothing outside the automation runs a scene directly. Every route ends by switching a mode's input_boolean on, and that re-enters the same automation on a different trigger, which is where the other three are switched off and the scene script runs:
flowchart TD
live["TV source → Live TV"] --> bTV["TV mode on"]
shield["TV source → SHIELD<br/>or Shield starts playing"] --> bMovie["Movie mode on"]
ps5["TV source → PlayStation 5"] --> bGame["Game mode on"]
manual["Dashboard switch"] --> bTV & bMovie & bGame & bHygge["Hygge mode on"]
bTV & bMovie & bGame & bHygge --> excl["Switch the other three off"]
excl --> scene["Run that mode's scene script"]
off["TV off for 3 s"] --> reset["Reset all four modes<br/>lighting returns to adaptive"] That indirection is what keeps the modes mutually exclusive with one implementation instead of four. A source change and a tap on the dashboard are the same event by the time exclusivity is enforced, so neither can leave two modes on.
Two guards in the automation are worth reading, because both cover a failure that looks like nothing at all:
conditions:
# The Shield emits state changes constantly while playing — timeline
# updates and other attribute churn. Continue only when the state itself
# changed (playing → paused), not when an attribute did.
- condition: template
value_template: >
{{ trigger.id != 'shield_change' or
trigger.to_state.state != trigger.from_state.state }}
actions:
- choose:
# The Shield starts playing → movie mode, unless the game is on.
- conditions:
- condition: trigger
id: shield_change
- condition: state
entity_id: input_boolean.elokuvatila_paalla
state: "off"
# The Shield can keep reporting "playing" after the TV has been
# switched to the PS5. Without this, game mode is taken over by
# movie mode a moment after it starts.
- condition: state
entity_id: input_boolean.pelitila_paalla
state: "off"
- condition: or
conditions:
- condition: state
entity_id: media_player.shield
state: "playing"
- condition: state
entity_id: media_player.plex_shield
state: "playing"
sequence:
- action: input_boolean.turn_on
target:
entity_id: input_boolean.elokuvatila_paalla
mode: restart
mode: restart is deliberate: if a new trigger arrives while the automation is still running, it starts over rather than queueing. A queued run would apply a scene for a mode that has already been superseded.
The sources are matched by name rather than by port, because the TV reports them over CEC — HDMI 1 is not a value it ever produces. The PS5 is matched against two names, since the TV reports it sometimes by its CEC name and sometimes by the entry in its own source list.
Plex vs. Shield priority
During movie mode, playback state is read from both the Shield media player and the dedicated Plex integration. Plex takes priority: if the Plex entity says paused, that wins even if the Shield entity reports playing. This avoids a false "watching" state when Plex is paused but the Shield hasn't updated yet.
- choose:
# Plex playing → dim lights
- conditions:
- condition: state
entity_id: media_player.plex_shield
state: playing
sequence:
- action: script.apply_movie_viewing_lighting
# Plex paused → brighten lights
- conditions:
- condition: state
entity_id: media_player.plex_shield
state: paused
sequence:
- action: script.apply_movie_paused_lighting
# Shield playing, but NOT via Plex → dim lights
- conditions:
- condition: state
entity_id: media_player.shield
state: playing
- condition: not
conditions:
- condition: state
entity_id: media_player.shield
attribute: app_name
state: "Plex"
sequence:
- action: script.apply_movie_viewing_lighting
Adaptive Lighting
The Adaptive Lighting custom component adjusts brightness and colour temperature throughout the day, following the sun — cool and bright at midday (5500 K), warm and dim in the evening (2000 K), so the lighting supports the body's circadian rhythm instead of working against it. Activating an entertainment mode disables adaptive lighting for the duration, giving full manual control over the scene.
Lights are grouped into Zigbee groups where possible to avoid the "popcorn effect" (lights turning on one by one with a delay).
Clamping the sun to sane hours
At Helsinki's latitude the real sun is a poor scheduler. Around midsummer it rises before 04:00 and sets close to 23:00; at the winter solstice it rises after 09:00 and is gone by 15:15. Following it literally would ramp the lights to full daylight in the middle of a June night, and let them fade to warm and dim in the early afternoon in December — exactly when you want the opposite.
Both zones therefore clamp the virtual sunrise and sunset into a fixed window:
# Living areas
min_sunrise_time: "06:00:00" # earliest the lights may start waking up
max_sunrise_time: "08:00:00" # latest — dark winter mornings still get a sunrise
min_sunset_time: "18:30:00" # keeps usable light into the evening through the dark months
max_sunset_time: "21:00:00" # forces a wind-down even when it's still bright outside
The bedroom uses the same idea shifted half an hour later (06:30–08:30 and 19:00–21:30), plus a 30-minute sunrise_offset so it begins its dawn simulation before the living areas do.
The result is that the daily curve keeps roughly the same shape year-round: the lights always have a morning, a bright middle, and an evening, regardless of what the sun is actually doing. Sleep mode drops the whole thing to 1 % brightness at 2000 K.
Blueprints, and the controller/hook split
The Zigbee remotes are the one part of the lighting setup with no automation written out for it. They use the Awesome HA Blueprints collection, which splits "this remote controls that light" into two automations that know nothing about each other:
- A Controller binds the physical device — a Philips Hue Dimmer v2 here — and turns its button events into an abstract set of actions, recording the last one in a helper
input_textentity. - A Hook binds a target, a light, and names the controller model it is listening for.
Seven controller/hook pairs cover the flat. What the split buys is that each side changes without the other: swapping a remote for a different model replaces the Controller and leaves the Hook alone, and pointing the same remote at a different light is a change to the Hook alone. It is the same shape as routing the entertainment modes through a boolean rather than calling scenes directly — one implementation, many bindings.
The low-battery automation further down uses a different one: sbyx's Low battery level detection & notification for all battery sensors, which is where its unavailable behaviour comes from.
Air quality
Two Apollo Air-1 sensors monitor CO2, PM2.5, and VOC levels — one in the bedroom and one in the office. When CO2 exceeds 1000 ppm for more than 5 minutes:
- A notification is sent
- The sensor's RGB LED turns orange as a local visual indicator
The alert only fires when someone is home and the room isn't in sleep mode.
Water leak detection
Three Aqara leak sensors cover the places where a leak would otherwise go unnoticed for a long time: behind the fridge, under the washbasin, and in the bathroom. This is the one automation here where failure has a real cost, so it's built differently from the rest.
Escalation is ordered by reach, and only the last step is conditional. Email and a mobile push fire unconditionally; the spoken announcement over every speaker in the flat fires only when someone is home. That ordering is the point — remote notification matters most precisely when nobody is there, and an audible alarm in an empty flat accomplishes nothing.
flowchart TD
leak["Any of three leak sensors<br/>wet for 1 minute"] --> email["Email<br/>all three sensor states in the body"]
leak --> push["Phone push<br/>ttl 0 · priority high"]
leak --> home{"Anyone home?"}
home -->|yes| tts["Set every speaker to 40%<br/>spoken alert in the room's own name"]
home -->|no| quiet["Nothing spoken —<br/>the push has already left"] - action: notify.mobile_app_s24
data:
data:
ttl: 0 # deliver now or not at all
priority: high # wake the device out of doze
title: "VESIVUOTO!"
Those two fields are doing real work. A normal-priority Android push can sit undelivered while the phone is dozing, and a leak alert that arrives forty minutes late is close to useless. ttl: 0 refuses to queue it and priority: high wakes the device. The trigger also carries a one-minute for: debounce, so a momentary sensor blip doesn't raise an alarm.
One quirk is worth recording because it fails quietly: the SMTP notifier renders only message, title and target — any data.data keys are silently discarded. Sensor details therefore have to be built into the message body itself. A configuration that looks correct and drops half of what you gave it is a recurring shape in this lab, and this is the version of it that would matter at 3 am.
The sensor that watches the sensors
Those three sensors are the leak detection, and all three run on batteries. A flat battery removes the coverage without removing the appearance of it — the automation stays enabled, the dashboard looks normal, and nothing is watching that corner of the flat any more.
A low-battery automation covers that, using sbyx's blueprint to notify at 20% across every battery sensor in the system. It's a deadman switch for the safety layer, in the same sense as the backup metrics: the failure being guarded against is silence, and silence needs something else to notice it.
It has one hole, and it's worth naming precisely. The blueprint selects sensors with 0 <= state | int(-1) < threshold. A sensor that has dropped off the network reports unavailable, which converts to -1, fails the 0 <= bound, and is skipped. So a battery at 12% raises a notification, while a sensor that has gone completely dark does not. The guard is there for a sensible reason — it stops entities that briefly report unknown at startup from firing false alarms — but the consequence is that total failure is quieter than partial failure, which is exactly backwards for the one automation here that protects against water damage.
Sauna notifications
A RuuviTag Pro on the sauna bench reports temperature over Bluetooth LE, and crossing 40 °C sends a notification to the phone plus a spoken announcement over the speakers. The threshold was the easy part; making it fire once was not.
What went wrong: the RuuviTag's BLE connection drops intermittently. When the sensor reappears while the sauna is still above 40 °C, numeric_state reads that as a fresh threshold crossing — unavailable isn't a number, so a dropout breaks the trigger's matched state and the next real reading looks like a new crossing. The notification repeated through the entire cool-down, once for every time the sensor blinked.
The fix is a lock permitting one notification per heating cycle, released only once the sauna has genuinely cooled. Three details make it correct rather than merely working:
- The lock closes before the notifications, not after. The announcement involves TTS over the speakers and takes several seconds; setting the flag first means a second trigger arriving during that window finds the door already shut.
- Release uses hysteresis — below 30 °C for ten minutes, deliberately well clear of the 40 °C trigger, so noise around the threshold can't unlock it in the middle of a sauna.
- Release also fires on Home Assistant startup. A restart while the lock was set would otherwise carry it into the next heating cycle and swallow that notification entirely. It's guarded by a condition requiring a real reading below 30 °C, so an unavailable sensor doesn't release it either.
The last two are the ones worth keeping. The same unavailable behaviour that caused the original bug is what the release condition has to defend against — and a lock without a recovery path only moves the failure from "too many notifications" to "none at all", which is the quieter and therefore worse of the two.
Wake-up routine
A circadian wake-up routine gradually brings up the bedroom light before the alarm time — a 30-minute ramp from 1 % at 2000 K to full daylight at 5500 K, in 60 steps. It reads the next alarm from the Android Companion App.
The follow-up script sets the bedroom Sonos to a low volume and then waits for the phone alarm to actually be dismissed — it watches the alarm sensor until it rolls over to the next alarm time, with a 90-second timeout so a missed update doesn't stall the routine. Only then does it play the spoken morning greeting and hand the rest of the apartment back to Adaptive Lighting, bringing up the kitchen, living room and office lights over a 60-second transition.
The morning greeting
The greeting is generated rather than recorded. A pre-alarm script runs 30 minutes ahead of the ramp, pulls the current weather, hourly forecast, apparent temperature and bedroom CO2 level from Home Assistant's REST API, and asks Gemini Flash to write a short Finnish greeting from them. A second call renders that text to speech, and the resulting WAV is written to www/ so Sonos can fetch it over HTTP.
Generating it early matters: the file is finished well before the alarm fires, so playback never waits on an API call. Every step is marked continue_on_error, so a failed API call costs you the greeting but not the wake-up.
Two smaller details worth noting:
- The media URL points at the Home Assistant host by IP, not
homeassistant.local— Sonos doesn't reliably resolve mDNS names. The URL lives insecrets.yamlso the address isn't hardcoded into the package. - Playback is bracketed by two
wait_templatesteps: one waits for the speaker to start (Sonos buffers for a moment), the other for it to finish. Without the first wait, the "has it stopped?" check passes instantly and the routine talks over itself.
One important quirk: the Companion App must be configured with an allow list in its sensor settings, permitting only the clock app (com.google.android.deskclock). Without this, other apps (calendar events, Tasker) can trigger the wake-up routine unintentionally.
The routine also has a quiet hours guard (22:00–06:00) to prevent it from firing if the sensor sends an incorrect alarm time.
Removing three switches nothing referenced
network.yaml used to define Wake-on-LAN switches for the TV, the NAS and kontti. All three are gone, and the reasoning is recorded in the file rather than in a commit message, because the absence is what a future reader will be puzzled by.
Nothing referenced them — not an automation, not a script, not a dashboard card. Two of the three had no plausible caller either: the NAS and kontti are in practice always on, so waking them is a problem that doesn't arise. The third had never worked at all. The TV's switch never got the entity_id it was assumed to have, because switch.olohuoneen_tv was already taken by an older integration — so it had been sitting in the configuration doing nothing, in a form where nothing would report it.
One piece stayed: the shell command that powers kontti off. It has no switch in front of it any more, which means it is called from Developer Tools → Actions rather than tapped. That is the right trade for something used a few times a year — the entity was the part that needed maintaining, not the command.
My experience
The entertainment state machine took the most work to get right. Tracking playback state requires checking both the Plex server and the Nvidia Shield client, since they don't always agree. Once it was working, though, it's been a stylish addition — lights dimming when a movie starts and brightening on pause feels polished.
The wake-up routine has been a nice way to start the day. The lights gradually brighten before the alarm, and a short morning greeting plays through the speaker — generated with Gemini.
The sauna notification took the most working out. It kept re-firing during cool-down, and the reason wasn't obvious — the RuuviTag's Bluetooth drops occasionally, and when it reappears with the sauna still warm, Home Assistant reads that as a new threshold crossing. An LLM was what got me from noticing the symptom to understanding the cause; the fix itself was small once that was clear.
The water leak alarm has never fired for real, so I test it with a glass of water. With an alarm that has never gone off, that's the only evidence it still works.
AI has made writing automations much easier overall.