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
This commit is contained in:
@@ -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]].
|
||||
|
||||
@@ -27,11 +27,16 @@ The guard lives on the entry relay's spec (`config.relays[]` — see [[entry-exi
|
||||
whether real one-car-one-ticket is *possible* depends on the hardware at that lane. Two modes:
|
||||
|
||||
### PRESENCE mode (preferred — when a vehicle-presence sensor is wired)
|
||||
`relays[].presenceInput` = the 1-based input terminal of a **vehicle-presence sensor** on the same
|
||||
Inputs are a first-class `config.inputs[]` list (the twin of `relays[]`): each row is a terminal +
|
||||
a **role** (`button` / `presence` / `alertTrigger`) + the `relay` it serves. A **presence** row =
|
||||
the 1-based input terminal of a **vehicle-presence sensor** serving an entry/both relay on the same
|
||||
[[dingtian-relay|controller]] (the Dingtian's inputs are decoupled from its relays). The sensor may
|
||||
be an **induction loop** OR a **[[hikvision-radar|radar]]** (`relays[].presenceKind: "loop"|"radar"`
|
||||
— a label; the gate behaviour is identical). A radar wired to idle opposite the button needs
|
||||
`presenceActiveLow: true` so its edge reads correctly. The rule makes one-car-one-ticket **physical**:
|
||||
be an **induction loop** OR a **[[hikvision-radar|radar]]** (`inputs[].kind: "loop"|"radar"` — a
|
||||
label; the gate behaviour is identical). A radar wired to idle opposite the button needs
|
||||
`inputs[].activeLow: true` so its edge reads correctly. **Multiple radars (entry + exit) are just
|
||||
multiple presence rows** — adding an exit radar is adding a row. (Pre-2026-06-28 configs wired this
|
||||
on `relays[].presenceInput/presenceKind/presenceActiveLow`; the resolvers still read those as
|
||||
back-compat, synthesizing inputs[] from them.) The rule makes one-car-one-ticket **physical**:
|
||||
|
||||
- A press prints **only while a car is present** on the loop.
|
||||
- After a ticket prints, the relay is **disarmed** — no second ticket — **until the loop CLEARS**
|
||||
@@ -68,11 +73,14 @@ in telemetry if ever needed.
|
||||
host (single-writer); it is derived from live input edges, never the source of truth. A restart
|
||||
starts armed (the first press after a restart works), which is the safe default.
|
||||
|
||||
## As-built (2026-06-19)
|
||||
## As-built (2026-06-19; inputs[] 2026-06-28)
|
||||
|
||||
- `RelaySpec` gains `presenceInput?` + `entryCooldownSec?` (`device-resolve.ts`); `relayForButton`
|
||||
carries them onto the `ResolvedRelay`, and a new `relayForPresence()` resolves a loop-input edge to
|
||||
the entry relay it gates.
|
||||
- Inputs live in `config.inputs[] = [{ input, role, relay?, kind?, activeLow?, cooldownSec? }]`
|
||||
(`device-resolve.ts`). `inputsOf(row)` returns them, **or synthesizes** the list from the legacy
|
||||
`relays[].button/presenceInput/...` fields when a controller predates inputs[] (one back-compat
|
||||
shim; the UI no longer writes the legacy fields). `relayForButton`/`relayForPresence` resolve
|
||||
through `inputsOf`, carry `presenceInput`/`entryCooldownSec` onto the `ResolvedRelay`, and only ever
|
||||
gate entry/both relays. An exit radar = a `presence` row on the exit relay.
|
||||
- `EntryFlow` (`entry-flow.ts`) keeps a `#guard` map keyed `controllerId:relay`: `#onPresenceEdge`
|
||||
tracks the loop, `#suppressReason` decides presence/cooldown, `#recordSuppressedPress` writes the
|
||||
telemetry. The guard disarms + stamps the cooldown on **print success** (not on open).
|
||||
|
||||
@@ -56,18 +56,31 @@ web/config API is on a configurable HTTP port (default **80**), distinct from th
|
||||
### Spare relays + aux outputs (`setAux`)
|
||||
|
||||
A 4-input board typically has spare relays once the entry/exit barriers are wired. These drive
|
||||
**non-barrier indicators** — e.g. the entry button's 12 V lamp (see [[button-light-indicator]]).
|
||||
Business logic drives them through the device-agnostic `AuxOutputDevice.setAux(channel, on)` (a
|
||||
latch), **never** the barrier `pulseOpen`. The [[barrier-not-a-door]] rule doesn't apply to an aux
|
||||
output (it never gates a vehicle), so holding/blinking it is fine.
|
||||
**non-barrier indicators** — e.g. the entry button's 12 V lamp. Every relay is a `config.relays[]`
|
||||
row carrying the **event** it reacts to (`direction`): the barrier events (`entry`/`exit`/`both`)
|
||||
pulse, while a **`radarAlert`** row is an [[button-light-indicator|alert relay]] (blink + camera-lock).
|
||||
Business logic drives alert relays through the device-agnostic `AuxOutputDevice.setAux(channel, on)`
|
||||
(a latch), **never** the barrier `pulseOpen`; every barrier resolver skips `radarAlert` rows. The
|
||||
[[barrier-not-a-door]] rule doesn't apply to an aux output (it never gates a vehicle), so
|
||||
holding/blinking it is fine.
|
||||
|
||||
### Per-input active level (`presenceActiveLow` / `inputActiveLow`)
|
||||
### Inputs are a first-class list (`config.inputs[]`)
|
||||
|
||||
Input wiring lives in `config.inputs[] = [{ input, role, relay?, kind?, activeLow?, cooldownSec? }]`
|
||||
— the twin of `relays[]`. `role` ∈ `button` | `presence` | `alertTrigger`; a button/presence row
|
||||
names the `relay` it serves; presence rows carry `kind` (loop/radar) + `activeLow`. Adding an exit
|
||||
radar is adding a `presence` row. (Pre-2026-06-28 configs wired this on the relay itself —
|
||||
`relays[].button/presenceInput/...`; `inputsOf()` synthesizes inputs[] from those for back-compat,
|
||||
so old configs keep working until re-saved.)
|
||||
|
||||
### Per-input active level (`inputs[].activeLow` / `inputActiveLow`)
|
||||
|
||||
Inputs are normalised against ONE board-wide resting level (`inputRestingHigh`). When a sensor (e.g.
|
||||
a [[hikvision-radar|radar]]) idles **opposite** the button, list its terminal as active-LOW —
|
||||
sourced from each relay's `presenceActiveLow`, merged into the driver's `inputActiveLow` set — so
|
||||
that one input is read inverted while the button keeps the board default. (`inputActive()` is the
|
||||
pure helper; push-mode uses the device's own `ilu.active_level` instead.)
|
||||
a [[hikvision-radar|radar]]) idles **opposite** the button, mark its terminal active-LOW — sourced
|
||||
from `inputs[].activeLow` (and the legacy `relays[].presenceActiveLow`, plus an explicit top-level
|
||||
`inputActiveLow[]` escape hatch), all merged by `activeLowFrom()` into the driver's `inputActiveLow`
|
||||
set — so that one input is read inverted while the button keeps the board default. (`inputActive()`
|
||||
is the pure helper; push-mode uses the device's own `ilu.active_level` instead.)
|
||||
|
||||
### Precondition: input_link_relay must be OFF
|
||||
|
||||
|
||||
+41
@@ -1753,3 +1753,44 @@ event for this class — it's the *target filter* that matters.
|
||||
Flagged an **observability gap**: a camera with `alarmPushEnabled=true` and 0 pushes ever should be
|
||||
a surfaced status (cf. the reader-liveness fix). Recorded in the `g3h-anpr-push-gotchas` memory + a
|
||||
new troubleshooting section in [[lpr-camera]]. No code changed — diagnosis + camera reconfig only.
|
||||
|
||||
## [2026-06-28] refactor | Unified controller relays into one event→action list (drop config.buttonLight)
|
||||
Reframed the controller "Outputs — relays" model with the user: **Entry / Exit / Both are EVENTS**,
|
||||
not a "direction" — a relay is uniformly *"when EVENT X happens, do action Y"*. Barrier events
|
||||
(`entry`/`exit`/`both`) `pulseOpen`; 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 now just another `config.relays[]` row
|
||||
(`direction:"radarAlert"`, carrying `triggerInput` + blink cadence). One list, one editor, one shape;
|
||||
a future "R4 alert" is just another row with its own trigger input — no new config, no code change.
|
||||
The proven `ButtonLightController` 3-state machine (serialized UDP, fail-OFF, hot-reload) is kept
|
||||
verbatim — only its source changed from `buttonLightOf()` to `alertRelaysOf()`, keyed per
|
||||
`controllerId:relay` so several alert relays on one controller run independently. Every barrier
|
||||
resolver skips `radarAlert` rows (no auto-open; barrier-not-a-door intact). Touched
|
||||
`device-resolve.ts`, `button-light.ts`, `device-monitor.ts`, web `api.ts` + `SetupWizard.tsx` (the
|
||||
dropdown gained a "Radar alert" option that reveals trigger/blink inputs), i18n sq+en. Tests:
|
||||
rewrote `button-light.test.ts` to the `radarAlert` row + added a two-independent-alert-relays case;
|
||||
full workspace `build lint test` green (173 server tests). Updated [[button-light-indicator]],
|
||||
[[dingtian-relay]], memory `access-direction-is-per-relay`.
|
||||
|
||||
## [2026-06-28] refactor | Generic controller inputs (config.inputs[]) — the twin of unified relays[]
|
||||
After unifying OUTPUTS into one event→action `relays[]`, did the same for INPUTS — the user hit the
|
||||
wall that **there was no way to add a free-standing input** (e.g. an EXIT radar): inputs were fields
|
||||
bolted onto an entry barrier relay (`relays[].button/presenceInput/...`) and the UI only rendered a
|
||||
button+presence block per entry/both relay. Now a first-class **`config.inputs[]`** list — each row
|
||||
`{ input, role: "button"|"presence"|"alertTrigger", relay?, kind?, activeLow?, cooldownSec? }` — with
|
||||
a "+ Add input" button. An exit radar = just another `presence` row serving the exit relay. **Keystone:
|
||||
`inputsOf(row)`** returns `config.inputs[]` or SYNTHESIZES it from the legacy per-relay fields, so
|
||||
`relayForButton`/`relayForPresence` resolve identically from either shape — **zero-downtime, no DB
|
||||
migration** (old configs keep working until re-saved; the UI seeds its editor from the synth).
|
||||
`entry-flow.ts` is unchanged (resolves through the same functions). Also fixed a latent bug this
|
||||
exposed: the alert lamp's camera **lock** was hardcoded to the ENTRY camera — added
|
||||
`relays[].lockLane: "entry"|"exit"` (button-light tracks both `#entryBusy`/`#exitBusy`; a lamp goes
|
||||
SOLID off its own lane's camera), so an exit radar's lamp locks on the EXIT camera. Driver: extracted
|
||||
`activeLowFrom(config)` (merges inputs[] `activeLow` + legacy `presenceActiveLow` + the `inputActiveLow`
|
||||
escape hatch). Touched `device-resolve.ts`, `button-light.ts`, `access-dingtian.ts`, web `api.ts` +
|
||||
`SetupWizard.tsx` (InputEditor rewritten to a generic list; role select folds loop/radar; OutputEditor
|
||||
radarAlert row gained a lock-lane select), i18n sq+en. Tests: new `device-resolve.test.ts` (inputs[]
|
||||
resolution + legacy-fallback identical + exit-radar resolves to the exit relay), exit-lamp lockLane
|
||||
case in `button-light.test.ts`, `activeLowFrom` cases in the dingtian suite. Full workspace
|
||||
`build lint test` green. Updated [[entry-double-press]], [[button-light-indicator]], [[dingtian-relay]],
|
||||
memory `access-direction-is-per-relay`.
|
||||
|
||||
Reference in New Issue
Block a user