Files
parking_solution/wiki/concepts/button-light-indicator.md
T
julian 38481f105f
Build desktop / desktop (push) Successful in 4m14s
Build & push images / images (push) Successful in 2m42s
CI / check (push) Successful in 37s
feat(booth): blink the Entry/Exit lights on radar presence (mirror relay 3)
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
2026-06-28 11:48:12 +02:00

109 lines
6.9 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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]].