Files
julian 22544ecf63
Build desktop / desktop (push) Successful in 4m37s
CI / check (push) Successful in 42s
Build & push images / images (push) Successful in 2m51s
docs(wiki): log-storm hardening + reset drift guard (2026-07-07 incident)
button-light-indicator: failure backoff + rate-limited logging rationale;
app-logs: storm coalescing invariant + --diagnostics wipe; local-dev-workflow
and appliance-provisioning §7d: new reset flag table + drift guard; log entry
tying all three layers to the ENETUNREACH incident.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-10 08:29:59 +02:00

120 lines
8.0 KiB
Markdown
Raw Permalink 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-07-08
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.
- **Failure backoff + rate-limited logging (2026-07-08).** The first serialized-worker cut re-pumped
*immediately* after a FAILED send (`confirmedOn` unchanged → converge again) — correct for a lost
packet, but an **unreachable controller** (`ENETUNREACH`, rejects instantly) turned it into a hot
loop: hundreds of identical error lines per minute into stdout AND [[app-logs]] (field incident
2026-07-07, park-buzi). Now a failed send arms a **retry backoff — 1 s doubling to a 30 s cap,
reset on success**; during the window `desiredOn` keeps tracking the truth table and the armed
retry converges to whatever it says when it fires (`#finalOff` waives the backoff for the one-shot
last-gasp OFF). Logging: only the **first** failure of a streak is logged, then **one summary per
minute** (`still failing (attempt N…)`), and a single `info` on recovery. The app_logs sink
additionally coalesces identical rows (see [[app-logs]]) as defense in depth.
- **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.)
## On-screen twin — the booth barrier lights
The booth's **Hyrje / Dalje (Entry / Exit) indicators** mirror the physical lamp with the SAME
3-state rule, per lane: radar-present + camera-free → **blink green↔red** (~1 Hz); camera-busy →
**solid red**; else **solid green**. So the operator sees the same "detected, not yet confirmed →
confirmed → clear" story on screen as the lamp shows on the post.
The radar half is sourced by a small server tracker, **`LanePresence` (`lane-presence.ts`)**, that
subscribes to `deviceEvents.onInput` and resolves each presence edge to its lane via
**`presenceLaneOf`** (`device-resolve.ts`) — direction-agnostic (entry **and** exit), unlike the
entry-gated `relayForPresence` the one-car-one-ticket gate uses. It emits a `lane-presence`
`{entry,exit}` bus event on change; the WS forwards it (hello snapshot + push) into the booth's
`live-store.radar`, and `BarrierLight` (`BoothScreen.tsx`) blinks via the `.lane-blink` keyframe
(`index.css`, holds solid-red under `prefers-reduced-motion`). The camera half is the existing
[[lpr-camera|lane status]]. Same inputs, same rule as the lamp, so screen and post never disagree.
## 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, two independent
alert relays on one controller, and — since 2026-07-08 — backoff cadence on an unreachable
controller, log rate-limiting, and single-recovery-line + backoff-reset after success).
Related: [[hikvision-radar]], [[entry-double-press]], [[lpr-camera]], [[dingtian-relay]],
[[entry-exit-points]], [[barrier-not-a-door]].