From 38481f105f454e9c93fdd0ae627e135343ef4eda Mon Sep 17 00:00:00 2001 From: Julian Cuni Date: Sun, 28 Jun 2026 11:48:12 +0200 Subject: [PATCH] feat(booth): blink the Entry/Exit lights on radar presence (mirror relay 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/server/src/device-events.ts | 20 ++++ apps/server/src/device-resolve.ts | 20 ++++ apps/server/src/lane-presence.test.ts | 144 ++++++++++++++++++++++++ apps/server/src/lane-presence.ts | 61 ++++++++++ apps/server/src/routes/ws.ts | 28 ++++- apps/server/src/server.ts | 9 +- apps/web/src/BoothScreen.tsx | 33 +++--- apps/web/src/index.css | 27 +++++ apps/web/src/lib/live-store.ts | 16 ++- apps/web/src/lib/use-live-feed.ts | 12 +- wiki/concepts/button-light-indicator.md | 16 +++ wiki/log.md | 16 +++ 12 files changed, 378 insertions(+), 24 deletions(-) create mode 100644 apps/server/src/lane-presence.test.ts create mode 100644 apps/server/src/lane-presence.ts diff --git a/apps/server/src/device-events.ts b/apps/server/src/device-events.ts index 1e68e83..9b6b6e8 100644 --- a/apps/server/src/device-events.ts +++ b/apps/server/src/device-events.ts @@ -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. */ diff --git a/apps/server/src/device-resolve.ts b/apps/server/src/device-resolve.ts index 5f29ef0..d855620 100644 --- a/apps/server/src/device-resolve.ts +++ b/apps/server/src/device-resolve.ts @@ -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 diff --git a/apps/server/src/lane-presence.test.ts b/apps/server/src/lane-presence.test.ts new file mode 100644 index 0000000..0547390 --- /dev/null +++ b/apps/server/src/lane-presence.test.ts @@ -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(); + }); +}); diff --git a/apps/server/src/lane-presence.ts b/apps/server/src/lane-presence.ts new file mode 100644 index 0000000..3afa15b --- /dev/null +++ b/apps/server/src/lane-presence.ts @@ -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(); + readonly #exit = new Set(); + #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; + } +} diff --git a/apps/server/src/routes/ws.ts b/apps/server/src/routes/ws.ts index 332e89b..c73987f 100644 --- a/apps/server/src/routes/ws.ts +++ b/apps/server/src/routes/ws.ts @@ -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; devices: unknown; lanes: LaneStatusEvent } + | { + kind: "hello"; + occupancy: ReturnType; + devices: unknown; + lanes: LaneStatusEvent; + radar: LanePresenceEvent; + } | { kind: "ledger"; event: unknown; occupancy: ReturnType } | { 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 { 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(); }); }, ); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 9d0e353..00c7b77 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -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 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 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 (
- {/* Barrier glyph: a post + an arm. Colour carries the state. */} - + {/* Barrier glyph: a post + an arm. `currentColor` follows the (possibly blinking) state. */} +
{label}
-
- {busy ? "●" : "○"} -
+
{busy ? "●" : blinking ? "◐" : "○"}
); } -/** 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 (
- - + +
); } diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 745f192..4488103 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -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); + } +} diff --git a/apps/web/src/lib/live-store.ts b/apps/web/src/lib/live-store.ts index c19fceb..bbe5d29 100644 --- a/apps/web/src/lib/live-store.ts +++ b/apps/web/src/lib/live-store.ts @@ -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; /** 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((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((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 }), })); diff --git a/apps/web/src/lib/use-live-feed.ts b/apps/web/src/lib/use-live-feed.ts index bd1223a..ec68ef5 100644 --- a/apps/web/src/lib/use-live-feed.ts +++ b/apps/web/src/lib/use-live-feed.ts @@ -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(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); diff --git a/wiki/concepts/button-light-indicator.md b/wiki/concepts/button-light-indicator.md index 5a346db..32ed366 100644 --- a/wiki/concepts/button-light-indicator.md +++ b/wiki/concepts/button-light-indicator.md @@ -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 diff --git a/wiki/log.md b/wiki/log.md index f086644..a441f0f 100644 --- a/wiki/log.md +++ b/wiki/log.md @@ -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).