From 392d44d842c4afbcf51302bbbd35fccbbb9b72e3 Mon Sep 17 00:00:00 2001 From: Julian Cuni Date: Tue, 16 Jun 2026 12:12:09 +0200 Subject: [PATCH] server: GEE/Dingtian QR reader endpoint + synchronous ReadOutcome The reader HTTP-GETs on each scan and beeps/acts on our JSON reply (host-in-the- loop, synchronous). New route GET/POST /qa/mcardsea.php parses the SDK query, runs the scan through the read dispatcher (permit match -> permit flow; else transient exit), and replies the SDK verdict: status 1=valid (beep 2x) / 0=invalid (beep 1x), output, time-sync. Refactored the read flows to return a ReadOutcome {accepted, direction, reason} so the reply reflects the real accept/reject decision (ReadDispatcher.dispatch, ExitFlow.handleAt, PermitFlow.run). Fire-and-forget readers ignore it. Reader's lane is keyed off its serial (cjihao) as lane_devices.id for now; endpoint is public (reader has no auth, on the device subnet). Verified via inject: valid permit QR -> status:1 + open; re-scan -> permit exit; unknown QR -> status:0; barrier-less lane -> status:0. --- apps/server/src/device-events.ts | 14 +++++ apps/server/src/exit-flow.ts | 19 ++++--- apps/server/src/permit-flow.ts | 26 +++++---- apps/server/src/read-dispatch.ts | 13 ++--- apps/server/src/routes/qr-reader.ts | 83 +++++++++++++++++++++++++++++ apps/server/src/server.ts | 6 +++ wiki/entities/gee-qr-er80.md | 26 +++++++-- wiki/log.md | 16 ++++++ 8 files changed, 173 insertions(+), 30 deletions(-) create mode 100644 apps/server/src/routes/qr-reader.ts diff --git a/apps/server/src/device-events.ts b/apps/server/src/device-events.ts index 22b3549..f2c97fb 100644 --- a/apps/server/src/device-events.ts +++ b/apps/server/src/device-events.ts @@ -26,6 +26,20 @@ export interface DeviceReadEvent { 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). */ export interface PrinterStatusEvent { readonly deviceId: string; // lane_devices id diff --git a/apps/server/src/exit-flow.ts b/apps/server/src/exit-flow.ts index 2dc5c37..cc1a1bf 100644 --- a/apps/server/src/exit-flow.ts +++ b/apps/server/src/exit-flow.ts @@ -2,7 +2,7 @@ import { and, eq, laneDevices, ledgerEvents, sessions, type Db } from "@parking/ import { registry, type AccessControlDevice } from "@parking/devices"; import type { LedgerPayload } from "@parking/shared"; import type { FastifyBaseLogger } from "fastify"; -import type { DeviceReadEvent } from "./device-events.js"; +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 @@ -46,32 +46,34 @@ export class ExitFlow { /** Handle a transient-ticket read at a known exit lane (lane pre-resolved by the * read dispatcher, which has already ruled out a permit match). */ - async handleAt(lane: number, e: DeviceReadEvent): Promise { + async handleAt(lane: number, e: DeviceReadEvent): Promise { const key = `${e.deviceId}:${e.value}`; - if (this.#inFlight.has(key)) return; + if (this.#inFlight.has(key)) return { accepted: false, reason: "duplicate read in flight" }; this.#inFlight.add(key); try { - await this.#runExit(lane, e); + return await this.#runExit(lane, e); } catch (err) { this.#logger.error(`exit-flow failed (lane ${lane}): ${(err as Error).message}`); + return { accepted: false, reason: (err as Error).message }; } finally { this.#inFlight.delete(key); } } - async #runExit(lane: number, e: DeviceReadEvent): Promise { + async #runExit(lane: number, e: DeviceReadEvent): Promise { 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", lane, identity: e.value, - payload: { reason: view ? "exit refused — session already closed" : "exit refused — no open session for credential", exitRefused: true }, + payload: { reason, exitRefused: true }, }); this.#logger.warn(`exit refused (lane ${lane}): no open session for ${e.value}`); - return; + return { accepted: false, direction: "exit", reason }; } // PAID + within walk-back grace? @@ -92,7 +94,7 @@ export class ExitFlow { payload: { reason, exitRefused: true, sessionRef: e.value }, }); this.#logger.warn(`exit refused (lane ${lane}, ${e.value}): ${reason}`); - return; + return { accepted: false, direction: "exit", reason }; } // Valid: sign the exit BEFORE opening, then open, then update the cache. @@ -121,6 +123,7 @@ export class ExitFlow { } 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). */ diff --git a/apps/server/src/permit-flow.ts b/apps/server/src/permit-flow.ts index 279c17b..1f54f52 100644 --- a/apps/server/src/permit-flow.ts +++ b/apps/server/src/permit-flow.ts @@ -1,7 +1,7 @@ import { and, eq, laneDevices, ledgerEvents, permitCredentials, permitPlates, permits, sessions, type Db } from "@parking/db"; import { registry, type AccessControlDevice } from "@parking/devices"; import type { FastifyBaseLogger } from "fastify"; -import type { DeviceReadEvent } from "./device-events.js"; +import type { DeviceReadEvent, ReadOutcome } from "./device-events.js"; import type { EventLog } from "./event-log.js"; // PERMIT flow: a subscriber identified by card/QR/plate enters/exits without paying. @@ -58,22 +58,23 @@ export class PermitFlow { } /** Run the permit entry/exit for a matched read at a lane. */ - async run(lane: number, e: DeviceReadEvent, m: PermitMatch): Promise { + async run(lane: number, e: DeviceReadEvent, m: PermitMatch): Promise { const key = `${m.permitId}:${m.carKey}`; - if (this.#inFlight.has(key)) return; + if (this.#inFlight.has(key)) return { accepted: false, reason: "duplicate read in flight" }; this.#inFlight.add(key); try { - await this.#run(lane, e, m); + return await this.#run(lane, e, m); } catch (err) { this.#logger.error(`permit-flow failed (lane ${lane}): ${(err as Error).message}`); + return { accepted: false, reason: (err as Error).message }; } finally { this.#inFlight.delete(key); } } - async #run(lane: number, e: DeviceReadEvent, m: PermitMatch): Promise { + async #run(lane: number, e: DeviceReadEvent, m: PermitMatch): Promise { const permit = this.#db.select().from(permits).where(eq(permits.id, m.permitId)).get(); - if (!permit) return; + if (!permit) return { accepted: false, reason: "permit not found" }; // Validity: active + within the coverage window. const now = new Date().toISOString(); @@ -82,8 +83,9 @@ export class PermitFlow { (permit.validFrom != null && now < permit.validFrom) || (permit.validTo != null && now > permit.validTo); if (invalid) { - await this.#reject(lane, m, `permit ${permit.status}/out-of-window`); - return; + const reason = `permit ${permit.status}/out-of-window`; + await this.#reject(lane, m, reason); + return { accepted: false, reason }; } const carOpen = this.#carHasOpenSession(m.carKey); @@ -100,15 +102,16 @@ export class PermitFlow { }); await this.#open(lane, m.carKey, "permit exit"); this.#closeCache(m.carKey); - return; + 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) { - await this.#reject(lane, m, `permit at capacity (${open}/${permit.maxConcurrent} cars in)`); - return; + const reason = `permit at capacity (${open}/${permit.maxConcurrent} cars in)`; + await this.#reject(lane, m, reason); + return { accepted: false, direction: "entry", reason }; } } @@ -131,6 +134,7 @@ export class PermitFlow { } 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? */ diff --git a/apps/server/src/read-dispatch.ts b/apps/server/src/read-dispatch.ts index 5809dcb..2163836 100644 --- a/apps/server/src/read-dispatch.ts +++ b/apps/server/src/read-dispatch.ts @@ -1,6 +1,6 @@ import type { Db } from "@parking/db"; import type { FastifyBaseLogger } from "fastify"; -import type { DeviceReadEvent } from "./device-events.js"; +import type { DeviceReadEvent, ReadOutcome } from "./device-events.js"; import type { ExitFlow } from "./exit-flow.js"; import type { PermitFlow } from "./permit-flow.js"; import { readerLaneWithAccess } from "./lane-map.js"; @@ -26,16 +26,17 @@ export class ReadDispatcher { this.#logger = logger; } - async dispatch(e: DeviceReadEvent): Promise { + async dispatch(e: DeviceReadEvent): Promise { const lane = await readerLaneWithAccess(this.#db, e.deviceId); - if (lane == null) return; // reader not on an access-equipped lane — ignore + if (lane == null) { + return { accepted: false, reason: "reader not on an access-equipped lane" }; + } const permit = this.#permit.match(e); if (permit) { - await this.#permit.run(lane, e, permit); - return; + return this.#permit.run(lane, e, permit); } // Not a permit → transient ticket exit (the exit flow rejects+logs if unknown). - await this.#exit.handleAt(lane, e); + return this.#exit.handleAt(lane, e); } } diff --git a/apps/server/src/routes/qr-reader.ts b/apps/server/src/routes/qr-reader.ts new file mode 100644 index 0000000..76d0317 --- /dev/null +++ b/apps/server/src/routes/qr-reader.ts @@ -0,0 +1,83 @@ +import type { FastifyInstance } from "fastify"; +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=&mjihao=&cjihao=&status=<2ch>&time= +// 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; +} + +const SDK_PATH = "/qa/mcardsea.php"; + +export async function qrReaderRoutes(app: FastifyInstance, dispatcher: ReadDispatcher): Promise { + // 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 }) => { + const q = req.query; + const cardid = (q.cardid ?? "").trim(); + const mjihao = q.mjihao != null ? Number(q.mjihao) : 0; + + // The device id we map to a lane is the configured reader's lane_devices id. + // The reader sends its own mjihao/cjihao; the admin records that as the device's + // config so we can resolve it. For now we key the read on the device serial + // (cjihao) as the lane_devices id — see wiki note; refine when assignment lands. + const deviceId = (q.cjihao ?? "").trim() || String(mjihao); + + let accepted = false; + if (cardid) { + const read: DeviceReadEvent = { + driverId: "gee-qr-er80", + 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 uses GET; accept POST too in case a variant differs. + app.get<{ Querystring: ReaderQuery }>(SDK_PATH, handler); + app.post<{ Querystring: ReaderQuery }>(SDK_PATH, handler); +} diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 33e957e..4dc5120 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -20,6 +20,7 @@ import { deviceRoutes } from "./routes/devices.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 { tariffRoutes } from "./routes/tariffs.js"; @@ -113,6 +114,11 @@ export async function buildServer(opts: BuildOptions = {}): Promise 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, 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); diff --git a/wiki/entities/gee-qr-er80.md b/wiki/entities/gee-qr-er80.md index 78e6b01..7dc46f0 100644 --- a/wiki/entities/gee-qr-er80.md +++ b/wiki/entities/gee-qr-er80.md @@ -56,9 +56,25 @@ barrier. ([[device-input-flow]] is the analogous push pattern; this one also ret 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. -## Next +## As-built (2026-06-16) -A backend route (like `routes/devices.ts` for the Dingtian push) that parses the GET, **decides** -(reuse the permit/exit lookup), replies the JSON verdict, and emits on the `read` bus. The -[[device-registry]] `reader` entry can model it for [[first-run-setup]] (server IP/port are set in -the vendor tool; the app side is the endpoint). Build-ready — protocol fully known. +- **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. +- **Lane mapping:** the endpoint keys the reader's `lane_devices` id off the device **serial + (`cjihao`)** for now — so assign the reader with `lane_devices.id = `. Refine when the + setup wizard models the reader's server-side identity properly. +- 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`. + +## Open / to confirm on hardware + +- A **live scan** still hadn't reached the server during bring-up (no beep). With the real endpoint + now replying the verdict, re-test: scan → expect a beep + a GET in the server log. If still + nothing, it's the reader's scan/trigger/mode (not the server). +- **Reader→lane identity:** confirm what the device actually sends as `cjihao`/`mjihao` and align the + `lane_devices` assignment (the wizard doesn't yet capture the reader's serial as its id). diff --git a/wiki/log.md b/wiki/log.md index 6f11c59..1b135dd 100644 --- a/wiki/log.md +++ b/wiki/log.md @@ -640,3 +640,19 @@ guarantee. Recorded in [[dingtian-relay]] (new Hardening section). [[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).