Office Presence-Aware Lighting — Design¶
Date: 2026-07-13 Status: Approved design — ready for implementation plan Scope: One subsystem (office lighting + the new Aqara FP300 presence sensor). Single implementation plan.
Goal¶
A newly-added Office Presence Sensor (Aqara FP300, Matter mmWave) should control all lights in Louis' Office:
- No presence → turn every office light off.
- Presence detected → restore the lights to exactly how they were left.
The existing per-light schedules/renderers are the steady state ("default state"). Presence layers on top as a remember-and-restore gate:
- If a light was off when you were present, you leave, and come back → it stays off.
- If you turn a light on, leave, and come back → it comes back on.
The sensor¶
Aqara FP300 presence multi-sensor, paired over Matter (Thread). Relevant entities:
| Entity | Role |
|---|---|
binary_sensor.louis_office_office_presence_sensor_occupancy | Occupancy — the presence signal (on/off) |
number.louis_office_office_presence_sensor_hold_time | On-device hold time (keeps occupied for N seconds after last detection) — tunable debounce |
sensor.louis_office_office_presence_sensor_illuminance | Room lux (not used in this design — see Out of Scope) |
mmWave detects micro-motion (breathing, small shifts), so a seated, still person stays occupied — unlike a PIR. Debounce is handled at the device via hold_time, reinforced by a for: grace in the automation (below).
The office light inventory¶
Grounded from the live area/entity registry. Two classes, because several office lights are self-driving (their own automations re-assert them on timers):
Plain / manually-controlled — snapshot & restore exact state:
| Light | Entity | Steady-state owner |
|---|---|---|
| Salt lamp | switch.office_plug_1 | Schedule: on at sunset → off at midnight |
| TOU price bulb | light.louis_office_sengled_a19_bulb_1 | TOU-color automation (recolors an already-on bulb only) |
| SOFI ticker bulb | light.louis_office_sengled_a19_bulb_2 | SOFI-ticker automation (turns bulb on every 5 min during market hours) |
Update 2026-07-13: the two
light.louis_office_govee_e12_1/2entities were removed — they were duplicate govee2mqtt registrations of two of the three Office Lamp (H600B) heads, not separate fixtures. Dropped from the snapshot/turn-off lists; the plain-light set is now three.
Self-driving Govee ambiance lamps — gated & re-rendered (NOT snapshotted):
| Light | Entities | Steady-state owner |
|---|---|---|
| Corner Lamp (H607C) | switch.corner_lamp_power_switch + light.corner_lamp_segment_001..016 | script.corner_lamp_render |
| Office Lamp (H600B ×3) | light.office_lamp_bulb_1/2/3 + switch.office_lamp_bulb_*_power_switch | script.office_lamp_render |
The Govee lamps are excluded from the snapshot because their animated scene (set via rest_command.govee_scene) isn't captured by scene.create (which only records on/off/brightness/color). Their "restore" is re-running the renderer, which reapplies the correct current scene. A Govee lamp the user wants held off is handled by its existing manual toggle (input_boolean.corner_lamp_manual / office_lamp_manual), which already outranks everything.
Approach (chosen)¶
Occupancy gate + snapshot/restore. A single debounced signal, input_boolean.office_occupied, is the one source of truth for "is the office occupied." Every office-light controller respects it.
Rejected alternatives:
- Pure snapshot/restore, no gate — fails the requirement: SOFI (every 5 min) and the lamp renderers (5-min tick) re-light themselves while you're away.
- Gate only, no snapshot — a light you manually turned off would come back on on return (schedule/renderer resumes), and a purely-manual light that was on would be lost. Violates the remember-exact-state rule.
Components¶
1. New helper — input_boolean.office_occupied¶
Defined in configuration.yaml (version-controlled, deploys via CI). The debounced office-presence signal, owned solely by the presence automation. No initial: (state persists across restarts; the HA-start handler re-syncs it).
2. New automations — "Office Presence — …" (automations.yaml)¶
Implemented as three focused sibling automations (one per trigger, for single-responsibility clarity and easy testing): office_presence_vacant_off, office_presence_occupied_restore, office_presence_startup_sync.
Trigger A — occupancy → off, for: "00:01:00" (grace, on top of device hold_time): 1. input_boolean.office_occupied → off 2. scene.create with snapshot_entities = the 5 plain lights → creates scene.office_presence_snapshot 3. homeassistant.turn_off the 5 plain lights (salt lamp, TOU bulb, SOFI bulb, E12 ×2) 4. Call script.corner_lamp_render and script.office_lamp_render (they now see office_occupied == off → off)
Trigger B — occupancy → on (immediate, no grace): 1. input_boolean.office_occupied → on 2. scene.turn_on scene.office_presence_snapshot (restores the 5 plain lights to exact last state) 3. Call script.corner_lamp_render and script.office_lamp_render (they resume the current scene)
(SOFI and TOU self-correct on their own timers once occupied — SOFI on its next ≤5-min tick, TOU on its next rate change — so no explicit re-kick is needed. The snapshot already gives immediate exact-state feedback.)
Correction (#92, 2026-07-21): the parenthetical above was wrong for the TOU bulb and caused a bug. The TOU-color automation (§37) only recolors an already-on bulb — it can't turn it on, so it does not self-correct. Snapshotting bulb_1 meant its state drifted to
off(via Good Night, or anunknownsnapshot after a restart → the restore is skipped) and nothing re-lit it on entry. Fix: the TOU bulb is treated like the SOFI bulb — removed from the snapshot and turned on unconditionally on the occupied edge (and at startup when occupied), then §37 tints it. Only the salt lamp is now snapshot/restored.
Trigger C — homeassistant start (re-sync after a restart): 1. Set input_boolean.office_occupied = current occupancy sensor state. 2. If occupied → call the two render scripts (resume). If vacant → turn off the 5 plain lights + call the render scripts (all off). No snapshot/restore (nothing meaningful to restore at boot).
mode: queued, max: 3 — a rapid vacant/occupied flip should resolve to the latest state in order; restart is avoided because it could abort a scene.create snapshot mid-write.
3. Vacant branch in the two render scripts (scripts.yaml)¶
Add one new choose branch to both corner_lamp_render and office_lamp_render, inserted after the "asleep → off" branch and before the "severe weather" branch:
# 5b. Office vacant → off (ranked below alarm/leak so alerts still show,
# above severe-weather/ambiance so a storm/scene never lights an empty office).
- conditions: "{{ is_state('input_boolean.office_occupied','off') }}"
sequence:
- action: script.corner_lamp_off # office_lamp_off in the office renderer
data: { token: "off:vacant" }
Placement rationale — the existing stack is: manual → alarm → leak → movie → away → asleep → [vacant here] → severe-weather → ambiance default. Below alarm/leak means a house-wide alert still displays on the lamp even when the office is empty (matches the "safety beats ambiance" principle already in the system). Above severe-weather/default means an empty office is never lit by a storm animation or the ambiance scene. The off:vacant token feeds the existing dedup (input_text.*_last_render) so a re-render doesn't re-send.
4. One-line gates on the two re-asserting automations (automations.yaml)¶
Salt Lamp — On at Sunset— add condition{{ is_state('input_boolean.office_occupied','on') }}so sunset never lights an empty office. (Salt Lamp — Off at Midnightunchanged — turning off is always fine.)SOFI Ticker — Stock Color— add the same condition so its 5-min tick doesn't re-light the ticker bulb while vacant. When vacant the bulb is already off from the snapshot; when occupied it runs normally.
(The TOU-color automation needs no gate: it only acts on an already-on bulb, so with the bulb off during vacancy it can't re-light it.)
5. Render-trigger robustness (automations.yaml)¶
Add input_boolean.office_occupied to the trigger lists of automation.corner_lamp_render_triggers and automation.office_lamp_render_triggers, so any path that changes occupancy re-renders the lamps even outside the presence automation. (Belt-and-suspenders; the presence automation already calls the scripts directly.)
Behavior — per light¶
| Office light | While vacant | On return |
|---|---|---|
| Salt lamp | off; sunset-on gated | restored to exact last state |
| TOU price bulb | off (its automation can't re-light an off bulb) | restored; recolors on next rate change |
| SOFI ticker | off; ticker gated | restored; self-corrects on next ≤5-min tick |
| Govee E12 ×2 | off | restored to exact last state |
| Corner Lamp | off (vacant branch) | renderer resumes current sun-phase scene |
| Office Lamp | off (vacant branch) | renderer resumes |
Behavior — walkthroughs¶
- User's example 1 (off stays off): salt lamp off while present → leave → snapshot records off, all-off → return → restore = off. ✓
- User's example 2 (on comes on): E12 on while present → leave → snapshot records on, all-off → return → restore = on. ✓
- Alarm/leak while empty: vacant branch is below alarm/leak → the Corner/Office lamp still shows the red alert scene even with the office empty. ✓
- Manual Govee off held: flip
corner_lamp_manualon and paint it off → leave/return → manual outranks vacant, lamp stays as painted. ✓ - Watching a movie: you're present (mmWave sees you) → not vacant → movie branch (already in the stack) governs the lamp normally. ✓
Known / intended quirks (documented, accepted)¶
- HA restart while away: the runtime
scene.office_presence_snapshotis lost on restart. Trigger C turns the plain lights off (vacant) but has nothing to restore; on return they stay off until their schedule fires or you turn them on. Normal (no-restart) operation is unaffected. - Away across sunset: the salt-lamp sunset-on is gated, so it won't light an empty office; on return you get your last state (exact-restore semantics), not a freshly-scheduled "on." It catches up at the next sunset you're present for, or a tap. This is the direct consequence of "remember exact state" winning over "resume schedule," per the approved requirement.
Debounce¶
- Primary: FP300 on-device
hold_time(number.louis_office_office_presence_sensor_hold_time) — tune on the device. - Secondary:
for: "00:01:00"on Trigger A (vacancy) so a brief mmWave dropout while seated doesn't kill the lights. Occupancy (Trigger B) is immediate — walking in lights the room with no delay.
Test-suite / entity registration¶
The suite (tests/) is entity-ref checked against tests/fixtures/entities.txt. New references to register:
binary_sensor.louis_office_office_presence_sensor_occupancy— live Matter entity; refreshentities.txtviamake snapshot, or allow-list until then.input_boolean.office_occupied— exists after theconfiguration.yamldeploy; add viamake snapshotpost-deploy ortests/fixtures/entities_allow.txtwith a comment.scene.office_presence_snapshot— created at runtime byscene.create, so it never appears in the registry until first run → add totests/fixtures/entities_allow.txtwith a justifying comment.- New automation needs a unique
id/alias(pytest enforces uniqueness).
Deploy / CI notes¶
configuration.yamlchange (new helper) → CI does a fullha core restart.automations.yaml+scripts.yamlchanges ride the same push;scripts.yamlis a config file, so the restart covers it.- No new
!includepackage files, so noci.ymlHA_FILES/regex change needed.
Docs to update (same change)¶
docs/lighting.md— add an "Office Presence" section and a row/note in the Full Lighting Schedule.docs/automations.md— technical reference row for the new automation + the vacant branch.- Memory
lighting-schedule-in-manualalready flags that timing/trigger changes must sync the schedule — this is one.
Out of scope (YAGNI)¶
- Illuminance gating ("only turn on if dark") — the existing schedules already handle darkness; the FP300 lux sensor is available but unused here.
- Lindsay's Office / Office Bathroom — separate areas, separate sensors; not part of this.
- Per-person / multi-occupant logic — occupancy is binary.
- Dashboard tile for
office_occupied— can be added later if wanted.