38481f105f
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
109 lines
6.9 KiB
Markdown
109 lines
6.9 KiB
Markdown
---
|
||
type: concept
|
||
tags: [parking, device, indicator, radar, camera, aux-output, barrier-not-a-door, event-relay]
|
||
sources: []
|
||
updated: 2026-06-28
|
||
status: settled
|
||
---
|
||
|
||
# Alert relays (radar × camera disagreement lamp)
|
||
|
||
A relay on the [[dingtian-relay|Dingtian]] controller is uniformly **"when EVENT X happens, do
|
||
action Y"** — see [[entry-exit-points|relays carry an event]]. 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|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|radar]]). When
|
||
unset, it falls back to the controller's entry-relay `presenceInput` — the same edge the
|
||
[[entry-double-press|one-car-one-ticket]] gate observes, so the lamp and the gate agree on "a car
|
||
is here".
|
||
- **Lock (camera "car in zone")** = the existing **[[lpr-camera|lane status]]** (`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.
|
||
- **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|DeviceMonitor]], 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|lane status]]. 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, 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]].
|