fix(button-light): serialize relay sends + hot-reload the lamp config
Build desktop / desktop (push) Successful in 4m20s
Build & push images / images (push) Successful in 2m45s
CI / check (push) Successful in 37s

Two bugs in the button-light controller:

1. Stuck relay (random on/off). The blink fired fire-and-forget setAux every 500ms over
   UNORDERED UDP with no serialization — concurrent on/off packets reordered/overlapped,
   so the relay latched on whichever packet the device processed last. Replace with a
   desired-state + serialized worker (#pump): the blink timer only flips desiredOn; a
   single in-flight send per lamp is guaranteed, and on completion it re-converges to the
   latest desired state — so the final state is always authoritative and a lost/stale
   packet self-corrects.

2. Lamp ignored until restart. The lamp map was built once at start(); a button light
   added/changed via the UI never took effect without a server restart. #reconcile now
   re-reads the device config (at start and before each event, like DeviceMonitor),
   adding/updating/dropping lamps live — so a just-saved lamp blinks on the next radar
   edge.

Tests assert confirmedOf() (the device's latched state); +1 reconcile-after-start case.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
2026-06-24 19:04:18 +02:00
parent fd15988a73
commit 830993bcb8
2 changed files with 194 additions and 61 deletions
+100 -35
View File
@@ -20,17 +20,25 @@ const DEFAULT_BLINK_MS = 500;
/** Per-controller live state for the lamp rule. */
interface LampState {
readonly spec: ButtonLightSpec;
/** Lamp config (relay #, blink ms). Mutable: #reconcile updates it in place when the
* admin changes the button-light config without a restart. */
spec: ButtonLightSpec;
/** Is the radar (presence input on an entry relay) currently active? */
present: boolean;
/** The output we last commanded (de-dupe — avoid UDP spam at the 50ms input poll). */
lastOn: boolean | null;
/** The high-level state we're rendering (to avoid restarting a running blink). */
rendered: LightState | null;
/** Active blink timer, if blinking. */
blink: ReturnType<typeof setInterval> | null;
/** Blink phase (true = currently on). */
blinkOn: boolean;
/** The output we WANT the relay to be in. The serialized worker drives the device
* toward this. The blink timer only flips this flag — it never sends directly. */
desiredOn: boolean;
/** The output we last CONFIRMED on the device (after a successful send). null = unknown. */
confirmedOn: boolean | null;
/** True while a send is in flight for this lamp — serializes UDP so on/off can't
* overlap or reorder (UDP is unordered; concurrent toggles left the relay stuck). */
sending: boolean;
}
/** Resolves a controller's live aux-output adapter. The default goes through the
@@ -59,7 +67,7 @@ export class ButtonLightController {
/** Subscribe to radar input edges + lane status, and initialise every lamp OFF. */
start(): void {
this.#loadLamps();
this.#reconcile();
// All lamps start OFF (known-safe baseline) regardless of prior device state.
for (const [controllerId, lamp] of this.#lamps) this.#apply(controllerId, lamp);
@@ -67,23 +75,45 @@ export class ButtonLightController {
this.#unsubLane = deviceEvents.onLaneStatus((s) => this.#onLane(s));
}
/** (Re)build the lamp map from the current device config. Each enabled access
* controller with a `buttonLight` gets a lamp; others are skipped. */
#loadLamps(): void {
this.#lamps.clear();
/** Reconcile the lamp map with the CURRENT device config (the booth can add/change a
* button light without a server restart). Mirrors DeviceMonitor, which re-reads the
* device set each tick. Adds lamps for newly-configured controllers, updates the spec
* (relay #, blink ms) in place — preserving live `present`/blink state — and drops
* lamps whose controller lost its buttonLight or was disabled. Called at start() and
* before handling each event, so a just-saved lamp takes effect immediately. */
#reconcile(): void {
const rows = this.#db.select().from(devices).where(eq(devices.category, "access")).all();
const seen = new Set<string>();
for (const row of rows) {
if (!row.enabled) continue;
const spec = buttonLightOf(row);
if (!spec) continue;
this.#lamps.set(row.id, {
spec,
present: false,
lastOn: null,
rendered: null,
blink: null,
blinkOn: false,
});
seen.add(row.id);
const existing = this.#lamps.get(row.id);
if (existing) {
existing.spec = spec; // pick up a changed relay # / blink cadence
} else {
this.#lamps.set(row.id, {
spec,
present: false,
rendered: null,
blink: null,
blinkOn: false,
desiredOn: false,
confirmedOn: null,
sending: false,
});
}
}
// Drop lamps whose controller no longer declares one (or was disabled/removed).
for (const [id, lamp] of this.#lamps) {
if (seen.has(id)) continue;
if (lamp.blink) {
clearInterval(lamp.blink);
lamp.blink = null;
}
this.#finalOff(id, lamp); // best-effort fail-OFF before forgetting it
this.#lamps.delete(id);
}
}
@@ -91,6 +121,8 @@ export class ButtonLightController {
* edge the SAME way the entry flow does (relayForPresence on an entry/both relay),
* so the lamp and the one-car-one-ticket gate always agree on "a car is here". */
#onInput(e: DeviceInputEvent): void {
// Reconcile first so a lamp added/changed since boot (no restart) is picked up.
this.#reconcile();
const lamp = this.#lamps.get(e.deviceId);
if (!lamp) return; // no lamp on this controller
const presence = relayForPresence(this.#db, e.deviceId, e.input);
@@ -123,19 +155,24 @@ export class ButtonLightController {
lamp.rendered = target;
if (target === "off") {
void this.#drive(controllerId, lamp, false);
lamp.desiredOn = false;
this.#pump(controllerId, lamp);
} else if (target === "solid") {
void this.#drive(controllerId, lamp, true);
lamp.desiredOn = true;
this.#pump(controllerId, lamp);
} else {
// BLINK: arm the toggle timer SYNCHRONOUSLY (it must not wait on a UDP write), then
// drive the first "on". A symmetric cadence uses one interval; an asymmetric one
// re-arms each phase with its own duration.
// BLINK: a wall-clock timer flips ONLY the desired flag; #pump does the actual
// (serialized) UDP send. A symmetric cadence uses one interval; an asymmetric one
// re-arms each phase with its own duration. Sends never overlap or reorder, so the
// relay can't get stuck on a stale packet.
const onMs = lamp.spec.blinkOnMs && lamp.spec.blinkOnMs > 0 ? lamp.spec.blinkOnMs : DEFAULT_BLINK_MS;
const offMs = lamp.spec.blinkOffMs && lamp.spec.blinkOffMs > 0 ? lamp.spec.blinkOffMs : DEFAULT_BLINK_MS;
lamp.blinkOn = true;
lamp.desiredOn = true;
const tick = () => {
lamp.blinkOn = !lamp.blinkOn;
void this.#drive(controllerId, lamp, lamp.blinkOn);
lamp.desiredOn = lamp.blinkOn;
this.#pump(controllerId, lamp);
if (onMs !== offMs && lamp.blink) {
clearInterval(lamp.blink);
lamp.blink = setInterval(tick, lamp.blinkOn ? onMs : offMs);
@@ -144,23 +181,37 @@ export class ButtonLightController {
};
lamp.blink = setInterval(tick, onMs);
lamp.blink.unref?.();
void this.#drive(controllerId, lamp, true);
this.#pump(controllerId, lamp);
}
}
/** Latch the lamp's relay via the device's aux-output capability. De-duped + fail-OFF:
* an error logs and leaves `lastOn` unchanged so the next compute retries. */
async #drive(controllerId: string, lamp: LampState, on: boolean): Promise<void> {
if (lamp.lastOn === on) return; // no redundant UDP writes
/** Serialized per-lamp worker: drive the relay toward `desiredOn`, one UDP send at a
* time. Because UDP is unordered, concurrent on/off sends previously raced and left
* the relay stuck on a stale packet. Here a single in-flight send is guaranteed
* (`sending` guard); when it resolves, if the desired state moved on we send again —
* so the LAST desired state is always the one finally asserted on the device. */
#pump(controllerId: string, lamp: LampState): void {
if (lamp.sending) return; // a send is already in flight; it'll re-check on completion
if (lamp.confirmedOn === lamp.desiredOn) return; // already there — no redundant UDP
const aux = this.#resolveAux(controllerId);
if (!aux) return;
try {
await aux.setAux(lamp.spec.relay, on);
lamp.lastOn = on;
} catch (err) {
this.#logger.error(`button-light setAux failed (${controllerId} R${lamp.spec.relay}): ${(err as Error).message}`);
// Leave lastOn unchanged → retried on the next state compute. Never escalates.
}
const target = lamp.desiredOn;
lamp.sending = true;
void aux
.setAux(lamp.spec.relay, target)
.then(() => {
lamp.confirmedOn = target;
})
.catch((err: unknown) => {
// Leave confirmedOn unchanged so the next pump retries this state. Never escalates.
this.#logger.error(`button-light setAux failed (${controllerId} R${lamp.spec.relay}): ${(err as Error).message}`);
})
.finally(() => {
lamp.sending = false;
// Desired state may have changed (or the send failed) while we were busy —
// re-pump to converge. This is what makes the final state authoritative.
if (lamp.confirmedOn !== lamp.desiredOn) this.#pump(controllerId, lamp);
});
}
/** Build the live aux-output adapter for a controller, or null (logged once). */
@@ -197,14 +248,28 @@ export class ButtonLightController {
lamp.blink = null;
}
// Best-effort fail-OFF on shutdown.
void this.#drive(controllerId, lamp, false);
this.#finalOff(controllerId, lamp);
}
}
/** Drive a lamp OFF as a one-shot (used when dropping/stopping a lamp): set desired
* OFF and pump. The serialized worker still applies, so this can't collide with an
* in-flight send — it converges to OFF. */
#finalOff(controllerId: string, lamp: LampState): void {
lamp.desiredOn = false;
this.#pump(controllerId, lamp);
}
/** Test seam: current high-level state being rendered for a controller. */
stateOf(controllerId: string): LightState | null {
return this.#lamps.get(controllerId)?.rendered ?? null;
}
/** Test seam: the state last CONFIRMED on the device for a controller (after a
* successful send). null = unknown / nothing sent yet. */
confirmedOf(controllerId: string): boolean | null {
return this.#lamps.get(controllerId)?.confirmedOn ?? null;
}
}
/** Build a controller row's live aux device (exported for reuse/tests). */