--- 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.) ## 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]].