From 77606da2c9102ea882af104aae64afed5b119228 Mon Sep 17 00:00:00 2001 From: Julian Cuni Date: Sun, 14 Jun 2026 10:29:43 +0200 Subject: [PATCH] UHPPOTE hardware bring-up + entry-flow blocker Brought up the real UHPPOTE controller (serial 225088491, fw 09120) end to end and recorded a procurement-level blocker. Verified on hardware: - discovery (LAN scan), host-commanded openDoor on doors 1 & 2 (physically actuated; reason="remote open door"), and live button capture (reason="push button ok"). Driver/networking fixes (packages/devices/src/drivers/access-uhppote.ts): - broadcast to subnet-directed address (lib doesn't enable SO_BROADCAST for the global 255.255.255.255 -> EACCES); - Config broadcast must match the target's subnet for unicast reply routing (fixes the health-check timeout: 5s -> 24ms ready); - discover across all local subnets, dedupe by serial; - serialize all controller I/O (concurrent calls collided on UDP :60001). Server/UX: - load .env via node --env-file-if-exists (vars weren't being read before); - SETUP_AUTH_BYPASS hardened: env-gated, dev + loopback only, fails closed otherwise; surfaced as catalog.authBypass so the wizard drops the token field; - .env.example documents all vars; inline favicon stops a 404. - apps/server/scripts/: uhppote-listen (live events, restores prior listener) and uhppote-relay (guarded door-open test). BLOCKER (wiki/decisions/access-controller-button-flow.md): the controller's push-button input auto-opens the relay in firmware with no report-without-open mode, so ticket-first entry (button -> print -> open, fail-closed) is impossible as wired. UHPPOTE can't do it on that input; ZKTeco *might* via a programmable aux input + PULL SDK but that's unverified and needs a new driver. Entry-lane hardware decision paused to focus on the business side. wiki: access-controller-button-flow (blocker), zkteco-controller (stub + assessment), uhppote-controller callout, index + log. --- .gitignore | 3 + apps/server/.env.example | 22 ++- apps/server/package.json | 13 +- apps/server/scripts/uhppote-common.mjs | 72 ++++++++ apps/server/scripts/uhppote-listen.mjs | 100 +++++++++++ apps/server/scripts/uhppote-relay.mjs | 44 +++++ apps/server/src/routes/setup.ts | 59 ++++++- apps/web/index.html | 1 + apps/web/src/SetupWizard.tsx | 35 ++-- apps/web/src/api.ts | 2 + packages/devices/package.json | 1 + .../devices/src/drivers/access-uhppote.ts | 162 +++++++++++++++--- packages/devices/tsconfig.json | 3 +- pnpm-lock.yaml | 6 + .../access-controller-button-flow.md | 86 ++++++++++ wiki/entities/uhppote-controller.md | 10 ++ wiki/entities/zkteco-controller.md | 41 +++++ wiki/index.md | 2 + wiki/log.md | 20 +++ 19 files changed, 632 insertions(+), 50 deletions(-) create mode 100644 apps/server/scripts/uhppote-common.mjs create mode 100644 apps/server/scripts/uhppote-listen.mjs create mode 100644 apps/server/scripts/uhppote-relay.mjs create mode 100644 wiki/decisions/access-controller-button-flow.md create mode 100644 wiki/entities/zkteco-controller.md diff --git a/.gitignore b/.gitignore index 5903dfc..ea412d7 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,6 @@ dist/ # Editor/OS .DS_Store *:Zone.Identifier +.playwright-mcp/ +# stray hardware/UI test screenshots +/*.png \ No newline at end of file diff --git a/apps/server/.env.example b/apps/server/.env.example index 6309338..1cc4427 100644 --- a/apps/server/.env.example +++ b/apps/server/.env.example @@ -1,11 +1,23 @@ -# Copy to .env and fill in. The server refuses to start without a strong JWT_SECRET. +# Copy this file to `.env` (same folder: apps/server/.env) and fill it in. +# The dev/start scripts load it automatically via Node's --env-file-if-exists. # -# Generate a strong secret: -# openssl rand -hex 32 +# cp apps/server/.env.example apps/server/.env +# +# Required ---------------------------------------------------------------- +# The server refuses to start without a strong JWT_SECRET (>=32 chars). +# Generate one with: openssl rand -hex 32 JWT_SECRET= -# Optional +# Optional ---------------------------------------------------------------- # PORT=3000 -# HOST=0.0.0.0 +# HOST=0.0.0.0 # interface to bind. 127.0.0.1 = loopback only. # LOG_LEVEL=info # DATABASE_URL=./parking.sqlite + +# Testing-only ------------------------------------------------------------ +# Bypass the admin auth on /api/setup/* so you can discover/assign devices +# before the login flow exists. HARDENED: only honoured when NODE_ENV is not +# "production" AND HOST is loopback (127.0.0.1 / ::1 / localhost); otherwise +# the server refuses to start. Never set this in production. +# SETUP_AUTH_BYPASS=1 +# HOST=127.0.0.1 diff --git a/apps/server/package.json b/apps/server/package.json index 1e03c73..5c79737 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -5,21 +5,22 @@ "type": "module", "scripts": { "build": "tsc -b", - "dev": "node --watch --experimental-strip-types src/index.ts", - "start": "node dist/index.js", + "dev": "node --env-file-if-exists=.env --watch --experimental-strip-types src/index.ts", + "start": "node --env-file-if-exists=.env dist/index.js", "typecheck": "tsc --noEmit", "lint": "tsc --noEmit" }, "dependencies": { - "@parking/db": "workspace:*", - "@parking/devices": "workspace:*", - "@parking/shared": "workspace:*", "@fastify/cors": "11.2.0", "@fastify/jwt": "10.1.0", "@fastify/static": "9.1.3", + "@parking/db": "workspace:*", + "@parking/devices": "workspace:*", + "@parking/shared": "workspace:*", "bcrypt": "6.0.0", "fastify": "5.8.5", - "fastify-plugin": "6.0.0" + "fastify-plugin": "6.0.0", + "uhppoted": "0.9.0" }, "devDependencies": { "@types/bcrypt": "6.0.0", diff --git a/apps/server/scripts/uhppote-common.mjs b/apps/server/scripts/uhppote-common.mjs new file mode 100644 index 0000000..ff8b75d --- /dev/null +++ b/apps/server/scripts/uhppote-common.mjs @@ -0,0 +1,72 @@ +// 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 }; diff --git a/apps/server/scripts/uhppote-listen.mjs b/apps/server/scripts/uhppote-listen.mjs new file mode 100644 index 0000000..bd127f1 --- /dev/null +++ b/apps/server/scripts/uhppote-listen.mjs @@ -0,0 +1,100 @@ +// 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= 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); +}); diff --git a/apps/server/scripts/uhppote-relay.mjs b/apps/server/scripts/uhppote-relay.mjs new file mode 100644 index 0000000..db0f5ca --- /dev/null +++ b/apps/server/scripts/uhppote-relay.mjs @@ -0,0 +1,44 @@ +// 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."); diff --git a/apps/server/src/routes/setup.ts b/apps/server/src/routes/setup.ts index 057c16c..03bb5a2 100644 --- a/apps/server/src/routes/setup.ts +++ b/apps/server/src/routes/setup.ts @@ -24,12 +24,26 @@ export async function setupRoutes(app: FastifyInstance, db: Db): Promise { registerBuiltinDrivers(); setDeviceLogSink((line) => app.log.info(line)); + // TEMPORARY hardware-bench escape hatch. When SETUP_AUTH_BYPASS=1, the setup + // endpoints skip the admin guard so devices can be discovered/assigned before + // the login flow exists. Remove once real admin login is wired. + // + // Hardened (flagged by security review): this can NEVER silently open auth in + // a deployable config. It is honoured ONLY when all hold, else the server + // FAILS CLOSED (throws) rather than running unauthenticated: + // (a) NODE_ENV !== 'production' + // (b) the listener is bound to loopback (HOST is 127.0.0.1 / ::1 / localhost) + // See server.ts TODO + wiki/concepts/first-run-setup.md. + const { guard: adminGuard, bypassed: authBypass } = resolveAdminGuard(app); + // Catalog of selectable drivers per category (no secrets — schema only). // `discoverable` flags drivers that can scan the LAN (e.g. UHPPOTE). + // `authBypass` tells the UI the setup endpoints aren't requiring a token + // (testing only), so it can drop the admin-token requirement. app.get("/api/setup/catalog", async () => { const catalog = registry.catalog(); const discoverable = registry.list().filter(isDiscoverable).map((d) => d.id); - return { ...catalog, discoverable }; + return { ...catalog, discoverable, authBypass }; }); // Scan the LAN for devices a driver can discover (UHPPOTE UDP broadcast, etc). @@ -37,7 +51,7 @@ export async function setupRoutes(app: FastifyInstance, db: Db): Promise { // assigning. Admin-only. See wiki/concepts/device-discovery.md. app.get<{ Params: { driverId: string } }>( "/api/setup/discover/:driverId", - { preHandler: requireRole("admin") }, + { preHandler: adminGuard }, async (req, reply) => { const driver = registry.get(req.params.driverId); if (!driver) return reply.code(404).send({ error: `unknown driver: ${req.params.driverId}` }); @@ -67,7 +81,7 @@ export async function setupRoutes(app: FastifyInstance, db: Db): Promise { // Current setup status + assignments. app.get( "/api/setup/state", - { preHandler: requireRole("admin") }, + { preHandler: adminGuard }, async () => { const state = await db.select().from(setupState).where(eq(setupState.id, 1)).get(); const assignments = await db.select().from(laneDevices).all(); @@ -79,7 +93,7 @@ export async function setupRoutes(app: FastifyInstance, db: Db): Promise { // registry before persisting; rejects unknown drivers / missing config. app.post<{ Body: AssignBody }>( "/api/setup/assign", - { preHandler: requireRole("admin") }, + { preHandler: adminGuard }, async (req, reply) => { const { lane, category, driverId, config } = req.body; const driver = registry.get(driverId); @@ -107,7 +121,7 @@ export async function setupRoutes(app: FastifyInstance, db: Db): Promise { // Mark first-run setup complete. app.post( "/api/setup/complete", - { preHandler: requireRole("admin") }, + { preHandler: adminGuard }, async () => { const completedAt = new Date().toISOString(); await db @@ -118,3 +132,38 @@ export async function setupRoutes(app: FastifyInstance, db: Db): Promise { }, ); } + +const LOOPBACK_HOSTS = new Set(["127.0.0.1", "::1", "localhost"]); + +/** + * Resolve the setup admin guard. Returns the real admin role guard unless the + * SETUP_AUTH_BYPASS escape hatch is both requested AND safe; if it's requested + * but unsafe, throws so the server fails closed instead of running open. + * `bypassed` is surfaced to the UI so it can drop the admin-token requirement. + */ +function resolveAdminGuard(app: FastifyInstance): { + guard: ReturnType; + bypassed: boolean; +} { + if (process.env.SETUP_AUTH_BYPASS !== "1") { + return { guard: requireRole("admin"), bypassed: false }; + } + + const isProd = process.env.NODE_ENV === "production"; + const host = process.env.HOST ?? "0.0.0.0"; + const isLoopback = LOOPBACK_HOSTS.has(host); + + if (isProd || !isLoopback) { + // Fail closed: never honour an auth bypass in production or on a non-loopback + // listener (that would expose unauthenticated setup endpoints on the network). + throw new Error( + `SETUP_AUTH_BYPASS refused: requires NODE_ENV!=production (is "${process.env.NODE_ENV ?? "undefined"}") ` + + `and a loopback HOST (is "${host}"). Set HOST=127.0.0.1 for local testing, or unset the bypass.`, + ); + } + + app.log.warn( + `⚠️ SETUP_AUTH_BYPASS=1 — /api/setup/* admin auth DISABLED on ${host} (testing only)`, + ); + return { guard: async () => {}, bypassed: true }; +} diff --git a/apps/web/index.html b/apps/web/index.html index 8987670..ff9dfef 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -3,6 +3,7 @@ + Parking System diff --git a/apps/web/src/SetupWizard.tsx b/apps/web/src/SetupWizard.tsx index ea654c8..d3a5eb1 100644 --- a/apps/web/src/SetupWizard.tsx +++ b/apps/web/src/SetupWizard.tsx @@ -52,16 +52,22 @@ export function SetupWizard() { style={{ width: "4rem" }} /> - + {catalog.authBypass ? ( + + ⚠️ auth bypass on (testing) — no token needed + + ) : ( + + )} {CATEGORIES.map(({ key, title }) => ( @@ -71,6 +77,7 @@ export function SetupWizard() { entries={catalog[key]} discoverableIds={catalog.discoverable} token={token} + authBypass={catalog.authBypass} selectedId={picked[key]} onSelect={(id) => setPicked((p) => ({ ...p, [key]: id }))} /> @@ -84,6 +91,7 @@ function CategoryPicker({ entries, discoverableIds, token, + authBypass, selectedId, onSelect, }: { @@ -91,6 +99,7 @@ function CategoryPicker({ entries: CatalogEntry[]; discoverableIds: string[]; token: string; + authBypass: boolean; selectedId: string | undefined; onSelect: (id: string) => void; }) { @@ -144,10 +153,12 @@ function CategoryPicker({ {canDiscover && (
- - {!token && enter an admin token to scan} + {!authBypass && !token && ( + enter an admin token to scan + )} {scanError && {scanError}} {found && found.length === 0 &&

No controllers found on the LAN.

} {found && found.length > 0 && ( diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index 4cf46e3..0a30a62 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -22,6 +22,8 @@ export type DeviceCategory = "access" | "reader" | "camera" | "printer"; export type Catalog = Record & { /** Driver ids that support LAN discovery. */ discoverable: string[]; + /** True when setup endpoints skip admin auth (testing only) — no token needed. */ + authBypass: boolean; }; export async function fetchCatalog(): Promise { diff --git a/packages/devices/package.json b/packages/devices/package.json index 28b4277..45c548a 100644 --- a/packages/devices/package.json +++ b/packages/devices/package.json @@ -22,6 +22,7 @@ "uhppoted": "0.9.0" }, "devDependencies": { + "@types/node": "25.9.3", "typescript": "6.0.3" } } diff --git a/packages/devices/src/drivers/access-uhppote.ts b/packages/devices/src/drivers/access-uhppote.ts index 847a8d0..e19018d 100644 --- a/packages/devices/src/drivers/access-uhppote.ts +++ b/packages/devices/src/drivers/access-uhppote.ts @@ -1,3 +1,4 @@ +import { networkInterfaces } from "node:os"; import uhppoted, { type Controller, type Ctx } from "uhppoted"; import type { AccessControlDevice, DeviceHealth } from "../interfaces.js"; import type { @@ -11,6 +12,82 @@ import { hostField, stubLog } from "./common.js"; // 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 = Promise.resolve(); +function serialize(fn: () => Promise): Promise { + 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 @@ -20,13 +97,16 @@ const { Config, getDevices, getStatus, openDoor } = uhppoted; // assumes the controller sits on an isolated VLAN reachable only by the host. // See wiki/concepts/uhppote-udp-protocol.md and network-isolation.md. -/** Shared uhppoted context (UDP broadcast on :60000, listener on :60001). */ -function buildCtx(timeoutMs = 5000): Ctx { +/** + * 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", - "255.255.255.255:60000", + `${broadcast}:60000`, "0.0.0.0:60001", timeoutMs, [], @@ -36,6 +116,23 @@ function buildCtx(timeoutMs = 5000): Ctx { }; } +/** 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; @@ -49,7 +146,13 @@ class UhppoteAccessControl implements AccessControlDevice { // Addressable descriptor when a host is given; otherwise rely on UDP // broadcast discovery by serial. this.#controller = address ? { id: serial, address, protocol } : serial; - this.#ctx = buildCtx(config.timeoutMs ? Number(config.timeoutMs) : 5000); + + // 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 { @@ -63,7 +166,7 @@ class UhppoteAccessControl implements AccessControlDevice { async healthCheck(): Promise { try { - await getStatus(this.#ctx, this.#controller); + await serialize(() => getStatus(this.#ctx, this.#controller)); return { status: "ready" }; } catch (err) { return { status: "offline", detail: (err as Error).message }; @@ -75,7 +178,9 @@ class UhppoteAccessControl implements AccessControlDevice { * vehicle — auto-close/anti-crush is the barrier operator's firmware. */ async pulseOpen(doorId: number): Promise { - const res = await openDoor(this.#ctx, this.#controller, doorId); + const res = await serialize(() => + openDoor(this.#ctx, this.#controller, doorId), + ); if (!res.opened) { throw new Error(`uhppote: door ${doorId} not opened (deviceId ${res.deviceId})`); } @@ -85,7 +190,7 @@ class UhppoteAccessControl implements AccessControlDevice { // 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 getStatus(this.#ctx, this.#controller); + await serialize(() => getStatus(this.#ctx, this.#controller)); return "closed"; } } @@ -100,21 +205,36 @@ export const uhppoteDriver: AccessDriver & { "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. See wiki/concepts/device-discovery.md. + // 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 { - const found = await getDevices(buildCtx(3000)); - return found.map((d) => ({ - 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, - }, - })); + const bySerial = new Map(); + // 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: [ { diff --git a/packages/devices/tsconfig.json b/packages/devices/tsconfig.json index 0059635..4999a19 100644 --- a/packages/devices/tsconfig.json +++ b/packages/devices/tsconfig.json @@ -3,7 +3,8 @@ "compilerOptions": { "rootDir": "./src", "outDir": "./dist", - "composite": true + "composite": true, + "types": ["node"] }, "references": [{ "path": "../shared" }], "include": ["src/**/*"] diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 494e932..fb778ca 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -47,6 +47,9 @@ importers: fastify-plugin: specifier: 6.0.0 version: 6.0.0 + uhppoted: + specifier: 0.9.0 + version: 0.9.0 devDependencies: '@types/bcrypt': specifier: 6.0.0 @@ -117,6 +120,9 @@ importers: specifier: 0.9.0 version: 0.9.0 devDependencies: + '@types/node': + specifier: 25.9.3 + version: 25.9.3 typescript: specifier: 6.0.3 version: 6.0.3 diff --git a/wiki/decisions/access-controller-button-flow.md b/wiki/decisions/access-controller-button-flow.md new file mode 100644 index 0000000..235e8c4 --- /dev/null +++ b/wiki/decisions/access-controller-button-flow.md @@ -0,0 +1,86 @@ +--- +type: decision +tags: [parking, hardware, access-control, blocker, open] +sources: [parking-system-architecture] +updated: 2026-06-15 +status: open +--- + +# Blocker: Push-Button → Auto-Open Defeats the Ticket-First Entry Flow + +> **Procurement-blocking finding (2026-06-15), from on-hardware testing.** The UHPPOTE and +> ZKTeco access controllers **on hand** cannot, as wired/configured, deliver the required entry +> flow. This blocks the entry lane and needs a hardware/wiring resolution before that lane ships. +> Work paused here to focus on the business side. See [[entry-exit-readers]], [[trust-boundary]]. + +## The required flow + +``` +car arrives → driver presses button → TICKET PRINTS → then barrier opens +``` + +The ticket must print **before** the barrier opens, and entry must **fail closed**: if the +ticket can't print (printer offline / out of paper), the barrier must **stay shut** — no +untracked car enters (the anti-fraud core, see [[threat-model]], [[append-only-event-chain]]). + +## The problem (verified on real hardware) + +The push-button is wired into the controller's **push-button / request-to-exit (REX) input**. +On that input the controller **firmware auto-fires the relay immediately** — the door opens on +press. The host only **observes** the event *after* the relay has already actuated, so there is +no point at which it can insert the ticket-print step. The host is structurally too late. + +This is the [[entry-exit-readers]] principle in the negative: a button on the controller's own +input is decided **by the controller**, not the host. For host-in-the-loop entry, the button +must be a **host-side input** and the controller demoted to a **commanded relay**. + +### Verified facts (UHPPOTE 225088491, firmware 09120) + +Tested live via the `uhppoted` lib (see `apps/server/scripts/`): + +- **Relays work**, host-commanded: `openDoor` doors 1 & 2 → `{opened:true}`, physically + actuated, logged as events with `reason="remote open door"`. +- **Buttons fire events** — but only **after** auto-opening: press logs an event with + `reason="push button ok"` (door 1 button → door 1, door 2 button → door 2). +- Both doors are in `control: "controlled"` mode with a 3 s delay. +- **No UDP command exists** to stop the push-button input from auto-opening the relay. The + `uhppoted` protocol (and the UHPPOTE firmware, which is **not changeable** — + [[uhppote-udp-protocol]]) has no "report-but-don't-open" mode for that input. + +## Per-device assessment + +- **UHPPOTE** — push-button input is **firmware-hardwired to auto-open**; not configurable. + Cannot do ticket-first entry while the button is on that input. Fine as a **commanded relay** + (host `openDoor`) and for permit/[[wiegand]] lanes; **not suitable for the button-driven entry + lane** without rewiring the button to a host-side input. See [[uhppote-controller]]. +- **ZKTeco (C3 / inBio)** — *better positioned but unverified by us.* Its **auxiliary inputs** + have **programmable linkage** (via ZKBioSecurity / the PULL SDK) and need **not** be tied to + "open door", and its SDK streams real-time events + an explicit open command — so + `button → aux input → host event → print → host opens` is achievable **in principle**. BUT: + (a) wiring the button to the door's *exit-switch* input still auto-opens, same as UHPPOTE — it + only works on a properly-configured **aux** input; (b) ZKTeco speaks its **own PULL SDK + protocol**, not the UHPPOTE one — no verified MIT-licensed Node lib exists (mature open + implementations are Python: `zkaccess-c3-py`, `pyzkaccess`), so it needs a **new driver**. + See [[zkteco-controller]]. + +**Bottom line:** *neither controller currently on hand* delivers ticket-first entry as wired. +UHPPOTE can't at all on that input; ZKTeco might with an aux-input reconfig + a new driver, but +that is unverified. + +## Options (unresolved — to settle later) + +1. **Rewire the button off the controller's REX/exit input** to a host-readable input (spare + GPIO, a USB/IP digital-input module, or a non-auto-open input) so the host sees the press, + prints, then commands `openDoor`. Keeps UHPPOTE as a commanded relay. +2. **ZKTeco aux-input path** — wire the button to a programmable aux input, host decides via SDK. + Requires building a `[[zkteco-controller|zkteco]]` driver (PULL SDK) and validating the + no-auto-open linkage on real hardware. +3. **Custom [[esp32-custom-controller|ESP32]] controller** for the entry lane — full control of + button logic; the documented (currently deferred) prevention-grade path. + +## Status + +**Open / paused.** Recorded so the constraint isn't rediscovered. The relay + event-log command +path is otherwise **proven on hardware** (discovery, open, event capture all work) — the gap is +specifically the **button-before-ticket ordering** on the entry lane. Revisit when the entry-lane +hardware decision is taken. Related: [[open-questions]] (lane topology, failure modes). diff --git a/wiki/entities/uhppote-controller.md b/wiki/entities/uhppote-controller.md index 86115dc..4ca1533 100644 --- a/wiki/entities/uhppote-controller.md +++ b/wiki/entities/uhppote-controller.md @@ -11,6 +11,16 @@ The starting access-control hardware: a **UHPPOTE Wiegand 26/34 network controll a cheap reader-plus-relay frontend, acceptable **provided you understand its limits**. The plan is UHPPOTE now → ZKTeco later (see [[bom]]). (See [[parking-system-architecture]] §6.) +> **⚠️ Entry-flow blocker (verified on hardware):** the push-button input **auto-opens the relay +> in firmware** — there's no command to make it report-without-opening — so it **cannot** do +> 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 +> options: [[access-controller-button-flow]]. +> +> **Verified working on the real unit** (serial 225088491, fw 09120): host-commanded `openDoor` +> on doors 1 & 2 (physically actuated, `reason="remote open door"`); button presses captured live +> (`reason="push button ok"`); [[device-discovery]] scan. Test scripts: `apps/server/scripts/`. + > **Implementation:** integrated via the official **`uhppoted`** npm package (MIT, by the > `uhppoted` org — `github.com/uhppoted/uhppoted-lib-nodejs`), added to `@parking/devices` as the > `uhppote` access driver ([[device-registry]]). It exposes exactly the protocol commands this diff --git a/wiki/entities/zkteco-controller.md b/wiki/entities/zkteco-controller.md new file mode 100644 index 0000000..4ca9611 --- /dev/null +++ b/wiki/entities/zkteco-controller.md @@ -0,0 +1,41 @@ +--- +type: entity +tags: [parking, hardware, access-control] +sources: [parking-system-architecture] +updated: 2026-06-15 +--- + +# ZKTeco Controller + +A network access controller (C3 / inBio families) — the documented "UHPPOTE now → ZKTeco later" +upgrade in the [[bom]]. A `zkteco` driver **stub** exists in the [[device-registry]] but the +**real protocol is not implemented** (see below). + +## Relevance to the entry-flow blocker + +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**: + +- 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 + **without** auto-opening, enabling `button → host → print ticket → host opens`. +- The **PULL SDK** streams real-time events (`GetRTLog`) and has an explicit open command + (`ControlDeviceOutput`). +- Caveat: a button on the **door's exit-switch input still auto-opens** (same as UHPPOTE) — only + the **aux-input** path avoids it. + +## Driver status + +- ZKTeco speaks its **own PULL SDK / TCP protocol** — **not** the UHPPOTE UDP protocol, so the + `uhppoted` lib does **not** work with it. +- No verified MIT/Apache/BSD **Node** library found. Mature open implementations are **Python** + (`zkaccess-c3-py`, `pyzkaccess`). Adopting ZKTeco means **writing a new driver** for the + registry ([[device-adapter-pattern]]) — real protocol work. + +## Reference + +- ZKTeco SDK / PULL SDK: https://www.zkteco.com/en/SDK +- `zkaccess-c3-py`: https://github.com/vwout/zkaccess-c3-py +- `pyzkaccess`: https://github.com/bdragon300/pyzkaccess + +See [[access-controller-button-flow]] for the full blocker context. diff --git a/wiki/index.md b/wiki/index.md index 79570d8..5c6826c 100644 --- a/wiki/index.md +++ b/wiki/index.md @@ -37,6 +37,7 @@ Counts: 1 source · 14 entities · 10 concepts · 2 decision records. - [[atecc608]] — secure element; non-extractable signing key (host events + controller auth). - [[wiegand]] — reader standard feeding the controller directly (autonomous permit-holder path). - [[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). - [[bom]] — reference bill of materials (barrier, loops, controller, readers, payment, host, network). ## Concepts — foundational forces @@ -68,3 +69,4 @@ Counts: 1 source · 14 entities · 10 concepts · 2 decision records. ## Decisions - [[standing-decisions]] — settled decisions (stack, platform, integrity, access control, readers). - [[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. diff --git a/wiki/log.md b/wiki/log.md index 08ccdcd..ac51dc9 100644 --- a/wiki/log.md +++ b/wiki/log.md @@ -52,3 +52,23 @@ health badges and auto-fills serial + host on selection. Verified: catalog flags uhppote; discover runs and fails gracefully without hardware (broadcast EACCES); non-discoverable driver → 400; no token → 401. Modeled generically so cameras (ONVIF) can add discovery later. + +## [2026-06-15] test+blocker | UHPPOTE hardware bring-up + entry-flow blocker +Brought up the real UHPPOTE (serial 225088491, fw 09120) end to end. Fixed the +networking path: WSL2 mirrored mode, then driver bugs — subnet-directed broadcast +(the lib doesn't enable SO_BROADCAST for global 255.255.255.255), broadcast must +match the target's subnet for unicast reply routing (health-check timeout fix), +multi-subnet discovery, and serialized I/O (concurrent calls collided on :60001). +Added .env loading (Node --env-file), env-gated+fail-closed SETUP_AUTH_BYPASS, and +an authBypass flag so the wizard drops the token field. Test scripts in +apps/server/scripts/ (uhppote-listen, uhppote-relay). + +VERIFIED on hardware: discovery; host-commanded openDoor doors 1&2 (physical + +reason="remote open door"); button presses live (reason="push button ok"). + +BLOCKER FOUND: the controller push-button input auto-opens the relay in firmware — +no command to report-without-opening — so ticket-first entry (button→print→open) +is impossible as wired. UHPPOTE can't do it on that input; ZKTeco *might* via a +programmable aux input + PULL SDK but that's unverified and needs a new driver. +Recorded in [[access-controller-button-flow]] + [[zkteco-controller]]. Entry-lane +hardware decision paused to focus on the business side.