1efa77bf56
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.
110 lines
4.7 KiB
TypeScript
110 lines
4.7 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";
|
||
|
||
// 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);
|
||
}
|
||
}
|