Skip to content

SOFI Ticker — Saturation-Encoded Move Magnitude

Date: 2026-07-24 Status: Design — approved for planning Scope: Change how script.sofi_ticker_render encodes the day's move size on the SOFI Ticker bulb (light.louis_office_sengled_a19_bulb_2) — magnitude moves from brightness to saturation. One script in scripts.yaml. Sensors, the §41 render triggers, and the §41b heartbeat are untouched.


Motivation

The resting tint currently encodes direction as hue (green/red) and magnitude as brightness, with saturation pinned at 100:

target_hs:      [40, 12] if |pct| < 0.1 else [(120 if pct >= 0 else 0), 100]
target_bri_pct: 8       if |pct| < 0.1 else 10 + min(|pct|, 4)/4 * 55

A fully-saturated hue at 10% brightness is very nearly black. Rendered HSV→RGB, a +0.1% day is #001d00 and a +0.5% day is #002b00 — the colour is technically present but unreadable across the room. In practice the bulb only becomes legible on large-move days, which is exactly backwards for an at-a-glance instrument: you want to read it every day, not only on dramatic ones.

The requested behaviour was "lighter green → deeper green, lighter red → deeper red."

Precision note that shaped the design: hue is the colour angle (green 120°, red 0°) and sets direction. "Lighter → deeper" is saturation, not hue. So the change is to ramp saturation and hold brightness constant, leaving hue binary as the direction channel.

Chosen encoding — saturation ramp at constant brightness

target_hue:     120 (up) / 0 (down)          # unchanged — direction only
target_sat:     40 → 100 across 0 → ±4%      # NEW — carries magnitude
target_bri_pct: 55                            # constant — carries nothing

Rendered output (HSV→RGB, what the bulb actually emits):

|Δ%| 0.1 0.5 1.0 2.0 3.0 4.0+
Up #528c52 #4a8c4a #3f8c3f #2a8c2a #158c15 #008c00
Down #8c5252 #8c4a4a #8c3f3f #8c2a2a #8c1515 #8c0000

Washed-out sage → pure green; dusty rose → pure red. Direction is unmistakable at every magnitude because hue never varies, and the bulb is equally readable on a quiet day as a wild one.

The ±4% clamp is unchanged.

Flat day

|Δ%| < 0.1 renders amber hue 40, sat 35, brightness 55% (#8c7c5b) — raised from today's dim 8%.

Rationale: the entire point of the change is that brightness no longer means anything. Leaving the flat case encoded as "dim" would keep brightness secretly carrying signal, and would produce a jarring 8%→55% jump the moment the stock crosses 0.1%. Flat now reads as a distinct amber at the same presence as every other state.

Alternatives considered

  • Pastel → deep (saturation up and brightness down, 70%→45%). The most literal "light green → deep forest" ramp, and visually the prettiest. Rejected because big moves would get dimmer, losing peripheral punch, and because it keeps brightness as a second meaning-bearing variable — the thing this change is trying to eliminate.
  • True hue shift (olive→green / amber→red, magnitude travels the colour wheel). Reads like a heat scale. Rejected on two counts: a small drop renders amber, colliding with the flat-day neutral; and direction becomes hard to call instantly, which is the bulb's primary job.

Implementation

Tint math

- 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

Later variables referencing earlier ones in the same variables: block is already relied on by the existing code (target_bri_255 reads target_bri_pct), so this pattern is proven on this instance.

Verification — the correctness fix this change forces

The retry loop currently confirms a command landed by comparing brightness:

{{ is_state(bulb,'on')
   and (((state_attr(bulb,'brightness') | int(0)) - target_bri_255) | abs) < 20 }}

With brightness now constant at 55%, this check passes on the first pass every time — including when the hue/saturation never applied. The 3-attempt retry loop would silently become a no-op on a Matter bulb that demonstrably needs it (see the 2026-07-22 heartbeat spec: ~250–290ms per command, spikes to ~1.9s, "Peer no longer responding" MRP errors).

Verification must therefore cover all three channels — hue, saturation, and brightness:

{% 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 }}

target_bri_255 is therefore retained, not deleted.

Two constraints make this exact form necessary, and dropping either one reintroduces a silent failure:

  • The circular hue comparison ([dh, 360 - dh] | min) is required. The bulb reports red as either 0 or 359.x; a naive absolute difference would fail forever on down days, burning all three retries on every render.
  • Brightness must stay in the check, even though it is now constant. The §41b heartbeat hands off at sat 100, brightness 20%. On a ≥4% day the resting target is also sat 100 — so hue and saturation already match at hand-off and only brightness differs. A hue+saturation-only check would pass on the first pass and stop, stranding the bulb at 20% if that settle command dropped. Checking brightness alone is inert (it matches on nearly every render); checking it alongside hue and saturation is what closes the gap.

Retry/apply loop

Unchanged: 3 attempts, light.turn_on with hs_color + brightness_pct, continue_on_error: true, 1s spacing, stop on success.

