Files
parking_solution/apps/server/src/routes/qr-reader.ts
T
julian 1efa77bf56 devices: pool-of-spaces model — drop lane, per-relay direction
A parking lot is one pool of spaces with a flexible set of entry/exit
points — no "lane". Direction is a property of each RELAY inside an access
controller; readers/cameras bind to a controller relay and inherit it.

Schema:
- drop `lane` from ledger_events, device_events, sessions
- rename lane_devices -> devices (no lane/direction columns)
- access config.relays=[{relay,direction,button?}]; reader/camera
  config.controllerId+relay binding
- fresh 0000_baseline migration (history reset; dev data was throwaway)

Signed ledger:
- remove `lane` from canonicalize(); bump signer keyId sw-hmac-v1 -> v2
  (v1 events won't verify under v2 — intentional, gated per-event by keyId)

Server:
- new device-resolve.ts (replaces lane-map.ts): relayForButton,
  relayForDevice, firstRelayByDirection, devicesByDirection
- entry-flow: button terminal -> its relay; exit/permit: reader's bound
  relay; dispatcher resolves the bound relay + inherited direction
- camera snapshots fire by direction site-wide, async, never block open
- DeviceConfig widened to nested JSON for relays[]

Web:
- wizard: no lane selector; add controllers (relay map + entry-button
  terminal) first, then bind readers/cameras/printers to a controller relay

Wiki: new entry-exit-points.md (replaces lane-direction); reworked
entry-exit-readers, parking-session, first-run-setup, device-registry,
append-only-event-chain, device-events; removed stale lane/LaneMap mentions.
2026-06-16 20:29:38 +02:00

110 lines
4.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}
}