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=&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; } export async function qrReaderRoutes( app: FastifyInstance, db: Db, dispatcher: ReadDispatcher, ): Promise { // 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); } }