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.
This commit is contained in:
2026-06-16 12:12:09 +02:00
parent f67c1ead87
commit 392d44d842
8 changed files with 173 additions and 30 deletions
+7 -6
View File
@@ -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<void> {
async dispatch(e: DeviceReadEvent): Promise<ReadOutcome> {
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);
}
}