Skip to content

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:

  1. The bulb is Matter, not Zigbee. Platform is matter (Sengled W41-N15A, hw V3), node 181D944291AB964C. The comment at automations.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.
  2. 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 of transition: 0 snaps — the bulb is a snap-and-hold device.
  3. ~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:

on 100%  → on 30%     (lub)
on 85%   → on 20% → on 8%   (dub)
sleep 0.9s
≈ 5 snaps + 0.9s ≈ 2.3s per pair.

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_changed to 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 / unavailable on either from_state or to_state → skip beats, render only.
  • Vacant office → no beats. §41b carries the same input_boolean.office_occupied == on condition as §41 (don't animate an empty room; the presence system owns on/off).
  • Outside market hourssofi_ticker_render's existing time branch turns the bulb off; a delta arriving off-hours renders (→ off), no beats.
  • /5 tick lands mid-burst → §41's render carries a sofi_heartbeat_active == off condition, 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 /5 tick 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 to tests/fixtures/entities_allow.txt with justifying comments; run make snapshot after the deploy lands, then drop the allowlist entries.
  • Helper additions touch configuration.yaml → deploy does a full ha core restart, not an automation.reload. (scripts.yaml + automations.yaml also 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.md is 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.