Compare commits
10 Commits
6df9927b94
...
2a86e578a8
| Author | SHA1 | Date | |
|---|---|---|---|
| 2a86e578a8 | |||
| 382c32f2bc | |||
| 7fd407ac82 | |||
| 0375227a16 | |||
| 3294f188dd | |||
| 23919164ee | |||
| 355026dcf7 | |||
| 1b55e2034d | |||
| 4319fb86dc | |||
| dbf1fa17d7 |
+3
-1
@@ -17,4 +17,6 @@ dist/
|
|||||||
*:Zone.Identifier
|
*:Zone.Identifier
|
||||||
.playwright-mcp/
|
.playwright-mcp/
|
||||||
# stray hardware/UI test screenshots
|
# stray hardware/UI test screenshots
|
||||||
/*.png
|
/*.png
|
||||||
|
# Vendor device SDKs (reference only — protocol captured in wiki, not committed)
|
||||||
|
/dingtian/
|
||||||
|
|||||||
@@ -21,8 +21,7 @@
|
|||||||
"@parking/shared": "workspace:*",
|
"@parking/shared": "workspace:*",
|
||||||
"bcrypt": "6.0.0",
|
"bcrypt": "6.0.0",
|
||||||
"fastify": "5.8.5",
|
"fastify": "5.8.5",
|
||||||
"fastify-plugin": "6.0.0",
|
"fastify-plugin": "6.0.0"
|
||||||
"uhppoted": "0.9.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bcrypt": "6.0.0",
|
"@types/bcrypt": "6.0.0",
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
// Dingtian relay+input hardware test.
|
||||||
|
//
|
||||||
|
// node apps/server/scripts/dingtian-test.mjs # status only (safe)
|
||||||
|
// node apps/server/scripts/dingtian-test.mjs watch # live input/button monitor
|
||||||
|
// node apps/server/scripts/dingtian-test.mjs pulse 1 # pulse relay 1 (prompts)
|
||||||
|
//
|
||||||
|
// Env: DINGTIAN_HOST (default 10.0.10.172), DINGTIAN_PORT (60001).
|
||||||
|
//
|
||||||
|
// SAFETY: `pulse` fires a relay → the barrier may move. It prompts first unless
|
||||||
|
// YES=1. pulseOpen is momentary (the device self-releases).
|
||||||
|
|
||||||
|
import { createInterface } from "node:readline/promises";
|
||||||
|
import { stdin, stdout } from "node:process";
|
||||||
|
import { createRequire } from "node:module";
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
const { dingtianDriver } = require("@parking/devices");
|
||||||
|
|
||||||
|
const host = process.env.DINGTIAN_HOST ?? "10.0.10.172";
|
||||||
|
const port = process.env.DINGTIAN_PORT ? Number(process.env.DINGTIAN_PORT) : 60001;
|
||||||
|
const dev = dingtianDriver.create({ host, port, channels: 4 });
|
||||||
|
|
||||||
|
const mode = process.argv[2] ?? "status";
|
||||||
|
console.log(`dingtian @ ${host}:${port}\n`);
|
||||||
|
|
||||||
|
async function showStatus() {
|
||||||
|
const health = await dev.healthCheck();
|
||||||
|
console.log("health:", JSON.stringify(health));
|
||||||
|
const inputs = await dev.readInputs();
|
||||||
|
console.log("inputs (active=pressed):", inputs.map((v, i) => `in${i + 1}=${v ? "ON" : "off"}`).join(" "));
|
||||||
|
for (let ch = 1; ch <= 4; ch++) {
|
||||||
|
console.log(`relay ${ch}:`, await dev.getDoorStatus(ch));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === "status") {
|
||||||
|
await showStatus();
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === "watch") {
|
||||||
|
console.log("── press the buttons on the inputs — Ctrl-C to stop ──\n");
|
||||||
|
const unsub = dev.onInput((e) => {
|
||||||
|
console.log(`[${e.at}] input ${e.input} ${e.edge.toUpperCase()}`);
|
||||||
|
});
|
||||||
|
process.on("SIGINT", () => {
|
||||||
|
unsub();
|
||||||
|
console.log("\nstopped.");
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
// keep alive
|
||||||
|
await new Promise(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === "pulse") {
|
||||||
|
const ch = Number(process.argv[3] ?? 1);
|
||||||
|
if (process.env.YES !== "1") {
|
||||||
|
const rl = createInterface({ input: stdin, output: stdout });
|
||||||
|
const ans = (await rl.question(`Pulse relay ${ch}? (barrier may move) [y/N] `)).trim();
|
||||||
|
rl.close();
|
||||||
|
if (ans.toLowerCase() !== "y") {
|
||||||
|
console.log("aborted.");
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await dev.pulseOpen(ch);
|
||||||
|
console.log(`pulsed relay ${ch}.`);
|
||||||
|
// show the relay state right after (likely back off — pulse is momentary)
|
||||||
|
setTimeout(async () => {
|
||||||
|
console.log(`relay ${ch} now:`, await dev.getDoorStatus(ch));
|
||||||
|
process.exit(0);
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
// Shared helpers for the UHPPOTE hardware test scripts.
|
|
||||||
// Run directly against the device (independent of the HTTP server).
|
|
||||||
//
|
|
||||||
// node apps/server/scripts/uhppote-listen.mjs
|
|
||||||
// node apps/server/scripts/uhppote-relay.mjs
|
|
||||||
//
|
|
||||||
// Env overrides:
|
|
||||||
// UHPPOTE_SERIAL controller serial (default 225088491)
|
|
||||||
// UHPPOTE_HOST controller IP (default 10.0.10.3)
|
|
||||||
// UHPPOTE_BCAST Config broadcast (default derived from HOST subnet)
|
|
||||||
// HOST_IP this host's IP the controller pushes events to
|
|
||||||
// (default: auto-detected interface on the controller's subnet)
|
|
||||||
|
|
||||||
import { networkInterfaces } from "node:os";
|
|
||||||
import { createRequire } from "node:module";
|
|
||||||
|
|
||||||
const require = createRequire(import.meta.url);
|
|
||||||
const uhppoted = require("uhppoted");
|
|
||||||
|
|
||||||
export const SERIAL = Number(process.env.UHPPOTE_SERIAL ?? 225088491);
|
|
||||||
export const HOST = process.env.UHPPOTE_HOST ?? "10.0.10.3";
|
|
||||||
|
|
||||||
/** Subnet-directed broadcast for the interface that owns `ip`. */
|
|
||||||
function broadcastForHost(ip) {
|
|
||||||
const o = ip.split(".").map(Number);
|
|
||||||
for (const ifaces of Object.values(networkInterfaces())) {
|
|
||||||
for (const i of ifaces ?? []) {
|
|
||||||
if (i.family !== "IPv4" || i.internal) continue;
|
|
||||||
const a = i.address.split(".").map(Number);
|
|
||||||
const m = i.netmask.split(".").map(Number);
|
|
||||||
if (o.every((x, k) => (x & m[k]) === (a[k] & m[k]))) {
|
|
||||||
return a.map((x, k) => (x & m[k]) | (~m[k] & 0xff)).join(".");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "255.255.255.255";
|
|
||||||
}
|
|
||||||
|
|
||||||
/** This host's own IP on the controller's subnet (where it should push events). */
|
|
||||||
export function hostIpOnControllerSubnet(ip = HOST) {
|
|
||||||
if (process.env.HOST_IP) return process.env.HOST_IP;
|
|
||||||
const o = ip.split(".").map(Number);
|
|
||||||
for (const ifaces of Object.values(networkInterfaces())) {
|
|
||||||
for (const i of ifaces ?? []) {
|
|
||||||
if (i.family !== "IPv4" || i.internal) continue;
|
|
||||||
const a = i.address.split(".").map(Number);
|
|
||||||
const m = i.netmask.split(".").map(Number);
|
|
||||||
if (o.every((x, k) => (x & m[k]) === (a[k] & m[k]))) return i.address;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const BCAST = process.env.UHPPOTE_BCAST ?? broadcastForHost(HOST);
|
|
||||||
|
|
||||||
export function makeCtx(timeoutMs = 5000) {
|
|
||||||
return {
|
|
||||||
config: new uhppoted.Config(
|
|
||||||
"parking",
|
|
||||||
"0.0.0.0",
|
|
||||||
`${BCAST}:60000`,
|
|
||||||
"0.0.0.0:60001",
|
|
||||||
timeoutMs,
|
|
||||||
[],
|
|
||||||
false,
|
|
||||||
),
|
|
||||||
locale: "en-US",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export const controller = { id: SERIAL, address: HOST, protocol: "udp" };
|
|
||||||
export { uhppoted };
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
// Live button/event listener for the UHPPOTE controller.
|
|
||||||
//
|
|
||||||
// Points the controller's event listener at THIS host, then prints each pushed
|
|
||||||
// event in real time. Press the door buttons on the controller and watch them
|
|
||||||
// appear. Ctrl-C to stop.
|
|
||||||
//
|
|
||||||
// node apps/server/scripts/uhppote-listen.mjs
|
|
||||||
|
|
||||||
import {
|
|
||||||
controller,
|
|
||||||
hostIpOnControllerSubnet,
|
|
||||||
makeCtx,
|
|
||||||
uhppoted,
|
|
||||||
} from "./uhppote-common.mjs";
|
|
||||||
|
|
||||||
const ctx = makeCtx();
|
|
||||||
|
|
||||||
const hostIp = hostIpOnControllerSubnet();
|
|
||||||
if (!hostIp) {
|
|
||||||
console.error("Could not determine this host's IP on the controller's subnet.");
|
|
||||||
console.error("Set HOST_IP=<your-ip-on-the-controller-LAN> and retry.");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`controller : ${controller.id} @ ${controller.address}`);
|
|
||||||
console.log(`this host : ${hostIp} (events will be pushed here on :60001)`);
|
|
||||||
|
|
||||||
// 0) Remember the controller's current listener so we can restore it on exit
|
|
||||||
// (it was pointing somewhere else, e.g. 10.0.10.241).
|
|
||||||
let prevListener = null;
|
|
||||||
try {
|
|
||||||
prevListener = await uhppoted.getListener(ctx, controller);
|
|
||||||
console.log(`prior listener: ${prevListener.address}:${prevListener.port} (will restore on exit)`);
|
|
||||||
} catch (e) {
|
|
||||||
console.warn("getListener (non-fatal):", e.code ?? e.message);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1) Tell the controller to push events to us.
|
|
||||||
try {
|
|
||||||
const r = await uhppoted.setListener(ctx, controller, hostIp, 60001);
|
|
||||||
console.log("setListener:", JSON.stringify(r));
|
|
||||||
} catch (e) {
|
|
||||||
console.error("setListener failed:", e.code ?? e.message);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2) (Best-effort) ensure door open/close + button events are recorded.
|
|
||||||
try {
|
|
||||||
await uhppoted.recordSpecialEvents(ctx, controller, true);
|
|
||||||
console.log("recordSpecialEvents: enabled");
|
|
||||||
} catch (e) {
|
|
||||||
console.warn("recordSpecialEvents (non-fatal):", e.code ?? e.message);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("\n── listening — press the door buttons on the controller ──\n");
|
|
||||||
|
|
||||||
function describe(ev) {
|
|
||||||
const e = ev?.state?.event ?? ev?.event;
|
|
||||||
const buttons = ev?.state?.buttons;
|
|
||||||
const doors = ev?.state?.doors;
|
|
||||||
const parts = [];
|
|
||||||
if (e) {
|
|
||||||
parts.push(
|
|
||||||
`event#${e.index} type=${e.type?.event ?? e.type?.code} door=${e.door} granted=${e.granted} reason="${e.reason?.reason ?? e.reason?.code}" @${e.timestamp}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (buttons) {
|
|
||||||
const pressed = Object.entries(buttons).filter(([, v]) => v).map(([k]) => k);
|
|
||||||
parts.push(`buttons=[${pressed.join(",") || "none"}]`);
|
|
||||||
}
|
|
||||||
if (doors) {
|
|
||||||
const open = Object.entries(doors).filter(([, v]) => v).map(([k]) => k);
|
|
||||||
parts.push(`doorsOpen=[${open.join(",") || "none"}]`);
|
|
||||||
}
|
|
||||||
return parts.join(" ");
|
|
||||||
}
|
|
||||||
|
|
||||||
uhppoted.listen(
|
|
||||||
ctx,
|
|
||||||
(event) => {
|
|
||||||
console.log(`[${new Date().toISOString()}] ${describe(event)}`);
|
|
||||||
},
|
|
||||||
(err) => {
|
|
||||||
console.error("listen error:", err?.message ?? err);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
process.on("SIGINT", async () => {
|
|
||||||
// Restore the controller's previous listener so we don't hijack it.
|
|
||||||
if (prevListener && prevListener.address && prevListener.address !== "0.0.0.0") {
|
|
||||||
try {
|
|
||||||
await uhppoted.setListener(ctx, controller, prevListener.address, prevListener.port);
|
|
||||||
console.log(`\nrestored listener -> ${prevListener.address}:${prevListener.port}`);
|
|
||||||
} catch (e) {
|
|
||||||
console.warn("\ncould not restore listener:", e.code ?? e.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
console.log("stopped.");
|
|
||||||
process.exit(0);
|
|
||||||
});
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
// Guarded relay (door-open) test for the UHPPOTE controller.
|
|
||||||
//
|
|
||||||
// Prompts before firing each relay so a door only opens when you're ready and
|
|
||||||
// watching. This is a 2-door controller, so it tests doors 1 and 2 by default.
|
|
||||||
//
|
|
||||||
// node apps/server/scripts/uhppote-relay.mjs # doors 1,2 (prompted)
|
|
||||||
// node apps/server/scripts/uhppote-relay.mjs 1 # only door 1
|
|
||||||
// YES=1 node apps/server/scripts/uhppote-relay.mjs # no prompts (fires!)
|
|
||||||
//
|
|
||||||
// SAFETY: openDoor only expresses INTENT to open. The controller / barrier
|
|
||||||
// operator owns the close timing and anti-crush — we never time a close.
|
|
||||||
|
|
||||||
import { createInterface } from "node:readline/promises";
|
|
||||||
import { stdin, stdout } from "node:process";
|
|
||||||
import { controller, makeCtx, uhppoted } from "./uhppote-common.mjs";
|
|
||||||
|
|
||||||
const ctx = makeCtx();
|
|
||||||
const doors = process.argv.slice(2).map(Number).filter((n) => n >= 1 && n <= 4);
|
|
||||||
const targets = doors.length ? doors : [1, 2];
|
|
||||||
const autoYes = process.env.YES === "1";
|
|
||||||
|
|
||||||
console.log(`controller : ${controller.id} @ ${controller.address}`);
|
|
||||||
console.log(`testing doors: ${targets.join(", ")}${autoYes ? " (auto, no prompts)" : ""}\n`);
|
|
||||||
|
|
||||||
const rl = autoYes ? null : createInterface({ input: stdin, output: stdout });
|
|
||||||
|
|
||||||
for (const door of targets) {
|
|
||||||
if (rl) {
|
|
||||||
const ans = await rl.question(`Open door ${door}? [y/N] `);
|
|
||||||
if (ans.trim().toLowerCase() !== "y") {
|
|
||||||
console.log(` skipped door ${door}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const res = await uhppoted.openDoor(ctx, controller, door);
|
|
||||||
console.log(` door ${door}: openDoor -> ${JSON.stringify(res)}`);
|
|
||||||
} catch (e) {
|
|
||||||
console.log(` door ${door}: ERROR ${e.code ?? e.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
rl?.close();
|
|
||||||
console.log("\ndone.");
|
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { EventEmitter } from "node:events";
|
||||||
|
|
||||||
|
// Internal event bus for device-originated events (button presses, etc.).
|
||||||
|
// Hardware drivers / inbound device pushes emit here; business logic (entry
|
||||||
|
// flow, event-log) subscribes — keeping the HTTP/transport layer thin and the
|
||||||
|
// app device-agnostic. See wiki/entities/fastify.md.
|
||||||
|
|
||||||
|
export interface DeviceInputEvent {
|
||||||
|
readonly driverId: string; // e.g. "dingtian"
|
||||||
|
readonly deviceId: string; // which configured device (lane_devices id)
|
||||||
|
readonly input: number; // 1-based input/channel
|
||||||
|
readonly edge: "on" | "off"; // active / inactive
|
||||||
|
readonly at: string; // ISO-8601 (server receive time)
|
||||||
|
readonly source: "push" | "poll";
|
||||||
|
}
|
||||||
|
|
||||||
|
class DeviceEventBus extends EventEmitter {
|
||||||
|
emitInput(event: DeviceInputEvent): void {
|
||||||
|
this.emit("input", event);
|
||||||
|
}
|
||||||
|
onInput(cb: (event: DeviceInputEvent) => void): () => void {
|
||||||
|
this.on("input", cb);
|
||||||
|
return () => this.off("input", cb);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Process-wide device event bus. */
|
||||||
|
export const deviceEvents = new DeviceEventBus();
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
||||||
|
import type { FastifyReply, FastifyRequest } from "fastify";
|
||||||
|
|
||||||
|
// HTTP Digest auth (RFC 2617, MD5, qop=auth) — verified against the Dingtian
|
||||||
|
// device, which CAN do Digest but CANNOT do HTTPS to a self-signed cert. On
|
||||||
|
// this flat network Digest is the strongest available push auth: the password
|
||||||
|
// is never sent (only a nonce-keyed hash). It is defence-in-depth; the signed
|
||||||
|
// event log is the real anti-fraud guarantee. See wiki/concepts/device-input-flow.md.
|
||||||
|
|
||||||
|
export const DIGEST_REALM = "parking";
|
||||||
|
|
||||||
|
const md5 = (s: string) => createHash("md5").update(s).digest("hex");
|
||||||
|
|
||||||
|
/** Nonces we've issued and not yet consumed (single-use → replay resistance). */
|
||||||
|
const issuedNonces = new Map<string, number>(); // nonce → issuedAt (ms epoch is unavailable in scripts but fine at runtime)
|
||||||
|
const NONCE_TTL_MS = 5 * 60_000;
|
||||||
|
|
||||||
|
function issueNonce(): string {
|
||||||
|
const nonce = randomBytes(16).toString("hex");
|
||||||
|
issuedNonces.set(nonce, Date.now());
|
||||||
|
// opportunistic cleanup
|
||||||
|
if (issuedNonces.size > 1000) {
|
||||||
|
const cutoff = Date.now() - NONCE_TTL_MS;
|
||||||
|
for (const [n, t] of issuedNonces) if (t < cutoff) issuedNonces.delete(n);
|
||||||
|
}
|
||||||
|
return nonce;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDigest(header: string): Record<string, string> {
|
||||||
|
const out: Record<string, string> = {};
|
||||||
|
const re = /(\w+)=(?:"([^"]*)"|([^,]*))/g;
|
||||||
|
let m: RegExpExecArray | null;
|
||||||
|
while ((m = re.exec(header))) out[m[1]!] = (m[2] ?? m[3] ?? "").trim();
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function eq(a: string, b: string): boolean {
|
||||||
|
const ab = Buffer.from(a);
|
||||||
|
const bb = Buffer.from(b);
|
||||||
|
return ab.length === bb.length && timingSafeEqual(ab, bb);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DigestCreds {
|
||||||
|
readonly user: string;
|
||||||
|
readonly password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify a Digest Authorization header. Returns true on success. On failure (or
|
||||||
|
* a missing/expired header) sets a 401 challenge on `reply` and returns false —
|
||||||
|
* the caller should stop. `creds` is the device's stored push credentials.
|
||||||
|
*/
|
||||||
|
export function verifyDigest(
|
||||||
|
req: FastifyRequest,
|
||||||
|
reply: FastifyReply,
|
||||||
|
creds: DigestCreds,
|
||||||
|
): boolean {
|
||||||
|
const header = req.headers["authorization"];
|
||||||
|
|
||||||
|
if (!header || !/^Digest /i.test(header)) {
|
||||||
|
challenge(reply);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const p = parseDigest(header.replace(/^Digest /i, ""));
|
||||||
|
// Nonce must be one we issued and not yet consumed (single-use).
|
||||||
|
const issuedAt = p.nonce ? issuedNonces.get(p.nonce) : undefined;
|
||||||
|
if (!p.nonce || issuedAt === undefined || Date.now() - issuedAt > NONCE_TTL_MS) {
|
||||||
|
challenge(reply, true);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ha1 = md5(`${creds.user}:${DIGEST_REALM}:${creds.password}`);
|
||||||
|
const ha2 = md5(`${req.method}:${p.uri ?? req.url}`);
|
||||||
|
const expected =
|
||||||
|
p.qop === "auth"
|
||||||
|
? md5(`${ha1}:${p.nonce}:${p.nc}:${p.cnonce}:${p.qop}:${ha2}`)
|
||||||
|
: md5(`${ha1}:${p.nonce}:${ha2}`);
|
||||||
|
|
||||||
|
if (!p.response || !eq(expected, p.response) || !eq(p.username ?? "", creds.user)) {
|
||||||
|
challenge(reply);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Consume the nonce so it can't be replayed.
|
||||||
|
issuedNonces.delete(p.nonce);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function challenge(reply: FastifyReply, stale = false): void {
|
||||||
|
const nonce = issueNonce();
|
||||||
|
reply.header(
|
||||||
|
"www-authenticate",
|
||||||
|
`Digest realm="${DIGEST_REALM}", qop="auth", nonce="${nonce}", algorithm=MD5${stale ? ", stale=true" : ""}`,
|
||||||
|
);
|
||||||
|
reply.code(401).send("authentication required");
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { networkInterfaces } from "node:os";
|
||||||
|
|
||||||
|
// Figure out which local IP a device should call back on. For input-push, the
|
||||||
|
// device needs OUR address on ITS subnet — pick the local IPv4 interface whose
|
||||||
|
// network contains the device's IP. Override with BACKEND_HOST_IP if the
|
||||||
|
// auto-pick is wrong (e.g. multi-homed host). See wiki/concepts/device-input-flow.md.
|
||||||
|
|
||||||
|
export function backendIpForDevice(deviceHost: string): string | null {
|
||||||
|
if (process.env.BACKEND_HOST_IP) return process.env.BACKEND_HOST_IP;
|
||||||
|
|
||||||
|
const ip = deviceHost.split(".").map(Number);
|
||||||
|
if (ip.length !== 4 || ip.some((o) => Number.isNaN(o))) return null;
|
||||||
|
|
||||||
|
for (const ifaces of Object.values(networkInterfaces())) {
|
||||||
|
for (const i of ifaces ?? []) {
|
||||||
|
if (i.family !== "IPv4" || i.internal) continue;
|
||||||
|
const addr = i.address.split(".").map(Number);
|
||||||
|
const mask = i.netmask.split(".").map(Number);
|
||||||
|
if (addr.length !== 4 || mask.length !== 4) continue;
|
||||||
|
const sameNet = ip.every((o, k) => (o & mask[k]!) === (addr[k]! & mask[k]!));
|
||||||
|
if (sameNet) return i.address;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Backend port the device should call (the server's listen port). */
|
||||||
|
export function backendPort(): number {
|
||||||
|
return Number(process.env.PORT ?? 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackendIpCandidate {
|
||||||
|
ip: string;
|
||||||
|
iface: string;
|
||||||
|
/** True if this interface's subnet contains the device IP (the likely one). */
|
||||||
|
onDeviceSubnet: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List local IPv4 addresses the device could call back on, with the ones on the
|
||||||
|
* device's own subnet flagged + sorted first. Lets the admin see/override the
|
||||||
|
* auto-pick (important on multi-NIC hosts). BACKEND_HOST_IP, if set, is the only
|
||||||
|
* candidate (the deterministic override).
|
||||||
|
*/
|
||||||
|
export function backendIpCandidates(deviceHost: string): BackendIpCandidate[] {
|
||||||
|
if (process.env.BACKEND_HOST_IP) {
|
||||||
|
return [{ ip: process.env.BACKEND_HOST_IP, iface: "BACKEND_HOST_IP", onDeviceSubnet: true }];
|
||||||
|
}
|
||||||
|
|
||||||
|
const dev = deviceHost.split(".").map(Number);
|
||||||
|
const validDev = dev.length === 4 && !dev.some((o) => Number.isNaN(o));
|
||||||
|
const out: BackendIpCandidate[] = [];
|
||||||
|
|
||||||
|
for (const [iface, ifaces] of Object.entries(networkInterfaces())) {
|
||||||
|
for (const i of ifaces ?? []) {
|
||||||
|
if (i.family !== "IPv4" || i.internal) continue;
|
||||||
|
const addr = i.address.split(".").map(Number);
|
||||||
|
const mask = i.netmask.split(".").map(Number);
|
||||||
|
const onDeviceSubnet =
|
||||||
|
validDev &&
|
||||||
|
addr.length === 4 &&
|
||||||
|
mask.length === 4 &&
|
||||||
|
dev.every((o, k) => (o & mask[k]!) === (addr[k]! & mask[k]!));
|
||||||
|
out.push({ ip: i.address, iface, onDeviceSubnet });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// On-subnet candidates first.
|
||||||
|
return out.sort((a, b) => Number(b.onDeviceSubnet) - Number(a.onDeviceSubnet));
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||||
|
import { eq, laneDevices, type Db } from "@parking/db";
|
||||||
|
import { deviceEvents } from "../device-events.js";
|
||||||
|
import { verifyDigest } from "../digest-auth.js";
|
||||||
|
|
||||||
|
// Inbound device push endpoints. The Dingtian board's "Input Link URL" feature
|
||||||
|
// HTTP-calls us when an input (button) fires — no polling. We translate the
|
||||||
|
// push into an internal device event; the entry flow decides what to do
|
||||||
|
// (print a ticket, then command the relay). See wiki/concepts/device-input-flow.md.
|
||||||
|
//
|
||||||
|
// AUTH: HTTP Digest (the device can do Digest but not HTTPS-to-self-signed —
|
||||||
|
// both tested on hardware). The password is never sent on the wire; the secret
|
||||||
|
// is NOT in the URL. Per-device credentials live in lane_devices (written on
|
||||||
|
// assign). This is defence-in-depth on a flat network; the signed event log is
|
||||||
|
// the real anti-fraud guarantee (an open with no matching signed event is an
|
||||||
|
// anomaly). Source-IP is also checked. NOT behind the SPA cookie/CSRF auth
|
||||||
|
// (machine call from the device).
|
||||||
|
|
||||||
|
interface InputParams {
|
||||||
|
deviceId: string;
|
||||||
|
n: string;
|
||||||
|
edge: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DingtianDeviceConfig {
|
||||||
|
host?: string;
|
||||||
|
pushUser?: string;
|
||||||
|
pushPassword?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clientIp(req: FastifyRequest): string {
|
||||||
|
return req.ip.replace(/^::ffff:/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deviceRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||||
|
const handle = async (req: FastifyRequest<{ Params: InputParams }>, reply: FastifyReply) => {
|
||||||
|
const { deviceId, n, edge } = req.params;
|
||||||
|
|
||||||
|
const row = await db.select().from(laneDevices).where(eq(laneDevices.id, deviceId)).get();
|
||||||
|
const cfg = row?.config as DingtianDeviceConfig | undefined;
|
||||||
|
|
||||||
|
// Unknown device / not a dingtian / no push creds / wrong source IP → 404.
|
||||||
|
if (
|
||||||
|
!row ||
|
||||||
|
row.driverId !== "dingtian" ||
|
||||||
|
!cfg?.pushUser ||
|
||||||
|
!cfg.pushPassword ||
|
||||||
|
!cfg.host ||
|
||||||
|
clientIp(req) !== cfg.host
|
||||||
|
) {
|
||||||
|
app.log.warn(`rejected device push: device=${deviceId} ip=${clientIp(req)}`);
|
||||||
|
return reply.code(404).send({ error: "not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Digest auth — issues a 401 challenge on first hit; the device retries with
|
||||||
|
// the hashed response (verifyDigest sends the challenge + returns false).
|
||||||
|
if (!verifyDigest(req, reply, { user: cfg.pushUser, password: cfg.pushPassword })) {
|
||||||
|
return; // 401 already sent
|
||||||
|
}
|
||||||
|
|
||||||
|
const input = Number(n);
|
||||||
|
const ed = edge === "off" ? "off" : "on";
|
||||||
|
app.log.info(`[dingtian:${deviceId}] input ${input} ${ed} (push)`);
|
||||||
|
deviceEvents.emitInput({
|
||||||
|
driverId: "dingtian",
|
||||||
|
deviceId,
|
||||||
|
input,
|
||||||
|
edge: ed,
|
||||||
|
at: new Date().toISOString(),
|
||||||
|
source: "push",
|
||||||
|
});
|
||||||
|
return { ok: true };
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const method of ["GET", "POST"] as const) {
|
||||||
|
app.route({
|
||||||
|
method,
|
||||||
|
url: "/api/devices/dingtian/:deviceId/input/:n/:edge",
|
||||||
|
handler: handle,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
+124
-10
@@ -1,14 +1,18 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomBytes, randomUUID } from "node:crypto";
|
||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
import { eq, laneDevices, setupState, type Db } from "@parking/db";
|
import { eq, laneDevices, setupState, type Db } from "@parking/db";
|
||||||
import {
|
import {
|
||||||
|
hasPreconditions,
|
||||||
|
hasPushConfig,
|
||||||
isDiscoverable,
|
isDiscoverable,
|
||||||
|
isHardenable,
|
||||||
registerBuiltinDrivers,
|
registerBuiltinDrivers,
|
||||||
registry,
|
registry,
|
||||||
setDeviceLogSink,
|
setDeviceLogSink,
|
||||||
type DeviceCategory,
|
type DeviceCategory,
|
||||||
} from "@parking/devices";
|
} from "@parking/devices";
|
||||||
import { requireRole } from "../auth.js";
|
import { requireRole } from "../auth.js";
|
||||||
|
import { backendIpCandidates, backendIpForDevice, backendPort } from "../net.js";
|
||||||
|
|
||||||
// First-run setup API. The admin reads the driver catalog and assigns devices
|
// First-run setup API. The admin reads the driver catalog and assigns devices
|
||||||
// per lane. See wiki/concepts/first-run-setup.md.
|
// per lane. See wiki/concepts/first-run-setup.md.
|
||||||
@@ -18,6 +22,14 @@ interface AssignBody {
|
|||||||
category: DeviceCategory;
|
category: DeviceCategory;
|
||||||
driverId: string;
|
driverId: string;
|
||||||
config: Record<string, string | number | boolean>;
|
config: Record<string, string | number | boolean>;
|
||||||
|
/** Optional: the backend IP the device should push to (overrides auto-pick;
|
||||||
|
* matters on multi-NIC hosts). */
|
||||||
|
backendIp?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TestBody {
|
||||||
|
driverId: string;
|
||||||
|
config: Record<string, string | number | boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function setupRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
export async function setupRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||||
@@ -28,14 +40,14 @@ export async function setupRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
|||||||
const adminGuard = requireRole("admin");
|
const adminGuard = requireRole("admin");
|
||||||
|
|
||||||
// Catalog of selectable drivers per category (no secrets — schema only).
|
// Catalog of selectable drivers per category (no secrets — schema only).
|
||||||
// `discoverable` flags drivers that can scan the LAN (e.g. UHPPOTE).
|
// `discoverable` flags drivers that can scan the LAN.
|
||||||
app.get("/api/setup/catalog", async () => {
|
app.get("/api/setup/catalog", async () => {
|
||||||
const catalog = registry.catalog();
|
const catalog = registry.catalog();
|
||||||
const discoverable = registry.list().filter(isDiscoverable).map((d) => d.id);
|
const discoverable = registry.list().filter(isDiscoverable).map((d) => d.id);
|
||||||
return { ...catalog, discoverable };
|
return { ...catalog, discoverable };
|
||||||
});
|
});
|
||||||
|
|
||||||
// Scan the LAN for devices a driver can discover (UHPPOTE UDP broadcast, etc).
|
// Scan the LAN for devices a driver can discover (UDP broadcast, etc).
|
||||||
// Each found device is health-checked so the admin sees reachability before
|
// Each found device is health-checked so the admin sees reachability before
|
||||||
// assigning. Admin-only. See wiki/concepts/device-discovery.md.
|
// assigning. Admin-only. See wiki/concepts/device-discovery.md.
|
||||||
app.get<{ Params: { driverId: string } }>(
|
app.get<{ Params: { driverId: string } }>(
|
||||||
@@ -78,32 +90,134 @@ export async function setupRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Assign a device to a lane. Validates the chosen driver + config against the
|
// Test a device config WITHOUT saving or changing the device: validate the
|
||||||
// registry before persisting; rejects unknown drivers / missing config.
|
// config, probe reachability (healthCheck), and report preconditions
|
||||||
|
// (e.g. input_link_relay state). Lets the admin verify before committing.
|
||||||
|
app.post<{ Body: TestBody }>(
|
||||||
|
"/api/setup/test",
|
||||||
|
{ preHandler: adminGuard },
|
||||||
|
async (req, reply) => {
|
||||||
|
const { driverId, config } = req.body;
|
||||||
|
const driver = registry.get(driverId);
|
||||||
|
if (!driver) return reply.code(400).send({ error: `unknown driver: ${driverId}` });
|
||||||
|
|
||||||
|
let device;
|
||||||
|
try {
|
||||||
|
device = registry.create(driverId, config);
|
||||||
|
} catch (err) {
|
||||||
|
return reply.code(400).send({ error: (err as Error).message });
|
||||||
|
}
|
||||||
|
|
||||||
|
const health = await device.healthCheck();
|
||||||
|
const preconditions = hasPreconditions(device)
|
||||||
|
? await device.checkPreconditions()
|
||||||
|
: { ok: true, issues: [] };
|
||||||
|
return { health, preconditions };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Candidate backend IPs the device can push to, for a given device host. The
|
||||||
|
// wizard pre-fills with the on-subnet one and lets the admin override (matters
|
||||||
|
// on multi-NIC hosts). See net.ts / wiki/concepts/device-input-flow.md.
|
||||||
|
app.get<{ Querystring: { host?: string } }>(
|
||||||
|
"/api/setup/backend-ips",
|
||||||
|
{ preHandler: adminGuard },
|
||||||
|
async (req) => {
|
||||||
|
const candidates = backendIpCandidates(req.query.host ?? "");
|
||||||
|
return { candidates, port: backendPort() };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Assign a device to a lane. Validates the chosen driver + config, configures
|
||||||
|
// the device (fix preconditions + set up Digest-authenticated input push — no
|
||||||
|
// manual device-web-UI step by the admin), then persists. Fails the save if
|
||||||
|
// the device can't be configured. See wiki/concepts/device-input-flow.md.
|
||||||
app.post<{ Body: AssignBody }>(
|
app.post<{ Body: AssignBody }>(
|
||||||
"/api/setup/assign",
|
"/api/setup/assign",
|
||||||
{ preHandler: adminGuard },
|
{ preHandler: adminGuard },
|
||||||
async (req, reply) => {
|
async (req, reply) => {
|
||||||
const { lane, category, driverId, config } = req.body;
|
const { lane, category, driverId, config, backendIp } = req.body;
|
||||||
const driver = registry.get(driverId);
|
const driver = registry.get(driverId);
|
||||||
if (!driver || driver.category !== category) {
|
if (!driver || driver.category !== category) {
|
||||||
return reply.code(400).send({ error: `invalid driver for ${category}: ${driverId}` });
|
return reply.code(400).send({ error: `invalid driver for ${category}: ${driverId}` });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const id = randomUUID();
|
||||||
|
const fullConfig: Record<string, unknown> = { ...config };
|
||||||
|
|
||||||
|
let device;
|
||||||
try {
|
try {
|
||||||
registry.create(driverId, config); // validates required fields
|
device = registry.create(driverId, config); // validates required fields
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return reply.code(400).send({ error: (err as Error).message });
|
return reply.code(400).send({ error: (err as Error).message });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Configure the device on save (before persisting, so we don't store a row
|
||||||
|
// for a device we couldn't configure):
|
||||||
|
// 1. fix preconditions (e.g. disable input_link_relay so a button press
|
||||||
|
// doesn't auto-fire its relay — host must decide first),
|
||||||
|
// 2. harden (relay password + disable unused protocol channels), and
|
||||||
|
// 3. set up input push (Digest creds + push URLs).
|
||||||
|
// Each step is a device config write (the device reboots on apply).
|
||||||
|
try {
|
||||||
|
if (hasPreconditions(device)) {
|
||||||
|
const fixed = await device.fixPreconditions();
|
||||||
|
if (!fixed.ok) {
|
||||||
|
const unfixable = fixed.issues.find((i) => !i.fixable);
|
||||||
|
return reply.code(502).send({
|
||||||
|
error: `device precondition not satisfied: ${unfixable?.message ?? fixed.issues[0]?.message}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isHardenable(device)) {
|
||||||
|
const { secrets } = await device.harden();
|
||||||
|
Object.assign(fullConfig, secrets); // e.g. relayPassword
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasPushConfig(device)) {
|
||||||
|
const host = String(config.host ?? "");
|
||||||
|
// Admin-provided backend IP wins; else auto-derive (on-subnet NIC).
|
||||||
|
const pushHost = backendIp ?? backendIpForDevice(host);
|
||||||
|
if (!pushHost) {
|
||||||
|
return reply.code(400).send({
|
||||||
|
error: `cannot determine the backend IP on the device's subnet (${host}). Pick one in setup or set BACKEND_HOST_IP.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const pushUser = "dingtian";
|
||||||
|
// 24 hex chars = 96 bits. The Dingtian `pass` field caps at 31 chars
|
||||||
|
// (longer is silently truncated → auth mismatch), so keep it short.
|
||||||
|
const pushPassword = randomBytes(12).toString("hex");
|
||||||
|
await device.configureInputPush({
|
||||||
|
host: pushHost,
|
||||||
|
port: backendPort(),
|
||||||
|
pathBase: `/api/devices/${driverId}/${id}/input`,
|
||||||
|
auth: { user: pushUser, password: pushPassword },
|
||||||
|
});
|
||||||
|
fullConfig.pushUser = pushUser;
|
||||||
|
fullConfig.pushPassword = pushPassword;
|
||||||
|
// Record the backend IP the device was told to push to — lets us detect
|
||||||
|
// a later mismatch if the host's IP changes.
|
||||||
|
fullConfig.backendIp = pushHost;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
return reply
|
||||||
|
.code(502)
|
||||||
|
.send({ error: `device configuration failed: ${(err as Error).message}` });
|
||||||
|
}
|
||||||
|
|
||||||
const row = {
|
const row = {
|
||||||
id: randomUUID(),
|
id,
|
||||||
lane,
|
lane,
|
||||||
category,
|
category,
|
||||||
driverId,
|
driverId,
|
||||||
config,
|
config: fullConfig,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
};
|
};
|
||||||
await db.insert(laneDevices).values(row);
|
await db.insert(laneDevices).values(row);
|
||||||
return reply.code(201).send(row);
|
// Don't echo device secrets back (push Digest password, web-UI login).
|
||||||
|
const { pushPassword: _pw, webPassword: _wp, ...safeConfig } = fullConfig;
|
||||||
|
return reply.code(201).send({ ...row, config: safeConfig });
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import Fastify, { type FastifyInstance } from "fastify";
|
|||||||
import { createDb, type Db } from "@parking/db";
|
import { createDb, type Db } from "@parking/db";
|
||||||
import { TOKEN_COOKIE, requireJwtSecret } from "./auth.js";
|
import { TOKEN_COOKIE, requireJwtSecret } from "./auth.js";
|
||||||
import { authRoutes } from "./routes/auth.js";
|
import { authRoutes } from "./routes/auth.js";
|
||||||
|
import { deviceRoutes } from "./routes/devices.js";
|
||||||
import { setupRoutes } from "./routes/setup.js";
|
import { setupRoutes } from "./routes/setup.js";
|
||||||
|
|
||||||
// The backend is Fastify (Node). Hardware drivers live as isolated Fastify
|
// The backend is Fastify (Node). Hardware drivers live as isolated Fastify
|
||||||
@@ -43,7 +44,12 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
|||||||
// catalog at first-run. See wiki/concepts/first-run-setup.md.
|
// catalog at first-run. See wiki/concepts/first-run-setup.md.
|
||||||
await setupRoutes(app, db);
|
await setupRoutes(app, db);
|
||||||
|
|
||||||
// TODO: device-driver runtime plugins, append-only event-log routes.
|
// Inbound device pushes (e.g. Dingtian Input Link URL → button events),
|
||||||
|
// guarded by source-IP allowlist + a shared-secret path token, both read from
|
||||||
|
// the device's lane_devices config (written on assign).
|
||||||
|
await deviceRoutes(app, db);
|
||||||
|
|
||||||
|
// TODO: entry flow (input event → signed event → print → relay), event-log routes.
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import {
|
import {
|
||||||
|
assignDevice,
|
||||||
discoverDevices,
|
discoverDevices,
|
||||||
|
fetchBackendIps,
|
||||||
fetchCatalog,
|
fetchCatalog,
|
||||||
|
testDevice,
|
||||||
|
type BackendIpCandidate,
|
||||||
type Catalog,
|
type Catalog,
|
||||||
type CatalogEntry,
|
type CatalogEntry,
|
||||||
type DeviceCategory,
|
type DeviceCategory,
|
||||||
type DiscoveredDevice,
|
type DiscoveredDevice,
|
||||||
|
type TestResult,
|
||||||
} from "./api.js";
|
} from "./api.js";
|
||||||
|
|
||||||
// First-run setup wizard (scaffold). The admin picks a device per category for a
|
// First-run setup wizard (scaffold). The admin picks a device per category for a
|
||||||
@@ -54,6 +59,8 @@ export function SetupWizard() {
|
|||||||
{CATEGORIES.map(({ key, title }) => (
|
{CATEGORIES.map(({ key, title }) => (
|
||||||
<CategoryPicker
|
<CategoryPicker
|
||||||
key={key}
|
key={key}
|
||||||
|
lane={lane}
|
||||||
|
category={key}
|
||||||
title={title}
|
title={title}
|
||||||
entries={catalog[key]}
|
entries={catalog[key]}
|
||||||
discoverableIds={catalog.discoverable}
|
discoverableIds={catalog.discoverable}
|
||||||
@@ -66,12 +73,16 @@ export function SetupWizard() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function CategoryPicker({
|
function CategoryPicker({
|
||||||
|
lane,
|
||||||
|
category,
|
||||||
title,
|
title,
|
||||||
entries,
|
entries,
|
||||||
discoverableIds,
|
discoverableIds,
|
||||||
selectedId,
|
selectedId,
|
||||||
onSelect,
|
onSelect,
|
||||||
}: {
|
}: {
|
||||||
|
lane: number;
|
||||||
|
category: DeviceCategory;
|
||||||
title: string;
|
title: string;
|
||||||
entries: CatalogEntry[];
|
entries: CatalogEntry[];
|
||||||
discoverableIds: string[];
|
discoverableIds: string[];
|
||||||
@@ -83,10 +94,49 @@ function CategoryPicker({
|
|||||||
|
|
||||||
// Config values (auto-filled by discovery, editable by hand).
|
// Config values (auto-filled by discovery, editable by hand).
|
||||||
const [config, setConfig] = useState<Record<string, string | number>>({});
|
const [config, setConfig] = useState<Record<string, string | number>>({});
|
||||||
|
const [tested, setTested] = useState<TestResult | null>(null);
|
||||||
|
const [testing, setTesting] = useState(false);
|
||||||
|
const [testError, setTestError] = useState<string | null>(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [saved, setSaved] = useState(false);
|
||||||
|
const [saveError, setSaveError] = useState<string | null>(null);
|
||||||
const [found, setFound] = useState<DiscoveredDevice[] | null>(null);
|
const [found, setFound] = useState<DiscoveredDevice[] | null>(null);
|
||||||
const [scanning, setScanning] = useState(false);
|
const [scanning, setScanning] = useState(false);
|
||||||
const [scanError, setScanError] = useState<string | null>(null);
|
const [scanError, setScanError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Backend push IP: which of OUR addresses the device should call back on. We
|
||||||
|
// auto-pick the NIC on the device's subnet, but surface it editable here so a
|
||||||
|
// multi-NIC host can be corrected (the chosen IP is baked into the device on
|
||||||
|
// save). Only relevant for drivers that push (the field hides if no candidates).
|
||||||
|
const [backendIps, setBackendIps] = useState<BackendIpCandidate[] | null>(null);
|
||||||
|
const [backendIp, setBackendIp] = useState<string>("");
|
||||||
|
|
||||||
|
// (Re)load backend-IP candidates whenever the device host changes after a
|
||||||
|
// successful test (the test confirms the host is real + reachable).
|
||||||
|
const testedHost = tested ? String(mergedConfig().host ?? "") : "";
|
||||||
|
useEffect(() => {
|
||||||
|
if (!testedHost) {
|
||||||
|
setBackendIps(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let live = true;
|
||||||
|
fetchBackendIps(testedHost)
|
||||||
|
.then(({ candidates }) => {
|
||||||
|
if (!live) return;
|
||||||
|
setBackendIps(candidates);
|
||||||
|
// Pre-fill with the on-subnet auto-pick (the first candidate, since the
|
||||||
|
// server sorts on-subnet first), unless the admin already chose one.
|
||||||
|
setBackendIp((cur) => cur || candidates.find((c) => c.onDeviceSubnet)?.ip || "");
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (live) setBackendIps(null);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
live = false;
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [testedHost]);
|
||||||
|
|
||||||
async function scan() {
|
async function scan() {
|
||||||
if (!selected) return;
|
if (!selected) return;
|
||||||
setScanning(true);
|
setScanning(true);
|
||||||
@@ -102,6 +152,59 @@ function CategoryPicker({
|
|||||||
|
|
||||||
function applyDiscovered(d: DiscoveredDevice) {
|
function applyDiscovered(d: DiscoveredDevice) {
|
||||||
setConfig((c) => ({ ...c, ...(d.config as Record<string, string | number>) }));
|
setConfig((c) => ({ ...c, ...(d.config as Record<string, string | number>) }));
|
||||||
|
resetStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config the user actually entered, merged over driver defaults.
|
||||||
|
function mergedConfig(): Record<string, string | number> {
|
||||||
|
const out: Record<string, string | number> = {};
|
||||||
|
for (const f of selected?.configFields ?? []) {
|
||||||
|
const v = config[f.key] ?? (f.default as string | number | undefined);
|
||||||
|
if (v !== undefined && v !== "") out[f.key] = v;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Editing config invalidates a prior test/save.
|
||||||
|
function resetStatus() {
|
||||||
|
setTested(null);
|
||||||
|
setTestError(null);
|
||||||
|
setSaved(false);
|
||||||
|
setSaveError(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function test() {
|
||||||
|
if (!selected) return;
|
||||||
|
setTesting(true);
|
||||||
|
setTestError(null);
|
||||||
|
setTested(null);
|
||||||
|
try {
|
||||||
|
setTested(await testDevice(selected.id, mergedConfig()));
|
||||||
|
} catch (e) {
|
||||||
|
setTestError((e as Error).message);
|
||||||
|
} finally {
|
||||||
|
setTesting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (!selected) return;
|
||||||
|
setSaving(true);
|
||||||
|
setSaveError(null);
|
||||||
|
try {
|
||||||
|
await assignDevice({
|
||||||
|
lane,
|
||||||
|
category,
|
||||||
|
driverId: selected.id,
|
||||||
|
config: mergedConfig(),
|
||||||
|
...(backendIp ? { backendIp } : {}),
|
||||||
|
});
|
||||||
|
setSaved(true);
|
||||||
|
} catch (e) {
|
||||||
|
setSaveError((e as Error).message);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -159,11 +262,77 @@ function CategoryPicker({
|
|||||||
type={f.type === "secret" ? "password" : f.type === "number" || f.type === "port" ? "number" : "text"}
|
type={f.type === "secret" ? "password" : f.type === "number" || f.type === "port" ? "number" : "text"}
|
||||||
value={config[f.key] ?? (f.default as string | number | undefined) ?? ""}
|
value={config[f.key] ?? (f.default as string | number | undefined) ?? ""}
|
||||||
placeholder={f.help}
|
placeholder={f.help}
|
||||||
onChange={(e) => setConfig((c) => ({ ...c, [f.key]: e.target.value }))}
|
onChange={(e) => {
|
||||||
|
const v = e.target.value;
|
||||||
|
setConfig((c) => ({ ...c, [f.key]: v }));
|
||||||
|
resetStatus();
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{/* Test (no save/no device change) then Save (configures + persists). */}
|
||||||
|
<div style={{ marginTop: "0.75rem", display: "flex", gap: "0.5rem", alignItems: "center" }}>
|
||||||
|
<button type="button" onClick={test} disabled={testing}>
|
||||||
|
{testing ? "Testing…" : "Test connection"}
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={save} disabled={saving || saved}>
|
||||||
|
{saving ? "Saving…" : saved ? "Saved ✓" : "Save & configure"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{testError && <p style={{ color: "crimson", margin: "0.5rem 0 0" }}>Test failed: {testError}</p>}
|
||||||
|
{tested && (
|
||||||
|
<div style={{ margin: "0.5rem 0 0" }}>
|
||||||
|
<div>
|
||||||
|
Device: <HealthBadge status={tested.health.status} />
|
||||||
|
{tested.health.detail && <span style={{ color: "#666" }}> — {tested.health.detail}</span>}
|
||||||
|
</div>
|
||||||
|
{tested.preconditions.ok ? (
|
||||||
|
<div style={{ color: "#16a34a" }}>● preconditions OK</div>
|
||||||
|
) : (
|
||||||
|
tested.preconditions.issues.map((i) => (
|
||||||
|
<div key={i.key} style={{ color: "#d97706" }}>
|
||||||
|
⚠ {i.message}
|
||||||
|
{i.fixable && <span style={{ color: "#666" }}> (auto-fixed on save)</span>}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Backend push IP — only for push-capable devices (candidates present).
|
||||||
|
Pre-filled with the auto-pick; editable for multi-NIC hosts. */}
|
||||||
|
{backendIps && backendIps.length > 0 && (
|
||||||
|
<div style={{ margin: "0.5rem 0 0" }}>
|
||||||
|
<label>
|
||||||
|
Backend push IP{" "}
|
||||||
|
<select value={backendIp} onChange={(e) => setBackendIp(e.target.value)}>
|
||||||
|
{!backendIps.some((c) => c.onDeviceSubnet) && (
|
||||||
|
<option value="" disabled>
|
||||||
|
Choose an address…
|
||||||
|
</option>
|
||||||
|
)}
|
||||||
|
{backendIps.map((c) => (
|
||||||
|
<option key={c.ip} value={c.ip}>
|
||||||
|
{c.ip} ({c.iface}){c.onDeviceSubnet ? " — on device subnet" : ""}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{!backendIps.some((c) => c.onDeviceSubnet) && (
|
||||||
|
<span style={{ marginLeft: 8, color: "#d97706" }}>
|
||||||
|
⚠ no NIC on the device's subnet — the device may not reach the backend
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<p style={{ margin: "0.25rem 0 0", color: "#666", fontSize: "0.85em" }}>
|
||||||
|
The address this device will POST input events to.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{saveError && <p style={{ color: "crimson", margin: "0.5rem 0 0" }}>Save failed: {saveError}</p>}
|
||||||
|
{saved && <p style={{ color: "#16a34a", margin: "0.5rem 0 0" }}>Saved and configured ✓</p>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|||||||
+38
-3
@@ -110,7 +110,7 @@ export interface DiscoveredDevice {
|
|||||||
health: { status: string; detail?: string };
|
health: { status: string; detail?: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Scan the LAN for devices a driver can discover (e.g. UHPPOTE). Admin-only. */
|
/** Scan the LAN for devices a driver can discover. Admin-only. */
|
||||||
export async function discoverDevices(driverId: string): Promise<DiscoveredDevice[]> {
|
export async function discoverDevices(driverId: string): Promise<DiscoveredDevice[]> {
|
||||||
const body = await apiFetch<{ devices: DiscoveredDevice[] }>(
|
const body = await apiFetch<{ devices: DiscoveredDevice[] }>(
|
||||||
`/api/setup/discover/${driverId}`,
|
`/api/setup/discover/${driverId}`,
|
||||||
@@ -118,13 +118,48 @@ export async function discoverDevices(driverId: string): Promise<DiscoveredDevic
|
|||||||
return body.devices;
|
return body.devices;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type DeviceConfig = Record<string, string | number | boolean>;
|
||||||
|
|
||||||
|
export interface TestResult {
|
||||||
|
health: { status: string; detail?: string };
|
||||||
|
preconditions: {
|
||||||
|
ok: boolean;
|
||||||
|
issues: { key: string; message: string; fixable: boolean }[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test a device config (reachability + preconditions) without saving. */
|
||||||
|
export function testDevice(driverId: string, config: DeviceConfig): Promise<TestResult> {
|
||||||
|
return apiFetch<TestResult>("/api/setup/test", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ driverId, config }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackendIpCandidate {
|
||||||
|
ip: string;
|
||||||
|
iface: string;
|
||||||
|
onDeviceSubnet: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Local IPs the device could push to (on-subnet first), for the wizard to
|
||||||
|
* pre-fill/override. Matters on multi-NIC hosts. */
|
||||||
|
export function fetchBackendIps(
|
||||||
|
host: string,
|
||||||
|
): Promise<{ candidates: BackendIpCandidate[]; port: number }> {
|
||||||
|
return apiFetch(`/api/setup/backend-ips?host=${encodeURIComponent(host)}`);
|
||||||
|
}
|
||||||
|
|
||||||
export interface AssignBody {
|
export interface AssignBody {
|
||||||
lane: number;
|
lane: number;
|
||||||
category: DeviceCategory;
|
category: DeviceCategory;
|
||||||
driverId: string;
|
driverId: string;
|
||||||
config: Record<string, string | number | boolean>;
|
config: DeviceConfig;
|
||||||
|
/** Backend IP the device should push to (overrides auto-pick). */
|
||||||
|
backendIp?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function assignDevice(body: AssignBody): Promise<unknown> {
|
/** Save + configure the device (preconditions, push setup), then persist. */
|
||||||
|
export function assignDevice(body: AssignBody): Promise<{ id: string }> {
|
||||||
return apiFetch("/api/setup/assign", { method: "POST", body: JSON.stringify(body) });
|
return apiFetch("/api/setup/assign", { method: "POST", body: JSON.stringify(body) });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,8 +18,7 @@
|
|||||||
"lint": "tsc --noEmit"
|
"lint": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@parking/shared": "workspace:*",
|
"@parking/shared": "workspace:*"
|
||||||
"uhppoted": "0.9.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "25.9.3",
|
"@types/node": "25.9.3",
|
||||||
|
|||||||
@@ -0,0 +1,674 @@
|
|||||||
|
import { randomBytes } from "node:crypto";
|
||||||
|
import { createSocket } from "node:dgram";
|
||||||
|
import { request as httpRequest } from "node:http";
|
||||||
|
import type {
|
||||||
|
AccessControlDevice,
|
||||||
|
DeviceHealth,
|
||||||
|
HardenableDevice,
|
||||||
|
HardenResult,
|
||||||
|
InputDevice,
|
||||||
|
InputEvent,
|
||||||
|
PreconditionDevice,
|
||||||
|
PreconditionResult,
|
||||||
|
PushConfig,
|
||||||
|
PushConfigurableDevice,
|
||||||
|
} from "../interfaces.js";
|
||||||
|
import type { AccessDriver, DeviceConfig } from "../registry.js";
|
||||||
|
import { hostField, portField, stubLog } from "./common.js";
|
||||||
|
|
||||||
|
// Dingtian relay+input controller driver. Backed by the "Dingtian string"
|
||||||
|
// protocol over UDP. Implements AccessControlDevice (relay/barrier) AND the
|
||||||
|
// optional InputDevice capability (host-readable buttons, decoupled from relays)
|
||||||
|
// — which is what makes host-in-the-loop entry possible. See
|
||||||
|
// wiki/entities/dingtian-relay.md and access-controller-button-flow.md.
|
||||||
|
//
|
||||||
|
// SAFETY: pulseOpen expresses INTENT only. It uses the device's jog/pulse
|
||||||
|
// (momentary) so the relay self-releases; we never time a close against a
|
||||||
|
// vehicle — anti-crush/auto-reverse is the barrier operator's firmware.
|
||||||
|
// See wiki/concepts/barrier-not-a-door.md.
|
||||||
|
//
|
||||||
|
// SECURITY: unauthenticated UDP — the board must sit on an isolated VLAN
|
||||||
|
// reachable only by the host. See wiki/concepts/network-isolation.md.
|
||||||
|
//
|
||||||
|
// NOTE: by default Dingtian links each input to auto-fire its relay
|
||||||
|
// (input_link_relay). That must be DISABLED on the device for ticket-first
|
||||||
|
// entry, else the button opens the barrier before the host can act.
|
||||||
|
|
||||||
|
/** Send one UDP datagram and (optionally) await a single reply. */
|
||||||
|
function udpRequest(
|
||||||
|
host: string,
|
||||||
|
port: number,
|
||||||
|
payload: string,
|
||||||
|
timeoutMs: number,
|
||||||
|
expectReply: boolean,
|
||||||
|
): Promise<string | null> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const sock = createSocket("udp4");
|
||||||
|
let settled = false;
|
||||||
|
const done = (err: Error | null, val: string | null) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
sock.close();
|
||||||
|
err ? reject(err) : resolve(val);
|
||||||
|
};
|
||||||
|
const timer = setTimeout(
|
||||||
|
() => done(expectReply ? new Error("timeout") : null, null),
|
||||||
|
timeoutMs,
|
||||||
|
);
|
||||||
|
sock.on("error", (e) => done(e, null));
|
||||||
|
sock.on("message", (m) => done(null, m.toString()));
|
||||||
|
sock.bind(() => {
|
||||||
|
sock.send(Buffer.from(payload), port, host, (e) => {
|
||||||
|
if (e) done(e, null);
|
||||||
|
else if (!expectReply) done(null, null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a Dingtian *binary* protocol frame (UDP, default port 60000) and await
|
||||||
|
* the reply. Used for relay control because — unlike the string protocol — the
|
||||||
|
* binary protocol supports a password (`relay_pw`), so an attacker on a flat
|
||||||
|
* network can't fire a relay without it. Frame verified on hardware:
|
||||||
|
*
|
||||||
|
* FF AA <session> <relayCmd> <pwLo> <pwHi> <data...>
|
||||||
|
*
|
||||||
|
* FF = command "set relay"
|
||||||
|
* AA = result xor (0x00 ^ 0xAA, pc→device)
|
||||||
|
* session = echoed back
|
||||||
|
* relayCmd = 1 write, 3 jogging, …
|
||||||
|
* pwLo,pwHi = relay password, 16-bit LSB-first (0 = none)
|
||||||
|
* data = command-specific
|
||||||
|
*/
|
||||||
|
function binaryUdp(
|
||||||
|
host: string,
|
||||||
|
port: number,
|
||||||
|
frame: Buffer,
|
||||||
|
timeoutMs: number,
|
||||||
|
): Promise<Buffer> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const sock = createSocket("udp4");
|
||||||
|
let settled = false;
|
||||||
|
const done = (err: Error | null, val: Buffer | null) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
sock.close();
|
||||||
|
err ? reject(err) : resolve(val!);
|
||||||
|
};
|
||||||
|
const timer = setTimeout(() => done(new Error("timeout"), null), timeoutMs);
|
||||||
|
sock.on("error", (e) => done(e, null));
|
||||||
|
sock.on("message", (m) => done(null, m));
|
||||||
|
sock.bind(() => {
|
||||||
|
sock.send(frame, port, host, (e) => {
|
||||||
|
if (e) done(e, null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let binarySession = 0;
|
||||||
|
/** Build a binary "write relay with jogging" frame (relay on, auto-off). */
|
||||||
|
function jogFrame(channel: number, password: number, jogMs: number): Buffer {
|
||||||
|
const session = binarySession++ & 0xff;
|
||||||
|
// relay index + on/off: bit0 = on, bits1..7 = (channel-1)
|
||||||
|
const relayByte = (((channel - 1) & 0x7f) << 1) | 0x01;
|
||||||
|
const units = Math.max(1, Math.round(jogMs / 100)); // 100ms units
|
||||||
|
return Buffer.from([
|
||||||
|
0xff,
|
||||||
|
0xaa,
|
||||||
|
session,
|
||||||
|
0x03, // jogging
|
||||||
|
password & 0xff,
|
||||||
|
(password >> 8) & 0xff,
|
||||||
|
relayByte,
|
||||||
|
units & 0xff,
|
||||||
|
(units >> 8) & 0xff,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build a binary "write relay" frame (latch on/off via mask+set). */
|
||||||
|
function writeRelayFrame(channel: number, on: boolean, password: number, channels: number): Buffer {
|
||||||
|
const session = binarySession++ & 0xff;
|
||||||
|
const bit = 1 << (channel - 1);
|
||||||
|
const mask = bit; // only this channel updates
|
||||||
|
const set = on ? bit : 0;
|
||||||
|
// 4ch: mask + set are 1 byte each (bit0→relay1).
|
||||||
|
const widthBytes = channels <= 8 ? 1 : channels <= 16 ? 2 : channels <= 24 ? 3 : 4;
|
||||||
|
const maskBuf = Buffer.alloc(widthBytes);
|
||||||
|
const setBuf = Buffer.alloc(widthBytes);
|
||||||
|
maskBuf.writeUIntLE(mask, 0, widthBytes);
|
||||||
|
setBuf.writeUIntLE(set, 0, widthBytes);
|
||||||
|
return Buffer.concat([
|
||||||
|
Buffer.from([0xff, 0xaa, session, 0x01, password & 0xff, (password >> 8) & 0xff]),
|
||||||
|
maskBuf,
|
||||||
|
setBuf,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rand16 = () => randomBytes(2).readUInt16BE(0);
|
||||||
|
|
||||||
|
/** GET a CGI path on the device's HTTP server and return the raw response text. */
|
||||||
|
function cgiGet(host: string, httpPort: number, path: string, timeoutMs: number): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = httpRequest({ host, port: httpPort, path, method: "GET", timeout: timeoutMs }, (res) => {
|
||||||
|
let data = "";
|
||||||
|
res.on("data", (c) => (data += c));
|
||||||
|
res.on("end", () => resolve(data));
|
||||||
|
});
|
||||||
|
req.on("error", reject);
|
||||||
|
req.on("timeout", () => req.destroy(new Error("cgi timeout")));
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DingtianStatus {
|
||||||
|
relays: boolean[]; // true = on
|
||||||
|
inputs: boolean[]; // true = active (after resting-level normalisation)
|
||||||
|
channels: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const INPUT_LINK_ISSUE = {
|
||||||
|
key: "input_link_relay",
|
||||||
|
message:
|
||||||
|
"input_link_relay is ENABLED — a button press will auto-fire its relay (opening the barrier before the host can act). Disable it for ticket-first entry.",
|
||||||
|
fixable: true,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** GET/POST the device's JSON config API (HTTP; port is configurable). */
|
||||||
|
function configApi(
|
||||||
|
host: string,
|
||||||
|
httpPort: number,
|
||||||
|
path: string,
|
||||||
|
method: "GET" | "POST",
|
||||||
|
body: string | null,
|
||||||
|
timeoutMs: number,
|
||||||
|
sessionId?: number, // device session check: sent as Cookie: session=<id>
|
||||||
|
): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
// The device's embedded HTTP server does NOT support chunked request bodies.
|
||||||
|
// Node uses chunked encoding when Content-Length is absent, so the device
|
||||||
|
// silently ignores the body (POST returns {"status":0} but nothing changes).
|
||||||
|
// Always set Content-Length explicitly.
|
||||||
|
const headers: Record<string, string | number> = {};
|
||||||
|
if (body) {
|
||||||
|
headers["content-type"] = "application/json";
|
||||||
|
headers["content-length"] = Buffer.byteLength(body);
|
||||||
|
}
|
||||||
|
// When the device's HTTP session check is enabled, the CGI API requires a
|
||||||
|
// matching session cookie (a numeric magic id). See programming manual §3.8.
|
||||||
|
if (sessionId) headers["cookie"] = `session=${sessionId}`;
|
||||||
|
const req = httpRequest(
|
||||||
|
{
|
||||||
|
host,
|
||||||
|
port: httpPort,
|
||||||
|
path,
|
||||||
|
method,
|
||||||
|
timeout: timeoutMs,
|
||||||
|
headers: Object.keys(headers).length ? headers : undefined,
|
||||||
|
},
|
||||||
|
(res) => {
|
||||||
|
let data = "";
|
||||||
|
res.on("data", (c) => (data += c));
|
||||||
|
res.on("end", () => resolve(data));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
req.on("error", reject);
|
||||||
|
req.on("timeout", () => req.destroy(new Error("config api timeout")));
|
||||||
|
if (body) req.write(body);
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class DingtianController
|
||||||
|
implements
|
||||||
|
AccessControlDevice,
|
||||||
|
InputDevice,
|
||||||
|
PreconditionDevice,
|
||||||
|
PushConfigurableDevice,
|
||||||
|
HardenableDevice
|
||||||
|
{
|
||||||
|
readonly driverId = "dingtian";
|
||||||
|
readonly #host: string;
|
||||||
|
readonly #port: number; // string protocol (status read) — UDP 60001
|
||||||
|
readonly #binaryPort: number; // binary protocol (relay control) — UDP 60000
|
||||||
|
readonly #relayPassword: number; // relay_pw (0 = none)
|
||||||
|
readonly #sessionId: number; // device CGI session id (0 = session check off)
|
||||||
|
readonly #httpPort: number;
|
||||||
|
readonly #timeout: number;
|
||||||
|
readonly #channels: number;
|
||||||
|
/** Input level at rest; an input is "active" when it differs from this. */
|
||||||
|
readonly #restingHigh: boolean;
|
||||||
|
readonly #pulseMs: number;
|
||||||
|
/** Current device web-UI login (gates the browser UI only, not the CGI API). */
|
||||||
|
readonly #webUser: string;
|
||||||
|
readonly #webPassword: string;
|
||||||
|
|
||||||
|
#poll: ReturnType<typeof setInterval> | null = null;
|
||||||
|
#last: boolean[] | null = null;
|
||||||
|
#subs = new Set<(e: InputEvent) => void>();
|
||||||
|
|
||||||
|
constructor(config: DeviceConfig) {
|
||||||
|
this.#host = String(config.host);
|
||||||
|
this.#port = config.port ? Number(config.port) : 60001;
|
||||||
|
this.#binaryPort = config.binaryPort ? Number(config.binaryPort) : 60000;
|
||||||
|
this.#relayPassword = config.relayPassword ? Number(config.relayPassword) : 0;
|
||||||
|
this.#sessionId = config.sessionId ? Number(config.sessionId) : 0;
|
||||||
|
this.#httpPort = config.httpPort ? Number(config.httpPort) : 80;
|
||||||
|
this.#timeout = config.timeoutMs ? Number(config.timeoutMs) : 2000;
|
||||||
|
this.#channels = config.channels ? Number(config.channels) : 4;
|
||||||
|
// This unit idles with inputs HIGH (status "1111"); a press pulls LOW.
|
||||||
|
this.#restingHigh = config.inputRestingHigh !== false;
|
||||||
|
this.#pulseMs = config.pulseMs ? Number(config.pulseMs) : 500;
|
||||||
|
// The device ships with admin/admin. After harden() rotates it, the new
|
||||||
|
// creds are stored back in config so a re-created driver knows the current
|
||||||
|
// login (needed to rotate again — userset.cgi checks the old credentials).
|
||||||
|
this.#webUser = config.webUser ? String(config.webUser) : "admin";
|
||||||
|
this.#webPassword = config.webPassword ? String(config.webPassword) : "admin";
|
||||||
|
}
|
||||||
|
|
||||||
|
async connect(): Promise<void> {
|
||||||
|
await this.healthCheck();
|
||||||
|
}
|
||||||
|
|
||||||
|
async disconnect(): Promise<void> {
|
||||||
|
this.#stopPolling();
|
||||||
|
stubLog(this.driverId, "disconnect");
|
||||||
|
}
|
||||||
|
|
||||||
|
async healthCheck(): Promise<DeviceHealth> {
|
||||||
|
try {
|
||||||
|
await this.#status();
|
||||||
|
return { status: "ready" };
|
||||||
|
} catch (err) {
|
||||||
|
return { status: "offline", detail: (err as Error).message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- relay / barrier ----------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pulse a relay open (momentary). Channel is 1-based. Intent only — the device
|
||||||
|
* jogs the relay ON then auto-releases after pulseMs, so we never time a close
|
||||||
|
* against a vehicle. Uses the binary protocol + relay password (authenticated).
|
||||||
|
*/
|
||||||
|
async pulseOpen(doorId: number): Promise<void> {
|
||||||
|
this.#assertChannel(doorId);
|
||||||
|
const frame = jogFrame(doorId, this.#relayPassword, this.#pulseMs);
|
||||||
|
await binaryUdp(this.#host, this.#binaryPort, frame, this.#timeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Latch a relay on/off (e.g. for a held-open mode). Channel is 1-based. */
|
||||||
|
async setRelay(doorId: number, on: boolean): Promise<void> {
|
||||||
|
this.#assertChannel(doorId);
|
||||||
|
const frame = writeRelayFrame(doorId, on, this.#relayPassword, this.#channels);
|
||||||
|
await binaryUdp(this.#host, this.#binaryPort, frame, this.#timeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getDoorStatus(doorId: number): Promise<"open" | "closed"> {
|
||||||
|
this.#assertChannel(doorId);
|
||||||
|
const { relays } = await this.#status();
|
||||||
|
// "open" here = relay energised. Physical door state needs a sensor input.
|
||||||
|
return relays[doorId - 1] ? "open" : "closed";
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- inputs (buttons) ---------------------------------------------------
|
||||||
|
|
||||||
|
async readInputs(): Promise<boolean[]> {
|
||||||
|
return (await this.#status()).inputs;
|
||||||
|
}
|
||||||
|
|
||||||
|
onInput(cb: (event: InputEvent) => void): () => void {
|
||||||
|
this.#subs.add(cb);
|
||||||
|
this.#startPolling();
|
||||||
|
return () => {
|
||||||
|
this.#subs.delete(cb);
|
||||||
|
if (this.#subs.size === 0) this.#stopPolling();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- preconditions ------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parking requires `input_link_relay` DISABLED: otherwise a button press
|
||||||
|
* auto-fires its relay, opening the barrier before the host can act (print a
|
||||||
|
* ticket / decide). This is the configurable version of the UHPPOTE blocker.
|
||||||
|
*/
|
||||||
|
async checkPreconditions(): Promise<PreconditionResult> {
|
||||||
|
let cfg: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
cfg = await this.#readConfig();
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
issues: [
|
||||||
|
{
|
||||||
|
key: "config_unreachable",
|
||||||
|
message: `could not read device config: ${(err as Error).message}`,
|
||||||
|
fixable: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { ok: this.#linkDisabled(cfg), issues: this.#linkDisabled(cfg) ? [] : [INPUT_LINK_ISSUE] };
|
||||||
|
}
|
||||||
|
|
||||||
|
async fixPreconditions(): Promise<PreconditionResult> {
|
||||||
|
const cfg = await this.#readConfig();
|
||||||
|
if (this.#linkDisabled(cfg)) return { ok: true, issues: [] };
|
||||||
|
|
||||||
|
// Disable the master flag AND clear the per-input action maps.
|
||||||
|
const ilr = cfg.input_link_relay as Record<string, unknown>;
|
||||||
|
ilr.input_link_relay = 0;
|
||||||
|
if (Array.isArray(ilr.on_action_on)) {
|
||||||
|
ilr.on_action_on = (ilr.on_action_on as unknown[]).map(() => []);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.#writeConfig(cfg, (after) => this.#linkDisabled(after));
|
||||||
|
return this.checkPreconditions();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configure the device to HTTP-push input (button) events to our backend —
|
||||||
|
* the "Input Link URL" feature. Each input N calls `${pathBase}/<N>/on` (and
|
||||||
|
* `/off`) on host:port via GET, authenticated with **HTTP Digest** (the device
|
||||||
|
* does Digest but not HTTPS-to-self-signed; both verified on hardware). The
|
||||||
|
* password is never sent on the wire and the secret is not in the URL.
|
||||||
|
* Enables the feature, plain HTTP, active-LOW. Replaces polling.
|
||||||
|
*/
|
||||||
|
async configureInputPush(opts: PushConfig): Promise<void> {
|
||||||
|
const cfg = await this.#readConfig();
|
||||||
|
const ilu = cfg.input_link_url as Record<string, unknown>;
|
||||||
|
const n = Number((ilu.cnt as number) ?? this.#channels);
|
||||||
|
const fill = (v: unknown) => Array.from({ length: n }, () => v);
|
||||||
|
|
||||||
|
ilu.en = 1;
|
||||||
|
ilu.active_level = fill(0); // active-LOW (matches this board's wiring)
|
||||||
|
ilu.tls = fill(0); // plain HTTP (device can't do HTTPS to self-signed)
|
||||||
|
ilu.auth = fill(2); // 2 = Digest
|
||||||
|
ilu.server = fill(opts.host);
|
||||||
|
ilu.port = fill(opts.port);
|
||||||
|
ilu.user = fill(opts.auth.user);
|
||||||
|
ilu.pass = fill(opts.auth.password);
|
||||||
|
ilu.on_method = fill(0); // GET
|
||||||
|
ilu.off_method = fill(0);
|
||||||
|
ilu.on_path = Array.from({ length: n }, (_, i) => `${opts.pathBase}/${i + 1}/on`);
|
||||||
|
ilu.off_path = Array.from({ length: n }, (_, i) => `${opts.pathBase}/${i + 1}/off`);
|
||||||
|
ilu.on_body = fill("");
|
||||||
|
ilu.off_body = fill("");
|
||||||
|
|
||||||
|
const wantPath = `${opts.pathBase}/1/on`;
|
||||||
|
await this.#writeConfig(cfg, (after) => {
|
||||||
|
const a = after.input_link_url as Record<string, unknown> | undefined;
|
||||||
|
const paths = a?.on_path as string[] | undefined;
|
||||||
|
const pass = a?.pass as string[] | undefined;
|
||||||
|
// Verify both the path and the (secret) password landed — the password is
|
||||||
|
// what the backend's Digest check depends on.
|
||||||
|
return (
|
||||||
|
a?.en === 1 &&
|
||||||
|
Array.isArray(paths) &&
|
||||||
|
paths[0] === wantPath &&
|
||||||
|
Array.isArray(pass) &&
|
||||||
|
pass[0] === opts.auth.password
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- hardening ----------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lock the device down for a flat (no-VLAN) network:
|
||||||
|
* - set a random relay password (`relay_pw`) so binary relay commands need it,
|
||||||
|
* - disable unused protocol channels (rs485/can/tcp×2/mqtt) — keep only UDP1
|
||||||
|
* binary (relay control) and UDP2 string (status read).
|
||||||
|
* Returns the relay password for the backend to persist (required to keep
|
||||||
|
* commanding the device afterwards).
|
||||||
|
*
|
||||||
|
* NOTE: deliberately does NOT touch the device's HTTP CGI session check
|
||||||
|
* (`session_en`). On this firmware enabling it makes the config-read API drop
|
||||||
|
* connections, locking us out of the very API we depend on (verified the hard
|
||||||
|
* way — required a factory reset). So we leave the config API as-is and rely on
|
||||||
|
* relay_pw + fewer open channels + the signed event log.
|
||||||
|
*
|
||||||
|
* All are plaintext over HTTP/UDP on a flat network → defence-in-depth, not a
|
||||||
|
* boundary; the signed event log is the real guarantee. See device-input-flow.
|
||||||
|
*/
|
||||||
|
async harden(): Promise<HardenResult> {
|
||||||
|
const cfg = await this.#readConfig();
|
||||||
|
const rc = cfg.relay_connect as Record<string, unknown>;
|
||||||
|
|
||||||
|
const relayPassword = 1 + (rand16() % 9999); // 1..9999 (0 = none)
|
||||||
|
|
||||||
|
rc.relay_pw = relayPassword;
|
||||||
|
// Keep UDP1=Binary (p:1) for relay control, UDP2=String (p:0) for status.
|
||||||
|
// Disable everything else (p:255 = None).
|
||||||
|
(rc.udp1 as Record<string, unknown>).p = 1;
|
||||||
|
(rc.udp2 as Record<string, unknown>).p = 0;
|
||||||
|
(rc.rs485 as Record<string, unknown>).p = 255;
|
||||||
|
(rc.can as Record<string, unknown>).p = 255;
|
||||||
|
(rc.tcpc as Record<string, unknown>).p = 255;
|
||||||
|
(rc.tcps as Record<string, unknown>).p = 255;
|
||||||
|
(rc.mqtt as Record<string, unknown>).p = 255;
|
||||||
|
|
||||||
|
await this.#writeConfig(cfg, (after) => {
|
||||||
|
const a = after.relay_connect as Record<string, unknown> | undefined;
|
||||||
|
return (
|
||||||
|
a?.relay_pw === relayPassword &&
|
||||||
|
(a?.rs485 as Record<string, unknown> | undefined)?.p === 255 &&
|
||||||
|
(a?.mqtt as Record<string, unknown> | undefined)?.p === 255
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const applied = [
|
||||||
|
"set relay password",
|
||||||
|
"disabled rs485/can/tcp/mqtt channels (kept UDP binary + string)",
|
||||||
|
];
|
||||||
|
const secrets: Record<string, string | number> = { relayPassword };
|
||||||
|
|
||||||
|
// Rotate the default admin/admin web login. NOTE: cosmetic — this device's
|
||||||
|
// CGI API needs NO auth (config read/write + relay fire + this very call all
|
||||||
|
// work unauthenticated), so the login only gates the interactive browser UI,
|
||||||
|
// not the control plane. We rotate it anyway (defence-in-depth: stops a
|
||||||
|
// casual browser reaching the settings page), but it is NOT a boundary; the
|
||||||
|
// signed event log is. See dingtian-relay.md.
|
||||||
|
try {
|
||||||
|
const newPassword = await this.#rotateWebLogin();
|
||||||
|
secrets.webUser = this.#webUser;
|
||||||
|
secrets.webPassword = newPassword;
|
||||||
|
applied.push("rotated the admin/admin web-UI login (cosmetic — CGI API is unauthenticated)");
|
||||||
|
} catch (err) {
|
||||||
|
// Don't fail the whole harden over a cosmetic step — log and continue.
|
||||||
|
stubLog(this.driverId, `web-login rotate skipped: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { secrets, applied };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rotate the device web-UI login password (keeps the username) via
|
||||||
|
* `userset.cgi?<old_user>&<old_pass>&<new_user>&<new_pass>&`. Returns the new
|
||||||
|
* password. The device validates the OLD credentials in the query, so we send
|
||||||
|
* the current ones (admin/admin on first run, the stored pair afterwards).
|
||||||
|
* Response is `&<code>&<redirect>&` with code 0 = success. Password is hex
|
||||||
|
* (URL-safe, no escaping) and ≤31 chars (the device truncates longer).
|
||||||
|
*/
|
||||||
|
async #rotateWebLogin(): Promise<string> {
|
||||||
|
const newPassword = randomBytes(12).toString("hex"); // 24 hex chars
|
||||||
|
const u = encodeURIComponent(this.#webUser);
|
||||||
|
const oldP = encodeURIComponent(this.#webPassword);
|
||||||
|
const path = `/userset.cgi?${u}&${oldP}&${u}&${newPassword}&`;
|
||||||
|
const res = await cgiGet(this.#host, this.#httpPort, path, this.#timeout);
|
||||||
|
// "&0&/&" = success; anything else (e.g. "&-5&/&" bad params / wrong old pw).
|
||||||
|
const code = res.split("&")[1];
|
||||||
|
if (code !== "0") {
|
||||||
|
throw new Error(`userset.cgi rejected (response "${res.trim()}")`);
|
||||||
|
}
|
||||||
|
return newPassword;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- config api internals ----------------------------------------------
|
||||||
|
|
||||||
|
async #readConfig(): Promise<Record<string, unknown>> {
|
||||||
|
const raw = await configApi(this.#host, this.#httpPort, "/api/v2/config.cgi", "GET", null, this.#timeout, this.#sessionId);
|
||||||
|
return JSON.parse(raw) as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write full config back, then WAIT for the device to apply it. The device
|
||||||
|
* reboots on apply (~10s) and back-to-back writes onto a rebooting device are
|
||||||
|
* silently lost — so we poll until the device is reachable again AND `verify`
|
||||||
|
* confirms the change landed, retrying the write if needed.
|
||||||
|
*
|
||||||
|
* @param verify predicate over the re-read config; should return true once the
|
||||||
|
* intended change is present.
|
||||||
|
*/
|
||||||
|
async #writeConfig(
|
||||||
|
cfg: Record<string, unknown>,
|
||||||
|
verify: (after: Record<string, unknown>) => boolean,
|
||||||
|
): Promise<void> {
|
||||||
|
// The set endpoint requires `"command":"setconfig"` injected after `status`
|
||||||
|
// (the GET payload omits it). Rebuild preserving node order, command second.
|
||||||
|
const out: Record<string, unknown> = {};
|
||||||
|
for (const [k, v] of Object.entries(cfg)) {
|
||||||
|
out[k] = v;
|
||||||
|
if (k === "status") out.command = "setconfig";
|
||||||
|
}
|
||||||
|
if (!("command" in out)) out.command = "setconfig";
|
||||||
|
const payload = JSON.stringify(out);
|
||||||
|
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= 3; attempt++) {
|
||||||
|
// POST. The device resets on apply, so the connection may drop — that's
|
||||||
|
// expected, not failure.
|
||||||
|
try {
|
||||||
|
await configApi(this.#host, this.#httpPort, "/api/v2/config_set.cgi", "POST", payload, this.#timeout, this.#sessionId);
|
||||||
|
} catch {
|
||||||
|
// device likely reset on apply
|
||||||
|
}
|
||||||
|
|
||||||
|
// Poll for the device to come back and the change to be present.
|
||||||
|
for (let i = 0; i < 12; i++) {
|
||||||
|
await sleep(2000);
|
||||||
|
try {
|
||||||
|
if (verify(await this.#readConfig())) return; // applied
|
||||||
|
} catch {
|
||||||
|
// still rebooting / unreachable — keep polling
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Not applied within the window — likely the POST hit a rebooting device.
|
||||||
|
// Loop and re-POST (now that it's reachable again).
|
||||||
|
}
|
||||||
|
throw new Error("dingtian: config write did not apply after retries");
|
||||||
|
}
|
||||||
|
|
||||||
|
#linkDisabled(cfg: Record<string, unknown>): boolean {
|
||||||
|
const ilr = cfg.input_link_relay as Record<string, unknown> | undefined;
|
||||||
|
if (!ilr) return true; // no such block → nothing to link
|
||||||
|
const flagOff = ilr.input_link_relay === 0;
|
||||||
|
const mapsEmpty =
|
||||||
|
!Array.isArray(ilr.on_action_on) ||
|
||||||
|
(ilr.on_action_on as unknown[]).every((a) => Array.isArray(a) && a.length === 0);
|
||||||
|
return flagOff || mapsEmpty;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- internals ----------------------------------------------------------
|
||||||
|
|
||||||
|
#assertChannel(ch: number): void {
|
||||||
|
if (!Number.isInteger(ch) || ch < 1 || ch > this.#channels) {
|
||||||
|
throw new Error(`dingtian: channel ${ch} out of range (1..${this.#channels})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Query "00" → parse "0000:1111:4" into relays/inputs/channels. */
|
||||||
|
async #status(): Promise<DingtianStatus> {
|
||||||
|
const reply = await udpRequest(this.#host, this.#port, "00", this.#timeout, true);
|
||||||
|
if (!reply) throw new Error("dingtian: empty status reply");
|
||||||
|
const [relayStr, inputStr, countStr] = reply.trim().split(":");
|
||||||
|
if (relayStr === undefined || inputStr === undefined) {
|
||||||
|
throw new Error(`dingtian: bad status reply "${reply}"`);
|
||||||
|
}
|
||||||
|
const bit = (c: string) => c === "1";
|
||||||
|
return {
|
||||||
|
relays: [...relayStr].map(bit),
|
||||||
|
// active = differs from the resting level (press pulls the line).
|
||||||
|
inputs: [...inputStr].map((c) => bit(c) !== this.#restingHigh),
|
||||||
|
channels: countStr ? Number(countStr) : this.#channels,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
#startPolling(): void {
|
||||||
|
if (this.#poll) return;
|
||||||
|
const tick = async () => {
|
||||||
|
let inputs: boolean[];
|
||||||
|
try {
|
||||||
|
inputs = await this.readInputs();
|
||||||
|
} catch {
|
||||||
|
return; // transient; try again next tick
|
||||||
|
}
|
||||||
|
const prev = this.#last;
|
||||||
|
this.#last = inputs;
|
||||||
|
if (!prev) return; // first sample establishes a baseline, no events
|
||||||
|
const at = new Date().toISOString();
|
||||||
|
for (let i = 0; i < inputs.length; i++) {
|
||||||
|
if (inputs[i] === prev[i]) continue;
|
||||||
|
const event: InputEvent = {
|
||||||
|
input: i + 1,
|
||||||
|
edge: inputs[i] ? "pressed" : "released",
|
||||||
|
at,
|
||||||
|
};
|
||||||
|
for (const cb of this.#subs) cb(event);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// ~50ms poll: a button press is held well longer than this.
|
||||||
|
this.#poll = setInterval(() => void tick(), 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
#stopPolling(): void {
|
||||||
|
if (this.#poll) {
|
||||||
|
clearInterval(this.#poll);
|
||||||
|
this.#poll = null;
|
||||||
|
this.#last = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const dingtianDriver: AccessDriver = {
|
||||||
|
id: "dingtian",
|
||||||
|
category: "access",
|
||||||
|
label: "Dingtian relay controller",
|
||||||
|
description:
|
||||||
|
"Dingtian network relay+input board (UDP). Inputs are decoupled from relays — enables host-in-the-loop entry. Unauthenticated UDP: isolate the VLAN.",
|
||||||
|
transports: ["udp"],
|
||||||
|
configFields: [
|
||||||
|
hostField,
|
||||||
|
{ ...portField(60001), required: false, help: "Dingtian string protocol UDP port — status read (default 60001)." },
|
||||||
|
{ key: "binaryPort", label: "Binary protocol port", type: "port", required: false, default: 60000, help: "Dingtian binary protocol UDP port — authenticated relay control (default 60000)." },
|
||||||
|
{ key: "httpPort", label: "HTTP config port", type: "port", required: false, default: 80, help: "Device web/config-API port (default 80)." },
|
||||||
|
{ key: "channels", label: "Channels (relays/inputs)", type: "number", required: true, default: 4 },
|
||||||
|
{
|
||||||
|
key: "pulseMs",
|
||||||
|
label: "Pulse open (ms)",
|
||||||
|
type: "number",
|
||||||
|
required: false,
|
||||||
|
default: 500,
|
||||||
|
help: "Momentary relay pulse; the barrier operator owns the close.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "inputRestingHigh",
|
||||||
|
label: "Inputs idle HIGH",
|
||||||
|
type: "boolean",
|
||||||
|
required: false,
|
||||||
|
default: true,
|
||||||
|
help: "This board idles inputs HIGH (status 1111); a press pulls LOW.",
|
||||||
|
},
|
||||||
|
{ key: "timeoutMs", label: "Timeout (ms)", type: "number", required: false, default: 2000 },
|
||||||
|
// Current device web-UI login. Defaults to admin/admin; harden() rotates the
|
||||||
|
// password and stores the new pair back here so a re-run can rotate again.
|
||||||
|
// (Gates only the browser UI — the CGI control plane is unauthenticated.)
|
||||||
|
{ key: "webUser", label: "Device web username", type: "string", required: false, default: "admin", help: "Device web-UI login user (default admin)." },
|
||||||
|
{ key: "webPassword", label: "Device web password", type: "secret", required: false, help: "Device web-UI login password (default admin; rotated on save)." },
|
||||||
|
],
|
||||||
|
create: (c) => new DingtianController(c),
|
||||||
|
};
|
||||||
@@ -1,263 +0,0 @@
|
|||||||
import { networkInterfaces } from "node:os";
|
|
||||||
import uhppoted, { type Controller, type Ctx } from "uhppoted";
|
|
||||||
import type { AccessControlDevice, DeviceHealth } from "../interfaces.js";
|
|
||||||
import type {
|
|
||||||
AccessDriver,
|
|
||||||
DeviceConfig,
|
|
||||||
DiscoveredDevice,
|
|
||||||
} from "../registry.js";
|
|
||||||
import { hostField, stubLog } from "./common.js";
|
|
||||||
|
|
||||||
// `uhppoted` is CommonJS — import the default and destructure (named ESM imports
|
|
||||||
// don't resolve off a CJS module under NodeNext).
|
|
||||||
const { Config, getDevices, getStatus, openDoor } = uhppoted;
|
|
||||||
|
|
||||||
// Every uhppoted call binds a UDP listener on :60001 for replies. Concurrent
|
|
||||||
// calls collide on that port (EACCES / dropped replies → spurious timeouts), so
|
|
||||||
// we serialize ALL controller I/O through one queue. UDP request/response is
|
|
||||||
// fast, so serial throughput is fine for a parking host. This is why parallel
|
|
||||||
// discovery + health checks were timing out.
|
|
||||||
let chain: Promise<unknown> = Promise.resolve();
|
|
||||||
function serialize<T>(fn: () => Promise<T>): Promise<T> {
|
|
||||||
const run = chain.then(fn, fn);
|
|
||||||
// keep the chain alive regardless of this call's outcome
|
|
||||||
chain = run.then(
|
|
||||||
() => undefined,
|
|
||||||
() => undefined,
|
|
||||||
);
|
|
||||||
return run;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Compute subnet-directed broadcast addresses (e.g. 10.0.10.255) for every
|
|
||||||
* non-internal IPv4 interface.
|
|
||||||
*
|
|
||||||
* Why this matters: the uhppoted lib only enables SO_BROADCAST when the target
|
|
||||||
* matches a *subnet-directed* broadcast of a local interface — it does NOT
|
|
||||||
* recognise the global 255.255.255.255, so broadcasting there fails with EACCES.
|
|
||||||
* We must broadcast to the per-interface address (e.g. 10.0.10.255) instead.
|
|
||||||
*/
|
|
||||||
interface Iface {
|
|
||||||
network: number[]; // ip & mask, per octet
|
|
||||||
mask: number[];
|
|
||||||
broadcast: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function localIfaces(): Iface[] {
|
|
||||||
const out: Iface[] = [];
|
|
||||||
for (const ifaces of Object.values(networkInterfaces())) {
|
|
||||||
for (const i of ifaces ?? []) {
|
|
||||||
if (i.family !== "IPv4" || i.internal) continue;
|
|
||||||
const ip = i.address.split(".").map(Number);
|
|
||||||
const mask = i.netmask.split(".").map(Number);
|
|
||||||
if (ip.length !== 4 || mask.length !== 4) continue;
|
|
||||||
out.push({
|
|
||||||
network: ip.map((o, k) => o & mask[k]!),
|
|
||||||
mask,
|
|
||||||
broadcast: ip.map((o, k) => (o & mask[k]!) | (~mask[k]! & 0xff)).join("."),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
function subnetBroadcastAddrs(): string[] {
|
|
||||||
return localIfaces().map((i) => i.broadcast);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Broadcast target for discovery: explicit override, else first subnet bcast. */
|
|
||||||
function discoveryBroadcast(): string {
|
|
||||||
return (
|
|
||||||
process.env.UHPPOTE_BROADCAST ?? subnetBroadcastAddrs()[0] ?? "255.255.255.255"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The subnet-directed broadcast for the interface that `host` belongs to. The
|
|
||||||
* uhppoted Config's broadcast address governs reply routing even for unicast
|
|
||||||
* ops, so it must match the TARGET's subnet (not just the first interface) or
|
|
||||||
* the reply is missed → timeout.
|
|
||||||
*/
|
|
||||||
function broadcastForHost(host: string): string {
|
|
||||||
const ip = host.split(".").map(Number);
|
|
||||||
if (ip.length === 4) {
|
|
||||||
for (const i of localIfaces()) {
|
|
||||||
if (ip.every((o, k) => (o & i.mask[k]!) === i.network[k])) return i.broadcast;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return discoveryBroadcast();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Real UHPPOTE access-control driver, backed by the official `uhppoted` lib.
|
|
||||||
// Implements AccessControlDevice (intent-only relay — "a barrier is not a door";
|
|
||||||
// the controller/barrier operator owns physical safety). See
|
|
||||||
// wiki/entities/uhppote-controller.md and wiki/concepts/barrier-not-a-door.md.
|
|
||||||
//
|
|
||||||
// SECURITY: the UHPPOTE protocol is unauthenticated UDP (port 60000). This driver
|
|
||||||
// assumes the controller sits on an isolated VLAN reachable only by the host.
|
|
||||||
// See wiki/concepts/uhppote-udp-protocol.md and network-isolation.md.
|
|
||||||
|
|
||||||
/**
|
|
||||||
* uhppoted context broadcasting to a specific address on :60000, listening for
|
|
||||||
* replies on :60001.
|
|
||||||
*/
|
|
||||||
function buildCtxFor(broadcast: string, timeoutMs = 5000): Ctx {
|
|
||||||
return {
|
|
||||||
config: new Config(
|
|
||||||
"parking",
|
|
||||||
"0.0.0.0",
|
|
||||||
`${broadcast}:60000`,
|
|
||||||
"0.0.0.0:60001",
|
|
||||||
timeoutMs,
|
|
||||||
[],
|
|
||||||
false,
|
|
||||||
),
|
|
||||||
locale: "en-US",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Default context for non-discovery ops (status/open use a unicast host). */
|
|
||||||
function buildCtx(timeoutMs = 5000): Ctx {
|
|
||||||
return buildCtxFor(discoveryBroadcast(), timeoutMs);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Broadcast targets to try for discovery. An explicit UHPPOTE_BROADCAST wins;
|
|
||||||
* otherwise every local subnet-directed broadcast (a host may have several
|
|
||||||
* interfaces — LAN, VPN, docker — and the controller is on only one).
|
|
||||||
*/
|
|
||||||
function discoveryBroadcasts(): string[] {
|
|
||||||
const override = process.env.UHPPOTE_BROADCAST;
|
|
||||||
if (override) return [override];
|
|
||||||
const addrs = subnetBroadcastAddrs();
|
|
||||||
return addrs.length > 0 ? addrs : ["255.255.255.255"];
|
|
||||||
}
|
|
||||||
|
|
||||||
class UhppoteAccessControl implements AccessControlDevice {
|
|
||||||
readonly driverId = "uhppote";
|
|
||||||
readonly #controller: Controller;
|
|
||||||
readonly #ctx: Ctx;
|
|
||||||
|
|
||||||
constructor(config: DeviceConfig) {
|
|
||||||
const serial = Number(config.serial);
|
|
||||||
const address = config.host ? String(config.host) : undefined;
|
|
||||||
const protocol = config.protocol === "tcp" ? "tcp" : "udp";
|
|
||||||
|
|
||||||
// Addressable descriptor when a host is given; otherwise rely on UDP
|
|
||||||
// broadcast discovery by serial.
|
|
||||||
this.#controller = address ? { id: serial, address, protocol } : serial;
|
|
||||||
|
|
||||||
// The Config broadcast must match the target host's subnet (it governs
|
|
||||||
// reply routing even for unicast), else replies are missed → timeout.
|
|
||||||
const timeoutMs = config.timeoutMs ? Number(config.timeoutMs) : 5000;
|
|
||||||
this.#ctx = address
|
|
||||||
? buildCtxFor(broadcastForHost(address), timeoutMs)
|
|
||||||
: buildCtx(timeoutMs);
|
|
||||||
}
|
|
||||||
|
|
||||||
async connect(): Promise<void> {
|
|
||||||
// No persistent socket to open (request/response over UDP); verify reachability.
|
|
||||||
await this.healthCheck();
|
|
||||||
}
|
|
||||||
|
|
||||||
async disconnect(): Promise<void> {
|
|
||||||
stubLog(this.driverId, "disconnect (stateless udp — nothing to close)");
|
|
||||||
}
|
|
||||||
|
|
||||||
async healthCheck(): Promise<DeviceHealth> {
|
|
||||||
try {
|
|
||||||
await serialize(() => getStatus(this.#ctx, this.#controller));
|
|
||||||
return { status: "ready" };
|
|
||||||
} catch (err) {
|
|
||||||
return { status: "offline", detail: (err as Error).message };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Express intent to open a door (1–4). NEVER timed/forced closed against a
|
|
||||||
* vehicle — auto-close/anti-crush is the barrier operator's firmware.
|
|
||||||
*/
|
|
||||||
async pulseOpen(doorId: number): Promise<void> {
|
|
||||||
const res = await serialize(() =>
|
|
||||||
openDoor(this.#ctx, this.#controller, doorId),
|
|
||||||
);
|
|
||||||
if (!res.opened) {
|
|
||||||
throw new Error(`uhppote: door ${doorId} not opened (deviceId ${res.deviceId})`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async getDoorStatus(): Promise<"open" | "closed"> {
|
|
||||||
// The UHPPOTE status payload carries per-door state; without a confirmed
|
|
||||||
// wiring of door sensors we report the safe default until the real status
|
|
||||||
// mapping is added. (Status is fetched to prove reachability.)
|
|
||||||
await serialize(() => getStatus(this.#ctx, this.#controller));
|
|
||||||
return "closed";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const uhppoteDriver: AccessDriver & {
|
|
||||||
discover(): Promise<DiscoveredDevice[]>;
|
|
||||||
} = {
|
|
||||||
id: "uhppote",
|
|
||||||
category: "access",
|
|
||||||
label: "UHPPOTE controller",
|
|
||||||
description:
|
|
||||||
"UHPPOTE Wiegand 26/34 network controller via the official uhppoted lib. Unauthenticated UDP — isolate the VLAN.",
|
|
||||||
transports: ["udp", "tcp"],
|
|
||||||
// UDP broadcast discovery (get-devices): every controller on the LAN answers
|
|
||||||
// with its serial, IP, and firmware. Broadcasts on every local subnet (the
|
|
||||||
// controller is on only one interface) and dedupes by serial.
|
|
||||||
// See wiki/concepts/device-discovery.md.
|
|
||||||
async discover(): Promise<DiscoveredDevice[]> {
|
|
||||||
const bySerial = new Map<number, DiscoveredDevice>();
|
|
||||||
// Serial, not parallel: each getDevices binds :60001, so concurrent scans
|
|
||||||
// across interfaces collide (EACCES / dropped replies).
|
|
||||||
for (const bcast of discoveryBroadcasts()) {
|
|
||||||
let found;
|
|
||||||
try {
|
|
||||||
found = await serialize(() => getDevices(buildCtxFor(bcast, 3000)));
|
|
||||||
} catch {
|
|
||||||
continue; // a dead interface shouldn't fail the whole scan
|
|
||||||
}
|
|
||||||
for (const d of found) {
|
|
||||||
bySerial.set(d.device.serialNumber, {
|
|
||||||
id: String(d.device.serialNumber),
|
|
||||||
label: `UHPPOTE ${d.device.serialNumber} @ ${d.device.address}`,
|
|
||||||
config: { serial: d.device.serialNumber, host: d.device.address, protocol: "udp" },
|
|
||||||
info: {
|
|
||||||
address: d.device.address,
|
|
||||||
netmask: d.device.netmask,
|
|
||||||
gateway: d.device.gateway,
|
|
||||||
MAC: d.device.MAC,
|
|
||||||
firmware: d.device.version,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return [...bySerial.values()];
|
|
||||||
},
|
|
||||||
configFields: [
|
|
||||||
{
|
|
||||||
key: "serial",
|
|
||||||
label: "Controller serial number",
|
|
||||||
type: "number",
|
|
||||||
required: true,
|
|
||||||
help: "Printed on the controller (e.g. 405419896).",
|
|
||||||
},
|
|
||||||
{ ...hostField, required: false, help: "Optional: target a specific IP instead of UDP broadcast. Isolated VLAN only." },
|
|
||||||
{
|
|
||||||
key: "protocol",
|
|
||||||
label: "Protocol",
|
|
||||||
type: "select",
|
|
||||||
required: false,
|
|
||||||
default: "udp",
|
|
||||||
options: [
|
|
||||||
{ value: "udp", label: "UDP (default)" },
|
|
||||||
{ value: "tcp", label: "TCP (newer firmware)" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{ key: "doors", label: "Door count", type: "number", required: true, default: 4 },
|
|
||||||
{ key: "timeoutMs", label: "Timeout (ms)", type: "number", required: false, default: 5000 },
|
|
||||||
],
|
|
||||||
create: (c) => new UhppoteAccessControl(c),
|
|
||||||
};
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
import type { AccessControlDevice, DeviceHealth } from "../interfaces.js";
|
|
||||||
import type { AccessDriver, DeviceConfig } from "../registry.js";
|
|
||||||
import { hostField, portField, stubLog } from "./common.js";
|
|
||||||
|
|
||||||
// Access-control drivers. Each implements AccessControlDevice (intent-only relay
|
|
||||||
// — "a barrier is not a door"). STUBS: connect/log only, no real protocol yet.
|
|
||||||
|
|
||||||
class StubAccessControl implements AccessControlDevice {
|
|
||||||
constructor(
|
|
||||||
readonly driverId: string,
|
|
||||||
protected readonly config: DeviceConfig,
|
|
||||||
) {}
|
|
||||||
async connect(): Promise<void> {
|
|
||||||
stubLog(this.driverId, `connect ${this.config.host}:${this.config.port}`);
|
|
||||||
}
|
|
||||||
async disconnect(): Promise<void> {
|
|
||||||
stubLog(this.driverId, "disconnect");
|
|
||||||
}
|
|
||||||
async healthCheck(): Promise<DeviceHealth> {
|
|
||||||
return { status: "ready", detail: "stub" };
|
|
||||||
}
|
|
||||||
async pulseOpen(doorId: number): Promise<void> {
|
|
||||||
// Intent only — never times/forces a close against a vehicle.
|
|
||||||
stubLog(this.driverId, `pulseOpen door=${doorId}`);
|
|
||||||
}
|
|
||||||
async getDoorStatus(): Promise<"open" | "closed"> {
|
|
||||||
return "closed";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const zktecoDriver: AccessDriver = {
|
|
||||||
id: "zkteco",
|
|
||||||
category: "access",
|
|
||||||
label: "ZKTeco controller",
|
|
||||||
description: "ZKTeco network access controller (TCP/IP). Reader + relay.",
|
|
||||||
transports: ["tcp-ip"],
|
|
||||||
configFields: [hostField, portField(4370), { key: "doors", label: "Door count", type: "number", required: true, default: 4 }],
|
|
||||||
create: (c) => new StubAccessControl("zkteco", c),
|
|
||||||
};
|
|
||||||
|
|
||||||
export const esp32RelayDriver: AccessDriver = {
|
|
||||||
id: "esp32-relay",
|
|
||||||
category: "access",
|
|
||||||
label: "ESP32 relay controller",
|
|
||||||
description: "Simple ESP32-based relay controller over the network.",
|
|
||||||
transports: ["tcp-ip"],
|
|
||||||
configFields: [hostField, portField(80), { key: "doors", label: "Relay channels", type: "number", required: true, default: 1 }],
|
|
||||||
create: (c) => new StubAccessControl("esp32-relay", c),
|
|
||||||
};
|
|
||||||
@@ -2,8 +2,7 @@
|
|||||||
// module wires the catalog. Add a new device by registering it here.
|
// module wires the catalog. Add a new device by registering it here.
|
||||||
|
|
||||||
import { registry } from "../registry.js";
|
import { registry } from "../registry.js";
|
||||||
import { esp32RelayDriver, zktecoDriver } from "./access.js";
|
import { dingtianDriver } from "./access-dingtian.js";
|
||||||
import { uhppoteDriver } from "./access-uhppote.js";
|
|
||||||
import { dahuaDriver, hikvisionDriver } from "./camera.js";
|
import { dahuaDriver, hikvisionDriver } from "./camera.js";
|
||||||
import { tcpipReaderDriver, wiegandReaderDriver } from "./reader.js";
|
import { tcpipReaderDriver, wiegandReaderDriver } from "./reader.js";
|
||||||
|
|
||||||
@@ -13,9 +12,7 @@ let registered = false;
|
|||||||
export function registerBuiltinDrivers(): void {
|
export function registerBuiltinDrivers(): void {
|
||||||
if (registered) return;
|
if (registered) return;
|
||||||
registered = true;
|
registered = true;
|
||||||
registry.register(uhppoteDriver);
|
registry.register(dingtianDriver);
|
||||||
registry.register(zktecoDriver);
|
|
||||||
registry.register(esp32RelayDriver);
|
|
||||||
registry.register(wiegandReaderDriver);
|
registry.register(wiegandReaderDriver);
|
||||||
registry.register(tcpipReaderDriver);
|
registry.register(tcpipReaderDriver);
|
||||||
registry.register(hikvisionDriver);
|
registry.register(hikvisionDriver);
|
||||||
@@ -23,9 +20,7 @@ export function registerBuiltinDrivers(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
uhppoteDriver,
|
dingtianDriver,
|
||||||
zktecoDriver,
|
|
||||||
esp32RelayDriver,
|
|
||||||
wiegandReaderDriver,
|
wiegandReaderDriver,
|
||||||
tcpipReaderDriver,
|
tcpipReaderDriver,
|
||||||
hikvisionDriver,
|
hikvisionDriver,
|
||||||
|
|||||||
-83
@@ -1,83 +0,0 @@
|
|||||||
// Minimal ambient types for the `uhppoted` CommonJS module (no bundled types).
|
|
||||||
// Only the surface we use; extend as we adopt more of the API.
|
|
||||||
// Upstream: https://github.com/uhppoted/uhppoted-lib-nodejs
|
|
||||||
declare module "uhppoted" {
|
|
||||||
export class Config {
|
|
||||||
constructor(
|
|
||||||
name?: string,
|
|
||||||
bindAddr?: string,
|
|
||||||
broadcastAddr?: string,
|
|
||||||
listenAddr?: string,
|
|
||||||
timeout?: number,
|
|
||||||
controllers?: unknown[],
|
|
||||||
debug?: boolean,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Either a bare controller serial, or an addressable descriptor. */
|
|
||||||
export type Controller =
|
|
||||||
| number
|
|
||||||
| { id: number; address?: string; protocol?: "udp" | "tcp" };
|
|
||||||
|
|
||||||
export interface Ctx {
|
|
||||||
config: Config;
|
|
||||||
locale?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DiscoveredController {
|
|
||||||
deviceId: number;
|
|
||||||
device: {
|
|
||||||
serialNumber: number;
|
|
||||||
address: string;
|
|
||||||
netmask: string;
|
|
||||||
gateway: string;
|
|
||||||
MAC: string;
|
|
||||||
version: string;
|
|
||||||
date: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** UDP broadcast discovery — returns every controller answering on the LAN. */
|
|
||||||
export function getDevices(ctx: Ctx): Promise<DiscoveredController[]>;
|
|
||||||
|
|
||||||
export function openDoor(
|
|
||||||
ctx: Ctx,
|
|
||||||
controller: Controller,
|
|
||||||
door: number,
|
|
||||||
): Promise<{ deviceId: number; opened: boolean }>;
|
|
||||||
|
|
||||||
export function getStatus(
|
|
||||||
ctx: Ctx,
|
|
||||||
controller: Controller,
|
|
||||||
): Promise<Record<string, unknown>>;
|
|
||||||
|
|
||||||
export function getEvent(
|
|
||||||
ctx: Ctx,
|
|
||||||
controller: Controller,
|
|
||||||
index: number,
|
|
||||||
): Promise<Record<string, unknown>>;
|
|
||||||
|
|
||||||
export function getEventIndex(
|
|
||||||
ctx: Ctx,
|
|
||||||
controller: Controller,
|
|
||||||
): Promise<{ deviceId: number; index: number }>;
|
|
||||||
|
|
||||||
export function setListener(
|
|
||||||
ctx: Ctx,
|
|
||||||
controller: Controller,
|
|
||||||
address: string,
|
|
||||||
port: number,
|
|
||||||
): Promise<unknown>;
|
|
||||||
|
|
||||||
// CommonJS default export (module.exports = { ... }). Destructure from this.
|
|
||||||
const uhppoted: {
|
|
||||||
Config: typeof Config;
|
|
||||||
getDevices: typeof getDevices;
|
|
||||||
openDoor: typeof openDoor;
|
|
||||||
getStatus: typeof getStatus;
|
|
||||||
getEvent: typeof getEvent;
|
|
||||||
getEventIndex: typeof getEventIndex;
|
|
||||||
setListener: typeof setListener;
|
|
||||||
};
|
|
||||||
export default uhppoted;
|
|
||||||
}
|
|
||||||
@@ -5,5 +5,14 @@
|
|||||||
|
|
||||||
export * from "./interfaces.js";
|
export * from "./interfaces.js";
|
||||||
export * from "./registry.js";
|
export * from "./registry.js";
|
||||||
export { registerBuiltinDrivers } from "./drivers/index.js";
|
|
||||||
export { setDeviceLogSink, type DeviceLogSink } from "./drivers/common.js";
|
export { setDeviceLogSink, type DeviceLogSink } from "./drivers/common.js";
|
||||||
|
// Built-in drivers: the registrar plus the individual driver objects (used by
|
||||||
|
// hardware test scripts and any direct/programmatic device access).
|
||||||
|
export {
|
||||||
|
registerBuiltinDrivers,
|
||||||
|
dingtianDriver,
|
||||||
|
wiegandReaderDriver,
|
||||||
|
tcpipReaderDriver,
|
||||||
|
hikvisionDriver,
|
||||||
|
dahuaDriver,
|
||||||
|
} from "./drivers/index.js";
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export type DeviceCategory = "access" | "reader" | "camera" | "printer";
|
|||||||
|
|
||||||
/** Lifecycle shared by every device adapter. */
|
/** Lifecycle shared by every device adapter. */
|
||||||
export interface Device {
|
export interface Device {
|
||||||
/** Stable id of the driver that produced this instance (e.g. "zkteco"). */
|
/** Stable id of the driver that produced this instance (e.g. "dingtian"). */
|
||||||
readonly driverId: string;
|
readonly driverId: string;
|
||||||
connect(): Promise<void>;
|
connect(): Promise<void>;
|
||||||
disconnect(): Promise<void>;
|
disconnect(): Promise<void>;
|
||||||
@@ -28,13 +28,126 @@ export interface DeviceHealth {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- Access control (barrier relay) --------------------------------------
|
// --- Access control (barrier relay) --------------------------------------
|
||||||
// ZKTeco, an ESP32 relay controller, UHPPOTE, etc. all implement this.
|
// The Dingtian relay board (and any future relay controller) implements this.
|
||||||
export interface AccessControlDevice extends Device {
|
export interface AccessControlDevice extends Device {
|
||||||
/** Express intent to open. NEVER timed/forced closed against a vehicle. */
|
/** Express intent to open. NEVER timed/forced closed against a vehicle. */
|
||||||
pulseOpen(doorId: number): Promise<void>;
|
pulseOpen(doorId: number): Promise<void>;
|
||||||
getDoorStatus(doorId: number): Promise<"open" | "closed">;
|
getDoorStatus(doorId: number): Promise<"open" | "closed">;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Inputs (buttons / dry contacts) -------------------------------------
|
||||||
|
// Optional capability for controllers that expose host-readable inputs SEPARATE
|
||||||
|
// from their relays — e.g. the Dingtian board. This is what enables host-in-the-
|
||||||
|
// loop entry: a button press is reported to the host, which decides (print a
|
||||||
|
// ticket) before commanding the relay — instead of the input auto-firing the
|
||||||
|
// relay. See wiki/decisions/access-controller-button-flow.md.
|
||||||
|
export interface InputDevice {
|
||||||
|
/** Read the current state of all inputs (true = active/pressed). */
|
||||||
|
readInputs(): Promise<boolean[]>;
|
||||||
|
/**
|
||||||
|
* Subscribe to input edges. Returns an unsubscribe fn. Implementations may
|
||||||
|
* back this with hardware push or polling — the consumer doesn't care.
|
||||||
|
*/
|
||||||
|
onInput(cb: (event: InputEvent) => void): () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InputEvent {
|
||||||
|
/** 1-based input/channel index. */
|
||||||
|
readonly input: number;
|
||||||
|
/** Edge: pressed = went active, released = went inactive. */
|
||||||
|
readonly edge: "pressed" | "released";
|
||||||
|
readonly at: string; // ISO-8601
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Type guard: does this device expose host-readable inputs? */
|
||||||
|
export function hasInputs(device: Device): device is Device & InputDevice {
|
||||||
|
return (
|
||||||
|
typeof (device as Partial<InputDevice>).readInputs === "function" &&
|
||||||
|
typeof (device as Partial<InputDevice>).onInput === "function"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Preconditions (device must be configured a certain way) -------------
|
||||||
|
// Optional capability: a device that depends on specific on-device configuration
|
||||||
|
// to work correctly for parking can report it. Example: the Dingtian board must
|
||||||
|
// have `input_link_relay` DISABLED, else a button press auto-fires the relay and
|
||||||
|
// defeats host-in-the-loop entry (the same trap as the UHPPOTE, but fixable here).
|
||||||
|
// The app does not own full device config (that's the vendor's web UI) — it only
|
||||||
|
// checks the few preconditions our flow depends on, and optionally fixes them.
|
||||||
|
// See wiki/decisions/access-controller-button-flow.md.
|
||||||
|
export interface PreconditionDevice {
|
||||||
|
checkPreconditions(): Promise<PreconditionResult>;
|
||||||
|
/** Apply automatic fixes for fixable issues; returns the re-checked result. */
|
||||||
|
fixPreconditions(): Promise<PreconditionResult>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PreconditionResult {
|
||||||
|
readonly ok: boolean;
|
||||||
|
readonly issues: PreconditionIssue[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PreconditionIssue {
|
||||||
|
readonly key: string;
|
||||||
|
readonly message: string;
|
||||||
|
/** True if fixPreconditions() can correct this automatically. */
|
||||||
|
readonly fixable: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasPreconditions(
|
||||||
|
device: Device,
|
||||||
|
): device is Device & PreconditionDevice {
|
||||||
|
return typeof (device as Partial<PreconditionDevice>).checkPreconditions === "function";
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Push configuration (device → backend) -------------------------------
|
||||||
|
// Optional capability: a device that can be told to HTTP-push its input/button
|
||||||
|
// events to our backend (vs. the host polling it). The backend configures the
|
||||||
|
// device with where to call and a shared-secret token embedded in the path.
|
||||||
|
// The Dingtian board implements this via its "Input Link URL" feature.
|
||||||
|
// See wiki/concepts/device-input-flow.md.
|
||||||
|
export interface PushConfigurableDevice {
|
||||||
|
configureInputPush(opts: PushConfig): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PushConfig {
|
||||||
|
/** Backend host the device should call (our IP on the device's subnet). */
|
||||||
|
readonly host: string;
|
||||||
|
readonly port: number;
|
||||||
|
/** Path prefix the device appends `/<input>/<on|off>` to,
|
||||||
|
* e.g. `/api/devices/dingtian/<deviceId>/input`. */
|
||||||
|
readonly pathBase: string;
|
||||||
|
/** HTTP Digest credentials the device authenticates the push with. */
|
||||||
|
readonly auth: { user: string; password: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasPushConfig(
|
||||||
|
device: Device,
|
||||||
|
): device is Device & PushConfigurableDevice {
|
||||||
|
return typeof (device as Partial<PushConfigurableDevice>).configureInputPush === "function";
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Hardening (lock the device down) ------------------------------------
|
||||||
|
// Optional capability: a device that can be hardened against a flat (no-VLAN)
|
||||||
|
// network — disable unused protocols/channels, set a relay password, and change
|
||||||
|
// the default web/config login. Returns any secrets the backend must persist to
|
||||||
|
// keep talking to the device. See wiki/concepts/device-input-flow.md.
|
||||||
|
export interface HardenableDevice {
|
||||||
|
harden(): Promise<HardenResult>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HardenResult {
|
||||||
|
/** Secrets to persist in lane_devices so the backend can keep operating the
|
||||||
|
* device (relay password, new web login). The backend merges these into the
|
||||||
|
* stored config. */
|
||||||
|
readonly secrets: Record<string, string | number>;
|
||||||
|
/** Human-readable summary of what was changed (for logging/UI). */
|
||||||
|
readonly applied: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isHardenable(device: Device): device is Device & HardenableDevice {
|
||||||
|
return typeof (device as Partial<HardenableDevice>).harden === "function";
|
||||||
|
}
|
||||||
|
|
||||||
// --- Readers (RF / optical; TCP-IP or Wiegand) ---------------------------
|
// --- Readers (RF / optical; TCP-IP or Wiegand) ---------------------------
|
||||||
export interface ReaderDevice extends Device {
|
export interface ReaderDevice extends Device {
|
||||||
/** Emits when a credential is read (card number, plate, QR payload, …). */
|
/** Emits when a credential is read (card number, plate, QR payload, …). */
|
||||||
|
|||||||
@@ -35,9 +35,9 @@ export type DeviceConfig = Record<string, string | number | boolean>;
|
|||||||
* fields the admin must supply, and a factory that builds a live adapter.
|
* fields the admin must supply, and a factory that builds a live adapter.
|
||||||
*/
|
*/
|
||||||
export interface DeviceDriver<T extends Device = Device> {
|
export interface DeviceDriver<T extends Device = Device> {
|
||||||
readonly id: string; // stable, e.g. "zkteco", "esp32-relay", "hikvision"
|
readonly id: string; // stable, e.g. "dingtian", "hikvision"
|
||||||
readonly category: DeviceCategory;
|
readonly category: DeviceCategory;
|
||||||
readonly label: string; // human name for the picker, e.g. "ZKTeco controller"
|
readonly label: string; // human name for the picker, e.g. "Dingtian relay controller"
|
||||||
readonly description: string;
|
readonly description: string;
|
||||||
/** Transports/notes surfaced in the UI, e.g. ["tcp-ip"], ["wiegand"]. */
|
/** Transports/notes surfaced in the UI, e.g. ["tcp-ip"], ["wiegand"]. */
|
||||||
readonly transports: readonly string[];
|
readonly transports: readonly string[];
|
||||||
@@ -53,7 +53,7 @@ export type PrinterDriver = DeviceDriver<PrinterDevice>;
|
|||||||
|
|
||||||
/** A device found on the LAN by a driver's discovery scan. */
|
/** A device found on the LAN by a driver's discovery scan. */
|
||||||
export interface DiscoveredDevice {
|
export interface DiscoveredDevice {
|
||||||
/** Identifier to pre-fill (e.g. UHPPOTE serial number). */
|
/** Identifier to pre-fill (e.g. a serial number). */
|
||||||
readonly id: string;
|
readonly id: string;
|
||||||
readonly label: string;
|
readonly label: string;
|
||||||
/** Config values to auto-fill into the setup form (host, serial, …). */
|
/** Config values to auto-fill into the setup form (host, serial, …). */
|
||||||
@@ -63,9 +63,10 @@ export interface DiscoveredDevice {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Optional capability: a driver that can find devices on the LAN. UHPPOTE
|
* Optional capability: a driver that can find devices on the LAN (e.g. UDP
|
||||||
* implements this via the protocol's UDP broadcast discovery (get-devices);
|
* broadcast discovery). No bundled driver implements this yet — the Dingtian
|
||||||
* cameras (ONVIF) and others may add it later. See wiki/concepts/device-discovery.md.
|
* board uses a fixed IP; cameras (ONVIF) or other UDP-discoverable devices may
|
||||||
|
* add it later. See wiki/concepts/device-discovery.md.
|
||||||
*/
|
*/
|
||||||
export interface DiscoverableDriver {
|
export interface DiscoverableDriver {
|
||||||
discover(): Promise<DiscoveredDevice[]>;
|
discover(): Promise<DiscoveredDevice[]>;
|
||||||
|
|||||||
Generated
-19
@@ -50,9 +50,6 @@ importers:
|
|||||||
fastify-plugin:
|
fastify-plugin:
|
||||||
specifier: 6.0.0
|
specifier: 6.0.0
|
||||||
version: 6.0.0
|
version: 6.0.0
|
||||||
uhppoted:
|
|
||||||
specifier: 0.9.0
|
|
||||||
version: 0.9.0
|
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@types/bcrypt':
|
'@types/bcrypt':
|
||||||
specifier: 6.0.0
|
specifier: 6.0.0
|
||||||
@@ -122,9 +119,6 @@ importers:
|
|||||||
'@parking/shared':
|
'@parking/shared':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../shared
|
version: link:../shared
|
||||||
uhppoted:
|
|
||||||
specifier: 0.9.0
|
|
||||||
version: 0.9.0
|
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: 25.9.3
|
specifier: 25.9.3
|
||||||
@@ -1262,9 +1256,6 @@ packages:
|
|||||||
once@1.4.0:
|
once@1.4.0:
|
||||||
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
|
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
|
||||||
|
|
||||||
os@0.1.2:
|
|
||||||
resolution: {integrity: sha512-ZoXJkvAnljwvc56MbvhtKVWmSkzV712k42Is2mA0+0KTSRakq5XXuXpjZjgAt9ctzl51ojhQWakQQpmOvXWfjQ==}
|
|
||||||
|
|
||||||
path-scurry@2.0.2:
|
path-scurry@2.0.2:
|
||||||
resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==}
|
resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==}
|
||||||
engines: {node: 18 || 20 || >=22}
|
engines: {node: 18 || 20 || >=22}
|
||||||
@@ -1467,10 +1458,6 @@ packages:
|
|||||||
engines: {node: '>=14.17'}
|
engines: {node: '>=14.17'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
uhppoted@0.9.0:
|
|
||||||
resolution: {integrity: sha512-7VDPNg4x31TETgMD3xp9NwVr+NvmZJ6CO8gTpyuRrdHu/UBGXw9/9kq8yiB0vR4opaUQPdvR8Gj373Ac/QWPwQ==}
|
|
||||||
engines: {node: '>=14.18.3'}
|
|
||||||
|
|
||||||
undici-types@7.24.6:
|
undici-types@7.24.6:
|
||||||
resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==}
|
resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==}
|
||||||
|
|
||||||
@@ -2357,8 +2344,6 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
wrappy: 1.0.2
|
wrappy: 1.0.2
|
||||||
|
|
||||||
os@0.1.2: {}
|
|
||||||
|
|
||||||
path-scurry@2.0.2:
|
path-scurry@2.0.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
lru-cache: 11.5.1
|
lru-cache: 11.5.1
|
||||||
@@ -2586,10 +2571,6 @@ snapshots:
|
|||||||
|
|
||||||
typescript@6.0.3: {}
|
typescript@6.0.3: {}
|
||||||
|
|
||||||
uhppoted@0.9.0:
|
|
||||||
dependencies:
|
|
||||||
os: 0.1.2
|
|
||||||
|
|
||||||
undici-types@7.24.6: {}
|
undici-types@7.24.6: {}
|
||||||
|
|
||||||
util-deprecate@1.0.2: {}
|
util-deprecate@1.0.2: {}
|
||||||
|
|||||||
+2
-2
@@ -35,9 +35,9 @@ wiki/
|
|||||||
- **Frontmatter** (YAML) on every wiki page:
|
- **Frontmatter** (YAML) on every wiki page:
|
||||||
```yaml
|
```yaml
|
||||||
---
|
---
|
||||||
type: source | entity | concept | decision | overview
|
type: source | entity | concept | decision | overview | reference
|
||||||
tags: [parking, ...]
|
tags: [parking, ...]
|
||||||
sources: [parking-system-architecture] # raw source slugs this draws from
|
sources: [parking-system-architecture] # raw source slugs (omit/[] if not source-derived)
|
||||||
updated: 2026-06-14
|
updated: 2026-06-14
|
||||||
status: settled | open # decisions only
|
status: settled | open # decisions only
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -20,13 +20,39 @@ device carries an `id`, a `label`, a `config` blob to **auto-fill** the setup fo
|
|||||||
(firmware, MAC, …). The [[device-registry]]'s `isDiscoverable()` guard lets the system treat it
|
(firmware, MAC, …). The [[device-registry]]'s `isDiscoverable()` guard lets the system treat it
|
||||||
as optional; the setup catalog returns a `discoverable` list of driver ids.
|
as optional; the setup catalog returns a `discoverable` list of driver ids.
|
||||||
|
|
||||||
## UHPPOTE discovery
|
> **No current driver implements discovery.** The [[dingtian-relay]] board uses a fixed IP
|
||||||
|
> (entered/known at setup). The capability remains for future UDP-discoverable devices (cameras
|
||||||
|
> via ONVIF, etc.). The worked example below is the (removed) UHPPOTE driver — kept because the
|
||||||
|
> **broadcast gotchas are transferable** to any UDP discovery we add later.
|
||||||
|
|
||||||
The [[uhppote-controller]] supports discovery natively: a **UDP broadcast** (`get-devices` on
|
## UHPPOTE discovery (historical example)
|
||||||
`255.255.255.255:60000`) that **every controller on the LAN answers** with its serial, IP,
|
|
||||||
netmask, gateway, MAC, firmware version, and date. The official `uhppoted` lib exposes this as
|
The [[uhppote-controller]] supported discovery natively: a **UDP broadcast** (`get-devices` on
|
||||||
`getDevices(ctx)`; the `uhppote` driver maps each result into a `DiscoveredDevice` (serial → id,
|
port `60000`) that **every controller on the LAN answers** with its serial, IP, netmask, gateway,
|
||||||
IP → host).
|
MAC, firmware version, and date. The `uhppoted` lib exposed this as `getDevices(ctx)`; the
|
||||||
|
(now-removed) `uhppote` driver mapped each result into a `DiscoveredDevice` (serial → id, IP →
|
||||||
|
host). **Was verified on real hardware** (serial 225088491).
|
||||||
|
|
||||||
|
### Broadcast gotchas (learned the hard way — see [[wsl-dev-networking]])
|
||||||
|
|
||||||
|
These cost real debugging time; the (removed) `uhppote` driver handled all three, and any future
|
||||||
|
UDP-discovery driver will need to as well:
|
||||||
|
|
||||||
|
1. **Broadcast to the *subnet-directed* address, not the global `255.255.255.255`.** The
|
||||||
|
`uhppoted` lib only calls `setBroadcast(true)` when the target matches a **local interface's
|
||||||
|
subnet broadcast** (e.g. `10.0.10.255`). For the global address it skips it, so the `send`
|
||||||
|
fails with **`EACCES`**. The driver computes the subnet broadcast from `os.networkInterfaces()`.
|
||||||
|
2. **A host with multiple interfaces must broadcast on *all* subnets.** With several NICs (LAN,
|
||||||
|
VPN/Tailscale, docker bridges) the controller is on only one. Picking the first interface
|
||||||
|
misses it; the driver broadcasts on every subnet and dedupes by serial.
|
||||||
|
3. **For *unicast* ops (status / open), the lib's `Config` broadcast must match the target's
|
||||||
|
subnet** — it governs reply routing, so a mismatched broadcast makes `getStatus` time out even
|
||||||
|
though `openDoor` "succeeds". The driver sets the broadcast per the target host's subnet. (This
|
||||||
|
was the health-check "offline/timeout" bug: a 5 s timeout dropped to 24 ms once fixed.)
|
||||||
|
|
||||||
|
Also: concurrent `uhppoted` calls collide on the `:60001` reply-listener port (EACCES / dropped
|
||||||
|
replies), so the driver **serializes** all controller I/O. Override the broadcast with
|
||||||
|
`UHPPOTE_BROADCAST` for unusual setups.
|
||||||
|
|
||||||
## Flow
|
## Flow
|
||||||
|
|
||||||
@@ -38,9 +64,9 @@ IP → host).
|
|||||||
|
|
||||||
## Deployment notes
|
## Deployment notes
|
||||||
|
|
||||||
- UHPPOTE discovery is a **broadcast** — the host socket needs broadcast permission (a raw
|
- Discovery is an **L2 broadcast**: the host and controller must share a layer-2 segment. Works
|
||||||
`send EACCES …:60000` means the OS blocked it). Works on the isolated device VLAN
|
on the isolated device VLAN ([[network-isolation]]). A routed/NAT'd network (e.g. WSL2 NAT mode
|
||||||
([[network-isolation]]) where the controller and host share an L2 segment.
|
— see [[wsl-dev-networking]]) blocks it entirely.
|
||||||
- Discovery shares the same unauthenticated UDP exposure as everything else UHPPOTE — another
|
- Discovery shares the same unauthenticated UDP exposure as everything else UHPPOTE — another
|
||||||
reason the controllers live on an isolated VLAN ([[uhppote-udp-protocol]]).
|
reason the controllers live on an isolated VLAN ([[uhppote-udp-protocol]]).
|
||||||
- Cameras (Hikvision/Dahua via ONVIF/WS-Discovery) could implement the same interface later.
|
- Cameras (Hikvision/Dahua via ONVIF/WS-Discovery) could implement the same interface later.
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
---
|
||||||
|
type: concept
|
||||||
|
tags: [parking, architecture, devices, entry-flow]
|
||||||
|
sources: []
|
||||||
|
updated: 2026-06-15
|
||||||
|
---
|
||||||
|
|
||||||
|
# Device Input Flow (button → backend → relay)
|
||||||
|
|
||||||
|
How a physical button press drives the entry lane. The **backend is the source of truth**: the
|
||||||
|
device only *reports* the press; the host decides and commands the relay. This is the host-in-the-
|
||||||
|
loop flow the [[dingtian-relay]] makes possible (and the [[uhppote-controller]] could not).
|
||||||
|
|
||||||
|
## The path (no polling)
|
||||||
|
|
||||||
|
```
|
||||||
|
car arrives → driver presses button (input I_N, dry contact to GND)
|
||||||
|
→ device HTTP-pushes GET …/api/devices/dingtian/<deviceId>/input/<N>/on
|
||||||
|
→ backend: emit internal device event (device-events bus)
|
||||||
|
→ backend entry flow: create + sign an entry event, print the ticket
|
||||||
|
→ backend: pulseOpen(N) over UDP → barrier opens
|
||||||
|
→ (on release) device pushes …/input/<N>/off
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Push, not poll.** The device's `input_link_url` feature is configured (by the driver's
|
||||||
|
`configureInputPush()`) to call the backend on each input edge — see [[dingtian-relay]]. The
|
||||||
|
driver's poll path remains only as a dev/fallback aid.
|
||||||
|
- **Per-input path** carries the input number in the URL (`…/input/3/on`), so routing needs no
|
||||||
|
body parsing. Both edges (`on`/`off`) are sent.
|
||||||
|
- **Internal event bus** (`device-events.ts`, a Node `EventEmitter`) decouples the HTTP/transport
|
||||||
|
layer from business logic — drivers/pushes emit; the entry flow subscribes. Keeps the app
|
||||||
|
[[device-adapter-pattern|device-agnostic]].
|
||||||
|
|
||||||
|
## Trust model (important — flat network, no VLAN)
|
||||||
|
|
||||||
|
The site is a **flat network with no VLAN** ([[network-isolation]] is not yet enforceable here),
|
||||||
|
so we do **not** trust the device or the network. Both directions now have defence-in-depth, but
|
||||||
|
neither is the real boundary:
|
||||||
|
|
||||||
|
- **Relay control (host → device)** — UDP, now via the Dingtian **binary protocol on :60000 with a
|
||||||
|
`relay_pw`** (the only authenticated relay option; the string protocol has none). Set on the
|
||||||
|
device + stored in `lane_devices` by the harden step (below).
|
||||||
|
- **Input push (device → host)** — guarded by **HTTP Digest auth** + a **source-IP allowlist**.
|
||||||
|
- **The real guarantee is the signed log:** every barrier open is a host decision, recorded as a
|
||||||
|
signed event BEFORE the relay fires ([[append-only-event-chain]]). An out-of-band open (which a
|
||||||
|
flat network allows) has **no matching signed event → a detectable anomaly**. Device/network
|
||||||
|
auth is just speed bumps; both are plaintext over a sniffable network.
|
||||||
|
- This sharpens under the [[autonomous-direction|unmanned]] roadmap: with no operator, tamper
|
||||||
|
detection via the signed log matters more than perimeter auth.
|
||||||
|
|
||||||
|
## Device hardening (on assign)
|
||||||
|
|
||||||
|
The assign/Save step configures the device end-to-end (admin never touches the device web UI):
|
||||||
|
fix preconditions (disable `input_link_relay`) → **harden** → set up input push. The `harden`
|
||||||
|
capability ([[device-registry|HardenableDevice]]):
|
||||||
|
|
||||||
|
- **Sets a random `relay_pw`** (1–9999) so binary relay commands need it; stores it in
|
||||||
|
`lane_devices` so the backend can keep commanding the relay.
|
||||||
|
- **Disables unused protocol channels** (rs485, can, tcp×2, mqtt → `p:255`), keeping only UDP1
|
||||||
|
binary (relay control) + UDP2 string (status read) — fewer open doors.
|
||||||
|
|
||||||
|
> **⚠️ Lesson (the hard way):** do **NOT** enable the device's HTTP CGI session check
|
||||||
|
> (`session_en`). On this firmware (DT-R004) it makes the config-**read** API drop connections
|
||||||
|
> (`ECONNRESET`), locking the backend out of the very API it depends on — it required a **factory
|
||||||
|
> reset** to recover. The harden step deliberately leaves `session_en` off. The CGI config API
|
||||||
|
> being open is accepted as part of the flat-network reality (the signed log is the guarantee);
|
||||||
|
> the proper fix is network isolation, not this fragile device feature.
|
||||||
|
|
||||||
|
## Push authentication — Digest (decided by hardware testing)
|
||||||
|
|
||||||
|
The secret must not be in the URL (sniffable, logged) and the password must not cross the wire in
|
||||||
|
the clear. We **empirically tested the device** to pick the strongest achievable option:
|
||||||
|
|
||||||
|
| Option | Device result |
|
||||||
|
| --- | --- |
|
||||||
|
| HTTPS (self-signed) | ❌ device won't push to a self-signed cert |
|
||||||
|
| **Digest auth** (`auth=2`) | ✅ **works** — full 401-nonce challenge/response |
|
||||||
|
| Basic auth | ✅ works (but password base64 on the wire) |
|
||||||
|
| URL token | rejected by design (visible in URL/logs) |
|
||||||
|
|
||||||
|
→ **HTTP Digest** (MD5, qop=auth). The password is never sent (only a nonce-keyed hash); nonces
|
||||||
|
are **single-use** (replay resistance). Per-device credentials (`pushUser`/`pushPassword`) are
|
||||||
|
generated by the backend on **device assign**, written to the device's `input_link_url` config,
|
||||||
|
and stored in `lane_devices` — the admin never types a URL or secret. HTTPS would be stronger but
|
||||||
|
the device can't do it here; Digest + the signed log is the practical answer on a flat network.
|
||||||
|
See `apps/server/src/digest-auth.ts`.
|
||||||
|
|
||||||
|
## Dingtian config-write gotchas (cost a lot of debugging)
|
||||||
|
|
||||||
|
Writing the device's config API (`/api/v2/config_set.cgi`) has two non-obvious traps — both now
|
||||||
|
handled in the driver:
|
||||||
|
|
||||||
|
1. **Content-Length is mandatory.** The device's embedded HTTP server does **not** accept chunked
|
||||||
|
request bodies. Node uses chunked encoding when `Content-Length` is absent, so the device
|
||||||
|
silently ignores the body and returns `{"status":0}` anyway — the write looks successful but
|
||||||
|
nothing changes. Always set `Content-Length`.
|
||||||
|
2. **The `pass` field caps at 31 chars** (longer is silently truncated → Digest mismatch). The
|
||||||
|
generated push password is 24 hex chars (96 bits).
|
||||||
|
3. (Also: the device reboots on apply, so the driver writes then **polls until the change is
|
||||||
|
verified**, retrying — back-to-back writes onto a rebooting device are lost.)
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Input push **verified on hardware** with Digest auth (all 4 inputs, real presses authenticated, no
|
||||||
|
failures). The entry
|
||||||
|
flow itself (signed event + ticket print + `pulseOpen`) is the next build — see [[dingtian-relay]].
|
||||||
@@ -38,7 +38,8 @@ driver; **no business-logic change** — this is the [[device-adapter-pattern]]
|
|||||||
- Config is **validated against the driver's declared fields** before persisting.
|
- Config is **validated against the driver's declared fields** before persisting.
|
||||||
- Selections persist in the `lane_devices` table and drive runtime adapter construction.
|
- Selections persist in the `lane_devices` table and drive runtime adapter construction.
|
||||||
- Drivers may optionally implement **[[device-discovery]]** (`discover()`), so the admin can scan
|
- Drivers may optionally implement **[[device-discovery]]** (`discover()`), so the admin can scan
|
||||||
the LAN instead of typing connection details — UHPPOTE does this today.
|
the LAN instead of typing connection details — no current driver uses it (the UHPPOTE did,
|
||||||
|
before removal; the [[dingtian-relay]] uses a fixed IP).
|
||||||
|
|
||||||
Cameras are modelled as **snapshot-on-event**: the host requests an image at entry/exit; it's
|
Cameras are modelled as **snapshot-on-event**: the host requests an image at entry/exit; it's
|
||||||
stored and referenced from the signed event as an **independent record** — a fraud-control input
|
stored and referenced from the signed event as an **independent record** — a fraud-control input
|
||||||
|
|||||||
@@ -18,11 +18,16 @@ each device's connection config.
|
|||||||
1. **Read the catalog** — `GET /api/setup/catalog` returns supported drivers per category (no
|
1. **Read the catalog** — `GET /api/setup/catalog` returns supported drivers per category (no
|
||||||
secrets, just schema) plus a `discoverable` list. The web `SetupWizard` renders a picker + the
|
secrets, just schema) plus a `discoverable` list. The web `SetupWizard` renders a picker + the
|
||||||
driver's config fields, and a **Scan** button for discoverable drivers ([[device-discovery]]).
|
driver's config fields, and a **Scan** button for discoverable drivers ([[device-discovery]]).
|
||||||
2. **Assign per lane** — `POST /api/setup/assign` (admin-only, role-guarded; see
|
2. **Test** (optional, no save) — `POST /api/setup/test` (admin-only). Validates the config,
|
||||||
[[local-jwt-auth]]). The server validates the chosen driver + config against the registry
|
probes reachability (`healthCheck`), and reports preconditions (e.g. `input_link_relay`) —
|
||||||
before persisting to the `lane_devices` table; unknown drivers / missing required fields are
|
**without** saving or changing the device. The wizard's **Test connection** button shows a
|
||||||
rejected.
|
health badge + any precondition warnings.
|
||||||
3. **Complete** — `POST /api/setup/complete` marks the single-row `setup_state`.
|
3. **Save & configure** — `POST /api/setup/assign` (admin-only). Validates, then **configures the
|
||||||
|
device**: fixes preconditions (e.g. disables `input_link_relay`) and sets up the Digest-
|
||||||
|
authenticated input push ([[device-input-flow]]) — the admin never touches the device's own web
|
||||||
|
UI. **Fails the save** (no DB row) if the device can't be configured, so there are no
|
||||||
|
orphan/half-configured rows. On success persists to `lane_devices`.
|
||||||
|
4. **Complete** — `POST /api/setup/complete` marks the single-row `setup_state`.
|
||||||
|
|
||||||
## Config granularity
|
## Config granularity
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
---
|
||||||
|
type: reference
|
||||||
|
tags: [parking, dev-environment, workflow]
|
||||||
|
sources: []
|
||||||
|
updated: 2026-06-15
|
||||||
|
---
|
||||||
|
|
||||||
|
# Local Dev Workflow
|
||||||
|
|
||||||
|
> Dev-environment reference, not product architecture. How to run the stack locally and the
|
||||||
|
> gotchas that have bitten us. For device testing under WSL also read [[wsl-dev-networking]].
|
||||||
|
|
||||||
|
## First-time setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm install
|
||||||
|
cp apps/server/.env.example apps/server/.env # then fill in JWT_SECRET
|
||||||
|
# JWT_SECRET=$(openssl rand -hex 32) # server refuses to start without a strong one
|
||||||
|
pnpm --filter @parking/db exec drizzle-kit migrate # create the SQLite schema
|
||||||
|
pnpm seed:admin # create the first admin (see [[local-jwt-auth]])
|
||||||
|
```
|
||||||
|
|
||||||
|
`apps/server/.env` and the `*.sqlite` files are **gitignored** (local-only). Leave `NODE_ENV`
|
||||||
|
**unset** in dev so the auth cookies aren't `Secure`-only (Vite dev is plain http).
|
||||||
|
|
||||||
|
## Running
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm dev # turbo runs both: Vite (web, :5173) + Fastify (server, :3000)
|
||||||
|
```
|
||||||
|
|
||||||
|
Open `http://localhost:5173`. The Vite dev proxy forwards `/api` + `/health` to the backend, so
|
||||||
|
the SPA and API are **same-origin** and the [[local-jwt-auth|cookie auth]] works without CORS.
|
||||||
|
Production uses an **nginx** reverse proxy (`deploy/nginx.conf`) for the same same-origin setup.
|
||||||
|
|
||||||
|
## Gotchas (all fixed, recorded so they don't recur)
|
||||||
|
|
||||||
|
- **Server dev must not be `node --experimental-strip-types src/index.ts`.** Type-stripping does
|
||||||
|
**not** rewrite `.js` import specifiers to `.ts`, so it crashed with `ERR_MODULE_NOT_FOUND` and
|
||||||
|
silently never started — the symptom was the SPA hanging for *minutes* (the Vite proxy waiting
|
||||||
|
on a dead backend), then finally erroring. The `dev` script uses **`tsx watch`** instead.
|
||||||
|
- **Vite proxy → `127.0.0.1`, not `localhost`.** `localhost` resolves to IPv6 `::1` first while
|
||||||
|
the backend binds IPv4; Node's proxy can stall on the v6 attempt. Same class of "slow then
|
||||||
|
works" hang, worse under WSL2 mirrored mode ([[wsl-dev-networking]]).
|
||||||
|
- **`.env` must actually be loaded.** The server reads `process.env` only; the dev/start scripts
|
||||||
|
load the file via Node's `--env-file-if-exists=.env`. An empty `JWT_SECRET=` makes the server
|
||||||
|
fail-fast at boot.
|
||||||
|
- **Seed into the DB the server reads.** `seed:admin` and the server must use the same
|
||||||
|
`DATABASE_URL`; running via `pnpm seed:admin` (which loads `apps/server/.env`) keeps them aligned.
|
||||||
|
|
||||||
|
## Useful one-offs
|
||||||
|
|
||||||
|
- First admin: `pnpm seed:admin` (prompts; blank username → `admin`). Non-interactive:
|
||||||
|
`ADMIN_USER=.. ADMIN_PASS=.. pnpm seed:admin`. Reset a password: add `FORCE=1`.
|
||||||
|
- Hardware test scripts (UHPPOTE): `apps/server/scripts/uhppote-listen.mjs` (live events),
|
||||||
|
`uhppote-relay.mjs` (guarded door-open). See [[uhppote-controller]].
|
||||||
@@ -35,3 +35,9 @@ The controls that actually address insider/operator fraud are different in kind:
|
|||||||
The same reframing recurs at the device layer: the [[uhppote-controller]]'s real problem is
|
The same reframing recurs at the device layer: the [[uhppote-controller]]'s real problem is
|
||||||
unauthenticated commands ([[uhppote-udp-protocol]]), addressed by detection
|
unauthenticated commands ([[uhppote-udp-protocol]]), addressed by detection
|
||||||
([[event-log-ingestion]]) or prevention ([[esp32-custom-controller]]).
|
([[event-log-ingestion]]) or prevention ([[esp32-custom-controller]]).
|
||||||
|
|
||||||
|
> **Direction shift:** the system is heading toward **fully unmanned operation** — no operator, no
|
||||||
|
> booth ([[autonomous-direction]]). That removes the booth-operator as the *primary* adversary, but
|
||||||
|
> swaps in **unattended-machine threats** (tailgating, plate spoofing, physical tampering, forced
|
||||||
|
> entry). The append-only signed log + reconciliation controls carry over; the emphasis moves from
|
||||||
|
> "catch the cashier" to "trust the automated record and detect tampering."
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ updated: 2026-06-14
|
|||||||
|
|
||||||
# UHPPOTE vs. Custom ESP32 — Detection vs. Prevention
|
# UHPPOTE vs. Custom ESP32 — Detection vs. Prevention
|
||||||
|
|
||||||
|
> **Historical comparison.** Neither is the current device — the [[uhppote-controller]] was
|
||||||
|
> **rejected** (entry-flow blocker → [[dingtian-relay]] chosen) and the [[esp32-custom-controller]]
|
||||||
|
> is **deferred**. Kept because the **detection-vs-prevention** framing on the [[trust-boundary]]
|
||||||
|
> fork is a durable lens that applies to any access device.
|
||||||
|
|
||||||
A head-to-head on the [[trust-boundary]] fork: the off-the-shelf [[uhppote-controller]] versus
|
A head-to-head on the [[trust-boundary]] fork: the off-the-shelf [[uhppote-controller]] versus
|
||||||
the [[esp32-custom-controller]]. (Synthesized from [[parking-system-architecture]] §6–7.)
|
the [[esp32-custom-controller]]. (Synthesized from [[parking-system-architecture]] §6–7.)
|
||||||
|
|
||||||
@@ -23,9 +28,10 @@ the [[esp32-custom-controller]]. (Synthesized from [[parking-system-architecture
|
|||||||
|
|
||||||
## Bottom line
|
## Bottom line
|
||||||
|
|
||||||
- The UHPPOTE is the **current choice**: good enough as a detection/audit layer **when only the
|
- The UHPPOTE was the **detection-grade** option: good enough as a detection/audit layer **when
|
||||||
host can reach it** (isolation) and every event lands in the [[append-only-event-chain]].
|
only the host can reach it** (isolation) and every event lands in the [[append-only-event-chain]]
|
||||||
- The ESP32 is the **documented upgrade** when you need a control path that holds even against an
|
— but it was rejected for the entry lane (the button blocker).
|
||||||
attacker on the wire. They're **mixable per lane**.
|
- The ESP32 is the **prevention-grade** option when you need a control path that holds even against
|
||||||
|
an attacker on the wire. Deferred.
|
||||||
- Both still rely on host-side integrity ([[append-only-event-chain]]) and external
|
- Both still rely on host-side integrity ([[append-only-event-chain]]) and external
|
||||||
[[reconciliation]] as the ultimate anti-fraud control.
|
[[reconciliation]] as the ultimate anti-fraud control.
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
---
|
||||||
|
type: reference
|
||||||
|
tags: [parking, dev-environment, networking, wsl, troubleshooting]
|
||||||
|
sources: []
|
||||||
|
updated: 2026-06-15
|
||||||
|
---
|
||||||
|
|
||||||
|
# WSL2 Dev Networking (for device testing)
|
||||||
|
|
||||||
|
> Dev-environment note, not product architecture. Recorded because reaching real
|
||||||
|
> hardware (the [[uhppote-controller]]) from a dev box running under **WSL2** took
|
||||||
|
> significant debugging. If you test devices from WSL, read this first.
|
||||||
|
|
||||||
|
## The problem
|
||||||
|
|
||||||
|
By default WSL2 uses **NAT networking**: the Linux VM sits on its own virtual subnet
|
||||||
|
(e.g. `172.x`), not the Windows host's LAN. Consequences for device work:
|
||||||
|
|
||||||
|
- **UDP broadcast (UHPPOTE discovery) cannot leave the VM** — a `get-devices` broadcast gets
|
||||||
|
`EACCES` / never reaches a controller on the physical LAN. The device is reachable from
|
||||||
|
*Windows* but not from *inside WSL*.
|
||||||
|
- Even unicast to a LAN device may not route, depending on setup.
|
||||||
|
|
||||||
|
## The fix: mirrored networking
|
||||||
|
|
||||||
|
Switch WSL to **mirrored** mode so it shares the Windows host's interfaces (and thus the real
|
||||||
|
LAN). Requires **Windows 11 22H2+** and **WSL ≥ 2.0**.
|
||||||
|
|
||||||
|
`%UserProfile%\.wslconfig` (create it; it doesn't exist by default):
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[wsl2]
|
||||||
|
networkingMode=mirrored
|
||||||
|
firewall=false # Windows Firewall otherwise filters WSL traffic (can drop UDP replies)
|
||||||
|
|
||||||
|
[experimental]
|
||||||
|
hostAddressLoopback=true # host <-> WSL over the host's IP
|
||||||
|
```
|
||||||
|
|
||||||
|
Apply: in **PowerShell** `wsl --shutdown`, wait ~10 s, reopen WSL. Verify with `ip -4 addr` —
|
||||||
|
interfaces should now show the **real LAN subnet** (e.g. `10.0.10.x`) instead of `172.x`.
|
||||||
|
(Microsoft recommends editing via the **WSL Settings** GUI rather than the file by hand.)
|
||||||
|
|
||||||
|
> `wsl --shutdown` kills the dev servers — restart `pnpm dev` afterward.
|
||||||
|
|
||||||
|
## After mirrored mode: app-level gotchas that remained
|
||||||
|
|
||||||
|
Mirrored networking is necessary but **not sufficient** — these still bit us:
|
||||||
|
|
||||||
|
- **Multiple interfaces.** Mirrored WSL exposes *all* host NICs (LAN, Tailscale/CGNAT `100.x`,
|
||||||
|
docker bridges). UHPPOTE discovery must broadcast on **every** subnet, not the first one — see
|
||||||
|
[[device-discovery]].
|
||||||
|
- **Subnet-directed broadcast** (`10.0.10.255`, not `255.255.255.255`) — the lib won't enable
|
||||||
|
`SO_BROADCAST` otherwise. See [[device-discovery]].
|
||||||
|
- **`localhost` → IPv6 first.** `localhost` resolves to `::1`, but the backend binds IPv4
|
||||||
|
(`127.0.0.1`). Node's Vite proxy can stall on the v6 attempt before falling back — point the
|
||||||
|
proxy at `127.0.0.1` explicitly. (See [[local-dev-workflow]].)
|
||||||
|
|
||||||
|
## Alternative if you can't use mirrored mode
|
||||||
|
|
||||||
|
Windows 10 / old WSL can't do mirrored mode. Options: run the **backend natively on Windows**
|
||||||
|
(shares the LAN), or use **unicast by IP** instead of broadcast discovery (target the controller's
|
||||||
|
known IP — the driver supports an explicit host). On the real **appliance** (a dedicated hardened
|
||||||
|
Linux box, [[disk-os-hardening]]) none of this applies — it's bare-metal on the device VLAN
|
||||||
|
([[network-isolation]]).
|
||||||
@@ -1,17 +1,22 @@
|
|||||||
---
|
---
|
||||||
type: decision
|
type: decision
|
||||||
tags: [parking, hardware, access-control, blocker, open]
|
tags: [parking, hardware, access-control, resolved]
|
||||||
sources: [parking-system-architecture]
|
sources: [parking-system-architecture]
|
||||||
updated: 2026-06-15
|
updated: 2026-06-15
|
||||||
status: open
|
status: settled
|
||||||
---
|
---
|
||||||
|
|
||||||
# Blocker: Push-Button → Auto-Open Defeats the Ticket-First Entry Flow
|
# Push-Button → Auto-Open: the Ticket-First Entry Blocker (RESOLVED)
|
||||||
|
|
||||||
> **Procurement-blocking finding (2026-06-15), from on-hardware testing.** The UHPPOTE and
|
> **✅ RESOLVED (2026-06-15) by the [[dingtian-relay]] controller.** Its inputs are decoupled from
|
||||||
> ZKTeco access controllers **on hand** cannot, as wired/configured, deliver the required entry
|
> its relays (`input_link_relay` configurable off — done & verified on hardware), so a button on an
|
||||||
> flow. This blocks the entry lane and needs a hardware/wiring resolution before that lane ships.
|
> input reports to the host **without** firing a relay. Host-in-the-loop entry
|
||||||
> Work paused here to focus on the business side. See [[entry-exit-readers]], [[trust-boundary]].
|
> (`button → host → ticket → host opens relay`) now works. The original blocker (below) stands as
|
||||||
|
> the record of why the UHPPOTE/ZKTeco units couldn't do it.
|
||||||
|
>
|
||||||
|
> **Original procurement-blocking finding (2026-06-15), from on-hardware testing:** the UHPPOTE and
|
||||||
|
> ZKTeco controllers on hand could not, as wired/configured, deliver the required entry flow.
|
||||||
|
> See [[entry-exit-readers]], [[trust-boundary]].
|
||||||
|
|
||||||
## The required flow
|
## The required flow
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
---
|
||||||
|
type: decision
|
||||||
|
tags: [parking, direction, roadmap]
|
||||||
|
sources: []
|
||||||
|
updated: 2026-06-15
|
||||||
|
status: open
|
||||||
|
---
|
||||||
|
|
||||||
|
# Project Direction: Toward Fully Autonomous (Unmanned)
|
||||||
|
|
||||||
|
> Stated goal (2026-06-15): the system will evolve to **fully automatic operation — no human
|
||||||
|
> operator, no booth at all**. Recorded because "unmanned" is an architectural force that shapes
|
||||||
|
> several existing decisions, not just a feature.
|
||||||
|
|
||||||
|
## What "unmanned" changes
|
||||||
|
|
||||||
|
- **Threat model shift.** The original primary adversary was *"the legitimate operator at the
|
||||||
|
booth"* ([[threat-model]]). Remove the operator and that specific fraud vector (take cash → void
|
||||||
|
the record) largely disappears — but it's replaced by **unattended-machine threats**: tailgating,
|
||||||
|
plate spoofing/obscuring, physical tampering with a box nobody is watching, and forced entry.
|
||||||
|
The [[append-only-event-chain]] + [[reconciliation]] controls still apply; the emphasis moves
|
||||||
|
from "catch the cashier" to "trust the automated record + detect tampering."
|
||||||
|
- **Host-in-the-loop entry becomes mandatory, not optional.** With no person to hand over a ticket
|
||||||
|
or wave a car through, the machine must own the whole flow: detect arrival → issue ticket / read
|
||||||
|
plate → open. This is exactly why the [[access-controller-button-flow]] blocker matters and why
|
||||||
|
a controller whose input does **not** auto-fire the relay (see [[dingtian-relay]]) is required.
|
||||||
|
- **Reliability / fail-state get more critical** ([[fail-state-safety]]). No operator to recover a
|
||||||
|
stuck barrier or a trapped car ⇒ watchdogs, **exit-fails-open**, and hardware manual override
|
||||||
|
stop being nice-to-haves. Unattended uptime is a hard requirement.
|
||||||
|
- **Payment goes unmanned.** Pay-station / pay-on-foot or in-lane unmanned terminal rather than a
|
||||||
|
booth P2PE + cash drawer — sharpens [[open-questions]] #3 toward the unmanned option (PCI scope
|
||||||
|
still kept out of the app via a certified terminal).
|
||||||
|
- **Identity leans on automation.** Plate recognition ([[lpr-camera]]) and permit reads become the
|
||||||
|
primary identity sources, since there's no one to issue/inspect a paper ticket by hand.
|
||||||
|
|
||||||
|
## Near-term stance
|
||||||
|
|
||||||
|
Build for the unmanned target but don't over-engineer ahead of it. Current concrete step: the
|
||||||
|
**[[dingtian-relay]]** controller over **HTTP** (device pushes input events to the host; host
|
||||||
|
commands relays) — see [[dingtian-vs-mqtt]] for why HTTP over a message bus for now.
|
||||||
|
|
||||||
|
## Open
|
||||||
|
|
||||||
|
Lane topology, payment subsystem, and reconciliation channel ([[open-questions]]) should all be
|
||||||
|
(re)evaluated through the **unmanned** lens before procurement.
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
---
|
||||||
|
type: decision
|
||||||
|
tags: [parking, decision, devices, transport]
|
||||||
|
sources: []
|
||||||
|
updated: 2026-06-15
|
||||||
|
status: settled
|
||||||
|
---
|
||||||
|
|
||||||
|
# Transport for the Relay Controller: HTTP/UDP now, MQTT parked
|
||||||
|
|
||||||
|
**Decision (2026-06-15): use direct HTTP + UDP for the [[dingtian-relay]] controller now. MQTT is
|
||||||
|
deliberately skipped, but kept on the radar** for when the system scales.
|
||||||
|
|
||||||
|
## Options the device supports
|
||||||
|
|
||||||
|
The Dingtian relay board speaks several protocols: Dingtian string (UDP/TCP), Dingtian binary
|
||||||
|
(UDP, optional multicast/password), **HTTP CGI**, **HTTP input-link push** (`input_link_url`),
|
||||||
|
**Modbus** (RTU/TCP/ASCII), and **MQTT**.
|
||||||
|
|
||||||
|
## Why not MQTT (yet)
|
||||||
|
|
||||||
|
- **A broker is new infrastructure** on a deliberately **single-purpose hardened appliance**
|
||||||
|
([[disk-os-hardening]]) — another service to install, secure, supervise, and keep alive.
|
||||||
|
- **Extra failure mode on the critical path.** Today host→UDP→relay. MQTT inserts a broker on both
|
||||||
|
control and event paths; if it stalls, the lane stalls — and there are 3 processes to debug, not 2.
|
||||||
|
- **Doesn't fit [[offline-first]] for this scale.** MQTT earns its keep with *many* devices/consumers
|
||||||
|
and intermittent links. Here it's **one host + a few devices on one isolated LAN, metres apart** —
|
||||||
|
request/response control + a single input event, no fleet.
|
||||||
|
- **The device's MQTT input publish is periodic** ("default every 30 s"), so it's not even a clean
|
||||||
|
on-press event without relying on unverified on-change behaviour.
|
||||||
|
|
||||||
|
## Why HTTP/UDP fits
|
||||||
|
|
||||||
|
- **Relay control:** direct **UDP string protocol** (port 60001) — `11`=relay1 on, `21`=off,
|
||||||
|
`T1`=toggle, `11*`=jog/pulse. No deps, no broker.
|
||||||
|
- **Input/button events:** the device's **`input_link_url`** can **HTTP POST to the host backend
|
||||||
|
when an input fires** — real push, device calls our existing Fastify server directly, no broker.
|
||||||
|
(Polling `00` status over UDP every ~50 ms is the self-contained fallback.)
|
||||||
|
- Fewest moving parts; matches the local same-origin model already in use.
|
||||||
|
|
||||||
|
## When to revisit MQTT
|
||||||
|
|
||||||
|
If the system grows to **many lanes / many controllers**, or multiple subsystems (LPR, payment,
|
||||||
|
signage) all need to share events, a broker becomes a worthwhile central event bus. That aligns
|
||||||
|
with the [[autonomous-direction|unmanned]] roadmap at multi-lane scale — re-evaluate then. Until
|
||||||
|
then, direct HTTP/UDP wins on simplicity and reliability.
|
||||||
@@ -27,7 +27,7 @@ status: open
|
|||||||
later" currently leaves a disk failure as **total revenue-history loss**.
|
later" currently leaves a disk failure as **total revenue-history loss**.
|
||||||
6. **Secure-element integration.** Confirm [[atecc608]] wiring/usage on the host (event
|
6. **Secure-element integration.** Confirm [[atecc608]] wiring/usage on the host (event
|
||||||
signing). The [[esp32-custom-controller]] command-authentication use is **deferred — not
|
signing). The [[esp32-custom-controller]] command-authentication use is **deferred — not
|
||||||
being implemented for now** (access control stays on the [[uhppote-controller]] behind
|
being implemented for now** (access control is the [[dingtian-relay]] behind
|
||||||
[[network-isolation]]); revisit only if prevention-grade device auth becomes a requirement.
|
[[network-isolation]]); revisit only if prevention-grade device auth becomes a requirement.
|
||||||
7. **JWT signing: symmetric vs. asymmetric key.** _(Raised by the commit security review, not the
|
7. **JWT signing: symmetric vs. asymmetric key.** _(Raised by the commit security review, not the
|
||||||
source doc.)_ Auth currently uses a symmetric HMAC secret (`@fastify/jwt`, see
|
source doc.)_ Auth currently uses a symmetric HMAC secret (`@fastify/jwt`, see
|
||||||
|
|||||||
@@ -19,10 +19,12 @@ The decisions treated as settled in the design notes. (See [[parking-system-arch
|
|||||||
- **Integrity:** append-only, hash-chained, [[atecc608]]-signed event log
|
- **Integrity:** append-only, hash-chained, [[atecc608]]-signed event log
|
||||||
([[append-only-event-chain]]); **[[reconciliation]] is the anti-fraud control**; encryption
|
([[append-only-event-chain]]); **[[reconciliation]] is the anti-fraud control**; encryption
|
||||||
protects only at-rest (see [[threat-model]]).
|
protects only at-rest (see [[threat-model]]).
|
||||||
- **Access control:** [[uhppote-controller]] for now, on an **isolated VLAN**
|
- **Access control:** the **[[dingtian-relay]]** relay+input controller, on an **isolated VLAN**
|
||||||
([[network-isolation]]); event log used as a tamper-evident audit source with host-side index
|
([[network-isolation]]). Chosen because its **inputs are decoupled from its relays**, enabling
|
||||||
tracking ([[event-log-ingestion]]). The [[esp32-custom-controller]] is the documented
|
host-in-the-loop ticket-first entry — the resolution to [[access-controller-button-flow]].
|
||||||
prevention-grade upgrade path (the [[trust-boundary]] fork).
|
(The [[uhppote-controller]] and [[zkteco-controller]] were evaluated and **rejected** — kept as
|
||||||
|
historical record. The [[esp32-custom-controller]] remains the documented prevention-grade
|
||||||
|
alternative — the [[trust-boundary]] fork.)
|
||||||
- **Readers:** prefer [[wiegand]]-into-controller for permit holders (autonomous); host-in-the-loop
|
- **Readers:** prefer [[wiegand]]-into-controller for permit holders (autonomous); host-in-the-loop
|
||||||
for [[lpr-camera|LPR]]/QR/pure-network readers; both can share a relay (see
|
for [[lpr-camera|LPR]]/QR/pure-network readers; both can share a relay (see
|
||||||
[[entry-exit-readers]]).
|
[[entry-exit-readers]]).
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ payment terminal is dictated by the acquiring bank. (See [[parking-system-archit
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| Barrier operator | Magnetic Autocontrol / FAAC / CAME / Nice | Owns physical safety in firmware ([[barrier-not-a-door]]) |
|
| Barrier operator | Magnetic Autocontrol / FAAC / CAME / Nice | Owns physical safety in firmware ([[barrier-not-a-door]]) |
|
||||||
| Induction loops | Feig / BEA / EMX | Safety + free-exit detection |
|
| Induction loops | Feig / BEA / EMX | Safety + free-exit detection |
|
||||||
| Access controller | [[uhppote-controller]] now → ZKTeco later | Reader + relay; **isolate the VLAN** ([[network-isolation]]) |
|
| Access controller | [[dingtian-relay]] relay+input board | Decoupled inputs (host-in-the-loop); **isolate the VLAN** ([[network-isolation]]). ([[uhppote-controller]]/[[zkteco-controller]] rejected) |
|
||||||
| Permit readers | Nedap/Kathrein UHF, or Mifare → [[wiegand]] | Hands-free, or autonomous offline decisions |
|
| Permit readers | Nedap/Kathrein UHF, or Mifare → [[wiegand]] | Hands-free, or autonomous offline decisions |
|
||||||
| Casual identity | [[lpr-camera]] (Milesight, edge AI) | Plate = ticket + independent record |
|
| Casual identity | [[lpr-camera]] (Milesight, edge AI) | Plate = ticket + independent record |
|
||||||
| Ticket dispenser | Custom VKP80 | Parking-grade thermal/ESC-POS |
|
| Ticket dispenser | Custom VKP80 | Parking-grade thermal/ESC-POS |
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
---
|
||||||
|
type: entity
|
||||||
|
tags: [parking, hardware, access-control, relay]
|
||||||
|
sources: []
|
||||||
|
updated: 2026-06-14
|
||||||
|
---
|
||||||
|
|
||||||
|
# Dingtian Relay Controller
|
||||||
|
|
||||||
|
A network **relay + input** board (the unit on hand is the **4-channel** variant: 4 relays + 4
|
||||||
|
inputs). Chosen to drive the entry/exit lane because — unlike the [[uhppote-controller]] — its
|
||||||
|
**inputs are independent of its relays**, which solves the [[access-controller-button-flow]]
|
||||||
|
blocker (a button on an input does not auto-open a relay; the host decides).
|
||||||
|
|
||||||
|
SDK: `dingtian/4ch/sdk_v2_0_0/` (programming manual, examples). MIT-compatible use; no vendor
|
||||||
|
runtime needed.
|
||||||
|
|
||||||
|
## ⚠️ The one gotcha: `input_link_relay`
|
||||||
|
|
||||||
|
By **default the device links each input to auto-fire its matching relay** (`input_link_relay: 1`,
|
||||||
|
`on_action_on: [[0],[1],…]` in the config) — i.e. the *same* auto-open problem as the UHPPOTE.
|
||||||
|
The difference: **it is configurable.** Set `input_link_relay: 0` (or clear the action mappings)
|
||||||
|
so an input only *reports* and the host commands the relay. **This config step is mandatory** for
|
||||||
|
the ticket-first entry flow. See [[autonomous-direction]].
|
||||||
|
|
||||||
|
## Protocol (Dingtian string — what we use)
|
||||||
|
|
||||||
|
Transport options: UDP/TCP string, UDP binary, HTTP CGI, Modbus, MQTT. We use **HTTP + UDP** —
|
||||||
|
see [[dingtian-vs-mqtt]].
|
||||||
|
|
||||||
|
- **Relay control — UDP *binary*, port 60000 (authenticated):** the driver's `pulseOpen` sends a
|
||||||
|
binary "write relay with jogging" frame carrying the `relay_pw` (the only relay option with a
|
||||||
|
password). Frame (verified on hardware):
|
||||||
|
`FF AA <session> 03 <pwLo> <pwHi> <relayByte> <jogLo> <jogHi>` — relayByte bit0=on, bits1-7=
|
||||||
|
channel-1; jog is 100 ms units, LSB-first; password 16-bit LSB-first (0 = none). The relay jogs
|
||||||
|
ON then auto-releases, so we never time a close ([[barrier-not-a-door]]). *(The simpler string
|
||||||
|
protocol — `1`+ch on, `2`+ch off, `11*` jog — works too but has no auth; we use it only for the
|
||||||
|
read-only status query.)*
|
||||||
|
- **Status / inputs — send `00`** → `「relays」:「inputs」:「count」`, e.g. **`0000:1111:4`** (4ch:
|
||||||
|
relays off, inputs high). `0` = OFF/Low, `1` = ON/High. Poll-based.
|
||||||
|
- **Input push — `input_link_url`:** device **HTTP POSTs to a host URL on input change** — the
|
||||||
|
push path for button events without a broker.
|
||||||
|
- **Discovery:** UDP multicast `224.0.2.11:60000`, send `\x05\xAA` (devices reply). Defaults:
|
||||||
|
IP `192.168.1.100`, UDP `60000` (binary) / `60001` (string).
|
||||||
|
- Binary protocol (port 60000) adds optional **password** + multicast; bitmask relay/input maps.
|
||||||
|
|
||||||
|
## Driver & config API
|
||||||
|
|
||||||
|
The `dingtian` driver ([[device-registry]]) implements three capabilities:
|
||||||
|
`AccessControlDevice` (relay pulse/latch over UDP), `InputDevice` (read inputs + poll-based
|
||||||
|
press/release events ~50 ms), and `PreconditionDevice` (below). Config fields include a separate
|
||||||
|
**`httpPort`** — the device's web/config API is on a configurable HTTP port (default **80**),
|
||||||
|
distinct from the UDP control port 60001.
|
||||||
|
|
||||||
|
### Precondition: input_link_relay must be OFF
|
||||||
|
|
||||||
|
The driver reads the device's JSON config (`GET /api/v2/config.cgi`) and **checks
|
||||||
|
`input_link_relay`**; if enabled it reports a fixable issue, and `fixPreconditions()` writes the
|
||||||
|
correction (`POST /api/v2/config_set.cgi`) — setting the flag to 0 and clearing `on_action_on`,
|
||||||
|
preserving everything else (network, etc.). This is the generic [[device-registry|precondition]]
|
||||||
|
capability: the app doesn't own full device config (that's the vendor web UI), only the few
|
||||||
|
settings our flow depends on.
|
||||||
|
|
||||||
|
> **Write gotcha (cost real debugging):** the GET config payload **omits** a `"command"` field, but
|
||||||
|
> the set endpoint **requires `"command":"setconfig"`** injected right after `"status"`. Without it
|
||||||
|
> the POST returns/looks like success but silently does nothing (and the device may reset). With it,
|
||||||
|
> POST returns `{"status":0}` and the change sticks. JSON node order must be preserved.
|
||||||
|
|
||||||
|
## Input push (no polling) — the chosen architecture
|
||||||
|
|
||||||
|
The device **pushes** button events to the backend; the backend decides. **No polling.** The
|
||||||
|
driver's `configureInputPush()` writes the device's `input_link_url` config to point each input at
|
||||||
|
the backend: input N HTTP-GETs `…/api/devices/dingtian/<deviceId>/input/<N>/on` (and `/off`) on
|
||||||
|
press/release. The backend ([[fastify]] route `routes/devices.ts`) translates each push into an
|
||||||
|
internal device event ([[device-input-flow]]); the entry flow then prints a ticket and commands
|
||||||
|
the relay via UDP. See [[device-input-flow]] for the full path + trust model.
|
||||||
|
|
||||||
|
> The input-poll path in the driver (`onInput`) remains as a dev/fallback aid, but **push is the
|
||||||
|
> real path** — lower latency, and it can be authenticated (the device supports Basic/Digest +
|
||||||
|
> HTTPS on the push), unlike the open UDP control direction.
|
||||||
|
|
||||||
|
## Hardening (`harden()`) — and why HTTP auth is not a boundary here
|
||||||
|
|
||||||
|
On assign the driver runs `harden()` (the [[device-registry|HardenableDevice]] capability):
|
||||||
|
1. **`relay_pw`** — set a random relay password so binary relay commands (UDP 60000) need it.
|
||||||
|
2. **Disable unused channels** — set `p:255` on rs485/can/tcp×2/mqtt; keep only UDP1 binary
|
||||||
|
(relay control) + UDP2 string (status read).
|
||||||
|
3. **Rotate the `admin`/`admin` web login** — `GET /userset.cgi?<old_u>&<old_p>&<new_u>&<new_p>&`
|
||||||
|
(response `&0&…&` = success, verified on hardware). The new password is stored back in
|
||||||
|
config (`webUser`/`webPassword`) so a re-run can rotate again (the device checks the *old*
|
||||||
|
creds). This step is **best-effort** — a failure logs and does not fail the assign.
|
||||||
|
|
||||||
|
> ⚠️ **The device CGI API is UNAUTHENTICATED.** Verified on hardware: `GET /api/v2/config.cgi`,
|
||||||
|
> `/`, and even `/userset.cgi` all return **200 with no credentials**. The `admin`/`admin` login
|
||||||
|
> gates only the interactive **browser UI** — the CGI control plane (read/write full config, fire
|
||||||
|
> relays, change the password) bypasses it entirely. The `http` config block has **no** setting to
|
||||||
|
> require Basic/Digest on inbound requests; the only inbound gate is `session_en`, which **bricks
|
||||||
|
> the config-read API on this firmware** (the factory-reset incident — *do not enable it*). So
|
||||||
|
> **rotating the login is cosmetic** (stops a casual browser reaching settings); it is **not** a
|
||||||
|
> boundary. On this flat, no-VLAN network the device control plane is effectively open — the
|
||||||
|
> **signed event log is the real anti-fraud guarantee**. See [[device-input-flow]].
|
||||||
|
|
||||||
|
> ⚠️ **`session_en` must stay OFF.** Enabling the HTTP CGI session check makes the config-read API
|
||||||
|
> drop connections (ECONNRESET), locking out the API the driver depends on — recoverable only by
|
||||||
|
> factory reset. `harden()` deliberately never touches it.
|
||||||
|
|
||||||
|
## Status — VERIFIED on hardware (DT-R004, sw V3.1.5461A, 10.0.10.172)
|
||||||
|
|
||||||
|
- ✅ status read (`0000:1111:4`), relay pulse, input press/release events (active-LOW, idle HIGH).
|
||||||
|
- ✅ **`input_link_relay` disabled via the driver** → pressing an input reports the event and
|
||||||
|
**fires NO relay** (`0000` after presses). The [[access-controller-button-flow]] blocker is
|
||||||
|
**solved**.
|
||||||
|
- ✅ **Input HTTP-push end to end** — configured the device via `configureInputPush()`, then real
|
||||||
|
button presses (all 4 inputs) **pushed to the backend** (`/input/N/on` + `/off` per press,
|
||||||
|
source = the device IP). No polling. Host-in-the-loop entry (`button → backend → ticket →
|
||||||
|
backend opens relay`) is real.
|
||||||
|
- ✅ **Web-login rotation** — `userset.cgi` rotates `admin`/`admin` (response `&0&/&`; wrong old
|
||||||
|
password → `&2&/&`). Confirmed the device validates the old creds. **Also confirmed the CGI API
|
||||||
|
needs NO auth** (config dump + `userset.cgi` return 200 unauthenticated) → rotation is cosmetic.
|
||||||
|
- ⬜ Next: wire the actual entry flow (input event → signed event + print ticket → `pulseOpen`).
|
||||||
@@ -1,33 +1,37 @@
|
|||||||
---
|
---
|
||||||
type: entity
|
type: entity
|
||||||
tags: [parking, hardware, access-control, current-choice]
|
tags: [parking, hardware, access-control, rejected, historical]
|
||||||
sources: [parking-system-architecture]
|
sources: [parking-system-architecture]
|
||||||
updated: 2026-06-15
|
updated: 2026-06-15
|
||||||
---
|
---
|
||||||
|
|
||||||
# UHPPOTE Controller (current choice)
|
# UHPPOTE Controller (rejected — historical)
|
||||||
|
|
||||||
The starting access-control hardware: a **UHPPOTE Wiegand 26/34 network controller (4-door)** —
|
> **❌ NOT USED. Replaced by the [[dingtian-relay]] controller** (and its driver/test code
|
||||||
a cheap reader-plus-relay frontend, acceptable **provided you understand its limits**. The plan
|
> removed). Kept as the record of *why* — its firmware-fixed push-button blocker
|
||||||
is UHPPOTE now → ZKTeco later (see [[bom]]). (See [[parking-system-architecture]] §6.)
|
> ([[access-controller-button-flow]]) is what drove the switch to a board with decoupled inputs.
|
||||||
|
> The transferable lessons below (network isolation, append-only log ingestion, "a barrier is not
|
||||||
|
> a door") still apply to any access device.
|
||||||
|
|
||||||
> **⚠️ Entry-flow blocker (verified on hardware):** the push-button input **auto-opens the relay
|
The original starting hardware: a **UHPPOTE Wiegand 26/34 network controller (4-door)** — a cheap
|
||||||
> in firmware** — there's no command to make it report-without-opening — so it **cannot** do
|
reader-plus-relay frontend. (See [[parking-system-architecture]] §6.)
|
||||||
> ticket-first entry (`button → print → open`). Fine as a host-**commanded relay** and for
|
|
||||||
> [[wiegand]]/permit lanes, but **not** the button-driven entry lane as wired. Full detail and
|
> **⚠️ The fatal limit (verified on hardware):** the push-button input **auto-opens the relay in
|
||||||
> options: [[access-controller-button-flow]].
|
> firmware** — no command makes it report-without-opening — so it **cannot** do ticket-first entry
|
||||||
|
> (`button → print → open`). This is *the* reason it was dropped: full detail and the resolution in
|
||||||
|
> [[access-controller-button-flow]].
|
||||||
>
|
>
|
||||||
> **Verified working on the real unit** (serial 225088491, fw 09120): host-commanded `openDoor`
|
> **What was verified on the real unit** (serial 225088491, fw 09120) before retiring it:
|
||||||
> on doors 1 & 2 (physically actuated, `reason="remote open door"`); button presses captured live
|
> host-commanded `openDoor` on doors 1 & 2 (physically actuated, `reason="remote open door"`);
|
||||||
> (`reason="push button ok"`); [[device-discovery]] scan. Test scripts: `apps/server/scripts/`.
|
> button presses captured live (`reason="push button ok"`); UDP-broadcast [[device-discovery]].
|
||||||
|
> The driver, `uhppoted` dependency, and test scripts have since been removed from the codebase.
|
||||||
|
|
||||||
> **Implementation:** integrated via the official **`uhppoted`** npm package (MIT, by the
|
> **Past implementation (removed):** was integrated via the official **`uhppoted`** npm package
|
||||||
> `uhppoted` org — `github.com/uhppoted/uhppoted-lib-nodejs`), added to `@parking/devices` as the
|
> (MIT — `github.com/uhppoted/uhppoted-lib-nodejs`) as the `uhppote` access driver. It exposed
|
||||||
> `uhppote` access driver ([[device-registry]]). It exposes exactly the protocol commands this
|
> exactly the protocol commands the design needs: `openDoor`, `getStatus`, and the event-log set
|
||||||
> design needs: `openDoor`, `getStatus`, and the event-log set (`getEvent`, `getEventIndex`,
|
> (`getEvent`, `getEventIndex`, `setEventIndex`, `recordSpecialEvents`) plus `setListener`/`listen`
|
||||||
> `setEventIndex`, `recordSpecialEvents`) plus `setListener`/`listen` for auto-push — see
|
> for auto-push — see [[event-log-ingestion]]. Transport defaulted to **UDP** (broadcast `…:60000`),
|
||||||
> [[event-log-ingestion]]. Transport defaults to **UDP** (broadcast `…:60000`), with optional
|
> with optional per-call TCP on newer firmware. The driver also implemented **[[device-discovery]]**
|
||||||
> per-call TCP on newer firmware. The driver also implements **[[device-discovery]]**
|
|
||||||
> (`getDevices` broadcast) so the setup wizard can scan for controllers. Note: the lib pulls one
|
> (`getDevices` broadcast) so the setup wizard can scan for controllers. Note: the lib pulls one
|
||||||
> trivial extra dep (the npm `os` shim) and uses UDP broadcast, which needs socket broadcast
|
> trivial extra dep (the npm `os` shim) and uses UDP broadcast, which needs socket broadcast
|
||||||
> permission on the host.
|
> permission on the host.
|
||||||
|
|||||||
@@ -1,20 +1,24 @@
|
|||||||
---
|
---
|
||||||
type: entity
|
type: entity
|
||||||
tags: [parking, hardware, access-control]
|
tags: [parking, hardware, access-control, rejected, historical]
|
||||||
sources: [parking-system-architecture]
|
sources: [parking-system-architecture]
|
||||||
updated: 2026-06-15
|
updated: 2026-06-15
|
||||||
---
|
---
|
||||||
|
|
||||||
# ZKTeco Controller
|
# ZKTeco Controller (rejected — historical)
|
||||||
|
|
||||||
A network access controller (C3 / inBio families) — the documented "UHPPOTE now → ZKTeco later"
|
> **❌ NOT USED.** Was considered as the access controller; the **[[dingtian-relay]]** board was
|
||||||
upgrade in the [[bom]]. A `zkteco` driver **stub** exists in the [[device-registry]] but the
|
> chosen instead (decoupled inputs, already verified). The `zkteco` driver **stub** has been
|
||||||
**real protocol is not implemented** (see below).
|
> **removed** from the codebase. Kept for the record of the comparison below.
|
||||||
|
|
||||||
## Relevance to the entry-flow blocker
|
A network access controller (C3 / inBio families), originally the documented "UHPPOTE now →
|
||||||
|
ZKTeco later" upgrade in the [[bom]].
|
||||||
|
|
||||||
|
## Why it was a contender (vs. UHPPOTE)
|
||||||
|
|
||||||
ZKTeco is **better positioned** than the [[uhppote-controller]] for host-in-the-loop entry (the
|
ZKTeco is **better positioned** than the [[uhppote-controller]] for host-in-the-loop entry (the
|
||||||
[[access-controller-button-flow]] blocker), but this is **unverified on our hardware**:
|
[[access-controller-button-flow]] blocker) — but it was **never verified on our hardware**, and the
|
||||||
|
Dingtian solved the problem first with less effort:
|
||||||
|
|
||||||
- Its **auxiliary inputs** have **programmable linkage** (ZKBioSecurity software / PULL SDK) and
|
- Its **auxiliary inputs** have **programmable linkage** (ZKBioSecurity software / PULL SDK) and
|
||||||
are **not** hardwired to "open door" — so a button on an *aux* input can raise a host event
|
are **not** hardwired to "open door" — so a button on an *aux* input can raise a host event
|
||||||
|
|||||||
+12
-4
@@ -32,12 +32,13 @@ Counts: 1 source · 14 entities · 10 concepts · 2 decision records.
|
|||||||
- [[logto-zitadel-oidc]] — OIDC providers ruled out by offline-first.
|
- [[logto-zitadel-oidc]] — OIDC providers ruled out by offline-first.
|
||||||
|
|
||||||
## Entities — hardware & devices
|
## Entities — hardware & devices
|
||||||
- [[uhppote-controller]] — current access controller; cheap, tamper-evident, open-UDP, fixed firmware.
|
- [[uhppote-controller]] — ❌ rejected/historical; firmware auto-open blocker drove the switch to Dingtian.
|
||||||
- [[esp32-custom-controller]] — prevention-grade upgrade; device-level auth.
|
- [[esp32-custom-controller]] — prevention-grade upgrade; device-level auth.
|
||||||
- [[atecc608]] — secure element; non-extractable signing key (host events + controller auth).
|
- [[atecc608]] — secure element; non-extractable signing key (host events + controller auth).
|
||||||
- [[wiegand]] — reader standard feeding the controller directly (autonomous permit-holder path).
|
- [[wiegand]] — reader standard feeding the controller directly (autonomous permit-holder path).
|
||||||
- [[lpr-camera]] — edge-AI plate recognition; host-side casual-identity source.
|
- [[lpr-camera]] — edge-AI plate recognition; host-side casual-identity source.
|
||||||
- [[zkteco-controller]] — C3/inBio controller; aux-input path may enable host-in-the-loop (driver TBD).
|
- [[zkteco-controller]] — ❌ rejected/historical; aux-input path was a contender, not pursued.
|
||||||
|
- [[dingtian-relay]] — ✅ CHOSEN access controller; decoupled inputs solve the button blocker (driver verified on hardware).
|
||||||
- [[bom]] — reference bill of materials (barrier, loops, controller, readers, payment, host, network).
|
- [[bom]] — reference bill of materials (barrier, loops, controller, readers, payment, host, network).
|
||||||
|
|
||||||
## Concepts — foundational forces
|
## Concepts — foundational forces
|
||||||
@@ -53,7 +54,8 @@ Counts: 1 source · 14 entities · 10 concepts · 2 decision records.
|
|||||||
- [[device-adapter-pattern]] — business logic talks to interfaces; swap hardware → new adapter.
|
- [[device-adapter-pattern]] — business logic talks to interfaces; swap hardware → new adapter.
|
||||||
- [[device-registry]] — catalog of selectable drivers per category (admin-configurable).
|
- [[device-registry]] — catalog of selectable drivers per category (admin-configurable).
|
||||||
- [[first-run-setup]] — admin assigns devices per lane from the catalog at install.
|
- [[first-run-setup]] — admin assigns devices per lane from the catalog at install.
|
||||||
- [[device-discovery]] — optional driver capability to scan the LAN (UHPPOTE UDP broadcast).
|
- [[device-input-flow]] — button → device push → backend decides → relay; backend is source of truth.
|
||||||
|
- [[device-discovery]] — optional driver capability to scan the LAN (no current driver uses it; UHPPOTE was the example).
|
||||||
- [[barrier-not-a-door]] — never timed-close a barrier; safety lives in barrier firmware.
|
- [[barrier-not-a-door]] — never timed-close a barrier; safety lives in barrier firmware.
|
||||||
- [[trust-boundary]] — the core fork: network vs. device; auditable vs. unforgeable.
|
- [[trust-boundary]] — the core fork: network vs. device; auditable vs. unforgeable.
|
||||||
- [[fail-state-safety]] — entry fails closed, exit fails open; manual override; watchdog.
|
- [[fail-state-safety]] — entry fails closed, exit fails open; manual override; watchdog.
|
||||||
@@ -66,7 +68,13 @@ Counts: 1 source · 14 entities · 10 concepts · 2 decision records.
|
|||||||
- [[entry-exit-readers]] — two populations, two integration paths; both can share a relay.
|
- [[entry-exit-readers]] — two populations, two integration paths; both can share a relay.
|
||||||
- [[uhppote-vs-esp32]] — comparison: detection vs. prevention.
|
- [[uhppote-vs-esp32]] — comparison: detection vs. prevention.
|
||||||
|
|
||||||
|
## Dev environment (reference)
|
||||||
|
- [[local-dev-workflow]] — running the stack locally; setup, the dev-hang gotchas, seed:admin.
|
||||||
|
- [[wsl-dev-networking]] — WSL2 NAT blocks device broadcast; use mirrored mode + the gotchas after.
|
||||||
|
|
||||||
## Decisions
|
## Decisions
|
||||||
- [[standing-decisions]] — settled decisions (stack, platform, integrity, access control, readers).
|
- [[standing-decisions]] — settled decisions (stack, platform, integrity, access control, readers).
|
||||||
- [[open-questions]] — 7 open items (6 procurement + JWT key choice); ESP32 device auth deferred.
|
- [[open-questions]] — 7 open items (6 procurement + JWT key choice); ESP32 device auth deferred.
|
||||||
- [[access-controller-button-flow]] — ⚠️ BLOCKER: UHPPOTE/ZKTeco on hand can't do ticket-first entry as wired.
|
- [[access-controller-button-flow]] — ✅ RESOLVED: Dingtian decoupled inputs enable ticket-first entry (was a UHPPOTE/ZKTeco blocker).
|
||||||
|
- [[autonomous-direction]] — roadmap: toward fully unmanned (no booth); reshapes threat model + fail-state.
|
||||||
|
- [[dingtian-vs-mqtt]] — transport choice: direct HTTP/UDP now, MQTT parked until multi-lane scale.
|
||||||
|
|||||||
+122
@@ -83,3 +83,125 @@ admins. Same-origin via the Vite dev proxy and a new prod nginx config
|
|||||||
(deploy/nginx.conf). Verified end to end (curl + browser): wrong pass→401,
|
(deploy/nginx.conf). Verified end to end (curl + browser): wrong pass→401,
|
||||||
login→cookies set, me→admin, assign without CSRF→403 / with→201, no cookie→401,
|
login→cookies set, me→admin, assign without CSRF→403 / with→201, no cookie→401,
|
||||||
session persists across reload. Updated [[local-jwt-auth]].
|
session persists across reload. Updated [[local-jwt-auth]].
|
||||||
|
|
||||||
|
## [2026-06-15] lint+docs | Dev-environment pages (WSL networking, workflow)
|
||||||
|
Captured hard-won dev knowledge that was only in commit messages: new
|
||||||
|
[[wsl-dev-networking]] (WSL2 NAT blocks UDP broadcast → mirrored mode + the
|
||||||
|
multi-interface / subnet-broadcast / IPv6-localhost gotchas that remained) and
|
||||||
|
[[local-dev-workflow]] (setup, seed:admin, the dev-server-hang from the broken
|
||||||
|
strip-types script → tsx, the 127.0.0.1 proxy fix, .env loading). Corrected the
|
||||||
|
earlier "broadcast permission (EACCES)" note in [[device-discovery]] — the real
|
||||||
|
cause was the lib not enabling SO_BROADCAST for the global 255.255.255.255;
|
||||||
|
documented the three verified broadcast gotchas + serialization. Added a `reference`
|
||||||
|
page type to the schema; new "Dev environment" index section.
|
||||||
|
|
||||||
|
## [2026-06-15] decision | Dingtian relay chosen; HTTP over MQTT; unmanned direction
|
||||||
|
New relay+input controller on hand (Dingtian 4ch). Its inputs are decoupled from
|
||||||
|
relays (configurable via input_link_relay) — solves the [[access-controller-button-flow]]
|
||||||
|
blocker the UHPPOTE couldn't. Transport decision [[dingtian-vs-mqtt]]: direct
|
||||||
|
HTTP/UDP now (UDP string for relay control on :60001; device input_link_url HTTP
|
||||||
|
push for button events), MQTT skipped (broker = infra + failure mode + overkill at
|
||||||
|
this scale) but kept for later multi-lane scale. Recorded the stated roadmap to
|
||||||
|
**fully unmanned, no-booth** operation in [[autonomous-direction]] and its threat-model
|
||||||
|
shift (operator-fraud → unattended-machine threats). New stub [[dingtian-relay]]
|
||||||
|
with the full protocol from the SDK. Driver + on-hardware test still to build.
|
||||||
|
|
||||||
|
## [2026-06-15] driver+test | Dingtian driver built; button blocker RESOLVED
|
||||||
|
Built the `dingtian` access driver (AccessControlDevice relay control + InputDevice
|
||||||
|
poll-based button events + new PreconditionDevice capability). Verified end to end on
|
||||||
|
real hardware (DT-R004 @ 10.0.10.172, HTTP config on :8080, UDP control :60001):
|
||||||
|
status read, relay pulse, input press/release. Disabled `input_link_relay` via the
|
||||||
|
driver's fixPreconditions (GET config → flag 0 + clear maps → POST config_set), then
|
||||||
|
confirmed: pressing inputs now fires NO relay (0000 status) — host-in-the-loop entry
|
||||||
|
works. The [[access-controller-button-flow]] blocker is RESOLVED. Gotcha recorded in
|
||||||
|
[[dingtian-relay]]: config_set requires injecting "command":"setconfig" after "status"
|
||||||
|
(GET omits it) or the write silently no-ops. Added httpPort config field (port 8080 ≠
|
||||||
|
default 80). Test script apps/server/scripts/dingtian-test.mjs. Next: input HTTP-push
|
||||||
|
endpoint + wiring input→ticket→pulseOpen.
|
||||||
|
|
||||||
|
## [2026-06-15] cleanup | Remove UHPPOTE/ZKTeco code; wiki → rejected/historical
|
||||||
|
Neither UHPPOTE nor ZKTeco is used (Dingtian chosen). Removed their code:
|
||||||
|
deleted access-uhppote.ts, uhppoted.d.ts, access.ts (zkteco/esp32 stubs), the
|
||||||
|
three uhppote-*.mjs scripts; dropped the `uhppoted` npm dep from both packages;
|
||||||
|
unregistered uhppote/zkteco/esp32-relay from the driver registry; updated example
|
||||||
|
comments. Catalog access drivers now = dingtian only. Build green.
|
||||||
|
Wiki: kept the pages but marked [[uhppote-controller]] + [[zkteco-controller]]
|
||||||
|
rejected/historical, [[uhppote-vs-esp32]] historical; re-pointed all "current
|
||||||
|
device" framing (standing-decisions, bom, overview, open-questions) to
|
||||||
|
[[dingtian-relay]]; noted no current driver uses [[device-discovery]]. Transferable
|
||||||
|
concepts (network-isolation, event-log-ingestion, barrier-not-a-door, threat-model)
|
||||||
|
kept as-is. Links lint clean; raw source untouched (immutable).
|
||||||
|
|
||||||
|
## [2026-06-15] feature | Dingtian input HTTP-push → backend (no polling)
|
||||||
|
Wired the device's "Input Link URL" feature so it HTTP-pushes button events to
|
||||||
|
our backend — no polling. Driver `configureInputPush()` writes input_link_url
|
||||||
|
(per-input server/port/path, en=1, active-LOW, plain HTTP) via the config API
|
||||||
|
(reusing the #writeConfig + command:setconfig helper). New backend route
|
||||||
|
`routes/devices.ts`: public `GET/POST /api/devices/dingtian/:deviceId/input/:n/{on,off}`
|
||||||
|
→ emits onto an internal device-events bus (device-events.ts, EventEmitter) for
|
||||||
|
the entry flow to consume. VERIFIED on hardware: configured device, real presses
|
||||||
|
on all 4 inputs pushed to the backend (input N on+off, source = device IP). Trust
|
||||||
|
model recorded in [[device-input-flow]]: flat network / no VLAN → backend is source
|
||||||
|
of truth, every open is a signed event (out-of-band open = anomaly); push endpoint
|
||||||
|
not behind cookie auth (machine call), shared-secret available as defence-in-depth.
|
||||||
|
Next: wire signed event + ticket print + pulseOpen.
|
||||||
|
|
||||||
|
## [2026-06-15] feature | Dingtian push auth via HTTP Digest (hardware-tested)
|
||||||
|
Secured the device→backend input push. Empirically tested auth options on the
|
||||||
|
device: HTTPS-to-self-signed FAILS, Basic works, **Digest works** → chose Digest
|
||||||
|
(MD5, qop=auth): password never on the wire, single-use nonces. Backend
|
||||||
|
digest-auth.ts (challenge/verify) + source-IP allowlist on the push route;
|
||||||
|
per-device pushUser/pushPassword generated on assign, written to the device and
|
||||||
|
stored in lane_devices (admin never types a URL/secret). Driver
|
||||||
|
configureInputPush now sets auth=2 + creds; the assign flow auto-configures the
|
||||||
|
device and persists the creds (net.ts derives the backend IP on the device's
|
||||||
|
subnet). Removed the earlier URL-token approach (token in URL is sniffable/logged).
|
||||||
|
TWO HARD-WON DEVICE BUGS fixed: (1) config_set requires an explicit Content-Length
|
||||||
|
— the device silently ignores chunked bodies (Node's default), which masqueraded
|
||||||
|
as "writes don't apply" all session; (2) the `pass` field caps at 31 chars →
|
||||||
|
use a 24-char password. Driver #writeConfig now polls-until-verified (device
|
||||||
|
reboots on apply). VERIFIED on hardware: assign auto-configures the device, then
|
||||||
|
all 4 inputs push with Digest auth, zero failures. Recorded in [[device-input-flow]].
|
||||||
|
|
||||||
|
## [2026-06-15] feature | Setup wizard: Test connection + Save & configure
|
||||||
|
Two-step device setup UX. New admin-only POST /api/setup/test (healthCheck +
|
||||||
|
checkPreconditions, no save / no device change). The assign (Save) step now also
|
||||||
|
fixes preconditions (disables input_link_relay) before configuring push — closing
|
||||||
|
a gap where assigned devices could still auto-fire relays; fails the save with no
|
||||||
|
DB row if device config fails (no orphan rows). SetupWizard wires the config
|
||||||
|
fields → Test button (health badge + precondition warnings) → Save & configure
|
||||||
|
button. Verified in-browser against the real device: Test shows ● ready +
|
||||||
|
preconditions OK; Save persists the row AND writes the device's Input Link URL
|
||||||
|
(push path matches the saved device id). Admin never logs into the device web UI.
|
||||||
|
Updated [[first-run-setup]].
|
||||||
|
|
||||||
|
## [2026-06-15] feature | Device hardening: binary relay + relay_pw + disable channels
|
||||||
|
Hardened the Dingtian relay control for the flat (no-VLAN) network. Switched
|
||||||
|
pulseOpen from the unauthenticated string protocol (:60001) to the **binary
|
||||||
|
protocol (:60000) with a relay password** — the only authenticated relay option
|
||||||
|
(frame verified on hardware: FF AA <sess> 03 <pwLE> <relayByte> <jogLE>). New
|
||||||
|
HardenableDevice capability: harden() sets a random relay_pw + disables unused
|
||||||
|
channels (rs485/can/tcp×2/mqtt → p:255, keep UDP binary+string). Folded into the
|
||||||
|
assign/Save flow (preconditions → harden → push); relayPassword stored in
|
||||||
|
lane_devices. Verified end to end: assign configures + hardens the device, config
|
||||||
|
API stays reachable, pulseOpen with the stored password fires the relay, without
|
||||||
|
it is rejected.
|
||||||
|
|
||||||
|
⚠️ LESSON: enabling the device's HTTP CGI session check (session_en) on this
|
||||||
|
firmware breaks the config-READ API (ECONNRESET) — locked us out, needed a FACTORY
|
||||||
|
RESET to recover. harden() deliberately does NOT touch session_en. The open CGI
|
||||||
|
API is accepted as flat-network reality; the signed log is the real guarantee.
|
||||||
|
Recorded in [[device-input-flow]] + [[dingtian-relay]].
|
||||||
|
|
||||||
|
## [2026-06-14] query | Dingtian web-login rotation + CGI API is unauthenticated
|
||||||
|
While addressing "change the device's default admin/admin", traced the device web
|
||||||
|
UI JS (system.js) → the change-login endpoint is
|
||||||
|
`GET /userset.cgi?<old_u>&<old_p>&<new_u>&<new_p>&` (response `&0&/&` = success,
|
||||||
|
`&2&/&` = wrong old pw). Added a best-effort `setWebLogin`/`#rotateWebLogin` step
|
||||||
|
to `harden()` (new pw stored back as config `webPassword`, stripped from API
|
||||||
|
responses). KEY FINDING: the device CGI API needs NO authentication — config dump,
|
||||||
|
config write, relay fire, and userset.cgi itself all return 200 unauthenticated
|
||||||
|
(verified on 10.0.10.5). admin/admin gates only the browser UI; there is no
|
||||||
|
inbound-auth setting (only session_en, which bricks the read API). So rotating the
|
||||||
|
login is COSMETIC, not a boundary — the signed event log remains the real
|
||||||
|
guarantee. Recorded in [[dingtian-relay]] (new Hardening section).
|
||||||
|
|||||||
+8
-7
@@ -29,11 +29,12 @@ deployed on-site at a parking facility. Two forces shape nearly every decision:
|
|||||||
rest ([[disk-os-hardening]]) defends a secondary threat.
|
rest ([[disk-os-hardening]]) defends a secondary threat.
|
||||||
- **Devices** sit behind a [[device-adapter-pattern]] (swap hardware → new adapter only), with
|
- **Devices** sit behind a [[device-adapter-pattern]] (swap hardware → new adapter only), with
|
||||||
the [[barrier-not-a-door]] safety principle keeping physical safety in barrier-operator firmware.
|
the [[barrier-not-a-door]] safety principle keeping physical safety in barrier-operator firmware.
|
||||||
- **Access control** hinges on the [[trust-boundary]] fork:
|
- **Access control** today is the **[[dingtian-relay]]** relay+input controller behind
|
||||||
[[uhppote-vs-esp32|detection vs. prevention]]. Today: [[uhppote-controller]] behind
|
[[network-isolation]] — chosen because its inputs are **decoupled from its relays**, enabling
|
||||||
[[network-isolation]], its open [[uhppote-udp-protocol]] contained, its log made trustworthy by
|
host-in-the-loop ticket-first entry (resolving [[access-controller-button-flow]]). The
|
||||||
[[event-log-ingestion]]. Upgrade path: the [[esp32-custom-controller]] with
|
[[uhppote-controller]] and [[zkteco-controller]] were evaluated and **rejected** (historical).
|
||||||
[[challenge-response-auth]] and [[fail-state-safety]].
|
The deeper fork is still the [[trust-boundary]] ([[uhppote-vs-esp32|detection vs. prevention]]);
|
||||||
|
the [[esp32-custom-controller]] remains the prevention-grade alternative.
|
||||||
- **Readers** split two ways ([[entry-exit-readers]]): permit holders via [[wiegand]]
|
- **Readers** split two ways ([[entry-exit-readers]]): permit holders via [[wiegand]]
|
||||||
(autonomous), casual/transient via host-side [[lpr-camera]] / QR; both can share a relay.
|
(autonomous), casual/transient via host-side [[lpr-camera]] / QR; both can share a relay.
|
||||||
- A reference [[bom]] lists recommended devices.
|
- A reference [[bom]] lists recommended devices.
|
||||||
@@ -47,6 +48,6 @@ modes (fail-open on exit)**, the **reconciliation channel**, and **backup/durabi
|
|||||||
|
|
||||||
- *Security-first:* [[threat-model]] → [[append-only-event-chain]] → [[reconciliation]] →
|
- *Security-first:* [[threat-model]] → [[append-only-event-chain]] → [[reconciliation]] →
|
||||||
[[uhppote-vs-esp32]].
|
[[uhppote-vs-esp32]].
|
||||||
- *Hardware-first:* [[bom]] → [[uhppote-controller]] → [[entry-exit-readers]] →
|
- *Hardware-first:* [[bom]] → [[dingtian-relay]] → [[access-controller-button-flow]] →
|
||||||
[[esp32-custom-controller]].
|
[[entry-exit-readers]].
|
||||||
- *Stack-first:* [[technology-stack]] → [[offline-first]] → [[device-adapter-pattern]].
|
- *Stack-first:* [[technology-stack]] → [[offline-first]] → [[device-adapter-pattern]].
|
||||||
|
|||||||
Reference in New Issue
Block a user