Files
parking_solution/wiki/concepts/button-light-indicator.md
T
julian 4418594af0
Build desktop / desktop (push) Successful in 4m16s
Build & push images / images (push) Successful in 2m43s
CI / check (push) Successful in 38s
refactor(setup): unify controller I/O — event-driven relays[] + generic inputs[]
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
2026-06-28 11:23:15 +02:00

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