b8ddda86e7
Rounds out subscriptions across enrollment, the barrier flow, and the booth.
- RFID credentials enabled with a "Read card" enrollment flow: the operator
arms ONE chosen reader (CredentialCapture, single-shot + ~30s TTL); that
reader's next read is captured into the form and NOT dispatched to the access
flow — the OTHER reader keeps serving live entry/exit. Routes:
/api/subscriptions/readers + /capture/{arm,cancel} + poll.
- Enter with one credential, exit with another: sessions are keyed by a
per-occurrence id (SUBSESS-<short>), not the credential value, with
permitId in the payload. Direction is decided by the barrier the reader sits
at (entry-lane→entry, exit-lane→exit; "both" infers); a fleet (maxConcurrent>1)
admits several cars and exits any with any credential, FIFO (oldest first).
- Booth treats a subscription occurrence as PREPAID: never quoted/charged; the
pay/exit modal shows a subscription mode (snapshots + a single audited
Open-barrier action) to assist a faulty exit reader / missing card;
reopenBarrier authorizes paidAt!=null OR subscription. Active Sessions badges
"abonim" and labels by holder name (not the raw key).
- Plus a per-read diagnostic log in the QR-reader route (serial → device →
verdict/dir), which surfaced the earlier duplicate-reader-IP misroute.
Verified via buildServer+inject + reader-scan/TCP-capture simulations
(enrollment isolation, cross-credential + FIFO fleet, prepaid-not-charged,
subscription reopen, unpaid-transient guard). Updated wiki (subscription,
booth-exit-flow). No migration.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
129 lines
6.0 KiB
TypeScript
129 lines
6.0 KiB
TypeScript
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";
|
||
|
||
// 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,
|
||
capture: CredentialCapture,
|
||
): 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 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: "gee-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);
|
||
}
|
||
}
|