Skip to content

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 → 100 across |Δ%| 0 → 4. Clamp at ±4%.
  • Brightness: constant 55 percent for every market-hours state, including flat.
  • Flat day: |Δ%| < 0.1 → hue 40, sat 35, brightness 55.
  • Hue: 120 up / 0 down. 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 0 or 359.x. Hue comparison must be circular ([dh, 360 - dh] | min).
  • Do not modify script.sofi_heartbeat, automations §41/§41b, sensor.sofi_quote, or sensor.sofi_change_pct.
  • No new entitiestests/fixtures/entities.txt and entities_allow.txt need no changes.
  • Commit style: Conventional Commits, scope sofi. Do not push without explicit user approval — pushing scripts.yaml triggers a full ha core restart on 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:

pytest==9.1.1
PyYAML==6.0.3
yamllint==1.38.0
Jinja2==3.1.6
  • [ ] 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
git 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.