Skip to content

Office Lamp + Shared Ambiance Renderer 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: Make the 3-head office lamp (H600B ×3) and the corner lamp (H607C) twin pure-ambiance fixtures driven by one shared schedule; remove the SOFI ticker from the corner lamp.

Architecture: A shared sensor.ambiance_scene (time+weather → logical scene key) and binary_sensor.weather_is_severe are the single source of the ambiance policy. Two renderer scripts (corner_lamp_render, office_lamp_render) run an identical priority stack, each mapping the logical key to its own SKU's scene ids and firing via a generalized rest_command.govee_scene(device, scene_id, param_id).

Tech Stack: Home Assistant YAML (packages, template sensors, rest_command, scripts, automations), Govee Platform API, the repo's tiered test suite, live /api/template render-testing.

Global Constraints

  • Ambiance lamps stay ambient; data lives on the Sengled bulbs. The two Govee lamps show ONLY circadian + weather ambiance + safety alerts (alarm/leak/severe). NO SOFI/TOU/status on either.
  • Clock is ET; time blocks: 05–12 morning · 12–17 afternoon · 17–20 twilight · 20–23 night · 23–05 starry_sky.
  • Logical scene keys (sensor.ambiance_scene values): morning afternoon twilight night starry_skytime-of-day only, NO mild weather (Met.no forecast unreliable; per user decision 2026-07-10).
  • Weather affects the lamps ONLY via binary_sensor.weather_is_severe (lightning/lightning-rainy/pouring/hail/exceptional) → the storm branch. No raining/snowing/dense_fog ambiance keys.
  • Unified priority stack (top wins): 0 manual→yield · 1 alarm→alert · 2 leak→alert · 3 movie-mode (sun.sun below AND media_player.office_apple_tv in playing/paused)→off · 4 person.louis≠home→off · 5 asleep→off · 6 severe→storm · 7 default→ambiance. (No SOFI branch on either lamp.)
  • Everything visible is an animated Govee scene (never a solid color) except off/yield.
  • Device ids: corner 99:49:E7:99:B2:60:F0:8E; office bulbs 07:F6:3C:DC:75:17:45:E8 (1), D2:06:3C:DC:75:14:FF:8C (2), 8D:97:3C:DC:75:11:63:54 (3). One secret: govee_api_key (already on box).
  • Corner (H607C) scene ids: morning 9668/16543 · afternoon 9669/16544 · twilight 9670/16545 · night 9709/16584 · starry_sky 9707/16582 · alarm 9690/16565 (Breathe) · leak 9695/16570 (Dripping) · storm 9705/16580 (Thunderstorm).
  • Office (H600B) scene ids (default map — preview-tune the alert/storm picks): morning 3133/3283 (Morning) · afternoon 1197/1259 (Awaken) · twilight 1172/1234 (Dusk) · night 3130/3280 (Candlelight) · starry_sky 1174/1236 (Starry Sky) · alarm 3128/3278 (Fire) · leak 1176/1238 (River) · storm 3125/3275 (Aurora). H600B has no native storm scene — Aurora is the nearest match.
  • Branch: feat/office-lamp-ambiance (spec committed). TDD-for-YAML: each task's "test" is make lint + (where possible) make check-config, plus live /api/template render-tests for template logic; runtime behavior verified at deploy (Task 8).

File Structure

  • configuration_ambiance_addition.yaml — NEW package: sensor.ambiance_scene + binary_sensor.weather_is_severe.
  • configuration.yaml — register the package under homeassistant.packages; generalize rest_command (→ govee_scene); add input_boolean.office_lamp_manual + input_text.office_lamp_last_render.
  • scripts.yaml — generalize corner_lamp_apply_scene; refactor corner_lamp_render; add office_lamp_apply_scene/office_lamp_off/office_lamp_render; edit wake_house.
  • automations.yaml — refactor corner_lamp_render_triggers; add office_lamp_render_triggers + 2 office webhooks.
  • tests/fixtures/entities_allow.txt — allowlist new helpers/sensors pre-deploy.
  • Docs: projects/smart_lighting_scenes.md, CLAUDE.md, docs/lighting.md, docs/automations.md; corner-lamp spec note.