What is deliberately unchanged

  • Market-hours gate (09:30–16:00 ET weekdays) and the has_value('sensor.sofi_change_pct') guard.
  • Off-hours branch — bulb off, with its own 3-attempt loop.
  • mode: restart.
  • §41 triggers — /5 tick, HA start, office-return, and the sofi_heartbeat_active stand-down.
  • §41b heartbeat and script.sofi_heartbeat — including its hue field (0 = red / 120 = green), which describes the move's direction and is independent of the resting encoding.
  • Data chainsensor.sofi_quotesensor.sofi_change_pct. Verified healthy and fresh on 2026-07-24.

A latent bug this fixes for free

script.sofi_heartbeat's final pair stops at 20% and hands off to sofi_ticker_render to "resolve it UP into the resting tint." On a small-move day the resting brightness is currently 10–20%, so that documented resolve-up is actually a resolve-down — the burst ends by dimming. With brightness pinned at 55%, the hand-off always genuinely resolves upward, matching the documented intent in every case.

Verification plan

check_config validates schema, not template execution — comma-string→tuple coercion and undefined-field defaults both ship past CI. So static checks are necessary but not sufficient here.

  1. make test — yamllint, JSON validity, structural + entity-ref tiers. No new entities are introduced, so tests/fixtures/entities.txt and the allowlist need no changes.
  2. Live render-test both templates via /api/template against real sensor.sofi_change_pct values, plus synthetic ones spanning the range: -4.5, -2, -0.05, 0, +0.05, +2, +4.5. Confirm target_sat clamps at 100 and floors at 40, and that flat catches the near-zero band.
  3. Live bulb test — call script.sofi_ticker_render during market hours and read back hs_color / brightness from the REST API; confirm the bulb lands on target and that the verification template returns true only when it actually has.
  4. Down-day check — specifically exercise a negative pct so the circular hue comparison is proven against the bulb's 0/359.x reporting, rather than assumed.
  5. Post-heartbeat settle check — call script.sofi_heartbeat and confirm the bulb settles to the full resting tint afterwards, including brightness. Exercise this on a ≥4% (sat 100) day specifically — that is the case where hue and saturation already match at hand-off and only brightness distinguishes "settled" from "stranded at the burst's 20% tail."

Docs to update in the same change

File What
CLAUDE.md:171 (§19) "deeper+brighter for bigger moves" → saturation ramp at constant brightness
docs/lighting.md:30 Schedule row wording
docs/lighting.md:399–400 User-facing green/red description
docs/automations.md:709 Resting-tint technical reference

Risks and open items

  • ~~55% may read brighter than wanted.~~ Resolved same day — see the follow-up below. Quiet days go from near-dark to a clearly-present glow; rather than leave this as a code edit, the constant was promoted to a helper.
  • Low-saturation direction legibility. Sat 40 is the floor precisely so a small up-day (sage) stays distinguishable from a small down-day (dusty rose). If they prove hard to tell apart in the room, raise the floor rather than reintroducing a brightness ramp.

Follow-up (same day): brightness as a tunable helper

Status: Design — approved.

Now that brightness is a single constant carrying no information, it is purely a comfort dial — exactly the shape of a dashboard helper. Promoting it closes the "55% may read brighter than wanted" risk above without a code edit each time.

The helper

  sofi_bulb_bri_pct:
    name: "SOFI Ticker Brightness"
    icon: mdi:brightness-6
    min: 10
    max: 100
    step: 5
    unit_of_measurement: "%"
    mode: box

No initial: — tweaks persist across restarts (restore_state), matching precool_high_threshold, sofi_heartbeat_threshold and sitting_lamp_dark_lux.

First boot after deploy: with no stored value HA seeds the helper at its min, so the bulb renders at 10% until set. 10 is deliberately chosen as the floor: it is roughly the old brightness floor, so an unset helper looks like the previous behaviour rather than a dead bulb. Set it to 55 once after deploy — the same one-time step the other helpers document.

Script change

target_bri_pct: "{{ states('input_number.sofi_bulb_bri_pct') | float(55) | round(0) | int }}"

The float(55) fallback matters: an unknown/unavailable helper must not render brightness 0 and mimic a dead bulb. target_bri_255 is unchanged — it already derives from target_bri_pct, so the retry-loop verification keeps working untouched.

Re-render trigger

§41 (sofi_ticker_color) gains one trigger so a change applies immediately instead of waiting up to 5 minutes for the /5 tick:

    - trigger: state
      entity_id: input_number.sofi_bulb_bri_pct

§41's existing office_occupied == on condition still gates it, so adjusting the value while the office is empty will not light an empty room — consistent with §26.

Deliberately unchanged

script.sofi_heartbeat's beat levels stay hardcoded (8/20/30/85/100%). They are a waveform, not a comfort setting, and scaling them to the helper would couple two unrelated concerns. Consequence to accept: setting resting brightness near 90 shrinks the burst's contrast, since the peak is 100. Below ~65 the lub-dub stays punchy.

Also in scope

  • §41's description: still reads "deeper+brighter for bigger moves" — stale since the saturation change. Fix it.
  • input_number.sofi_bulb_bri_pct goes in tests/fixtures/entities_allow.txt (it does not exist in the registry until HA restarts).
  • Tests: test_brightness_is_constant_55_everywhere no longer holds as written — brightness is no longer a YAML literal. It splits into "does not vary with move size" and "follows the helper". The render_vars stub must become entity-aware; today it returns the SOFI pct for every states() call and would otherwise feed the pct in as brightness.