5a5fedf4f4
- dingtian-relay: the "offline despite ping" gotcha (relay_pw in every binary frame, missing form field → Test connection sent 0 → timeout) + the identity-gated secret re-merge that stops a redirected probe exfiltrating the password. - button-light-indicator: serialized desired-state worker (UDP is unordered → the lamp stuck on/off) and hot-reload of the lamp config (no restart). - log entry for the three fixes (commits420542c/fd15988/830993b). Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
76 lines
4.3 KiB
Markdown
76 lines
4.3 KiB
Markdown
---
|
||
type: concept
|
||
tags: [parking, device, indicator, radar, camera, aux-output, barrier-not-a-door]
|
||
sources: []
|
||
updated: 2026-06-24
|
||
status: settled
|
||
---
|
||
|
||
# Button-light indicator (radar × camera disagreement lamp)
|
||
|
||
The entry button has a **12 V light**. It is driven by the host on a **spare relay** of the
|
||
[[dingtian-relay|Dingtian]] controller as a 3-state indicator that combines the **[[hikvision-radar|
|
||
radar]]** input with the **camera "car in zone"** signal:
|
||
|
||
| Radar input | Camera (lane entry busy) | Button light |
|
||
| --- | --- | --- |
|
||
| detecting | **free** — no car confirmed | **BLINK** (~1 Hz) |
|
||
| detecting | **busy** — camera confirms a car | **SOLID on** |
|
||
| clear | — | **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.
|
||
|
||
## Signals
|
||
|
||
- **Radar** = the presence input edge on the entry relay (`relays[].presenceInput`, the same edge
|
||
the [[entry-double-press|one-car-one-ticket]] gate observes — so the lamp and the gate always
|
||
agree on "a car is here").
|
||
- **Camera "car in zone"** = the existing **[[lpr-camera|lane status]]** (`LaneStatusEvent` entry
|
||
busy/free, from camera vehicle detection). Already advisory; already drives the booth's barrier
|
||
lights. No new camera plumbing.
|
||
|
||
## Config
|
||
|
||
A controller-level `config.buttonLight = { relay, blinkOnMs?, blinkOffMs? }` (the operator picks a
|
||
**spare** relay — not a barrier relay; the setup UI warns if it overlaps one). Blink defaults to
|
||
500 ms / 500 ms.
|
||
|
||
## Implementation
|
||
|
||
`apps/server/src/button-light.ts` — `ButtonLightController` subscribes to `deviceEvents.onInput`
|
||
(radar) + `onLaneStatus` (camera), computes the target state per controller, and drives the 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. 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, and a lamp-added-after-start reconcile case).
|
||
Related: [[hikvision-radar]], [[entry-double-press]], [[lpr-camera]], [[dingtian-relay]],
|
||
[[barrier-not-a-door]].
|