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"; import type { CredentialCapture } from "../credential-capture.js"; // Dingtian DT-008 QR/RFID 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/dingtian-dt008-reader.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, capture: CredentialCapture, ): 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 dingtian-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/dingtian-dt008-reader.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 matchedRowId = readerRowIdForSerial(serial); const deviceId = matchedRowId ?? serial; let accepted = false; if (cardid) { // ENROLLMENT INTERCEPT: if THIS reader is armed for credential capture, grab the // value for the subscription form and do NOT run the access flow (we must not // open a barrier for a card being enrolled). Single-shot — capture auto-disarms. // Reads from the OTHER reader are untouched and dispatch normally below. if (capture.tryConsume(deviceId, cardid)) { app.log.info(`CAPTURE serial=${serial || "?"} device=${matchedRowId ? matchedRowId.slice(0, 8) : "?"} value=${cardid}`); accepted = true; // beep "ok" so the operator knows the card was read } else { const read: DeviceReadEvent = { driverId: "dingtian-qr-reader", deviceId, value: cardid, kind: "qr", at: new Date().toISOString(), }; try { const outcome = await dispatcher.dispatch(read); accepted = outcome.accepted; // Per-read diagnostic: which reader (serial) sent it, which configured device // it mapped to, and the verdict — so a barrier/serial mismatch is visible in // the logs (e.g. an entry-side scan resolving to the exit relay). app.log.info( `READ serial=${serial || "?"} → device=${matchedRowId ? matchedRowId.slice(0, 8) : "UNASSIGNED"} ` + `card=${cardid} verdict=${accepted ? "ACCEPT" : "REJECT"}${outcome.direction ? ` dir=${outcome.direction}` : ""}` + `${accepted ? "" : ` reason="${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); } }