SOFI Ticker Saturation Tint 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: Move the SOFI Ticker bulb's move-magnitude encoding from brightness to saturation, so small moves are legible instead of near-black.
Architecture: One script changes — script.sofi_ticker_render in scripts.yaml. Its variables: block gains flat / target_hue / target_sat and pins target_bri_pct to a constant 55; its retry-loop verification expands from a brightness-only comparison to hue + saturation + brightness. Nothing else moves: the sensors, the §41 triggers, and the §41b lub-dub heartbeat are untouched.
Tech Stack: Home Assistant YAML scripts with Jinja2 templates; pytest for the pre-deploy suite; GitHub Actions CI deploys on push to main.
Global Constraints¶
- Spec:
docs/superpowers/specs/2026-07-24-sofi-ticker-saturation-tint-design.md— values below are copied verbatim from it. - Bulb entity:
light.louis_office_sengled_a19_bulb_2(Matter, snap-and-hold;transition:is a no-op; ~3 commands/sec ceiling). - Saturation ramp:
40 → 100across|Δ%| 0 → 4. Clamp at ±4%. - Brightness: constant
55percent for every market-hours state, including flat. - Flat day:
|Δ%| < 0.1→ hue40, sat35, brightness55. - Hue:
120up /0down. Direction only — never varies with magnitude. - Verification must check all three channels. Brightness alone is inert under a constant target; dropping brightness strands the bulb at the heartbeat's 20% tail on ≥4% days. Keep
target_bri_255. - Red hue wraparound: the bulb reports red as
0or359.x. Hue comparison must be circular ([dh, 360 - dh] | min). - Do not modify
script.sofi_heartbeat, automations §41/§41b,sensor.sofi_quote, orsensor.sofi_change_pct. - No new entities —
tests/fixtures/entities.txtandentities_allow.txtneed no changes. - Commit style: Conventional Commits, scope
sofi. Do not push without explicit user approval — pushingscripts.yamltriggers a fullha core restarton the live house.
File Structure¶
| File | Responsibility | Change |
|---|---|---|
tests/requirements.txt | Pins the test suite's deps | Modify — add Jinja2 |
tests/test_sofi_tint.py | Renders the tint math the way HA does and asserts the ramp; guards the 3-channel verification | Create |
scripts.yaml:1399-1443 | script.sofi_ticker_render — resting appearance | Modify |
CLAUDE.md:171 | §19 architecture note | Modify |
docs/lighting.md:30,399-400 | User-facing manual | Modify |
docs/automations.md:709 | Technical reference | Modify |
Task 1: Test the tint math (RED)¶
Renders the script's variables: block with Jinja2 exactly as HA does — sequential binding, native types — because check_config validates schema, not template execution. This repo has shipped template-execution bugs past CI before (comma-string→tuple coercion, undefined-field defaults), so the math gets a real test rather than a string match.
Files: - Modify: tests/requirements.txt - Create: tests/test_sofi_tint.py
Interfaces: - Consumes: the scripts fixture from tests/conftest.py:191 (parsed scripts.yaml). - Produces: render_vars(scripts, pct) -> dict — the fully-bound variable context for a given % change. Task 2 must keep the variable names flat, target_hue, target_sat, target_hs, target_bri_pct, target_bri_255 for these tests to bind.
- [ ] Step 1: Add Jinja2 to the test requirements
Replace the contents of tests/requirements.txt with:
- [ ] Step 2: Install it
Run: python3 -m pip install -r tests/requirements.txt Expected: succeeds, Jinja2 installed. If Jinja2==3.1.6 cannot be resolved, use the newest available 3.1.x and pin that exact version instead.
- [ ] Step 3: Write the failing test file
Create tests/test_sofi_tint.py:
"""SOFI Ticker resting tint (§41) — move magnitude is encoded in SATURATION.
Spec: docs/superpowers/specs/2026-07-24-sofi-ticker-saturation-tint-design.md
Brightness is constant (55%) so it carries no information; saturation ramps
40→100 across 0→±4%; hue stays binary for direction.
These tests RENDER the script's `variables:` block with Jinja2 the way HA does
(sequential binding, native-typed results) rather than string-matching the YAML.
check_config validates SCHEMA, not template EXECUTION — bad math ships past CI.
"""
import ast
import json
import jinja2
BULB = "light.louis_office_sengled_a19_bulb_2"
def _native(text):
"""Mimic HA's native-typed template result (parse_result=True).
Load-bearing: if `flat` came back as the STRING 'False' it would be truthy
and every single day would render amber.
"""
t = text.strip()
try:
return ast.literal_eval(t)
except (ValueError, SyntaxError):
return t
def _market_branch(scripts):
"""The `choose` branch that runs during market hours."""
return scripts["sofi_ticker_render"]["sequence"][0]["choose"][0]
def _vars_block(scripts):
for step in _market_branch(scripts)["sequence"]:
if "variables" in step:
return step["variables"]
raise AssertionError("market-hours branch must define a variables block")
def _verify_template(scripts):
"""The retry loop's 'did the command land?' condition."""
for step in _market_branch(scripts)["sequence"]:
rpt = step.get("repeat")
if not rpt:
continue
for inner in rpt["sequence"]:
if "if" in inner:
return inner["if"][0]["value_template"]
raise AssertionError("retry loop must contain a verification condition")
def render_vars(scripts, pct):
"""Render the variables block in order with a stubbed states(), like HA does."""
env = jinja2.Environment()
env.globals["states"] = lambda entity_id: str(pct)
ctx = {}
for name, tmpl in _vars_block(scripts).items():
if not isinstance(tmpl, str):
ctx[name] = tmpl # plain YAML literal, e.g. `target_bri_pct: 55`
continue
ctx[name] = _native(env.from_string(tmpl).render(**ctx))
return ctx
# ---- brightness no longer carries information ----
def test_brightness_is_constant_55_everywhere(scripts):
for pct in (-9, -4, -1, -0.05, 0, 0.05, 1, 4, 9):
assert render_vars(scripts, pct)["target_bri_pct"] == 55, \
f"brightness must be a constant 55 at pct={pct}"
# ---- saturation carries magnitude ----
def test_saturation_floor_is_40(scripts):
assert render_vars(scripts, 0.1)["target_sat"] >= 40, \
"smallest non-flat move must still be saturated enough to read direction"
def test_saturation_reaches_100_at_4_percent(scripts):
assert render_vars(scripts, 4)["target_sat"] == 100
assert render_vars(scripts, -4)["target_sat"] == 100
def test_saturation_clamps_beyond_4_percent(scripts):
assert render_vars(scripts, 10)["target_sat"] == 100
assert render_vars(scripts, -10)["target_sat"] == 100
def test_saturation_is_monotonic_in_move_size(scripts):
sats = [render_vars(scripts, p)["target_sat"] for p in (0.1, 0.5, 1, 2, 3, 4)]
assert sats == sorted(sats), f"saturation must rise with move size, got {sats}"
assert len(set(sats)) > 1, "saturation must actually vary, not be pinned"
def test_saturation_is_symmetric_up_and_down(scripts):
for p in (0.5, 1, 2, 3):
assert render_vars(scripts, p)["target_sat"] == render_vars(scripts, -p)["target_sat"]
# ---- hue carries direction only ----
def test_hue_encodes_direction_and_never_magnitude(scripts):
for p in (0.1, 1, 4, 10):
assert render_vars(scripts, p)["target_hue"] == 120, "up must be green"
assert render_vars(scripts, -p)["target_hue"] == 0, "down must be red"
# ---- flat day ----
def test_flat_day_is_amber_at_full_brightness(scripts):
v = render_vars(scripts, 0.05)
assert v["target_hue"] == 40, "flat is amber"
assert v["target_sat"] == 35
assert v["target_bri_pct"] == 55, "flat is NOT encoded as dim any more"
def test_flat_flag_is_a_real_boolean(scripts):
"""If `flat` rendered as the string 'False' it would be truthy and every day
would come out amber. HA native-types template results; depend on it correctly."""
v = render_vars(scripts, 2.0)
assert v["flat"] is False
assert v["target_hue"] == 120, "a +2% day must be green, not the flat amber"
def test_hs_pairs_hue_and_saturation(scripts):
v = render_vars(scripts, -2.0)
assert list(v["target_hs"]) == [v["target_hue"], v["target_sat"]]
# ---- verification must cover all three channels ----
def test_verification_checks_hue_saturation_and_brightness(scripts):
t = _verify_template(scripts)
assert "hs_color" in t and "target_hue" in t and "target_sat" in t, \
"must verify the channels that now carry the signal"
assert "brightness" in t and "target_bri_255" in t, (
"brightness must STAY in the check: script.sofi_heartbeat hands off at "
"sat 100 / 20% brightness, so on a >=4% day hue+sat already match and a "
"hue+sat-only check would stop early, stranding the bulb at the burst's tail"
)
def test_verification_handles_red_hue_wraparound(scripts):
t = _verify_template(scripts)
assert "360" in t, \
"red is reported as 0 OR 359.x — hue distance must be circular, else every "
"down-day render burns all 3 retries and never confirms"
def test_bri_255_still_derived_from_bri_pct(scripts):
assert render_vars(scripts, 1.0)["target_bri_255"] == 140 # 55% of 255
# ---- the lub-dub heartbeat is out of scope and must stay untouched ----
def test_heartbeat_is_independent_of_resting_encoding(scripts):
hb = json.dumps(scripts["sofi_heartbeat"])
assert "target_sat" not in hb and "target_bri_pct" not in hb, \
"heartbeat must not read the resting-tint variables"
assert "[hue | int, 100]" in hb, "beats stay at full saturation"
assert BULB in hb
- [ ] Step 4: Run the tests to verify they fail
Run: python3 -m pytest tests/test_sofi_tint.py -v Expected: FAIL. The saturation/flat/hue tests fail with KeyError: 'target_sat' / KeyError: 'flat' (the variables don't exist yet), test_brightness_is_constant_55_everywhere fails because brightness currently varies, and the two verification tests fail because the current condition only mentions brightness. test_heartbeat_is_independent_of_resting_encoding should already PASS — it is a regression guard.
- [ ] Step 5: Commit the failing tests
git add tests/requirements.txt tests/test_sofi_tint.py
git commit -m "test(sofi): cover saturation-encoded tint math and 3-channel verify"
Task 2: Implement the saturation tint (GREEN)¶
Files: - Modify: scripts.yaml:1399-1443
Interfaces: - Consumes: render_vars binding from Task 1 — variable names must match exactly. - Produces: the finished script.sofi_ticker_render. Nothing downstream consumes its internals; script.sofi_heartbeat only calls it by name.
- [ ] Step 1: Replace the header comment
Replace lines 1399-1404 (the comment block directly above sofi_ticker_render:) with:
# SOFI Ticker — resting appearance (single source). Tints bulb_2 by SOFI's % change vs
# previous close during market hours: hue is DIRECTION only (green up / red down) and
# SATURATION carries magnitude (40 → 100 across 0 → ±4%), with brightness held constant
# at 55%. Amber when flat. Off outside 9:30-16:00 ET weekdays. Magnitude used to ride on
# brightness, which rendered small moves nearly black (#001d00 at +0.1%) — see
# docs/superpowers/specs/2026-07-24-sofi-ticker-saturation-tint-design.md.
# The Sengled is Matter and occasionally times out ("Peer no longer responding"), so each
# write retries up to 3× until the bulb reports hue AND saturation AND brightness on
# target. All three matter: brightness alone is inert now that it is constant, but
# dropping it would let a post-heartbeat render stop early and strand the bulb at the
# burst's 20% tail on a ≥4% day (hue+sat already match at hand-off). Hue comparison is
# circular because the bulb reports red as 0 or 359.x. Called by automations.yaml §41
# (/5 tick, start, presence-return) and by script.sofi_heartbeat (to settle after a
# burst). transition: is a no-op on this bulb.
- [ ] Step 2: Replace the variables block
Replace lines 1418-1424 (the - variables: step) with:
- variables:
pct: "{{ states('sensor.sofi_change_pct') | float(0) }}"
flat: "{{ (pct | abs) < 0.1 }}"
target_hue: "{{ 40 if flat else (120 if pct >= 0 else 0) }}"
target_sat: >-
{{ 35 if flat else (40 + ([pct | abs, 4] | min) / 4 * 60) | round(0) | int }}
target_hs: "{{ [target_hue, target_sat] }}"
target_bri_pct: 55
target_bri_255: "{{ ((target_bri_pct | float) * 255 / 100) | round(0) | int }}"
- [ ] Step 3: Replace the verification condition
Replace the value_template: inside the retry loop's - if: (lines 1437-1440) so the whole if step reads:
- if:
- condition: template
value_template: >-
{% set b = 'light.louis_office_sengled_a19_bulb_2' %}
{% set hs = state_attr(b, 'hs_color') %}
{% set dh = ((hs[0] | float(0)) - target_hue) | abs if hs else 999 %}
{{ is_state(b, 'on') and hs is not none
and ([dh, 360 - dh] | min) < 10
and (((hs[1] | float(0)) - target_sat) | abs) < 8
and (((state_attr(b, 'brightness') | int(0)) - target_bri_255) | abs) < 20 }}
then:
- stop: "SOFI bulb applied"
Leave the light.turn_on step, continue_on_error: true, the - delay: "00:00:01", count: 3, and the entire default: (lights-off) branch exactly as they are.
- [ ] Step 4: Run the tint tests to verify they pass
Run: python3 -m pytest tests/test_sofi_tint.py -v Expected: PASS, all tests.
- [ ] Step 5: Run the full suite
Run: make test Expected: PASS — yamllint clean, all pytest tiers green, check_config green. If Docker isn't running, make check-config will fail on the container tier only; run make lint && make pytest and note that the container tier is covered by CI.
- [ ] Step 6: Commit
git add scripts.yaml
git commit -m "feat(sofi): encode ticker magnitude as saturation, not brightness
Sat 40->100 across 0->±4% at a constant 55% brightness; hue stays binary
for direction. A +0.1% day rendered #001d00 (near-black) before — the
bulb was only legible on dramatic days.
Verification now checks hue+saturation+brightness together: brightness
alone is inert under a constant target, but dropping it would strand the
bulb at the heartbeat's 20% tail on >=4% days. Circular hue distance
handles the bulb reporting red as 0 or 359.x."
Task 3: Update the docs¶
Repo convention (CLAUDE.md): a user-visible change updates the manual in the same change, not just the spec.
Files: - Modify: CLAUDE.md:171 - Modify: docs/lighting.md:30, docs/lighting.md:399-400 - Modify: docs/automations.md:709
Interfaces: - Consumes: nothing. Produces: nothing. Docs-only.
- [ ] Step 1: Update CLAUDE.md §19
In CLAUDE.md line 171, replace the phrase:
green up / red down, deeper+brighter for bigger moves (clamped ±4%), pale neutral when flat
with:
green up / red down by hue, with SATURATION carrying magnitude (40→100 across 0→±4%, clamped) at a constant 55% brightness — amber when flat (#sofi-tint 2026-07-24; magnitude used to ride on brightness, which rendered small moves near-black)
- [ ] Step 2: Update the lighting manual schedule row
In docs/lighting.md line 30, replace:
Market opens — the ticker bulb lights **green** (up) or **red** (down), deeper with bigger moves.
with:
Market opens — the ticker bulb lights **green** (up) or **red** (down), richer and more vivid with bigger moves.
- [ ] Step 3: Update the lighting manual description
In docs/lighting.md lines 399-400, replace:
- 🟢 **Green** when the stock is **up** on the day — deeper, brighter green the more it's up.
- 🔴 **Red** when it's **down** — deeper red the more it's down.
with:
- 🟢 **Green** when the stock is **up** on the day — a pale sage green for a small gain, deepening to a vivid pure green the more it's up.
- 🔴 **Red** when it's **down** — a soft dusty rose for a small dip, deepening to a vivid pure red the more it's down.
- 🟡 **Amber** when the day is flat (less than 0.1% either way).
The bulb stays the same brightness all day — it's the **richness of the colour**, not how bright it is, that tells you how big the move is.
- [ ] Step 4: Update the automations reference
In docs/automations.md line 709, replace:
green up / red down, deeper + brighter for bigger moves (clamped ±4%), pale neutral when flat, off outside market hours.
with:
hue = direction (green up / red down), saturation = magnitude (40→100 across 0→±4%, clamped), brightness constant at 55%; amber (hue 40, sat 35) when flat; off outside market hours. The retry loop verifies hue + saturation + brightness together — see docs/superpowers/specs/2026-07-24-sofi-ticker-saturation-tint-design.md.
- [ ] Step 5: Verify the docs still build strict
Run: python3 -m mkdocs build --strict 2>&1 | tail -20 Expected: builds with no warnings. docs.yml gates on --strict, so a broken link fails the deploy. If mkdocs isn't installed locally, skip and note that CI covers it.
- [ ] Step 6: Commit
git add CLAUDE.md docs/lighting.md docs/automations.md
git commit -m "docs(sofi): describe saturation-encoded ticker magnitude"
Task 4: Deploy and verify on the live bulb¶
⚠️ Gate: do not start this task without explicit user approval. Pushing scripts.yaml triggers CI's deploy-ha job, which does a full ha core restart on the live house. Pushing docs/** separately triggers the Cloudflare Pages docs deploy.
Files: none — this task is verification only.
Interfaces: - Consumes: the deployed script.sofi_ticker_render from Task 2.
- [ ] Step 1: Push
- [ ] Step 2: Confirm CI passed and deployed
Run: gh run list --workflow=ci.yml --limit 1 Expected: the run is completed / success. If the test job fails, every deploy job is skipped and the live house is untouched — fix and re-push.
- [ ] Step 3: Render-test the math against the live instance
check_config validates schema, not execution — so exercise the real template engine:
for P in -4.5 -2 -0.05 0 0.05 2 4.5; do
echo -n "pct=$P -> "
curl -sf -X POST -H "Authorization: Bearer $HA_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"template\":\"{% set pct = $P %}{% set flat = (pct|abs) < 0.1 %}hue={{ 40 if flat else (120 if pct >= 0 else 0) }} sat={{ 35 if flat else (40 + ([pct|abs,4]|min)/4*60)|round(0)|int }}\"}" \
"$HA_URL/api/template"; echo
done
Expected: sat floors at 40, tops out at 100 for |pct| ≥ 4, and the two ±0.05 rows report hue=40 sat=35.
- [ ] Step 4: Verify the live bulb lands on target
During market hours (09:30–16:00 ET, weekdays), with the office occupied:
curl -sf -X POST -H "Authorization: Bearer $HA_TOKEN" \
-H "Content-Type: application/json" -d '{"entity_id":"script.sofi_ticker_render"}' \
"$HA_URL/api/services/script/turn_on" >/dev/null
sleep 5
curl -sf -H "Authorization: Bearer $HA_TOKEN" \
"$HA_URL/api/states/light.louis_office_sengled_a19_bulb_2" \
| python3 -c "import sys,json;d=json.load(sys.stdin);a=d['attributes'];print('state',d['state'],'hs',a.get('hs_color'),'bri',a.get('brightness'))"
Expected: bri ≈ 140 (55% of 255) and hs matching the current sensor.sofi_change_pct per the ramp — e.g. at +0.66% roughly [120, 50].
- [ ] Step 5: Verify the down-day hue wraparound
The circular hue comparison must be proven, not assumed, because red is reported as 0 or 359.x. Either wait for a genuinely negative sensor.sofi_change_pct and repeat Step 4, or force red once and confirm the bulb settles rather than burning all three retries:
curl -sf -X POST -H "Authorization: Bearer $HA_TOKEN" -H "Content-Type: application/json" \
-d '{"entity_id":"light.louis_office_sengled_a19_bulb_2","hs_color":[0,80],"brightness_pct":55}' \
"$HA_URL/api/services/light/turn_on" >/dev/null
sleep 2
curl -sf -H "Authorization: Bearer $HA_TOKEN" \
"$HA_URL/api/states/light.louis_office_sengled_a19_bulb_2" \
| python3 -c "import sys,json;print(json.load(sys.stdin)['attributes'].get('hs_color'))"
Expected: prints the reported hue for red — note whether it comes back as 0.0 or ~359.x. Either value must satisfy [dh, 360-dh] | min < 10 against target_hue = 0.
- [ ] Step 6: Verify the post-heartbeat settle (the stranding case)
This is the case the 3-channel verification exists for — hue and saturation already match at hand-off, so only brightness distinguishes "settled" from "stuck at the burst's tail":
curl -sf -X POST -H "Authorization: Bearer $HA_TOKEN" -H "Content-Type: application/json" \
-d '{"entity_id":"script.sofi_heartbeat","variables":{"pairs":2,"hue":120}}' \
"$HA_URL/api/services/script/turn_on" >/dev/null
sleep 15
curl -sf -H "Authorization: Bearer $HA_TOKEN" \
"$HA_URL/api/states/light.louis_office_sengled_a19_bulb_2" \
| python3 -c "import sys,json;a=json.load(sys.stdin)['attributes'];print('hs',a.get('hs_color'),'bri',a.get('brightness'))"
Expected: the lub-dub plays, then the bulb settles to bri ≈ 140 — not ≈ 51 (the burst's 20% tail). Also confirm input_boolean.sofi_heartbeat_active is back off.
- [ ] Step 7: Report results
State plainly what each check returned, including anything that did not match expectation. Do not claim success for a step that was skipped (e.g. Step 5 outside market hours) — say it was skipped and why.
Self-Review¶
Spec coverage:
| Spec requirement | Task |
|---|---|
| Sat 40→100 across 0→±4%, clamped | 1 (tests), 2 (impl) |
| Brightness constant 55% | 1, 2 |
| Hue binary 120/0, direction only | 1, 2 |
| Flat = amber hue 40 / sat 35 / 55% | 1, 2 |
| Verification covers hue + sat + brightness | 1, 2 |
target_bri_255 retained | 1 (test_bri_255_still_derived_from_bri_pct), 2 |
| Circular hue for red 0/359.x | 1, 2, 4 (Step 5) |
| Heartbeat unchanged | 1 (test_heartbeat_is_independent_of_resting_encoding) |
Off-hours branch / market gate / mode: restart unchanged | 2 (Step 3 explicitly leaves them) |
Live render-test via /api/template | 4 (Step 3) |
| Live bulb read-back | 4 (Step 4) |
| Down-day check | 4 (Step 5) |
| Post-heartbeat settle check | 4 (Step 6) |
| Docs: CLAUDE.md, lighting.md ×2, automations.md | 3 |
| No entity fixture changes | Global Constraints |
Placeholders: none — every code step contains the literal content to write.
Type consistency: variable names (pct, flat, target_hue, target_sat, target_hs, target_bri_pct, target_bri_255) are identical between Task 1's render_vars bindings and Task 2's YAML. _verify_template in Task 1 reads the same if/value_template structure Task 2 writes.