SOFI Ticker — Heartbeat on Big Moves¶
Date: 2026-07-22 Status: Design — approved for planning Scope: Add an event-driven "lub-dub" pulse to the SOFI Ticker bulb (light.louis_office_sengled_a19_bulb_2) when the stock makes a sharp intraday move, layered on top of the existing steady color tint (automations.yaml §41).
Motivation¶
Today the SOFI Ticker bulb encodes exactly one thing: the day's % change vs. previous close, as a static hue (green up / red down) + brightness (deeper = bigger). It sits still. A big sudden move looks identical to a slow drift to the same level — the bulb tells you where the day stands but never that something just happened.
This adds a second, independent information channel — motion — that fires only on sharp moves and then gets out of the way. A glance gives you both: the resting color says where the day is, and a burst of heartbeats says "it just lurched, this direction, this hard."
Hardware findings (measured 2026-07-22 — these constrain the whole design)¶
Live experimentation on the bulb established three facts that overturn assumptions baked into the current code:
- The bulb is Matter, not Zigbee. Platform is
matter(Sengled W41-N15A, hw V3), node181D944291AB964C. The comment atautomations.yaml:2777("intermittently times out on Zigbee") is wrong — and "Peer no longer responding" is a Matter/CHIP MRP error, not a ZHA one. Fix that comment as part of this work. transition:is a no-op. The bulb ignores the requested duration and ramps at its own fixed rate (~1.4% brightness/sec, ~13° hue/sec). A requested 5-second fade actually takes ~70 seconds. Every fade must be drawn by HA as a series oftransition: 0snaps — the bulb is a snap-and-hold device.- ~3 commands/sec ceiling. Each Matter command blocks ~250–290ms (spikes to ~1.9s) until the device acks. Pushing 360 rapid commands produced zero Matter errors — the bulb is stable at this rate, it just won't go faster. This kills smooth fast fades but makes crisp pulses/beats (≤2 cmd/s) completely reliable.
Design consequence: the honest visual vocabulary for this bulb is snaps, pulses, and beats — not smooth motion. The heartbeat exploits the snap instead of fighting it.
The effect¶
A "lub-dub" heartbeat — two quick pulses per beat, like a real heart, chosen by feel over slow/fast/snappy single-pulse variants.
One pair, all transition: 0:
The beats play in the move's direction (not the day's): a +0.7% jerk on a red day beats green, then settles back to the red resting tint. Motion and color are two independent axes — that's the entire payoff.
Shape refinement "C" (live-tuned 2026-07-22, shipped). Driving the real bulb surfaced two rough edges at the ends of the burst, both fixed in script.sofi_heartbeat: - Prime the floor. The middle lubs rise from the previous pair's 8% tail, but the first lub would otherwise rise from the resting brightness — different contrast, so beat 1 read as "off." Fix: a single on 8% prime (+280 ms) before the loop, so every lub — including the first — rises from the same floor. - Resolve the last beat up. The final pair would otherwise decay to 8% and hang there before the settle. Fix: the trailing on 8% + gap is gated on {{ not repeat.last }}, so the final pair stops at 20% and the existing sofi_ticker_render call resolves it up into the resting tint — a clean finish into the day's glow even when the beat hue differs from the resting hue (red down-jerk on a green up-day). The heartbeat script never hardcodes the resting color; the render stays the single source of it. - Inter-pair gap is 900 ms (matches the live-tuned demo), so a pair is ≈ 2.3 s and a 4-pair burst ≈ 9 s.
Trigger: sharp delta¶
"Big move" = |change since the previous 5-minute reading| ≥ threshold.
sensor.sofi_change_pct is derived from sensor.sofi_quote, which polls every 300s (scan_interval: 300). So the finest signal available is the delta between two consecutive polls — there is no sub-5-minute data. That makes a continuously-animating "faster as it moves faster" effect pointless (it would animate on stale data); event-driven bursts are the correct shape.
The delta is computed directly from trigger.from_state / trigger.to_state on the state trigger — no helper entity needed to remember the previous value.
Beat mapping¶
| 5-min delta (abs) | Beats | Hue |
|---|---|---|
| < 0.5% (threshold) | none — render only | — |
| ≥ 0.5% | 2 pairs | move direction |
| ≥ 1.0% | 3 pairs | 120 (green) up / 0 (red) down |
| ≥ 1.5% | 4 pairs |
Max burst = 4 pairs ≈ 9s, far inside the 5-minute poll gap, so bursts can never overlap.
Threshold is a dashboard-tunable input_number (default 0.5%), no initial: so tweaks persist across restarts (repo convention).
Architecture¶
Follows the established renderer idiom (night_lights_render, corner_lamp_render, office_lamp_render). The current §41 automation both computes the resting tint and owns the sensor-change trigger; this splits those so each event path has exactly one owner and the /5 tick can't race the heartbeat.
| Component | Kind | Role |
|---|---|---|
script.sofi_ticker_render | new script | Single source of the resting appearance. The existing §41 tint math + market-hours-off branch + 3× retry, moved verbatim. Called by everything that wants the bulb "at rest." |
script.sofi_heartbeat | new script | Params pairs, hue. Order: set mutex → play N lub-dub pairs (transition: 0 snaps) → clear mutex → call sofi_ticker_render to settle. The clear precedes the settle render so the render isn't itself blocked by the mutex it's recovering from. |
| §41 (existing, trimmed) | automation | Keeps triggers: /5 tick, HA start, presence-return (office_occupied → on). Body becomes a call to script.sofi_ticker_render. Keeps the office_occupied == on condition. Loses the sensor.sofi_change_pct state trigger (moves to §41b). |
| §41b (new) | automation | Trigger: sensor.sofi_change_pct state change. Computes delta, applies guards, decides beats, calls heartbeat-or-render. |
input_boolean.sofi_heartbeat_active | new helper | Mutex — set while a burst plays so the /5 render tick doesn't stomp it. |
input_number.sofi_heartbeat_threshold | new helper | Trip point, default 0.5, dashboard-tunable, no initial:. |
Data flow¶
quote poll (5 min) → sensor.sofi_change_pct changes
→ §41b:
guards fail (see below) → render only
delta = |to − from| < threshold → render only
delta ≥ threshold → flag on → lub-dub ×N in move's hue → flag off → render
Every path ends in a render, so the bulb always lands on the correct resting state even if a burst is interrupted (self-correcting).
Edge cases & error handling¶
- Market-open gap trap (critical): the first poll at 09:30 would compare against the previous close and fabricate a huge delta → false heartbeat. Guard: require
trigger.from_state.last_changedto be within the last 10 minutes. This same guard neutralizes the identical bug after an HA restart (stale restored state) or a stalled/again-available sensor. unknown/unavailableon eitherfrom_stateorto_state→ skip beats, render only.- Vacant office → no beats. §41b carries the same
input_boolean.office_occupied == oncondition as §41 (don't animate an empty room; the presence system owns on/off). - Outside market hours →
sofi_ticker_render's existing time branch turns the bulb off; a delta arriving off-hours renders (→ off), no beats. /5tick lands mid-burst → §41's render carries asofi_heartbeat_active == offcondition, so a tick arriving mid-burst is dropped (a no-op, not deferred/queued). The burst's own closing render then settles the bulb ~9s later, and the next/5tick corrects anything if needed. (§41b's render path is unaffected — bursts can't overlap, so it never sees the flag set.)- No retries during beats. A dropped beat frame is cosmetic; retrying would distort the 0.9s rhythm. The 3× retry stays only in
sofi_ticker_render, where a stale resting color actually matters.
Testing & deploy¶
- New entities (
script.sofi_ticker_render,script.sofi_heartbeat,input_boolean.sofi_heartbeat_active,input_number.sofi_heartbeat_threshold) don't exist until deployed → add totests/fixtures/entities_allow.txtwith justifying comments; runmake snapshotafter the deploy lands, then drop the allowlist entries. - Helper additions touch
configuration.yaml→ deploy does a fullha core restart, not anautomation.reload. (scripts.yaml+automations.yamlalso change.) - Fix the wrong comment at
automations.yaml:2777(Zigbee → Matter) and record the two hardware findings (transition is a no-op; ~3 cmd/s ceiling) inline so nobody re-derives them. - Docs: update
CLAUDE.md§19 (SOFI Ticker),docs/automations.md(§41/§41b technical ref),docs/entities.md(the 4 new entities).docs/notifications.mdis not touched — the bulb is ambient, not a push alert.
Explicitly out of scope (YAGNI)¶
- Velocity-driven continuous beating (no sub-5-min data to justify it).
- Threshold-crossing / session-high-low triggers (considered, rejected in favor of sharp-delta).
- Premarket/afterhours heartbeats (the bulb is off then).
- Any change to the TOU bulb (
bulb_1) or the Govee ambiance lamps.
Open questions¶
None — mapping, direction, trigger, and hardware limits are all settled by live testing.