refactor(setup): unify controller I/O — event-driven relays[] + generic inputs[]
Build desktop / desktop (push) Successful in 4m16s
Build & push images / images (push) Successful in 2m43s
CI / check (push) Successful in 38s

The controller new/edit modal hardcoded both its outputs and its inputs, so an
operator could neither add a generic event-driven relay nor a free-standing input
(e.g. a second radar at the exit). This unifies both into symmetric, first-class
lists. Behaviour for existing booths is unchanged (back-compat, no DB migration).

Outputs — one event→action relays[] list:
- A relay is "when EVENT X happens, do its action": entry/exit/both pulse a
  barrier; a new `radarAlert` event drives a non-barrier alert lamp (blink while
  its trigger input is active, SOLID once the camera confirms a car).
- Dropped the separate config.buttonLight block — the lamp is just a relays[] row
  with direction:"radarAlert" (triggerInput + blink cadence). `alertRelaysOf()`
  replaces `buttonLightOf()`; ButtonLightController keeps its proven 3-state
  machine (serialized UDP, fail-OFF, hot-reload), now keyed per controllerId:relay
  so several alert lamps on one controller run independently. Every barrier
  resolver skips radarAlert rows (no auto-open; barrier-not-a-door intact).

Inputs — one first-class config.inputs[] list (the twin of relays[]):
- Each row is { input, role, relay?, kind?, activeLow?, cooldownSec? } with a
  "+ Add input" button. role ∈ button | presence | alertTrigger; button/presence
  name the relay they serve. An exit radar is just another presence row.
- Keystone `inputsOf(row)`: returns config.inputs[] or SYNTHESIZES it from the
  legacy relays[].button/presenceInput/... fields, so relayForButton /
  relayForPresence resolve identically from either shape — zero-downtime, no
  migration. entry-flow.ts is unchanged (resolves through the same functions).
- Fixed a latent bug this exposed: the alert lamp's camera lock was hardcoded to
  the ENTRY camera. Added relays[].lockLane ("entry"|"exit", default entry); the
  lamp now locks on its own lane's camera, so an exit radar's lamp tracks the exit
  camera. button-light tracks both #entryBusy/#exitBusy.
- Driver: extracted activeLowFrom(config) — merges inputs[] activeLow, legacy
  relays[].presenceActiveLow, and the inputActiveLow escape hatch.

UI: the relay dropdown gained a "Radar alert" option (reveals trigger/lock/blink
inputs); InputEditor is rewritten to a generic list (role select folds loop/radar);
i18n sq+en kept at type-parity.

Tests: new device-resolve.test.ts (inputs[] resolution + legacy fallback identical
+ exit-radar resolves to the exit relay); button-light gains a two-independent-
alert-relays case and an exit-lamp lockLane case; access-dingtian gains
activeLowFrom cases. Full workspace build/lint/test green (i18n parity included).

Wiki + memory updated (button-light-indicator, entry-double-press, dingtian-relay).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
2026-06-28 11:23:15 +02:00
parent 25a72ff20a
commit 4418594af0
15 changed files with 970 additions and 444 deletions
+43 -26
View File
@@ -1,46 +1,59 @@
---
type: concept
tags: [parking, device, indicator, radar, camera, aux-output, barrier-not-a-door]
tags: [parking, device, indicator, radar, camera, aux-output, barrier-not-a-door, event-relay]
sources: []
updated: 2026-06-24
updated: 2026-06-28
status: settled
---
# Button-light indicator (radar × camera disagreement lamp)
# Alert relays (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:
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:
| Radar input | Camera (lane entry busy) | Button light |
| Trigger input (radar) | Camera (lane entry busy) | Alert lamp |
| --- | --- | --- |
| detecting | **free** — no car confirmed | **BLINK** (~1 Hz) |
| detecting | **busy** — camera confirms a car | **SOLID on** |
| clear | — | **OFF** |
| 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
- **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.
- **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
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.
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` subscribes to `deviceEvents.onInput`
(radar) + `onLaneStatus` (camera), computes the target state per controller, and drives the lamp via
a **device-agnostic aux-output** capability.
`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**
@@ -68,8 +81,12 @@ a **device-agnostic aux-output** capability.
## 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).
+ 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]],
[[barrier-not-a-door]].
[[entry-exit-points]], [[barrier-not-a-door]].