Event log: resolve input_received lane from the firing device

Replace the hardcoded lane: 0 on input_received events with a real
device->lane lookup. A new LaneMap caches lane_devices.id -> lane,
built at startup and refreshed by the setup routes on assign/unassign.
An unmapped device logs lane: -1 + a warning (0 is a real lane) and is
still recorded faithfully (append-only chain).

source stays null for raw inputs by design: it's an IdentitySource
(how a vehicle was identified), not a device field; device provenance
remains in identity. Documented both in the wiki.
This commit is contained in:
2026-06-15 12:51:21 +02:00
parent f5fd61984a
commit 59bfe2013f
5 changed files with 83 additions and 7 deletions
+30
View File
@@ -0,0 +1,30 @@
import { laneDevices, type Db } from "@parking/db";
// Resolves a device instance id (lane_devices.id) to its lane number.
//
// Device pushes/events carry the `lane_devices` id (which device fired), not a
// lane. The event log wants the lane, so we keep a small in-memory id->lane map
// rebuilt from the DB at startup and refreshed whenever assignments change
// (assign/unassign). It's tiny (one row per device) and read on the hot path of
// every input event, so a cached map beats a per-event DB lookup.
export class LaneMap {
readonly #db: Db;
#byDeviceId = new Map<string, number>();
constructor(db: Db) {
this.#db = db;
}
/** (Re)load the id->lane map from the lane_devices table. */
refresh(): void {
const rows = this.#db.select().from(laneDevices).all();
const next = new Map<string, number>();
for (const r of rows) next.set(r.id, r.lane);
this.#byDeviceId = next;
}
/** Lane for a device instance id, or null if the device isn't known. */
laneFor(deviceId: string): number | null {
return this.#byDeviceId.get(deviceId) ?? null;
}
}
+9 -1
View File
@@ -48,7 +48,13 @@ function redactSecrets(config: Record<string, unknown>): Record<string, unknown>
return out;
}
export async function setupRoutes(app: FastifyInstance, db: Db): Promise<void> {
export async function setupRoutes(
app: FastifyInstance,
db: Db,
// Called after the set of assignments changes (assign/unassign) so the caller
// can refresh anything derived from it — e.g. the device id->lane map.
onAssignmentsChanged: () => void = () => {},
): Promise<void> {
registerBuiltinDrivers();
setDeviceLogSink((line) => app.log.info(line));
@@ -251,6 +257,7 @@ export async function setupRoutes(app: FastifyInstance, db: Db): Promise<void> {
enabled: true,
};
await db.insert(laneDevices).values(row);
onAssignmentsChanged(); // refresh derived state (device->lane map)
// Don't echo device secrets back (push Digest password, web-UI login, …).
return reply.code(201).send({
...row,
@@ -281,6 +288,7 @@ export async function setupRoutes(app: FastifyInstance, db: Db): Promise<void> {
.get();
if (!existing) return reply.code(404).send({ error: "no such device assignment" });
await db.delete(laneDevices).where(eq(laneDevices.id, req.params.id));
onAssignmentsChanged(); // refresh derived state (device->lane map)
app.log.info(`unassigned device ${req.params.id} (${existing.category}/${existing.driverId}, lane ${existing.lane})`);
return reply.code(204).send();
},
+22 -3
View File
@@ -5,6 +5,7 @@ import { createDb, type Db } from "@parking/db";
import { TOKEN_COOKIE, requireJwtSecret } from "./auth.js";
import { deviceEvents } from "./device-events.js";
import { EventLog } from "./event-log.js";
import { LaneMap } from "./lane-map.js";
import { PrinterMonitor } from "./printer-monitor.js";
import { buildSigner } from "./signer.js";
import { authRoutes } from "./routes/auth.js";
@@ -46,9 +47,15 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
// Local username/password login → JWT in an HttpOnly cookie + CSRF cookie.
await authRoutes(app, db);
// device id -> lane resolver. Built from lane_devices at startup and refreshed
// by setupRoutes on assign/unassign, so device events can be stamped with the
// lane the device belongs to (events carry the device id, not a lane).
const laneMap = new LaneMap(db);
laneMap.refresh();
// Device-agnostic setup: the admin selects devices per lane from the driver
// catalog at first-run. See wiki/concepts/first-run-setup.md.
await setupRoutes(app, db);
await setupRoutes(app, db, () => laneMap.refresh());
// Inbound device pushes (e.g. Dingtian Input Link URL → button events),
// guarded by source-IP allowlist + a shared-secret path token, both read from
@@ -72,10 +79,22 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
const eventLog = new EventLog(db, buildSigner(app.log));
await eventRoutes(app, db, eventLog);
const unsubscribeInput = deviceEvents.onInput((e) => {
// Resolve which lane the device belongs to. -1 marks "device fired but isn't
// mapped to a lane" (assigned without a lane, or a stale id) — still recorded
// faithfully (the chain is append-only) rather than silently dropped or
// mis-stamped as lane 0, which is a real lane.
const lane = laneMap.laneFor(e.deviceId) ?? -1;
if (lane === -1) {
app.log.warn(`input from unmapped device ${e.driverId}:${e.deviceId} — logged as lane -1`);
}
eventLog
.append({
type: "input_received",
lane: 0, // lane mapping is a TODO — device->lane lookup arrives with setup/lane wiring
lane,
// `source` is an IdentitySource (wiegand/lpr/qr/ticket/manual) — how a
// VEHICLE was identified. A raw input has none, so it stays null. The
// device provenance lives in `identity` instead.
source: null,
identity: `${e.driverId}:${e.deviceId} input:${e.input}/${e.edge}`,
occurredAt: e.at,
})
@@ -83,7 +102,7 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
});
app.addHook("onClose", async () => unsubscribeInput());
// TODO: entry flow (input event → signed event → print → relay); map device→lane.
// TODO: entry flow (input event → signed event → print → relay).
return app;
}