Task 1: Shared schedule package (sensor.ambiance_scene + binary_sensor.weather_is_severe)

Files: - Create: configuration_ambiance_addition.yaml - Modify: configuration.yaml (add package entry) - Modify: tests/fixtures/entities_allow.txt

Interfaces: - Produces: sensor.ambiance_scene (state = one of the 8 logical keys); binary_sensor.weather_is_severe (on/off).

  • [ ] Step 1: Create the package file

configuration_ambiance_addition.yaml:

# Shared office AMBIANCE schedule — the single source of truth for what calm scene the
# two Govee ambiance lamps (corner + office 3-head) show. Tweak the time blocks / weather
# map HERE and both lamps follow. Loaded via homeassistant.packages.ambiance. Each lamp
# maps sensor.ambiance_scene's logical key to its own SKU scene ids. Severe weather is a
# SEPARATE higher-priority signal (binary_sensor.weather_is_severe), not a scene key here.
template:
  - sensor:
      - name: "Ambiance Scene"
        unique_id: ambiance_scene
        icon: mdi:palette-outline
        state: >
          {% set h = now().hour %}
          {% if 5 <= h < 12 %}{% set block = 'morning' %}
          {% elif 12 <= h < 17 %}{% set block = 'afternoon' %}
          {% elif 17 <= h < 20 %}{% set block = 'twilight' %}
          {% elif 20 <= h < 23 %}{% set block = 'night' %}
          {% else %}{% set block = 'starry_sky' %}{% endif %}
          {% set wx = {'rainy':'raining','snowy':'snowing','snowy-rainy':'snowing','fog':'dense_fog'} %}
          {{ wx.get(states('weather.forecast_home'), block) }}
  - binary_sensor:
      - name: "Weather Is Severe"
        unique_id: weather_is_severe
        icon: mdi:weather-lightning
        state: >
          {{ states('weather.forecast_home') in
             ['lightning','lightning-rainy','pouring','hail','exceptional'] }}

  • [ ] Step 2: Register the package in configuration.yaml

Find the homeassistant: block's packages: key (the TOU package is loaded there). Add:

    ambiance: !include configuration_ambiance_addition.yaml
