Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1efa77bf56 | |||
| 15d3e1ba08 | |||
| ff3b011fe0 | |||
| 5705098054 | |||
| 68d61f2d99 | |||
| 04135b27cf | |||
| 392d44d842 | |||
| f67c1ead87 | |||
| bf37106c5c | |||
| e579fe5b6e | |||
| 644bfa1462 | |||
| 3429642edb | |||
| c24d99b0f4 | |||
| b4d0dfadd6 | |||
| f18e28eeca | |||
| a8c6d6e714 | |||
| 2a36830880 |
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"hooks": {
|
||||||
|
"PreToolUse": [
|
||||||
|
{
|
||||||
|
"matcher": "Bash",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "CMD=$(python3 -c \"import json,sys; d=json.load(sys.stdin); print(d.get('tool_input',d).get('command',''))\" 2>/dev/null || true); case \"$CMD\" in *grep*|*rg\\ *|*ripgrep*|*find\\ *|*fd\\ *|*ack\\ *|*ag\\ *) [ -f graphify-out/graph.json ] && echo '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"additionalContext\":\"MANDATORY: graphify-out/graph.json exists. You MUST run `graphify query \\\"<question>\\\"` before grepping raw files. Only grep after graphify has oriented you, or to modify/debug specific lines.\"}}' || true ;; esac"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matcher": "Read|Glob",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "HIT=$(python3 -c \"import json,sys;d=json.load(sys.stdin);t=d.get('tool_input',d);s=(str(t.get('file_path') or '')+' '+str(t.get('pattern') or '')+' '+str(t.get('path') or '')).lower().replace(chr(92),'/');exts=('.py','.js','.ts','.tsx','.jsx','.go','.rs','.java','.rb','.c','.h','.cpp','.hpp','.cc','.cs','.kt','.swift','.php','.scala','.lua','.sh','.md','.rst','.txt','.mdx');sys.stdout.write('1' if 'graphify-out/' not in s and any(e in s for e in exts) else '')\" 2>/dev/null || true); if [ \"$HIT\" = 1 ] && [ -f graphify-out/graph.json ]; then echo '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"additionalContext\":\"MANDATORY: graphify-out/graph.json exists. You MUST run graphify before reading source files. Use: `graphify query \\\"<question>\\\"` (scoped subgraph), `graphify explain \\\"<concept>\\\"`, or `graphify path \\\"<A>\\\" \\\"<B>\\\"`. Only read raw files after graphify has oriented you, or to modify/debug specific lines. This rule applies to subagents too \u2014 include it in every subagent prompt involving code exploration.\"}}'; fi || true"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,3 +20,7 @@ dist/
|
|||||||
/*.png
|
/*.png
|
||||||
# Vendor device SDKs (reference only — protocol captured in wiki, not committed)
|
# Vendor device SDKs (reference only — protocol captured in wiki, not committed)
|
||||||
/dingtian/
|
/dingtian/
|
||||||
|
/QRCode_sdk*/
|
||||||
|
|
||||||
|
# Graphify knowledge-graph output (dev tool; generated, not committed)
|
||||||
|
graphify-out/
|
||||||
|
|||||||
@@ -86,3 +86,13 @@ For the full reasoning behind each, follow the links from `wiki/overview.md`.
|
|||||||
|
|
||||||
- TypeScript throughout. Match the style of surrounding code.
|
- TypeScript throughout. Match the style of surrounding code.
|
||||||
- Confirm before destructive or outward-facing actions. Commit/push only when asked.
|
- Confirm before destructive or outward-facing actions. Commit/push only when asked.
|
||||||
|
|
||||||
|
## graphify
|
||||||
|
|
||||||
|
This project has a knowledge graph at graphify-out/ with god nodes, community structure, and cross-file relationships.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- For codebase questions, first run `graphify query "<question>"` when graphify-out/graph.json exists. Use `graphify path "<A>" "<B>"` for relationships and `graphify explain "<concept>"` for focused concepts. These return a scoped subgraph, usually much smaller than GRAPH_REPORT.md or raw grep output.
|
||||||
|
- If graphify-out/wiki/index.md exists, use it for broad navigation instead of raw source browsing.
|
||||||
|
- Read graphify-out/GRAPH_REPORT.md only for broad architecture review or when query/path/explain do not surface enough context.
|
||||||
|
- After modifying code, run `graphify update .` to keep the graph current (AST-only, no API cost).
|
||||||
|
|||||||
+11
-5
@@ -18,9 +18,15 @@ export const TOKEN_COOKIE = "parking_token";
|
|||||||
export const CSRF_COOKIE = "parking_csrf";
|
export const CSRF_COOKIE = "parking_csrf";
|
||||||
export const CSRF_HEADER = "x-csrf-token";
|
export const CSRF_HEADER = "x-csrf-token";
|
||||||
|
|
||||||
/** Token lifetime, also used as the cookie maxAge. */
|
// Session lifetime: the JWT has NO expiry — a login is valid until explicit
|
||||||
export const TOKEN_TTL = "8h";
|
// logout. Booth reality breaks any fixed clock (relief late/absent, forced double
|
||||||
export const TOKEN_TTL_SECONDS = 8 * 60 * 60;
|
// shifts), and a shift is a separate explicit boundary, not the token's lifetime.
|
||||||
|
// See wiki/entities/local-jwt-auth.md + wiki/concepts/shift.md.
|
||||||
|
//
|
||||||
|
// The cookie still needs a maxAge so it survives a browser restart (a session
|
||||||
|
// cookie would log out an active operator on browser close — the opposite of
|
||||||
|
// "until logout"). Use a long fixed window; the server clears it on logout.
|
||||||
|
export const COOKIE_MAX_AGE_SECONDS = 30 * 24 * 60 * 60; // 30 days
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve the JWT signing secret, refusing to start without a strong one.
|
* Resolve the JWT signing secret, refusing to start without a strong one.
|
||||||
@@ -55,7 +61,7 @@ export function setAuthCookies(reply: FastifyReply, jwt: string, csrf: string):
|
|||||||
sameSite: "strict",
|
sameSite: "strict",
|
||||||
secure,
|
secure,
|
||||||
path: "/",
|
path: "/",
|
||||||
maxAge: TOKEN_TTL_SECONDS,
|
maxAge: COOKIE_MAX_AGE_SECONDS,
|
||||||
});
|
});
|
||||||
// Readable by JS so the SPA can echo it back in the CSRF header (double-submit).
|
// Readable by JS so the SPA can echo it back in the CSRF header (double-submit).
|
||||||
reply.setCookie(CSRF_COOKIE, csrf, {
|
reply.setCookie(CSRF_COOKIE, csrf, {
|
||||||
@@ -63,7 +69,7 @@ export function setAuthCookies(reply: FastifyReply, jwt: string, csrf: string):
|
|||||||
sameSite: "strict",
|
sameSite: "strict",
|
||||||
secure,
|
secure,
|
||||||
path: "/",
|
path: "/",
|
||||||
maxAge: TOKEN_TTL_SECONDS,
|
maxAge: COOKIE_MAX_AGE_SECONDS,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,17 +8,41 @@ import type { PrinterStatus } from "@parking/devices";
|
|||||||
|
|
||||||
export interface DeviceInputEvent {
|
export interface DeviceInputEvent {
|
||||||
readonly driverId: string; // e.g. "dingtian"
|
readonly driverId: string; // e.g. "dingtian"
|
||||||
readonly deviceId: string; // which configured device (lane_devices id)
|
readonly deviceId: string; // which configured device (devices id)
|
||||||
readonly input: number; // 1-based input/channel
|
readonly input: number; // 1-based input/channel
|
||||||
readonly edge: "on" | "off"; // active / inactive
|
readonly edge: "on" | "off"; // active / inactive
|
||||||
readonly at: string; // ISO-8601 (server receive time)
|
readonly at: string; // ISO-8601 (server receive time)
|
||||||
readonly source: "push" | "poll";
|
readonly source: "push" | "poll";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A credential read: a ticket scanned at exit, a plate from LPR, a card at a reader.
|
||||||
|
// Drives identity-based flows (exit validation, permits, pay-station lookup). `kind`
|
||||||
|
// mirrors IdentitySource. See parking-session.md.
|
||||||
|
export interface DeviceReadEvent {
|
||||||
|
readonly driverId: string;
|
||||||
|
readonly deviceId: string; // devices id of the reader/scanner/camera
|
||||||
|
readonly value: string; // the ticket id / plate / card number
|
||||||
|
readonly kind: "ticket" | "plate" | "qr" | "card";
|
||||||
|
readonly at: string; // ISO-8601
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The decision a read produced. Returned by the read flows so a SYNCHRONOUS reader
|
||||||
|
* (e.g. the QR reader, whose HTTP reply drives its beep + output) can answer the
|
||||||
|
* device. A fire-and-forget reader simply ignores it. See wiki/entities/gee-qr-er80.md.
|
||||||
|
*/
|
||||||
|
export interface ReadOutcome {
|
||||||
|
/** Was the vehicle admitted/exited (barrier opened)? Drives the reader's beep. */
|
||||||
|
readonly accepted: boolean;
|
||||||
|
/** Which way it went, when known (permit/exit infer this). */
|
||||||
|
readonly direction?: "entry" | "exit";
|
||||||
|
/** Human-readable reason (for logs / the reader UI), esp. on reject. */
|
||||||
|
readonly reason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
/** A printer's status as tracked by the live monitor (status + identity). */
|
/** A printer's status as tracked by the live monitor (status + identity). */
|
||||||
export interface PrinterStatusEvent {
|
export interface PrinterStatusEvent {
|
||||||
readonly deviceId: string; // lane_devices id
|
readonly deviceId: string; // devices id
|
||||||
readonly lane: number;
|
|
||||||
readonly driverId: string;
|
readonly driverId: string;
|
||||||
readonly role?: string; // entry-dispenser | booth-receipt
|
readonly role?: string; // entry-dispenser | booth-receipt
|
||||||
readonly status: PrinterStatus;
|
readonly status: PrinterStatus;
|
||||||
@@ -33,6 +57,15 @@ class DeviceEventBus extends EventEmitter {
|
|||||||
return () => this.off("input", cb);
|
return () => this.off("input", cb);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A credential read (ticket scan, plate, card). */
|
||||||
|
emitRead(event: DeviceReadEvent): void {
|
||||||
|
this.emit("read", event);
|
||||||
|
}
|
||||||
|
onRead(cb: (event: DeviceReadEvent) => void): () => void {
|
||||||
|
this.on("read", cb);
|
||||||
|
return () => this.off("read", cb);
|
||||||
|
}
|
||||||
|
|
||||||
/** Emitted by the printer monitor whenever a printer's status CHANGES. */
|
/** Emitted by the printer monitor whenever a printer's status CHANGES. */
|
||||||
emitPrinterStatus(event: PrinterStatusEvent): void {
|
emitPrinterStatus(event: PrinterStatusEvent): void {
|
||||||
this.emit("printer-status", event);
|
this.emit("printer-status", event);
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
import { and, eq, devices, type Db, type DeviceRow } from "@parking/db";
|
||||||
|
|
||||||
|
// Device resolution for the pool-of-spaces model — NO lane. A parking lot is one
|
||||||
|
// pool with a flexible set of entry/exit points. Direction lives on each RELAY
|
||||||
|
// inside an access controller, and readers/cameras BIND to a (controller, relay).
|
||||||
|
// See wiki/concepts/entry-exit-points.md.
|
||||||
|
|
||||||
|
/** A flow direction. "both" = one relay/barrier serving entry AND exit. */
|
||||||
|
export type Direction = "entry" | "exit" | "both";
|
||||||
|
/** A concrete flow a credential/button drives (never "both"). */
|
||||||
|
export type FlowDirection = "entry" | "exit";
|
||||||
|
|
||||||
|
/** One relay on an access controller: which barrier it opens, in which direction,
|
||||||
|
* and (optionally) the input terminal its entry button is wired to. */
|
||||||
|
export interface RelaySpec {
|
||||||
|
/** 1-based relay channel on the board (the driver's pulseOpen(doorId)). */
|
||||||
|
readonly relay: number;
|
||||||
|
readonly direction: Direction;
|
||||||
|
/** 1-based input terminal of the entry button that fires this relay (transient
|
||||||
|
* entry). Absent = no button at this barrier (subscriber/reader-driven only). */
|
||||||
|
readonly button?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Access controller config (the `relays[]` map + connection fields). */
|
||||||
|
interface AccessConfig {
|
||||||
|
readonly relays?: RelaySpec[];
|
||||||
|
readonly [k: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reader/camera config: optional binding to a controller relay. */
|
||||||
|
interface BoundConfig {
|
||||||
|
/** The access `devices.id` this reader/camera sits at. */
|
||||||
|
readonly controllerId?: string;
|
||||||
|
/** The relay on that controller it opens. */
|
||||||
|
readonly relay?: number;
|
||||||
|
/** Fallback direction when not bound to a relay. */
|
||||||
|
readonly direction?: Direction;
|
||||||
|
readonly [k: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A resolved barrier: the controller row + the specific relay to pulse. */
|
||||||
|
export interface ResolvedRelay {
|
||||||
|
readonly controller: DeviceRow;
|
||||||
|
readonly relay: number;
|
||||||
|
readonly direction: Direction;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** All enabled access controller rows. */
|
||||||
|
function accessRows(db: Db): DeviceRow[] {
|
||||||
|
return db
|
||||||
|
.select()
|
||||||
|
.from(devices)
|
||||||
|
.where(eq(devices.category, "access"))
|
||||||
|
.all()
|
||||||
|
.filter((r) => r.enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The relay specs declared on an access controller (defaults to none). */
|
||||||
|
export function relaysOf(row: DeviceRow): RelaySpec[] {
|
||||||
|
const cfg = row.config as AccessConfig;
|
||||||
|
return Array.isArray(cfg.relays) ? cfg.relays : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a button press to the relay it fires: the access controller with this
|
||||||
|
* deviceId, and the relay whose `button` terminal matches the pressed input. Only
|
||||||
|
* an ENTRY (or both) relay is a transient-entry trigger. Returns null otherwise.
|
||||||
|
*/
|
||||||
|
export function relayForButton(db: Db, controllerId: string, terminal: number): ResolvedRelay | 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 spec = relaysOf(row).find((r) => r.button === terminal);
|
||||||
|
if (!spec) return null;
|
||||||
|
if (spec.direction !== "entry" && spec.direction !== "both") return null;
|
||||||
|
return { controller: row, relay: spec.relay, direction: spec.direction };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a reader/camera to the relay it opens. Preferred: its config binding
|
||||||
|
* (controllerId + relay) → exactly that barrier, direction inherited from the relay
|
||||||
|
* spec. Fallback (unbound): the device's config.direction + the first relay site-
|
||||||
|
* wide matching that direction — keeps the single-barrier case trivial. Null if
|
||||||
|
* nothing resolves (no barrier to open).
|
||||||
|
*/
|
||||||
|
export function relayForDevice(db: Db, deviceRow: DeviceRow): ResolvedRelay | null {
|
||||||
|
const cfg = deviceRow.config as BoundConfig;
|
||||||
|
|
||||||
|
// Bound: follow controllerId + relay to the exact barrier.
|
||||||
|
if (cfg.controllerId && typeof cfg.relay === "number") {
|
||||||
|
const controller = db
|
||||||
|
.select()
|
||||||
|
.from(devices)
|
||||||
|
.where(and(eq(devices.id, cfg.controllerId), eq(devices.category, "access")))
|
||||||
|
.get();
|
||||||
|
if (controller && controller.enabled) {
|
||||||
|
const spec = relaysOf(controller).find((r) => r.relay === cfg.relay);
|
||||||
|
if (spec) return { controller, relay: spec.relay, direction: spec.direction };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unbound: fall back to the device's declared direction + first matching relay.
|
||||||
|
const want = cfg.direction;
|
||||||
|
if (want === "entry" || want === "exit" || want === "both") {
|
||||||
|
return firstRelayByDirection(db, want === "both" ? "entry" : want);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The first relay site-wide serving a direction ("both" relays match either).
|
||||||
|
* Used as the unbound fallback and where a flow only needs "an exit barrier".
|
||||||
|
*/
|
||||||
|
export function firstRelayByDirection(db: Db, direction: FlowDirection): ResolvedRelay | null {
|
||||||
|
for (const controller of accessRows(db)) {
|
||||||
|
const spec = relaysOf(controller).find(
|
||||||
|
(r) => r.direction === direction || r.direction === "both",
|
||||||
|
);
|
||||||
|
if (spec) return { controller, relay: spec.relay, direction: spec.direction };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Enabled devices of a category whose direction matches `want` (or is "both").
|
||||||
|
* Direction is inherited from each device's bound relay, else its config fallback.
|
||||||
|
* Used for snapshots: every entry/exit camera fires on an entry/exit. */
|
||||||
|
export function devicesByDirection(
|
||||||
|
db: Db,
|
||||||
|
category: DeviceRow["category"],
|
||||||
|
want: FlowDirection,
|
||||||
|
): DeviceRow[] {
|
||||||
|
return db
|
||||||
|
.select()
|
||||||
|
.from(devices)
|
||||||
|
.where(eq(devices.category, category))
|
||||||
|
.all()
|
||||||
|
.filter((r) => {
|
||||||
|
if (!r.enabled) return false;
|
||||||
|
const d = directionOf(db, r);
|
||||||
|
return d === want || d === "both";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The direction a reader/camera operates in (inherited from its bound relay, or
|
||||||
|
* its config fallback). "both" when undetermined → the flow infers. */
|
||||||
|
export function directionOf(db: Db, deviceRow: DeviceRow): Direction {
|
||||||
|
const resolved = relayForDevice(db, deviceRow);
|
||||||
|
if (resolved) return resolved.direction;
|
||||||
|
const cfg = deviceRow.config as BoundConfig;
|
||||||
|
return cfg.direction === "entry" || cfg.direction === "exit" ? cfg.direction : "both";
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { and, eq, laneDevices, sessions, type Db } from "@parking/db";
|
import { sessions, type Db, type DeviceRow } from "@parking/db";
|
||||||
import {
|
import {
|
||||||
NoPrinterAvailableError,
|
NoPrinterAvailableError,
|
||||||
printWithFailover,
|
printWithFailover,
|
||||||
@@ -11,12 +11,15 @@ import {
|
|||||||
} from "@parking/devices";
|
} from "@parking/devices";
|
||||||
import type { FastifyBaseLogger } from "fastify";
|
import type { FastifyBaseLogger } from "fastify";
|
||||||
import type { DeviceInputEvent } from "./device-events.js";
|
import type { DeviceInputEvent } from "./device-events.js";
|
||||||
|
import { getOccupancy } from "./occupancy.js";
|
||||||
import type { EventLog } from "./event-log.js";
|
import type { EventLog } from "./event-log.js";
|
||||||
import type { LaneMap } from "./lane-map.js";
|
import { devicesByDirection, relayForButton, type ResolvedRelay } from "./device-resolve.js";
|
||||||
|
import { snapshotAsync } from "./snapshot.js";
|
||||||
|
|
||||||
// The transient ENTRY flow: a button press → print a ticket → sign a vehicle_entry
|
// The transient ENTRY flow: a button press → print a ticket → sign a vehicle_entry
|
||||||
// → open the barrier. This is the step the device layer left dangling
|
// → open the barrier. The button is wired into an access controller's input; the
|
||||||
// (wiki/concepts/device-input-flow.md "the entry flow itself is the next build").
|
// admin maps that input terminal to a relay (config.relays[].button), so a press
|
||||||
|
// resolves to exactly the entry relay it should open. See entry-exit-points.md.
|
||||||
//
|
//
|
||||||
// Two invariants from the threat model + safety analysis:
|
// Two invariants from the threat model + safety analysis:
|
||||||
// 1. SIGNED BEFORE OPEN — the vehicle_entry is appended to the signed ledger
|
// 1. SIGNED BEFORE OPEN — the vehicle_entry is appended to the signed ledger
|
||||||
@@ -28,66 +31,72 @@ import type { LaneMap } from "./lane-map.js";
|
|||||||
// Crucially, NO vehicle_entry is written in that case — we never record an
|
// Crucially, NO vehicle_entry is written in that case — we never record an
|
||||||
// "entered" event for a car that didn't get in (decision 2026-06-15).
|
// "entered" event for a car that didn't get in (decision 2026-06-15).
|
||||||
//
|
//
|
||||||
// Ordering, therefore: print → (ok) sign vehicle_entry → pulseOpen → cache session.
|
// Ordering: print → (ok) sign vehicle_entry → pulseOpen → snapshot → cache session.
|
||||||
// (fail) sign anomaly, stop.
|
// (fail) sign anomaly, stop.
|
||||||
|
|
||||||
/** Map a 1-based entry input to the relay/door it opens. Default: same channel. */
|
|
||||||
function doorForInput(input: number): number {
|
|
||||||
return input;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class EntryFlow {
|
export class EntryFlow {
|
||||||
readonly #db: Db;
|
readonly #db: Db;
|
||||||
readonly #log: EventLog;
|
readonly #log: EventLog;
|
||||||
readonly #laneMap: LaneMap;
|
|
||||||
readonly #logger: FastifyBaseLogger;
|
readonly #logger: FastifyBaseLogger;
|
||||||
/** Guard against double-fire from the same physical press (on edge only). */
|
/** Guard against double-fire from the same physical press (on edge only). */
|
||||||
readonly #inFlight = new Set<string>();
|
readonly #inFlight = new Set<string>();
|
||||||
|
|
||||||
constructor(db: Db, log: EventLog, laneMap: LaneMap, logger: FastifyBaseLogger) {
|
constructor(db: Db, log: EventLog, logger: FastifyBaseLogger) {
|
||||||
this.#db = db;
|
this.#db = db;
|
||||||
this.#log = log;
|
this.#log = log;
|
||||||
this.#laneMap = laneMap;
|
|
||||||
this.#logger = logger;
|
this.#logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Handle a device input edge. Acts only on the rising ("on") edge of an entry
|
/** Handle a device input edge. Acts only on the rising ("on") edge of an entry
|
||||||
* button in a lane that has an access (barrier) device. */
|
* button — an input terminal mapped to an entry relay on its controller. */
|
||||||
async onInput(e: DeviceInputEvent): Promise<void> {
|
async onInput(e: DeviceInputEvent): Promise<void> {
|
||||||
if (e.edge !== "on") return; // release edge is just telemetry
|
if (e.edge !== "on") return; // release edge is just telemetry
|
||||||
|
|
||||||
const lane = this.#laneMap.laneFor(e.deviceId);
|
// The firing device must be an access controller, and the pressed input terminal
|
||||||
if (lane == null) return; // unmapped device — telemetry already recorded, no entry
|
// must map to an ENTRY (or both) relay — that's an entry button. Anything else
|
||||||
|
// (reader/printer edge, exit-only relay's input) is not a transient-entry trigger.
|
||||||
// Only treat this as an entry trigger if the firing device IS the lane's
|
const resolved = relayForButton(this.#db, e.deviceId, e.input);
|
||||||
// access controller (a reader/printer input edge isn't an entry button).
|
if (!resolved) return;
|
||||||
const access = await this.#loadAccess(lane, e.deviceId);
|
|
||||||
if (!access) return;
|
|
||||||
|
|
||||||
const key = `${e.deviceId}:${e.input}`;
|
const key = `${e.deviceId}:${e.input}`;
|
||||||
if (this.#inFlight.has(key)) return; // ignore re-fire while one is processing
|
if (this.#inFlight.has(key)) return; // ignore re-fire while one is processing
|
||||||
this.#inFlight.add(key);
|
this.#inFlight.add(key);
|
||||||
try {
|
try {
|
||||||
await this.#runEntry(lane, e.input, access);
|
await this.#runEntry(resolved);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.#logger.error(`entry-flow failed (lane ${lane}): ${(err as Error).message}`);
|
this.#logger.error(`entry-flow failed: ${(err as Error).message}`);
|
||||||
} finally {
|
} finally {
|
||||||
this.#inFlight.delete(key);
|
this.#inFlight.delete(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async #runEntry(lane: number, input: number, access: AccessControlDevice): Promise<void> {
|
async #runEntry(resolved: ResolvedRelay): Promise<void> {
|
||||||
|
// CAPACITY GATE (transient only). When the lot is full, refuse transient entry:
|
||||||
|
// no ticket, no vehicle_entry, no open — sign an anomaly. Permit holders are NOT
|
||||||
|
// gated here (their flow ignores site-full; their own maxConcurrent applies), so
|
||||||
|
// subscribers aren't locked out. "Full" is a soft policy seam for valet over-
|
||||||
|
// capacity later. See wiki/concepts/capacity-occupancy.md.
|
||||||
|
const occ = getOccupancy(this.#db);
|
||||||
|
if (occ.full) {
|
||||||
|
await this.#log.append({
|
||||||
|
type: "anomaly",
|
||||||
|
payload: { reason: `transient entry refused — lot full (${occ.count}/${occ.capacity})`, entryRefused: true, full: true },
|
||||||
|
});
|
||||||
|
this.#logger.warn(`transient entry REFUSED: full (${occ.count}/${occ.capacity})`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const ticketId = newTicketId();
|
const ticketId = newTicketId();
|
||||||
const issuedAt = new Date().toISOString();
|
const issuedAt = new Date().toISOString();
|
||||||
const printers = await this.#loadPrinters(lane);
|
const printers = this.#loadPrinters();
|
||||||
|
|
||||||
// 1. PRINT FIRST. The ticket is the transient's session key — no ticket, no entry.
|
// 1. PRINT FIRST. The ticket is the transient's session key — no ticket, no entry.
|
||||||
const ticket: TicketData = { ticketId, lane, issuedAt };
|
const ticket: TicketData = { ticketId, issuedAt };
|
||||||
try {
|
try {
|
||||||
const printedBy = await printWithFailover(printers, "entry-dispenser", (d: PrinterDevice) =>
|
const printedBy = await printWithFailover(printers, "entry-dispenser", (d: PrinterDevice) =>
|
||||||
d.printTicket(ticket),
|
d.printTicket(ticket),
|
||||||
);
|
);
|
||||||
this.#logger.info(`entry ticket ${ticketId} printed on ${printedBy} (lane ${lane})`);
|
this.#logger.info(`entry ticket ${ticketId} printed on ${printedBy}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// HOLD: do not open, do not record a vehicle_entry. Sign an anomaly so the
|
// HOLD: do not open, do not record a vehicle_entry. Sign an anomaly so the
|
||||||
// failed attempt is in the tamper-evident record for the operator.
|
// failed attempt is in the tamper-evident record for the operator.
|
||||||
@@ -95,18 +104,16 @@ export class EntryFlow {
|
|||||||
err instanceof NoPrinterAvailableError ? err.message : (err as Error).message;
|
err instanceof NoPrinterAvailableError ? err.message : (err as Error).message;
|
||||||
await this.#log.append({
|
await this.#log.append({
|
||||||
type: "anomaly",
|
type: "anomaly",
|
||||||
lane,
|
|
||||||
identity: ticketId,
|
identity: ticketId,
|
||||||
payload: { reason: `entry held — ticket not printed: ${reason}`, ticketPrinted: false },
|
payload: { reason: `entry held — ticket not printed: ${reason}`, ticketPrinted: false },
|
||||||
});
|
});
|
||||||
this.#logger.warn(`entry HELD on lane ${lane}: ${reason} (barrier NOT opened)`);
|
this.#logger.warn(`entry HELD: ${reason} (barrier NOT opened)`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. SIGN the vehicle_entry — BEFORE the relay fires (the core invariant).
|
// 2. SIGN the vehicle_entry — BEFORE the relay fires (the core invariant).
|
||||||
await this.#log.append({
|
await this.#log.append({
|
||||||
type: "vehicle_entry",
|
type: "vehicle_entry",
|
||||||
lane,
|
|
||||||
direction: "entry",
|
direction: "entry",
|
||||||
source: "ticket",
|
source: "ticket",
|
||||||
identity: ticketId,
|
identity: ticketId,
|
||||||
@@ -114,15 +121,26 @@ export class EntryFlow {
|
|||||||
occurredAt: issuedAt,
|
occurredAt: issuedAt,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 3. OPEN the barrier (intent only; the barrier owns the close).
|
// 3. OPEN the resolved entry barrier (intent only; the barrier owns the close).
|
||||||
await access.pulseOpen(doorForInput(input));
|
const access = this.#buildAccess(resolved.controller);
|
||||||
|
if (access) await access.pulseOpen(resolved.relay);
|
||||||
|
else this.#logger.warn(`entry signed for ${ticketId} but the entry relay won't build`);
|
||||||
|
|
||||||
|
// 3b. SNAPSHOT — fire the entry camera(s), never awaited (evidence, not a gate;
|
||||||
|
// a camera failure must not delay or block the already-open barrier).
|
||||||
|
void snapshotAsync({
|
||||||
|
db: this.#db,
|
||||||
|
direction: "entry",
|
||||||
|
identity: ticketId,
|
||||||
|
logger: this.#logger,
|
||||||
|
}).catch((err) => this.#logger.error(`entry snapshot error: ${(err as Error).message}`));
|
||||||
|
|
||||||
// 4. Update the session projection cache (rebuildable from the ledger; this is
|
// 4. Update the session projection cache (rebuildable from the ledger; this is
|
||||||
// just a fast read-model, never the source of truth).
|
// just a fast read-model, never the source of truth).
|
||||||
try {
|
try {
|
||||||
this.#db
|
this.#db
|
||||||
.insert(sessions)
|
.insert(sessions)
|
||||||
.values({ id: ticketId, lane, identity: ticketId, source: "ticket", enteredAt: issuedAt, state: "open" })
|
.values({ id: ticketId, identity: ticketId, source: "ticket", enteredAt: issuedAt, state: "open" })
|
||||||
.run();
|
.run();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Cache miss is non-fatal — the ledger is authoritative and the projection
|
// Cache miss is non-fatal — the ledger is authoritative and the projection
|
||||||
@@ -131,15 +149,8 @@ export class EntryFlow {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The lane's access device, but only if it's the one that fired (the entry
|
/** Build a live access adapter from a resolved controller row, or null. */
|
||||||
* button). Returns a live adapter or null. */
|
#buildAccess(row: DeviceRow): AccessControlDevice | null {
|
||||||
async #loadAccess(lane: number, deviceId: string): Promise<AccessControlDevice | null> {
|
|
||||||
const row = await this.#db
|
|
||||||
.select()
|
|
||||||
.from(laneDevices)
|
|
||||||
.where(and(eq(laneDevices.id, deviceId), eq(laneDevices.category, "access")))
|
|
||||||
.get();
|
|
||||||
if (!row || !row.enabled || row.lane !== lane) return null;
|
|
||||||
const driver = registry.get(row.driverId);
|
const driver = registry.get(row.driverId);
|
||||||
if (!driver) return null;
|
if (!driver) return null;
|
||||||
try {
|
try {
|
||||||
@@ -149,16 +160,11 @@ export class EntryFlow {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Build live printer instances for a lane (for failover selection). */
|
/** Build live ENTRY printer instances (for failover selection). */
|
||||||
async #loadPrinters(lane: number): Promise<PrinterInstance[]> {
|
#loadPrinters(): PrinterInstance[] {
|
||||||
const rows = await this.#db
|
const rows = devicesByDirection(this.#db, "printer", "entry"); // already enabled-filtered
|
||||||
.select()
|
|
||||||
.from(laneDevices)
|
|
||||||
.where(and(eq(laneDevices.category, "printer"), eq(laneDevices.lane, lane)))
|
|
||||||
.all();
|
|
||||||
const out: PrinterInstance[] = [];
|
const out: PrinterInstance[] = [];
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
if (!row.enabled) continue;
|
|
||||||
const driver = registry.get(row.driverId);
|
const driver = registry.get(row.driverId);
|
||||||
if (!driver) continue;
|
if (!driver) continue;
|
||||||
const cfg = row.config as Record<string, unknown>;
|
const cfg = row.config as Record<string, unknown>;
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import type { Direction, IdentitySource, LedgerEventType, LedgerPayload, Signer
|
|||||||
|
|
||||||
export interface AppendInput {
|
export interface AppendInput {
|
||||||
readonly type: LedgerEventType;
|
readonly type: LedgerEventType;
|
||||||
readonly lane: number;
|
|
||||||
readonly direction?: Direction | null;
|
readonly direction?: Direction | null;
|
||||||
readonly source?: IdentitySource | null;
|
readonly source?: IdentitySource | null;
|
||||||
readonly identity?: string | null;
|
readonly identity?: string | null;
|
||||||
@@ -38,7 +37,6 @@ export function canonicalize(e: {
|
|||||||
index: number;
|
index: number;
|
||||||
type: string;
|
type: string;
|
||||||
direction: string | null;
|
direction: string | null;
|
||||||
lane: number;
|
|
||||||
source: string | null;
|
source: string | null;
|
||||||
identity: string | null;
|
identity: string | null;
|
||||||
payload: Record<string, unknown> | null;
|
payload: Record<string, unknown> | null;
|
||||||
@@ -49,7 +47,6 @@ export function canonicalize(e: {
|
|||||||
e.index,
|
e.index,
|
||||||
e.type,
|
e.type,
|
||||||
e.direction ?? null,
|
e.direction ?? null,
|
||||||
e.lane,
|
|
||||||
e.source ?? null,
|
e.source ?? null,
|
||||||
e.identity ?? null,
|
e.identity ?? null,
|
||||||
// Payload is part of the signed form so business data is tamper-evident.
|
// Payload is part of the signed form so business data is tamper-evident.
|
||||||
@@ -120,7 +117,6 @@ export class EventLog {
|
|||||||
index,
|
index,
|
||||||
type: input.type,
|
type: input.type,
|
||||||
direction: input.direction ?? null,
|
direction: input.direction ?? null,
|
||||||
lane: input.lane,
|
|
||||||
source: input.source ?? null,
|
source: input.source ?? null,
|
||||||
identity: input.identity ?? null,
|
identity: input.identity ?? null,
|
||||||
payload,
|
payload,
|
||||||
@@ -133,7 +129,6 @@ export class EventLog {
|
|||||||
index,
|
index,
|
||||||
type: input.type,
|
type: input.type,
|
||||||
direction: input.direction ?? null,
|
direction: input.direction ?? null,
|
||||||
lane: input.lane,
|
|
||||||
source: input.source ?? null,
|
source: input.source ?? null,
|
||||||
identity: input.identity ?? null,
|
identity: input.identity ?? null,
|
||||||
payload,
|
payload,
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
import { eq, ledgerEvents, sessions, type Db, type DeviceRow } from "@parking/db";
|
||||||
|
import { registry, type AccessControlDevice } from "@parking/devices";
|
||||||
|
import type { ResolvedRelay } from "./device-resolve.js";
|
||||||
|
import { snapshotAsync } from "./snapshot.js";
|
||||||
|
import type { LedgerPayload } from "@parking/shared";
|
||||||
|
import type { FastifyBaseLogger } from "fastify";
|
||||||
|
import type { DeviceReadEvent, ReadOutcome } from "./device-events.js";
|
||||||
|
import type { EventLog } from "./event-log.js";
|
||||||
|
|
||||||
|
// The EXIT flow (pay-on-foot model): a credential read at the exit lane → look up
|
||||||
|
// the session → validate it is PAID and within the walk-back grace → sign a
|
||||||
|
// vehicle_exit → open. Payment is decoupled from exit (it happens earlier at the
|
||||||
|
// pay station); the exit lane only VALIDATES. See wiki/concepts/parking-session.md.
|
||||||
|
//
|
||||||
|
// Validation is a fold over the SIGNED ledger (the authoritative record), not the
|
||||||
|
// projection cache: find the open vehicle_entry for this identity, then a covering
|
||||||
|
// payment within grace. The cache is updated after, for fast reads.
|
||||||
|
//
|
||||||
|
// REJECT (barrier stays closed) when unpaid / over grace — this is correct business
|
||||||
|
// logic, NOT a fail-state. "Exit fails OPEN" (fail-state-safety) is about the SYSTEM
|
||||||
|
// being unable to decide (power/host loss), not about an unpaid car; an unpaid driver
|
||||||
|
// is sent back to the pay station, the rejection is logged.
|
||||||
|
//
|
||||||
|
// NOTE: payments / the pay station don't exist yet, so no session is ever PAID — every
|
||||||
|
// transient exit currently REJECTS (logged). That's the correct end-state; it becomes
|
||||||
|
// passable once the pay-station + `payment` events land.
|
||||||
|
|
||||||
|
interface SessionView {
|
||||||
|
readonly identity: string;
|
||||||
|
readonly enteredAt: string;
|
||||||
|
readonly open: boolean; // no vehicle_exit yet
|
||||||
|
readonly paidAt: string | null; // latest payment time, if any
|
||||||
|
readonly graceExitMin: number | null; // from the payment's tariff context, if known
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ExitFlow {
|
||||||
|
readonly #db: Db;
|
||||||
|
readonly #log: EventLog;
|
||||||
|
readonly #logger: FastifyBaseLogger;
|
||||||
|
readonly #inFlight = new Set<string>();
|
||||||
|
|
||||||
|
constructor(db: Db, log: EventLog, logger: FastifyBaseLogger) {
|
||||||
|
this.#db = db;
|
||||||
|
this.#log = log;
|
||||||
|
this.#logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Handle a transient-ticket read at an exit barrier (the relay pre-resolved by the
|
||||||
|
* read dispatcher from the reader's binding, which has ruled out a permit match). */
|
||||||
|
async handleAt(resolved: ResolvedRelay, e: DeviceReadEvent): Promise<ReadOutcome> {
|
||||||
|
const key = `${e.deviceId}:${e.value}`;
|
||||||
|
if (this.#inFlight.has(key)) return { accepted: false, reason: "duplicate read in flight" };
|
||||||
|
this.#inFlight.add(key);
|
||||||
|
try {
|
||||||
|
return await this.#runExit(resolved, e);
|
||||||
|
} catch (err) {
|
||||||
|
this.#logger.error(`exit-flow failed: ${(err as Error).message}`);
|
||||||
|
return { accepted: false, reason: (err as Error).message };
|
||||||
|
} finally {
|
||||||
|
this.#inFlight.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async #runExit(resolved: ResolvedRelay, e: DeviceReadEvent): Promise<ReadOutcome> {
|
||||||
|
const view = this.#sessionFor(e.value);
|
||||||
|
|
||||||
|
// No matching open session — unknown/duplicate ticket. Reject + log.
|
||||||
|
if (!view || !view.open) {
|
||||||
|
const reason = view ? "exit refused — session already closed" : "exit refused — no open session for credential";
|
||||||
|
await this.#log.append({
|
||||||
|
type: "anomaly",
|
||||||
|
identity: e.value,
|
||||||
|
payload: { reason, exitRefused: true },
|
||||||
|
});
|
||||||
|
this.#logger.warn(`exit refused: no open session for ${e.value}`);
|
||||||
|
return { accepted: false, direction: "exit", reason };
|
||||||
|
}
|
||||||
|
|
||||||
|
// PAID + within walk-back grace?
|
||||||
|
const paid = view.paidAt != null;
|
||||||
|
const withinGrace =
|
||||||
|
paid &&
|
||||||
|
view.graceExitMin != null &&
|
||||||
|
Date.now() - Date.parse(view.paidAt!) <= view.graceExitMin * 60_000;
|
||||||
|
|
||||||
|
if (!paid || !withinGrace) {
|
||||||
|
const reason = !paid
|
||||||
|
? "exit refused — not paid (pay at the station)"
|
||||||
|
: "exit refused — walk-back grace expired (top-up required)";
|
||||||
|
await this.#log.append({
|
||||||
|
type: "anomaly",
|
||||||
|
identity: e.value,
|
||||||
|
payload: { reason, exitRefused: true, sessionRef: e.value },
|
||||||
|
});
|
||||||
|
this.#logger.warn(`exit refused (${e.value}): ${reason}`);
|
||||||
|
return { accepted: false, direction: "exit", reason };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Valid: sign the exit BEFORE opening, then open, then update the cache.
|
||||||
|
await this.#log.append({
|
||||||
|
type: "vehicle_exit",
|
||||||
|
direction: "exit",
|
||||||
|
source: e.kind === "plate" ? "lpr" : "ticket",
|
||||||
|
identity: e.value,
|
||||||
|
payload: { sessionRef: e.value },
|
||||||
|
});
|
||||||
|
|
||||||
|
const access = this.#buildAccess(resolved.controller);
|
||||||
|
if (access) await access.pulseOpen(resolved.relay);
|
||||||
|
else this.#logger.warn(`exit signed for ${e.value} but the exit relay won't build`);
|
||||||
|
|
||||||
|
// SNAPSHOT — fire the exit camera(s), never awaited (evidence, not a gate).
|
||||||
|
void snapshotAsync({
|
||||||
|
db: this.#db,
|
||||||
|
direction: "exit",
|
||||||
|
identity: e.value,
|
||||||
|
logger: this.#logger,
|
||||||
|
}).catch((err) => this.#logger.error(`exit snapshot error: ${(err as Error).message}`));
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.#db
|
||||||
|
.update(sessions)
|
||||||
|
.set({ exitedAt: new Date().toISOString(), state: "closed" })
|
||||||
|
.where(eq(sessions.id, e.value))
|
||||||
|
.run();
|
||||||
|
} catch (err) {
|
||||||
|
this.#logger.error(`session-cache close failed for ${e.value}: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
return { accepted: true, direction: "exit" };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fold the signed ledger into a session view for one identity (authoritative). */
|
||||||
|
#sessionFor(identity: string): SessionView | null {
|
||||||
|
const rows = this.#db
|
||||||
|
.select()
|
||||||
|
.from(ledgerEvents)
|
||||||
|
.where(eq(ledgerEvents.identity, identity))
|
||||||
|
.orderBy(ledgerEvents.index)
|
||||||
|
.all();
|
||||||
|
if (rows.length === 0) return null;
|
||||||
|
|
||||||
|
const entry = rows.find((r) => r.type === "vehicle_entry");
|
||||||
|
if (!entry) return null;
|
||||||
|
const exited = rows.some((r) => r.type === "vehicle_exit");
|
||||||
|
|
||||||
|
let paidAt: string | null = null;
|
||||||
|
let graceExitMin: number | null = null;
|
||||||
|
for (const r of rows) {
|
||||||
|
if (r.type === "payment") {
|
||||||
|
paidAt = r.occurredAt;
|
||||||
|
const p = (r.payload ?? {}) as LedgerPayload & { graceExitMin?: number };
|
||||||
|
if (typeof p.graceExitMin === "number") graceExitMin = p.graceExitMin;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
identity,
|
||||||
|
enteredAt: entry.occurredAt,
|
||||||
|
open: !exited,
|
||||||
|
paidAt,
|
||||||
|
graceExitMin,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build a live access adapter from a resolved controller row, or null. */
|
||||||
|
#buildAccess(row: DeviceRow): AccessControlDevice | null {
|
||||||
|
const driver = registry.get(row.driverId);
|
||||||
|
if (!driver) return null;
|
||||||
|
try {
|
||||||
|
return driver.create(row.config as never) as AccessControlDevice;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { eq, ledgerEvents, siteConfig, type Db } from "@parking/db";
|
||||||
|
|
||||||
|
// Occupancy = a FOLD over the signed ledger: the count of vehicle_entry events
|
||||||
|
// with no matching vehicle_exit. Never a hand-maintained counter (which is
|
||||||
|
// editable + drifts) — the chain is the truth. See wiki/concepts/capacity-occupancy.md.
|
||||||
|
|
||||||
|
export interface Occupancy {
|
||||||
|
/** Cars currently inside (open sessions). */
|
||||||
|
readonly count: number;
|
||||||
|
/** Admin-set nominal capacity, or null = no limit. */
|
||||||
|
readonly capacity: number | null;
|
||||||
|
/** capacity − count, or null when uncapped. Can read 0 (or below) when full. */
|
||||||
|
readonly free: number | null;
|
||||||
|
/** True when count ≥ capacity (always false when uncapped). */
|
||||||
|
readonly full: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Count cars inside: entries minus exits, per identity, over the ledger. */
|
||||||
|
export function occupancyCount(db: Db): number {
|
||||||
|
const rows = db
|
||||||
|
.select({ type: ledgerEvents.type, identity: ledgerEvents.identity })
|
||||||
|
.from(ledgerEvents)
|
||||||
|
.all();
|
||||||
|
const balance = new Map<string, number>();
|
||||||
|
for (const r of rows) {
|
||||||
|
if (r.type === "vehicle_entry") balance.set(r.identity ?? "", (balance.get(r.identity ?? "") ?? 0) + 1);
|
||||||
|
else if (r.type === "vehicle_exit") balance.set(r.identity ?? "", (balance.get(r.identity ?? "") ?? 0) - 1);
|
||||||
|
}
|
||||||
|
let open = 0;
|
||||||
|
for (const v of balance.values()) if (v > 0) open += 1;
|
||||||
|
return open;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Admin-set capacity (null = uncapped). */
|
||||||
|
export function siteCapacity(db: Db): number | null {
|
||||||
|
const row = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||||
|
return row?.capacity ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getOccupancy(db: Db): Occupancy {
|
||||||
|
const count = occupancyCount(db);
|
||||||
|
const capacity = siteCapacity(db);
|
||||||
|
return {
|
||||||
|
count,
|
||||||
|
capacity,
|
||||||
|
free: capacity == null ? null : capacity - count,
|
||||||
|
full: capacity != null && count >= capacity,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import { desc, eq, ledgerEvents, sessions, tariffVersions, tariffs, type Db } from "@parking/db";
|
||||||
|
import { computeFee, type TariffStructure, type Tender } from "@parking/shared";
|
||||||
|
import type { FastifyBaseLogger } from "fastify";
|
||||||
|
import type { EventLog } from "./event-log.js";
|
||||||
|
|
||||||
|
// The PAY STATION: a customer pays for an open session BEFORE walking back to the
|
||||||
|
// car (pay-on-foot — payment is decoupled from exit). Two steps:
|
||||||
|
// 1. quote(identity) → look up the open session, price it against the tariff in
|
||||||
|
// force at entry, return the amount due (no side effect).
|
||||||
|
// 2. pay(identity, tender) → re-price, append a SIGNED `payment` event carrying
|
||||||
|
// the amount, currency, tender, tariffVersionId, and graceExitMin (so the exit
|
||||||
|
// flow can validate paid + within walk-back grace). Payment is a signed ledger
|
||||||
|
// event, never a mutable "paid" flag — an operator can't forge or delete it.
|
||||||
|
// See wiki/concepts/tariff.md, parking-session.md.
|
||||||
|
|
||||||
|
export class NoOpenSessionError extends Error {
|
||||||
|
constructor(identity: string) {
|
||||||
|
super(`no open session for ${identity}`);
|
||||||
|
this.name = "NoOpenSessionError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class NoTariffError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super("no active tariff configured");
|
||||||
|
this.name = "NoTariffError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Quote {
|
||||||
|
readonly identity: string;
|
||||||
|
readonly enteredAt: string;
|
||||||
|
readonly amountMinor: number;
|
||||||
|
readonly currency: string;
|
||||||
|
readonly tariffVersionId: string;
|
||||||
|
readonly graceExitMin: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PayStation {
|
||||||
|
readonly #db: Db;
|
||||||
|
readonly #log: EventLog;
|
||||||
|
readonly #logger: FastifyBaseLogger;
|
||||||
|
|
||||||
|
constructor(db: Db, log: EventLog, logger: FastifyBaseLogger) {
|
||||||
|
this.#db = db;
|
||||||
|
this.#log = log;
|
||||||
|
this.#logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Price an open session against the tariff in force at its entry. No side effect. */
|
||||||
|
quote(identity: string): Quote {
|
||||||
|
const entry = this.#openEntry(identity);
|
||||||
|
if (!entry) throw new NoOpenSessionError(identity);
|
||||||
|
|
||||||
|
const tv = this.#tariffVersionFor(entry.occurredAt);
|
||||||
|
if (!tv) throw new NoTariffError();
|
||||||
|
const structure = tv.structure as unknown as TariffStructure;
|
||||||
|
|
||||||
|
const amountMinor = computeFee(entry.occurredAt, new Date().toISOString(), structure);
|
||||||
|
return {
|
||||||
|
identity,
|
||||||
|
enteredAt: entry.occurredAt,
|
||||||
|
amountMinor,
|
||||||
|
currency: tv.currency,
|
||||||
|
tariffVersionId: tv.id,
|
||||||
|
graceExitMin: structure.gracePeriodExitMin,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Take payment for a session and append the signed `payment` event. Re-quotes at
|
||||||
|
* the moment of payment (the customer pays for time parked SO FAR). For an
|
||||||
|
* overstay top-up the same call re-prices entry→now and the exit flow's
|
||||||
|
* grace-window restarts from this payment. `overrideMinor` lets the operator set
|
||||||
|
* an arbitrary amount (lost ticket / dispute) — recorded as the charged amount.
|
||||||
|
*/
|
||||||
|
async pay(
|
||||||
|
identity: string,
|
||||||
|
tender: Tender,
|
||||||
|
overrideMinor?: number,
|
||||||
|
): Promise<{ amountMinor: number; currency: string }> {
|
||||||
|
const q = this.quote(identity);
|
||||||
|
const amountMinor = overrideMinor ?? q.amountMinor;
|
||||||
|
|
||||||
|
await this.#log.append({
|
||||||
|
type: "payment",
|
||||||
|
source: "manual",
|
||||||
|
identity,
|
||||||
|
payload: {
|
||||||
|
sessionRef: identity,
|
||||||
|
amountMinor,
|
||||||
|
currency: q.currency,
|
||||||
|
tender,
|
||||||
|
tariffVersionId: q.tariffVersionId,
|
||||||
|
// The exit flow reads graceExitMin off the payment to validate the
|
||||||
|
// walk-back window without re-resolving the tariff.
|
||||||
|
graceExitMin: q.graceExitMin,
|
||||||
|
...(overrideMinor != null ? { reason: "operator-set amount", quotedMinor: q.amountMinor } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update the projection cache (rebuildable; not the source of truth).
|
||||||
|
try {
|
||||||
|
this.#db.update(sessions).set({ state: "paid" }).where(eq(sessions.id, identity)).run();
|
||||||
|
} catch (err) {
|
||||||
|
this.#logger.error(`session-cache mark-paid failed for ${identity}: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.#logger.info(`payment ${amountMinor} ${q.currency} (${tender}) for ${identity}`);
|
||||||
|
return { amountMinor, currency: q.currency };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The vehicle_entry of an OPEN session for this identity (no later exit), or null. */
|
||||||
|
#openEntry(identity: string) {
|
||||||
|
const rows = this.#db
|
||||||
|
.select()
|
||||||
|
.from(ledgerEvents)
|
||||||
|
.where(eq(ledgerEvents.identity, identity))
|
||||||
|
.orderBy(ledgerEvents.index)
|
||||||
|
.all();
|
||||||
|
const entry = rows.find((r) => r.type === "vehicle_entry");
|
||||||
|
if (!entry) return null;
|
||||||
|
if (rows.some((r) => r.type === "vehicle_exit")) return null; // already closed
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The tariff version in force at `at` — latest effectiveFrom ≤ at, for the
|
||||||
|
* (single, for now) active site tariff. */
|
||||||
|
#tariffVersionFor(at: string) {
|
||||||
|
const tariff = this.#db.select().from(tariffs).where(eq(tariffs.scope, "site")).get();
|
||||||
|
if (!tariff) return null;
|
||||||
|
const versions = this.#db
|
||||||
|
.select()
|
||||||
|
.from(tariffVersions)
|
||||||
|
.where(eq(tariffVersions.tariffId, tariff.id))
|
||||||
|
.orderBy(desc(tariffVersions.effectiveFrom))
|
||||||
|
.all();
|
||||||
|
return versions.find((v) => v.effectiveFrom <= at) ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
import { eq, ledgerEvents, permitCredentials, permitPlates, permits, sessions, type Db, type DeviceRow } from "@parking/db";
|
||||||
|
import { registry, type AccessControlDevice } from "@parking/devices";
|
||||||
|
import type { FastifyBaseLogger } from "fastify";
|
||||||
|
import type { DeviceReadEvent, ReadOutcome } from "./device-events.js";
|
||||||
|
import type { EventLog } from "./event-log.js";
|
||||||
|
import { type FlowDirection, type ResolvedRelay } from "./device-resolve.js";
|
||||||
|
import { snapshotAsync } from "./snapshot.js";
|
||||||
|
|
||||||
|
// PERMIT flow: a subscriber identified by card/QR/plate enters/exits without paying.
|
||||||
|
// Reached from the read dispatcher when a read matches a permit (not an open ticket).
|
||||||
|
// See wiki/entities/permit.md.
|
||||||
|
//
|
||||||
|
// Two optional, independent bindings:
|
||||||
|
// - car-count: `maxConcurrent` (default 1, null = unbound) — how many of the
|
||||||
|
// permit's cars may be inside at once; enforced over the session projection.
|
||||||
|
// - plate: optional `plates[]` — when set, a matching plate is an accepted identity
|
||||||
|
// too (card/QR OR plate). When unset, any car may use the permit's card/QR.
|
||||||
|
//
|
||||||
|
// Direction is inferred from session state for THAT car (the read credential value
|
||||||
|
// is the per-car session key): no open session → ENTRY; open session → EXIT. So a
|
||||||
|
// fleet permit can have several cars in at once, each its own session, and
|
||||||
|
// anti-passback falls out (a second "entry" on a car already in becomes its exit).
|
||||||
|
|
||||||
|
export interface PermitMatch {
|
||||||
|
readonly permitId: string;
|
||||||
|
/** The specific credential/plate value read — the per-car session key. */
|
||||||
|
readonly carKey: string;
|
||||||
|
readonly via: "card" | "qr" | "plate";
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PermitFlow {
|
||||||
|
readonly #db: Db;
|
||||||
|
readonly #log: EventLog;
|
||||||
|
readonly #logger: FastifyBaseLogger;
|
||||||
|
readonly #inFlight = new Set<string>();
|
||||||
|
|
||||||
|
constructor(db: Db, log: EventLog, logger: FastifyBaseLogger) {
|
||||||
|
this.#db = db;
|
||||||
|
this.#log = log;
|
||||||
|
this.#logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve a read to a permit (by card/QR credential, or by a bound plate), or null. */
|
||||||
|
match(e: DeviceReadEvent): PermitMatch | null {
|
||||||
|
// Card / QR / generic credential value.
|
||||||
|
const cred = this.#db
|
||||||
|
.select()
|
||||||
|
.from(permitCredentials)
|
||||||
|
.where(eq(permitCredentials.value, e.value))
|
||||||
|
.get();
|
||||||
|
if (cred) {
|
||||||
|
return { permitId: cred.permitId, carKey: e.value, via: cred.kind === "qr" ? "qr" : "card" };
|
||||||
|
}
|
||||||
|
// Plate binding: a read plate that matches a permit's bound plate is an identity.
|
||||||
|
if (e.kind === "plate") {
|
||||||
|
const plate = this.#db.select().from(permitPlates).where(eq(permitPlates.plate, e.value)).get();
|
||||||
|
if (plate) return { permitId: plate.permitId, carKey: e.value, via: "plate" };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Run the permit entry/exit for a matched read at a barrier. `resolved` is the
|
||||||
|
* reader's bound relay; its direction constrains, "both" defers to session state. */
|
||||||
|
async run(resolved: ResolvedRelay, e: DeviceReadEvent, m: PermitMatch): Promise<ReadOutcome> {
|
||||||
|
const key = `${m.permitId}:${m.carKey}`;
|
||||||
|
if (this.#inFlight.has(key)) return { accepted: false, reason: "duplicate read in flight" };
|
||||||
|
this.#inFlight.add(key);
|
||||||
|
try {
|
||||||
|
return await this.#run(resolved, e, m);
|
||||||
|
} catch (err) {
|
||||||
|
this.#logger.error(`permit-flow failed: ${(err as Error).message}`);
|
||||||
|
return { accepted: false, reason: (err as Error).message };
|
||||||
|
} finally {
|
||||||
|
this.#inFlight.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async #run(resolved: ResolvedRelay, e: DeviceReadEvent, m: PermitMatch): Promise<ReadOutcome> {
|
||||||
|
const permit = this.#db.select().from(permits).where(eq(permits.id, m.permitId)).get();
|
||||||
|
if (!permit) return { accepted: false, reason: "permit not found" };
|
||||||
|
|
||||||
|
// Validity: active + within the coverage window.
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const invalid =
|
||||||
|
permit.status !== "active" ||
|
||||||
|
(permit.validFrom != null && now < permit.validFrom) ||
|
||||||
|
(permit.validTo != null && now > permit.validTo);
|
||||||
|
if (invalid) {
|
||||||
|
const reason = `permit ${permit.status}/out-of-window`;
|
||||||
|
await this.#reject(m, reason);
|
||||||
|
return { accepted: false, reason };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Direction: the car's open-session state is the natural verb (in→exit, out→entry).
|
||||||
|
// The barrier the car is at (resolved.direction) must AGREE — a car at an exit
|
||||||
|
// barrier that isn't inside (or at an entry barrier while already in) is a
|
||||||
|
// wrong-barrier / anti-passback signal, refused + logged. A "both" barrier follows
|
||||||
|
// the session state.
|
||||||
|
const carOpen = this.#carHasOpenSession(m.carKey);
|
||||||
|
const inferred: FlowDirection = carOpen ? "exit" : "entry";
|
||||||
|
if (resolved.direction !== "both" && resolved.direction !== inferred) {
|
||||||
|
const reason = `permit wrong barrier — ${resolved.direction} barrier but car would ${inferred}`;
|
||||||
|
await this.#reject(m, reason);
|
||||||
|
return { accepted: false, direction: resolved.direction === "exit" ? "exit" : "entry", reason };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (carOpen) {
|
||||||
|
// EXIT: this car is already inside → the read is its exit.
|
||||||
|
await this.#log.append({
|
||||||
|
type: "vehicle_exit",
|
||||||
|
direction: "exit",
|
||||||
|
source: m.via === "plate" ? "lpr" : m.via === "qr" ? "qr" : "wiegand",
|
||||||
|
identity: m.carKey,
|
||||||
|
payload: { sessionRef: m.carKey, permitId: m.permitId },
|
||||||
|
});
|
||||||
|
await this.#open(resolved, "exit", m.carKey, "permit exit");
|
||||||
|
this.#closeCache(m.carKey);
|
||||||
|
return { accepted: true, direction: "exit" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ENTRY: enforce the car-count binding (maxConcurrent), then sign + open.
|
||||||
|
if (permit.maxConcurrent != null) {
|
||||||
|
const open = this.#permitOpenCount(m.permitId);
|
||||||
|
if (open >= permit.maxConcurrent) {
|
||||||
|
const reason = `permit at capacity (${open}/${permit.maxConcurrent} cars in)`;
|
||||||
|
await this.#reject(m, reason);
|
||||||
|
return { accepted: false, direction: "entry", reason };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.#log.append({
|
||||||
|
type: "vehicle_entry",
|
||||||
|
direction: "entry",
|
||||||
|
source: m.via === "plate" ? "lpr" : m.via === "qr" ? "qr" : "wiegand",
|
||||||
|
identity: m.carKey,
|
||||||
|
// No ticket, no fee — the permit IS the authorization. Recorded for audit.
|
||||||
|
payload: { sessionRef: m.carKey, permitId: m.permitId, permit: true },
|
||||||
|
occurredAt: now,
|
||||||
|
});
|
||||||
|
await this.#open(resolved, "entry", m.carKey, "permit entry");
|
||||||
|
try {
|
||||||
|
this.#db
|
||||||
|
.insert(sessions)
|
||||||
|
.values({ id: m.carKey, identity: m.carKey, source: m.via === "plate" ? "lpr" : "wiegand", permitId: m.permitId, enteredAt: now, state: "open" })
|
||||||
|
.run();
|
||||||
|
} catch (err) {
|
||||||
|
this.#logger.error(`session-cache insert failed for ${m.carKey}: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
return { accepted: true, direction: "entry" };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Does this specific car (credential value) have an open session right now? */
|
||||||
|
#carHasOpenSession(carKey: string): boolean {
|
||||||
|
const rows = this.#db
|
||||||
|
.select()
|
||||||
|
.from(ledgerEvents)
|
||||||
|
.where(eq(ledgerEvents.identity, carKey))
|
||||||
|
.orderBy(ledgerEvents.index)
|
||||||
|
.all();
|
||||||
|
const entries = rows.filter((r) => r.type === "vehicle_entry").length;
|
||||||
|
const exits = rows.filter((r) => r.type === "vehicle_exit").length;
|
||||||
|
return entries > exits;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How many of this permit's cars are inside right now (fold over the ledger). */
|
||||||
|
#permitOpenCount(permitId: string): number {
|
||||||
|
const rows = this.#db
|
||||||
|
.select()
|
||||||
|
.from(ledgerEvents)
|
||||||
|
.where(eq(ledgerEvents.type, "vehicle_entry"))
|
||||||
|
.all()
|
||||||
|
.filter((r) => (r.payload as { permitId?: string } | null)?.permitId === permitId);
|
||||||
|
let open = 0;
|
||||||
|
for (const entry of rows) {
|
||||||
|
if (!this.#carHasOpenSession(entry.identity ?? "")) continue;
|
||||||
|
open += 1;
|
||||||
|
}
|
||||||
|
return open;
|
||||||
|
}
|
||||||
|
|
||||||
|
async #reject(m: PermitMatch, reason: string): Promise<void> {
|
||||||
|
await this.#log.append({
|
||||||
|
type: "anomaly",
|
||||||
|
identity: m.carKey,
|
||||||
|
payload: { reason: `permit refused — ${reason}`, permitId: m.permitId, permitRefused: true },
|
||||||
|
});
|
||||||
|
this.#logger.warn(`permit refused (${m.carKey}): ${reason}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async #open(resolved: ResolvedRelay, dir: FlowDirection, carKey: string, what: string): Promise<void> {
|
||||||
|
const access = this.#buildAccess(resolved.controller);
|
||||||
|
if (access) await access.pulseOpen(resolved.relay);
|
||||||
|
else this.#logger.warn(`${what} signed for ${carKey} but the ${dir} relay won't build`);
|
||||||
|
|
||||||
|
// SNAPSHOT — fire the directional camera(s), never awaited (evidence, not a gate).
|
||||||
|
void snapshotAsync({
|
||||||
|
db: this.#db,
|
||||||
|
direction: dir,
|
||||||
|
identity: carKey,
|
||||||
|
logger: this.#logger,
|
||||||
|
}).catch((err) => this.#logger.error(`permit snapshot error: ${(err as Error).message}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
#closeCache(carKey: string): void {
|
||||||
|
try {
|
||||||
|
this.#db.update(sessions).set({ exitedAt: new Date().toISOString(), state: "closed" }).where(eq(sessions.id, carKey)).run();
|
||||||
|
} catch (err) {
|
||||||
|
this.#logger.error(`session-cache close failed for ${carKey}: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build a live access adapter from a resolved controller row, or null. */
|
||||||
|
#buildAccess(row: DeviceRow): AccessControlDevice | null {
|
||||||
|
const driver = registry.get(row.driverId);
|
||||||
|
if (!driver) return null;
|
||||||
|
try {
|
||||||
|
return driver.create(row.config as never) as AccessControlDevice;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { FastifyBaseLogger } from "fastify";
|
import type { FastifyBaseLogger } from "fastify";
|
||||||
import { eq, laneDevices, type Db } from "@parking/db";
|
import { eq, devices, type Db } from "@parking/db";
|
||||||
import {
|
import {
|
||||||
isMonitorable,
|
isMonitorable,
|
||||||
registry,
|
registry,
|
||||||
@@ -66,8 +66,8 @@ export class PrinterMonitor {
|
|||||||
async refreshDevices(): Promise<void> {
|
async refreshDevices(): Promise<void> {
|
||||||
const rows = await this.#db
|
const rows = await this.#db
|
||||||
.select()
|
.select()
|
||||||
.from(laneDevices)
|
.from(devices)
|
||||||
.where(eq(laneDevices.category, "printer"))
|
.where(eq(devices.category, "printer"))
|
||||||
.all();
|
.all();
|
||||||
|
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
@@ -89,7 +89,6 @@ export class PrinterMonitor {
|
|||||||
build: () => driver.create(cfg as never),
|
build: () => driver.create(cfg as never),
|
||||||
meta: {
|
meta: {
|
||||||
deviceId: row.id,
|
deviceId: row.id,
|
||||||
lane: row.lane,
|
|
||||||
driverId: row.driverId,
|
driverId: row.driverId,
|
||||||
role: typeof cfg.role === "string" ? cfg.role : undefined,
|
role: typeof cfg.role === "string" ? cfg.role : undefined,
|
||||||
},
|
},
|
||||||
@@ -139,7 +138,7 @@ export class PrinterMonitor {
|
|||||||
|
|
||||||
if (!prev || statusChanged(prev.status, status)) {
|
if (!prev || statusChanged(prev.status, status)) {
|
||||||
this.#log.info(
|
this.#log.info(
|
||||||
`printer-monitor: ${entry.meta.role ?? "printer"} ${id} (lane ${entry.meta.lane}) -> ${status.status}${status.detail ? ` (${status.detail})` : ""}`,
|
`printer-monitor: ${entry.meta.role ?? "printer"} ${id} -> ${status.status}${status.detail ? ` (${status.detail})` : ""}`,
|
||||||
);
|
);
|
||||||
deviceEvents.emitPrinterStatus(event);
|
deviceEvents.emitPrinterStatus(event);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
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 { 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,
|
||||||
|
// - else → transient EXIT flow (open ticket session → exit, else reject+log).
|
||||||
|
//
|
||||||
|
// 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;
|
||||||
|
readonly #exit: ExitFlow;
|
||||||
|
readonly #permit: PermitFlow;
|
||||||
|
readonly #logger: FastifyBaseLogger;
|
||||||
|
|
||||||
|
constructor(db: Db, exit: ExitFlow, permit: PermitFlow, logger: FastifyBaseLogger) {
|
||||||
|
this.#db = db;
|
||||||
|
this.#exit = exit;
|
||||||
|
this.#permit = permit;
|
||||||
|
this.#logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
async dispatch(e: DeviceReadEvent): Promise<ReadOutcome> {
|
||||||
|
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(resolved, e, permit);
|
||||||
|
}
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,6 @@ import bcrypt from "bcrypt";
|
|||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
import { eq, users, type Db } from "@parking/db";
|
import { eq, users, type Db } from "@parking/db";
|
||||||
import {
|
import {
|
||||||
TOKEN_TTL,
|
|
||||||
clearAuthCookies,
|
clearAuthCookies,
|
||||||
newCsrfToken,
|
newCsrfToken,
|
||||||
requireRole,
|
requireRole,
|
||||||
@@ -34,10 +33,13 @@ export async function authRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const csrf = newCsrfToken();
|
const csrf = newCsrfToken();
|
||||||
const token = await reply.jwtSign(
|
// No expiresIn: the token is valid until explicit logout (see auth.ts).
|
||||||
{ sub: user.id, username: user.username, role: user.role, csrf },
|
const token = await reply.jwtSign({
|
||||||
{ expiresIn: TOKEN_TTL },
|
sub: user.id,
|
||||||
);
|
username: user.username,
|
||||||
|
role: user.role,
|
||||||
|
csrf,
|
||||||
|
});
|
||||||
setAuthCookies(reply, token, csrf);
|
setAuthCookies(reply, token, csrf);
|
||||||
return { id: user.id, username: user.username, role: user.role };
|
return { id: user.id, username: user.username, role: user.role };
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||||
import { eq, laneDevices, type Db } from "@parking/db";
|
import { eq, devices, type Db } from "@parking/db";
|
||||||
import { deviceEvents } from "../device-events.js";
|
import { deviceEvents } from "../device-events.js";
|
||||||
import { verifyDigest } from "../digest-auth.js";
|
import { verifyDigest } from "../digest-auth.js";
|
||||||
|
|
||||||
@@ -36,7 +36,7 @@ export async function deviceRoutes(app: FastifyInstance, db: Db): Promise<void>
|
|||||||
const handle = async (req: FastifyRequest<{ Params: InputParams }>, reply: FastifyReply) => {
|
const handle = async (req: FastifyRequest<{ Params: InputParams }>, reply: FastifyReply) => {
|
||||||
const { deviceId, n, edge } = req.params;
|
const { deviceId, n, edge } = req.params;
|
||||||
|
|
||||||
const row = await db.select().from(laneDevices).where(eq(laneDevices.id, deviceId)).get();
|
const row = await db.select().from(devices).where(eq(devices.id, deviceId)).get();
|
||||||
const cfg = row?.config as DingtianDeviceConfig | undefined;
|
const cfg = row?.config as DingtianDeviceConfig | undefined;
|
||||||
|
|
||||||
// Unknown device / not a dingtian / no push creds / wrong source IP → 404.
|
// Unknown device / not a dingtian / no push creds / wrong source IP → 404.
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import { requireRole } from "../auth.js";
|
||||||
|
import {
|
||||||
|
NoOpenSessionError,
|
||||||
|
NoTariffError,
|
||||||
|
type PayStation,
|
||||||
|
} from "../pay-station.js";
|
||||||
|
|
||||||
|
// Pay-station endpoints (pay-on-foot). The terminal/operator UI quotes a session
|
||||||
|
// then takes payment; the payment becomes a signed ledger event. PCI scope stays
|
||||||
|
// OUT of the app — actual card capture is a standalone P2PE terminal; here `tender`
|
||||||
|
// just records cash vs. card. See wiki/concepts/tariff.md, parking-session.md, bom.md.
|
||||||
|
|
||||||
|
interface QuoteQuery {
|
||||||
|
identity: string;
|
||||||
|
}
|
||||||
|
interface PayBody {
|
||||||
|
identity: string;
|
||||||
|
tender: "cash" | "card";
|
||||||
|
/** Operator-set amount (lost ticket / dispute) — overrides the computed fee. */
|
||||||
|
overrideMinor?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function payRoutes(app: FastifyInstance, payStation: PayStation): Promise<void> {
|
||||||
|
// Cashier/operator/admin operate the pay station; readonly may not.
|
||||||
|
const guard = requireRole("admin", "operator", "cashier");
|
||||||
|
|
||||||
|
// Quote: what does this session owe right now? (No side effect.)
|
||||||
|
app.get<{ Querystring: QuoteQuery }>(
|
||||||
|
"/api/pay/quote",
|
||||||
|
{ preHandler: guard },
|
||||||
|
async (req, reply) => {
|
||||||
|
const identity = (req.query.identity ?? "").trim();
|
||||||
|
if (!identity) return reply.code(400).send({ error: "identity required" });
|
||||||
|
try {
|
||||||
|
return payStation.quote(identity);
|
||||||
|
} catch (err) {
|
||||||
|
return mapError(reply, err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Pay: take payment and append the signed `payment` event.
|
||||||
|
app.post<{ Body: PayBody }>(
|
||||||
|
"/api/pay",
|
||||||
|
{ preHandler: guard },
|
||||||
|
async (req, reply) => {
|
||||||
|
const { identity, tender, overrideMinor } = req.body ?? {};
|
||||||
|
if (!identity || (tender !== "cash" && tender !== "card")) {
|
||||||
|
return reply.code(400).send({ error: "identity and tender (cash|card) required" });
|
||||||
|
}
|
||||||
|
if (overrideMinor != null && (!Number.isInteger(overrideMinor) || overrideMinor < 0)) {
|
||||||
|
return reply.code(400).send({ error: "overrideMinor must be a non-negative integer (minor units)" });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await payStation.pay(identity, tender, overrideMinor);
|
||||||
|
return reply.code(201).send(res);
|
||||||
|
} catch (err) {
|
||||||
|
return mapError(reply, err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapError(reply: import("fastify").FastifyReply, err: unknown) {
|
||||||
|
if (err instanceof NoOpenSessionError) return reply.code(404).send({ error: err.message });
|
||||||
|
if (err instanceof NoTariffError) return reply.code(409).send({ error: err.message });
|
||||||
|
return reply.code(500).send({ error: (err as Error).message });
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import { eq, permitCredentials, permitPlates, permits, type Db } from "@parking/db";
|
||||||
|
import { requireRole } from "../auth.js";
|
||||||
|
|
||||||
|
// Permit (subscription) admin CRUD. A permit is mutable master data — admins
|
||||||
|
// grant/edit/revoke — but every USE of it is a signed ledger event, so the audit
|
||||||
|
// trail stays append-only (see wiki/entities/permit.md). A permit is an aggregate:
|
||||||
|
// the permit row + its credentials (card/QR) + its bound plates. The API treats them
|
||||||
|
// as one unit (create/update replace the child sets; delete removes all).
|
||||||
|
|
||||||
|
interface Credential {
|
||||||
|
kind: "rf" | "qr";
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
interface PermitBody {
|
||||||
|
holderName?: string;
|
||||||
|
contact?: string;
|
||||||
|
/** Car-count binding: cars inside at once. Default 1; null = unbound. */
|
||||||
|
maxConcurrent?: number | null;
|
||||||
|
validFrom?: string | null;
|
||||||
|
validTo?: string | null;
|
||||||
|
status?: "active" | "suspended" | "revoked";
|
||||||
|
credentials?: Credential[];
|
||||||
|
/** Plate binding (optional): bound plates that also serve as identity. */
|
||||||
|
plates?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function permitRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||||
|
// Admin manages permits; operator/cashier/readonly may LIST (to look one up).
|
||||||
|
const readGuard = requireRole("admin", "operator", "cashier", "readonly");
|
||||||
|
const writeGuard = requireRole("admin");
|
||||||
|
|
||||||
|
// Validate the body; returns problems (empty = ok). Shared by create + update.
|
||||||
|
function validate(b: PermitBody): string[] {
|
||||||
|
const errs: string[] = [];
|
||||||
|
if (b.maxConcurrent != null) {
|
||||||
|
if (!Number.isInteger(b.maxConcurrent) || b.maxConcurrent < 1) {
|
||||||
|
errs.push("maxConcurrent must be a positive integer, or null for unbound");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (b.status && !["active", "suspended", "revoked"].includes(b.status)) {
|
||||||
|
errs.push("status must be active|suspended|revoked");
|
||||||
|
}
|
||||||
|
for (const c of b.credentials ?? []) {
|
||||||
|
if ((c.kind !== "rf" && c.kind !== "qr") || !c.value?.trim()) {
|
||||||
|
errs.push("each credential needs kind (rf|qr) and a non-empty value");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ((b.credentials?.length ?? 0) === 0 && (b.plates?.length ?? 0) === 0) {
|
||||||
|
errs.push("a permit needs at least one credential or one bound plate (else nothing identifies it)");
|
||||||
|
}
|
||||||
|
return errs;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadAggregate(id: string) {
|
||||||
|
const permit = db.select().from(permits).where(eq(permits.id, id)).get();
|
||||||
|
if (!permit) return null;
|
||||||
|
const credentials = db.select().from(permitCredentials).where(eq(permitCredentials.permitId, id)).all();
|
||||||
|
const plates = db.select().from(permitPlates).where(eq(permitPlates.permitId, id)).all();
|
||||||
|
return {
|
||||||
|
...permit,
|
||||||
|
credentials: credentials.map((c) => ({ kind: c.kind, value: c.value })),
|
||||||
|
plates: plates.map((p) => p.plate),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace a permit's child rows (credentials + plates) from the body.
|
||||||
|
function writeChildren(id: string, b: PermitBody) {
|
||||||
|
db.delete(permitCredentials).where(eq(permitCredentials.permitId, id)).run();
|
||||||
|
db.delete(permitPlates).where(eq(permitPlates.permitId, id)).run();
|
||||||
|
for (const c of b.credentials ?? []) {
|
||||||
|
db.insert(permitCredentials).values({ id: randomUUID(), permitId: id, kind: c.kind, value: c.value.trim() }).run();
|
||||||
|
}
|
||||||
|
for (const p of b.plates ?? []) {
|
||||||
|
if (p.trim()) db.insert(permitPlates).values({ id: randomUUID(), permitId: id, plate: p.trim() }).run();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// List all permits (with their credentials + plates).
|
||||||
|
app.get("/api/permits", { preHandler: readGuard }, async () => {
|
||||||
|
const rows = db.select().from(permits).all();
|
||||||
|
return { permits: rows.map((r) => loadAggregate(r.id)) };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create a permit.
|
||||||
|
app.post<{ Body: PermitBody }>("/api/permits", { preHandler: writeGuard }, async (req, reply) => {
|
||||||
|
const b = req.body ?? {};
|
||||||
|
const problems = validate(b);
|
||||||
|
if (problems.length) return reply.code(400).send({ error: "invalid permit", problems });
|
||||||
|
const id = randomUUID();
|
||||||
|
db.insert(permits)
|
||||||
|
.values({
|
||||||
|
id,
|
||||||
|
holderName: b.holderName ?? null,
|
||||||
|
contact: b.contact ?? null,
|
||||||
|
maxConcurrent: b.maxConcurrent === undefined ? 1 : b.maxConcurrent,
|
||||||
|
validFrom: b.validFrom ?? null,
|
||||||
|
validTo: b.validTo ?? null,
|
||||||
|
status: b.status ?? "active",
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
writeChildren(id, b);
|
||||||
|
return reply.code(201).send(loadAggregate(id));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update a permit (replaces fields + child sets).
|
||||||
|
app.put<{ Params: { id: string }; Body: PermitBody }>(
|
||||||
|
"/api/permits/:id",
|
||||||
|
{ preHandler: writeGuard },
|
||||||
|
async (req, reply) => {
|
||||||
|
const existing = db.select().from(permits).where(eq(permits.id, req.params.id)).get();
|
||||||
|
if (!existing) return reply.code(404).send({ error: "permit not found" });
|
||||||
|
const b = req.body ?? {};
|
||||||
|
const problems = validate(b);
|
||||||
|
if (problems.length) return reply.code(400).send({ error: "invalid permit", problems });
|
||||||
|
db.update(permits)
|
||||||
|
.set({
|
||||||
|
holderName: b.holderName ?? null,
|
||||||
|
contact: b.contact ?? null,
|
||||||
|
maxConcurrent: b.maxConcurrent === undefined ? existing.maxConcurrent : b.maxConcurrent,
|
||||||
|
validFrom: b.validFrom ?? null,
|
||||||
|
validTo: b.validTo ?? null,
|
||||||
|
status: b.status ?? existing.status,
|
||||||
|
})
|
||||||
|
.where(eq(permits.id, req.params.id))
|
||||||
|
.run();
|
||||||
|
writeChildren(req.params.id, b);
|
||||||
|
return loadAggregate(req.params.id);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Revoke (soft): the common case — keeps the permit + its history, just bars it.
|
||||||
|
// A revoked permit fails the entry check (see permit-flow.ts). Use DELETE only to
|
||||||
|
// fully remove a permit created in error.
|
||||||
|
app.post<{ Params: { id: string } }>(
|
||||||
|
"/api/permits/:id/revoke",
|
||||||
|
{ preHandler: writeGuard },
|
||||||
|
async (req, reply) => {
|
||||||
|
const r = db.update(permits).set({ status: "revoked" }).where(eq(permits.id, req.params.id)).run();
|
||||||
|
if (r.changes === 0) return reply.code(404).send({ error: "permit not found" });
|
||||||
|
return loadAggregate(req.params.id);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Hard delete a permit + its child rows. (Past ledger events that reference it
|
||||||
|
// are untouched — the audit trail is append-only and independent of this row.)
|
||||||
|
app.delete<{ Params: { id: string } }>(
|
||||||
|
"/api/permits/:id",
|
||||||
|
{ preHandler: writeGuard },
|
||||||
|
async (req, reply) => {
|
||||||
|
const r = db.delete(permits).where(eq(permits.id, req.params.id)).run();
|
||||||
|
if (r.changes === 0) return reply.code(404).send({ error: "permit not found" });
|
||||||
|
db.delete(permitCredentials).where(eq(permitCredentials.permitId, req.params.id)).run();
|
||||||
|
db.delete(permitPlates).where(eq(permitPlates.permitId, req.params.id)).run();
|
||||||
|
return reply.code(204).send();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import { eq, devices, type Db } from "@parking/db";
|
||||||
|
import type { DeviceReadEvent } from "../device-events.js";
|
||||||
|
import type { ReadDispatcher } from "../read-dispatch.js";
|
||||||
|
|
||||||
|
// GEE/Dingtian QR reader endpoint. The reader is configured (vendor tool) with our
|
||||||
|
// host as its "server"; on each scan it sends an HTTP GET and BEEPS/acts based on
|
||||||
|
// our JSON reply — host-in-the-loop and synchronous. Protocol from the QRCode SDK
|
||||||
|
// v1.6.5; see wiki/sources/qrcode-sdk.md and wiki/entities/gee-qr-er80.md.
|
||||||
|
//
|
||||||
|
// reader → GET /qa/mcardsea.php?cardid=<QR>&mjihao=<devId>&cjihao=<devSN>&status=<2ch>&time=<utc>
|
||||||
|
// server → {"data":[{cardid,cjihao,mjihao,status,time,output}],"code":0,"message":""}
|
||||||
|
// reply status: 1 = valid (beep 2×) / 0 = invalid (beep 1×)
|
||||||
|
// reply output: 0 = Access, 1 = WG26, 2 = WG34 (line driven on a valid read)
|
||||||
|
// reply time: UTC — syncs the device clock
|
||||||
|
//
|
||||||
|
// The "server language" set on the device only selects this URL path; we accept the
|
||||||
|
// SDK default path. No auth on the device side (it can't); the reader sits on the
|
||||||
|
// device subnet (network-isolation) and the signed ledger is the real guarantee.
|
||||||
|
|
||||||
|
interface ReaderQuery {
|
||||||
|
cardid?: string;
|
||||||
|
mjihao?: string; // device id
|
||||||
|
cjihao?: string; // device serial
|
||||||
|
status?: string; // 2 chars: high valid/invalid, low 1=in/0=out
|
||||||
|
time?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function qrReaderRoutes(
|
||||||
|
app: FastifyInstance,
|
||||||
|
db: Db,
|
||||||
|
dispatcher: ReadDispatcher,
|
||||||
|
): Promise<void> {
|
||||||
|
// Resolve the lane_devices row whose config.serial matches the reader's reported
|
||||||
|
// serial (cjihao). The row id is a normal UUID; the serial is config the admin
|
||||||
|
// enters when assigning the gee-qr-reader. Returns the row id, or null if no
|
||||||
|
// reader is assigned for that serial. (Small device set → scan in JS.)
|
||||||
|
const readerRowIdForSerial = (serial: string): string | null => {
|
||||||
|
if (!serial) return null;
|
||||||
|
const rows = db.select().from(devices).where(eq(devices.category, "reader")).all();
|
||||||
|
const match = rows.find((r) => r.enabled && (r.config as { serial?: string }).serial === serial);
|
||||||
|
return match?.id ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// No auth: the reader is a machine on the isolated device subnet and offers no
|
||||||
|
// auth on its side. Public route, like the Dingtian input push.
|
||||||
|
const handler = async (req: { query: ReaderQuery }, reply: import("fastify").FastifyReply) => {
|
||||||
|
const q = req.query;
|
||||||
|
// The reader sends `Connection: keep-alive` but only ACTS on our verdict (beep,
|
||||||
|
// drive output) once the socket CLOSES — every vendor demo replies
|
||||||
|
// `Connection: close` and shuts the socket. Without it the reader waits out a
|
||||||
|
// ~10 s keep-alive timeout before beeping. So force-close the connection.
|
||||||
|
// See wiki/sources/qrcode-sdk.md, entities/gee-qr-er80.md.
|
||||||
|
reply.header("connection", "close");
|
||||||
|
const cardid = (q.cardid ?? "").trim();
|
||||||
|
const mjihao = q.mjihao != null ? Number(q.mjihao) : 0;
|
||||||
|
const serial = (q.cjihao ?? "").trim();
|
||||||
|
|
||||||
|
// Map the reader's serial → its assigned lane_devices row id (the dispatcher
|
||||||
|
// resolves the lane from that row). If unassigned, deviceId stays the serial so
|
||||||
|
// the dispatcher simply finds no lane and rejects (status:0) — never crashes.
|
||||||
|
const deviceId = readerRowIdForSerial(serial) ?? serial;
|
||||||
|
|
||||||
|
let accepted = false;
|
||||||
|
if (cardid) {
|
||||||
|
const read: DeviceReadEvent = {
|
||||||
|
driverId: "gee-qr-reader",
|
||||||
|
deviceId,
|
||||||
|
value: cardid,
|
||||||
|
kind: "qr",
|
||||||
|
at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const outcome = await dispatcher.dispatch(read);
|
||||||
|
accepted = outcome.accepted;
|
||||||
|
if (!accepted) app.log.info(`QR ${cardid} rejected: ${outcome.reason ?? "?"}`);
|
||||||
|
} catch (err) {
|
||||||
|
app.log.error(`QR dispatch failed for ${cardid}: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reply the SDK verdict. status 1 → beep 2× (valid) / 0 → beep 1× (invalid).
|
||||||
|
// output 0 = Access (drive the reader's access line on a valid read).
|
||||||
|
return {
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
cardid,
|
||||||
|
cjihao: q.cjihao ?? 0,
|
||||||
|
mjihao,
|
||||||
|
status: accepted ? 1 : 0,
|
||||||
|
time: String(Math.floor(Date.now() / 1000)),
|
||||||
|
output: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
code: 0,
|
||||||
|
message: "",
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// The reader's "server language" setting (JSP/PHP/C#/ASP/CGI) selects the URL
|
||||||
|
// EXTENSION it GETs — verified on hardware: a JSP-configured unit posts
|
||||||
|
// /qa/mcardsea.jsp. Register every extension so the endpoint works whatever the
|
||||||
|
// device is set to; accept POST too in case a variant differs.
|
||||||
|
for (const ext of ["php", "jsp", "asp", "aspx", "cgi"]) {
|
||||||
|
const path = `/qa/mcardsea.${ext}`;
|
||||||
|
app.get<{ Querystring: ReaderQuery }>(path, handler);
|
||||||
|
app.post<{ Querystring: ReaderQuery }>(path, handler);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { randomBytes, randomUUID } from "node:crypto";
|
import { randomBytes, randomUUID } from "node:crypto";
|
||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
import { eq, laneDevices, setupState, type Db } from "@parking/db";
|
import { eq, devices, setupState, type Db } from "@parking/db";
|
||||||
import {
|
import {
|
||||||
hasPreconditions,
|
hasPreconditions,
|
||||||
hasPushConfig,
|
hasPushConfig,
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
registry,
|
registry,
|
||||||
setDeviceLogSink,
|
setDeviceLogSink,
|
||||||
type DeviceCategory,
|
type DeviceCategory,
|
||||||
|
type DeviceConfig,
|
||||||
} from "@parking/devices";
|
} from "@parking/devices";
|
||||||
import { requireRole } from "../auth.js";
|
import { requireRole } from "../auth.js";
|
||||||
import { backendIpCandidates, backendIpForDevice, backendPort } from "../net.js";
|
import { backendIpCandidates, backendIpForDevice, backendPort } from "../net.js";
|
||||||
@@ -18,10 +19,12 @@ import { backendIpCandidates, backendIpForDevice, backendPort } from "../net.js"
|
|||||||
// per lane. See wiki/concepts/first-run-setup.md.
|
// per lane. See wiki/concepts/first-run-setup.md.
|
||||||
|
|
||||||
interface AssignBody {
|
interface AssignBody {
|
||||||
lane: number;
|
|
||||||
category: DeviceCategory;
|
category: DeviceCategory;
|
||||||
driverId: string;
|
driverId: string;
|
||||||
config: Record<string, string | number | boolean>;
|
// Driver config (opaque JSON, validated by the driver). Carries the model's
|
||||||
|
// direction/binding: access → config.relays=[{relay,direction,button?}];
|
||||||
|
// reader/camera → config.controllerId + config.relay. See entry-exit-points.md.
|
||||||
|
config: DeviceConfig;
|
||||||
/** Optional: the backend IP the device should push to (overrides auto-pick;
|
/** Optional: the backend IP the device should push to (overrides auto-pick;
|
||||||
* matters on multi-NIC hosts). */
|
* matters on multi-NIC hosts). */
|
||||||
backendIp?: string;
|
backendIp?: string;
|
||||||
@@ -48,13 +51,7 @@ function redactSecrets(config: Record<string, unknown>): Record<string, unknown>
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function setupRoutes(
|
export async function setupRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||||
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();
|
registerBuiltinDrivers();
|
||||||
setDeviceLogSink((line) => app.log.info(line));
|
setDeviceLogSink((line) => app.log.info(line));
|
||||||
|
|
||||||
@@ -110,7 +107,7 @@ export async function setupRoutes(
|
|||||||
{ preHandler: adminGuard },
|
{ preHandler: adminGuard },
|
||||||
async () => {
|
async () => {
|
||||||
const state = await db.select().from(setupState).where(eq(setupState.id, 1)).get();
|
const state = await db.select().from(setupState).where(eq(setupState.id, 1)).get();
|
||||||
const rows = await db.select().from(laneDevices).all();
|
const rows = await db.select().from(devices).all();
|
||||||
const assignments = rows.map((r) => ({ ...r, config: redactSecrets(r.config) }));
|
const assignments = rows.map((r) => ({ ...r, config: redactSecrets(r.config) }));
|
||||||
return { completedAt: state?.completedAt ?? null, assignments };
|
return { completedAt: state?.completedAt ?? null, assignments };
|
||||||
},
|
},
|
||||||
@@ -154,15 +151,15 @@ export async function setupRoutes(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Assign a device to a lane. Validates the chosen driver + config, configures
|
// Assign a device. Validates the chosen driver + config, configures the device
|
||||||
// the device (fix preconditions + set up Digest-authenticated input push — no
|
// (fix preconditions + set up Digest-authenticated input push — no manual device-
|
||||||
// manual device-web-UI step by the admin), then persists. Fails the save if
|
// web-UI step by the admin), then persists. Fails the save if the device can't be
|
||||||
// the device can't be configured. See wiki/concepts/device-input-flow.md.
|
// configured. See wiki/concepts/device-input-flow.md, entry-exit-points.md.
|
||||||
app.post<{ Body: AssignBody }>(
|
app.post<{ Body: AssignBody }>(
|
||||||
"/api/setup/assign",
|
"/api/setup/assign",
|
||||||
{ preHandler: adminGuard },
|
{ preHandler: adminGuard },
|
||||||
async (req, reply) => {
|
async (req, reply) => {
|
||||||
const { lane, category, driverId, config, backendIp } = req.body;
|
const { category, driverId, config, backendIp } = req.body;
|
||||||
const driver = registry.get(driverId);
|
const driver = registry.get(driverId);
|
||||||
if (!driver || driver.category !== category) {
|
if (!driver || driver.category !== category) {
|
||||||
return reply.code(400).send({ error: `invalid driver for ${category}: ${driverId}` });
|
return reply.code(400).send({ error: `invalid driver for ${category}: ${driverId}` });
|
||||||
@@ -252,14 +249,12 @@ export async function setupRoutes(
|
|||||||
|
|
||||||
const row = {
|
const row = {
|
||||||
id,
|
id,
|
||||||
lane,
|
|
||||||
category,
|
category,
|
||||||
driverId,
|
driverId,
|
||||||
config: fullConfig,
|
config: fullConfig,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
};
|
};
|
||||||
await db.insert(laneDevices).values(row);
|
await db.insert(devices).values(row);
|
||||||
onAssignmentsChanged(); // refresh derived state (device->lane map)
|
|
||||||
// Don't echo device secrets back (push Digest password, web-UI login, …).
|
// Don't echo device secrets back (push Digest password, web-UI login, …).
|
||||||
return reply.code(201).send({
|
return reply.code(201).send({
|
||||||
...row,
|
...row,
|
||||||
@@ -285,13 +280,12 @@ export async function setupRoutes(
|
|||||||
async (req, reply) => {
|
async (req, reply) => {
|
||||||
const existing = await db
|
const existing = await db
|
||||||
.select()
|
.select()
|
||||||
.from(laneDevices)
|
.from(devices)
|
||||||
.where(eq(laneDevices.id, req.params.id))
|
.where(eq(devices.id, req.params.id))
|
||||||
.get();
|
.get();
|
||||||
if (!existing) return reply.code(404).send({ error: "no such device assignment" });
|
if (!existing) return reply.code(404).send({ error: "no such device assignment" });
|
||||||
await db.delete(laneDevices).where(eq(laneDevices.id, req.params.id));
|
await db.delete(devices).where(eq(devices.id, req.params.id));
|
||||||
onAssignmentsChanged(); // refresh derived state (device->lane map)
|
app.log.info(`unassigned device ${req.params.id} (${existing.category}/${existing.driverId})`);
|
||||||
app.log.info(`unassigned device ${req.params.id} (${existing.category}/${existing.driverId}, lane ${existing.lane})`);
|
|
||||||
return reply.code(204).send();
|
return reply.code(204).send();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import { requireRole } from "../auth.js";
|
||||||
|
import {
|
||||||
|
NoOpenShiftError,
|
||||||
|
ShiftAlreadyOpenError,
|
||||||
|
type ShiftService,
|
||||||
|
} from "../shift-service.js";
|
||||||
|
|
||||||
|
// Shift endpoints (manned mode). The operator is the logged-in user; a shift is
|
||||||
|
// opened/closed explicitly (not time-based — see wiki/concepts/shift.md and
|
||||||
|
// local-jwt-auth.md "until logout"). End Shift signs a shift_z_report + prints it.
|
||||||
|
|
||||||
|
export async function shiftRoutes(app: FastifyInstance, shift: ShiftService): Promise<void> {
|
||||||
|
// Cashier/operator/admin run shifts; readonly can't.
|
||||||
|
const guard = requireRole("admin", "operator", "cashier");
|
||||||
|
|
||||||
|
// Is the current operator's shift open? (For the UI to show Start vs. End.)
|
||||||
|
app.get("/api/shift/current", { preHandler: guard }, async (req) => {
|
||||||
|
const operator = req.user.username;
|
||||||
|
const open = shift.openShiftFor(operator);
|
||||||
|
return { operator, open: open ? { startedAt: open.occurredAt } : null };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/shift/open", { preHandler: guard }, async (req, reply) => {
|
||||||
|
try {
|
||||||
|
return await shift.open(req.user.username);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ShiftAlreadyOpenError) return reply.code(409).send({ error: err.message });
|
||||||
|
return reply.code(500).send({ error: (err as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/shift/close", { preHandler: guard }, async (req, reply) => {
|
||||||
|
try {
|
||||||
|
return await shift.close(req.user.username);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof NoOpenShiftError) return reply.code(409).send({ error: err.message });
|
||||||
|
return reply.code(500).send({ error: (err as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import { eq, siteConfig, type Db } from "@parking/db";
|
||||||
|
import { requireRole } from "../auth.js";
|
||||||
|
import { getOccupancy } from "../occupancy.js";
|
||||||
|
|
||||||
|
// Site config (capacity) + live occupancy. Occupancy is a fold over the signed
|
||||||
|
// ledger; capacity is an admin-set knob. The FULL gate (refuse transient entry at
|
||||||
|
// capacity) lives in the entry flow. See wiki/concepts/capacity-occupancy.md.
|
||||||
|
|
||||||
|
interface SiteConfigBody {
|
||||||
|
/** Nominal capacity; null = no limit. */
|
||||||
|
capacity: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function siteRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||||
|
const readGuard = requireRole("admin", "operator", "cashier", "readonly");
|
||||||
|
const writeGuard = requireRole("admin");
|
||||||
|
|
||||||
|
// Live occupancy: cars inside, capacity, free, full. Any signed-in role.
|
||||||
|
app.get("/api/occupancy", { preHandler: readGuard }, async () => getOccupancy(db));
|
||||||
|
|
||||||
|
// Read site config (capacity).
|
||||||
|
app.get("/api/site-config", { preHandler: readGuard }, async () => {
|
||||||
|
const row = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||||
|
return { capacity: row?.capacity ?? null };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set capacity (admin). null or 0+ integer.
|
||||||
|
app.put<{ Body: SiteConfigBody }>("/api/site-config", { preHandler: writeGuard }, async (req, reply) => {
|
||||||
|
const { capacity } = req.body ?? ({} as SiteConfigBody);
|
||||||
|
if (capacity != null && (!Number.isInteger(capacity) || capacity < 0)) {
|
||||||
|
return reply.code(400).send({ error: "capacity must be a non-negative integer or null" });
|
||||||
|
}
|
||||||
|
const existing = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||||
|
const updatedAt = new Date().toISOString();
|
||||||
|
if (existing) {
|
||||||
|
db.update(siteConfig).set({ capacity: capacity ?? null, updatedAt }).where(eq(siteConfig.id, 1)).run();
|
||||||
|
} else {
|
||||||
|
db.insert(siteConfig).values({ id: 1, capacity: capacity ?? null, updatedAt }).run();
|
||||||
|
}
|
||||||
|
return { capacity: capacity ?? null };
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import { desc, eq, snapshots, type Db } from "@parking/db";
|
||||||
|
import { requireRole } from "../auth.js";
|
||||||
|
|
||||||
|
// Read access to captured entry/exit snapshots (the BLOB-in-DB image store, see
|
||||||
|
// packages/db schema + wiki/concepts/lane-direction.md). Snapshots are evidence
|
||||||
|
// tied to a signed vehicle_entry/exit by `identity`; the operator reviews them
|
||||||
|
// next to the event. Read-only — images are written only by the flows (snapshot.ts),
|
||||||
|
// never via the API.
|
||||||
|
|
||||||
|
export async function snapshotRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||||
|
const guard = requireRole("admin", "operator", "cashier", "readonly");
|
||||||
|
|
||||||
|
// Snapshot metadata for one session/credential identity (NOT the bytes), newest
|
||||||
|
// first — lets the UI show "entry/exit image" links beside an event.
|
||||||
|
app.get<{ Params: { identity: string } }>(
|
||||||
|
"/api/snapshots/by-identity/:identity",
|
||||||
|
{ preHandler: guard },
|
||||||
|
async (req) => {
|
||||||
|
const rows = db
|
||||||
|
.select({
|
||||||
|
id: snapshots.id,
|
||||||
|
direction: snapshots.direction,
|
||||||
|
deviceId: snapshots.deviceId,
|
||||||
|
identity: snapshots.identity,
|
||||||
|
contentType: snapshots.contentType,
|
||||||
|
capturedAt: snapshots.capturedAt,
|
||||||
|
})
|
||||||
|
.from(snapshots)
|
||||||
|
.where(eq(snapshots.identity, req.params.identity))
|
||||||
|
.orderBy(desc(snapshots.capturedAt))
|
||||||
|
.all();
|
||||||
|
return { snapshots: rows };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Stream one snapshot's image bytes by id. Returns the stored content type.
|
||||||
|
app.get<{ Params: { id: string } }>(
|
||||||
|
"/api/snapshots/:id",
|
||||||
|
{ preHandler: guard },
|
||||||
|
async (req, reply) => {
|
||||||
|
const row = db.select().from(snapshots).where(eq(snapshots.id, req.params.id)).get();
|
||||||
|
if (!row) return reply.code(404).send({ error: "no such snapshot" });
|
||||||
|
reply.header("content-type", row.contentType);
|
||||||
|
reply.header("cache-control", "private, max-age=31536000, immutable");
|
||||||
|
return reply.send(row.bytes);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import { desc, eq, tariffVersions, tariffs, type Db } from "@parking/db";
|
||||||
|
import { validateTariffStructure, type TariffStructure } from "@parking/shared";
|
||||||
|
import { requireRole } from "../auth.js";
|
||||||
|
|
||||||
|
// Tariff composer API — the admin builds + edits the rate card at runtime. Tariffs
|
||||||
|
// are EFFECTIVE-DATED IMMUTABLE VERSIONS: editing publishes a new version, never
|
||||||
|
// mutates one; a session reprices against the version in force at its entry, and
|
||||||
|
// the `payment` event records the tariffVersionId. "One active tariff per site" for
|
||||||
|
// now (a single `tariffs` row, lazily created). See wiki/concepts/tariff.md.
|
||||||
|
|
||||||
|
interface PublishBody {
|
||||||
|
currency: string;
|
||||||
|
structure: TariffStructure;
|
||||||
|
/** When this version takes effect (ISO-8601). Defaults to now. */
|
||||||
|
effectiveFrom?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SITE_TARIFF_NAME = "Site tariff";
|
||||||
|
|
||||||
|
export async function tariffRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||||
|
// Any signed-in role may READ the tariff (the pay station / operator UI needs it).
|
||||||
|
const readGuard = requireRole("admin", "operator", "cashier", "readonly");
|
||||||
|
// Only an admin may PUBLISH a new version (it changes what customers are charged).
|
||||||
|
const writeGuard = requireRole("admin");
|
||||||
|
|
||||||
|
// The single site tariff row, created on first read/publish.
|
||||||
|
function ensureSiteTariff(): string {
|
||||||
|
const existing = db.select().from(tariffs).where(eq(tariffs.scope, "site")).get();
|
||||||
|
if (existing) return existing.id;
|
||||||
|
const id = randomUUID();
|
||||||
|
db.insert(tariffs).values({ id, scope: "site", name: SITE_TARIFF_NAME }).run();
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Current state: the active (latest-effective, ≤ now) version + the full history.
|
||||||
|
app.get("/api/tariff", { preHandler: readGuard }, async () => {
|
||||||
|
const tariffId = ensureSiteTariff();
|
||||||
|
const versions = db
|
||||||
|
.select()
|
||||||
|
.from(tariffVersions)
|
||||||
|
.where(eq(tariffVersions.tariffId, tariffId))
|
||||||
|
.orderBy(desc(tariffVersions.effectiveFrom))
|
||||||
|
.all();
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const active = versions.find((v) => v.effectiveFrom <= now) ?? null;
|
||||||
|
return { tariffId, active, versions };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Publish a new immutable version. Validates the structure first — a malformed
|
||||||
|
// rate card can never be published (the fee calc + the chain depend on it).
|
||||||
|
app.post<{ Body: PublishBody }>(
|
||||||
|
"/api/tariff/versions",
|
||||||
|
{ preHandler: writeGuard },
|
||||||
|
async (req, reply) => {
|
||||||
|
const { currency, structure, effectiveFrom } = req.body ?? ({} as PublishBody);
|
||||||
|
if (!currency || typeof currency !== "string" || currency.length < 3) {
|
||||||
|
return reply.code(400).send({ error: "currency (ISO 4217) required" });
|
||||||
|
}
|
||||||
|
const problems = validateTariffStructure(structure);
|
||||||
|
if (problems.length) {
|
||||||
|
return reply.code(400).send({ error: "invalid tariff structure", problems });
|
||||||
|
}
|
||||||
|
const tariffId = ensureSiteTariff();
|
||||||
|
const id = randomUUID();
|
||||||
|
const row = {
|
||||||
|
id,
|
||||||
|
tariffId,
|
||||||
|
effectiveFrom: effectiveFrom ?? new Date().toISOString(),
|
||||||
|
currency,
|
||||||
|
structure: structure as unknown as Record<string, unknown>,
|
||||||
|
createdBy: req.user?.username ?? null,
|
||||||
|
};
|
||||||
|
db.insert(tariffVersions).values(row).run();
|
||||||
|
return reply.code(201).send(row);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
+63
-22
@@ -7,12 +7,23 @@ import { TOKEN_COOKIE, requireJwtSecret } from "./auth.js";
|
|||||||
import { deviceEvents } from "./device-events.js";
|
import { deviceEvents } from "./device-events.js";
|
||||||
import { EntryFlow } from "./entry-flow.js";
|
import { EntryFlow } from "./entry-flow.js";
|
||||||
import { EventLog } from "./event-log.js";
|
import { EventLog } from "./event-log.js";
|
||||||
import { LaneMap } from "./lane-map.js";
|
import { ExitFlow } from "./exit-flow.js";
|
||||||
|
import { PayStation } from "./pay-station.js";
|
||||||
|
import { PermitFlow } from "./permit-flow.js";
|
||||||
|
import { ShiftService } from "./shift-service.js";
|
||||||
|
import { ReadDispatcher } from "./read-dispatch.js";
|
||||||
import { PrinterMonitor } from "./printer-monitor.js";
|
import { PrinterMonitor } from "./printer-monitor.js";
|
||||||
import { buildSigner } from "./signer.js";
|
import { buildSigner } from "./signer.js";
|
||||||
import { authRoutes } from "./routes/auth.js";
|
import { authRoutes } from "./routes/auth.js";
|
||||||
import { deviceRoutes } from "./routes/devices.js";
|
import { deviceRoutes } from "./routes/devices.js";
|
||||||
import { eventRoutes } from "./routes/events.js";
|
import { eventRoutes } from "./routes/events.js";
|
||||||
|
import { payRoutes } from "./routes/pay.js";
|
||||||
|
import { permitRoutes } from "./routes/permits.js";
|
||||||
|
import { qrReaderRoutes } from "./routes/qr-reader.js";
|
||||||
|
import { shiftRoutes } from "./routes/shift.js";
|
||||||
|
import { siteRoutes } from "./routes/site.js";
|
||||||
|
import { snapshotRoutes } from "./routes/snapshots.js";
|
||||||
|
import { tariffRoutes } from "./routes/tariffs.js";
|
||||||
import { printerRoutes } from "./routes/printers.js";
|
import { printerRoutes } from "./routes/printers.js";
|
||||||
import { setupRoutes } from "./routes/setup.js";
|
import { setupRoutes } from "./routes/setup.js";
|
||||||
|
|
||||||
@@ -40,7 +51,8 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
|||||||
// The token is carried in an HttpOnly cookie (not the Authorization header).
|
// The token is carried in an HttpOnly cookie (not the Authorization header).
|
||||||
await app.register(jwt, {
|
await app.register(jwt, {
|
||||||
secret: requireJwtSecret(),
|
secret: requireJwtSecret(),
|
||||||
sign: { expiresIn: "8h" }, // bound to a shift; minted tokens must expire
|
// No expiry: a login is valid until explicit logout — a shift is a separate
|
||||||
|
// boundary, not the token lifetime (see auth.ts + wiki/concepts/shift.md).
|
||||||
cookie: { cookieName: TOKEN_COOKIE, signed: false },
|
cookie: { cookieName: TOKEN_COOKIE, signed: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -49,15 +61,11 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
|||||||
// Local username/password login → JWT in an HttpOnly cookie + CSRF cookie.
|
// Local username/password login → JWT in an HttpOnly cookie + CSRF cookie.
|
||||||
await authRoutes(app, db);
|
await authRoutes(app, db);
|
||||||
|
|
||||||
// device id -> lane resolver. Built from lane_devices at startup and refreshed
|
// Device-agnostic setup: the admin adds controllers (with their relays + entry
|
||||||
// by setupRoutes on assign/unassign, so device events can be stamped with the
|
// button) and binds readers/cameras to a controller relay at first-run. There is
|
||||||
// lane the device belongs to (events carry the device id, not a lane).
|
// no lane — a parking lot is one pool with a flexible set of entry/exit points.
|
||||||
const laneMap = new LaneMap(db);
|
// See wiki/concepts/first-run-setup.md, entry-exit-points.md.
|
||||||
laneMap.refresh();
|
await setupRoutes(app, db);
|
||||||
|
|
||||||
// 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, () => laneMap.refresh());
|
|
||||||
|
|
||||||
// Inbound device pushes (e.g. Dingtian Input Link URL → button events),
|
// Inbound device pushes (e.g. Dingtian Input Link URL → button events),
|
||||||
// guarded by source-IP allowlist + a shared-secret path token, both read from
|
// guarded by source-IP allowlist + a shared-secret path token, both read from
|
||||||
@@ -81,30 +89,65 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
|||||||
const eventLog = new EventLog(db, buildSigner(app.log));
|
const eventLog = new EventLog(db, buildSigner(app.log));
|
||||||
await eventRoutes(app, db, eventLog);
|
await eventRoutes(app, db, eventLog);
|
||||||
|
|
||||||
|
// Entry/exit camera snapshots (BLOB-in-DB), read-only. See snapshot.ts.
|
||||||
|
await snapshotRoutes(app, db);
|
||||||
|
|
||||||
// Entry flow: a button press → print ticket → signed vehicle_entry → pulseOpen.
|
// Entry flow: a button press → print ticket → signed vehicle_entry → pulseOpen.
|
||||||
// Subscribes to the SAME input bus as the telemetry writer below; the two are
|
// Subscribes to the SAME input bus as the telemetry writer below; the two are
|
||||||
// independent (telemetry always records; the entry flow acts only on an access
|
// independent (telemetry always records; the entry flow acts only on an access
|
||||||
// device's rising edge). See wiki/concepts/device-input-flow.md + parking-session.md.
|
// device's rising edge). See wiki/concepts/device-input-flow.md + parking-session.md.
|
||||||
const entryFlow = new EntryFlow(db, eventLog, laneMap, app.log);
|
const entryFlow = new EntryFlow(db, eventLog, app.log);
|
||||||
const unsubscribeEntry = deviceEvents.onInput((e) => {
|
const unsubscribeEntry = deviceEvents.onInput((e) => {
|
||||||
void entryFlow.onInput(e);
|
void entryFlow.onInput(e);
|
||||||
});
|
});
|
||||||
app.addHook("onClose", async () => unsubscribeEntry());
|
app.addHook("onClose", async () => unsubscribeEntry());
|
||||||
|
|
||||||
|
// Read-driven flows: a credential read (ticket scan / plate / card) routes via the
|
||||||
|
// dispatcher to either the PERMIT flow (if it matches a permit) or the transient
|
||||||
|
// EXIT flow. See read-dispatch.ts, exit-flow.ts, permit-flow.ts, parking-session.md.
|
||||||
|
const exitFlow = new ExitFlow(db, eventLog, app.log);
|
||||||
|
const permitFlow = new PermitFlow(db, eventLog, app.log);
|
||||||
|
const readDispatcher = new ReadDispatcher(db, exitFlow, permitFlow, app.log);
|
||||||
|
const unsubscribeRead = deviceEvents.onRead((e) => {
|
||||||
|
void readDispatcher.dispatch(e);
|
||||||
|
});
|
||||||
|
app.addHook("onClose", async () => unsubscribeRead());
|
||||||
|
|
||||||
|
// GEE/Dingtian QR reader: it HTTP-GETs on each scan and beeps/acts on our JSON
|
||||||
|
// verdict (host-in-the-loop, synchronous). Routes the read through the dispatcher
|
||||||
|
// and replies the SDK verdict. See wiki/entities/gee-qr-er80.md, qrcode-sdk.md.
|
||||||
|
await qrReaderRoutes(app, db, readDispatcher);
|
||||||
|
|
||||||
|
// Pay station (pay-on-foot): quote an open session against the active tariff +
|
||||||
|
// take payment → signed `payment` event. See wiki/concepts/tariff.md.
|
||||||
|
const payStation = new PayStation(db, eventLog, app.log);
|
||||||
|
await payRoutes(app, payStation);
|
||||||
|
|
||||||
|
// Tariff composer: admin publishes effective-dated, immutable rate-card versions
|
||||||
|
// the pay station prices against. See wiki/concepts/tariff.md.
|
||||||
|
await tariffRoutes(app, db);
|
||||||
|
|
||||||
|
// Permit (subscription) admin CRUD. See wiki/entities/permit.md.
|
||||||
|
await permitRoutes(app, db);
|
||||||
|
|
||||||
|
// Shifts (manned mode): explicit open/close → signed shift_open / shift_z_report
|
||||||
|
// (sum payments by tender, print the Z-report). See wiki/concepts/shift.md.
|
||||||
|
const shiftService = new ShiftService(db, eventLog, app.log);
|
||||||
|
await shiftRoutes(app, shiftService);
|
||||||
|
|
||||||
|
// Site config (capacity) + live occupancy. The FULL gate (refuse transient entry
|
||||||
|
// at capacity) is in the entry flow. See wiki/concepts/capacity-occupancy.md.
|
||||||
|
await siteRoutes(app, db);
|
||||||
|
|
||||||
const unsubscribeInput = deviceEvents.onInput((e) => {
|
const unsubscribeInput = deviceEvents.onInput((e) => {
|
||||||
// Resolve which lane the device belongs to. -1 marks "device fired but isn't
|
// Record every input edge as unsigned telemetry, keyed to the device that fired
|
||||||
// mapped to a lane" (assigned without a lane, or a stale id) — still recorded
|
// (provenance). No lane — the pool-of-spaces model has none. The entry flow
|
||||||
// faithfully rather than silently dropped or mis-stamped as lane 0 (a real lane).
|
// (above) independently decides whether this edge is an entry button.
|
||||||
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`);
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
db.insert(deviceEventsTable)
|
db.insert(deviceEventsTable)
|
||||||
.values({
|
.values({
|
||||||
id: randomUUID(),
|
id: randomUUID(),
|
||||||
deviceId: e.deviceId,
|
deviceId: e.deviceId,
|
||||||
lane,
|
|
||||||
category: "access",
|
category: "access",
|
||||||
kind: "input",
|
kind: "input",
|
||||||
detail: { driverId: e.driverId, input: e.input, edge: e.edge },
|
detail: { driverId: e.driverId, input: e.input, edge: e.edge },
|
||||||
@@ -117,7 +160,5 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
|||||||
});
|
});
|
||||||
app.addHook("onClose", async () => unsubscribeInput());
|
app.addHook("onClose", async () => unsubscribeInput());
|
||||||
|
|
||||||
// TODO: entry flow (device input → signed vehicle_entry → print → relay).
|
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
import { eq, devices, ledgerEvents, type Db } from "@parking/db";
|
||||||
|
import { registry, type PrinterDevice } from "@parking/devices";
|
||||||
|
import type { LedgerPayload } from "@parking/shared";
|
||||||
|
import type { FastifyBaseLogger } from "fastify";
|
||||||
|
import type { EventLog } from "./event-log.js";
|
||||||
|
|
||||||
|
// Shift service (manned mode only). A shift is an operator's accountability period,
|
||||||
|
// delimited by EXPLICIT marks — not a clock. Represented entirely as signed ledger
|
||||||
|
// events (no mutable table): `shift_open` … `shift_z_report`. At close, sum the
|
||||||
|
// `payment` events taken during the shift by tender and print a Z-report.
|
||||||
|
// See wiki/concepts/shift.md.
|
||||||
|
|
||||||
|
export class ShiftAlreadyOpenError extends Error {
|
||||||
|
constructor(operator: string) {
|
||||||
|
super(`operator ${operator} already has an open shift`);
|
||||||
|
this.name = "ShiftAlreadyOpenError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class NoOpenShiftError extends Error {
|
||||||
|
constructor(operator: string) {
|
||||||
|
super(`operator ${operator} has no open shift`);
|
||||||
|
this.name = "NoOpenShiftError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShiftReport {
|
||||||
|
readonly operator: string;
|
||||||
|
readonly startedAt: string;
|
||||||
|
readonly endedAt: string;
|
||||||
|
readonly cashTotalMinor: number;
|
||||||
|
readonly cardTotalMinor: number;
|
||||||
|
readonly currency: string | null;
|
||||||
|
readonly paymentCount: number;
|
||||||
|
readonly printed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ShiftService {
|
||||||
|
readonly #db: Db;
|
||||||
|
readonly #log: EventLog;
|
||||||
|
readonly #logger: FastifyBaseLogger;
|
||||||
|
|
||||||
|
constructor(db: Db, log: EventLog, logger: FastifyBaseLogger) {
|
||||||
|
this.#db = db;
|
||||||
|
this.#log = log;
|
||||||
|
this.#logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Is there an open shift for this operator? Returns the open `shift_open` row or null. */
|
||||||
|
openShiftFor(operator: string) {
|
||||||
|
// Scan shift events for this operator; the shift is open if the most recent
|
||||||
|
// shift event for them is a `shift_open` (not yet closed by a z_report).
|
||||||
|
const rows = this.#db
|
||||||
|
.select()
|
||||||
|
.from(ledgerEvents)
|
||||||
|
.where(eq(ledgerEvents.identity, operator))
|
||||||
|
.orderBy(ledgerEvents.index)
|
||||||
|
.all()
|
||||||
|
.filter((r) => r.type === "shift_open" || r.type === "shift_z_report");
|
||||||
|
const last = rows[rows.length - 1];
|
||||||
|
return last && last.type === "shift_open" ? last : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Open a shift for the operator (explicit start). */
|
||||||
|
async open(operator: string): Promise<{ startedAt: string }> {
|
||||||
|
if (this.openShiftFor(operator)) throw new ShiftAlreadyOpenError(operator);
|
||||||
|
const startedAt = new Date().toISOString();
|
||||||
|
await this.#log.append({
|
||||||
|
type: "shift_open",
|
||||||
|
source: "manual",
|
||||||
|
identity: operator, // the shift's operator; `identity` keys the shift to them
|
||||||
|
payload: { operator },
|
||||||
|
occurredAt: startedAt,
|
||||||
|
});
|
||||||
|
this.#logger.info(`shift opened for ${operator}`);
|
||||||
|
return { startedAt };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Close the operator's open shift: sum payments in the window, sign + print the Z-report. */
|
||||||
|
async close(operator: string): Promise<ShiftReport> {
|
||||||
|
const open = this.openShiftFor(operator);
|
||||||
|
if (!open) throw new NoOpenShiftError(operator);
|
||||||
|
const startedAt = open.occurredAt;
|
||||||
|
const endedAt = new Date().toISOString();
|
||||||
|
|
||||||
|
// All payments taken in [startedAt, endedAt], summed by tender. Payment time =
|
||||||
|
// the operator who handled the money (decision: sum by payment time).
|
||||||
|
const payments = this.#db
|
||||||
|
.select()
|
||||||
|
.from(ledgerEvents)
|
||||||
|
.where(eq(ledgerEvents.type, "payment"))
|
||||||
|
.all()
|
||||||
|
.filter((r) => r.occurredAt >= startedAt && r.occurredAt <= endedAt);
|
||||||
|
|
||||||
|
let cashTotalMinor = 0;
|
||||||
|
let cardTotalMinor = 0;
|
||||||
|
let currency: string | null = null;
|
||||||
|
for (const p of payments) {
|
||||||
|
const pl = (p.payload ?? {}) as LedgerPayload;
|
||||||
|
const amt = typeof pl.amountMinor === "number" ? pl.amountMinor : 0;
|
||||||
|
if (pl.tender === "card") cardTotalMinor += amt;
|
||||||
|
else cashTotalMinor += amt;
|
||||||
|
if (pl.currency) currency = pl.currency;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.#log.append({
|
||||||
|
type: "shift_z_report",
|
||||||
|
source: "manual",
|
||||||
|
identity: operator,
|
||||||
|
payload: {
|
||||||
|
operator,
|
||||||
|
startedAt,
|
||||||
|
endedAt,
|
||||||
|
cashTotalMinor,
|
||||||
|
cardTotalMinor,
|
||||||
|
currency: currency ?? undefined,
|
||||||
|
paymentCount: payments.length,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const printed = await this.#printZReport({
|
||||||
|
operator,
|
||||||
|
startedAt,
|
||||||
|
endedAt,
|
||||||
|
cashTotalMinor,
|
||||||
|
cardTotalMinor,
|
||||||
|
currency,
|
||||||
|
paymentCount: payments.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.#logger.info(
|
||||||
|
`shift closed for ${operator}: cash ${cashTotalMinor} card ${cardTotalMinor} (${payments.length} payments)`,
|
||||||
|
);
|
||||||
|
return { operator, startedAt, endedAt, cashTotalMinor, cardTotalMinor, currency, paymentCount: payments.length, printed };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Print the Z-report on a booth-receipt printer (best-effort; the signed event
|
||||||
|
* is the record — a failed print doesn't undo the close). */
|
||||||
|
async #printZReport(r: Omit<ShiftReport, "printed">): Promise<boolean> {
|
||||||
|
const printer = await this.#boothPrinter();
|
||||||
|
if (!printer) {
|
||||||
|
this.#logger.warn(`no booth-receipt printer — Z-report for ${r.operator} not printed (event is recorded)`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const cur = r.currency ?? "";
|
||||||
|
const money = (m: number) => (m / 100).toFixed(2);
|
||||||
|
const lines = [
|
||||||
|
`Operator: ${r.operator}`,
|
||||||
|
`From: ${r.startedAt}`,
|
||||||
|
`To: ${r.endedAt}`,
|
||||||
|
"",
|
||||||
|
`Payments: ${r.paymentCount}`,
|
||||||
|
`Cash: ${money(r.cashTotalMinor)} ${cur}`,
|
||||||
|
`Card: ${money(r.cardTotalMinor)} ${cur}`,
|
||||||
|
];
|
||||||
|
try {
|
||||||
|
await printer.printReport({ title: "SHIFT Z-REPORT", lines });
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
this.#logger.warn(`Z-report print failed for ${r.operator}: ${(err as Error).message} (event recorded)`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** First enabled booth-receipt printer, or any enabled printer. */
|
||||||
|
async #boothPrinter(): Promise<PrinterDevice | null> {
|
||||||
|
const rows = await this.#db.select().from(devices).where(eq(devices.category, "printer")).all();
|
||||||
|
const enabled = rows.filter((r) => r.enabled);
|
||||||
|
const booth = enabled.find((r) => (r.config as { role?: string }).role === "booth-receipt") ?? enabled[0];
|
||||||
|
if (!booth) return null;
|
||||||
|
const driver = registry.get(booth.driverId);
|
||||||
|
if (!driver) return null;
|
||||||
|
try {
|
||||||
|
return driver.create(booth.config as never) as PrinterDevice;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,7 +14,10 @@ export class SoftwareSigner implements Signer {
|
|||||||
readonly keyId: string;
|
readonly keyId: string;
|
||||||
readonly #key: Buffer;
|
readonly #key: Buffer;
|
||||||
|
|
||||||
constructor(secret: string, keyId = "sw-hmac-v1") {
|
// v2 canonical form: `lane` dropped from the signed array (pool-of-spaces model,
|
||||||
|
// 2026-06-16). v1 events used a different field order and won't verify under v2 —
|
||||||
|
// that's intentional and gated by the per-event keyId. See event-log canonicalize().
|
||||||
|
constructor(secret: string, keyId = "sw-hmac-v2") {
|
||||||
this.#key = Buffer.from(secret, "utf8");
|
this.#key = Buffer.from(secret, "utf8");
|
||||||
this.keyId = keyId;
|
this.keyId = keyId;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { deviceEvents as deviceEventsTable, snapshots, type Db } from "@parking/db";
|
||||||
|
import { registry, type CameraDevice } from "@parking/devices";
|
||||||
|
import type { FastifyBaseLogger } from "fastify";
|
||||||
|
import { devicesByDirection, type FlowDirection } from "./device-resolve.js";
|
||||||
|
|
||||||
|
// Camera snapshot capture, fired AFTER the barrier opens and never awaited on the
|
||||||
|
// open path (decision 2026-06-16): a snapshot is EVIDENCE, not a gate. A camera
|
||||||
|
// failure must never delay or prevent an open — the signed ledger is the decision,
|
||||||
|
// the image is an independent, prunable record stored as a BLOB in `snapshots`.
|
||||||
|
// See wiki/concepts/entry-exit-points.md and append-only-event-chain.md.
|
||||||
|
//
|
||||||
|
// Every camera serving the firing direction (entry/exit, or both) snapshots. Each
|
||||||
|
// capture is independent — one camera down doesn't stop the others. A captured image
|
||||||
|
// → a `snapshots` row + a `kind:"snapshot"` telemetry device_event; a failure → a
|
||||||
|
// telemetry device_event only. The caller passes the session `identity` so the image
|
||||||
|
// links to the signed vehicle_entry/exit.
|
||||||
|
|
||||||
|
interface SnapshotJob {
|
||||||
|
readonly db: Db;
|
||||||
|
readonly direction: FlowDirection;
|
||||||
|
/** Session/credential ref (ticket id, plate, permit car key) — links to the ledger. */
|
||||||
|
readonly identity: string;
|
||||||
|
readonly logger: FastifyBaseLogger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fire snapshots for the directional camera set. Returns immediately with a promise
|
||||||
|
* the caller MAY ignore (fire-and-forget) — it resolves to the captured snapshot ids.
|
||||||
|
* The caller must NOT block its open path on this.
|
||||||
|
*/
|
||||||
|
export function snapshotAsync(job: SnapshotJob): Promise<string[]> {
|
||||||
|
const { db, direction, identity, logger } = job;
|
||||||
|
const rows = devicesByDirection(db, "camera", direction);
|
||||||
|
if (rows.length === 0) return Promise.resolve([]);
|
||||||
|
|
||||||
|
return Promise.all(
|
||||||
|
rows.map(async (row): Promise<string | null> => {
|
||||||
|
const camera = buildCamera(row);
|
||||||
|
if (!camera) {
|
||||||
|
recordFailure(db, direction, row.id, identity, "camera config won't build", logger);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const shot = await camera.captureSnapshot({ direction });
|
||||||
|
const id: string = randomUUID();
|
||||||
|
db.insert(snapshots)
|
||||||
|
.values({
|
||||||
|
id,
|
||||||
|
direction,
|
||||||
|
deviceId: row.id,
|
||||||
|
identity,
|
||||||
|
contentType: shot.contentType,
|
||||||
|
bytes: shot.bytes,
|
||||||
|
capturedAt: shot.capturedAt,
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
// Telemetry breadcrumb pointing at the stored image (NOT the bytes).
|
||||||
|
recordEvent(db, direction, row.id, identity, { snapshotId: id, ok: true }, logger);
|
||||||
|
return id;
|
||||||
|
} catch (err) {
|
||||||
|
recordFailure(db, direction, row.id, identity, (err as Error).message, logger);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
).then((ids) => ids.filter((id): id is string => id != null));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build a live camera adapter from a resolved devices row, or null. */
|
||||||
|
function buildCamera(row: { driverId: string; config: unknown }): CameraDevice | null {
|
||||||
|
const driver = registry.get(row.driverId);
|
||||||
|
if (!driver) return null;
|
||||||
|
try {
|
||||||
|
return driver.create(row.config as never) as CameraDevice;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordFailure(
|
||||||
|
db: Db,
|
||||||
|
direction: FlowDirection,
|
||||||
|
deviceId: string,
|
||||||
|
identity: string,
|
||||||
|
error: string,
|
||||||
|
logger: FastifyBaseLogger,
|
||||||
|
): void {
|
||||||
|
logger.warn(`snapshot failed (${direction}, ${identity}): ${error}`);
|
||||||
|
recordEvent(db, direction, deviceId, identity, { ok: false, error }, logger);
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordEvent(
|
||||||
|
db: Db,
|
||||||
|
direction: FlowDirection,
|
||||||
|
deviceId: string,
|
||||||
|
identity: string,
|
||||||
|
detail: Record<string, unknown>,
|
||||||
|
logger: FastifyBaseLogger,
|
||||||
|
): void {
|
||||||
|
try {
|
||||||
|
db.insert(deviceEventsTable)
|
||||||
|
.values({
|
||||||
|
id: randomUUID(),
|
||||||
|
deviceId,
|
||||||
|
category: "camera",
|
||||||
|
kind: "snapshot",
|
||||||
|
detail: { ...detail, direction, identity },
|
||||||
|
occurredAt: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
} catch (err) {
|
||||||
|
// Telemetry is best-effort; never let it surface on the (already-open) path.
|
||||||
|
logger.error(`snapshot device-event insert failed: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { fetchMe, logout, type SessionUser } from "./api.js";
|
import { fetchMe, logout, type SessionUser } from "./api.js";
|
||||||
import { Login } from "./Login.js";
|
import { Login } from "./Login.js";
|
||||||
|
import { PermitManager } from "./PermitManager.js";
|
||||||
import { SetupWizard } from "./SetupWizard.js";
|
import { SetupWizard } from "./SetupWizard.js";
|
||||||
|
import { ShiftControl } from "./ShiftControl.js";
|
||||||
|
import { SiteSettings } from "./SiteSettings.js";
|
||||||
|
import { TariffComposer } from "./TariffComposer.js";
|
||||||
|
|
||||||
// Operator UI shell. Plain React (no admin framework) — the operator UI is
|
// Operator UI shell. Plain React (no admin framework) — the operator UI is
|
||||||
// simple enough that a framework's abstractions cost more than they save.
|
// simple enough that a framework's abstractions cost more than they save.
|
||||||
@@ -38,8 +42,14 @@ export function App() {
|
|||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
</header>
|
</header>
|
||||||
|
<SiteSettings canEdit={user.role === "admin"} />
|
||||||
|
{user.role !== "readonly" && <ShiftControl />}
|
||||||
{user.role === "admin" ? (
|
{user.role === "admin" ? (
|
||||||
|
<>
|
||||||
<SetupWizard />
|
<SetupWizard />
|
||||||
|
<TariffComposer />
|
||||||
|
<PermitManager />
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<p style={{ marginTop: "1rem" }}>Signed in. (Operator console coming soon.)</p>
|
<p style={{ marginTop: "1rem" }}>Signed in. (Operator console coming soon.)</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,183 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
ApiError,
|
||||||
|
createPermit,
|
||||||
|
deletePermit,
|
||||||
|
fetchPermits,
|
||||||
|
revokePermit,
|
||||||
|
updatePermit,
|
||||||
|
type Permit,
|
||||||
|
type PermitCredential,
|
||||||
|
type PermitInput,
|
||||||
|
} from "./api.js";
|
||||||
|
|
||||||
|
// Permit (subscription) admin. Create/edit/revoke/delete permits + their
|
||||||
|
// credentials (card/QR) and bound plates. A permit is mutable master data; every
|
||||||
|
// USE of it is a signed ledger event elsewhere. See wiki/entities/permit.md.
|
||||||
|
|
||||||
|
interface FormState {
|
||||||
|
holderName: string;
|
||||||
|
contact: string;
|
||||||
|
carBound: boolean; // false = unbound (maxConcurrent null)
|
||||||
|
maxConcurrent: string;
|
||||||
|
validFrom: string;
|
||||||
|
validTo: string;
|
||||||
|
credentials: PermitCredential[];
|
||||||
|
platesText: string; // comma/space separated
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyForm(): FormState {
|
||||||
|
return { holderName: "", contact: "", carBound: true, maxConcurrent: "1", validFrom: "", validTo: "", credentials: [{ kind: "rf", value: "" }], platesText: "" };
|
||||||
|
}
|
||||||
|
function formFrom(p: Permit): FormState {
|
||||||
|
return {
|
||||||
|
holderName: p.holderName ?? "",
|
||||||
|
contact: p.contact ?? "",
|
||||||
|
carBound: p.maxConcurrent != null,
|
||||||
|
maxConcurrent: p.maxConcurrent != null ? String(p.maxConcurrent) : "1",
|
||||||
|
validFrom: p.validFrom ?? "",
|
||||||
|
validTo: p.validTo ?? "",
|
||||||
|
credentials: p.credentials.length ? p.credentials : [{ kind: "rf", value: "" }],
|
||||||
|
platesText: p.plates.join(", "),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function toInput(f: FormState): PermitInput {
|
||||||
|
return {
|
||||||
|
holderName: f.holderName.trim() || null,
|
||||||
|
contact: f.contact.trim() || null,
|
||||||
|
maxConcurrent: f.carBound ? Math.max(1, Math.round(Number(f.maxConcurrent) || 1)) : null,
|
||||||
|
validFrom: f.validFrom.trim() || null,
|
||||||
|
validTo: f.validTo.trim() || null,
|
||||||
|
credentials: f.credentials.filter((c) => c.value.trim()).map((c) => ({ kind: c.kind, value: c.value.trim() })),
|
||||||
|
plates: f.platesText.split(/[,\s]+/).map((s) => s.trim()).filter(Boolean),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PermitManager() {
|
||||||
|
const [permits, setPermits] = useState<Permit[] | null>(null);
|
||||||
|
const [editing, setEditing] = useState<string | "new" | null>(null);
|
||||||
|
const [form, setForm] = useState<FormState>(emptyForm);
|
||||||
|
const [msg, setMsg] = useState<{ kind: "ok" | "err"; text: string } | null>(null);
|
||||||
|
|
||||||
|
function reload() {
|
||||||
|
fetchPermits()
|
||||||
|
.then((r) => setPermits(r.permits))
|
||||||
|
.catch((e) => setMsg({ kind: "err", text: (e as Error).message }));
|
||||||
|
}
|
||||||
|
useEffect(reload, []);
|
||||||
|
|
||||||
|
function startNew() {
|
||||||
|
setForm(emptyForm());
|
||||||
|
setEditing("new");
|
||||||
|
setMsg(null);
|
||||||
|
}
|
||||||
|
function startEdit(p: Permit) {
|
||||||
|
setForm(formFrom(p));
|
||||||
|
setEditing(p.id);
|
||||||
|
setMsg(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
setMsg(null);
|
||||||
|
try {
|
||||||
|
if (editing === "new") await createPermit(toInput(form));
|
||||||
|
else if (editing) await updatePermit(editing, toInput(form));
|
||||||
|
setEditing(null);
|
||||||
|
reload();
|
||||||
|
setMsg({ kind: "ok", text: "Permit saved." });
|
||||||
|
} catch (e) {
|
||||||
|
const problems = e instanceof ApiError ? (e as ApiError & { problems?: string[] }).problems : undefined;
|
||||||
|
setMsg({ kind: "err", text: problems?.length ? `${(e as Error).message}: ${problems.join("; ")}` : (e as Error).message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function doRevoke(p: Permit) {
|
||||||
|
if (!confirm(`Revoke permit for ${p.holderName ?? p.id}? It will be refused at the barrier.`)) return;
|
||||||
|
await revokePermit(p.id).catch((e) => setMsg({ kind: "err", text: (e as Error).message }));
|
||||||
|
reload();
|
||||||
|
}
|
||||||
|
async function doDelete(p: Permit) {
|
||||||
|
if (!confirm(`Delete permit for ${p.holderName ?? p.id}? (Past events are kept.)`)) return;
|
||||||
|
await deletePermit(p.id).catch((e) => setMsg({ kind: "err", text: (e as Error).message }));
|
||||||
|
reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCred(i: number, patch: Partial<PermitCredential>) {
|
||||||
|
setForm((f) => ({ ...f, credentials: f.credentials.map((c, j) => (j === i ? { ...c, ...patch } : c)) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!permits) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section style={{ marginTop: "2rem" }}>
|
||||||
|
<h2>Permits</h2>
|
||||||
|
<ul style={{ listStyle: "none", padding: 0 }}>
|
||||||
|
{permits.map((p) => (
|
||||||
|
<li key={p.id} style={{ display: "flex", gap: "0.5rem", alignItems: "center", padding: "0.4rem 0", borderBottom: "1px solid #eee" }}>
|
||||||
|
<strong>{p.holderName ?? "(unnamed)"}</strong>
|
||||||
|
<span style={{ color: p.status === "active" ? "#16a34a" : "#b45309" }}>{p.status}</span>
|
||||||
|
<span style={{ color: "#666" }}>
|
||||||
|
{p.maxConcurrent == null ? "unbound" : `${p.maxConcurrent} car${p.maxConcurrent > 1 ? "s" : ""}`} ·{" "}
|
||||||
|
{p.credentials.length} cred · {p.plates.length} plate(s)
|
||||||
|
</span>
|
||||||
|
<span style={{ flex: 1 }} />
|
||||||
|
<button type="button" onClick={() => startEdit(p)}>Edit</button>
|
||||||
|
{p.status !== "revoked" && <button type="button" onClick={() => doRevoke(p)}>Revoke</button>}
|
||||||
|
<button type="button" onClick={() => doDelete(p)}>Delete</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{permits.length === 0 && <li style={{ color: "#777" }}>No permits yet.</li>}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{editing == null ? (
|
||||||
|
<button type="button" onClick={startNew}>+ Add permit</button>
|
||||||
|
) : (
|
||||||
|
<div style={{ border: "1px solid #ddd", padding: "1rem", marginTop: "0.5rem", maxWidth: 460 }}>
|
||||||
|
<h3 style={{ marginTop: 0 }}>{editing === "new" ? "New permit" : "Edit permit"}</h3>
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "max-content 1fr", gap: "0.4rem 0.75rem", alignItems: "center" }}>
|
||||||
|
<label>Holder name</label>
|
||||||
|
<input value={form.holderName} onChange={(e) => setForm((f) => ({ ...f, holderName: e.target.value }))} />
|
||||||
|
<label>Contact</label>
|
||||||
|
<input value={form.contact} onChange={(e) => setForm((f) => ({ ...f, contact: e.target.value }))} />
|
||||||
|
<label>Car limit</label>
|
||||||
|
<span>
|
||||||
|
<label style={{ marginRight: "0.5rem" }}>
|
||||||
|
<input type="checkbox" checked={form.carBound} onChange={(e) => setForm((f) => ({ ...f, carBound: e.target.checked }))} /> limit cars in at once
|
||||||
|
</label>
|
||||||
|
{form.carBound && (
|
||||||
|
<input value={form.maxConcurrent} onChange={(e) => setForm((f) => ({ ...f, maxConcurrent: e.target.value }))} style={{ width: 50 }} />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<label>Valid from</label>
|
||||||
|
<input value={form.validFrom} onChange={(e) => setForm((f) => ({ ...f, validFrom: e.target.value }))} placeholder="ISO date (optional)" />
|
||||||
|
<label>Valid to</label>
|
||||||
|
<input value={form.validTo} onChange={(e) => setForm((f) => ({ ...f, validTo: e.target.value }))} placeholder="ISO date (optional)" />
|
||||||
|
<label>Bound plates</label>
|
||||||
|
<input value={form.platesText} onChange={(e) => setForm((f) => ({ ...f, platesText: e.target.value }))} placeholder="comma-separated (optional)" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h4 style={{ marginBottom: "0.25rem" }}>Credentials (card / QR)</h4>
|
||||||
|
{form.credentials.map((c, i) => (
|
||||||
|
<div key={i} style={{ display: "flex", gap: "0.4rem", marginBottom: "0.3rem" }}>
|
||||||
|
<select value={c.kind} onChange={(e) => setCred(i, { kind: e.target.value as "rf" | "qr" })}>
|
||||||
|
<option value="rf">RF card/tag</option>
|
||||||
|
<option value="qr">QR</option>
|
||||||
|
</select>
|
||||||
|
<input value={c.value} onChange={(e) => setCred(i, { value: e.target.value })} placeholder="credential value" style={{ flex: 1 }} />
|
||||||
|
<button type="button" onClick={() => setForm((f) => ({ ...f, credentials: f.credentials.filter((_, j) => j !== i) }))}>×</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button type="button" onClick={() => setForm((f) => ({ ...f, credentials: [...f.credentials, { kind: "rf", value: "" }] }))}>+ credential</button>
|
||||||
|
<p style={{ color: "#777", fontSize: "0.85em", margin: "0.5rem 0 0" }}>
|
||||||
|
A permit needs at least one credential OR one bound plate.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style={{ marginTop: "1rem", display: "flex", gap: "0.5rem" }}>
|
||||||
|
<button type="button" onClick={save}>Save</button>
|
||||||
|
<button type="button" onClick={() => setEditing(null)}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{msg && <p style={{ color: msg.kind === "ok" ? "#16a34a" : "crimson" }}>{msg.text}</p>}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
+301
-68
@@ -12,28 +12,41 @@ import {
|
|||||||
type Catalog,
|
type Catalog,
|
||||||
type CatalogEntry,
|
type CatalogEntry,
|
||||||
type DeviceCategory,
|
type DeviceCategory,
|
||||||
|
type DeviceConfig,
|
||||||
|
type Direction,
|
||||||
type DiscoveredDevice,
|
type DiscoveredDevice,
|
||||||
|
type RelaySpec,
|
||||||
type TestResult,
|
type TestResult,
|
||||||
} from "./api.js";
|
} from "./api.js";
|
||||||
|
|
||||||
// First-run setup wizard (scaffold). The admin assigns devices per lane from the
|
// First-run setup wizard. The pool-of-spaces model: a parking lot is one pool with
|
||||||
// driver catalog. The data model is multi-instance — one lane_devices row per
|
// a flexible set of entry/exit points — NO lane. The admin adds CONTROLLERS (each
|
||||||
// instance — so EVERY category supports more than one device: each section lists
|
// declares its relays = entry/exit/both + which input terminal the entry button is
|
||||||
// the already-assigned instances (with Remove) and an "Add" form. Drivers that
|
// on), then binds READERS / CAMERAS to a controller relay (the barrier they sit at).
|
||||||
// support LAN discovery get a "Scan" button. Auth is via the admin's session
|
// Direction is a property of the relay, inherited by bound devices. The data model
|
||||||
// cookie. See wiki/concepts/first-run-setup.md and device-discovery.md.
|
// is multi-instance — one `devices` row per instance. See entry-exit-points.md.
|
||||||
|
|
||||||
const CATEGORIES: { key: DeviceCategory; title: string; noun: string }[] = [
|
const CONTROLLER: { key: DeviceCategory; title: string; noun: string } = {
|
||||||
{ key: "access", title: "Access controllers", noun: "access controller" },
|
key: "access",
|
||||||
{ key: "reader", title: "Readers", noun: "reader" },
|
title: "Controllers (barriers + entry button)",
|
||||||
{ key: "camera", title: "Cameras (entry/exit snapshot)", noun: "camera" },
|
noun: "controller",
|
||||||
{ key: "printer", title: "Printers", noun: "printer" },
|
};
|
||||||
|
// Categories that BIND to a controller relay (direction inherited from the relay).
|
||||||
|
const BOUND: { key: DeviceCategory; title: string; noun: string }[] = [
|
||||||
|
{ key: "reader", title: "Readers (QR / RFID)", noun: "reader" },
|
||||||
|
{ key: "camera", title: "Cameras (snapshot + plate)", noun: "camera" },
|
||||||
|
{ key: "printer", title: "Printers (tickets / vouchers)", noun: "printer" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const DIRECTION_LABELS: Record<Direction, string> = {
|
||||||
|
entry: "Entry",
|
||||||
|
exit: "Exit",
|
||||||
|
both: "Both (entry + exit)",
|
||||||
|
};
|
||||||
|
|
||||||
export function SetupWizard() {
|
export function SetupWizard() {
|
||||||
const [catalog, setCatalog] = useState<Catalog | null>(null);
|
const [catalog, setCatalog] = useState<Catalog | null>(null);
|
||||||
const [assignments, setAssignments] = useState<Assignment[] | null>(null);
|
const [assignments, setAssignments] = useState<Assignment[] | null>(null);
|
||||||
const [lane, setLane] = useState(1);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const reloadState = useCallback(() => {
|
const reloadState = useCallback(() => {
|
||||||
@@ -50,36 +63,41 @@ export function SetupWizard() {
|
|||||||
if (error) return <p style={{ color: "crimson" }}>Failed to load setup: {error}</p>;
|
if (error) return <p style={{ color: "crimson" }}>Failed to load setup: {error}</p>;
|
||||||
if (!catalog || !assignments) return <p>Loading device catalog…</p>;
|
if (!catalog || !assignments) return <p>Loading device catalog…</p>;
|
||||||
|
|
||||||
|
// Controllers are needed before binding readers/cameras (they pick a controller relay).
|
||||||
|
const controllers = assignments.filter((a) => a.category === "access");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section>
|
<section>
|
||||||
<h2>First-run setup</h2>
|
<h2>First-run setup</h2>
|
||||||
<div style={{ display: "flex", gap: "1rem", alignItems: "center" }}>
|
<p style={{ color: "#666", fontSize: "0.9em" }}>
|
||||||
<label>
|
Add your barrier controllers first — set which relay is entry/exit and which
|
||||||
Lane{" "}
|
terminal the entry button is wired to. Then add readers, cameras and printers
|
||||||
<input
|
and point each at the barrier it serves.
|
||||||
type="number"
|
</p>
|
||||||
min={1}
|
|
||||||
value={lane}
|
|
||||||
onChange={(e) => setLane(Number(e.target.value))}
|
|
||||||
style={{ width: "4rem" }}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<span style={{ color: "#666", fontSize: "0.85em" }}>
|
|
||||||
Devices are added per lane. Switch lanes to configure another.
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{CATEGORIES.map(({ key, title, noun }) => (
|
<CategorySection
|
||||||
|
category={CONTROLLER.key}
|
||||||
|
title={CONTROLLER.title}
|
||||||
|
noun={CONTROLLER.noun}
|
||||||
|
entries={catalog[CONTROLLER.key]}
|
||||||
|
discoverableIds={catalog.discoverable}
|
||||||
|
pushCapableIds={catalog.pushCapable}
|
||||||
|
controllers={controllers}
|
||||||
|
assignments={controllers}
|
||||||
|
onChanged={reloadState}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{BOUND.map(({ key, title, noun }) => (
|
||||||
<CategorySection
|
<CategorySection
|
||||||
key={key}
|
key={key}
|
||||||
lane={lane}
|
|
||||||
category={key}
|
category={key}
|
||||||
title={title}
|
title={title}
|
||||||
noun={noun}
|
noun={noun}
|
||||||
entries={catalog[key]}
|
entries={catalog[key]}
|
||||||
discoverableIds={catalog.discoverable}
|
discoverableIds={catalog.discoverable}
|
||||||
pushCapableIds={catalog.pushCapable}
|
pushCapableIds={catalog.pushCapable}
|
||||||
assignments={assignments.filter((a) => a.category === key && a.lane === lane)}
|
controllers={controllers}
|
||||||
|
assignments={assignments.filter((a) => a.category === key)}
|
||||||
onChanged={reloadState}
|
onChanged={reloadState}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -88,39 +106,37 @@ export function SetupWizard() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function CategorySection({
|
function CategorySection({
|
||||||
lane,
|
|
||||||
category,
|
category,
|
||||||
title,
|
title,
|
||||||
noun,
|
noun,
|
||||||
entries,
|
entries,
|
||||||
discoverableIds,
|
discoverableIds,
|
||||||
pushCapableIds,
|
pushCapableIds,
|
||||||
|
controllers,
|
||||||
assignments,
|
assignments,
|
||||||
onChanged,
|
onChanged,
|
||||||
}: {
|
}: {
|
||||||
lane: number;
|
|
||||||
category: DeviceCategory;
|
category: DeviceCategory;
|
||||||
title: string;
|
title: string;
|
||||||
noun: string;
|
noun: string;
|
||||||
entries: CatalogEntry[];
|
entries: CatalogEntry[];
|
||||||
discoverableIds: string[];
|
discoverableIds: string[];
|
||||||
pushCapableIds: string[];
|
pushCapableIds: string[];
|
||||||
|
controllers: Assignment[];
|
||||||
assignments: Assignment[];
|
assignments: Assignment[];
|
||||||
onChanged: () => Promise<void> | void;
|
onChanged: () => Promise<void> | void;
|
||||||
}) {
|
}) {
|
||||||
// Show the add-form automatically when nothing is assigned yet; otherwise it's
|
|
||||||
// collapsed behind "Add another" so the list stays the focus.
|
|
||||||
const [adding, setAdding] = useState(false);
|
const [adding, setAdding] = useState(false);
|
||||||
// Warnings from the most recent save (e.g. "string protocol could not be
|
|
||||||
// disabled — finish in the device web UI"). Persist after the form closes.
|
|
||||||
const [warnings, setWarnings] = useState<string[]>([]);
|
const [warnings, setWarnings] = useState<string[]>([]);
|
||||||
const showForm = adding || assignments.length === 0;
|
const showForm = adding || assignments.length === 0;
|
||||||
|
|
||||||
|
// Binding categories need a controller to point at first.
|
||||||
|
const isBound = category !== "access";
|
||||||
|
const blockedNoController = isBound && controllers.length === 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<fieldset style={{ marginTop: "1rem" }}>
|
<fieldset style={{ marginTop: "1rem" }}>
|
||||||
<legend>
|
<legend>{title}</legend>
|
||||||
{title} <span style={{ color: "#888", fontWeight: 400 }}>· lane {lane}</span>
|
|
||||||
</legend>
|
|
||||||
|
|
||||||
{warnings.length > 0 && (
|
{warnings.length > 0 && (
|
||||||
<div
|
<div
|
||||||
@@ -147,18 +163,20 @@ function CategorySection({
|
|||||||
{assignments.length > 0 && (
|
{assignments.length > 0 && (
|
||||||
<ul style={{ listStyle: "none", padding: 0, margin: "0 0 0.75rem" }}>
|
<ul style={{ listStyle: "none", padding: 0, margin: "0 0 0.75rem" }}>
|
||||||
{assignments.map((a) => (
|
{assignments.map((a) => (
|
||||||
<AssignmentRow key={a.id} assignment={a} onChanged={onChanged} />
|
<AssignmentRow key={a.id} assignment={a} controllers={controllers} onChanged={onChanged} />
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showForm ? (
|
{blockedNoController ? (
|
||||||
|
<p style={{ color: "#b45309", margin: 0 }}>Add a controller first — a {noun} points at one of its relays.</p>
|
||||||
|
) : showForm ? (
|
||||||
<DeviceForm
|
<DeviceForm
|
||||||
lane={lane}
|
|
||||||
category={category}
|
category={category}
|
||||||
entries={entries}
|
entries={entries}
|
||||||
discoverableIds={discoverableIds}
|
discoverableIds={discoverableIds}
|
||||||
pushCapableIds={pushCapableIds}
|
pushCapableIds={pushCapableIds}
|
||||||
|
controllers={controllers}
|
||||||
onSaved={async (w) => {
|
onSaved={async (w) => {
|
||||||
setWarnings(w);
|
setWarnings(w);
|
||||||
await onChanged();
|
await onChanged();
|
||||||
@@ -177,17 +195,17 @@ function CategorySection({
|
|||||||
|
|
||||||
function AssignmentRow({
|
function AssignmentRow({
|
||||||
assignment,
|
assignment,
|
||||||
|
controllers,
|
||||||
onChanged,
|
onChanged,
|
||||||
}: {
|
}: {
|
||||||
assignment: Assignment;
|
assignment: Assignment;
|
||||||
|
controllers: Assignment[];
|
||||||
onChanged: () => Promise<void> | void;
|
onChanged: () => Promise<void> | void;
|
||||||
}) {
|
}) {
|
||||||
const [removing, setRemoving] = useState(false);
|
const [removing, setRemoving] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
// A short, human summary of the instance: role (if any) + host.
|
const cfg = assignment.config as Record<string, unknown>;
|
||||||
const cfg = assignment.config;
|
|
||||||
const role = typeof cfg.role === "string" ? cfg.role : null;
|
|
||||||
const host = typeof cfg.host === "string" ? cfg.host : null;
|
const host = typeof cfg.host === "string" ? cfg.host : null;
|
||||||
|
|
||||||
async function remove() {
|
async function remove() {
|
||||||
@@ -214,8 +232,8 @@ function AssignmentRow({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<strong>{assignment.driverId}</strong>
|
<strong>{assignment.driverId}</strong>
|
||||||
{role && <span style={{ color: "#0369a1" }}>{role}</span>}
|
|
||||||
{host && <span style={{ color: "#666" }}>{host}</span>}
|
{host && <span style={{ color: "#666" }}>{host}</span>}
|
||||||
|
<DeviceSummary assignment={assignment} controllers={controllers} />
|
||||||
{!assignment.enabled && <span style={{ color: "#b45309" }}>(disabled)</span>}
|
{!assignment.enabled && <span style={{ color: "#b45309" }}>(disabled)</span>}
|
||||||
<span style={{ flex: 1 }} />
|
<span style={{ flex: 1 }} />
|
||||||
{error && <span style={{ color: "crimson" }}>{error}</span>}
|
{error && <span style={{ color: "crimson" }}>{error}</span>}
|
||||||
@@ -226,33 +244,66 @@ function AssignmentRow({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Inline summary of an assignment's direction/binding for the list. */
|
||||||
|
function DeviceSummary({ assignment, controllers }: { assignment: Assignment; controllers: Assignment[] }) {
|
||||||
|
const cfg = assignment.config as Record<string, unknown>;
|
||||||
|
if (assignment.category === "access") {
|
||||||
|
const relays = Array.isArray(cfg.relays) ? (cfg.relays as RelaySpec[]) : [];
|
||||||
|
if (relays.length === 0) return <em style={{ color: "#b45309" }}>no relays set</em>;
|
||||||
|
return (
|
||||||
|
<span style={{ display: "flex", gap: "0.35rem" }}>
|
||||||
|
{relays.map((r) => (
|
||||||
|
<DirectionBadge key={r.relay} direction={r.direction} label={`R${r.relay}${r.button ? `·btn${r.button}` : ""}`} />
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Bound device: show controller + relay it points at, with inherited direction.
|
||||||
|
const controllerId = typeof cfg.controllerId === "string" ? cfg.controllerId : null;
|
||||||
|
const relay = typeof cfg.relay === "number" ? cfg.relay : null;
|
||||||
|
if (!controllerId || relay == null) return <em style={{ color: "#b45309" }}>unbound</em>;
|
||||||
|
const controller = controllers.find((c) => c.id === controllerId);
|
||||||
|
const spec = controller
|
||||||
|
? (((controller.config as Record<string, unknown>).relays as RelaySpec[]) ?? []).find((r) => r.relay === relay)
|
||||||
|
: undefined;
|
||||||
|
return (
|
||||||
|
<DirectionBadge
|
||||||
|
direction={spec?.direction ?? "both"}
|
||||||
|
label={`${controller ? controller.driverId : "?"} · R${relay}`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function DeviceForm({
|
function DeviceForm({
|
||||||
lane,
|
|
||||||
category,
|
category,
|
||||||
entries,
|
entries,
|
||||||
discoverableIds,
|
discoverableIds,
|
||||||
pushCapableIds,
|
pushCapableIds,
|
||||||
|
controllers,
|
||||||
onSaved,
|
onSaved,
|
||||||
onCancel,
|
onCancel,
|
||||||
}: {
|
}: {
|
||||||
lane: number;
|
|
||||||
category: DeviceCategory;
|
category: DeviceCategory;
|
||||||
entries: CatalogEntry[];
|
entries: CatalogEntry[];
|
||||||
discoverableIds: string[];
|
discoverableIds: string[];
|
||||||
pushCapableIds: string[];
|
pushCapableIds: string[];
|
||||||
|
controllers: Assignment[];
|
||||||
onSaved: (warnings: string[]) => Promise<void> | void;
|
onSaved: (warnings: string[]) => Promise<void> | void;
|
||||||
onCancel?: () => void;
|
onCancel?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [selectedId, setSelectedId] = useState<string>("");
|
const [selectedId, setSelectedId] = useState<string>("");
|
||||||
const selected = entries.find((e) => e.id === selectedId);
|
const selected = entries.find((e) => e.id === selectedId);
|
||||||
const canDiscover = selected != null && discoverableIds.includes(selected.id);
|
const canDiscover = selected != null && discoverableIds.includes(selected.id);
|
||||||
// Only push-capable drivers (e.g. the Dingtian relay) call back to the
|
|
||||||
// backend and need a backend IP. Pull-only devices (cameras, commanded relays)
|
|
||||||
// must NOT show the field. See wiki/concepts/device-input-flow.md.
|
|
||||||
const pushesToBackend = selected != null && pushCapableIds.includes(selected.id);
|
const pushesToBackend = selected != null && pushCapableIds.includes(selected.id);
|
||||||
|
const isController = category === "access";
|
||||||
|
|
||||||
// Config values (auto-filled by discovery, editable by hand).
|
|
||||||
const [config, setConfig] = useState<Record<string, string | number>>({});
|
const [config, setConfig] = useState<Record<string, string | number>>({});
|
||||||
|
// Controllers: the relay map (which relay = entry/exit/both, + entry button terminal).
|
||||||
|
const [relays, setRelays] = useState<RelaySpec[]>([{ relay: 1, direction: "both" }]);
|
||||||
|
// Bound devices: which controller + relay this device sits at.
|
||||||
|
const [controllerId, setControllerId] = useState<string>("");
|
||||||
|
const [boundRelay, setBoundRelay] = useState<number | "">("");
|
||||||
|
|
||||||
const [tested, setTested] = useState<TestResult | null>(null);
|
const [tested, setTested] = useState<TestResult | null>(null);
|
||||||
const [testing, setTesting] = useState(false);
|
const [testing, setTesting] = useState(false);
|
||||||
const [testError, setTestError] = useState<string | null>(null);
|
const [testError, setTestError] = useState<string | null>(null);
|
||||||
@@ -262,17 +313,10 @@ function DeviceForm({
|
|||||||
const [scanning, setScanning] = useState(false);
|
const [scanning, setScanning] = useState(false);
|
||||||
const [scanError, setScanError] = useState<string | null>(null);
|
const [scanError, setScanError] = useState<string | null>(null);
|
||||||
|
|
||||||
// Backend push IP: which of OUR addresses the device should call back on. We
|
|
||||||
// auto-pick the NIC on the device's subnet, but surface it editable here so a
|
|
||||||
// multi-NIC host can be corrected (the chosen IP is baked into the device on
|
|
||||||
// save). Only relevant for drivers that push back to us (pushesToBackend).
|
|
||||||
const [backendIps, setBackendIps] = useState<BackendIpCandidate[] | null>(null);
|
const [backendIps, setBackendIps] = useState<BackendIpCandidate[] | null>(null);
|
||||||
const [backendIp, setBackendIp] = useState<string>("");
|
const [backendIp, setBackendIp] = useState<string>("");
|
||||||
|
|
||||||
// (Re)load backend-IP candidates whenever the device host changes after a
|
const testedHost = tested ? String(mergedScalarConfig().host ?? "") : "";
|
||||||
// successful test (the test confirms the host is real + reachable) — but only
|
|
||||||
// for push-capable drivers; a pull-only device never calls back.
|
|
||||||
const testedHost = tested ? String(mergedConfig().host ?? "") : "";
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!testedHost || !pushesToBackend) {
|
if (!testedHost || !pushesToBackend) {
|
||||||
setBackendIps(null);
|
setBackendIps(null);
|
||||||
@@ -319,8 +363,8 @@ function DeviceForm({
|
|||||||
resetStatus();
|
resetStatus();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Config the user actually entered, merged over driver defaults.
|
/** Scalar config the user entered, merged over driver defaults (for test/push-IP). */
|
||||||
function mergedConfig(): Record<string, string | number> {
|
function mergedScalarConfig(): Record<string, string | number> {
|
||||||
const out: Record<string, string | number> = {};
|
const out: Record<string, string | number> = {};
|
||||||
for (const f of selected?.configFields ?? []) {
|
for (const f of selected?.configFields ?? []) {
|
||||||
const v = config[f.key] ?? (f.default as string | number | undefined);
|
const v = config[f.key] ?? (f.default as string | number | undefined);
|
||||||
@@ -329,7 +373,22 @@ function DeviceForm({
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Editing config invalidates a prior test.
|
/** Full config to persist: scalars + the model's direction/binding fields. */
|
||||||
|
function mergedConfig(): DeviceConfig {
|
||||||
|
const out: DeviceConfig = { ...mergedScalarConfig() };
|
||||||
|
if (isController) {
|
||||||
|
out.relays = relays.map((r) => ({
|
||||||
|
relay: r.relay,
|
||||||
|
direction: r.direction,
|
||||||
|
...(r.button ? { button: r.button } : {}),
|
||||||
|
}));
|
||||||
|
} else if (controllerId && boundRelay !== "") {
|
||||||
|
out.controllerId = controllerId;
|
||||||
|
out.relay = boundRelay;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
function resetStatus() {
|
function resetStatus() {
|
||||||
setTested(null);
|
setTested(null);
|
||||||
setTestError(null);
|
setTestError(null);
|
||||||
@@ -342,7 +401,7 @@ function DeviceForm({
|
|||||||
setTestError(null);
|
setTestError(null);
|
||||||
setTested(null);
|
setTested(null);
|
||||||
try {
|
try {
|
||||||
setTested(await testDevice(selected.id, mergedConfig()));
|
setTested(await testDevice(selected.id, mergedScalarConfig()));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setTestError((e as Error).message);
|
setTestError((e as Error).message);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -352,17 +411,21 @@ function DeviceForm({
|
|||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
if (!selected) return;
|
if (!selected) return;
|
||||||
|
// Bound devices must point at a controller relay (binding is optional in the
|
||||||
|
// model with a fallback, but the wizard guides the admin to bind explicitly).
|
||||||
|
if (!isController && (!controllerId || boundRelay === "")) {
|
||||||
|
setSaveError("Pick the controller and relay this device sits at.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setSaveError(null);
|
setSaveError(null);
|
||||||
try {
|
try {
|
||||||
const result = await assignDevice({
|
const result = await assignDevice({
|
||||||
lane,
|
|
||||||
category,
|
category,
|
||||||
driverId: selected.id,
|
driverId: selected.id,
|
||||||
config: mergedConfig(),
|
config: mergedConfig(),
|
||||||
...(backendIp ? { backendIp } : {}),
|
...(backendIp ? { backendIp } : {}),
|
||||||
});
|
});
|
||||||
// Hand warnings to the parent so they persist after this form unmounts.
|
|
||||||
await onSaved(result.warnings ?? []);
|
await onSaved(result.warnings ?? []);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setSaveError((e as Error).message);
|
setSaveError((e as Error).message);
|
||||||
@@ -452,6 +515,23 @@ function DeviceForm({
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{/* CONTROLLER: the relay map — which relay opens which direction + entry button. */}
|
||||||
|
{isController && <RelayEditor relays={relays} onChange={setRelays} />}
|
||||||
|
|
||||||
|
{/* BOUND device: which controller + relay it sits at. */}
|
||||||
|
{!isController && (
|
||||||
|
<BindingPicker
|
||||||
|
controllers={controllers}
|
||||||
|
controllerId={controllerId}
|
||||||
|
relay={boundRelay}
|
||||||
|
onControllerChange={(id) => {
|
||||||
|
setControllerId(id);
|
||||||
|
setBoundRelay("");
|
||||||
|
}}
|
||||||
|
onRelayChange={setBoundRelay}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Test (no save/no device change) then Save (configures + persists). */}
|
{/* Test (no save/no device change) then Save (configures + persists). */}
|
||||||
<div style={{ marginTop: "0.75rem", display: "flex", gap: "0.5rem", alignItems: "center" }}>
|
<div style={{ marginTop: "0.75rem", display: "flex", gap: "0.5rem", alignItems: "center" }}>
|
||||||
<button type="button" onClick={test} disabled={testing}>
|
<button type="button" onClick={test} disabled={testing}>
|
||||||
@@ -487,8 +567,6 @@ function DeviceForm({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Backend push IP — only for push-capable devices (candidates present).
|
|
||||||
Pre-filled with the auto-pick; editable for multi-NIC hosts. */}
|
|
||||||
{backendIps && backendIps.length > 0 && (
|
{backendIps && backendIps.length > 0 && (
|
||||||
<div style={{ margin: "0.5rem 0 0" }}>
|
<div style={{ margin: "0.5rem 0 0" }}>
|
||||||
<label>
|
<label>
|
||||||
@@ -523,6 +601,161 @@ function DeviceForm({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Controller relay map editor: each row = a relay + its direction + (optional)
|
||||||
|
* the input terminal its entry button is wired to. */
|
||||||
|
function RelayEditor({ relays, onChange }: { relays: RelaySpec[]; onChange: (r: RelaySpec[]) => void }) {
|
||||||
|
function update(i: number, patch: Partial<RelaySpec>) {
|
||||||
|
onChange(relays.map((r, idx) => (idx === i ? { ...r, ...patch } : r)));
|
||||||
|
}
|
||||||
|
function add() {
|
||||||
|
const nextRelay = (relays.reduce((m, r) => Math.max(m, r.relay), 0) || 0) + 1;
|
||||||
|
onChange([...relays, { relay: nextRelay, direction: "both" }]);
|
||||||
|
}
|
||||||
|
function remove(i: number) {
|
||||||
|
onChange(relays.filter((_, idx) => idx !== i));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ margin: "0.5rem 0", padding: "0.5rem", background: "#f3f4f6", borderRadius: 6 }}>
|
||||||
|
<strong style={{ fontSize: "0.9em" }}>Relays on this controller</strong>
|
||||||
|
<p style={{ margin: "0.15rem 0 0.5rem", color: "#666", fontSize: "0.8em" }}>
|
||||||
|
Each relay opens one barrier. Set its direction; for transient entry, set which input
|
||||||
|
terminal the entry button is wired to.
|
||||||
|
</p>
|
||||||
|
{relays.map((r, i) => (
|
||||||
|
<div key={i} style={{ display: "flex", gap: "0.5rem", alignItems: "center", margin: "0.25rem 0" }}>
|
||||||
|
<label>
|
||||||
|
Relay{" "}
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={r.relay}
|
||||||
|
style={{ width: "3.5rem" }}
|
||||||
|
onChange={(e) => update(i, { relay: Number(e.target.value) })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<select value={r.direction} onChange={(e) => update(i, { direction: e.target.value as Direction })}>
|
||||||
|
{(["entry", "exit", "both"] as Direction[]).map((d) => (
|
||||||
|
<option key={d} value={d}>
|
||||||
|
{DIRECTION_LABELS[d]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{(r.direction === "entry" || r.direction === "both") && (
|
||||||
|
<label>
|
||||||
|
Entry button on terminal{" "}
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={r.button ?? ""}
|
||||||
|
placeholder="—"
|
||||||
|
style={{ width: "3.5rem" }}
|
||||||
|
onChange={(e) => update(i, { button: e.target.value === "" ? undefined : Number(e.target.value) })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
{relays.length > 1 && (
|
||||||
|
<button type="button" onClick={() => remove(i)}>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button type="button" onClick={add} style={{ marginTop: "0.25rem" }}>
|
||||||
|
+ Add relay
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Binding picker for readers/cameras/printers: choose the controller + relay this
|
||||||
|
* device sits at. Direction is inherited from the chosen relay (shown). */
|
||||||
|
function BindingPicker({
|
||||||
|
controllers,
|
||||||
|
controllerId,
|
||||||
|
relay,
|
||||||
|
onControllerChange,
|
||||||
|
onRelayChange,
|
||||||
|
}: {
|
||||||
|
controllers: Assignment[];
|
||||||
|
controllerId: string;
|
||||||
|
relay: number | "";
|
||||||
|
onControllerChange: (id: string) => void;
|
||||||
|
onRelayChange: (relay: number) => void;
|
||||||
|
}) {
|
||||||
|
const controller = controllers.find((c) => c.id === controllerId);
|
||||||
|
const relays: RelaySpec[] = controller
|
||||||
|
? (((controller.config as Record<string, unknown>).relays as RelaySpec[]) ?? [])
|
||||||
|
: [];
|
||||||
|
const chosen = relays.find((r) => r.relay === relay);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ margin: "0.5rem 0", padding: "0.5rem", background: "#f3f4f6", borderRadius: 6 }}>
|
||||||
|
<strong style={{ fontSize: "0.9em" }}>Which barrier does this device serve?</strong>
|
||||||
|
<div style={{ display: "flex", gap: "0.5rem", alignItems: "center", marginTop: "0.35rem", flexWrap: "wrap" }}>
|
||||||
|
<label>
|
||||||
|
Controller{" "}
|
||||||
|
<select value={controllerId} onChange={(e) => onControllerChange(e.target.value)}>
|
||||||
|
<option value="" disabled>
|
||||||
|
Choose…
|
||||||
|
</option>
|
||||||
|
{controllers.map((c) => {
|
||||||
|
const host = (c.config as Record<string, unknown>).host;
|
||||||
|
return (
|
||||||
|
<option key={c.id} value={c.id}>
|
||||||
|
{c.driverId}
|
||||||
|
{typeof host === "string" ? ` (${host})` : ""}
|
||||||
|
</option>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Relay{" "}
|
||||||
|
<select
|
||||||
|
value={relay === "" ? "" : String(relay)}
|
||||||
|
disabled={!controller}
|
||||||
|
onChange={(e) => onRelayChange(Number(e.target.value))}
|
||||||
|
>
|
||||||
|
<option value="" disabled>
|
||||||
|
Choose…
|
||||||
|
</option>
|
||||||
|
{relays.map((r) => (
|
||||||
|
<option key={r.relay} value={r.relay}>
|
||||||
|
Relay {r.relay} ({DIRECTION_LABELS[r.direction]})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{chosen && <DirectionBadge direction={chosen.direction} label={`inherits ${chosen.direction}`} />}
|
||||||
|
</div>
|
||||||
|
{controller && relays.length === 0 && (
|
||||||
|
<p style={{ margin: "0.35rem 0 0", color: "#b45309", fontSize: "0.85em" }}>
|
||||||
|
This controller has no relays configured.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DirectionBadge({ direction, label }: { direction: Direction; label?: string }) {
|
||||||
|
const color = direction === "entry" ? "#15803d" : direction === "exit" ? "#b45309" : "#6b7280";
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color,
|
||||||
|
border: `1px solid ${color}`,
|
||||||
|
borderRadius: 4,
|
||||||
|
padding: "0 0.35rem",
|
||||||
|
fontSize: "0.75em",
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label ?? direction}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function HealthBadge({ status }: { status: string }) {
|
function HealthBadge({ status }: { status: string }) {
|
||||||
const color = status === "ready" ? "#16a34a" : status === "degraded" ? "#d97706" : "#dc2626";
|
const color = status === "ready" ? "#16a34a" : status === "degraded" ? "#d97706" : "#dc2626";
|
||||||
return <span style={{ color, fontWeight: 600 }}>● {status}</span>;
|
return <span style={{ color, fontWeight: 600 }}>● {status}</span>;
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { closeShift, fetchShift, openShift, type ShiftReport } from "./api.js";
|
||||||
|
|
||||||
|
// Manned-mode shift control. Start/End are explicit (not time-based — see
|
||||||
|
// wiki/concepts/shift.md). End Shift signs + prints a Z-report and shows the
|
||||||
|
// totals. Available to cashier/operator/admin (readonly has no shift).
|
||||||
|
|
||||||
|
const money = (m: number, cur: string | null) => `${(m / 100).toFixed(2)} ${cur ?? ""}`.trim();
|
||||||
|
|
||||||
|
export function ShiftControl() {
|
||||||
|
const [startedAt, setStartedAt] = useState<string | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [report, setReport] = useState<ShiftReport | null>(null);
|
||||||
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchShift()
|
||||||
|
.then((s) => setStartedAt(s.open?.startedAt ?? null))
|
||||||
|
.catch(() => {
|
||||||
|
/* readonly / not permitted — hide control */
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function start() {
|
||||||
|
setBusy(true);
|
||||||
|
setErr(null);
|
||||||
|
setReport(null);
|
||||||
|
try {
|
||||||
|
const { startedAt } = await openShift();
|
||||||
|
setStartedAt(startedAt);
|
||||||
|
} catch (e) {
|
||||||
|
setErr((e as Error).message);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function end() {
|
||||||
|
setBusy(true);
|
||||||
|
setErr(null);
|
||||||
|
try {
|
||||||
|
const z = await closeShift();
|
||||||
|
setReport(z);
|
||||||
|
setStartedAt(null);
|
||||||
|
} catch (e) {
|
||||||
|
setErr((e as Error).message);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section style={{ marginTop: "1.5rem", padding: "0.75rem 1rem", border: "1px solid #ddd", borderRadius: 6, maxWidth: 460 }}>
|
||||||
|
<strong>Shift:</strong>{" "}
|
||||||
|
{startedAt ? (
|
||||||
|
<>
|
||||||
|
<span style={{ color: "#16a34a" }}>open</span> since {new Date(startedAt).toLocaleString()}{" "}
|
||||||
|
<button type="button" onClick={end} disabled={busy}>
|
||||||
|
{busy ? "Ending…" : "End shift"}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span style={{ color: "#777" }}>not started</span>{" "}
|
||||||
|
<button type="button" onClick={start} disabled={busy}>
|
||||||
|
{busy ? "Starting…" : "Start shift"}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{err && <p style={{ color: "crimson", margin: "0.5rem 0 0" }}>{err}</p>}
|
||||||
|
{report && (
|
||||||
|
<div style={{ marginTop: "0.75rem", fontFamily: "ui-monospace, monospace", fontSize: "0.9em" }}>
|
||||||
|
<div style={{ fontWeight: 600 }}>Z-REPORT — {report.operator}</div>
|
||||||
|
<div>Payments: {report.paymentCount}</div>
|
||||||
|
<div>Cash: {money(report.cashTotalMinor, report.currency)}</div>
|
||||||
|
<div>Card: {money(report.cardTotalMinor, report.currency)}</div>
|
||||||
|
<div style={{ color: report.printed ? "#16a34a" : "#b45309" }}>
|
||||||
|
{report.printed ? "Printed to booth receipt." : "Recorded (no printer to print to)."}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { fetchOccupancy, fetchSiteConfig, setCapacity, type Occupancy } from "./api.js";
|
||||||
|
|
||||||
|
// Live occupancy + capacity. Occupancy is shown to everyone (it's a fold over the
|
||||||
|
// signed ledger); the capacity field is admin-editable. The FULL gate (refuse
|
||||||
|
// transient entry at capacity) is enforced server-side in the entry flow.
|
||||||
|
// See wiki/concepts/capacity-occupancy.md.
|
||||||
|
|
||||||
|
export function SiteSettings({ canEdit }: { canEdit: boolean }) {
|
||||||
|
const [occ, setOcc] = useState<Occupancy | null>(null);
|
||||||
|
const [capInput, setCapInput] = useState("");
|
||||||
|
const [msg, setMsg] = useState<string | null>(null);
|
||||||
|
|
||||||
|
function reload() {
|
||||||
|
fetchOccupancy().then(setOcc).catch(() => {});
|
||||||
|
}
|
||||||
|
useEffect(() => {
|
||||||
|
reload();
|
||||||
|
fetchSiteConfig()
|
||||||
|
.then((c) => setCapInput(c.capacity == null ? "" : String(c.capacity)))
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
setMsg(null);
|
||||||
|
const raw = capInput.trim();
|
||||||
|
const capacity = raw === "" ? null : Math.round(Number(raw));
|
||||||
|
try {
|
||||||
|
await setCapacity(capacity);
|
||||||
|
reload();
|
||||||
|
setMsg("Capacity saved.");
|
||||||
|
} catch (e) {
|
||||||
|
setMsg((e as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section style={{ marginTop: "1.5rem", padding: "0.75rem 1rem", border: "1px solid #ddd", borderRadius: 6, maxWidth: 460 }}>
|
||||||
|
<strong>Occupancy:</strong>{" "}
|
||||||
|
{occ == null ? (
|
||||||
|
"…"
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span style={{ fontWeight: 600 }}>{occ.count}</span>
|
||||||
|
{occ.capacity != null ? ` / ${occ.capacity}` : " (no capacity set)"}
|
||||||
|
{occ.capacity != null && (
|
||||||
|
<span style={{ color: "#666" }}> · {occ.free} free</span>
|
||||||
|
)}
|
||||||
|
{occ.full && <span style={{ color: "crimson", marginLeft: "0.5rem", fontWeight: 600 }}>FULL</span>}{" "}
|
||||||
|
<button type="button" onClick={reload} style={{ marginLeft: "0.5rem" }}>↻</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{canEdit && (
|
||||||
|
<div style={{ marginTop: "0.6rem" }}>
|
||||||
|
<label>
|
||||||
|
Capacity (blank = no limit):{" "}
|
||||||
|
<input value={capInput} onChange={(e) => setCapInput(e.target.value)} style={{ width: 80 }} placeholder="e.g. 120" />
|
||||||
|
</label>{" "}
|
||||||
|
<button type="button" onClick={save}>Save</button>
|
||||||
|
{msg && <span style={{ marginLeft: "0.5rem", color: "#555" }}>{msg}</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
ApiError,
|
||||||
|
fetchTariff,
|
||||||
|
publishTariffVersion,
|
||||||
|
type TariffBlock,
|
||||||
|
type TariffStructure,
|
||||||
|
type TariffState,
|
||||||
|
} from "./api.js";
|
||||||
|
|
||||||
|
// Tariff composer — the admin builds + edits the rate card at runtime. Publishing
|
||||||
|
// creates a new IMMUTABLE version (the active card); old versions are kept so past
|
||||||
|
// sessions reprice correctly. Amounts are entered in major units (e.g. euros) for
|
||||||
|
// usability and converted to integer minor units on submit. See wiki/concepts/tariff.md.
|
||||||
|
|
||||||
|
// Editable form mirror of TariffStructure, but money in major-unit strings.
|
||||||
|
interface BlockForm {
|
||||||
|
uptoMin: string; // "" = open-ended (last block)
|
||||||
|
price: string; // major units, e.g. "2.00"
|
||||||
|
}
|
||||||
|
interface FormState {
|
||||||
|
currency: string;
|
||||||
|
gracePeriodEntryMin: string;
|
||||||
|
incrementMin: string;
|
||||||
|
dailyCap: string; // "" = no cap
|
||||||
|
lostTicket: string;
|
||||||
|
gracePeriodExitMin: string;
|
||||||
|
blocks: BlockForm[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const toMinor = (major: string): number => Math.round(parseFloat(major || "0") * 100);
|
||||||
|
const toMajor = (minor: number): string => (minor / 100).toFixed(2);
|
||||||
|
|
||||||
|
function emptyForm(): FormState {
|
||||||
|
return {
|
||||||
|
currency: "EUR",
|
||||||
|
gracePeriodEntryMin: "15",
|
||||||
|
incrementMin: "60",
|
||||||
|
dailyCap: "",
|
||||||
|
lostTicket: "20.00",
|
||||||
|
gracePeriodExitMin: "15",
|
||||||
|
blocks: [{ uptoMin: "60", price: "2.00" }, { uptoMin: "", price: "1.00" }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function formFromActive(s: TariffState): FormState {
|
||||||
|
const v = s.active;
|
||||||
|
if (!v) return emptyForm();
|
||||||
|
const st = v.structure;
|
||||||
|
return {
|
||||||
|
currency: v.currency,
|
||||||
|
gracePeriodEntryMin: String(st.gracePeriodEntryMin),
|
||||||
|
incrementMin: String(st.incrementMin),
|
||||||
|
dailyCap: st.dailyCapMinor == null ? "" : toMajor(st.dailyCapMinor),
|
||||||
|
lostTicket: toMajor(st.lostTicketMinor),
|
||||||
|
gracePeriodExitMin: String(st.gracePeriodExitMin),
|
||||||
|
blocks: st.blocks.map((b) => ({
|
||||||
|
uptoMin: b.uptoMin == null ? "" : String(b.uptoMin),
|
||||||
|
price: toMajor(b.priceMinorPerIncrement),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toStructure(f: FormState): TariffStructure {
|
||||||
|
const blocks: TariffBlock[] = f.blocks.map((b) => ({
|
||||||
|
uptoMin: b.uptoMin.trim() === "" ? null : Math.round(Number(b.uptoMin)),
|
||||||
|
priceMinorPerIncrement: toMinor(b.price),
|
||||||
|
}));
|
||||||
|
return {
|
||||||
|
gracePeriodEntryMin: Math.round(Number(f.gracePeriodEntryMin)),
|
||||||
|
incrementMin: Math.round(Number(f.incrementMin)),
|
||||||
|
blocks,
|
||||||
|
dailyCapMinor: f.dailyCap.trim() === "" ? null : toMinor(f.dailyCap),
|
||||||
|
lostTicketMinor: toMinor(f.lostTicket),
|
||||||
|
gracePeriodExitMin: Math.round(Number(f.gracePeriodExitMin)),
|
||||||
|
overstay: "reprice",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TariffComposer() {
|
||||||
|
const [state, setState] = useState<TariffState | null>(null);
|
||||||
|
const [form, setForm] = useState<FormState>(emptyForm);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [msg, setMsg] = useState<{ kind: "ok" | "err"; text: string } | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchTariff()
|
||||||
|
.then((s) => {
|
||||||
|
setState(s);
|
||||||
|
setForm(formFromActive(s));
|
||||||
|
})
|
||||||
|
.catch((e) => setMsg({ kind: "err", text: (e as Error).message }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function set<K extends keyof FormState>(key: K, value: FormState[K]) {
|
||||||
|
setForm((f) => ({ ...f, [key]: value }));
|
||||||
|
}
|
||||||
|
function setBlock(i: number, patch: Partial<BlockForm>) {
|
||||||
|
setForm((f) => ({ ...f, blocks: f.blocks.map((b, j) => (j === i ? { ...b, ...patch } : b)) }));
|
||||||
|
}
|
||||||
|
function addBlock() {
|
||||||
|
setForm((f) => ({ ...f, blocks: [...f.blocks, { uptoMin: "", price: "0.00" }] }));
|
||||||
|
}
|
||||||
|
function removeBlock(i: number) {
|
||||||
|
setForm((f) => ({ ...f, blocks: f.blocks.filter((_, j) => j !== i) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function publish() {
|
||||||
|
setSaving(true);
|
||||||
|
setMsg(null);
|
||||||
|
try {
|
||||||
|
await publishTariffVersion({ currency: form.currency.trim().toUpperCase(), structure: toStructure(form) });
|
||||||
|
const fresh = await fetchTariff();
|
||||||
|
setState(fresh);
|
||||||
|
setMsg({ kind: "ok", text: "New tariff version published — it's now the active rate card." });
|
||||||
|
} catch (e) {
|
||||||
|
const text =
|
||||||
|
e instanceof ApiError && (e as ApiError & { problems?: string[] }).problems
|
||||||
|
? `${e.message}: ${((e as ApiError & { problems?: string[] }).problems ?? []).join("; ")}`
|
||||||
|
: (e as Error).message;
|
||||||
|
setMsg({ kind: "err", text });
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section style={{ marginTop: "2rem" }}>
|
||||||
|
<h2>Tariff</h2>
|
||||||
|
{!state?.active ? (
|
||||||
|
<p style={{ color: "#b45309" }}>
|
||||||
|
No rate card published yet — the pay station can't charge until you publish one.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p style={{ color: "#555" }}>
|
||||||
|
Active since {new Date(state.active.effectiveFrom).toLocaleString()} ·{" "}
|
||||||
|
{state.versions.length} version(s) in history. Publishing creates a new version; past
|
||||||
|
sessions keep their original pricing.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "max-content 1fr", gap: "0.4rem 0.75rem", alignItems: "center", maxWidth: 460 }}>
|
||||||
|
<label>Currency</label>
|
||||||
|
<input value={form.currency} onChange={(e) => set("currency", e.target.value)} maxLength={3} style={{ width: 80 }} />
|
||||||
|
<label>Free entry grace (min)</label>
|
||||||
|
<input value={form.gracePeriodEntryMin} onChange={(e) => set("gracePeriodEntryMin", e.target.value)} />
|
||||||
|
<label>Billing increment (min)</label>
|
||||||
|
<input value={form.incrementMin} onChange={(e) => set("incrementMin", e.target.value)} />
|
||||||
|
<label>Daily cap (blank = none)</label>
|
||||||
|
<input value={form.dailyCap} onChange={(e) => set("dailyCap", e.target.value)} placeholder="e.g. 12.00" />
|
||||||
|
<label>Lost-ticket fee</label>
|
||||||
|
<input value={form.lostTicket} onChange={(e) => set("lostTicket", e.target.value)} />
|
||||||
|
<label>Exit walk-back grace (min)</label>
|
||||||
|
<input value={form.gracePeriodExitMin} onChange={(e) => set("gracePeriodExitMin", e.target.value)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 style={{ marginBottom: "0.25rem" }}>Rate blocks</h3>
|
||||||
|
<p style={{ color: "#777", margin: "0 0 0.5rem", fontSize: "0.9em" }}>
|
||||||
|
Consumed in order as time accrues. "Up to (min)" is the block's upper bound; leave the last
|
||||||
|
block's bound blank for "thereafter". Price is per billing increment.
|
||||||
|
</p>
|
||||||
|
<table style={{ borderCollapse: "collapse" }}>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ textAlign: "left", color: "#555" }}>
|
||||||
|
<th style={{ padding: "0 0.5rem" }}>Up to (min)</th>
|
||||||
|
<th style={{ padding: "0 0.5rem" }}>Price / increment</th>
|
||||||
|
<th />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{form.blocks.map((b, i) => (
|
||||||
|
<tr key={i}>
|
||||||
|
<td style={{ padding: "0.15rem 0.5rem" }}>
|
||||||
|
<input
|
||||||
|
value={b.uptoMin}
|
||||||
|
onChange={(e) => setBlock(i, { uptoMin: e.target.value })}
|
||||||
|
placeholder={i === form.blocks.length - 1 ? "thereafter" : "e.g. 60"}
|
||||||
|
style={{ width: 110 }}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: "0.15rem 0.5rem" }}>
|
||||||
|
<input value={b.price} onChange={(e) => setBlock(i, { price: e.target.value })} style={{ width: 90 }} />
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button type="button" onClick={() => removeBlock(i)} disabled={form.blocks.length <= 1}>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<button type="button" onClick={addBlock} style={{ marginTop: "0.4rem" }}>
|
||||||
|
+ Add block
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div style={{ marginTop: "1rem" }}>
|
||||||
|
<button type="button" onClick={publish} disabled={saving}>
|
||||||
|
{saving ? "Publishing…" : "Publish new version"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{msg && (
|
||||||
|
<p style={{ color: msg.kind === "ok" ? "#16a34a" : "crimson", marginTop: "0.5rem" }}>{msg.text}</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
+148
-3
@@ -120,7 +120,26 @@ export async function discoverDevices(driverId: string): Promise<DiscoveredDevic
|
|||||||
return body.devices;
|
return body.devices;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DeviceConfig = Record<string, string | number | boolean>;
|
export type ConfigValue =
|
||||||
|
| string
|
||||||
|
| number
|
||||||
|
| boolean
|
||||||
|
| null
|
||||||
|
| ConfigValue[]
|
||||||
|
| { [k: string]: ConfigValue };
|
||||||
|
export type DeviceConfig = Record<string, ConfigValue>;
|
||||||
|
|
||||||
|
/** Direction a barrier/relay (or a device bound to it) serves. */
|
||||||
|
export type Direction = "entry" | "exit" | "both";
|
||||||
|
|
||||||
|
/** One relay on an access controller: which barrier it opens, in which direction,
|
||||||
|
* and (optionally) the input terminal its entry button is wired to. */
|
||||||
|
export interface RelaySpec {
|
||||||
|
relay: number;
|
||||||
|
direction: Direction;
|
||||||
|
/** Input terminal of the entry button that fires this relay (transient entry). */
|
||||||
|
button?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TestResult {
|
export interface TestResult {
|
||||||
health: { status: string; detail?: string };
|
health: { status: string; detail?: string };
|
||||||
@@ -153,9 +172,10 @@ export function fetchBackendIps(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface AssignBody {
|
export interface AssignBody {
|
||||||
lane: number;
|
|
||||||
category: DeviceCategory;
|
category: DeviceCategory;
|
||||||
driverId: string;
|
driverId: string;
|
||||||
|
// Direction/binding lives in config: access → config.relays=[{relay,direction,button?}];
|
||||||
|
// reader/camera → config.controllerId + config.relay.
|
||||||
config: DeviceConfig;
|
config: DeviceConfig;
|
||||||
/** Backend IP the device should push to (overrides auto-pick). */
|
/** Backend IP the device should push to (overrides auto-pick). */
|
||||||
backendIp?: string;
|
backendIp?: string;
|
||||||
@@ -169,7 +189,6 @@ export function assignDevice(body: AssignBody): Promise<AssignResult> {
|
|||||||
/** A persisted device assignment (one per instance; machine-only secrets stripped). */
|
/** A persisted device assignment (one per instance; machine-only secrets stripped). */
|
||||||
export interface Assignment {
|
export interface Assignment {
|
||||||
id: string;
|
id: string;
|
||||||
lane: number;
|
|
||||||
category: DeviceCategory;
|
category: DeviceCategory;
|
||||||
driverId: string;
|
driverId: string;
|
||||||
config: DeviceConfig;
|
config: DeviceConfig;
|
||||||
@@ -197,3 +216,129 @@ export function fetchState(): Promise<SetupState> {
|
|||||||
export function unassignDevice(id: string): Promise<void> {
|
export function unassignDevice(id: string): Promise<void> {
|
||||||
return apiFetch(`/api/setup/assign/${id}`, { method: "DELETE" });
|
return apiFetch(`/api/setup/assign/${id}`, { method: "DELETE" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Tariff composer ------------------------------------------------------
|
||||||
|
|
||||||
|
export interface TariffBlock {
|
||||||
|
uptoMin: number | null;
|
||||||
|
priceMinorPerIncrement: number;
|
||||||
|
}
|
||||||
|
export interface TariffStructure {
|
||||||
|
gracePeriodEntryMin: number;
|
||||||
|
incrementMin: number;
|
||||||
|
blocks: TariffBlock[];
|
||||||
|
dailyCapMinor: number | null;
|
||||||
|
lostTicketMinor: number;
|
||||||
|
gracePeriodExitMin: number;
|
||||||
|
overstay: "reprice";
|
||||||
|
}
|
||||||
|
export interface TariffVersion {
|
||||||
|
id: string;
|
||||||
|
tariffId: string;
|
||||||
|
effectiveFrom: string;
|
||||||
|
currency: string;
|
||||||
|
structure: TariffStructure;
|
||||||
|
createdBy?: string | null;
|
||||||
|
createdAt?: string;
|
||||||
|
}
|
||||||
|
export interface TariffState {
|
||||||
|
tariffId: string;
|
||||||
|
active: TariffVersion | null;
|
||||||
|
versions: TariffVersion[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchTariff(): Promise<TariffState> {
|
||||||
|
return apiFetch<TariffState>("/api/tariff");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Publish a new immutable tariff version (becomes the active rate card). */
|
||||||
|
export function publishTariffVersion(body: {
|
||||||
|
currency: string;
|
||||||
|
structure: TariffStructure;
|
||||||
|
effectiveFrom?: string;
|
||||||
|
}): Promise<TariffVersion> {
|
||||||
|
return apiFetch("/api/tariff/versions", { method: "POST", body: JSON.stringify(body) });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Permits --------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface PermitCredential {
|
||||||
|
kind: "rf" | "qr";
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
export interface Permit {
|
||||||
|
id: string;
|
||||||
|
holderName: string | null;
|
||||||
|
contact: string | null;
|
||||||
|
maxConcurrent: number | null;
|
||||||
|
validFrom: string | null;
|
||||||
|
validTo: string | null;
|
||||||
|
status: "active" | "suspended" | "revoked";
|
||||||
|
credentials: PermitCredential[];
|
||||||
|
plates: string[];
|
||||||
|
}
|
||||||
|
export type PermitInput = Omit<Permit, "id" | "status"> & {
|
||||||
|
status?: Permit["status"];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function fetchPermits(): Promise<{ permits: Permit[] }> {
|
||||||
|
return apiFetch("/api/permits");
|
||||||
|
}
|
||||||
|
export function createPermit(body: PermitInput): Promise<Permit> {
|
||||||
|
return apiFetch("/api/permits", { method: "POST", body: JSON.stringify(body) });
|
||||||
|
}
|
||||||
|
export function updatePermit(id: string, body: PermitInput): Promise<Permit> {
|
||||||
|
return apiFetch(`/api/permits/${id}`, { method: "PUT", body: JSON.stringify(body) });
|
||||||
|
}
|
||||||
|
export function revokePermit(id: string): Promise<Permit> {
|
||||||
|
return apiFetch(`/api/permits/${id}/revoke`, { method: "POST" });
|
||||||
|
}
|
||||||
|
export function deletePermit(id: string): Promise<void> {
|
||||||
|
return apiFetch(`/api/permits/${id}`, { method: "DELETE" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Shifts ---------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface ShiftStatus {
|
||||||
|
operator: string;
|
||||||
|
open: { startedAt: string } | null;
|
||||||
|
}
|
||||||
|
export interface ShiftReport {
|
||||||
|
operator: string;
|
||||||
|
startedAt: string;
|
||||||
|
endedAt: string;
|
||||||
|
cashTotalMinor: number;
|
||||||
|
cardTotalMinor: number;
|
||||||
|
currency: string | null;
|
||||||
|
paymentCount: number;
|
||||||
|
printed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchShift(): Promise<ShiftStatus> {
|
||||||
|
return apiFetch("/api/shift/current");
|
||||||
|
}
|
||||||
|
export function openShift(): Promise<{ startedAt: string }> {
|
||||||
|
return apiFetch("/api/shift/open", { method: "POST" });
|
||||||
|
}
|
||||||
|
export function closeShift(): Promise<ShiftReport> {
|
||||||
|
return apiFetch("/api/shift/close", { method: "POST" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Site config / occupancy ----------------------------------------------
|
||||||
|
|
||||||
|
export interface Occupancy {
|
||||||
|
count: number;
|
||||||
|
capacity: number | null;
|
||||||
|
free: number | null;
|
||||||
|
full: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchOccupancy(): Promise<Occupancy> {
|
||||||
|
return apiFetch("/api/occupancy");
|
||||||
|
}
|
||||||
|
export function fetchSiteConfig(): Promise<{ capacity: number | null }> {
|
||||||
|
return apiFetch("/api/site-config");
|
||||||
|
}
|
||||||
|
export function setCapacity(capacity: number | null): Promise<{ capacity: number | null }> {
|
||||||
|
return apiFetch("/api/site-config", { method: "PUT", body: JSON.stringify({ capacity }) });
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,16 +11,14 @@ CREATE TABLE `blocklist` (
|
|||||||
CREATE TABLE `device_events` (
|
CREATE TABLE `device_events` (
|
||||||
`id` text PRIMARY KEY NOT NULL,
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
`device_id` text,
|
`device_id` text,
|
||||||
`lane` integer,
|
|
||||||
`category` text,
|
`category` text,
|
||||||
`kind` text NOT NULL,
|
`kind` text NOT NULL,
|
||||||
`detail` text,
|
`detail` text,
|
||||||
`occurred_at` text DEFAULT (current_timestamp) NOT NULL
|
`occurred_at` text DEFAULT (current_timestamp) NOT NULL
|
||||||
);
|
);
|
||||||
--> statement-breakpoint
|
--> statement-breakpoint
|
||||||
CREATE TABLE `lane_devices` (
|
CREATE TABLE `devices` (
|
||||||
`id` text PRIMARY KEY NOT NULL,
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
`lane` integer NOT NULL,
|
|
||||||
`category` text NOT NULL,
|
`category` text NOT NULL,
|
||||||
`driver_id` text NOT NULL,
|
`driver_id` text NOT NULL,
|
||||||
`config` text NOT NULL,
|
`config` text NOT NULL,
|
||||||
@@ -33,7 +31,6 @@ CREATE TABLE `ledger_events` (
|
|||||||
`index` integer NOT NULL,
|
`index` integer NOT NULL,
|
||||||
`type` text NOT NULL,
|
`type` text NOT NULL,
|
||||||
`direction` text,
|
`direction` text,
|
||||||
`lane` integer NOT NULL,
|
|
||||||
`source` text,
|
`source` text,
|
||||||
`identity` text,
|
`identity` text,
|
||||||
`payload` text,
|
`payload` text,
|
||||||
@@ -70,7 +67,6 @@ CREATE TABLE `permits` (
|
|||||||
--> statement-breakpoint
|
--> statement-breakpoint
|
||||||
CREATE TABLE `sessions` (
|
CREATE TABLE `sessions` (
|
||||||
`id` text PRIMARY KEY NOT NULL,
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
`lane` integer,
|
|
||||||
`identity` text,
|
`identity` text,
|
||||||
`source` text,
|
`source` text,
|
||||||
`permit_id` text,
|
`permit_id` text,
|
||||||
@@ -85,6 +81,22 @@ CREATE TABLE `setup_state` (
|
|||||||
`completed_at` text
|
`completed_at` text
|
||||||
);
|
);
|
||||||
--> statement-breakpoint
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `site_config` (
|
||||||
|
`id` integer PRIMARY KEY NOT NULL,
|
||||||
|
`capacity` integer,
|
||||||
|
`updated_at` text DEFAULT (current_timestamp) NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `snapshots` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`direction` text NOT NULL,
|
||||||
|
`device_id` text,
|
||||||
|
`identity` text,
|
||||||
|
`content_type` text NOT NULL,
|
||||||
|
`bytes` blob NOT NULL,
|
||||||
|
`captured_at` text NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
CREATE TABLE `tariff_versions` (
|
CREATE TABLE `tariff_versions` (
|
||||||
`id` text PRIMARY KEY NOT NULL,
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
`tariff_id` text NOT NULL,
|
`tariff_id` text NOT NULL,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"version": "6",
|
"version": "6",
|
||||||
"dialect": "sqlite",
|
"dialect": "sqlite",
|
||||||
"id": "cd09c11f-4306-4ac8-a335-7c050d080ab6",
|
"id": "a6d81d46-c4a4-4ee7-8565-ec012bbe0252",
|
||||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||||
"tables": {
|
"tables": {
|
||||||
"blocklist": {
|
"blocklist": {
|
||||||
@@ -82,13 +82,6 @@
|
|||||||
"notNull": false,
|
"notNull": false,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
"lane": {
|
|
||||||
"name": "lane",
|
|
||||||
"type": "integer",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": false,
|
|
||||||
"autoincrement": false
|
|
||||||
},
|
|
||||||
"category": {
|
"category": {
|
||||||
"name": "category",
|
"name": "category",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
@@ -125,8 +118,8 @@
|
|||||||
"uniqueConstraints": {},
|
"uniqueConstraints": {},
|
||||||
"checkConstraints": {}
|
"checkConstraints": {}
|
||||||
},
|
},
|
||||||
"lane_devices": {
|
"devices": {
|
||||||
"name": "lane_devices",
|
"name": "devices",
|
||||||
"columns": {
|
"columns": {
|
||||||
"id": {
|
"id": {
|
||||||
"name": "id",
|
"name": "id",
|
||||||
@@ -135,13 +128,6 @@
|
|||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
"lane": {
|
|
||||||
"name": "lane",
|
|
||||||
"type": "integer",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true,
|
|
||||||
"autoincrement": false
|
|
||||||
},
|
|
||||||
"category": {
|
"category": {
|
||||||
"name": "category",
|
"name": "category",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
@@ -217,13 +203,6 @@
|
|||||||
"notNull": false,
|
"notNull": false,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
"lane": {
|
|
||||||
"name": "lane",
|
|
||||||
"type": "integer",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true,
|
|
||||||
"autoincrement": false
|
|
||||||
},
|
|
||||||
"source": {
|
"source": {
|
||||||
"name": "source",
|
"name": "source",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
@@ -436,13 +415,6 @@
|
|||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
"lane": {
|
|
||||||
"name": "lane",
|
|
||||||
"type": "integer",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": false,
|
|
||||||
"autoincrement": false
|
|
||||||
},
|
|
||||||
"identity": {
|
"identity": {
|
||||||
"name": "identity",
|
"name": "identity",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
@@ -524,6 +496,97 @@
|
|||||||
"uniqueConstraints": {},
|
"uniqueConstraints": {},
|
||||||
"checkConstraints": {}
|
"checkConstraints": {}
|
||||||
},
|
},
|
||||||
|
"site_config": {
|
||||||
|
"name": "site_config",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"capacity": {
|
||||||
|
"name": "capacity",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(current_timestamp)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"snapshots": {
|
||||||
|
"name": "snapshots",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"direction": {
|
||||||
|
"name": "direction",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"device_id": {
|
||||||
|
"name": "device_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"identity": {
|
||||||
|
"name": "identity",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"content_type": {
|
||||||
|
"name": "content_type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"bytes": {
|
||||||
|
"name": "bytes",
|
||||||
|
"type": "blob",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"captured_at": {
|
||||||
|
"name": "captured_at",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
"tariff_versions": {
|
"tariff_versions": {
|
||||||
"name": "tariff_versions",
|
"name": "tariff_versions",
|
||||||
"columns": {
|
"columns": {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
{
|
{
|
||||||
"idx": 0,
|
"idx": 0,
|
||||||
"version": "6",
|
"version": "6",
|
||||||
"when": 1781539958008,
|
"when": 1781632874398,
|
||||||
"tag": "0000_baseline",
|
"tag": "0000_baseline",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
|
|||||||
+55
-16
@@ -1,5 +1,5 @@
|
|||||||
import { sql } from "drizzle-orm";
|
import { sql } from "drizzle-orm";
|
||||||
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
import { blob, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
||||||
|
|
||||||
// Schema notes:
|
// Schema notes:
|
||||||
// - TWO event streams, deliberately separate (see wiki/decisions/event-streams-split.md):
|
// - TWO event streams, deliberately separate (see wiki/decisions/event-streams-split.md):
|
||||||
@@ -39,7 +39,6 @@ export const ledgerEvents = sqliteTable("ledger_events", {
|
|||||||
index: integer("index").notNull().unique(),
|
index: integer("index").notNull().unique(),
|
||||||
type: text("type").notNull(),
|
type: text("type").notNull(),
|
||||||
direction: text("direction", { enum: ["entry", "exit"] }),
|
direction: text("direction", { enum: ["entry", "exit"] }),
|
||||||
lane: integer("lane").notNull(),
|
|
||||||
source: text("source"),
|
source: text("source"),
|
||||||
identity: text("identity"),
|
identity: text("identity"),
|
||||||
// Type-specific business payload (JSON). Signed as part of the canonical form.
|
// Type-specific business payload (JSON). Signed as part of the canonical form.
|
||||||
@@ -56,13 +55,12 @@ export const ledgerEvents = sqliteTable("ledger_events", {
|
|||||||
|
|
||||||
// --- Device telemetry (unsigned, prunable) -------------------------------
|
// --- Device telemetry (unsigned, prunable) -------------------------------
|
||||||
// Operational monitoring, NOT anti-fraud: relay fired, printer paper-out, camera
|
// Operational monitoring, NOT anti-fraud: relay fired, printer paper-out, camera
|
||||||
// offline, reader read, raw input edges. Keyed to a lane_devices instance; lane is
|
// offline, reader read, raw input edges. Keyed to a `devices` instance. No
|
||||||
// resolved via the LaneMap. No prevHash/signature — this stream may rotate/prune.
|
// prevHash/signature — this stream may rotate/prune.
|
||||||
export const deviceEvents = sqliteTable("device_events", {
|
export const deviceEvents = sqliteTable("device_events", {
|
||||||
id: text("id").primaryKey(),
|
id: text("id").primaryKey(),
|
||||||
// The lane_devices instance that produced it (raw provenance).
|
// The `devices` instance that produced it (raw provenance).
|
||||||
deviceId: text("device_id"),
|
deviceId: text("device_id"),
|
||||||
lane: integer("lane"),
|
|
||||||
category: text("category", {
|
category: text("category", {
|
||||||
enum: ["access", "reader", "camera", "printer"],
|
enum: ["access", "reader", "camera", "printer"],
|
||||||
}),
|
}),
|
||||||
@@ -75,13 +73,42 @@ export const deviceEvents = sqliteTable("device_events", {
|
|||||||
.default(sql`(current_timestamp)`),
|
.default(sql`(current_timestamp)`),
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Per-lane device assignments (first-run setup) -----------------------
|
// --- Camera snapshots (unsigned, prunable, blob-in-DB) -------------------
|
||||||
// One row per (lane, category, instance). `driverId` references a driver in the
|
// An entry/exit snapshot captured asynchronously AFTER the barrier opens — evidence,
|
||||||
// @parking/devices registry; `config` is that driver's JSON config. Keeps the
|
// not a gate (camera failure never blocks an open; see entry/exit flows). Stored as a
|
||||||
// system device-agnostic + admin-configurable. See device-registry.md, first-run-setup.md.
|
// BLOB so the appliance keeps a single backed-up file with nothing scattered on disk.
|
||||||
export const laneDevices = sqliteTable("lane_devices", {
|
// Kept in its own table (not inline in device_events) so the hot telemetry scans don't
|
||||||
|
// drag image bytes, and so images can be pruned independently. The signed
|
||||||
|
// vehicle_entry/exit references a snapshot by `id` in its payload — the image is an
|
||||||
|
// independent record (anti-fraud), unsigned and prunable. Retention policy is an open
|
||||||
|
// question — see wiki/concepts/entry-exit-points.md. Served via GET /api/snapshots/:id.
|
||||||
|
export const snapshots = sqliteTable("snapshots", {
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
direction: text("direction", { enum: ["entry", "exit"] }).notNull(),
|
||||||
|
// The camera `devices` instance that captured it (raw provenance).
|
||||||
|
deviceId: text("device_id"),
|
||||||
|
// The session/credential ref (ticket id, plate, permit) — links to the ledger event.
|
||||||
|
identity: text("identity"),
|
||||||
|
contentType: text("content_type").notNull(),
|
||||||
|
bytes: blob("bytes").notNull().$type<Buffer>(),
|
||||||
|
capturedAt: text("captured_at").notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Device assignments (first-run setup) --------------------------------
|
||||||
|
// One row per device instance. `driverId` references a driver in the @parking/devices
|
||||||
|
// registry; `config` is that driver's JSON config. There is NO lane: a parking lot is
|
||||||
|
// one pool of spaces with a flexible set of entry/exit points. Direction lives INSIDE
|
||||||
|
// the config, per the hardware:
|
||||||
|
// - access controller: config.relays = [{ relay, direction: entry|exit|both, button? }]
|
||||||
|
// — one physical board has several relays; each relay opens one barrier in one
|
||||||
|
// direction (or both). `button` = the input terminal the entry button is wired to
|
||||||
|
// (transient entry trigger; absent = no button at that barrier).
|
||||||
|
// - reader / camera: config.controllerId + config.relay BIND it to the barrier it sits
|
||||||
|
// at; its direction is INHERITED from that relay. Unbound → falls back to a
|
||||||
|
// direction picked in config.
|
||||||
|
// See device-registry.md, first-run-setup.md, wiki/concepts/entry-exit-points.md.
|
||||||
|
export const devices = sqliteTable("devices", {
|
||||||
id: text("id").primaryKey(),
|
id: text("id").primaryKey(),
|
||||||
lane: integer("lane").notNull(),
|
|
||||||
category: text("category", {
|
category: text("category", {
|
||||||
enum: ["access", "reader", "camera", "printer"],
|
enum: ["access", "reader", "camera", "printer"],
|
||||||
}).notNull(),
|
}).notNull(),
|
||||||
@@ -101,6 +128,17 @@ export const setupState = sqliteTable("setup_state", {
|
|||||||
completedAt: text("completed_at"),
|
completedAt: text("completed_at"),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Single-row site settings (admin-configurable). The home for site-wide knobs;
|
||||||
|
// `capacity` is the nominal space count the FULL gate refuses transient entry at
|
||||||
|
// (null = no cap). See wiki/concepts/capacity-occupancy.md.
|
||||||
|
export const siteConfig = sqliteTable("site_config", {
|
||||||
|
id: integer("id").primaryKey(), // always 1
|
||||||
|
capacity: integer("capacity"), // null = no capacity limit
|
||||||
|
updatedAt: text("updated_at")
|
||||||
|
.notNull()
|
||||||
|
.default(sql`(current_timestamp)`),
|
||||||
|
});
|
||||||
|
|
||||||
// --- Tariffs (composable, versioned) -------------------------------------
|
// --- Tariffs (composable, versioned) -------------------------------------
|
||||||
// A `tariffs` row is a logical rate card; its pricing lives in immutable, effective-
|
// A `tariffs` row is a logical rate card; its pricing lives in immutable, effective-
|
||||||
// dated `tariff_versions`. Editing prices PUBLISHES a new version, never mutates one.
|
// dated `tariff_versions`. Editing prices PUBLISHES a new version, never mutates one.
|
||||||
@@ -109,8 +147,8 @@ export const setupState = sqliteTable("setup_state", {
|
|||||||
// `scope` lets multiple be added later without migration. See wiki/concepts/tariff.md.
|
// `scope` lets multiple be added later without migration. See wiki/concepts/tariff.md.
|
||||||
export const tariffs = sqliteTable("tariffs", {
|
export const tariffs = sqliteTable("tariffs", {
|
||||||
id: text("id").primaryKey(),
|
id: text("id").primaryKey(),
|
||||||
// Only "site" used now; "lane"/"zone" reserved for multi-tariff later.
|
// Only "site" used now; "zone" reserved for multi-tariff later.
|
||||||
scope: text("scope", { enum: ["site", "lane", "zone"] }).notNull().default("site"),
|
scope: text("scope", { enum: ["site", "zone"] }).notNull().default("site"),
|
||||||
name: text("name").notNull(),
|
name: text("name").notNull(),
|
||||||
createdAt: text("created_at")
|
createdAt: text("created_at")
|
||||||
.notNull()
|
.notNull()
|
||||||
@@ -193,7 +231,6 @@ export const blocklist = sqliteTable("blocklist", {
|
|||||||
export const sessions = sqliteTable("sessions", {
|
export const sessions = sqliteTable("sessions", {
|
||||||
// The session key = the entry's identity (ticket id or plate).
|
// The session key = the entry's identity (ticket id or plate).
|
||||||
id: text("id").primaryKey(),
|
id: text("id").primaryKey(),
|
||||||
lane: integer("lane"),
|
|
||||||
// Identity that opened the session, and how it was read.
|
// Identity that opened the session, and how it was read.
|
||||||
identity: text("identity"),
|
identity: text("identity"),
|
||||||
source: text("source"),
|
source: text("source"),
|
||||||
@@ -213,8 +250,10 @@ export const sessions = sqliteTable("sessions", {
|
|||||||
export type UserRow = typeof users.$inferSelect;
|
export type UserRow = typeof users.$inferSelect;
|
||||||
export type LedgerEventRow = typeof ledgerEvents.$inferSelect;
|
export type LedgerEventRow = typeof ledgerEvents.$inferSelect;
|
||||||
export type DeviceEventRow = typeof deviceEvents.$inferSelect;
|
export type DeviceEventRow = typeof deviceEvents.$inferSelect;
|
||||||
export type LaneDeviceRow = typeof laneDevices.$inferSelect;
|
export type SnapshotRow = typeof snapshots.$inferSelect;
|
||||||
|
export type DeviceRow = typeof devices.$inferSelect;
|
||||||
export type SetupStateRow = typeof setupState.$inferSelect;
|
export type SetupStateRow = typeof setupState.$inferSelect;
|
||||||
|
export type SiteConfigRow = typeof siteConfig.$inferSelect;
|
||||||
export type TariffRow = typeof tariffs.$inferSelect;
|
export type TariffRow = typeof tariffs.$inferSelect;
|
||||||
export type TariffVersionRow = typeof tariffVersions.$inferSelect;
|
export type TariffVersionRow = typeof tariffVersions.$inferSelect;
|
||||||
export type PermitRow = typeof permits.$inferSelect;
|
export type PermitRow = typeof permits.$inferSelect;
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import type { AccessControlDevice, DeviceHealth } from "../interfaces.js";
|
||||||
|
import type { AccessDriver, DeviceConfig } from "../registry.js";
|
||||||
|
import { stubLog } from "./common.js";
|
||||||
|
|
||||||
|
// Stub access controller — a no-op barrier for BENCH TESTING the entry/exit/permit
|
||||||
|
// flows without real relay hardware. `pulseOpen` just logs "intent to open"; it
|
||||||
|
// performs no device I/O, so it can stand in on a lane while the real
|
||||||
|
// [[dingtian-relay]] isn't connected. NOT for production. See first-run-setup.md.
|
||||||
|
|
||||||
|
class StubAccess implements AccessControlDevice {
|
||||||
|
readonly driverId = "stub-access";
|
||||||
|
constructor(_config: DeviceConfig) {}
|
||||||
|
async connect(): Promise<void> {}
|
||||||
|
async disconnect(): Promise<void> {}
|
||||||
|
async healthCheck(): Promise<DeviceHealth> {
|
||||||
|
return { status: "ready", detail: "stub (no real barrier)" };
|
||||||
|
}
|
||||||
|
async pulseOpen(doorId: number): Promise<void> {
|
||||||
|
stubLog(this.driverId, `pulseOpen door ${doorId} (stub — no relay fired)`);
|
||||||
|
}
|
||||||
|
async getDoorStatus(): Promise<"open" | "closed"> {
|
||||||
|
return "closed";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const stubAccessDriver: AccessDriver = {
|
||||||
|
id: "stub-access",
|
||||||
|
category: "access",
|
||||||
|
label: "Stub barrier (bench testing — no relay)",
|
||||||
|
description:
|
||||||
|
"A no-op access controller for testing the flows without hardware. pulseOpen only logs; no relay is fired. Not for production.",
|
||||||
|
transports: ["tcp-ip"],
|
||||||
|
configFields: [],
|
||||||
|
create: (c) => new StubAccess(c),
|
||||||
|
};
|
||||||
@@ -61,10 +61,10 @@ class HttpCamera implements CameraDevice {
|
|||||||
const res = await this.#get();
|
const res = await this.#get();
|
||||||
if (res.status !== 200) {
|
if (res.status !== 200) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`${this.driverId} snapshot failed (lane=${ctx.lane} ${ctx.direction}): HTTP ${res.status}`,
|
`${this.driverId} snapshot failed (${ctx.direction}): HTTP ${res.status}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
stubLog(this.driverId, `captureSnapshot lane=${ctx.lane} ${ctx.direction} (${res.body.length} bytes)`);
|
stubLog(this.driverId, `captureSnapshot ${ctx.direction} (${res.body.length} bytes)`);
|
||||||
return {
|
return {
|
||||||
bytes: res.body,
|
bytes: res.body,
|
||||||
contentType: res.contentType || "image/jpeg",
|
contentType: res.contentType || "image/jpeg",
|
||||||
|
|||||||
@@ -3,9 +3,10 @@
|
|||||||
|
|
||||||
import { registry } from "../registry.js";
|
import { registry } from "../registry.js";
|
||||||
import { dingtianDriver } from "./access-dingtian.js";
|
import { dingtianDriver } from "./access-dingtian.js";
|
||||||
|
import { stubAccessDriver } from "./access-stub.js";
|
||||||
import { dahuaDriver, hikvisionDriver } from "./camera.js";
|
import { dahuaDriver, hikvisionDriver } from "./camera.js";
|
||||||
import { rongtaDriver } from "./printer-rongta.js";
|
import { rongtaDriver } from "./printer-rongta.js";
|
||||||
import { tcpipReaderDriver, wiegandReaderDriver } from "./reader.js";
|
import { geeQrReaderDriver, tcpipReaderDriver, wiegandReaderDriver } from "./reader.js";
|
||||||
|
|
||||||
let registered = false;
|
let registered = false;
|
||||||
|
|
||||||
@@ -14,8 +15,10 @@ export function registerBuiltinDrivers(): void {
|
|||||||
if (registered) return;
|
if (registered) return;
|
||||||
registered = true;
|
registered = true;
|
||||||
registry.register(dingtianDriver);
|
registry.register(dingtianDriver);
|
||||||
|
registry.register(stubAccessDriver);
|
||||||
registry.register(wiegandReaderDriver);
|
registry.register(wiegandReaderDriver);
|
||||||
registry.register(tcpipReaderDriver);
|
registry.register(tcpipReaderDriver);
|
||||||
|
registry.register(geeQrReaderDriver);
|
||||||
registry.register(hikvisionDriver);
|
registry.register(hikvisionDriver);
|
||||||
registry.register(dahuaDriver);
|
registry.register(dahuaDriver);
|
||||||
registry.register(rongtaDriver);
|
registry.register(rongtaDriver);
|
||||||
@@ -23,8 +26,10 @@ export function registerBuiltinDrivers(): void {
|
|||||||
|
|
||||||
export {
|
export {
|
||||||
dingtianDriver,
|
dingtianDriver,
|
||||||
|
stubAccessDriver,
|
||||||
wiegandReaderDriver,
|
wiegandReaderDriver,
|
||||||
tcpipReaderDriver,
|
tcpipReaderDriver,
|
||||||
|
geeQrReaderDriver,
|
||||||
hikvisionDriver,
|
hikvisionDriver,
|
||||||
dahuaDriver,
|
dahuaDriver,
|
||||||
rongtaDriver,
|
rongtaDriver,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type {
|
|||||||
MonitorableDevice,
|
MonitorableDevice,
|
||||||
PrinterDevice,
|
PrinterDevice,
|
||||||
PrinterStatus,
|
PrinterStatus,
|
||||||
|
PrintReport,
|
||||||
TicketData,
|
TicketData,
|
||||||
} from "../interfaces.js";
|
} from "../interfaces.js";
|
||||||
import type { ConfigField, DeviceConfig, PrinterDriver } from "../registry.js";
|
import type { ConfigField, DeviceConfig, PrinterDriver } from "../registry.js";
|
||||||
@@ -44,6 +45,21 @@ function line(text = ""): Buffer {
|
|||||||
return Buffer.concat([Buffer.from(text, "ascii"), Buffer.from([LF])]);
|
return Buffer.concat([Buffer.from(text, "ascii"), Buffer.from([LF])]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Build the ESC/POS byte stream for a free-form text report (e.g. shift Z-report). */
|
||||||
|
function renderReport(report: PrintReport): Buffer {
|
||||||
|
return Buffer.concat([
|
||||||
|
INIT,
|
||||||
|
ALIGN_CENTER,
|
||||||
|
BOLD_ON,
|
||||||
|
line(report.title),
|
||||||
|
BOLD_OFF,
|
||||||
|
ALIGN_LEFT,
|
||||||
|
line(),
|
||||||
|
...report.lines.map((l) => line(l)),
|
||||||
|
FEED_AND_CUT,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
/** Build the full ESC/POS byte stream for an entry ticket. */
|
/** Build the full ESC/POS byte stream for an entry ticket. */
|
||||||
function renderTicket(data: TicketData): Buffer {
|
function renderTicket(data: TicketData): Buffer {
|
||||||
return Buffer.concat([
|
return Buffer.concat([
|
||||||
@@ -55,8 +71,6 @@ function renderTicket(data: TicketData): Buffer {
|
|||||||
DOUBLE_OFF,
|
DOUBLE_OFF,
|
||||||
BOLD_OFF,
|
BOLD_OFF,
|
||||||
line(),
|
line(),
|
||||||
line(`Lane ${data.lane}`),
|
|
||||||
line(),
|
|
||||||
BOLD_ON,
|
BOLD_ON,
|
||||||
line(data.ticketId),
|
line(data.ticketId),
|
||||||
BOLD_OFF,
|
BOLD_OFF,
|
||||||
@@ -201,7 +215,12 @@ class RongtaPrinter implements PrinterDevice, MonitorableDevice {
|
|||||||
|
|
||||||
async printTicket(data: TicketData): Promise<void> {
|
async printTicket(data: TicketData): Promise<void> {
|
||||||
await sendRaw(this.#host, this.#port, renderTicket(data), this.#timeout);
|
await sendRaw(this.#host, this.#port, renderTicket(data), this.#timeout);
|
||||||
stubLog(this.driverId, `printed ticket ${data.ticketId} (lane ${data.lane})`);
|
stubLog(this.driverId, `printed ticket ${data.ticketId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async printReport(report: PrintReport): Promise<void> {
|
||||||
|
await sendRaw(this.#host, this.#port, renderReport(report), this.#timeout);
|
||||||
|
stubLog(this.driverId, `printed report "${report.title}" (${report.lines.length} lines)`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -58,3 +58,28 @@ export const tcpipReaderDriver: ReaderDriver = {
|
|||||||
configFields: [hostField, portField(9000)],
|
configFields: [hostField, portField(9000)],
|
||||||
create: (c) => new StubReader("tcpip-reader", c),
|
create: (c) => new StubReader("tcpip-reader", c),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// GEE/Fondvision QR access reader (e.g. GEE-QR-ER80). A PUSH device: on each scan
|
||||||
|
// it HTTP-GETs our backend (/qa/mcardsea.<ext>) carrying its serial (cjihao); the
|
||||||
|
// backend resolves the lane by matching that serial to this device's `serial`
|
||||||
|
// config, decides, and replies the verdict (drives the beep). No host-side
|
||||||
|
// connection — the adapter is a stub; the real integration is the HTTP endpoint
|
||||||
|
// (apps/server routes/qr-reader.ts). See wiki/entities/gee-qr-er80.md.
|
||||||
|
export const geeQrReaderDriver: ReaderDriver = {
|
||||||
|
id: "gee-qr-reader",
|
||||||
|
category: "reader",
|
||||||
|
label: "GEE/Fondvision QR reader (HTTP push)",
|
||||||
|
description:
|
||||||
|
"QR/barcode access reader that HTTP-pushes each scan to the backend. Set its server IP/port to this host in the vendor tool; enter its serial here so scans resolve to this lane.",
|
||||||
|
transports: ["tcp-ip"],
|
||||||
|
configFields: [
|
||||||
|
{
|
||||||
|
key: "serial",
|
||||||
|
label: "Device serial (cjihao)",
|
||||||
|
type: "string",
|
||||||
|
required: true,
|
||||||
|
help: "The reader's serial as it reports in each scan (the `cjihao` field). Used to map scans to this lane.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
create: (c) => new StubReader("gee-qr-reader", c),
|
||||||
|
};
|
||||||
|
|||||||
@@ -11,8 +11,10 @@ export { setDeviceLogSink, type DeviceLogSink } from "./drivers/common.js";
|
|||||||
export {
|
export {
|
||||||
registerBuiltinDrivers,
|
registerBuiltinDrivers,
|
||||||
dingtianDriver,
|
dingtianDriver,
|
||||||
|
stubAccessDriver,
|
||||||
wiegandReaderDriver,
|
wiegandReaderDriver,
|
||||||
tcpipReaderDriver,
|
tcpipReaderDriver,
|
||||||
|
geeQrReaderDriver,
|
||||||
hikvisionDriver,
|
hikvisionDriver,
|
||||||
dahuaDriver,
|
dahuaDriver,
|
||||||
rongtaDriver,
|
rongtaDriver,
|
||||||
|
|||||||
@@ -174,7 +174,6 @@ export interface CameraDevice extends Device {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface SnapshotContext {
|
export interface SnapshotContext {
|
||||||
readonly lane: number;
|
|
||||||
readonly direction: "entry" | "exit";
|
readonly direction: "entry" | "exit";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,12 +192,20 @@ export interface Snapshot {
|
|||||||
// --- Printers (ticket dispenser / booth printer) -------------------------
|
// --- Printers (ticket dispenser / booth printer) -------------------------
|
||||||
export interface TicketData {
|
export interface TicketData {
|
||||||
readonly ticketId: string;
|
readonly ticketId: string;
|
||||||
readonly lane: number;
|
|
||||||
readonly issuedAt: string; // ISO-8601
|
readonly issuedAt: string; // ISO-8601
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PrinterDevice extends Device {
|
export interface PrinterDevice extends Device {
|
||||||
printTicket(data: TicketData): Promise<void>;
|
printTicket(data: TicketData): Promise<void>;
|
||||||
|
/** Print a free-form text report (a shift Z-report, a receipt). `lines` are
|
||||||
|
* printed as-is; the driver adds a header/cut. Kept generic so the business
|
||||||
|
* layer composes the content. See wiki/concepts/shift.md. */
|
||||||
|
printReport(report: PrintReport): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PrintReport {
|
||||||
|
readonly title: string;
|
||||||
|
readonly lines: readonly string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Live printer status (consumable / mechanical faults) ----------------
|
// --- Live printer status (consumable / mechanical faults) ----------------
|
||||||
|
|||||||
@@ -27,8 +27,19 @@ export interface ConfigField {
|
|||||||
readonly help?: string;
|
readonly help?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A JSON-serializable config value. Mostly flat scalars (host, port, credentials),
|
||||||
|
* but some configs carry nested structure — e.g. an access controller's
|
||||||
|
* `relays: [{ relay, direction, button? }]` map. See entry-exit-points.md. */
|
||||||
|
export type ConfigValue =
|
||||||
|
| string
|
||||||
|
| number
|
||||||
|
| boolean
|
||||||
|
| null
|
||||||
|
| ConfigValue[]
|
||||||
|
| { [k: string]: ConfigValue };
|
||||||
|
|
||||||
/** Opaque per-instance config the admin fills in (host, port, credentials…). */
|
/** Opaque per-instance config the admin fills in (host, port, credentials…). */
|
||||||
export type DeviceConfig = Record<string, string | number | boolean>;
|
export type DeviceConfig = Record<string, ConfigValue>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A driver: metadata describing a supported device model/family, the config
|
* A driver: metadata describing a supported device model/family, the config
|
||||||
|
|||||||
@@ -48,6 +48,9 @@ export type LedgerEventType =
|
|||||||
// (loop/sensor) — reconciled against each other.
|
// (loop/sensor) — reconciled against each other.
|
||||||
| "barrier_open_command"
|
| "barrier_open_command"
|
||||||
| "barrier_open_observed"
|
| "barrier_open_observed"
|
||||||
|
// Manned-mode shift boundary: an operator takes over (shift_open) / hands over
|
||||||
|
// with a takings summary (shift_z_report). See wiki/concepts/shift.md.
|
||||||
|
| "shift_open"
|
||||||
| "shift_z_report"
|
| "shift_z_report"
|
||||||
| "anomaly";
|
| "anomaly";
|
||||||
|
|
||||||
@@ -113,6 +116,103 @@ export interface TariffBlock {
|
|||||||
readonly priceMinorPerIncrement: number;
|
readonly priceMinorPerIncrement: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute the parking fee (integer minor units) for a stay, from a TariffStructure.
|
||||||
|
* PURE + deterministic + offline — the pay station calls it with asOf = now; the
|
||||||
|
* result is fixed into a signed `payment` event, so it must be reproducible.
|
||||||
|
*
|
||||||
|
* Algorithm (wiki/concepts/tariff.md): round duration UP to incrementMin; free if
|
||||||
|
* within entry grace; else walk the stay one rolling-24h segment at a time, charging
|
||||||
|
* each increment at its block's rate (blocks consumed in order by cumulative minutes),
|
||||||
|
* capping each segment at dailyCapMinor. Times are ISO-8601; bad input → 0 (caller
|
||||||
|
* validates the tariff exists first).
|
||||||
|
*/
|
||||||
|
export function computeFee(
|
||||||
|
enteredAt: string,
|
||||||
|
asOf: string,
|
||||||
|
tariff: TariffStructure,
|
||||||
|
): number {
|
||||||
|
const ms = Date.parse(asOf) - Date.parse(enteredAt);
|
||||||
|
if (!Number.isFinite(ms) || ms <= 0) return 0;
|
||||||
|
const rawMinutes = ms / 60_000;
|
||||||
|
// Grace uses the RAW duration (a 10-min stay is free even if the increment is
|
||||||
|
// 60 min — otherwise rounding-up would defeat the grace window).
|
||||||
|
if (rawMinutes <= tariff.gracePeriodEntryMin) return 0;
|
||||||
|
const inc = Math.max(1, tariff.incrementMin);
|
||||||
|
const minutes = Math.ceil(rawMinutes / inc) * inc; // round UP to the increment
|
||||||
|
|
||||||
|
const DAY = 24 * 60;
|
||||||
|
let total = 0;
|
||||||
|
for (let segStart = 0; segStart < minutes; segStart += DAY) {
|
||||||
|
const segEnd = Math.min(segStart + DAY, minutes);
|
||||||
|
let segFee = 0;
|
||||||
|
// The block ladder RESETS each rolling-24h day: `within` is minutes elapsed
|
||||||
|
// WITHIN this day, so day 2 starts at the first block again (decision 2026-06-15).
|
||||||
|
for (let within = 0; segStart + within < segEnd; within += inc) {
|
||||||
|
segFee += rateAt(tariff.blocks, within);
|
||||||
|
}
|
||||||
|
if (tariff.dailyCapMinor != null) segFee = Math.min(segFee, tariff.dailyCapMinor);
|
||||||
|
total += segFee;
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate an admin-authored tariff structure. Returns [] if valid, else a list
|
||||||
|
* of human-readable problems. Pure — used by the composer route (and any caller)
|
||||||
|
* so a malformed rate card can never be published. See wiki/concepts/tariff.md.
|
||||||
|
*/
|
||||||
|
export function validateTariffStructure(s: unknown): string[] {
|
||||||
|
const errs: string[] = [];
|
||||||
|
if (!s || typeof s !== "object") return ["structure must be an object"];
|
||||||
|
const t = s as Partial<TariffStructure>;
|
||||||
|
|
||||||
|
const nonNegInt = (v: unknown, label: string) => {
|
||||||
|
if (typeof v !== "number" || !Number.isInteger(v) || v < 0) errs.push(`${label} must be a non-negative integer`);
|
||||||
|
};
|
||||||
|
nonNegInt(t.gracePeriodEntryMin, "gracePeriodEntryMin");
|
||||||
|
nonNegInt(t.gracePeriodExitMin, "gracePeriodExitMin");
|
||||||
|
nonNegInt(t.lostTicketMinor, "lostTicketMinor");
|
||||||
|
if (typeof t.incrementMin !== "number" || !Number.isInteger(t.incrementMin) || t.incrementMin < 1) {
|
||||||
|
errs.push("incrementMin must be a positive integer");
|
||||||
|
}
|
||||||
|
if (t.dailyCapMinor != null) nonNegInt(t.dailyCapMinor, "dailyCapMinor");
|
||||||
|
if (t.overstay !== "reprice") errs.push('overstay must be "reprice"');
|
||||||
|
|
||||||
|
if (!Array.isArray(t.blocks) || t.blocks.length === 0) {
|
||||||
|
errs.push("blocks must be a non-empty array");
|
||||||
|
} else {
|
||||||
|
let prevBound = 0;
|
||||||
|
t.blocks.forEach((b, i) => {
|
||||||
|
const last = i === t.blocks!.length - 1;
|
||||||
|
nonNegInt(b?.priceMinorPerIncrement, `blocks[${i}].priceMinorPerIncrement`);
|
||||||
|
if (b?.uptoMin == null) {
|
||||||
|
if (!last) errs.push(`blocks[${i}] is open-ended (uptoMin null) but not last`);
|
||||||
|
} else {
|
||||||
|
if (typeof b.uptoMin !== "number" || !Number.isInteger(b.uptoMin) || b.uptoMin <= prevBound) {
|
||||||
|
errs.push(`blocks[${i}].uptoMin must be an integer greater than the previous block's bound (${prevBound})`);
|
||||||
|
} else {
|
||||||
|
prevBound = b.uptoMin;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return errs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Price of the increment that starts at `cumulativeMin` — the block whose range
|
||||||
|
* [prevUpto, uptoMin) contains it; the open-ended (uptoMin=null) block catches the rest. */
|
||||||
|
function rateAt(blocks: readonly TariffBlock[], cumulativeMin: number): number {
|
||||||
|
let prev = 0;
|
||||||
|
for (const b of blocks) {
|
||||||
|
if (b.uptoMin == null || cumulativeMin < b.uptoMin) return b.priceMinorPerIncrement;
|
||||||
|
prev = b.uptoMin;
|
||||||
|
void prev;
|
||||||
|
}
|
||||||
|
// No open-ended block and past the last bound: charge the last block's rate.
|
||||||
|
return blocks.length ? blocks[blocks.length - 1]!.priceMinorPerIncrement : 0;
|
||||||
|
}
|
||||||
|
|
||||||
export const ROLES: readonly Role[] = [
|
export const ROLES: readonly Role[] = [
|
||||||
"admin",
|
"admin",
|
||||||
"operator",
|
"operator",
|
||||||
|
|||||||
@@ -89,16 +89,13 @@ The [[parking-session]] domain folds over these **signed ledger** events:
|
|||||||
A session is a **projection** over this chain, never a mutable table — the same anti-fraud reason
|
A session is a **projection** over this chain, never a mutable table — the same anti-fraud reason
|
||||||
the chain exists. See [[parking-session]].
|
the chain exists. See [[parking-session]].
|
||||||
|
|
||||||
### ⚠️ As-built vs. the table split (pending)
|
### As-built (table split done)
|
||||||
|
|
||||||
The current code records Dingtian **input (button) pushes** as `input_received` rows **in the
|
The split above is implemented: raw Dingtian **input (button) pushes** are **device telemetry** in
|
||||||
signed chain** (with `lane` resolved via the `LaneMap`, `source` null, device provenance in
|
**`device_events`** (unsigned, prunable), keyed to the firing `devices` instance. Only the business
|
||||||
`identity`). Per the 2026-06-15 split (above), a raw button press is **device telemetry** and
|
`vehicle_entry` the press drives is signed into **`ledger_events`**. The signed events carry **no
|
||||||
belongs in **`device_events`**, *not* the signed ledger — only the business `vehicle_entry` it
|
`lane`** — the pool-of-spaces model has none (dropped 2026-06-16; see [[entry-exit-points]]), and
|
||||||
drives gets signed. So `input_received`-in-the-ledger is **transitional**; the pending refactor
|
the canonical form bumped `sw-hmac-v1` → `sw-hmac-v2` accordingly.
|
||||||
moves raw inputs to `device_events` and renames the chain table to `ledger_events`. (`LaneMap`
|
|
||||||
lane-resolution and the "never stamp `lane: 0` for an unmapped device" rule carry over to whichever
|
|
||||||
stream records the event.)
|
|
||||||
|
|
||||||
### ⚠️ Limitation: the log captures HOST-ORIGINATED actions only
|
### ⚠️ Limitation: the log captures HOST-ORIGINATED actions only
|
||||||
|
|
||||||
|
|||||||
@@ -41,8 +41,27 @@ over, stacked beyond the marked count) instead of refusing. So the FULL gate is
|
|||||||
(refuse vs. valet-accept), set by the operator per site. Valet is a manned-mode feature with its
|
(refuse vs. valet-accept), set by the operator per site. Valet is a manned-mode feature with its
|
||||||
own custody/session shape — see [[valet-overcapacity]] (deferred).
|
own custody/session shape — see [[valet-overcapacity]] (deferred).
|
||||||
|
|
||||||
|
## As-built (2026-06-16)
|
||||||
|
|
||||||
|
- **Occupancy** = `occupancyCount` (`apps/server/src/occupancy.ts`): a fold over the ledger —
|
||||||
|
entries minus exits per identity, count those `> 0`. `getOccupancy` returns `{count, capacity,
|
||||||
|
free, full}`.
|
||||||
|
- **Capacity** is a single-row `site_config` table (admin-set; `null` = uncapped). Routes
|
||||||
|
(`routes/site.ts`): `GET /api/occupancy` + `GET /api/site-config` (any role), `PUT /api/site-config`
|
||||||
|
(admin; non-negative int or null).
|
||||||
|
- **FULL gate** is in the **transient entry flow**: `occupancy.full` → refuse (no ticket, no
|
||||||
|
`vehicle_entry`, no open) + signed `anomaly`. **Permit entry is NOT gated** here — subscribers are
|
||||||
|
admitted past transient-full (their own `maxConcurrent` still applies); occupancy can read
|
||||||
|
over-capacity (`free` negative) when permits enter a full lot, as intended.
|
||||||
|
- **UI** `SiteSettings`: live occupancy + FULL badge (everyone); capacity editor (admin).
|
||||||
|
- Verified: fill to cap → 3rd transient refused; permit still admitted past full; exit frees a
|
||||||
|
slot; RBAC (operator can't set capacity); verifyChain ok. Physical FULL-sign relay output is
|
||||||
|
**deferred** (needs a sign device).
|
||||||
|
|
||||||
## Open
|
## Open
|
||||||
|
|
||||||
- Zone/level granularity at launch vs. single capacity number.
|
- Zone/level granularity at launch vs. single capacity number.
|
||||||
- Reserve-for-permits threshold.
|
- Reserve-for-permits **threshold** (a soft transient cap below the hard capacity) — currently
|
||||||
|
permits are simply ungated; a tunable threshold is the richer version.
|
||||||
|
- Physical FULL-sign relay output (a sign-device role).
|
||||||
- The valet over-capacity mode + custody model ([[valet-overcapacity]]).
|
- The valet over-capacity mode + custody model ([[valet-overcapacity]]).
|
||||||
|
|||||||
@@ -33,6 +33,6 @@ principle. The choice of *which* adapter to trust is the [[trust-boundary]] deci
|
|||||||
|
|
||||||
> **In practice** the adapters are made *selectable*: a [[device-registry]] catalogs the
|
> **In practice** the adapters are made *selectable*: a [[device-registry]] catalogs the
|
||||||
> supported drivers (ZKTeco / ESP32 relay, Wiegand / TCP-IP readers, Hikvision / Dahua cameras),
|
> supported drivers (ZKTeco / ESP32 relay, Wiegand / TCP-IP readers, Hikvision / Dahua cameras),
|
||||||
> and the admin assigns one per lane during [[first-run-setup]]. Adding hardware support = one
|
> and the admin assigns instances during [[first-run-setup]]. Adding hardware support = one
|
||||||
> more registered driver, no business-logic change. (The implemented interfaces add a
|
> more registered driver, no business-logic change. (The implemented interfaces add a
|
||||||
> `CameraDevice` for entry/exit snapshots alongside reader/relay/printer.)
|
> `CameraDevice` for entry/exit snapshots alongside reader/relay/printer.)
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ replies), so the driver **serializes** all controller I/O. Override the broadcas
|
|||||||
2. Admin clicks **Scan** → `GET /api/setup/discover/:driverId` (admin-only).
|
2. Admin clicks **Scan** → `GET /api/setup/discover/:driverId` (admin-only).
|
||||||
3. The server runs `discover()` and **health-checks each found device** so the admin sees
|
3. The server runs `discover()` and **health-checks each found device** so the admin sees
|
||||||
reachability before assigning.
|
reachability before assigning.
|
||||||
4. Selecting a result **auto-fills serial + host**; the admin then assigns it to a lane.
|
4. Selecting a result **auto-fills serial + host**; the admin then assigns + binds it.
|
||||||
|
|
||||||
## Deployment notes
|
## Deployment notes
|
||||||
|
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ diagnostics, and live booth status — **not** anti-fraud.
|
|||||||
ledger's integrity machinery.
|
ledger's integrity machinery.
|
||||||
- **Disposable** — high-volume and churny; **may rotate/prune** on a retention policy (the ledger
|
- **Disposable** — high-volume and churny; **may rotate/prune** on a retention policy (the ledger
|
||||||
never does).
|
never does).
|
||||||
- **Device-keyed** — references the `lane_devices` instance; `lane` resolved via the same `LaneMap`
|
- **Device-keyed** — references the `devices` instance (raw device provenance). No `lane`
|
||||||
as before. Stores raw device provenance.
|
(pool-of-spaces model — see [[entry-exit-points]]).
|
||||||
|
|
||||||
## The boundary that matters
|
## The boundary that matters
|
||||||
|
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ neither is the real boundary:
|
|||||||
|
|
||||||
- **Relay control (host → device)** — UDP, now via the Dingtian **binary protocol on :60000 with a
|
- **Relay control (host → device)** — UDP, now via the Dingtian **binary protocol on :60000 with a
|
||||||
`relay_pw`** (the only authenticated relay option; the string protocol has none). Set on the
|
`relay_pw`** (the only authenticated relay option; the string protocol has none). Set on the
|
||||||
device + stored in `lane_devices` by the harden step (below).
|
device + stored in `devices` by the harden step (below).
|
||||||
- **Input push (device → host)** — guarded by **HTTP Digest auth** + a **source-IP allowlist**.
|
- **Input push (device → host)** — guarded by **HTTP Digest auth** + a **source-IP allowlist**.
|
||||||
- **The real guarantee is the signed log:** every barrier open is a host decision, recorded as a
|
- **The real guarantee is the signed log:** every barrier open is a host decision, recorded as a
|
||||||
signed event BEFORE the relay fires ([[append-only-event-chain]]). An out-of-band open (which a
|
signed event BEFORE the relay fires ([[append-only-event-chain]]). An out-of-band open (which a
|
||||||
@@ -55,7 +55,7 @@ fix preconditions (disable `input_link_relay`) → **harden** → set up input p
|
|||||||
capability ([[device-registry|HardenableDevice]]):
|
capability ([[device-registry|HardenableDevice]]):
|
||||||
|
|
||||||
- **Sets a random `relay_pw`** (1–9999) so binary relay commands need it; stores it in
|
- **Sets a random `relay_pw`** (1–9999) so binary relay commands need it; stores it in
|
||||||
`lane_devices` so the backend can keep commanding the relay.
|
`devices` so the backend can keep commanding the relay.
|
||||||
- **Disables unused protocol channels** (rs485, can, tcp×2, mqtt → `p:255`), keeping only UDP1
|
- **Disables unused protocol channels** (rs485, can, tcp×2, mqtt → `p:255`), keeping only UDP1
|
||||||
binary (relay control) + UDP2 string (status read) — fewer open doors.
|
binary (relay control) + UDP2 string (status read) — fewer open doors.
|
||||||
|
|
||||||
@@ -81,7 +81,7 @@ the clear. We **empirically tested the device** to pick the strongest achievable
|
|||||||
→ **HTTP Digest** (MD5, qop=auth). The password is never sent (only a nonce-keyed hash); nonces
|
→ **HTTP Digest** (MD5, qop=auth). The password is never sent (only a nonce-keyed hash); nonces
|
||||||
are **single-use** (replay resistance). Per-device credentials (`pushUser`/`pushPassword`) are
|
are **single-use** (replay resistance). Per-device credentials (`pushUser`/`pushPassword`) are
|
||||||
generated by the backend on **device assign**, written to the device's `input_link_url` config,
|
generated by the backend on **device assign**, written to the device's `input_link_url` config,
|
||||||
and stored in `lane_devices` — the admin never types a URL or secret. HTTPS would be stronger but
|
and stored in `devices` — the admin never types a URL or secret. HTTPS would be stronger but
|
||||||
the device can't do it here; Digest + the signed log is the practical answer on a flat network.
|
the device can't do it here; Digest + the signed log is the practical answer on a flat network.
|
||||||
See `apps/server/src/digest-auth.ts`.
|
See `apps/server/src/digest-auth.ts`.
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ updated: 2026-06-15
|
|||||||
How the system goes from "device-agnostic in principle" ([[device-adapter-pattern]]) to
|
How the system goes from "device-agnostic in principle" ([[device-adapter-pattern]]) to
|
||||||
"**admin picks the device at setup**" in practice. A **registry** holds a catalog of supported
|
"**admin picks the device at setup**" in practice. A **registry** holds a catalog of supported
|
||||||
**drivers**, grouped by category; the [[first-run-setup]] UI reads it so an
|
**drivers**, grouped by category; the [[first-run-setup]] UI reads it so an
|
||||||
operator can choose a device per lane and fill in its connection config.
|
operator can choose a device and fill in its connection config.
|
||||||
|
|
||||||
> Implementation-derived (from `packages/devices`), not the source doc.
|
> Implementation-derived (from `packages/devices`), not the source doc.
|
||||||
|
|
||||||
@@ -33,10 +33,11 @@ driver; **no business-logic change** — this is the [[device-adapter-pattern]]
|
|||||||
|
|
||||||
## Why a registry (not hard-coded wiring)
|
## Why a registry (not hard-coded wiring)
|
||||||
|
|
||||||
- The admin chooses between **multiple devices per category** at install time, per lane
|
- The admin chooses between **multiple devices per category** at install time
|
||||||
(mirrors the "mixable per lane" principle — see [[trust-boundary]], [[entry-exit-readers]]).
|
(a controller's relays mix entry/exit; readers bind to them — see [[entry-exit-points]],
|
||||||
|
[[trust-boundary]], [[entry-exit-readers]]).
|
||||||
- Config is **validated against the driver's declared fields** before persisting.
|
- Config is **validated against the driver's declared fields** before persisting.
|
||||||
- Selections persist in the `lane_devices` table and drive runtime adapter construction.
|
- Selections persist in the `devices` table and drive runtime adapter construction.
|
||||||
- Drivers may optionally implement **[[device-discovery]]** (`discover()`), so the admin can scan
|
- Drivers may optionally implement **[[device-discovery]]** (`discover()`), so the admin can scan
|
||||||
the LAN instead of typing connection details — no current driver uses it (the UHPPOTE did,
|
the LAN instead of typing connection details — no current driver uses it (the UHPPOTE did,
|
||||||
before removal; the [[dingtian-relay]] uses a fixed IP).
|
before removal; the [[dingtian-relay]] uses a fixed IP).
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
---
|
||||||
|
type: concept
|
||||||
|
tags: [parking, architecture, devices, setup]
|
||||||
|
sources: []
|
||||||
|
updated: 2026-06-16
|
||||||
|
---
|
||||||
|
|
||||||
|
# Entry / Exit Points (pool-of-spaces model)
|
||||||
|
|
||||||
|
A parking lot is **one pool of spaces** with a flexible set of **entry points** and **exit
|
||||||
|
points** — any number of each, in any combination (1 in + 1 out, 1 in + 2 out, 2 in + 1 out, …).
|
||||||
|
There is **no "lane"** concept anywhere in the system (dropped 2026-06-16 — see below).
|
||||||
|
|
||||||
|
## Direction lives on the relay, not the controller
|
||||||
|
|
||||||
|
An access controller (e.g. a [[dingtian-relay]] board) has **several relays** — each relay opens
|
||||||
|
one barrier. Direction is a property of **each relay**, declared in the controller's config:
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
// access `devices` row — one Dingtian board
|
||||||
|
config: {
|
||||||
|
host: "192.168.1.100",
|
||||||
|
relays: [
|
||||||
|
{ relay: 1, direction: "entry", button: 1 }, // entry barrier; entry button on input 1
|
||||||
|
{ relay: 2, direction: "exit" } // exit barrier; opened by a reader, no button
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `direction`: `entry` | `exit` | `both` (`both` = one barrier/relay serving in and out).
|
||||||
|
- `button`: the **input terminal** the transient **entry button** is wired to. Only entry/both
|
||||||
|
relays have one. Absent = no button at that barrier (subscriber/reader-driven only).
|
||||||
|
|
||||||
|
The four real layouts all fall out of this:
|
||||||
|
|
||||||
|
| Layout | Controllers | Relays |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 1 barrier, both directions | 1 | `{relay:1, both, button:1}` |
|
||||||
|
| 2 barriers, 1 board | 1 | `{relay:1, entry, button:1}`, `{relay:2, exit}` |
|
||||||
|
| 2 barriers far apart | 2 | board A `{relay:1, entry}`, board B `{relay:1, exit}` |
|
||||||
|
| 1 entry + 2 exit | 3 | A entry; B, C each exit |
|
||||||
|
|
||||||
|
## Readers / cameras BIND to a relay
|
||||||
|
|
||||||
|
A reader or camera points at the barrier it physically sits at, via its config:
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
config: { ...readerConfig, controllerId: "<access devices.id>", relay: 2 }
|
||||||
|
```
|
||||||
|
|
||||||
|
Its **direction is inherited** from that relay. So an exit read opens **exactly that relay** —
|
||||||
|
no ambiguity even with multiple exit barriers ("the relay at that reader", decided 2026-06-16).
|
||||||
|
Binding is optional: an unbound device falls back to a `config.direction` + the first relay
|
||||||
|
site-wide of that direction (keeps the single-barrier case trivial). LPR is a snapshot sink —
|
||||||
|
an ANPR service ([[opencv-anpr-service]]) POSTs the plate as a `plate` read to the reader
|
||||||
|
endpoint, flowing through the same dispatcher.
|
||||||
|
|
||||||
|
## Resolution (one module: `apps/server/src/device-resolve.ts`)
|
||||||
|
|
||||||
|
- **Button press** → `relayForButton(controllerId, terminal)` → the entry relay whose `button`
|
||||||
|
matches → entry flow → `pulseOpen(relay)`.
|
||||||
|
- **Reader/permit/LPR read** → `relayForDevice(reader)` → the bound relay → `pulseOpen(relay)`;
|
||||||
|
direction inherited.
|
||||||
|
- **Snapshots** → `devicesByDirection("camera", dir)` → every camera serving that direction.
|
||||||
|
|
||||||
|
A directional barrier that contradicts the car's open-session state (an exit barrier scanned by a
|
||||||
|
car not inside, or an entry barrier by a car already in) is a wrong-barrier / [[anti-passback]]
|
||||||
|
refusal. A `both` relay defers to session state.
|
||||||
|
|
||||||
|
## The flows
|
||||||
|
|
||||||
|
| Flow | Trigger | Opens |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Transient entry | entry **button** press | the entry relay (button-mapped) → ticket prints |
|
||||||
|
| Transient exit | voucher scan at exit reader | the exit relay (reader-bound), if paid+grace |
|
||||||
|
| Subscriber entry | QR/RFID/plate at entry reader | the entry relay (reader-bound), if permit valid |
|
||||||
|
| Subscriber exit | QR/RFID/plate at exit reader | the exit relay (reader-bound), if permit valid |
|
||||||
|
|
||||||
|
Every open also fires a [[camera snapshot|append-only-event-chain]] (async, never blocks the open).
|
||||||
|
|
||||||
|
## Why no lane
|
||||||
|
|
||||||
|
"Lane" was a leftover from a rows-of-gates mental model. It added nothing here:
|
||||||
|
|
||||||
|
- **Occupancy** is a site-wide fold over the ledger (entries − exits); it never grouped by lane.
|
||||||
|
- **Device grouping** is now done by the reader→relay binding, far more precisely than a lane key.
|
||||||
|
- **Anti-fraud** doesn't use it — the signed chain, the "open must match a signed event" check,
|
||||||
|
and [[reconciliation]] all work on *what happened*, not *which gate*. The relay's direction
|
||||||
|
already catches an exit firing an entry barrier, better than a lane number would.
|
||||||
|
|
||||||
|
Dropping it removed `lane` from `ledger_events`, `device_events`, `sessions`, and the device
|
||||||
|
table (renamed `lane_devices` → `devices`). Because `lane` was part of the **signed canonical
|
||||||
|
form**, this is a versioned change: the canonical array no longer includes lane, and the signer
|
||||||
|
keyId bumped `sw-hmac-v1` → `sw-hmac-v2`. v1 events won't verify under v2 — intentional, gated by
|
||||||
|
each event's stored `keyId` (done pre-deployment, on throwaway data, so zero real cost). See
|
||||||
|
[[append-only-event-chain]].
|
||||||
|
|
||||||
|
## Camera snapshots (evidence, not a gate)
|
||||||
|
|
||||||
|
Captured **after** the barrier opens, **never awaited** — a camera failure can't delay or block an
|
||||||
|
open (the signed ledger is the decision). Stored as a **BLOB in the `snapshots` table** (single
|
||||||
|
backed-up DB, nothing scattered on disk), in its own table so hot telemetry scans don't drag image
|
||||||
|
bytes and images prune independently. Linked to the signed `vehicle_entry/exit` by `identity`.
|
||||||
|
Served read-only via `GET /api/snapshots/:id`. **Retention is unresolved** — see [[open-questions]].
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
[[entry-exit-readers]] · [[device-events]] · [[parking-session]] · [[anti-passback]] ·
|
||||||
|
[[append-only-event-chain]] · [[barrier-not-a-door]] · [[opencv-anpr-service]] ·
|
||||||
|
[[dingtian-relay]] · [[first-run-setup]]
|
||||||
@@ -22,6 +22,12 @@ There are **two populations** of users, and they map to **two integration paths*
|
|||||||
| [[wiegand]] reader → UHPPOTE port | The controller | Controller (onboard card list) | **Yes** — works if host down |
|
| [[wiegand]] reader → UHPPOTE port | The controller | Controller (onboard card list) | **Yes** — works if host down |
|
||||||
| Pure TCP/IP reader | Host only | Host, then UDP `open` to relay | No — host on critical path |
|
| Pure TCP/IP reader | Host only | Host, then UDP `open` to relay | No — host on critical path |
|
||||||
| [[lpr-camera|LPR]] / QR scanner | Host only | Host | No |
|
| [[lpr-camera|LPR]] / QR scanner | Host only | Host | No |
|
||||||
|
| **[[gee-qr-er80]] QR reader (serial)** | Host only | Host (reads serial → `read` bus) | No |
|
||||||
|
|
||||||
|
> Concrete host-side reader on hand: the **[[gee-qr-er80]]** (QR over RS-232/RS-485). Note autonomy
|
||||||
|
> is moot here anyway — the current relay ([[dingtian-relay]]) has **no onboard card list**, so even
|
||||||
|
> a Wiegand reader would be host-decided. So we take the serial/QR path straight to the host's
|
||||||
|
> `read` bus.
|
||||||
|
|
||||||
## Key points
|
## Key points
|
||||||
|
|
||||||
@@ -32,6 +38,10 @@ There are **two populations** of users, and they map to **two integration paths*
|
|||||||
keeps autonomy + native event log.
|
keeps autonomy + native event log.
|
||||||
- **Both models can share one relay** (valid Wiegand read **or** host `open` in "controlled"
|
- **Both models can share one relay** (valid Wiegand read **or** host `open` in "controlled"
|
||||||
mode), so one lane serves permit + casual.
|
mode), so one lane serves permit + casual.
|
||||||
|
- **Each reader BINDS to a controller relay** (`config.controllerId` + `relay`) — the barrier it
|
||||||
|
sits at — and inherits that relay's direction (entry/exit/both). An exit read opens exactly that
|
||||||
|
relay; an entry read the entry relay. This is how separate in/out readers are disambiguated, with
|
||||||
|
no "lane". See [[entry-exit-points]].
|
||||||
- **Host-in-the-loop is good for fraud detection** — two independent records (host's signed
|
- **Host-in-the-loop is good for fraud detection** — two independent records (host's signed
|
||||||
[[append-only-event-chain]] entry + the UHPPOTE remote-open event) should reconcile 1:1; any
|
[[append-only-event-chain]] entry + the UHPPOTE remote-open event) should reconcile 1:1; any
|
||||||
mismatch is an anomaly.
|
mismatch is an anomaly.
|
||||||
|
|||||||
@@ -8,8 +8,10 @@ updated: 2026-06-15
|
|||||||
# First-Run Setup (device selection)
|
# First-Run Setup (device selection)
|
||||||
|
|
||||||
The admin install flow that makes the system **device-agnostic in practice**: on first run, an
|
The admin install flow that makes the system **device-agnostic in practice**: on first run, an
|
||||||
admin assigns devices **per lane** by choosing from the [[device-registry]] catalog and entering
|
admin adds **controllers** (each declaring its relays — entry/exit/both — and the entry-button
|
||||||
each device's connection config.
|
terminal) and then **readers/cameras/printers** bound to a controller relay, choosing from the
|
||||||
|
[[device-registry]] catalog and entering each device's connection config. There is **no lane** —
|
||||||
|
the pool-of-spaces model; see [[entry-exit-points]].
|
||||||
|
|
||||||
> Implementation-derived (from `apps/server` + `apps/web`), not the source doc.
|
> Implementation-derived (from `apps/server` + `apps/web`), not the source doc.
|
||||||
|
|
||||||
@@ -26,7 +28,7 @@ each device's connection config.
|
|||||||
device**: fixes preconditions (e.g. disables `input_link_relay`) and sets up the Digest-
|
device**: fixes preconditions (e.g. disables `input_link_relay`) and sets up the Digest-
|
||||||
authenticated input push ([[device-input-flow]]) — the admin never touches the device's own web
|
authenticated input push ([[device-input-flow]]) — the admin never touches the device's own web
|
||||||
UI. **Fails the save** (no DB row) if the device can't be configured, so there are no
|
UI. **Fails the save** (no DB row) if the device can't be configured, so there are no
|
||||||
orphan/half-configured rows. On success persists to `lane_devices`.
|
orphan/half-configured rows. On success persists to `devices`.
|
||||||
4. **Remove** — `DELETE /api/setup/assign/:id` (admin-only) drops one instance's row. Only our
|
4. **Remove** — `DELETE /api/setup/assign/:id` (admin-only) drops one instance's row. Only our
|
||||||
row is removed; the device itself is not un-hardened/un-configured (a stale push from an
|
row is removed; the device itself is not un-hardened/un-configured (a stale push from an
|
||||||
unknown device id is already rejected, and re-assigning reconfigures it).
|
unknown device id is already rejected, and re-assigning reconfigures it).
|
||||||
@@ -34,25 +36,25 @@ each device's connection config.
|
|||||||
|
|
||||||
## Config granularity — multi-instance per category
|
## Config granularity — multi-instance per category
|
||||||
|
|
||||||
The data model is **multi-instance**: `lane_devices` holds **one row per instance**, keyed by a
|
The data model is **multi-instance**: `devices` holds **one row per instance**, keyed by a
|
||||||
generated `id`, with no one-per-(lane, category) constraint. So a lane can have **more than one of
|
generated `id`. So the site can have **more than one of every category** — multiple controllers,
|
||||||
every category** — e.g. two printers (an entry dispenser + a booth printer; see
|
readers, cameras, and printers (e.g. an entry dispenser + a booth printer; see
|
||||||
[[printer-roles-failover]]), multiple readers, multiple cameras. `assign` always inserts a new row
|
[[printer-roles-failover]]). `assign` always inserts a new row (never an upsert), and `state`
|
||||||
(never an upsert), and `state` returns the full list.
|
returns the full list.
|
||||||
|
|
||||||
The `SetupWizard` reflects this: each category shows the **list of assigned instances** for the
|
The `SetupWizard` reflects this: each category shows the **list of assigned instances** (with
|
||||||
current lane (with **Remove**) plus an **Add another** form — not a single fixed slot. `select`-type
|
**Remove**) plus an **Add another** form — not a single fixed slot. `select`-type config fields
|
||||||
config fields (e.g. a printer's role) render as dropdowns.
|
(e.g. a printer's role) render as dropdowns.
|
||||||
|
|
||||||
Organized **per lane** — each lane gets its access controller(s), reader(s), camera(s), and
|
There is **no lane**. Direction lives on each access **relay**; readers/cameras **bind** to a
|
||||||
printer(s), each with its own connection settings. Matches the architecture's "mixable per lane"
|
controller relay (`config.controllerId` + `relay`) — the barrier they serve — and inherit its
|
||||||
reality (a lane can serve permit holders via [[wiegand]] and casual via host-side reads on one
|
direction. The wizard adds controllers first, then binds the other devices to a relay. See
|
||||||
relay — see [[entry-exit-readers]]).
|
[[entry-exit-points]], [[entry-exit-readers]].
|
||||||
|
|
||||||
## Security notes
|
## Security notes
|
||||||
|
|
||||||
- The assign/state/delete/complete endpoints require the **admin** role ([[local-jwt-auth]]).
|
- The assign/state/delete/complete endpoints require the **admin** role ([[local-jwt-auth]]).
|
||||||
- Device **credentials are stored in `lane_devices.config`** — protect at rest
|
- Device **credentials are stored in `devices.config`** — protect at rest
|
||||||
([[disk-os-hardening]]); device hosts belong on the isolated VLAN ([[network-isolation]]).
|
([[disk-os-hardening]]); device hosts belong on the isolated VLAN ([[network-isolation]]).
|
||||||
- **Secrets are stripped on the way out**: `assign` and `state` both redact `pushPassword`,
|
- **Secrets are stripped on the way out**: `assign` and `state` both redact `pushPassword`,
|
||||||
`webPassword`, and `relayPassword` from the returned config (the UI lists devices; it never
|
`webPassword`, and `relayPassword` from the returned config (the UI lists devices; it never
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ adversary is the insider who can edit the database) and the [[append-only-event-
|
|||||||
- A **session** is a **read-model folded from those events** — open when an entry has no matching
|
- A **session** is a **read-model folded from those events** — open when an entry has no matching
|
||||||
exit, paid when a `payment` event references it, closed when an exit lands. It MAY be cached in
|
exit, paid when a `payment` event references it, closed when an exit lands. It MAY be cached in
|
||||||
a table for query speed (dashboards, "cars currently in"), but that cache is **always rebuildable
|
a table for query speed (dashboards, "cars currently in"), but that cache is **always rebuildable
|
||||||
from the chain and never authoritative**. Same pattern as the `LaneMap`
|
from the chain and never authoritative** ([[append-only-event-chain]]), scaled to the business
|
||||||
([[append-only-event-chain]]), scaled to the business domain.
|
domain.
|
||||||
- **Why this matters:** a mutable `sessions` row that stored "amount owed / paid" would reopen
|
- **Why this matters:** a mutable `sessions` row that stored "amount owed / paid" would reopen
|
||||||
exactly the fraud hole the whole system exists to close (operator marks a session paid, pockets
|
exactly the fraud hole the whole system exists to close (operator marks a session paid, pockets
|
||||||
the cash). With sessions as a projection, "paid" is a **signed `payment` event** an operator
|
the cash). With sessions as a projection, "paid" is a **signed `payment` event** an operator
|
||||||
@@ -96,8 +96,34 @@ Permit sessions skip PAID: a valid [[permit]] at exit is itself the authorizatio
|
|||||||
|
|
||||||
## What this unblocks (build order)
|
## What this unblocks (build order)
|
||||||
|
|
||||||
The device layer left the entry flow dangling — `input_received` events land in the log and stop
|
The device layer left the entry flow dangling — the session domain is that next step. Schema + code
|
||||||
([[device-input-flow]] "the entry flow itself is the next build"). The session domain is that next
|
follow this page and [[tariff]]; the decision is recorded in [[session-model]].
|
||||||
step: consume `input_received` / a reader event → mint a signed `vehicle_entry` → print + open.
|
|
||||||
Then the pay-station and exit-validation flows. Schema + code follow this page and [[tariff]];
|
### As-built (2026-06-15)
|
||||||
the decision is recorded in [[session-model]].
|
|
||||||
|
- **Entry flow** (`apps/server/src/entry-flow.ts`): access-device input edge → print ticket
|
||||||
|
(failover) → signed `vehicle_entry` → `pulseOpen`. Holds (anomaly, no open, no entry) if printing
|
||||||
|
fails. See [[device-input-flow]].
|
||||||
|
- **Read dispatch** (`apps/server/src/read-dispatch.ts`): a credential read routes to the
|
||||||
|
**permit flow** if it matches a permit (card/QR/bound plate), else to the transient **exit flow**.
|
||||||
|
Lane resolved once (`readerLaneWithAccess`). See [[permit]] as-built.
|
||||||
|
- **Exit flow** (`apps/server/src/exit-flow.ts`): a credential **read** (the `read` bus channel) →
|
||||||
|
fold the signed ledger for that identity → validate **open + PAID + within `gracePeriodExitMin`**
|
||||||
|
→ signed `vehicle_exit` → `pulseOpen`. Unpaid / expired / unknown → signed `anomaly`, barrier
|
||||||
|
stays closed. Validation folds the **ledger** (authoritative), then updates the `sessions` cache.
|
||||||
|
- **Not a fail-state:** an unpaid reject keeps the barrier closed deliberately (driver returns to
|
||||||
|
the pay station); "exit fails open" ([[fail-state-safety]]) is about the *system* being unable
|
||||||
|
to decide (host/power loss), not an unpaid car.
|
||||||
|
- **Pay station** (`apps/server/src/pay-station.ts`, routes `GET /api/pay/quote` + `POST /api/pay`):
|
||||||
|
look up the open session → resolve the active tariff version (latest `effectiveFrom ≤ entry`) →
|
||||||
|
`computeFee` → append a signed `payment` event (amount, currency, tender, `tariffVersionId`,
|
||||||
|
`graceExitMin`). An operator `overrideMinor` covers lost-ticket/dispute (recorded as the charged
|
||||||
|
amount + the quoted amount). Pay-on-foot: payment is decoupled from the exit lane. PCI scope stays
|
||||||
|
out of the app — `tender` only records cash/card; card capture is the standalone P2PE terminal.
|
||||||
|
- **The full transient loop now passes end to end** (verified): entry → quote → pay → exit opens,
|
||||||
|
session closed, `verifyChain` ok.
|
||||||
|
|
||||||
|
> **Resolved (2026-06-16):** the earlier "no entry/exit direction" gap is closed by the
|
||||||
|
> [[entry-exit-points]] model. Direction lives on each access **relay**; readers/cameras bind to a
|
||||||
|
> relay and inherit it. The "lane" concept was dropped entirely (pool-of-spaces) — separate in/out
|
||||||
|
> readers are distinguished by their relay binding, not a lane.
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ still print when the outside dispenser jams or drops off the network.
|
|||||||
|
|
||||||
## Roles
|
## Roles
|
||||||
|
|
||||||
Each printer instance (a `lane_devices` row, category `printer`) declares a **role** in its
|
Each printer instance (a `devices` row, category `printer`) declares a **role** in its
|
||||||
config:
|
config:
|
||||||
|
|
||||||
- **`entry-dispenser`** — outside, at the lane. Prints the entry ticket the driver takes.
|
- **`entry-dispenser`** — outside, at the lane. Prints the entry ticket the driver takes.
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ interface.
|
|||||||
|
|
||||||
`PrinterMonitor` (`apps/server/src/printer-monitor.ts`):
|
`PrinterMonitor` (`apps/server/src/printer-monitor.ts`):
|
||||||
|
|
||||||
- reloads the monitored set from `lane_devices` each tick (so a newly-assigned printer is picked
|
- reloads the monitored set from `devices` each tick (so a newly-assigned printer is picked
|
||||||
up without a restart), keeping only enabled, monitorable printers;
|
up without a restart), keeping only enabled, monitorable printers;
|
||||||
- polls every `PRINTER_POLL_MS` (default 5000ms), never overlapping ticks;
|
- polls every `PRINTER_POLL_MS` (default 5000ms), never overlapping ticks;
|
||||||
- caches the latest status per device id;
|
- caches the latest status per device id;
|
||||||
|
|||||||
@@ -52,6 +52,24 @@ login ————————————————————————
|
|||||||
That's the whole human-side requirement: **print the cash and the POS (if any).** No blind count,
|
That's the whole human-side requirement: **print the cash and the POS (if any).** No blind count,
|
||||||
no variance gate, no manager override.
|
no variance gate, no manager override.
|
||||||
|
|
||||||
|
### As-built (2026-06-16)
|
||||||
|
|
||||||
|
- A shift is **two signed ledger events**, no mutable table (decision): `shift_open` (new event
|
||||||
|
type) at start, `shift_z_report` at close. The operator is the **logged-in user**, carried in the
|
||||||
|
event `identity`; a shift is **open** iff that operator's most recent shift event is a
|
||||||
|
`shift_open`. `ShiftService` (`apps/server/src/shift-service.ts`).
|
||||||
|
- **Close** sums `payment` events in `[startedAt, endedAt]` by tender (cash vs. card, by **payment
|
||||||
|
time**), appends the signed `shift_z_report` (totals + counts + window), then **prints** via the
|
||||||
|
new generic `PrinterDevice.printReport(title, lines)` (Rongta ESC/POS text) to a booth-receipt
|
||||||
|
printer. Printing is best-effort — a failed print does **not** undo the signed close (the event is
|
||||||
|
the record; `printed:false` is returned).
|
||||||
|
- **Routes** (`routes/shift.ts`, cashier/operator/admin): `GET /api/shift/current`,
|
||||||
|
`POST /api/shift/open` (409 if already open), `POST /api/shift/close` (409 if none open).
|
||||||
|
**UI** `ShiftControl` in the app shell (non-readonly): Start/End + the Z-report totals.
|
||||||
|
- Verified: open → double-open 409 → payments (cash+card, one dated outside the window excluded) →
|
||||||
|
close totals correct + signed + printed → close-again 409 → re-open works; readonly 403;
|
||||||
|
verifyChain ok.
|
||||||
|
|
||||||
## Where the fraud control actually lives
|
## Where the fraud control actually lives
|
||||||
|
|
||||||
Deliberately **not** in a shift-close ceremony. Because every payment is a **signed event in the
|
Deliberately **not** in a shift-close ceremony. Because every payment is a **signed event in the
|
||||||
|
|||||||
@@ -87,6 +87,31 @@ Deterministic, side-effect-free, unit-testable; the daily cap is applied **per r
|
|||||||
overnight stay doesn't hit the cap twice). Rounding and segment edges are part of the settled spec
|
overnight stay doesn't hit the cap twice). Rounding and segment edges are part of the settled spec
|
||||||
because the chain + reconciliation depend on the result being reproducible.
|
because the chain + reconciliation depend on the result being reproducible.
|
||||||
|
|
||||||
|
**Settled edges (2026-06-15, with tests):**
|
||||||
|
- **Grace uses RAW duration** — a stay within `gracePeriodEntryMin` is free even though the
|
||||||
|
increment would round it up (else rounding defeats the grace window).
|
||||||
|
- **The block ladder RESETS each rolling-24h day** — day 2 starts at the first block again (a 25h
|
||||||
|
stay = day-1 capped + day-2 first-hour rate), so the "daily" rate truly resets daily.
|
||||||
|
|
||||||
|
**As-built:** `computeFee(enteredAt, asOf, structure)` in `packages/shared` (pure). Unit-tested
|
||||||
|
across grace, block steps, daily cap, and multi-day reset.
|
||||||
|
|
||||||
|
### Composer (as-built 2026-06-15)
|
||||||
|
|
||||||
|
The admin authors the rate card at runtime — no hand-seeding:
|
||||||
|
|
||||||
|
- **API** (`apps/server/src/routes/tariffs.ts`): `GET /api/tariff` (active version + history; any
|
||||||
|
signed-in role) and `POST /api/tariff/versions` (publish a new immutable version; **admin only**).
|
||||||
|
Publishing validates the structure via `validateTariffStructure` (shared) — non-negative integers,
|
||||||
|
ordered/ascending block bounds, only the last block open-ended — so a malformed card can never be
|
||||||
|
published. The single site `tariffs` row is created lazily on first read/publish.
|
||||||
|
- **UI** (`apps/web/src/TariffComposer.tsx`, admin shell): edit currency, grace windows, increment,
|
||||||
|
daily cap, lost-ticket fee, and add/remove rate blocks; amounts entered in major units, converted
|
||||||
|
to integer minor units on submit. Shows the active version + history; "Publish" creates a new
|
||||||
|
version (past sessions keep their pricing).
|
||||||
|
- Ships **blank** — until a version is published, `GET /api/tariff` returns `active: null` and the
|
||||||
|
pay station returns `409 no active tariff`. Verified end to end (publish → pay station prices).
|
||||||
|
|
||||||
## The pay-on-foot consequence
|
## The pay-on-foot consequence
|
||||||
|
|
||||||
Because payment is decoupled from exit ([[parking-session]] lifecycle), the tariff has **two
|
Because payment is decoupled from exit ([[parking-session]] lifecycle), the tariff has **two
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ must have:
|
|||||||
- The **scanner is a device behind an adapter** ([[device-adapter-pattern]]): a new `ReaderDevice`
|
- The **scanner is a device behind an adapter** ([[device-adapter-pattern]]): a new `ReaderDevice`
|
||||||
kind (QR/barcode imager) — likely the same `IdentitySource = "ticket"` / `"qr"` path. Keeps the
|
kind (QR/barcode imager) — likely the same `IdentitySource = "ticket"` / `"qr"` path. Keeps the
|
||||||
app device-agnostic; hardware model is procurement ([[bom]], [[open-questions]]).
|
app device-agnostic; hardware model is procurement ([[bom]], [[open-questions]]).
|
||||||
|
- **On hand:** the **[[gee-qr-er80]]** QR access reader (`-Q-W`: QR scanner, Wiegand/RS-232/RS-485,
|
||||||
|
Linux-supported) — the concrete scanner for this path. A serial `ReaderDevice` adapter feeds the
|
||||||
|
`read` bus; pending the reader's RS-232 frame/baud (see [[gee-qr-er80]] open questions).
|
||||||
|
|
||||||
## Ticketless alternative (plate as the ticket)
|
## Ticketless alternative (plate as the ticket)
|
||||||
|
|
||||||
|
|||||||
@@ -56,3 +56,9 @@ status: open
|
|||||||
change what the `payment` event must store) and **refunds / overpayment / change** (cash change,
|
change what the `payment` event must store) and **refunds / overpayment / change** (cash change,
|
||||||
"exact change only", a refund as a signed reversal event). Both depend on the unmanned-vs-manned
|
"exact change only", a refund as a signed reversal event). Both depend on the unmanned-vs-manned
|
||||||
payment subsystem (#3) and the note/coin/card acceptor hardware. Revisit at procurement.
|
payment subsystem (#3) and the note/coin/card acceptor hardware. Revisit at procurement.
|
||||||
|
10. **Snapshot retention.** _(Raised by the [[entry-exit-points]] camera-snapshot build, 2026-06-16.)_
|
||||||
|
Entry/exit snapshots are stored as BLOBs in the [[sqlite]] `snapshots` table. This grows the
|
||||||
|
single DB file fast (~100–300 KB per image × every entry **and** exit), and SQLite doesn't
|
||||||
|
reclaim deleted-blob pages without `VACUUM`. **Undecided:** pruning policy (age-based vs.
|
||||||
|
total-size cap), VACUUM cadence, and how this interacts with the #5 backup strategy (blobs
|
||||||
|
bloat every backup). Until decided, snapshots accumulate unbounded. See [[entry-exit-points]].
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
---
|
||||||
|
type: entity
|
||||||
|
tags: [parking, hardware, readers, qr]
|
||||||
|
sources: [gee-qr-er80]
|
||||||
|
updated: 2026-06-16
|
||||||
|
status: open
|
||||||
|
---
|
||||||
|
|
||||||
|
# GEE-QR-ER80 (QR access reader)
|
||||||
|
|
||||||
|
The project's **QR-code reader** (GEE NFC LIMITED). A static optical scanner for **QR /
|
||||||
|
DataMatrix / 1D barcode**, optional ID/IC card. This is the **[[ticket-encoding|QR ticket]]
|
||||||
|
scanner** the design called for — read at the pay station and exit lane — and a path for **QR
|
||||||
|
[[permit]]** credentials. On hand: variant **`-Q-W`** (QR scanner; Wiegand/RS-232/RS-485).
|
||||||
|
(See [[gee-qr-er80|datasheet summary]] / `raw/`.)
|
||||||
|
|
||||||
|
## What it is (and isn't)
|
||||||
|
|
||||||
|
- **Optical, not RFID-prox.** Earlier we *assumed* "ER80-EM" = a 125 kHz EM4100 card reader — the
|
||||||
|
datasheet corrects that: it's a **QR/barcode scanner**. The `-EM` in the original label was a
|
||||||
|
mis-id; the real model is **GEE-QR-ER80**. Optional `D`/`C` variants add ID/IC card, but the unit
|
||||||
|
on hand is **QR-only** (`-Q`).
|
||||||
|
- **Multi-interface** (Wiegand 26/34, RS-232, RS-485, USB, TCP/IP); the `-W` variant exposes
|
||||||
|
**Wiegand + RS-232/RS-485**.
|
||||||
|
|
||||||
|
## How it integrates — HTTP-GET push, server replies the verdict (confirmed via SDK)
|
||||||
|
|
||||||
|
The protocol is settled by the **[[qrcode-sdk|QRCode SDK v1.6.5]]** (not serial as first guessed).
|
||||||
|
The reader is configured (Windows tool `QRCode_v1_6_5.exe`) with a **server IP/port** and a "server
|
||||||
|
language" (only picks the URL path, e.g. `/qa/mcardsea.php`). **On each scan it HTTP-GETs the host:**
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /qa/mcardsea.php?cardid=<QR>&mjihao=<devId>&cjihao=<devSN>&status=<2 chars>&time=<utc>
|
||||||
|
```
|
||||||
|
`cardid` = scanned data; `status` low digit = **direction (1=in / 0=out)**. The host replies JSON
|
||||||
|
`{data:[{cardid,cjihao,mjihao,status,time,output}],code:0}` where reply **`status` 1=valid (beep
|
||||||
|
2×) / 0=invalid (beep 1×)**, **`output` 0=Access/1=WG26/2=WG34**, `time` syncs the clock.
|
||||||
|
|
||||||
|
This is **host-in-the-loop and SYNCHRONOUS**: the GET *is* the access query and **our reply is the
|
||||||
|
decision** — it drives the reader's beep + output. So unlike a fire-and-forget reader, the endpoint
|
||||||
|
must decide (valid/invalid, direction from `status`) and reply, then also emit a `DeviceReadEvent`
|
||||||
|
on the `read` bus for the entry/exit/permit flows ([[parking-session]], [[permit]]) to open the
|
||||||
|
barrier. ([[device-input-flow]] is the analogous push pattern; this one also returns a verdict.)
|
||||||
|
|
||||||
|
> **This explains the "no beep":** feedback comes from the server's JSON reply, not locally. A
|
||||||
|
> non-JSON / missing reply ⇒ no beep even though the scan worked. So "no beep" ≠ "didn't scan."
|
||||||
|
|
||||||
|
- Pushes over plain **HTTP** to our `10.0.10.x` host (on the device subnet); no serial wiring, no
|
||||||
|
Wiegand-decode hardware. Suits the host-in-the-loop model; autonomy is moot anyway
|
||||||
|
([[dingtian-relay]] has no onboard ACL).
|
||||||
|
- **Linux-supported**, 4–15 VDC, default IP `192.168.1.99` — fits the [[disk-os-hardening|appliance]].
|
||||||
|
|
||||||
|
## Resolved (2026-06-16)
|
||||||
|
|
||||||
|
- Protocol = HTTP GET poll + JSON verdict (above). The earlier "serial/Wiegand, find the baud"
|
||||||
|
open questions are **moot** — it's HTTP. Wiegand is the reader's *output line* on a valid read
|
||||||
|
(the reply `output` field), not the host transport.
|
||||||
|
|
||||||
|
## As-built (2026-06-16)
|
||||||
|
|
||||||
|
- **Endpoint** `GET/POST /qa/mcardsea.php` (`apps/server/src/routes/qr-reader.ts`, public — the
|
||||||
|
reader has no auth, sits on the device subnet). Parses `cardid/mjihao/cjihao/status/time`, runs
|
||||||
|
the scan through the **read dispatcher** (permit match → permit flow; else transient exit), and
|
||||||
|
replies the **SDK verdict**: `status` 1=valid(beep 2×)/0=invalid(beep 1×), `output` 0, `time`.
|
||||||
|
- The read flows were refactored to **return a `ReadOutcome` { accepted, direction, reason }** so the
|
||||||
|
endpoint's reply reflects the real accept/reject (the dispatcher decides AND opens the barrier via
|
||||||
|
the flows). A fire-and-forget reader ignores the outcome.
|
||||||
|
- **Reader identity:** the endpoint matches the device **serial (`cjihao`)** against each reader's
|
||||||
|
`config.serial` to find its `devices` row; the dispatcher then resolves the relay that row is
|
||||||
|
**bound** to (`config.controllerId` + `relay`) and opens it. See [[entry-exit-points]].
|
||||||
|
- Verified via inject: valid permit QR → `status:1` + open; re-scan → permit exit (still valid);
|
||||||
|
unknown QR → `status:0`; reader on a barrier-less lane → `status:0`.
|
||||||
|
|
||||||
|
## Verified on hardware (2026-06-16)
|
||||||
|
|
||||||
|
Captured a real scan (vendor-emulator logger on :3000). The reader **does scan, send, and beep** —
|
||||||
|
the earlier "no beep" was simply that no server was answering on :3000 with valid JSON. Real GET:
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /qa/mcardsea.jsp?cardid=52020056&mjihao=1&cjihao=H05M2AFA&status=11&time=1781634494
|
||||||
|
from 10.0.10.7 (referer: http://www.fondvision.com — the OEM is Fondvision)
|
||||||
|
```
|
||||||
|
|
||||||
|
- **PATH carries the configured "server language" EXTENSION:** this unit is set to **JSP**, so it
|
||||||
|
GETs **`/qa/mcardsea.jsp`** — NOT `.php`. Our endpoint was registered at `.php` only → it would
|
||||||
|
have 404'd the real reader. **Fixed:** the route now registers `php/jsp/asp/aspx/cgi`.
|
||||||
|
- **`cjihao` = `H05M2AFA`** is the device **serial** — the value our endpoint matches against the
|
||||||
|
reader's `config.serial`. So assign the reader with **`config.serial = "H05M2AFA"`** and bind it
|
||||||
|
to a controller relay.
|
||||||
|
- **`mjihao` = 1** (device id). `cardid` = the scanned barcode (`52020056`). `status=11`.
|
||||||
|
- The reader **beeped on the vendor reply with `status:0`** — so it acts on the reply; `0` =
|
||||||
|
invalid/1-beep as documented. A matching permit/session will return `status:1` → 2-beep accept.
|
||||||
|
|
||||||
|
## Assignment (as-built 2026-06-16)
|
||||||
|
|
||||||
|
A dedicated **`gee-qr-reader`** driver ([[device-registry]], reader category) models the push reader:
|
||||||
|
its one config field is **`serial`** (the `cjihao` the device reports). The admin assigns it in the
|
||||||
|
[[first-run-setup|setup wizard]] like any device (normal UUID row id), enters the serial, and binds
|
||||||
|
it to a controller relay. The QR endpoint resolves the reader by **matching `config.serial` to the
|
||||||
|
scan's `cjihao`** — not by row id — so no DB hand-editing. Set the reader's server IP/port to this
|
||||||
|
host in the **vendor tool**; assign + enter its serial + bind it here.
|
||||||
|
|
||||||
|
- Verified via inject: assign `gee-qr-reader` {serial:"H05M2AFA"} bound to an access relay →
|
||||||
|
a `.jsp` scan with that serial + a matching permit QR → `status:1` (2-beep accept) + open; re-scan
|
||||||
|
→ permit exit; unknown card → `status:0`; unassigned serial → `status:0` (no relay, graceful).
|
||||||
|
- Note `tcpip-reader` is the WRONG model for this device (host-connects-out, a stub) — use
|
||||||
|
`gee-qr-reader`.
|
||||||
|
|
||||||
|
## Open / next
|
||||||
|
|
||||||
|
- Re-test on hardware against the real app (now `.jsp`-aware + serial-resolved): scan → expect a
|
||||||
|
`status:1` 2-beep when the QR matches a permit/open session.
|
||||||
|
- `output` is replied as `0` (Access). Confirm on hardware whether the reader needs `1`/`2` (WG26/34)
|
||||||
|
to drive its access line, vs. `0`.
|
||||||
|
|
||||||
|
## ⚠️ Reply MUST set `Connection: close` (verified on hardware)
|
||||||
|
|
||||||
|
The reader sends `Connection: keep-alive` but **only acts on the verdict (beep/output) once the TCP
|
||||||
|
socket CLOSES**. Fastify's default keeps the connection alive → the reader waits out a **~10 s
|
||||||
|
keep-alive timeout before beeping**, even though the server replied in ~15 ms. Every vendor demo
|
||||||
|
replies **`Connection: close`** and shuts the socket. Fix: the endpoint sets
|
||||||
|
`reply.header("connection","close")`. Symptom if regressed: correct accept/reject but a ~10 s lag
|
||||||
|
before the beep. (The request arrives fast; the delay is entirely the reader waiting for close.)
|
||||||
@@ -14,13 +14,13 @@ Authentication and authorization, kept **fully local** — a direct consequence
|
|||||||
- `@fastify/jwt` signs tokens with a **local secret** (symmetric HMAC). The server **refuses to
|
- `@fastify/jwt` signs tokens with a **local secret** (symmetric HMAC). The server **refuses to
|
||||||
start** without a strong `JWT_SECRET` (≥32 chars, no placeholder) — there is deliberately no
|
start** without a strong `JWT_SECRET` (≥32 chars, no placeholder) — there is deliberately no
|
||||||
insecure default.
|
insecure default.
|
||||||
- **Session lifetime: valid until explicit logout — no time expiry** (decision 2026-06-15).
|
- **Session lifetime: valid until explicit logout — no time expiry** (decision 2026-06-15, built).
|
||||||
Booth reality breaks any fixed clock: relief arrives late, fails to show, or one operator is
|
Booth reality breaks any fixed clock: relief arrives late, fails to show, or one operator is
|
||||||
forced to work two shifts in a row — a token that expired mid-duty would strand an active
|
forced to work two shifts in a row — a token that expired mid-duty would strand an active
|
||||||
operator. So the login persists until logout; a **[[shift]] is a separate, explicit boundary**,
|
operator. So the login persists until logout; a **[[shift]] is a separate, explicit boundary**,
|
||||||
not tied to token lifetime. (Superseded the earlier "8h expiry, bound to a shift" assumption.)
|
not tied to token lifetime. (Superseded the earlier "8h expiry, bound to a shift" assumption.)
|
||||||
> ⚠️ Code still mints an 8h-expiry token — this page records the decided design; the server
|
The JWT carries no `exp`; the cookie has a long fixed `maxAge` (30 days) so a browser restart
|
||||||
> change (drop `expiresIn`, persist until logout) is pending.
|
doesn't log out an active operator, and `logout` clears it.
|
||||||
- A `users` table in [[sqlite]] holds **bcrypt** password hashes plus a **role** column. The
|
- A `users` table in [[sqlite]] holds **bcrypt** password hashes plus a **role** column. The
|
||||||
first admin is seeded via `pnpm --filter @parking/server seed-admin` (no bootstrap endpoint).
|
first admin is seeded via `pnpm --filter @parking/server seed-admin` (no bootstrap endpoint).
|
||||||
- Authorization = a simple `preHandler` role guard per route: **admin / operator / cashier /
|
- Authorization = a simple `preHandler` role guard per route: **admin / operator / cashier /
|
||||||
|
|||||||
@@ -99,6 +99,36 @@ stays append-only even though the permit record itself is editable.
|
|||||||
- **Revoked:** a revoked permit fails the entry check → treated as transient (take a ticket) or
|
- **Revoked:** a revoked permit fails the entry check → treated as transient (take a ticket) or
|
||||||
refused, per policy (OPEN).
|
refused, per policy (OPEN).
|
||||||
|
|
||||||
|
## As-built (2026-06-15)
|
||||||
|
|
||||||
|
`apps/server/src/permit-flow.ts`, reached via the **read dispatcher**
|
||||||
|
(`read-dispatch.ts`): a credential read routes to the permit flow if it **matches a permit**
|
||||||
|
(card/QR credential, or a bound plate) — otherwise to the transient exit flow. So one read handler
|
||||||
|
serves both populations ([[entry-exit-readers]]), disambiguated by *what the credential is*.
|
||||||
|
|
||||||
|
- **Direction is inferred from session state for that car** — the read credential value is the
|
||||||
|
per-car session key. No open session for that car → **ENTRY** (check `maxConcurrent`, sign
|
||||||
|
`vehicle_entry`, open); an open session → **EXIT** (sign `vehicle_exit`, open, close). A fleet
|
||||||
|
permit thus has one session per car concurrently, and anti-passback falls out (a re-read of an
|
||||||
|
inside car is its exit, never a second entry).
|
||||||
|
- **`maxConcurrent`** is enforced as a **fold over the signed ledger** — count the permit's
|
||||||
|
`vehicle_entry` events whose car has no later exit; reject at the limit (`null` = unbound).
|
||||||
|
- **Validity** (active + within `validFrom`/`validTo`) and **plate-OR-card identity** as designed.
|
||||||
|
No ticket, no fee — the permit is the authorization; every use is still a signed ledger event
|
||||||
|
carrying `permitId`.
|
||||||
|
- Refusals (revoked / out-of-window / at-capacity) are signed `anomaly` events; the barrier stays
|
||||||
|
closed. Verified end to end (entry, inferred exit, fleet cap, plate-bound, revoked, dispatch).
|
||||||
|
|
||||||
|
**Admin CRUD** (`apps/server/src/routes/permits.ts` + `apps/web/src/PermitManager.tsx`): a permit is
|
||||||
|
an **aggregate** (the row + its credentials + bound plates); create/update treat it as one unit
|
||||||
|
(child sets are replaced on update). `GET /api/permits` (any signed-in role — for lookup),
|
||||||
|
`POST/PUT/DELETE /api/permits[/:id]` + `POST /api/permits/:id/revoke` (**admin only**). Validation:
|
||||||
|
`maxConcurrent` is a positive int or `null` (unbound); a permit must have **at least one credential
|
||||||
|
or one bound plate** (else nothing identifies it). Revoke is the soft, common case (keeps history,
|
||||||
|
barred at the barrier); DELETE hard-removes — past ledger events that reference the permit are
|
||||||
|
untouched (the audit trail is append-only and independent). Verified via inject (validation, child
|
||||||
|
replacement, RBAC, revoke/delete).
|
||||||
|
|
||||||
## Resolved (2026-06-15)
|
## Resolved (2026-06-15)
|
||||||
|
|
||||||
- **Two optional bindings, independent:** car-count (`maxConcurrent`, **default 1**, raisable or
|
- **Two optional bindings, independent:** car-count (`maxConcurrent`, **default 1**, raisable or
|
||||||
|
|||||||
+7
-3
@@ -7,7 +7,7 @@ updated: 2026-06-14
|
|||||||
# Index
|
# Index
|
||||||
|
|
||||||
Content catalog for the wiki. Start at [[overview]]. Maintained on every ingest.
|
Content catalog for the wiki. Start at [[overview]]. Maintained on every ingest.
|
||||||
Counts: 1 source · 18 entities · 24 concepts · 5 decision records.
|
Counts: 3 sources · 19 entities · 24 concepts · 5 decision records.
|
||||||
|
|
||||||
## Overview & navigation
|
## Overview & navigation
|
||||||
- [[overview]] — the top-level synthesis and entry point.
|
- [[overview]] — the top-level synthesis and entry point.
|
||||||
@@ -16,6 +16,8 @@ Counts: 1 source · 18 entities · 24 concepts · 5 decision records.
|
|||||||
|
|
||||||
## Sources
|
## Sources
|
||||||
- [[parking-system-architecture]] — design notes: stack, threat model, devices, UHPPOTE, ESP32, readers, BOM, open decisions.
|
- [[parking-system-architecture]] — design notes: stack, threat model, devices, UHPPOTE, ESP32, readers, BOM, open decisions.
|
||||||
|
- [[gee-qr-er80]] — datasheet: GEE QR access reader (QR/DM/1D; Wiegand/RS-232/485/USB/TCP; Linux).
|
||||||
|
- [[qrcode-sdk]] — QRCode SDK v1.6.5: the reader's HTTP-GET-poll protocol + JSON verdict (beep/output).
|
||||||
|
|
||||||
## Entities — technology stack
|
## Entities — technology stack
|
||||||
- [[technology-stack]] — the full stack table; all MIT/Apache/BSD, chosen to avoid lock-in.
|
- [[technology-stack]] — the full stack table; all MIT/Apache/BSD, chosen to avoid lock-in.
|
||||||
@@ -37,6 +39,7 @@ Counts: 1 source · 18 entities · 24 concepts · 5 decision records.
|
|||||||
- [[atecc608]] — secure element; non-extractable signing key (host events + controller auth).
|
- [[atecc608]] — secure element; non-extractable signing key (host events + controller auth).
|
||||||
- [[wiegand]] — reader standard feeding the controller directly (autonomous permit-holder path).
|
- [[wiegand]] — reader standard feeding the controller directly (autonomous permit-holder path).
|
||||||
- [[lpr-camera]] — edge-AI plate recognition; host-side casual-identity source.
|
- [[lpr-camera]] — edge-AI plate recognition; host-side casual-identity source.
|
||||||
|
- [[gee-qr-er80]] — QR access reader on hand; host-side serial → `read` bus (the QR-ticket scanner).
|
||||||
- [[zkteco-controller]] — ❌ rejected/historical; aux-input path was a contender, not pursued.
|
- [[zkteco-controller]] — ❌ rejected/historical; aux-input path was a contender, not pursued.
|
||||||
- [[dingtian-relay]] — ✅ CHOSEN access controller; decoupled inputs solve the button blocker (driver verified on hardware).
|
- [[dingtian-relay]] — ✅ CHOSEN access controller; decoupled inputs solve the button blocker (driver verified on hardware).
|
||||||
- [[rongta-printer]] — ✅ CHOSEN 80mm thermal printer; ESC/POS over raw TCP 9100; driver written, one unit reachable at 10.0.10.6.
|
- [[rongta-printer]] — ✅ CHOSEN 80mm thermal printer; ESC/POS over raw TCP 9100; driver written, one unit reachable at 10.0.10.6.
|
||||||
@@ -54,11 +57,11 @@ Counts: 1 source · 18 entities · 24 concepts · 5 decision records.
|
|||||||
## Concepts — device architecture & safety
|
## Concepts — device architecture & safety
|
||||||
- [[device-adapter-pattern]] — business logic talks to interfaces; swap hardware → new adapter.
|
- [[device-adapter-pattern]] — business logic talks to interfaces; swap hardware → new adapter.
|
||||||
- [[device-registry]] — catalog of selectable drivers per category (admin-configurable).
|
- [[device-registry]] — catalog of selectable drivers per category (admin-configurable).
|
||||||
- [[first-run-setup]] — admin assigns devices per lane from the catalog at install.
|
- [[first-run-setup]] — admin adds controllers + binds readers/cameras to relays from the catalog at install.
|
||||||
- [[device-input-flow]] — button → device push → backend decides → relay; backend is source of truth.
|
- [[device-input-flow]] — button → device push → backend decides → relay; backend is source of truth.
|
||||||
- [[device-discovery]] — optional driver capability to scan the LAN (no current driver uses it; UHPPOTE was the example).
|
- [[device-discovery]] — optional driver capability to scan the LAN (no current driver uses it; UHPPOTE was the example).
|
||||||
- [[barrier-not-a-door]] — never timed-close a barrier; safety lives in barrier firmware.
|
- [[barrier-not-a-door]] — never timed-close a barrier; safety lives in barrier firmware.
|
||||||
- [[printer-roles-failover]] — ≥2 printers per lane by role; entry ticket falls back outside→booth.
|
- [[printer-roles-failover]] — ≥2 printers by role; entry ticket falls back outside→booth.
|
||||||
- [[printer-status-monitoring]] — live poll of paper/cover/cutter/offline via the device's status page; SSE to the booth UI.
|
- [[printer-status-monitoring]] — live poll of paper/cover/cutter/offline via the device's status page; SSE to the booth UI.
|
||||||
- [[trust-boundary]] — the core fork: network vs. device; auditable vs. unforgeable.
|
- [[trust-boundary]] — the core fork: network vs. device; auditable vs. unforgeable.
|
||||||
- [[fail-state-safety]] — entry fails closed, exit fails open; manual override; watchdog.
|
- [[fail-state-safety]] — entry fails closed, exit fails open; manual override; watchdog.
|
||||||
@@ -69,6 +72,7 @@ Counts: 1 source · 18 entities · 24 concepts · 5 decision records.
|
|||||||
- [[event-log-ingestion]] — host-side index tracking that makes the UHPPOTE log trustworthy.
|
- [[event-log-ingestion]] — host-side index tracking that makes the UHPPOTE log trustworthy.
|
||||||
- [[challenge-response-auth]] — asymmetric nonce scheme for the ESP32 (auth + anti-replay).
|
- [[challenge-response-auth]] — asymmetric nonce scheme for the ESP32 (auth + anti-replay).
|
||||||
- [[entry-exit-readers]] — two populations, two integration paths; both can share a relay.
|
- [[entry-exit-readers]] — two populations, two integration paths; both can share a relay.
|
||||||
|
- [[entry-exit-points]] — pool-of-spaces model (no lane); per-relay direction, reader→relay binding, camera snapshots.
|
||||||
- [[uhppote-vs-esp32]] — comparison: detection vs. prevention.
|
- [[uhppote-vs-esp32]] — comparison: detection vs. prevention.
|
||||||
|
|
||||||
## Concepts — business domain
|
## Concepts — business domain
|
||||||
|
|||||||
+244
@@ -500,3 +500,247 @@ guarantee. Recorded in [[dingtian-relay]] (new Hardening section).
|
|||||||
new custody/session shape. Captured as [[valet-overcapacity]] + made [[capacity-occupancy]] FULL a
|
new custody/session shape. Captured as [[valet-overcapacity]] + made [[capacity-occupancy]] FULL a
|
||||||
soft policy; NOT built into the entry flow (clean seam left). Deferred.
|
soft policy; NOT built into the entry flow (clean seam left). Deferred.
|
||||||
- New page [[valet-overcapacity]]; updated [[capacity-occupancy]], [[index]].
|
- New page [[valet-overcapacity]]; updated [[capacity-occupancy]], [[index]].
|
||||||
|
|
||||||
|
## [2026-06-15] build | Exit flow (pay-on-foot validation)
|
||||||
|
- Built `apps/server/src/exit-flow.ts`. Added a `read` channel to the device bus (DeviceReadEvent:
|
||||||
|
ticket/plate/qr/card) — readers/LPR emit reads; entry stays button-driven, so reads are
|
||||||
|
unambiguously exit/identity events for now.
|
||||||
|
- Flow: read → fold the SIGNED ledger for that identity → validate open + PAID + within
|
||||||
|
`gracePeriodExitMin` → signed `vehicle_exit` → pulseOpen → close the session cache. Unpaid /
|
||||||
|
grace-expired / unknown → signed `anomaly`, barrier stays closed (a deliberate business reject,
|
||||||
|
NOT a fail-state; "exit fails open" is about host/power loss). Validation reads the ledger
|
||||||
|
(authoritative), not the cache.
|
||||||
|
- Pay station doesn't exist yet → no `payment` events → every transient exit currently REJECTS.
|
||||||
|
Correct end-state, not passable until pay-station lands (decided).
|
||||||
|
- VERIFIED against stubs: unpaid→anomaly+no-open; paid+grace→vehicle_exit+open+closed; grace-expired
|
||||||
|
→anomaly; unknown ticket→anomaly; verifyChain ok across entry→pay→exit.
|
||||||
|
- GAP flagged: lane_devices has no entry/exit DIRECTION model (door mapping hardcoded to 1 for exit);
|
||||||
|
fine while entry=button/exit=read, but multi-reader lanes need a lane-direction/role model (ties to
|
||||||
|
[[open-questions]] #1). Updated [[parking-session]] as-built + gap, [[index]].
|
||||||
|
|
||||||
|
## [2026-06-15] build | Pay station + fee calc; JWT 8h → until-logout
|
||||||
|
- JWT: dropped the 8h `expiresIn` (server.ts global + login). Token now valid **until explicit
|
||||||
|
logout**; cookie maxAge = 30 days so a browser restart doesn't log out an active operator
|
||||||
|
(auth.ts `COOKIE_MAX_AGE_SECONDS`). Closes the pending change from the shift decision; updated
|
||||||
|
[[local-jwt-auth]].
|
||||||
|
- `computeFee(enteredAt, asOf, structure)` in `packages/shared` — pure integer fee calc.
|
||||||
|
TWO BUGS caught by tests: (1) grace must use RAW duration, not the rounded-up minutes (a 10-min
|
||||||
|
stay was being charged a full hour); (2) the block ladder must RESET each rolling-24h day (decision:
|
||||||
|
day 2 restarts at first-block pricing → 25h = 1200 cap + 200). Both fixed; 9 cases pass.
|
||||||
|
- Pay station (`apps/server/src/pay-station.ts` + routes `GET /api/pay/quote`, `POST /api/pay`):
|
||||||
|
open session → active tariff version → computeFee → signed `payment` event (amount/currency/tender/
|
||||||
|
tariffVersionId/graceExitMin); `overrideMinor` for lost-ticket/dispute. Cashier/operator/admin guard.
|
||||||
|
- VERIFIED: full loop entry→quote(300 for 90min)→pay→exit opens+closes, verifyChain ok. (A
|
||||||
|
raw-SQL backdate in one test correctly broke the chain — the tamper-evidence working, not a flow bug.)
|
||||||
|
- Updated [[tariff]] (settled edges + as-built), [[parking-session]] (pay station as-built; full
|
||||||
|
loop passes).
|
||||||
|
|
||||||
|
## [2026-06-15] build | Tariff composer (makes the pay station operable)
|
||||||
|
- `validateTariffStructure` in `packages/shared` — non-negative ints, ascending block bounds, only
|
||||||
|
the last block open-ended; a malformed card can't be published.
|
||||||
|
- Routes (`apps/server/src/routes/tariffs.ts`): `GET /api/tariff` (active + history, any role) and
|
||||||
|
`POST /api/tariff/versions` (publish immutable version, ADMIN only). Single site `tariffs` row
|
||||||
|
created lazily. Editing = publish a new version (effective-dated, immutable).
|
||||||
|
- UI (`apps/web/src/TariffComposer.tsx`, admin shell next to SetupWizard): currency, grace windows,
|
||||||
|
increment, daily cap, lost-ticket, add/remove rate blocks; major-unit input → minor on submit;
|
||||||
|
shows active + history.
|
||||||
|
- VERIFIED via Fastify inject: GET empty→active null; invalid (out-of-order blocks)→400 w/ problem;
|
||||||
|
valid→201 createdBy=admin; readonly publish→403; after publish the pay station quote returns 404
|
||||||
|
(session) not 409 (no tariff) — i.e. it now sees the active card. Full build 5/5.
|
||||||
|
- Updated [[tariff]] (composer as-built).
|
||||||
|
|
||||||
|
## [2026-06-15] build | Permit entry/exit branch + read dispatcher
|
||||||
|
- `apps/server/src/permit-flow.ts` + `read-dispatch.ts`. A credential read now routes by WHAT the
|
||||||
|
credential is: matches a permit (card/QR credential, or a bound plate) → permit flow; else →
|
||||||
|
transient exit flow. Lane resolved once (`readerLaneWithAccess`, shared in lane-map.ts). Refactored
|
||||||
|
ExitFlow.onRead → handleAt(lane,e) so the dispatcher owns lane resolution.
|
||||||
|
- Permit DIRECTION inferred from session state for that car (the read value is the per-car session
|
||||||
|
key): no open session → ENTRY (enforce maxConcurrent, sign vehicle_entry, open); open → EXIT (sign
|
||||||
|
vehicle_exit, open, close). Fleet permit = one session per car; anti-passback falls out.
|
||||||
|
- maxConcurrent enforced as a fold over the signed ledger (count the permit's entries whose car has
|
||||||
|
no later exit); null = unbound. Validity window + status + plate-OR-card identity as designed.
|
||||||
|
No ticket/fee; every use is a signed event carrying permitId. Refusals = signed anomaly, no open.
|
||||||
|
- VERIFIED against stubs: card entry → inferred exit; fleet maxConcurrent=2 (F1,F2 in, F3 rejected,
|
||||||
|
F1 exits → F3 enters); plate-bound permit opens; revoked → reject; unknown credential falls through
|
||||||
|
to exit-flow reject (not mis-read as permit); verifyChain ok. Full build 5/5.
|
||||||
|
- Updated [[permit]] (as-built), [[parking-session]] (read dispatch).
|
||||||
|
|
||||||
|
## [2026-06-15] build | Permit admin CRUD (route + UI)
|
||||||
|
- `apps/server/src/routes/permits.ts`: a permit is an aggregate (row + credentials + bound plates);
|
||||||
|
create/update replace the child sets as one unit. GET (any role, for lookup), POST/PUT/DELETE +
|
||||||
|
POST /:id/revoke (admin only). Validation: maxConcurrent positive-int-or-null; must have ≥1
|
||||||
|
credential OR ≥1 plate. Revoke = soft (keeps history); DELETE = hard (past ledger events untouched).
|
||||||
|
- `apps/web/src/PermitManager.tsx` in the admin shell: list + add/edit (holder, car-bound toggle →
|
||||||
|
maxConcurrent or unbound, validity window, credentials add/remove, plates as a list), revoke, delete.
|
||||||
|
- Makes permits usable without hand-seeding (companion to the tariff composer).
|
||||||
|
- VERIFIED via inject: empty + maxConcurrent=0 → 400 w/ messages; valid → 201; operator LIST 200 but
|
||||||
|
create 403; update unbinds + REPLACES child rows (old cred gone); revoke→revoked; delete→204 then
|
||||||
|
404, children cleaned. Full build 5/5.
|
||||||
|
- Updated [[permit]] (CRUD as-built).
|
||||||
|
|
||||||
|
## [2026-06-16] build | Shifts: open/close + signed Z-report (manned mode)
|
||||||
|
- Shift = two signed ledger events, NO mutable table: new `shift_open` event type + existing
|
||||||
|
`shift_z_report`. Operator = logged-in user (in event `identity`); open iff their latest shift
|
||||||
|
event is a `shift_open`. `apps/server/src/shift-service.ts`.
|
||||||
|
- Close sums `payment` events in the window by tender (cash/card, by payment time) → signed
|
||||||
|
`shift_z_report` (totals/counts/window) → prints via the NEW generic
|
||||||
|
`PrinterDevice.printReport(title, lines)` (Rongta ESC/POS text) to a booth-receipt printer.
|
||||||
|
Print is best-effort — failure doesn't undo the signed close (`printed:false` returned).
|
||||||
|
- Routes (`routes/shift.ts`, cashier/operator/admin): GET /api/shift/current, POST open (409 if
|
||||||
|
open), POST close (409 if none). UI `ShiftControl` in the shell (non-readonly): Start/End + Z totals.
|
||||||
|
- Added `printReport` to the PrinterDevice interface + Rongta driver (reusable for receipts later).
|
||||||
|
- VERIFIED: open→double-open 409→payments (cash+card; one dated outside the window excluded)→close
|
||||||
|
totals (cash 500/card 250/3)→close-again 409→re-open ok; readonly 403; verifyChain ok. Full build 5/5.
|
||||||
|
- Updated [[shift]] (as-built).
|
||||||
|
|
||||||
|
## [2026-06-16] build | Capacity / FULL gate (occupancy fold + transient refuse)
|
||||||
|
- Occupancy = fold over the ledger (entries−exits per identity; `apps/server/src/occupancy.ts`),
|
||||||
|
`getOccupancy` → {count, capacity, free, full}. Capacity = single-row `site_config` table (admin,
|
||||||
|
null=uncapped); migration 0001 (additive, no prompt).
|
||||||
|
- FULL gate in the TRANSIENT entry flow: occupancy.full → refuse (no ticket/entry/open) + signed
|
||||||
|
anomaly. Permit entry NOT gated (subscribers admitted past transient-full; their maxConcurrent
|
||||||
|
still applies) — occupancy can read over-capacity by design.
|
||||||
|
- Routes (`routes/site.ts`): GET /api/occupancy + GET /api/site-config (any role), PUT
|
||||||
|
/api/site-config (admin; non-neg int or null). UI `SiteSettings`: live occupancy + FULL badge
|
||||||
|
(all), capacity editor (admin).
|
||||||
|
- VERIFIED: fill to cap=2 → 3rd transient refused (anomaly, no open); permit still admitted (occ 3/2,
|
||||||
|
free −1); exit frees a slot; routes RBAC (op can't set, −5→400, set/clear ok); verifyChain ok.
|
||||||
|
Full build 5/5. Physical FULL-sign relay output deferred.
|
||||||
|
- Updated [[capacity-occupancy]] (as-built).
|
||||||
|
|
||||||
|
## [2026-06-16] ingest | GEE-QR-ER80 QR access reader datasheet
|
||||||
|
- User has the reader; ingested `raw/GEE-QR-ER80 QR Code Access Control Reader.pdf`.
|
||||||
|
- CORRECTION: earlier guessed "ER80-EM" = a 125 kHz EM4100 prox-card reader. WRONG — the datasheet
|
||||||
|
shows **GEE-QR-ER80**, a **QR / DataMatrix / 1D barcode** optical access reader (optional ID/IC
|
||||||
|
card). It's the [[ticket-encoding|QR ticket]] scanner the design already needed, not a card reader.
|
||||||
|
- Specs: interfaces Wiegand 26/34 · RS-232 · RS-485 · USB · TCP/IP; 4–15 VDC <800 mA; 360°;
|
||||||
|
Windows + **Linux**; wiring VCC/GND/D0/D1/TX(R+)/RX(R-)/LED/BEEP. On hand: **`-Q-W`** (QR scanner;
|
||||||
|
Wiegand/RS-232/RS-485).
|
||||||
|
- Fit: host-side reader → a serial `ReaderDevice` adapter emitting `read` events → consumed by the
|
||||||
|
already-built exit flow + QR-permit path. Prefer RS-232/485 (serial) over Wiegand (Wiegand can't
|
||||||
|
carry variable-length QR; autonomy moot since [[dingtian-relay]] has no onboard ACL).
|
||||||
|
- New: source [[gee-qr-er80]] summary + entity [[gee-qr-er80]]. Updated [[ticket-encoding]],
|
||||||
|
[[entry-exit-readers]], [[index]].
|
||||||
|
- OPEN (blocks the adapter): the RS-232/485 **frame + baud** — is a QR scan an ASCII CR/LF string
|
||||||
|
(expected) or framed? Datasheet omits it; resolve via vendor docs or by observing the port.
|
||||||
|
|
||||||
|
## [2026-06-16] ingest+test | ER80 protocol = HTTP GET poll + JSON verdict (SDK)
|
||||||
|
- Hardware bring-up: configured the reader via the vendor Windows tool (server IP/port + "server
|
||||||
|
language"). Moved it to 10.0.10.7. It pings (source-pin must be 10.0.10.203 — trap recurs). No
|
||||||
|
beep on scans — initially looked like "not scanning."
|
||||||
|
- Found the QRCode SDK v1.6.5 (`QRCode_sdk - QRCode_v1_6_5/sdk/`). Protocol SETTLED, supersedes the
|
||||||
|
serial guess in [[gee-qr-er80]]: reader does **HTTP GET** `/qa/mcardsea.php?cardid&mjihao&cjihao&
|
||||||
|
status&time` on each scan; server replies **JSON** `{data:[{...,status,output}],code:0}`. Reply
|
||||||
|
`status` 1=valid(beep 2×)/0=invalid(beep 1×); `output` 0=Access/1=WG26/2=WG34; `time` syncs clock.
|
||||||
|
`status` low digit in the GET = direction (1=in/0=out).
|
||||||
|
- KEY: feedback/beep is decided by the SERVER REPLY, not locally → the "no beep" was my catch-all
|
||||||
|
replying plain "OK" not the JSON verdict, NOT a scan failure. Host-in-the-loop + SYNCHRONOUS.
|
||||||
|
- "Server language" (JSP/PHP/C#/ASP/CGI) only selects the URL PATH; transport is plain HTTP.
|
||||||
|
- New source [[qrcode-sdk]]; updated [[gee-qr-er80]] (protocol resolved, serial open-Qs dropped),
|
||||||
|
[[index]]. SDK kept in place (bulky+binaries), not copied to raw/.
|
||||||
|
- NEXT: backend route — parse GET, DECIDE (reuse permit/exit lookup), reply JSON verdict, emit on
|
||||||
|
read bus. Refactor read flows to RETURN an outcome so the reply can reflect accept/reject.
|
||||||
|
|
||||||
|
## [2026-06-16] build+fix | QR reader endpoint + ReadOutcome refactor; dev-DB migrate fix
|
||||||
|
- DB FIX: dev server crashed `no such table: lane_devices`. Cause: server `.env` DATABASE_URL points
|
||||||
|
at `apps/server/parking.sqlite` (the old dev DB I'd moved aside during the ledger split; new
|
||||||
|
migrations added since). Applied `drizzle-kit migrate` to that path → all 14 tables present. Fresh
|
||||||
|
DB → needs `seed-admin` + device re-assignment (empty, expected).
|
||||||
|
- REFACTOR: read flows now RETURN a `ReadOutcome {accepted,direction,reason}` (device-events.ts).
|
||||||
|
`ReadDispatcher.dispatch`, `ExitFlow.handleAt`, `PermitFlow.run` updated. A synchronous reader can
|
||||||
|
answer the device; fire-and-forget readers ignore it.
|
||||||
|
- ENDPOINT: `routes/qr-reader.ts` — `GET/POST /qa/mcardsea.php` (public; reader has no auth, on the
|
||||||
|
device subnet). Parses the SDK GET, dispatches the scan, replies the SDK verdict (status 1/0 →
|
||||||
|
beep 2×/1×, output 0, time-sync). Reader's lane keyed off device serial (cjihao) as lane_devices.id
|
||||||
|
for now.
|
||||||
|
- VERIFIED via inject: valid permit QR→status:1+open; re-scan→permit exit; unknown→status:0; reader
|
||||||
|
on barrier-less lane→status:0. Full build 5/5.
|
||||||
|
- Updated [[gee-qr-er80]] (endpoint as-built + hardware open items).
|
||||||
|
|
||||||
|
## [2026-06-16] test+fix | QR reader VERIFIED on hardware; path is .jsp not .php
|
||||||
|
- Ran a verbatim-vendor logger on :3000 (replies like mcardsea.php: status:0/output:2). Reader
|
||||||
|
**beeped** → it scans, sends, and acts on the reply. Earlier "no beep" = nothing was answering :3000.
|
||||||
|
- Real GET captured: `/qa/mcardsea.jsp?cardid=52020056&mjihao=1&cjihao=H05M2AFA&status=11&time=...`
|
||||||
|
from 10.0.10.7 (OEM = Fondvision, per referer).
|
||||||
|
- KEY FIX: the "server language" setting selects the URL EXTENSION — this unit is JSP → posts
|
||||||
|
**`.jsp`**, but our route was `.php` only (would 404 the reader). Route now registers
|
||||||
|
php/jsp/asp/aspx/cgi. Build green.
|
||||||
|
- Real serial **cjihao=H05M2AFA** = the lane key → assign reader as lane_devices.id="H05M2AFA".
|
||||||
|
Reader beeped on status:0 (invalid/1-beep); a matching permit/session → status:1 (2-beep accept).
|
||||||
|
- Updated [[gee-qr-er80]] (verified-on-hardware).
|
||||||
|
|
||||||
|
## [2026-06-16] feature | gee-qr-reader driver — assign by serial, resolve lane by config
|
||||||
|
- The QR reader is a push device; setup wizard always assigns a random-UUID id, so "id = serial"
|
||||||
|
isn't possible via the UI. Clean fix instead: new **`gee-qr-reader`** driver (reader category) with
|
||||||
|
a single `serial` config field. Admin assigns it in the wizard (UUID id) + types the serial.
|
||||||
|
- QR endpoint now resolves the lane by **matching `lane_devices.config.serial` to the scan's
|
||||||
|
`cjihao`** (was: row id == cjihao). `qrReaderRoutes(app, db, dispatcher)`. Unassigned serial →
|
||||||
|
no lane → status:0 (graceful).
|
||||||
|
- `tcpip-reader` flagged as the WRONG model for this device (host-connects-out stub).
|
||||||
|
- VERIFIED via inject through the real /api/setup/assign: assign {serial:"H05M2AFA"} → .jsp scan +
|
||||||
|
matching permit → status:1 + open; re-scan → exit; unknown card → status:0; unassigned serial →
|
||||||
|
status:0. Full build 5/5.
|
||||||
|
- Updated [[gee-qr-er80]] (assignment as-built).
|
||||||
|
|
||||||
|
## [2026-06-16] feature | stub-access driver (bench-test the flows without a relay)
|
||||||
|
- Live QR scan reached the real app (.jsp, serial resolved) but rejected: "reader not on an
|
||||||
|
access-equipped lane" — lane 1 had the reader but no access device. The dispatcher requires an
|
||||||
|
access device on the same lane.
|
||||||
|
- Added a no-op **`stub-access`** driver (access category, no config): `pulseOpen` just logs, no
|
||||||
|
device I/O — stands in on a lane to test QR→permit→accept (incl. the beep) without the
|
||||||
|
[[dingtian-relay]] connected. NOT for production. Registered in the catalog.
|
||||||
|
- To get a live accept: assign Stub barrier to the reader's lane + a permit whose QR = the scanned
|
||||||
|
code → status:1 (2-beep) + logged pulseOpen.
|
||||||
|
|
||||||
|
## [2026-06-16] fix | QR reader 10s beep delay — reply must Connection: close
|
||||||
|
- Live accept worked (status:1, pulseOpen, 2 beeps) but the beep came ~10 s LATE. Server responded
|
||||||
|
in 14.7 ms; user confirmed request is fast, only the beep lags → delay is the READER, not us.
|
||||||
|
- Cause: reader sends `Connection: keep-alive` but only ACTS on the verdict once the socket CLOSES;
|
||||||
|
Fastify kept it alive → reader waited out a ~10 s keep-alive timeout. Every vendor demo replies
|
||||||
|
`Connection: close` + shuts the socket.
|
||||||
|
- Fix: endpoint sets `reply.header("connection","close")`. Verified the header is now sent.
|
||||||
|
- Updated [[gee-qr-er80]] (⚠️ Connection: close requirement).
|
||||||
|
|
||||||
|
## [2026-06-16] feature | lane direction (per-device entry/exit) + camera snapshots
|
||||||
|
- Direction is per **device**, not per lane: added `direction` (`entry`/`exit`/`both`, default
|
||||||
|
`both`) to `lane_devices`. A lane's entry set = devices tagged entry|both, exit set likewise —
|
||||||
|
no join table; tagging in the wizard IS the grouping. Rejected a lane-level `lanes` table (can't
|
||||||
|
model one bidirectional lane). New [[lane-direction]] concept page; cross-linked [[entry-exit-readers]].
|
||||||
|
- All flows now resolve via `deviceRowsFor(lane, category, direction)` (lane-map.ts), replacing the
|
||||||
|
ad-hoc "first access on lane" lookups. Dispatcher trusts the **reader's own direction**; a
|
||||||
|
directional reader that contradicts the car's session state is a wrong-lane/anti-passback refusal.
|
||||||
|
`both` keeps the old infer-from-session behavior.
|
||||||
|
- Relay open channel is now config-driven (`config.openChannel`, default 1) since a lane can hold
|
||||||
|
an entry **and** an exit relay. LPR stays a snapshot sink — ANPR POSTs a `plate` read to the
|
||||||
|
reader endpoint (no camera-as-input coupling).
|
||||||
|
- Camera snapshots wired into all four paths (transient/permit × entry/exit): fired AFTER
|
||||||
|
pulseOpen, **never awaited** (evidence, not a gate — camera failure can't block an open). Stored
|
||||||
|
as BLOB in a new `snapshots` table (not files); telemetry `kind:"snapshot"` device_event per
|
||||||
|
capture/failure; linked to the signed event by `identity`. Served via `GET /api/snapshots/:id`.
|
||||||
|
- Migration `0002_wild_odin.sql` (additive). Snapshot **retention** left unresolved →
|
||||||
|
[[open-questions]] #10. Whole monorepo typechecks; no test suite exists in-repo.
|
||||||
|
|
||||||
|
## [2026-06-16] redesign | SUPERSEDES the above — pool-of-spaces, per-relay direction, NO lane
|
||||||
|
- User correction: direction is NOT a property of a device row. One Dingtian board has 2+ relays;
|
||||||
|
a single board drives both the entry barrier (relay 1) and the exit barrier (relay 2), and a
|
||||||
|
single relay can even serve **both**. So the row-level `direction` from the entry above was wrong.
|
||||||
|
- Further: the whole **"lane" concept was dropped**. Occupancy is site-wide, device grouping is now
|
||||||
|
the reader→relay binding, and anti-fraud never used lane. A parking lot = **one pool of spaces**
|
||||||
|
with a flexible set of entry/exit points (1 in + 2 out, etc). New [[entry-exit-points]] page
|
||||||
|
(replaces lane-direction); reworked [[entry-exit-readers]], [[parking-session]], [[first-run-setup]],
|
||||||
|
[[device-registry]].
|
||||||
|
- Model now: access `config.relays = [{ relay, direction: entry|exit|both, button? }]` (`button` =
|
||||||
|
the input terminal the entry button is wired to). Readers/cameras `config.controllerId + relay`
|
||||||
|
bind to the barrier they sit at; direction inherited ("the relay at that reader" opens on a read).
|
||||||
|
- Schema: dropped `lane` from `ledger_events`, `device_events`, `sessions`; renamed `lane_devices`
|
||||||
|
→ `devices` (no lane/direction columns). `lane` was in the SIGNED canonical form, so canonicalize()
|
||||||
|
dropped it and the signer keyId bumped **sw-hmac-v1 → sw-hmac-v2** (v1 events won't verify under
|
||||||
|
v2 — intentional, gated by per-event keyId; done pre-deployment on throwaway data). Migration
|
||||||
|
history reset to a fresh `0000_baseline` (dev DBs deleted + re-migrated).
|
||||||
|
- Resolvers in new `device-resolve.ts` (replaces lane-map.ts): `relayForButton`, `relayForDevice`,
|
||||||
|
`firstRelayByDirection`, `devicesByDirection`. `DeviceConfig` widened to nested JSON for `relays[]`.
|
||||||
|
- Wizard rewritten: no lane selector; Controllers section (relay map + entry-button terminal per
|
||||||
|
relay), then readers/cameras/printers bind to a controller relay. Whole monorepo typechecks +
|
||||||
|
builds; no test suite in-repo.
|
||||||
|
- Residual: incidental `lane_devices` / "per-lane" mentions remain in some secondary wiki pages
|
||||||
|
(device-events, device-input-flow, ticket-encoding, etc.) — flagged for a later lint pass.
|
||||||
|
|||||||
Binary file not shown.
@@ -0,0 +1,37 @@
|
|||||||
|
---
|
||||||
|
type: source
|
||||||
|
tags: [parking, hardware, readers, qr, datasheet]
|
||||||
|
sources: [gee-qr-er80]
|
||||||
|
updated: 2026-06-16
|
||||||
|
---
|
||||||
|
|
||||||
|
# Source: GEE-QR-ER80 QR Code Access Control Reader (datasheet)
|
||||||
|
|
||||||
|
Vendor datasheet (GEE NFC LIMITED, ©2007–2019) for the **GEE-QR-ER80** — a static
|
||||||
|
**QR-code access-control reader**, optional ID/IC card. The reader the project has
|
||||||
|
on hand for the [[ticket-encoding|QR ticket]] path. Raw:
|
||||||
|
`raw/GEE-QR-ER80 QR Code Access Control Reader.pdf` (3 pages). Entity: [[gee-qr-er80]].
|
||||||
|
|
||||||
|
## Key takeaways
|
||||||
|
|
||||||
|
- **Optical scanner**, not a prox-card reader: reads **QR, DataMatrix, 1D barcode** (static).
|
||||||
|
Optional add-ons for **IC card UID / ID card**.
|
||||||
|
- **Multi-interface:** **Wiegand 26/34, RS-232, RS-485, USB, TCP/IP** — selectable by variant.
|
||||||
|
- **Power:** 4–15 VDC, < 800 mA. **Read direction:** 360°. Built-in scanner LED.
|
||||||
|
- **OS:** Windows XP/7/8/10 **and Linux** (explicit) — fits the [[disk-os-hardening|Linux appliance]].
|
||||||
|
- **Wiring (Wiegand/RS-232/485 variant):** VCC(+12V), GND, **D0/D1** (Wiegand), **TX/R+ , RX/R-**
|
||||||
|
(RS-232 / RS-485), plus **LED** and **BEEP** control lines (host can drive feedback).
|
||||||
|
- **Order code** `GEE-QR-ER80-<scanner>-<interface>`: `Q`=QR scanner / `D`=ID reader / `C`=IC reader;
|
||||||
|
`W`=WG·RS232·RS485 / `U`=USB / `T`=RJ45 (TCP/IP). **On hand: `-Q-W`** (QR scanner; Wiegand/RS-232/RS-485).
|
||||||
|
|
||||||
|
## Section map
|
||||||
|
|
||||||
|
- p1 — overview, physical + feature table (interfaces, power, read direction).
|
||||||
|
- p2 — supported types (QR/DM/1D + optional IC/ID), OS, environment; **wire definition** (pin table).
|
||||||
|
- p3 — order-code breakdown, applications (access control / vacation rentals / time attendance).
|
||||||
|
|
||||||
|
## Not in this datasheet (open)
|
||||||
|
|
||||||
|
- The **RS-232/RS-485 data protocol**: baud rate, frame format, and whether a QR scan is emitted as
|
||||||
|
an **ASCII string** (expected) vs. some framed protocol. Decides the host-side adapter — see
|
||||||
|
[[gee-qr-er80]] open questions. Resolve by vendor docs or by observing the port on a scan.
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
---
|
||||||
|
type: source
|
||||||
|
tags: [parking, hardware, readers, qr, protocol, sdk]
|
||||||
|
sources: [qrcode-sdk]
|
||||||
|
updated: 2026-06-16
|
||||||
|
---
|
||||||
|
|
||||||
|
# Source: QRCode SDK v1.6.5 (GEE/Dingtian QR reader)
|
||||||
|
|
||||||
|
Vendor SDK for the QR access reader ([[gee-qr-er80]]; also branded Dingtian). Defines the
|
||||||
|
reader↔server **HTTP protocol** — the missing piece the datasheet omitted. Files at
|
||||||
|
`QRCode_sdk - QRCode_v1_6_5/sdk/` (config tool `QRCode_v1_6_5.exe`, demos in C#/PHP/VC++, protocol
|
||||||
|
docs `readme.txt`, `qrcode_HTTP_GET.txt`, `VC++/how to.txt`). **Not copied into `raw/`** — bulky +
|
||||||
|
binaries; this summary is the faithful capture. Entity: [[gee-qr-er80]].
|
||||||
|
|
||||||
|
## The protocol — HTTP GET poll, server replies the verdict
|
||||||
|
|
||||||
|
The reader is configured (via the Windows tool) with a **server IP/port + "server language"**
|
||||||
|
(JSP/PHP/C#/ASP/CGI — this only selects the URL path, e.g. `/qa/mcardsea.php`; transport is plain
|
||||||
|
HTTP either way). **On each scan** the reader sends:
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /qa/mcardsea.php?cardid=445D2C&mjihao=1&cjihao=HW256097&status=11&time=1540402036 HTTP/1.0
|
||||||
|
```
|
||||||
|
|
||||||
|
| Param | Meaning |
|
||||||
|
| --- | --- |
|
||||||
|
| `cardid` | **the scanned QR/barcode data** (or card id) |
|
||||||
|
| `mjihao` | device id (machine number) |
|
||||||
|
| `cjihao` | device serial number |
|
||||||
|
| `status` | **2 chars**: high = valid `1`/invalid `0` (reader's own pre-check), low = direction **`1`=in / `0`=out**. A 1-char status = fail. |
|
||||||
|
| `time` | UTC time |
|
||||||
|
|
||||||
|
**Server → reader reply (JSON) — this is the access DECISION and drives the beep + output:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"data":[{"cardid":"<echo>","cjihao":0,"mjihao":1,"status":1,"time":"<utc>","output":2}],"code":0,"message":""}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Reply field | Meaning (from the C# demo comments) |
|
||||||
|
| --- | --- |
|
||||||
|
| `status` | **`1` = valid → buzzer 2×; `0` = invalid → buzzer 1×** |
|
||||||
|
| `output` | **`0` = Access, `1` = WG26, `2` = WG34** — output line/format driven on a valid read |
|
||||||
|
| `time` | UTC — **can sync the device clock** |
|
||||||
|
| `code` | `0` = success |
|
||||||
|
|
||||||
|
> **Implication (explains the "no beep"):** the reader's beep/accept is decided by the **server's
|
||||||
|
> reply**, not locally. A non-JSON / missing reply ⇒ no valid feedback ⇒ no beep, even though the
|
||||||
|
> scan succeeded. So "no beep" ≠ "didn't scan" — it means the server didn't answer with the verdict.
|
||||||
|
|
||||||
|
## Integration consequence
|
||||||
|
|
||||||
|
This is **host-in-the-loop, synchronous**: the GET *is* the access query; our JSON reply *is* the
|
||||||
|
decision. So the backend endpoint must **decide (valid/invalid + direction) and reply** — richer
|
||||||
|
than a fire-and-forget read. Direction comes from the `status` low digit. See [[gee-qr-er80]] +
|
||||||
|
[[device-input-flow]].
|
||||||
|
|
||||||
|
## Defaults / misc
|
||||||
|
|
||||||
|
- Default device IP `192.168.1.99` (`readme.txt`).
|
||||||
|
- Demos: PHP `qa/mcardsea.php` (minimal echo, status 0), C# raw-socket server on :80 (full parse),
|
||||||
|
VC++ raw HTTP example. All show the same GET-in / JSON-out contract.
|
||||||
Reference in New Issue
Block a user