feat(booth): blink the Entry/Exit lights on radar presence (mirror relay 3)
Build desktop / desktop (push) Successful in 4m14s
Build & push images / images (push) Successful in 2m42s
CI / check (push) Successful in 37s

The on-screen Hyrje/Dalje barrier lights were 2-state (green=free / red=busy)
off the camera lane-status only — they couldn't show the radar-only "detected,
not yet confirmed" state that makes the physical button lamp (relay 3) blink.
Now they mirror the lamp's 3-state rule per lane:
  radar present + camera not busy → BLINK green↔red (~1 Hz)
  camera busy                     → SOLID red
  otherwise                       → SOLID green

End-to-end:
- LanePresence (lane-presence.ts): subscribes to deviceEvents.onInput, resolves
  each presence edge to its lane via the new direction-agnostic presenceLaneOf()
  (device-resolve.ts) — entry AND exit, unlike the entry-gated relayForPresence
  the one-car-one-ticket gate uses — and emits a lane-presence {entry,exit} bus
  event on change. Wired in server.ts (start + onClose).
- WS forwards it (hello snapshot + push) into live-store.radar.
- BarrierLight (BoothScreen.tsx) is now 3-state; blinks via the .lane-blink
  keyframe (index.css), which holds solid-red under prefers-reduced-motion.

Same input + same rule as the lamp, so the screen and the post never disagree.

