Skip to content

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/2 entities 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_occupiedoff 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_occupiedon 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 an unknown snapshot 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 Midnight unchanged — 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_manual on 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)

  1. HA restart while away: the runtime scene.office_presence_snapshot is 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.
  2. 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; refresh entities.txt via make snapshot, or allow-list until then.
  • input_boolean.office_occupied — exists after the configuration.yaml deploy; add via make snapshot post-deploy or tests/fixtures/entities_allow.txt with a comment.
  • scene.office_presence_snapshot — created at runtime by scene.create, so it never appears in the registry until first run → add to tests/fixtures/entities_allow.txt with a justifying comment.
  • New automation needs a unique id/alias (pytest enforces uniqueness).

Deploy / CI notes

  • configuration.yaml change (new helper) → CI does a full ha core restart.
  • automations.yaml + scripts.yaml changes ride the same push; scripts.yaml is a config file, so the restart covers it.
  • No new !include package files, so no ci.yml HA_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-manual already 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.