devices: pool-of-spaces model — drop lane, per-relay direction
A parking lot is one pool of spaces with a flexible set of entry/exit
points — no "lane". Direction is a property of each RELAY inside an access
controller; readers/cameras bind to a controller relay and inherit it.
Schema:
- drop `lane` from ledger_events, device_events, sessions
- rename lane_devices -> devices (no lane/direction columns)
- access config.relays=[{relay,direction,button?}]; reader/camera
config.controllerId+relay binding
- fresh 0000_baseline migration (history reset; dev data was throwaway)
Signed ledger:
- remove `lane` from canonicalize(); bump signer keyId sw-hmac-v1 -> v2
(v1 events won't verify under v2 — intentional, gated per-event by keyId)
Server:
- new device-resolve.ts (replaces lane-map.ts): relayForButton,
relayForDevice, firstRelayByDirection, devicesByDirection
- entry-flow: button terminal -> its relay; exit/permit: reader's bound
relay; dispatcher resolves the bound relay + inherited direction
- camera snapshots fire by direction site-wide, async, never block open
- DeviceConfig widened to nested JSON for relays[]
Web:
- wizard: no lane selector; add controllers (relay map + entry-button
terminal) first, then bind readers/cameras/printers to a controller relay
Wiki: new entry-exit-points.md (replaces lane-direction); reworked
entry-exit-readers, parking-session, first-run-setup, device-registry,
append-only-event-chain, device-events; removed stale lane/LaneMap mentions.
This commit is contained in:
@@ -1,17 +1,22 @@
|
||||
import type { Db } from "@parking/db";
|
||||
import { devices, eq, type Db } from "@parking/db";
|
||||
import type { FastifyBaseLogger } from "fastify";
|
||||
import type { DeviceReadEvent, ReadOutcome } from "./device-events.js";
|
||||
import type { ExitFlow } from "./exit-flow.js";
|
||||
import type { PermitFlow } from "./permit-flow.js";
|
||||
import { readerLaneWithAccess } from "./lane-map.js";
|
||||
import { relayForDevice } from "./device-resolve.js";
|
||||
|
||||
// Routes a credential read (ticket scan / plate / card) to the right flow. A read
|
||||
// can mean a permit entry/exit OR a transient exit, so we dispatch by WHAT the
|
||||
// credential is (decision 2026-06-15):
|
||||
// - matches a permit (card/QR/bound plate) → PERMIT flow (direction inferred from
|
||||
// the car's open-session state),
|
||||
// - matches a permit (card/QR/bound plate) → PERMIT flow,
|
||||
// - else → transient EXIT flow (open ticket session → exit, else reject+log).
|
||||
// Lane is resolved once here; both flows act on a known access-equipped lane.
|
||||
//
|
||||
// The reader is BOUND to a controller relay (config.controllerId + relay), so a read
|
||||
// resolves to exactly the barrier it sits at, and the direction is inherited from
|
||||
// that relay (see entry-exit-points.md). The resolved relay is handed to the flow so
|
||||
// it opens that exact barrier. An "entry" reader drives the entry side, an "exit"
|
||||
// reader the exit side; "both" defers to the flow's own inference (permit: session
|
||||
// state; transient: exit).
|
||||
|
||||
export class ReadDispatcher {
|
||||
readonly #db: Db;
|
||||
@@ -27,16 +32,25 @@ export class ReadDispatcher {
|
||||
}
|
||||
|
||||
async dispatch(e: DeviceReadEvent): Promise<ReadOutcome> {
|
||||
const lane = await readerLaneWithAccess(this.#db, e.deviceId);
|
||||
if (lane == null) {
|
||||
return { accepted: false, reason: "reader not on an access-equipped lane" };
|
||||
const reader = this.#db.select().from(devices).where(eq(devices.id, e.deviceId)).get();
|
||||
if (!reader || !reader.enabled) {
|
||||
return { accepted: false, reason: "read from unknown/disabled device" };
|
||||
}
|
||||
const resolved = relayForDevice(this.#db, reader);
|
||||
if (!resolved) {
|
||||
return { accepted: false, reason: "reader not bound to a barrier (no relay to open)" };
|
||||
}
|
||||
|
||||
const permit = this.#permit.match(e);
|
||||
if (permit) {
|
||||
return this.#permit.run(lane, e, permit);
|
||||
return this.#permit.run(resolved, e, permit);
|
||||
}
|
||||
// Not a permit → transient ticket exit (the exit flow rejects+logs if unknown).
|
||||
return this.#exit.handleAt(lane, e);
|
||||
// Not a permit → transient ticket exit. An ENTRY reader can't produce a transient
|
||||
// exit (transient entry is the button flow, not a reader), so reject+log rather
|
||||
// than treat an entry scan as an exit.
|
||||
if (resolved.direction === "entry") {
|
||||
return { accepted: false, direction: "entry", reason: "entry reader: no transient entry via reader" };
|
||||
}
|
||||
return this.#exit.handleAt(resolved, e);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user