Files
julian 22544ecf63
Build desktop / desktop (push) Successful in 4m37s
CI / check (push) Successful in 42s
Build & push images / images (push) Successful in 2m51s
docs(wiki): log-storm hardening + reset drift guard (2026-07-07 incident)
button-light-indicator: failure backoff + rate-limited logging rationale;
app-logs: storm coalescing invariant + --diagnostics wipe; local-dev-workflow
and appliance-provisioning §7d: new reset flag table + drift guard; log entry
tying all three layers to the ENETUNREACH incident.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-10 08:29:59 +02:00

8.0 KiB
Raw Permalink Blame History

type, tags, sources, updated, status
type tags sources updated status
concept
parking
device
indicator
radar
camera
aux-output
barrier-not-a-door
event-relay
2026-07-08 settled

Alert relays (radar × camera disagreement lamp)

A relay on the dingtian-relay controller is uniformly "when EVENT X happens, do action Y" — see entry-exit-points. The barrier events (entry/exit/ both) pulse a barrier; the radarAlert event drives a non-barrier indicator lamp (blink + camera-lock) on a spare relay. The entry button's 12 V light is the canonical alert relay, a 3-state indicator that combines a hikvision-radar trigger input with the camera "car in zone" signal:

Trigger input (radar) Camera (lane entry busy) Alert lamp
active free — no car confirmed BLINK (~1 Hz)
active busy — camera confirms a car SOLID on
inactive — OFF

It is a disagreement indicator: the radar sees something but the camera hasn't confirmed a real vehicle → blink (attention / "pull forward"); both agree → solid; nothing there → off.

Because it's just another relay row, a controller can carry several alert relays (e.g. R3 and a future R4), each with its own trigger input — no new config shape, no code change.

Signals

  • Trigger = the alert relay's own triggerInput edge (the hikvision-radar). When unset, it falls back to the controller's entry-relay presenceInput — the same edge the entry-double-press gate observes, so the lamp and the gate agree on "a car is here".
  • Lock (camera "car in zone") = the existing lpr-camera (LaneStatusEvent, from camera vehicle detection). Already advisory; already drives the booth's barrier lights. Each lamp picks which lane's camera locks it via relays[].lockLane: "entry"|"exit" (default entry) — so an exit radar's lamp locks on the EXIT camera, not the entry one. (Lane-busy is the only lock kind wired today; the model leaves room for others later.)

Config

An alert lamp is a config.relays[] row with direction: "radarAlert", carrying { relay, triggerInput?, blinkOnMs?, blinkOffMs? }. No separate buttonLight block (that was the pre-2026-06-28 shape — barriers and the lamp were two different configs; now they're one list). Blink defaults to 500 ms / 500 ms. The operator picks a spare relay (an alert relay never opens a barrier; every barrier resolver skips radarAlert rows).

Implementation

apps/server/src/button-light.ts — ButtonLightController reads the radarAlert rows (alertRelaysOf() in device-resolve.ts), subscribes to deviceEvents.onInput (radar) + onLaneStatus (camera), computes the target state per lamp (keyed controllerId:relay, so several alert relays on one controller are independent), and drives each lamp via a device-agnostic aux-output capability.

  • Aux-output capability. AuxOutputDevice { setAux(channel, on) } on the device interface (the Dingtian driver implements it as a latch). Business logic drives the lamp through this — never the driver's barrier methods.
  • Barrier-not-a-door is preserved. The lamp is not a barrier, so holding / blinking it on a timer is fine — the barrier-not-a-door rule forbids timing a barrier closed, and barriers still only ever pulseOpen. The lamp uses the separate setAux latch.
  • Fails OFF. On host loss, shutdown, or a setAux error the lamp defaults OFF — a dead lamp is "no hint", never a misleading solid "go". SOLID is only ever held while busy + present is actively true (never latched on through a crash path).
  • Serialized sends (must — UDP is unordered). The first cut fired fire-and-forget setAux every 500 ms; over unordered UDP the on/off packets reordered/overlapped and the relay latched on whichever packet the device processed last — the lamp got stuck on/off at random (observed on hardware). Fix: a desired-state + serialized worker (#pump). The blink timer only flips a desiredOn flag; the worker guarantees one in-flight send per lamp and, on completion, re-converges to the latest desired state. So the final state is always authoritative and a lost/stale packet self-corrects. This also de-dupes (it skips a send when confirmedOn === desiredOn), so the input stream never spams the controller.
  • Failure backoff + rate-limited logging (2026-07-08). The first serialized-worker cut re-pumped immediately after a FAILED send (confirmedOn unchanged → converge again) — correct for a lost packet, but an unreachable controller (ENETUNREACH, rejects instantly) turned it into a hot loop: hundreds of identical error lines per minute into stdout AND app-logs (field incident 2026-07-07, park-buzi). Now a failed send arms a retry backoff — 1 s doubling to a 30 s cap, reset on success; during the window desiredOn keeps tracking the truth table and the armed retry converges to whatever it says when it fires (#finalOff waives the backoff for the one-shot last-gasp OFF). Logging: only the first failure of a streak is logged, then one summary per minute (still failing (attempt N…)), and a single info on recovery. The app_logs sink additionally coalesces identical rows (see app-logs) as defense in depth.
  • Hot-reloads the config (no restart). The lamp map is reconciled against the live device config at start AND before each event (mirroring device-status-monitoring, which re-reads the device set each tick) — adding/updating/dropping lamps. So a button light added or re-pointed in the setup UI takes effect on the next radar edge, not after a server restart. (The first cut loaded the map once at boot, so a just-saved lamp silently did nothing until restart.)

On-screen twin — the booth barrier lights

The booth's Hyrje / Dalje (Entry / Exit) indicators mirror the physical lamp with the SAME 3-state rule, per lane: radar-present + camera-free → blink green↔red (~1 Hz); camera-busy → solid red; else solid green. So the operator sees the same "detected, not yet confirmed → confirmed → clear" story on screen as the lamp shows on the post.

The radar half is sourced by a small server tracker, LanePresence (lane-presence.ts), that subscribes to deviceEvents.onInput and resolves each presence edge to its lane via presenceLaneOf (device-resolve.ts) — direction-agnostic (entry and exit), unlike the entry-gated relayForPresence the one-car-one-ticket gate uses. It emits a lane-presence {entry,exit} bus event on change; the WS forwards it (hello snapshot + push) into the booth's live-store.radar, and BarrierLight (BoothScreen.tsx) blinks via the .lane-blink keyframe (index.css, holds solid-red under prefers-reduced-motion). The camera half is the existing lpr-camera. Same inputs, same rule as the lamp, so screen and post never disagree.

Status

Built 2026-06-24 for the first booth (button I1, radar I2, lamp on a spare relay); the serialized-send

  • hot-reload fixes landed the same day after the lamp stuck on/off on hardware. Reframed 2026-06-28: the dedicated config.buttonLight block was folded into the unified relays[] list as a radarAlert event-relay (carrying its own triggerInput), so the operator can add arbitrary event-driven blinkers (e.g. R4) without code changes; the 3-state machine itself is unchanged. Covered by apps/server/src/button-light.test.ts (the truth table, blink toggling asserted on the device's confirmed state, fail-OFF, de-dupe, lamp-added-after-start reconcile, two independent alert relays on one controller, and — since 2026-07-08 — backoff cadence on an unreachable controller, log rate-limiting, and single-recovery-line + backoff-reset after success). Related: hikvision-radar, entry-double-press, lpr-camera, dingtian-relay, entry-exit-points, barrier-not-a-door.