fix(button-light): serialize relay sends + hot-reload the lamp config
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:
@@ -1,6 +1,6 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { devices, type Db } from "@parking/db";
|
import { eq, devices, type Db } from "@parking/db";
|
||||||
import { createTestDb } from "@parking/db/testing";
|
import { createTestDb } from "@parking/db/testing";
|
||||||
import type { AuxOutputDevice } from "@parking/devices";
|
import type { AuxOutputDevice } from "@parking/devices";
|
||||||
import { ButtonLightController } from "./button-light.js";
|
import { ButtonLightController } from "./button-light.js";
|
||||||
@@ -72,107 +72,130 @@ function lane(entryBusy: boolean): void {
|
|||||||
deviceEvents.emitLaneStatus({ entry: entryBusy, exit: false });
|
deviceEvents.emitLaneStatus({ entry: entryBusy, exit: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Flush the microtask queue so serialized setAux promises (and their re-pump on
|
||||||
|
* completion) settle. The lamp worker sends ONE UDP at a time and re-pumps on resolve;
|
||||||
|
* a few turns drain a burst. Needed because sends are now async (was synchronous). */
|
||||||
|
async function flush(): Promise<void> {
|
||||||
|
for (let i = 0; i < 6; i++) await Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
describe("ButtonLightController truth table", () => {
|
describe("ButtonLightController truth table", () => {
|
||||||
it("OFF at start (no radar, no car)", () => {
|
it("OFF at start (no radar, no car)", async () => {
|
||||||
const calls: Array<{ ch: number; on: boolean }> = [];
|
const calls: Array<{ ch: number; on: boolean }> = [];
|
||||||
const ctl = new ButtonLightController(db, silentLogger(), () => fakeAux(calls));
|
const ctl = new ButtonLightController(db, silentLogger(), () => fakeAux(calls));
|
||||||
ctl.start();
|
ctl.start();
|
||||||
|
await flush();
|
||||||
expect(ctl.stateOf(CONTROLLER)).toBe("off");
|
expect(ctl.stateOf(CONTROLLER)).toBe("off");
|
||||||
// Initial apply drives the lamp off (false). It may de-dupe to no call since
|
// confirmedOn starts null; OFF de-dupes (null !== false → one off write), so the
|
||||||
// lastOn starts null -> false IS a change, so exactly one off write.
|
// device is confirmed OFF and at most one call was made.
|
||||||
expect(calls).toEqual([{ ch: LAMP_RELAY, on: false }]);
|
expect(ctl.confirmedOf(CONTROLLER)).toBe(false);
|
||||||
ctl.stop();
|
ctl.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("radar present + lane busy -> SOLID on", () => {
|
it("radar present + lane busy -> SOLID on", async () => {
|
||||||
const calls: Array<{ ch: number; on: boolean }> = [];
|
const calls: Array<{ ch: number; on: boolean }> = [];
|
||||||
const aux = fakeAux(calls);
|
const aux = fakeAux(calls);
|
||||||
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
|
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
|
||||||
ctl.start();
|
ctl.start();
|
||||||
calls.length = 0;
|
await flush();
|
||||||
lane(true);
|
lane(true);
|
||||||
radar(true);
|
radar(true);
|
||||||
|
await flush();
|
||||||
expect(ctl.stateOf(CONTROLLER)).toBe("solid");
|
expect(ctl.stateOf(CONTROLLER)).toBe("solid");
|
||||||
expect(calls.at(-1)).toEqual({ ch: LAMP_RELAY, on: true });
|
expect(ctl.confirmedOf(CONTROLLER)).toBe(true); // device latched ON
|
||||||
// Solid = no blinking: advancing time produces no further toggles.
|
// Solid = no blinking: advancing time produces no further sends.
|
||||||
const n = calls.length;
|
const n = calls.length;
|
||||||
vi.advanceTimersByTime(2000);
|
vi.advanceTimersByTime(2000);
|
||||||
|
await flush();
|
||||||
expect(calls.length).toBe(n);
|
expect(calls.length).toBe(n);
|
||||||
ctl.stop();
|
ctl.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("radar present + lane free -> BLINK (toggles over time)", () => {
|
it("radar present + lane free -> BLINK (toggles the device over time)", async () => {
|
||||||
const calls: Array<{ ch: number; on: boolean }> = [];
|
const calls: Array<{ ch: number; on: boolean }> = [];
|
||||||
const aux = fakeAux(calls);
|
const aux = fakeAux(calls);
|
||||||
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
|
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
|
||||||
ctl.start();
|
ctl.start();
|
||||||
calls.length = 0;
|
await flush();
|
||||||
radar(true); // lane still free
|
radar(true); // lane still free
|
||||||
|
await flush();
|
||||||
expect(ctl.stateOf(CONTROLLER)).toBe("blink");
|
expect(ctl.stateOf(CONTROLLER)).toBe("blink");
|
||||||
expect(calls.at(-1)).toEqual({ ch: LAMP_RELAY, on: true }); // on now
|
expect(ctl.confirmedOf(CONTROLLER)).toBe(true); // on now
|
||||||
vi.advanceTimersByTime(500);
|
vi.advanceTimersByTime(500);
|
||||||
expect(calls.at(-1)).toEqual({ ch: LAMP_RELAY, on: false }); // toggled off
|
await flush();
|
||||||
|
expect(ctl.confirmedOf(CONTROLLER)).toBe(false); // toggled off
|
||||||
vi.advanceTimersByTime(500);
|
vi.advanceTimersByTime(500);
|
||||||
expect(calls.at(-1)).toEqual({ ch: LAMP_RELAY, on: true }); // toggled on
|
await flush();
|
||||||
|
expect(ctl.confirmedOf(CONTROLLER)).toBe(true); // toggled on
|
||||||
ctl.stop();
|
ctl.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("blink -> solid when the camera confirms a car (lane busy)", () => {
|
it("blink -> solid when the camera confirms a car (lane busy)", async () => {
|
||||||
const calls: Array<{ ch: number; on: boolean }> = [];
|
const calls: Array<{ ch: number; on: boolean }> = [];
|
||||||
const aux = fakeAux(calls);
|
const aux = fakeAux(calls);
|
||||||
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
|
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
|
||||||
ctl.start();
|
ctl.start();
|
||||||
|
await flush();
|
||||||
radar(true); // blink
|
radar(true); // blink
|
||||||
|
await flush();
|
||||||
expect(ctl.stateOf(CONTROLLER)).toBe("blink");
|
expect(ctl.stateOf(CONTROLLER)).toBe("blink");
|
||||||
lane(true); // camera confirms
|
lane(true); // camera confirms
|
||||||
|
await flush();
|
||||||
expect(ctl.stateOf(CONTROLLER)).toBe("solid");
|
expect(ctl.stateOf(CONTROLLER)).toBe("solid");
|
||||||
expect(calls.at(-1)).toEqual({ ch: LAMP_RELAY, on: true });
|
expect(ctl.confirmedOf(CONTROLLER)).toBe(true);
|
||||||
// No more toggles (blink torn down).
|
// No more toggles (blink torn down) — the device stays ON over time.
|
||||||
const n = calls.length;
|
|
||||||
vi.advanceTimersByTime(2000);
|
vi.advanceTimersByTime(2000);
|
||||||
expect(calls.length).toBe(n);
|
await flush();
|
||||||
|
expect(ctl.confirmedOf(CONTROLLER)).toBe(true);
|
||||||
ctl.stop();
|
ctl.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("radar clears -> OFF", () => {
|
it("radar clears -> OFF", async () => {
|
||||||
const calls: Array<{ ch: number; on: boolean }> = [];
|
const calls: Array<{ ch: number; on: boolean }> = [];
|
||||||
const aux = fakeAux(calls);
|
const aux = fakeAux(calls);
|
||||||
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
|
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
|
||||||
ctl.start();
|
ctl.start();
|
||||||
|
await flush();
|
||||||
lane(true);
|
lane(true);
|
||||||
radar(true); // solid
|
radar(true); // solid
|
||||||
calls.length = 0;
|
await flush();
|
||||||
radar(false); // car gone
|
radar(false); // car gone
|
||||||
|
await flush();
|
||||||
expect(ctl.stateOf(CONTROLLER)).toBe("off");
|
expect(ctl.stateOf(CONTROLLER)).toBe("off");
|
||||||
expect(calls.at(-1)).toEqual({ ch: LAMP_RELAY, on: false });
|
expect(ctl.confirmedOf(CONTROLLER)).toBe(false); // device latched OFF
|
||||||
ctl.stop();
|
ctl.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("de-dupes redundant writes (no spam on repeat events)", () => {
|
it("de-dupes redundant writes (no spam on repeat events)", async () => {
|
||||||
const calls: Array<{ ch: number; on: boolean }> = [];
|
const calls: Array<{ ch: number; on: boolean }> = [];
|
||||||
const aux = fakeAux(calls);
|
const aux = fakeAux(calls);
|
||||||
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
|
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
|
||||||
ctl.start();
|
ctl.start();
|
||||||
|
await flush();
|
||||||
lane(true);
|
lane(true);
|
||||||
radar(true); // solid, on
|
radar(true); // solid, on
|
||||||
|
await flush();
|
||||||
const n = calls.length;
|
const n = calls.length;
|
||||||
radar(true); // same state — no new edge (present unchanged)
|
radar(true); // same state — no new edge (present unchanged)
|
||||||
lane(true); // same lane — no change
|
lane(true); // same lane — no change
|
||||||
|
await flush();
|
||||||
expect(calls.length).toBe(n);
|
expect(calls.length).toBe(n);
|
||||||
ctl.stop();
|
ctl.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("fails OFF: a setAux error does not throw or escalate", () => {
|
it("fails OFF: a setAux error does not throw or escalate", async () => {
|
||||||
const calls: Array<{ ch: number; on: boolean }> = [];
|
const calls: Array<{ ch: number; on: boolean }> = [];
|
||||||
const throwOnce = { v: true };
|
const throwOnce = { v: true };
|
||||||
const aux = fakeAux(calls, throwOnce);
|
const aux = fakeAux(calls, throwOnce);
|
||||||
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
|
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
|
||||||
// First write (initial off) throws — must be swallowed.
|
// First write (initial off) throws — must be swallowed.
|
||||||
expect(() => ctl.start()).not.toThrow();
|
expect(() => ctl.start()).not.toThrow();
|
||||||
// Subsequent writes work; driving to solid still succeeds.
|
await flush();
|
||||||
|
// Subsequent writes work; driving to solid still converges to ON.
|
||||||
lane(true);
|
lane(true);
|
||||||
radar(true);
|
radar(true);
|
||||||
expect(calls.at(-1)).toEqual({ ch: LAMP_RELAY, on: true });
|
await flush();
|
||||||
|
expect(ctl.confirmedOf(CONTROLLER)).toBe(true);
|
||||||
ctl.stop();
|
ctl.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -191,4 +214,49 @@ describe("ButtonLightController truth table", () => {
|
|||||||
expect(ctl.stateOf("ctl-2")).toBeNull();
|
expect(ctl.stateOf("ctl-2")).toBeNull();
|
||||||
ctl.stop();
|
ctl.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("picks up a button light ADDED after start() (no restart needed)", async () => {
|
||||||
|
// Fresh controller with a radar input but NO buttonLight yet.
|
||||||
|
const calls: Array<{ ch: number; on: boolean }> = [];
|
||||||
|
const aux = fakeAux(calls);
|
||||||
|
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
|
||||||
|
// Replace the seeded controller with one that has the radar but no lamp.
|
||||||
|
db.update(devices)
|
||||||
|
.set({
|
||||||
|
config: {
|
||||||
|
host: "10.0.0.5",
|
||||||
|
relays: [{ relay: 1, direction: "entry", presenceInput: RADAR_INPUT, presenceKind: "radar" }],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.where(eq(devices.id, CONTROLLER))
|
||||||
|
.run();
|
||||||
|
ctl.start();
|
||||||
|
await flush();
|
||||||
|
// No lamp configured → an input does nothing.
|
||||||
|
radar(true);
|
||||||
|
await flush();
|
||||||
|
expect(ctl.stateOf(CONTROLLER)).toBeNull();
|
||||||
|
expect(calls.length).toBe(0);
|
||||||
|
radar(false);
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
// Admin saves a button light (relay 3) — without restarting the server.
|
||||||
|
db.update(devices)
|
||||||
|
.set({
|
||||||
|
config: {
|
||||||
|
host: "10.0.0.5",
|
||||||
|
relays: [{ relay: 1, direction: "entry", presenceInput: RADAR_INPUT, presenceKind: "radar" }],
|
||||||
|
buttonLight: { relay: LAMP_RELAY, blinkOnMs: 500, blinkOffMs: 500 },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.where(eq(devices.id, CONTROLLER))
|
||||||
|
.run();
|
||||||
|
|
||||||
|
// The very next radar edge reconciles + blinks (lane still free).
|
||||||
|
radar(true);
|
||||||
|
await flush();
|
||||||
|
expect(ctl.stateOf(CONTROLLER)).toBe("blink");
|
||||||
|
expect(ctl.confirmedOf(CONTROLLER)).toBe(true);
|
||||||
|
ctl.stop();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -20,17 +20,25 @@ const DEFAULT_BLINK_MS = 500;
|
|||||||
|
|
||||||
/** Per-controller live state for the lamp rule. */
|
/** Per-controller live state for the lamp rule. */
|
||||||
interface LampState {
|
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? */
|
/** Is the radar (presence input on an entry relay) currently active? */
|
||||||
present: boolean;
|
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). */
|
/** The high-level state we're rendering (to avoid restarting a running blink). */
|
||||||
rendered: LightState | null;
|
rendered: LightState | null;
|
||||||
/** Active blink timer, if blinking. */
|
/** Active blink timer, if blinking. */
|
||||||
blink: ReturnType<typeof setInterval> | null;
|
blink: ReturnType<typeof setInterval> | null;
|
||||||
/** Blink phase (true = currently on). */
|
/** Blink phase (true = currently on). */
|
||||||
blinkOn: boolean;
|
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
|
/** 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. */
|
/** Subscribe to radar input edges + lane status, and initialise every lamp OFF. */
|
||||||
start(): void {
|
start(): void {
|
||||||
this.#loadLamps();
|
this.#reconcile();
|
||||||
// All lamps start OFF (known-safe baseline) regardless of prior device state.
|
// All lamps start OFF (known-safe baseline) regardless of prior device state.
|
||||||
for (const [controllerId, lamp] of this.#lamps) this.#apply(controllerId, lamp);
|
for (const [controllerId, lamp] of this.#lamps) this.#apply(controllerId, lamp);
|
||||||
|
|
||||||
@@ -67,30 +75,54 @@ export class ButtonLightController {
|
|||||||
this.#unsubLane = deviceEvents.onLaneStatus((s) => this.#onLane(s));
|
this.#unsubLane = deviceEvents.onLaneStatus((s) => this.#onLane(s));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** (Re)build the lamp map from the current device config. Each enabled access
|
/** Reconcile the lamp map with the CURRENT device config (the booth can add/change a
|
||||||
* controller with a `buttonLight` gets a lamp; others are skipped. */
|
* button light without a server restart). Mirrors DeviceMonitor, which re-reads the
|
||||||
#loadLamps(): void {
|
* device set each tick. Adds lamps for newly-configured controllers, updates the spec
|
||||||
this.#lamps.clear();
|
* (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 rows = this.#db.select().from(devices).where(eq(devices.category, "access")).all();
|
||||||
|
const seen = new Set<string>();
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
if (!row.enabled) continue;
|
if (!row.enabled) continue;
|
||||||
const spec = buttonLightOf(row);
|
const spec = buttonLightOf(row);
|
||||||
if (!spec) continue;
|
if (!spec) continue;
|
||||||
|
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, {
|
this.#lamps.set(row.id, {
|
||||||
spec,
|
spec,
|
||||||
present: false,
|
present: false,
|
||||||
lastOn: null,
|
|
||||||
rendered: null,
|
rendered: null,
|
||||||
blink: null,
|
blink: null,
|
||||||
blinkOn: false,
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** A radar (presence) edge updates that controller's `present` flag. We resolve the
|
/** A radar (presence) edge updates that controller's `present` flag. We resolve the
|
||||||
* edge the SAME way the entry flow does (relayForPresence on an entry/both relay),
|
* 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". */
|
* so the lamp and the one-car-one-ticket gate always agree on "a car is here". */
|
||||||
#onInput(e: DeviceInputEvent): void {
|
#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);
|
const lamp = this.#lamps.get(e.deviceId);
|
||||||
if (!lamp) return; // no lamp on this controller
|
if (!lamp) return; // no lamp on this controller
|
||||||
const presence = relayForPresence(this.#db, e.deviceId, e.input);
|
const presence = relayForPresence(this.#db, e.deviceId, e.input);
|
||||||
@@ -123,19 +155,24 @@ export class ButtonLightController {
|
|||||||
lamp.rendered = target;
|
lamp.rendered = target;
|
||||||
|
|
||||||
if (target === "off") {
|
if (target === "off") {
|
||||||
void this.#drive(controllerId, lamp, false);
|
lamp.desiredOn = false;
|
||||||
|
this.#pump(controllerId, lamp);
|
||||||
} else if (target === "solid") {
|
} else if (target === "solid") {
|
||||||
void this.#drive(controllerId, lamp, true);
|
lamp.desiredOn = true;
|
||||||
|
this.#pump(controllerId, lamp);
|
||||||
} else {
|
} else {
|
||||||
// BLINK: arm the toggle timer SYNCHRONOUSLY (it must not wait on a UDP write), then
|
// BLINK: a wall-clock timer flips ONLY the desired flag; #pump does the actual
|
||||||
// drive the first "on". A symmetric cadence uses one interval; an asymmetric one
|
// (serialized) UDP send. A symmetric cadence uses one interval; an asymmetric one
|
||||||
// re-arms each phase with its own duration.
|
// 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 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;
|
const offMs = lamp.spec.blinkOffMs && lamp.spec.blinkOffMs > 0 ? lamp.spec.blinkOffMs : DEFAULT_BLINK_MS;
|
||||||
lamp.blinkOn = true;
|
lamp.blinkOn = true;
|
||||||
|
lamp.desiredOn = true;
|
||||||
const tick = () => {
|
const tick = () => {
|
||||||
lamp.blinkOn = !lamp.blinkOn;
|
lamp.blinkOn = !lamp.blinkOn;
|
||||||
void this.#drive(controllerId, lamp, lamp.blinkOn);
|
lamp.desiredOn = lamp.blinkOn;
|
||||||
|
this.#pump(controllerId, lamp);
|
||||||
if (onMs !== offMs && lamp.blink) {
|
if (onMs !== offMs && lamp.blink) {
|
||||||
clearInterval(lamp.blink);
|
clearInterval(lamp.blink);
|
||||||
lamp.blink = setInterval(tick, lamp.blinkOn ? onMs : offMs);
|
lamp.blink = setInterval(tick, lamp.blinkOn ? onMs : offMs);
|
||||||
@@ -144,23 +181,37 @@ export class ButtonLightController {
|
|||||||
};
|
};
|
||||||
lamp.blink = setInterval(tick, onMs);
|
lamp.blink = setInterval(tick, onMs);
|
||||||
lamp.blink.unref?.();
|
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:
|
/** Serialized per-lamp worker: drive the relay toward `desiredOn`, one UDP send at a
|
||||||
* an error logs and leaves `lastOn` unchanged so the next compute retries. */
|
* time. Because UDP is unordered, concurrent on/off sends previously raced and left
|
||||||
async #drive(controllerId: string, lamp: LampState, on: boolean): Promise<void> {
|
* the relay stuck on a stale packet. Here a single in-flight send is guaranteed
|
||||||
if (lamp.lastOn === on) return; // no redundant UDP writes
|
* (`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);
|
const aux = this.#resolveAux(controllerId);
|
||||||
if (!aux) return;
|
if (!aux) return;
|
||||||
try {
|
const target = lamp.desiredOn;
|
||||||
await aux.setAux(lamp.spec.relay, on);
|
lamp.sending = true;
|
||||||
lamp.lastOn = on;
|
void aux
|
||||||
} catch (err) {
|
.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}`);
|
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.
|
})
|
||||||
}
|
.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). */
|
/** Build the live aux-output adapter for a controller, or null (logged once). */
|
||||||
@@ -197,14 +248,28 @@ export class ButtonLightController {
|
|||||||
lamp.blink = null;
|
lamp.blink = null;
|
||||||
}
|
}
|
||||||
// Best-effort fail-OFF on shutdown.
|
// 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. */
|
/** Test seam: current high-level state being rendered for a controller. */
|
||||||
stateOf(controllerId: string): LightState | null {
|
stateOf(controllerId: string): LightState | null {
|
||||||
return this.#lamps.get(controllerId)?.rendered ?? 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). */
|
/** Build a controller row's live aux device (exported for reuse/tests). */
|
||||||
|
|||||||
Reference in New Issue
Block a user