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

5.8 KiB
Raw Blame History

type, tags, sources, updated, status
type tags sources updated status
concept
parking
device
indicator
radar
camera
aux-output
barrier-not-a-door
event-relay
2026-06-28 settled

Alert relays (radar × camera disagreement lamp)

A relay on the dingtian-relay controller is uniformly "when EVENT X happens, do action Y" — see entry-exit-points. 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 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). When unset, it falls back to the controller's entry-relay presenceInput — the same edge the entry-double-press gate observes, so the lamp and the gate agree on "a car is here".
  • Lock (camera "car in zone") = the existing lpr-camera (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, 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.