A new test (lane-presence.test.ts) caught a real bug: the first cut reused
relayForPresence, so the EXIT lane never resolved (it's entry-gated) and never
blinked — presenceLaneOf fixes it. Covers entry/exit independence, de-dupe
across several radars on one lane, and ignoring non-presence inputs.

Full workspace build/lint/test green (185 server tests). Updated the
button-light-indicator wiki page ("On-screen twin").

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
2026-06-28 11:48:12 +02:00
parent 4418594af0
commit 38481f105f
12 changed files with 378 additions and 24 deletions
+20
View File
@@ -86,6 +86,16 @@ export interface LaneStatusEvent {
readonly exit: boolean; // true = busy (a vehicle is at the exit vicinity)
}
/** Per-lane RADAR presence — a vehicle-presence INPUT (loop/radar) is shorted at the
* entry/exit barrier, i.e. "something is in the lane vicinity" BEFORE the camera has
* confirmed a vehicle. Same signal that makes the physical button lamp (relay 3) blink:
* radar-present + camera-not-busy. Drives the booth's barrier light blink. Advisory only —
* it gates nothing. See wiki/concepts/button-light-indicator.md. */
export interface LanePresenceEvent {
readonly entry: boolean; // true = a presence input on an entry barrier is active
readonly exit: boolean; // true = a presence input on an exit barrier is active
}
class DeviceEventBus extends EventEmitter {
emitInput(event: DeviceInputEvent): void {
this.emit("input", event);
@@ -148,6 +158,16 @@ class DeviceEventBus extends EventEmitter {
this.on("lane-status", cb);
return () => this.off("lane-status", cb);
}
/** Emitted whenever a lane's RADAR presence CHANGES (a presence input shorted/cleared
* at an entry/exit barrier). Drives the booth barrier light's blink. Advisory only. */
emitLanePresence(event: LanePresenceEvent): void {
this.emit("lane-presence", event);
}
onLanePresence(cb: (event: LanePresenceEvent) => void): () => void {
this.on("lane-presence", cb);
return () => this.off("lane-presence", cb);
}
}
/** Process-wide device event bus. */
+20
View File
@@ -224,6 +224,26 @@ export function alertRelaysOf(row: DeviceRow): RelaySpec[] {
return relaysOf(row).filter((r) => r.direction === "radarAlert" && typeof r.relay === "number");
}
/**
* Which LANE a presence input belongs to — for the booth's barrier-light blink (advisory).
* Unlike `relayForPresence` (entry-gated, for the one-car-one-ticket gate), this resolves a
* presence input on ANY barrier: entry/both → "entry", exit → "exit". Returns null if the
* terminal isn't a presence input on a barrier relay. See lane-presence.ts.
*/
export function presenceLaneOf(db: Db, controllerId: string, terminal: number): FlowDirection | null {
const row = db
.select()
.from(devices)
.where(and(eq(devices.id, controllerId), eq(devices.category, "access")))
.get();
if (!row || !row.enabled) return null;
const presence = inputsOf(row).find((i) => i.role === "presence" && i.input === terminal);
if (!presence || typeof presence.relay !== "number") return null;
const relay = relaysOf(row).find((r) => r.relay === presence.relay);
if (!relay) return null;
return relay.direction === "exit" ? "exit" : relay.direction === "radarAlert" ? null : "entry";
}
/**
* Resolve a reader/camera to the relay it opens. Preferred: its config binding
* (controllerId + relay) → exactly that barrier, direction inherited from the relay
+144
View File
@@ -0,0 +1,144 @@
import { beforeEach, describe, expect, it } from "vitest";
import { eq, devices, type Db } from "@parking/db";
import { createTestDb } from "@parking/db/testing";
import { LanePresence } from "./lane-presence.js";
import { deviceEvents, type DeviceInputEvent, type LanePresenceEvent } from "./device-events.js";
import { silentLogger } from "./test-helpers.js";
// LanePresence: a vehicle-presence INPUT edge (loop/radar) on an entry/exit barrier marks
// that lane "present" — the same signal that blinks the physical button lamp (relay 3). It
// resolves the edge via relayForPresence (the SAME path relay 3 + the entry gate use), and
// emits a lane-presence change only when a lane's present/clear state actually flips.
let db: Db;
const CTL = "ctl-1";
const ENTRY_RADAR = 2;
const EXIT_RADAR = 5;
beforeEach(() => {
({ db } = createTestDb());
// Entry relay 1 with a radar on I2; exit relay 2 with a radar on I5.
db.insert(devices).values({
id: CTL,
category: "access",
driverId: "dingtian",
config: {
host: "10.0.0.5",
relays: [
{ relay: 1, direction: "entry" },
{ relay: 2, direction: "exit" },
],
inputs: [
{ input: ENTRY_RADAR, role: "presence", relay: 1, kind: "radar" },
{ input: EXIT_RADAR, role: "presence", relay: 2, kind: "radar" },
],
},
enabled: true,
}).run();
});
function edge(input: number, on: boolean): void {
const e: DeviceInputEvent = {
driverId: "dingtian",
deviceId: CTL,
input,
edge: on ? "on" : "off",
at: new Date().toISOString(),
source: "poll",
};
deviceEvents.emitInput(e);
}
/** Collect lane-presence emissions while running `fn`. */
function capture(fn: () => void): LanePresenceEvent[] {
const seen: LanePresenceEvent[] = [];
const off = deviceEvents.onLanePresence((p) => seen.push(p));
try {
fn();
} finally {
off();
}
return seen;
}
describe("LanePresence", () => {
it("starts clear and snapshots clear", () => {
const lp = new LanePresence(db, silentLogger());
lp.start();
expect(lp.snapshot()).toEqual({ entry: false, exit: false });
lp.stop();
});
it("an ENTRY radar edge marks the entry lane present, then clears", () => {
const lp = new LanePresence(db, silentLogger());
lp.start();
const events = capture(() => {
edge(ENTRY_RADAR, true);
edge(ENTRY_RADAR, false);
});
expect(events).toEqual([
{ entry: true, exit: false },
{ entry: false, exit: false },
]);
lp.stop();
});
it("an EXIT radar edge marks the exit lane independently", () => {
const lp = new LanePresence(db, silentLogger());
lp.start();
const events = capture(() => {
edge(EXIT_RADAR, true);
});
expect(events).toEqual([{ entry: false, exit: true }]);
expect(lp.snapshot()).toEqual({ entry: false, exit: true });
lp.stop();
});
it("de-dupes: a second 'on' from another presence input on the same lane emits once", () => {
// Two radars both serving the entry lane.
db.update(devices)
.set({
config: {
host: "10.0.0.5",
relays: [{ relay: 1, direction: "entry" }],
inputs: [
{ input: 2, role: "presence", relay: 1, kind: "radar" },
{ input: 3, role: "presence", relay: 1, kind: "radar" },
],
},
})
.where(eq(devices.id, CTL))
.run();
const lp = new LanePresence(db, silentLogger());
lp.start();
const events = capture(() => {
edge(2, true); // entry → present (emit)
edge(3, true); // still present (no emit — same lane)
edge(2, false); // still present via I3 (no emit)
edge(3, false); // now clear (emit)
});
expect(events).toEqual([
{ entry: true, exit: false },
{ entry: false, exit: false },
]);
lp.stop();
});
it("ignores a non-presence input (e.g. a button terminal)", () => {
db.update(devices)
.set({
config: {
host: "10.0.0.5",
relays: [{ relay: 1, direction: "entry" }],
inputs: [{ input: 1, role: "button", relay: 1 }],
},
})
.where(eq(devices.id, CTL))
.run();
const lp = new LanePresence(db, silentLogger());
lp.start();
const events = capture(() => edge(1, true));
expect(events).toEqual([]);
lp.stop();
});
});
+61
View File
@@ -0,0 +1,61 @@
import type { Db } from "@parking/db";
import type { FastifyBaseLogger } from "fastify";
import { deviceEvents, type DeviceInputEvent, type LanePresenceEvent } from "./device-events.js";
import { presenceLaneOf } from "./device-resolve.js";
// Per-lane RADAR presence for the booth's barrier lights. A vehicle-presence INPUT
// (loop/radar) shorted at an entry/exit barrier means "something is in the lane vicinity"
// BEFORE the camera confirms a vehicle. This is the SAME signal that makes the physical
// button lamp (relay 3) blink — see button-light.ts (#onInput) — so the on-screen light
// and the lamp stay in lockstep: both react to a presence edge resolved the SAME way
// (relayForPresence, on an entry/both relay). ADVISORY ONLY: it gates nothing.
//
// A radar serving an entry (or "both") barrier marks the ENTRY lane present; an exit radar
// marks EXIT. The lane is resolved via `presenceLaneOf` (direction-agnostic — unlike the
// entry-gated `relayForPresence` the one-car-one-ticket gate uses), so both lanes blink.
export class LanePresence {
readonly #db: Db;
readonly #logger: FastifyBaseLogger;
/** Active presence terminals per lane, keyed `${deviceId}:${input}` (several radars may
* serve one lane). A lane is "present" while its set is non-empty. */
readonly #entry = new Set<string>();
readonly #exit = new Set<string>();
#unsub: (() => void) | null = null;
constructor(db: Db, logger: FastifyBaseLogger) {
this.#db = db;
this.#logger = logger;
}
/** Subscribe to presence input edges. */
start(): void {
this.#unsub = deviceEvents.onInput((e) => this.#onInput(e));
}
/** Current snapshot (for the WS hello). */
snapshot(): LanePresenceEvent {
return { entry: this.#entry.size > 0, exit: this.#exit.size > 0 };
}
#onInput(e: DeviceInputEvent): void {
const lane = presenceLaneOf(this.#db, e.deviceId, e.input);
if (!lane) return; // not a presence terminal on a barrier relay
const key = `${e.deviceId}:${e.input}`;
const set = lane === "entry" ? this.#entry : this.#exit;
const before = set.size > 0;
if (e.edge === "on") set.add(key);
else set.delete(key);
const after = set.size > 0;
if (before !== after) {
this.#logger.info(`lane-presence: ${lane} -> ${after ? "present" : "clear"}`);
deviceEvents.emitLanePresence(this.snapshot());
}
}
/** Unsubscribe on shutdown. */
stop(): void {
this.#unsub?.();
this.#unsub = null;
}
}
+24 -4
View File
@@ -2,10 +2,11 @@ import type { FastifyInstance } from "fastify";
import type { Db } from "@parking/db";
import type { LedgerEvent } from "@parking/shared";
import { roleHasPermissions } from "../auth.js";
import { deviceEvents, type LaneStatusEvent } from "../device-events.js";
import { deviceEvents, type LaneStatusEvent, type LanePresenceEvent } from "../device-events.js";
import { enrichEvent } from "../event-enrich.js";
import type { DeviceMonitor } from "../device-monitor.js";
import type { LaneStatus } from "../lane-status.js";
import type { LanePresence } from "../lane-presence.js";
import { getOccupancy } from "../occupancy.js";
// Live booth feed over a WebSocket. The booth UI opens ONE socket and receives
@@ -53,17 +54,25 @@ function isAllowedOrigin(origin: string | undefined, host: string | undefined):
}
type OutMsg =
| { kind: "hello"; occupancy: ReturnType<typeof getOccupancy>; devices: unknown; lanes: LaneStatusEvent }
| {
kind: "hello";
occupancy: ReturnType<typeof getOccupancy>;
devices: unknown;
lanes: LaneStatusEvent;
radar: LanePresenceEvent;
}
| { kind: "ledger"; event: unknown; occupancy: ReturnType<typeof getOccupancy> }
| { kind: "printer-status"; event: unknown }
| { kind: "device-status"; event: unknown }
| { kind: "lane-status"; lanes: LaneStatusEvent };
| { kind: "lane-status"; lanes: LaneStatusEvent }
| { kind: "lane-presence"; radar: LanePresenceEvent };
export async function wsRoutes(
app: FastifyInstance,
db: Db,
deviceMonitor: DeviceMonitor,
laneStatus: LaneStatus,
lanePresence: LanePresence,
): Promise<void> {
app.get(
"/api/ws",
@@ -96,7 +105,13 @@ export async function wsRoutes(
// Initial snapshot so the client renders immediately, before any event:
// occupancy AND the current device-status set (for the footer).
send({ kind: "hello", occupancy: getOccupancy(db), devices: deviceMonitor.snapshot(), lanes: laneStatus.snapshot() });
send({
kind: "hello",
occupancy: getOccupancy(db),
devices: deviceMonitor.snapshot(),
lanes: laneStatus.snapshot(),
radar: lanePresence.snapshot(),
});
// Subscribe to the live buses. Each handler recomputes occupancy from the
// ledger (cheap fold) so the pushed count is always authoritative.
@@ -117,12 +132,17 @@ export async function wsRoutes(
const offLane = deviceEvents.onLaneStatus((lanes) => {
send({ kind: "lane-status", lanes });
});
// Lane RADAR presence (presence-input edge → barrier-light blink). Advisory.
const offPresence = deviceEvents.onLanePresence((radar) => {
send({ kind: "lane-presence", radar });
});
socket.on("close", () => {
offLedger();
offPrinter();
offDevice();
offLane();
offPresence();
});
},
);
+8 -1
View File
@@ -28,6 +28,7 @@ import { roleRoutes } from "./routes/roles.js";
import { deviceRoutes } from "./routes/devices.js";
import { hikvisionAlarmRoutes } from "./routes/hikvision-alarm.js";
import { LaneStatus } from "./lane-status.js";
import { LanePresence } from "./lane-presence.js";
import { AnprBridge } from "./anpr-entry.js";
import { eventRoutes } from "./routes/events.js";
import { reportRoutes } from "./routes/reports.js";
@@ -125,6 +126,12 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
const laneStatus = new LaneStatus(db, app.log);
app.addHook("onClose", async () => laneStatus.stop());
// Per-lane RADAR presence (presence-input edges → barrier-light blink). Mirrors the
// physical button lamp (relay 3): the SAME presence signal, surfaced to the booth UI.
const lanePresence = new LanePresence(db, app.log);
lanePresence.start();
app.addHook("onClose", async () => lanePresence.stop());
// NB: the Hikvision Alarm Server routes are registered LOWER DOWN — after the read
// flows are constructed — because the ANPR bridge they carry depends on the
// SubscriptionFlow. See the hikvisionAlarmRoutes() call below the read-flow wiring.
@@ -169,7 +176,7 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
// Live booth feed: server-pushed ledger + occupancy + printer-status over a
// single authenticated WebSocket (/api/ws). See routes/ws.ts.
await wsRoutes(app, db, deviceMonitor, laneStatus);
await wsRoutes(app, db, deviceMonitor, laneStatus, lanePresence);
// Entry/exit camera snapshots (BLOB-in-DB), read-only. See snapshot.ts.
await snapshotRoutes(app, db);
+19 -14
View File
@@ -112,40 +112,45 @@ function TicketInput({ onSubmit }: { onSubmit: (identity: string) => void }) {
);
}
/** One barrier light — green = free, red = busy (a vehicle is at the lane vicinity,
* from camera detection). Advisory only; it gates nothing. */
function BarrierLight({ label, busy }: { label: string; busy: boolean }) {
/** One barrier light — a 3-state indicator mirroring the physical button lamp (relay 3):
* - radar present + camera NOT busy → BLINK green↔red (~1 Hz): "detected, not yet confirmed"
* - camera busy → SOLID red: a vehicle is confirmed at the lane vicinity
* - otherwise → SOLID green: free
* Advisory only; it gates nothing. The blink uses the `.lane-blink` keyframe (index.css),
* whose children inherit the alternating colour via `currentColor`. */
function BarrierLight({ label, busy, radar }: { label: string; busy: boolean; radar: boolean }) {
// Blink only when the radar sees something the camera hasn't confirmed.
const blinking = radar && !busy;
const solid = busy ? "border-term-red bg-term-red/10 text-term-red" : "border-term-green bg-term-green/10 text-term-green";
return (
<div
className={`flex items-center gap-2 rounded-term border px-3 py-2 ${
busy ? "border-term-red bg-term-red/10" : "border-term-green bg-term-green/10"
}`}
className={`flex items-center gap-2 rounded-term border px-3 py-2 ${blinking ? "lane-blink" : solid}`}
title={label}
>
{/* Barrier glyph: a post + an arm. Colour carries the state. */}
<svg viewBox="0 0 24 24" className={`h-5 w-5 ${busy ? "text-term-red" : "text-term-green"}`} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
{/* Barrier glyph: a post + an arm. `currentColor` follows the (possibly blinking) state. */}
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<line x1="5" y1="21" x2="5" y2="9" />
<line x1="5" y1="10" x2="21" y2="6" />
<circle cx="5" cy="7" r="1.6" fill="currentColor" stroke="none" />
</svg>
<div className="leading-tight">
<div className="text-[10px] uppercase tracking-wider text-term-muted">{label}</div>
<div className={`text-xs font-bold ${busy ? "text-term-red" : "text-term-green"}`}>
{busy ? "●" : "○"}
</div>
<div className="text-xs font-bold">{busy ? "●" : blinking ? "◐" : "○"}</div>
</div>
</div>
);
}
/** The two lane barrier lights (entry / exit) fed by the live lane-status. */
/** The two lane barrier lights (entry / exit) fed by the live lane-status (camera busy/free)
* and lane-presence (radar). */
function LaneIndicators() {
const { t } = useTranslation();
const lanes = useLiveStore((s) => s.lanes);
const radar = useLiveStore((s) => s.radar);
return (
<div className="flex items-center gap-2">
<BarrierLight label={t("booth.laneEntry")} busy={lanes?.entry ?? false} />
<BarrierLight label={t("booth.laneExit")} busy={lanes?.exit ?? false} />
<BarrierLight label={t("booth.laneEntry")} busy={lanes?.entry ?? false} radar={radar?.entry ?? false} />
<BarrierLight label={t("booth.laneExit")} busy={lanes?.exit ?? false} radar={radar?.exit ?? false} />
</div>
);
}
+27
View File
@@ -428,3 +428,30 @@ html.theme-light .btn:hover:not(:disabled) {
html.theme-light .btn-primary {
color: #fafaf7;
}
/* ── Barrier-light blink ────────────────────────────────────────────────────
The booth Entry/Exit indicator blinks green↔red (~1 Hz) when the radar/presence
input is active but the camera hasn't confirmed a vehicle yet — mirroring the
physical button lamp (relay 3). Toggles a CSS var the component maps onto its
border / tint / glyph, so green and red alternate every 500 ms. */
@keyframes lane-blink {
0%, 49% { --lane-c: var(--color-term-green); --lane-tint: color-mix(in srgb, var(--color-term-green) 10%, transparent); }
50%, 100% { --lane-c: var(--color-term-red); --lane-tint: color-mix(in srgb, var(--color-term-red) 10%, transparent); }
}
.lane-blink {
animation: lane-blink 1s steps(1, end) infinite;
border-color: var(--lane-c);
background: var(--lane-tint);
color: var(--lane-c);
}
@media (prefers-reduced-motion: reduce) {
/* No flashing for motion-sensitive users — hold the "attention" (red) state. */
.lane-blink {
animation: none;
--lane-c: var(--color-term-red);
--lane-tint: color-mix(in srgb, var(--color-term-red) 10%, transparent);
border-color: var(--lane-c);
background: var(--lane-tint);
color: var(--lane-c);
}
}
+15 -1
View File
@@ -16,6 +16,14 @@ export interface LaneStatus {
exit: boolean; // true = busy
}
/** Per-lane RADAR presence — a presence input (loop/radar) is shorted at the barrier,
* i.e. "something is in the lane" BEFORE the camera confirms a vehicle. Drives the
* barrier light's BLINK (the same signal as the physical button lamp / relay 3). */
export interface LanePresence {
entry: boolean; // true = a radar/presence input on an entry barrier is active
exit: boolean; // true = … on an exit barrier
}
/** Cap the in-memory live feed so a long-running booth session can't grow it
* unbounded — the full history is always available via the /api/events query. */
const MAX_FEED = 200;
@@ -31,6 +39,8 @@ interface LiveState {
devices: Record<string, DeviceStatus>;
/** Per-lane busy/free (camera vehicle detection). Null until the first WS hello. */
lanes: LaneStatus | null;
/** Per-lane radar presence (advisory blink). Null until the first WS hello. */
radar: LanePresence | null;
setStatus: (s: WsStatus) => void;
setOccupancy: (o: Occupancy) => void;
pushEvent: (e: LedgerEvent) => void;
@@ -40,6 +50,8 @@ interface LiveState {
upsertDevice: (d: DeviceStatus) => void;
/** Set lane busy/free (WS hello + each lane-status push). */
setLanes: (l: LaneStatus) => void;
/** Set lane radar presence (WS hello + each lane-presence push). */
setRadar: (r: LanePresence) => void;
reset: () => void;
}
@@ -56,6 +68,7 @@ export const useLiveStore = create<LiveState>((set) => ({
feed: [],
devices: {},
lanes: null,
radar: null,
setStatus: (status) => set({ status }),
setOccupancy: (occupancy) => set({ occupancy }),
pushEvent: (e) =>
@@ -66,5 +79,6 @@ export const useLiveStore = create<LiveState>((set) => ({
setDevices: (list) => set({ devices: byId(list) }),
upsertDevice: (d) => set((s) => ({ devices: { ...s.devices, [d.deviceId]: d } })),
setLanes: (lanes) => set({ lanes }),
reset: () => set({ status: "connecting", occupancy: null, feed: [], devices: {}, lanes: null }),
setRadar: (radar) => set({ radar }),
reset: () => set({ status: "connecting", occupancy: null, feed: [], devices: {}, lanes: null, radar: null }),
}));
+8 -4
View File
@@ -2,7 +2,7 @@ import { useEffect, useRef } from "react";
import { useQueryClient } from "@tanstack/react-query";
import type { DeviceStatus, LedgerEvent, Occupancy } from "../api.js";
import { qk } from "./query.js";
import { useLiveStore, type LaneStatus } from "./live-store.js";
import { useLiveStore, type LaneStatus, type LanePresence } from "./live-store.js";
import { wsUrl } from "./origin.js";
// Booth WebSocket client. Opens ONE socket to /api/ws and turns server pushes into
@@ -14,16 +14,17 @@ import { wsUrl } from "./origin.js";
/** Server → client message shapes (mirror routes/ws.ts OutMsg). */
type WsMessage =
| { kind: "hello"; occupancy: Occupancy; devices: DeviceStatus[]; lanes: LaneStatus }
| { kind: "hello"; occupancy: Occupancy; devices: DeviceStatus[]; lanes: LaneStatus; radar: LanePresence }
| { kind: "ledger"; event: LedgerEvent; occupancy: Occupancy }
| { kind: "printer-status"; event: unknown }
| { kind: "device-status"; event: DeviceStatus }
| { kind: "lane-status"; lanes: LaneStatus };
| { kind: "lane-status"; lanes: LaneStatus }
| { kind: "lane-presence"; radar: LanePresence };
export function useLiveFeed(): void {
const qc = useQueryClient();
const { setStatus, setOccupancy, pushEvent, setDevices, upsertDevice, setLanes } = useLiveStore();
const { setStatus, setOccupancy, pushEvent, setDevices, upsertDevice, setLanes, setRadar } = useLiveStore();
// Hold the socket + reconnect timer across renders; guard against StrictMode
// double-invoke and unmount.
const sockRef = useRef<WebSocket | null>(null);
@@ -56,10 +57,13 @@ export function useLiveFeed(): void {
// Initial device-status snapshot for the footer.
if (Array.isArray(msg.devices)) setDevices(msg.devices);
if (msg.lanes) setLanes(msg.lanes);
if (msg.radar) setRadar(msg.radar);
} else if (msg.kind === "device-status") {
upsertDevice(msg.event);
} else if (msg.kind === "lane-status") {
setLanes(msg.lanes);
} else if (msg.kind === "lane-presence") {
setRadar(msg.radar);
} else if (msg.kind === "ledger") {
setOccupancy(msg.occupancy);
pushEvent(msg.event);
+16
View File
@@ -78,6 +78,22 @@ aux-output** capability.
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
+16
View File
@@ -1794,3 +1794,19 @@ resolution + legacy-fallback identical + exit-radar resolves to the exit relay),
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`.
## [2026-06-28] feat | Booth Entry/Exit lights blink on radar presence (mirror relay 3)
The on-screen Hyrje/Dalje barrier lights were 2-state (green=free / red=busy) off the camera
lane-status only — they couldn't show the radar-only "detected, not yet confirmed" state that makes
the physical button lamp (relay 3) blink. Added that signal end-to-end: a small server tracker
**`LanePresence`** (lane-presence.ts) subscribes to `deviceEvents.onInput`, resolves each presence
edge to its lane via a new **`presenceLaneOf`** (device-resolve.ts) — direction-agnostic (entry AND
exit), unlike the entry-gated `relayForPresence` — and emits a `lane-presence {entry,exit}` bus
event on change. The WS forwards it (hello snapshot + push) into `live-store.radar`; `BarrierLight`
(BoothScreen.tsx) became **3-state**, mirroring relay 3 exactly: radar+camera-free → BLINK green↔red
~1 Hz (`.lane-blink` keyframe in index.css, holds solid-red under prefers-reduced-motion);
camera-busy → SOLID red; else SOLID green. Same input + same rule as the lamp, so screen and post
never disagree. A test (lane-presence.test.ts) caught a real bug: the first cut reused
`relayForPresence`, so the EXIT lane never resolved (entry-gated) and never blinked —
`presenceLaneOf` fixes it. Full workspace build/lint/test green (185 server tests). Updated
[[button-light-indicator]] (new "On-screen twin" section).