The on-screen Hyrje/Dalje barrier lights were 2-state (green=free / red=busy)
off the camera lane-status only — they couldn't show the radar-only "detected,
not yet confirmed" state that makes the physical button lamp (relay 3) blink.
Now they mirror the lamp's 3-state rule per lane:
radar present + camera not busy → BLINK green↔red (~1 Hz)
camera busy → SOLID red
otherwise → SOLID green
End-to-end:
- LanePresence (lane-presence.ts): subscribes to deviceEvents.onInput, resolves
each presence edge to its lane via the new direction-agnostic presenceLaneOf()
(device-resolve.ts) — entry AND exit, unlike the entry-gated relayForPresence
the one-car-one-ticket gate uses — and emits a lane-presence {entry,exit} bus
event on change. Wired in server.ts (start + onClose).
- WS forwards it (hello snapshot + push) into live-store.radar.
- BarrierLight (BoothScreen.tsx) is now 3-state; blinks via the .lane-blink
keyframe (index.css), which holds solid-red under prefers-reduced-motion.
Same input + same rule as the lamp, so the screen and the post never disagree.
A new test (lane-presence.test.ts) caught a real bug: the first cut reused
relayForPresence, so the EXIT lane never resolved (it's entry-gated) and never
blinked — presenceLaneOf fixes it. Covers entry/exit independence, de-dupe
across several radars on one lane, and ignoring non-presence inputs.
Full workspace build/lint/test green (185 server tests). Updated the
button-light-indicator wiki page ("On-screen twin").
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
6.9 KiB
type, tags, sources, updated, status
| type | tags | sources | updated | status | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| concept |
|
2026-06-28 | 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
triggerInputedge (the hikvision-radar). When unset, it falls back to the controller's entry-relaypresenceInput— 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 viarelays[].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 separatesetAuxlatch. - Fails OFF. On host loss, shutdown, or a
setAuxerror 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
setAuxevery 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 adesiredOnflag; 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 whenconfirmedOn === desiredOn), so the input stream never spams the controller. - 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.buttonLightblock was folded into the unifiedrelays[]list as aradarAlertevent-relay (carrying its owntriggerInput), so the operator can add arbitrary event-driven blinkers (e.g. R4) without code changes; the 3-state machine itself is unchanged. Covered byapps/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, and two independent alert relays on one controller). Related: hikvision-radar, entry-double-press, lpr-camera, dingtian-relay, entry-exit-points, barrier-not-a-door.