(If packages: uses !include_dir_named or a different form, match the existing TOU entry's style — the TOU package is configuration_tou_addition.yaml; mirror exactly how it is included.)

  • [ ] Step 3: Allowlist the two new entities

Append to tests/fixtures/entities_allow.txt:

# Shared ambiance schedule (configuration_ambiance_addition.yaml) — registered after the
# next config deploy. Move to entities.txt via `make snapshot` post-deploy.
sensor.ambiance_scene
binary_sensor.weather_is_severe

  • [ ] Step 4: Validate schema

Run: make lint Expected: PASS.

Run: make check-config Expected: PASS — package loads, both template entities parse. (If Docker down, note and rely on CI.)

  • [ ] Step 5: Commit
git add configuration_ambiance_addition.yaml configuration.yaml tests/fixtures/entities_allow.txt
git commit -m "feat(lighting): shared ambiance schedule — sensor.ambiance_scene + binary_sensor.weather_is_severe (#office-lamp)"

Task 2: Generalize the Govee scene rest_command

Files: - Modify: configuration.yaml (rename/parameterize the rest_command) - Modify: scripts.yaml (corner_lamp_apply_scene passes device)

Interfaces: - Consumes: !secret govee_api_key. - Produces: rest_command.govee_scene (data vars: device, scene_id, param_id).

  • [ ] Step 1: Replace the rest_command in configuration.yaml

Find rest_command: govee_corner_lamp_scene: and replace the whole govee_corner_lamp_scene block with:

  # Fire a native Govee scene on ANY account device in one Platform-API call. device is a
  # non-secret identifier; only the key is a !secret. Used by both lamp renderers.
  govee_scene:
    url: https://openapi.api.govee.com/router/api/v1/device/control
    method: POST
    content_type: "application/json"
    headers:
      Govee-API-Key: !secret govee_api_key
    payload: >-
      {"requestId":"ha-govee-scene","payload":{"sku":"{{ sku }}","device":"{{ device }}","capability":{"type":"devices.capabilities.dynamic_scene","instance":"lightScene","value":{"id":{{ scene_id }},"paramId":{{ param_id }}}}}}
Note: sku is now also a variable (H607C vs H600B). Callers pass sku, device, scene_id, param_id.

  • [ ] Step 2: Update corner_lamp_apply_scene in scripts.yaml

In corner_lamp_apply_scene, change the rest_command call to pass sku+device:

        - action: rest_command.govee_scene
          data:
            sku: "H607C"
            device: "99:49:E7:99:B2:60:F0:8E"
            scene_id: "{{ scene_id }}"
            param_id: "{{ param_id }}"
(Leave the rest of corner_lamp_apply_scene — the dedup guard, power switch, token store — unchanged.)

  • [ ] Step 3: Validate

Run: make lint → PASS. Run: make check-config → PASS (rest_command schema valid; corner apply still resolves).

  • [ ] Step 4: Commit
git add configuration.yaml scripts.yaml
git commit -m "refactor(lighting): generalize rest_command.govee_scene(sku,device,scene_id,param_id) (#office-lamp)"

Task 3: Refactor the corner lamp renderer (remove SOFI, consume shared, new gates)

Files: - Modify: scripts.yaml (corner_lamp_render) - Modify: automations.yaml (corner_lamp_render_triggers)

Interfaces: - Consumes: sensor.ambiance_scene, binary_sensor.weather_is_severe, person.louis, media_player.office_apple_tv, sun.sun, script.corner_lamp_apply_scene, script.corner_lamp_off.

  • [ ] Step 1: Replace corner_lamp_render's stack in scripts.yaml

Replace the entire corner_lamp_render sequence: (from - choose: through the circadian default: block) with this stack. The corner_lamp_apply_scene/corner_lamp_off primitives and their calls are unchanged in signature.

  sequence:
    - choose:
        # 0. Manual → yield.
        - conditions: "{{ is_state('input_boolean.corner_lamp_manual','on') }}"
          sequence:
            - action: input_text.set_value
              target: { entity_id: input_text.corner_lamp_last_render }
              data: { value: "manual" }
            - stop: "manual override  renderer yields"
        # 1. Alarm → Breathe.
        - conditions: "{{ is_state('alarm_control_panel.blairmont_alarm','triggered') }}"
          sequence:
            - action: script.corner_lamp_apply_scene
              data: { scene_id: 9690, param_id: 16565, token: "alert:breathe", force: true }
        # 2. Leak → Dripping.
        - conditions: "{{ is_state('binary_sensor.any_leak_wet','on') }}"
          sequence:
            - action: script.corner_lamp_apply_scene
              data: { scene_id: 9695, param_id: 16570, token: "alert:dripping", force: true }
        # 3. Movie mode: dark + office Apple TV playing/paused → off.
        - conditions: >-
            {{ is_state('sun.sun','below_horizon')
               and states('media_player.office_apple_tv') in ['playing','paused'] }}
          sequence:
            - action: script.corner_lamp_off
              data: { token: "off:movie" }
        # 4. Louis away → off.
        - conditions: "{{ not is_state('person.louis','home') }}"
          sequence:
            - action: script.corner_lamp_off
              data: { token: "off:away" }
        # 5. Asleep → off.
        - conditions: "{{ is_state('input_boolean.sleeping','on') }}"
          sequence:
            - action: script.corner_lamp_off
              data: { token: "off:sleep" }
        # 6. Severe weather → Thunderstorm.
        - conditions: "{{ is_state('binary_sensor.weather_is_severe','on') }}"
          sequence:
            - action: script.corner_lamp_apply_scene
              data: { scene_id: 9705, param_id: 16580, token: "weather:storm" }
      # 7. default → shared ambiance scene, mapped to H607C ids.
      default:
        - variables:
            smap:
              morning: [9668, 16543]
              afternoon: [9669, 16544]
              twilight: [9670, 16545]
              night: [9709, 16584]
              starry_sky: [9707, 16582]
            # Fallback guard: sensor.ambiance_scene can be `unknown`/`unavailable` transiently
            # (HA-start window, before the template sensor first evaluates). Fall back to `night`
            # so smap[k] never raises UndefinedError and aborts the render.
            k: "{{ states('sensor.ambiance_scene') if states('sensor.ambiance_scene') in smap else 'night' }}"
        - action: script.corner_lamp_apply_scene
          data:
            scene_id: "{{ smap[k][0] }}"
            param_id: "{{ smap[k][1] }}"
            token: "ambiance:{{ k }}"
Update the renderer's header comment to drop the SOFI/market-ticker language and note it consumes sensor.ambiance_scene.

  • [ ] Step 2: Update corner_lamp_render_triggers in automations.yaml

In the - trigger: state entity_id: list, REMOVE sensor.sofi_change_pct and ADD sensor.ambiance_scene, binary_sensor.weather_is_severe, media_player.office_apple_tv, person.louis. Keep input_boolean.corner_lamp_manual, input_boolean.sleeping, binary_sensor.any_leak_wet, alarm_control_panel.blairmont_alarm. REMOVE zone.home (replaced by person.louis). Keep the sun/time/HA-start triggers; the time_pattern /5 may stay (harmless backstop) or be removed (ambiance changes are driven by sensor.ambiance_scene now) — keep it.

  • [ ] Step 3: Live render-test the new default mapping (before relying on it)

Run:

cd /Users/pk/code/homeassistant
T='{% set smap={"morning":[9668,16543],"afternoon":[9669,16544],"twilight":[9670,16545],"night":[9709,16584],"starry_sky":[9707,16582]} %}{% set key=states("sensor.ambiance_scene") %}key={{ key }} in_map={{ key in smap }} id={{ smap.get(key,[0,0])[0] }}'
jq -n --arg t "$T" '{template:$t}' | curl -s -H "Authorization: Bearer $HA_TOKEN" -H "Content-Type: application/json" -d @- http://homeassistant.local:8123/api/template
Expected: key=<something> in_map=True id=<nonzero>. (Note: sensor.ambiance_scene only exists after Task 1 deploys; if it reads unknown, this step is deferred to Task 8's post-deploy verify — mark it done there.)

  • [ ] Step 4: Validate

Run: make lint → PASS (entity refs resolve; sensor.ambiance_scene/binary_sensor.weather_is_severe via allowlist; person.louis, office_apple_tv, sun in snapshot). Run: make check-config → PASS.

  • [ ] Step 5: Commit
git add scripts.yaml automations.yaml
git commit -m "refactor(lighting): corner lamp pure ambiance — drop SOFI, consume shared schedule, movie-mode + person.louis gates (#office-lamp)"

Task 4: Office helpers + apply/off primitives

Files: - Modify: configuration.yaml (2 helpers) - Modify: scripts.yaml (office_lamp_apply_scene, office_lamp_off) - Modify: tests/fixtures/entities_allow.txt

Interfaces: - Produces: input_boolean.office_lamp_manual; input_text.office_lamp_last_render; script.office_lamp_apply_scene(scene_id, param_id, token, force); script.office_lamp_off(token).

  • [ ] Step 1: Add helpers to configuration.yaml

Under input_boolean: add:

  office_lamp_manual:
    name: "Office Lamp Manual"
    icon: mdi:ceiling-light-multiple
Under input_text: add:
  office_lamp_last_render:
    name: "Office Lamp Last Render"
    icon: mdi:history
    max: 32

  • [ ] Step 2: Add the primitives to scripts.yaml
# Office 3-head lamp render primitives (#office-lamp). Fires the SAME scene to all 3 H600B
# bulbs (one govee_scene call each). Dedupe via input_text.office_lamp_last_render.
office_lamp_apply_scene:
  alias: "Office Lamp  apply scene"
  icon: mdi:ceiling-light-multiple
  mode: queued
  max: 10
  fields:
    scene_id: { description: "Govee scene id", example: 3133 }
    param_id: { description: "Govee scene paramId", example: 3283 }
    token: { description: "Dedupe token", example: "ambiance:morning" }
    force: { description: "Bypass dedupe (alerts)", default: false }
  sequence:
    - if: "{{ (force | default(false)) or states('input_text.office_lamp_last_render') != token }}"
      then:
        - action: switch.turn_on
          target:
            entity_id:
              - switch.office_lamp_bulb_1_power_switch
              - switch.office_lamp_bulb_2_power_switch
              - switch.office_lamp_bulb_3_power_switch
          continue_on_error: true
        - repeat:
            for_each:
              - "07:F6:3C:DC:75:17:45:E8"
              - "D2:06:3C:DC:75:14:FF:8C"
              - "8D:97:3C:DC:75:11:63:54"
            sequence:
              - action: rest_command.govee_scene
                data:
                  sku: "H600B"
                  device: "{{ repeat.item }}"
                  scene_id: "{{ scene_id }}"
                  param_id: "{{ param_id }}"
        - action: input_text.set_value
          target: { entity_id: input_text.office_lamp_last_render }
          data: { value: "{{ token }}" }

office_lamp_off:
  alias: "Office Lamp  off"
  icon: mdi:ceiling-light-multiple
  mode: queued
  max: 10
  fields:
    token: { description: "Dedupe token", example: "off:away" }
  sequence:
    - if: "{{ states('input_text.office_lamp_last_render') != token }}"
      then:
        - action: switch.turn_off
          target:
            entity_id:
              - switch.office_lamp_bulb_1_power_switch
              - switch.office_lamp_bulb_2_power_switch
              - switch.office_lamp_bulb_3_power_switch
          continue_on_error: true
        - action: input_text.set_value
          target: { entity_id: input_text.office_lamp_last_render }
          data: { value: "{{ token }}" }
  • [ ] Step 3: Allowlist the office entities

Append to tests/fixtures/entities_allow.txt:

# Office lamp renderer helpers + bulb entities — registered after deploy / already live via
# govee2mqtt. Fold into entities.txt via `make snapshot` post-deploy.
input_boolean.office_lamp_manual
input_text.office_lamp_last_render
(The light.office_lamp_bulb_*/switch.office_lamp_bulb_*_power_switch are already live in the registry; make snapshot at Task 8 folds them into entities.txt. If Tier-3 fails before then because the switches aren't in entities.txt, add the three switch.office_lamp_bulb_{1,2,3}_power_switch here too.)

  • [ ] Step 4: Validate

Run: make lint → PASS. Run: make check-config → PASS.

  • [ ] Step 5: Commit
git add configuration.yaml scripts.yaml tests/fixtures/entities_allow.txt
git commit -m "feat(lighting): office lamp manual/dedupe helpers + apply/off primitives (3-bulb fan-out) (#office-lamp)"

Task 5: Office lamp renderer

Files: - Modify: scripts.yaml (office_lamp_render)

Interfaces: - Consumes: sensor.ambiance_scene, binary_sensor.weather_is_severe, person.louis, media_player.office_apple_tv, sun.sun, input_boolean.office_lamp_manual, input_boolean.sleeping, script.office_lamp_apply_scene, script.office_lamp_off. - Produces: script.office_lamp_render.

  • [ ] Step 1: Add office_lamp_render to scripts.yaml

Same stack as the refactored corner lamp, mapping to H600B ids, using the office manual flag + office primitives:

# Office 3-head lamp priority renderer (#office-lamp) — pure-ambiance twin of the corner lamp.
# Same stack; H600B scene ids; drives all 3 heads as one. Consumes the shared sensor.ambiance_scene.
office_lamp_render:
  alias: "Office Lamp  render"
  icon: mdi:ceiling-light-multiple
  mode: restart
  sequence:
    - choose:
        - conditions: "{{ is_state('input_boolean.office_lamp_manual','on') }}"
          sequence:
            - action: input_text.set_value
              target: { entity_id: input_text.office_lamp_last_render }
              data: { value: "manual" }
            - stop: "manual override  renderer yields"
        - conditions: "{{ is_state('alarm_control_panel.blairmont_alarm','triggered') }}"
          sequence:
            - action: script.office_lamp_apply_scene
              data: { scene_id: 3128, param_id: 3278, token: "alert:fire", force: true }
        - conditions: "{{ is_state('binary_sensor.any_leak_wet','on') }}"
          sequence:
            - action: script.office_lamp_apply_scene
              data: { scene_id: 1176, param_id: 1238, token: "alert:river", force: true }
        - conditions: >-
            {{ is_state('sun.sun','below_horizon')
               and states('media_player.office_apple_tv') in ['playing','paused'] }}
          sequence:
            - action: script.office_lamp_off
              data: { token: "off:movie" }
        - conditions: "{{ not is_state('person.louis','home') }}"
          sequence:
            - action: script.office_lamp_off
              data: { token: "off:away" }
        - conditions: "{{ is_state('input_boolean.sleeping','on') }}"
          sequence:
            - action: script.office_lamp_off
              data: { token: "off:sleep" }
        - conditions: "{{ is_state('binary_sensor.weather_is_severe','on') }}"
          sequence:
            - action: script.office_lamp_apply_scene
              data: { scene_id: 3125, param_id: 3275, token: "weather:storm" }
      default:
        - variables:
            smap:
              morning: [3133, 3283]
              afternoon: [1197, 1259]
              twilight: [1172, 1234]
              night: [3130, 3280]
              starry_sky: [1174, 1236]
            # Fallback guard: sensor.ambiance_scene can be `unknown`/`unavailable` transiently
            # (HA-start window). Fall back to `night` so smap[k] never raises UndefinedError.
            k: "{{ states('sensor.ambiance_scene') if states('sensor.ambiance_scene') in smap else 'night' }}"
        - action: script.office_lamp_apply_scene
          data:
            scene_id: "{{ smap[k][0] }}"
            param_id: "{{ smap[k][1] }}"
            token: "ambiance:{{ k }}"

  • [ ] Step 2: Validate

Run: make lint → PASS. Run: make check-config → PASS.

  • [ ] Step 3: Commit
git add scripts.yaml
git commit -m "feat(lighting): office lamp priority renderer — ambiance twin (H600B) (#office-lamp)"

Task 6: Office triggers, webhooks, wake_house wiring

Files: - Modify: automations.yaml (new section: triggers + 2 webhooks) - Modify: scripts.yaml (wake_house)

  • [ ] Step 1: Generate two webhook ids

Run: python3 -c "import secrets; print('manual', secrets.token_hex(16)); print('auto', secrets.token_hex(16))" Note as <OFFICE_MANUAL_ID> / <OFFICE_AUTO_ID>.

  • [ ] Step 2: Add automations to automations.yaml (new numbered section, next free header number)
- alias: "Office Lamp  Render Triggers"
  id: office_lamp_render_triggers
  triggers:
    - trigger: state
      entity_id:
        - input_boolean.office_lamp_manual
        - input_boolean.sleeping
        - binary_sensor.any_leak_wet
        - alarm_control_panel.blairmont_alarm
        - person.louis
        - sensor.ambiance_scene
        - binary_sensor.weather_is_severe
        - media_player.office_apple_tv
    - trigger: state
      entity_id: sun.sun
    - trigger: homeassistant
      event: start
  actions:
    - action: script.office_lamp_render
  mode: single

- alias: "Office Lamp  Manual On (webhook)"
  id: office_lamp_manual_webhook_on
  triggers:
    - trigger: webhook
      webhook_id: <OFFICE_MANUAL_ID>
      allowed_methods: [POST, GET]
      local_only: false
  actions:
    - action: input_boolean.turn_on
      target: { entity_id: input_boolean.office_lamp_manual }
  mode: single

- alias: "Office Lamp  Manual Off / Auto (webhook)"
  id: office_lamp_manual_webhook_off
  triggers:
    - trigger: webhook
      webhook_id: <OFFICE_AUTO_ID>
      allowed_methods: [POST, GET]
      local_only: false
  actions:
    - action: input_boolean.turn_off
      target: { entity_id: input_boolean.office_lamp_manual }
    - action: script.office_lamp_render
  mode: single
  • [ ] Step 3: Wire wake_house in scripts.yaml

After the corner-lamp step (which clears corner_lamp_manual + calls script.corner_lamp_render), add:

    - action: input_boolean.turn_off
      target: { entity_id: input_boolean.office_lamp_manual }
    - action: script.office_lamp_render

  • [ ] Step 4: Validate

Run: make lint → PASS (unique automation ids). Run: make check-config → PASS.

  • [ ] Step 5: Commit
git add automations.yaml scripts.yaml
git commit -m "feat(lighting): office lamp render triggers + webhooks + wake_house wiring (#office-lamp)"

Task 7: Documentation + reframe #75

Files: - Modify: projects/smart_lighting_scenes.md, CLAUDE.md, docs/lighting.md, docs/automations.md

  • [ ] Step 1: projects/smart_lighting_scenes.md — add a "Shared ambiance schedule + Office lamp" section: the sensor.ambiance_scene master, the twin renderers, the "ambiance lamps vs data bulbs" principle. Note the corner lamp's SOFI removal.

  • [ ] Step 2: CLAUDE.md — (a) new automation-section entry for the office lamp renderer; (b) update the corner-lamp §-entry: SOFI removed, now consumes sensor.ambiance_scene, movie-mode + person.louis gates; (c) Key Entities: add sensor.ambiance_scene, binary_sensor.weather_is_severe, input_boolean.office_lamp_manual; (d) note the two Sengled bulbs are the TOU+SOFI data pair.

  • [ ] Step 3: docs/lighting.md — GREAT user-facing docs with mermaid diagrams (the headline of this task). mkdocs already supports mermaid (pymdownx.superfences custom fence in mkdocs.yml; docs/architecture.md uses it — follow that style). Rewrite the Corner Lamp section (now pure ambiance, NO SOFI) and add a new Office Lamp (3-head) section, plus a "How the ambiance lamps work" overview. Include at least these three mermaid diagrams:

  • Priority-stack flowchart (flowchart TD): the top-wins decision — manual→yield · alarm→red alert · leak→water alert · movie-mode (dark + office Apple TV)→off · Louis away→off · asleep→off · severe weather→storm · else→time-of-day ambiance. Show it as a decision cascade so a household reader can trace "why is the lamp doing X right now."
  • Shared-schedule architecture (flowchart LR): sensor.ambiance_scene (time-of-day) + binary_sensor.weather_is_severe feeding BOTH corner_lamp_render and office_lamp_render, each mapping to its own Govee SKU scenes. Convey "tune one schedule → both lamps follow."
  • The ambiance-vs-data split (flowchart LR or a small graph): the two nice Govee lamps = ambiance; the two Sengled bulbs = data (TOU + SOFI). Reinforce the guiding principle. Also cover in prose: the time-of-day scene arc (Morning→Afternoon→Twilight→Night→Starry Sky), that weather only shows on genuine storms (severe-only, mild weather ignored), the alert scenes, the movie-mode + Louis-away + wake/sleep gates, and how to take manual control (dashboard toggle / webhook / Govee app) with the auto-restore at morning wake. Keep the household tone (plain language, admonitions/tables) per the existing manual style. Update docs/automations.md with the technical priority-stack reference for both renderers + the shared schedule.

  • [ ] Step 4: Reframe #75 — comment on GitHub issue #75 that the Sengled §19 SOFI ticker is now the intended sole SOFI display (not to be retired); close it or relabel per the board.

  • [ ] Step 5: Validate + commit

Run: mkdocs build --strict → PASS. Run: make lint → PASS.

git add projects/smart_lighting_scenes.md CLAUDE.md docs/lighting.md docs/automations.md
git commit -m "docs(lighting): office lamp + shared ambiance schedule; corner SOFI removed (#office-lamp)"


Task 8: Deploy + post-deploy verification

  • [ ] Step 1: Merge to main + push

git checkout main && git pull --ff-only && git merge --no-ff feat/office-lamp-ambiance -m "feat(lighting): office lamp + shared ambiance renderer (#office-lamp)"
git push
Watch CI: gh run watch $(gh run list --workflow=ci.yml --limit 1 --json databaseId -q '.[0].databaseId') --exit-status. Expect test PASS → deploy-ha full restart. ssh haos "ha core logs --lines 20" clean.

  • [ ] Step 2: Verify the shared sensor + both renderers live (logs + tokens)

ssh haos "ha core logs --lines 150 2>/dev/null | grep -iE 'corner_lamp|office_lamp|ambiance' | grep -iE 'ERROR|WARNING' | tail -10 || echo clean"
curl -s -H "Authorization: Bearer $HA_TOKEN" http://homeassistant.local:8123/api/states/sensor.ambiance_scene | jq -r .state
curl -s -H "Authorization: Bearer $HA_TOKEN" http://homeassistant.local:8123/api/states/input_text.corner_lamp_last_render | jq -r .state
curl -s -H "Authorization: Bearer $HA_TOKEN" http://homeassistant.local:8123/api/states/input_text.office_lamp_last_render | jq -r .state
Expect: no ERROR/WARNING; sensor.ambiance_scene = a logical key; both last_render tokens = ambiance:<key> (or an off/gate token). If a TupleWrapper/undefined error appears, fix per [[ha-template-runtime-gotchas]] and redeploy.

  • [ ] Step 3: Preview-test the office scene map (H600B alert/weather picks are approximations)

Freeze the office renderer (input_boolean.office_lamp_manual on), fire each candidate scene to all 3 bulbs 10s apart (reuse the corner-lamp preview pattern with the H600B ids + 3 device ids), eyeball morning/twilight/night/storm/alarm/leak, then clear manual. Swap any weak pick in office_lamp_render's smap/alert branches and redeploy that line.

  • [ ] Step 4: Verify corner lamp behavior preserved — off-market, input_text.corner_lamp_last_render should be ambiance:<current key> (same scene it showed before the refactor for this time/weather); confirm no SOFI token ever appears again.

  • [ ] Step 5: make snapshot + commit

make snapshot
Remove the 4 allowlisted lines (2 ambiance + 2 office helpers) from tests/fixtures/entities_allow.txt (now in entities.txt); the 12 bulb entities are folded in too.
make lint   # PASS
git add tests/fixtures/entities.txt tests/fixtures/notify_services.txt tests/fixtures/entities_allow.txt
git commit -m "chore(snapshot): ambiance + office lamp entities into snapshot (#office-lamp)" && git push

  • [ ] Step 6: Live spot-check — toggle office_lamp_manual (yields all 3 / resumes); fire the office webhooks; confirm movie-mode (dark + office ATV playing → off) and person.louis-away → off; confirm both lamps show the same ambiance key off-market. Reframe/close #75.

Self-Review

Spec coverage: shared schedule sensor+binary (Task 1) ✓ · generalized rest_command (Task 2) ✓ · corner refactor: drop SOFI, consume shared, person.louis, movie-mode (Task 3) ✓ · office helpers+primitives 3-bulb fan-out (Task 4) ✓ · office renderer twin stack + H600B map (Task 5) ✓ · triggers+webhooks+wake_house (Task 6) ✓ · docs + #75 reframe (Task 7) ✓ · deploy, live render-test, preview-tune, snapshot (Task 8) ✓. "Ambiance vs data" principle, alerts-stay, no-SOFI, unified stack, TOU-untouched — all reflected.

Placeholder scan: the only <...> tokens (<OFFICE_MANUAL_ID>/<OFFICE_AUTO_ID>) are generated in Task 6 Step 1. H600B weather/alert scene ids are concrete defaults flagged for preview-tuning (Task 8 Step 3), not TBDs. Task 1 Step 2 says "mirror the TOU package include style" — a real instruction against the existing file, not a placeholder.

Type/name consistency: rest_command.govee_scene vars sku/device/scene_id/param_id match between Task 2 (definition), Task 2 (corner call), and Task 4 (office fan-out). sensor.ambiance_scene / binary_sensor.weather_is_severe names consistent Tasks 1/3/5/6. Logical keys identical between the sensor (Task 1) and both smaps (Tasks 3/5). office_lamp_apply_scene(scene_id,param_id,token,force) / office_lamp_off(token) defined Task 4, called Task 5 with those exact fields.