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; } // ── CHANNEL TAGGING (2026-07-04) ──────────────────────────────────────────────── // The DT-008 push carries one opaque `cardid` whether its OPTICAL engine decoded a // QR/barcode or its RF engine read a card — the server can't tell them apart. That // enabled a cheap clone: print a card's UID (often written on the card face) as a // barcode and the optical decode matches the RF credential. Fix: the vendor tool's // "QRCode Output Prefix" / "Card Output Prefix" are set to the markers below on every // reader; the route strips the prefix and tags the read's confirmed channel, and the // subscription match refuses a channel-mismatched credential. A read with NO prefix // stays the legacy untagged shape (kind "qr", channel undefined) so an unconfigured // reader keeps working — the enforcement only bites where prefixes are deployed. // ⚠️ Prefixes must MATCH the vendor tool; also FREEZE "Card Input format" (6H) — that // setting defines the UID shape we enroll. See wiki/entities/dingtian-dt008-reader.md. const QR_CHANNEL_PREFIX = process.env.READER_QR_PREFIX ?? "Q:"; const CARD_CHANNEL_PREFIX = process.env.READER_CARD_PREFIX ?? "K:"; /** Split a raw pushed `cardid` into its bare value + confirmed channel (if prefixed). */ export function splitChannel(raw: string): { value: string; kind: "qr" | "card"; channel?: "optical" | "rf"; } { if (CARD_CHANNEL_PREFIX.length > 0 && raw.startsWith(CARD_CHANNEL_PREFIX)) { return { value: raw.slice(CARD_CHANNEL_PREFIX.length), kind: "card", channel: "rf" }; } if (QR_CHANNEL_PREFIX.length > 0 && raw.startsWith(QR_CHANNEL_PREFIX)) { return { value: raw.slice(QR_CHANNEL_PREFIX.length), kind: "qr", channel: "optical" }; } return { value: raw, kind: "qr" }; // legacy: unprefixed reader, channel unknown } 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 scan = splitChannel(cardid); // bare value + confirmed channel (if prefixed) 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 (scan.value) { // 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. // Captured BARE (prefix stripped) so enrolled values match future stripped reads. if (capture.tryConsume(deviceId, scan.value)) { app.log.info(`CAPTURE serial=${serial || "?"} device=${matchedRowId ? matchedRowId.slice(0, 8) : "?"} value=${scan.value}${scan.channel ? ` ch=${scan.channel}` : ""}`); accepted = true; // beep "ok" so the operator knows the card was read } else { const read: DeviceReadEvent = { driverId: "dingtian-qr-reader", deviceId, value: scan.value, kind: scan.kind, ...(scan.channel ? { channel: scan.channel } : {}), 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, the confirmed channel (if prefixed), and the verdict — so a // barrier/serial mismatch or a channel anomaly is visible in the logs. app.log.info( `READ serial=${serial || "?"} → device=${matchedRowId ? matchedRowId.slice(0, 8) : "UNASSIGNED"} ` + `card=${scan.value}${scan.channel ? ` ch=${scan.channel}` : ""} verdict=${accepted ? "ACCEPT" : "REJECT"}${outcome.direction ? ` dir=${outcome.direction}` : ""}` + `${accepted ? "" : ` reason="${outcome.reason ?? "?"}"`}`, ); } catch (err) { app.log.error(`QR dispatch failed for ${scan.value}: ${(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); } }