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.
This commit is contained in:
@@ -15,3 +15,6 @@ dist/
|
|||||||
# Editor/OS
|
# Editor/OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*:Zone.Identifier
|
*:Zone.Identifier
|
||||||
|
.playwright-mcp/
|
||||||
|
# stray hardware/UI test screenshots
|
||||||
|
/*.png
|
||||||
@@ -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:
|
# cp apps/server/.env.example apps/server/.env
|
||||||
# openssl rand -hex 32
|
#
|
||||||
|
# Required ----------------------------------------------------------------
|
||||||
|
# The server refuses to start without a strong JWT_SECRET (>=32 chars).
|
||||||
|
# Generate one with: openssl rand -hex 32
|
||||||
JWT_SECRET=
|
JWT_SECRET=
|
||||||
|
|
||||||
# Optional
|
# Optional ----------------------------------------------------------------
|
||||||
# PORT=3000
|
# PORT=3000
|
||||||
# HOST=0.0.0.0
|
# HOST=0.0.0.0 # interface to bind. 127.0.0.1 = loopback only.
|
||||||
# LOG_LEVEL=info
|
# LOG_LEVEL=info
|
||||||
# DATABASE_URL=./parking.sqlite
|
# 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
|
||||||
|
|||||||
@@ -5,21 +5,22 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc -b",
|
"build": "tsc -b",
|
||||||
"dev": "node --watch --experimental-strip-types src/index.ts",
|
"dev": "node --env-file-if-exists=.env --watch --experimental-strip-types src/index.ts",
|
||||||
"start": "node dist/index.js",
|
"start": "node --env-file-if-exists=.env dist/index.js",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"lint": "tsc --noEmit"
|
"lint": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@parking/db": "workspace:*",
|
|
||||||
"@parking/devices": "workspace:*",
|
|
||||||
"@parking/shared": "workspace:*",
|
|
||||||
"@fastify/cors": "11.2.0",
|
"@fastify/cors": "11.2.0",
|
||||||
"@fastify/jwt": "10.1.0",
|
"@fastify/jwt": "10.1.0",
|
||||||
"@fastify/static": "9.1.3",
|
"@fastify/static": "9.1.3",
|
||||||
|
"@parking/db": "workspace:*",
|
||||||
|
"@parking/devices": "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,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 };
|
||||||
@@ -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=<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);
|
||||||
|
});
|
||||||
@@ -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.");
|
||||||
@@ -24,12 +24,26 @@ export async function setupRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
|||||||
registerBuiltinDrivers();
|
registerBuiltinDrivers();
|
||||||
setDeviceLogSink((line) => app.log.info(line));
|
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).
|
// 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 (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 () => {
|
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, authBypass };
|
||||||
});
|
});
|
||||||
|
|
||||||
// Scan the LAN for devices a driver can discover (UHPPOTE UDP broadcast, etc).
|
// 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<void> {
|
|||||||
// 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 } }>(
|
||||||
"/api/setup/discover/:driverId",
|
"/api/setup/discover/:driverId",
|
||||||
{ preHandler: requireRole("admin") },
|
{ preHandler: adminGuard },
|
||||||
async (req, reply) => {
|
async (req, reply) => {
|
||||||
const driver = registry.get(req.params.driverId);
|
const driver = registry.get(req.params.driverId);
|
||||||
if (!driver) return reply.code(404).send({ error: `unknown driver: ${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<void> {
|
|||||||
// Current setup status + assignments.
|
// Current setup status + assignments.
|
||||||
app.get(
|
app.get(
|
||||||
"/api/setup/state",
|
"/api/setup/state",
|
||||||
{ preHandler: requireRole("admin") },
|
{ preHandler: adminGuard },
|
||||||
async () => {
|
async () => {
|
||||||
const state = await db.select().from(setupState).where(eq(setupState.id, 1)).get();
|
const state = await db.select().from(setupState).where(eq(setupState.id, 1)).get();
|
||||||
const assignments = await db.select().from(laneDevices).all();
|
const assignments = await db.select().from(laneDevices).all();
|
||||||
@@ -79,7 +93,7 @@ export async function setupRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
|||||||
// registry before persisting; rejects unknown drivers / missing config.
|
// registry before persisting; rejects unknown drivers / missing config.
|
||||||
app.post<{ Body: AssignBody }>(
|
app.post<{ Body: AssignBody }>(
|
||||||
"/api/setup/assign",
|
"/api/setup/assign",
|
||||||
{ preHandler: requireRole("admin") },
|
{ preHandler: adminGuard },
|
||||||
async (req, reply) => {
|
async (req, reply) => {
|
||||||
const { lane, category, driverId, config } = req.body;
|
const { lane, category, driverId, config } = req.body;
|
||||||
const driver = registry.get(driverId);
|
const driver = registry.get(driverId);
|
||||||
@@ -107,7 +121,7 @@ export async function setupRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
|||||||
// Mark first-run setup complete.
|
// Mark first-run setup complete.
|
||||||
app.post(
|
app.post(
|
||||||
"/api/setup/complete",
|
"/api/setup/complete",
|
||||||
{ preHandler: requireRole("admin") },
|
{ preHandler: adminGuard },
|
||||||
async () => {
|
async () => {
|
||||||
const completedAt = new Date().toISOString();
|
const completedAt = new Date().toISOString();
|
||||||
await db
|
await db
|
||||||
@@ -118,3 +132,38 @@ export async function setupRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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<typeof requireRole>;
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<link rel="icon" href="data:," />
|
||||||
<title>Parking System</title>
|
<title>Parking System</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -52,16 +52,22 @@ export function SetupWizard() {
|
|||||||
style={{ width: "4rem" }}
|
style={{ width: "4rem" }}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label style={{ flex: 1 }}>
|
{catalog.authBypass ? (
|
||||||
Admin token{" "}
|
<span style={{ flex: 1, color: "#92400e" }}>
|
||||||
<input
|
⚠️ auth bypass on (testing) — no token needed
|
||||||
type="password"
|
</span>
|
||||||
value={token}
|
) : (
|
||||||
onChange={(e) => setToken(e.target.value)}
|
<label style={{ flex: 1 }}>
|
||||||
placeholder="needed to scan / assign"
|
Admin token{" "}
|
||||||
style={{ width: "60%" }}
|
<input
|
||||||
/>
|
type="password"
|
||||||
</label>
|
value={token}
|
||||||
|
onChange={(e) => setToken(e.target.value)}
|
||||||
|
placeholder="needed to scan / assign"
|
||||||
|
style={{ width: "60%" }}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{CATEGORIES.map(({ key, title }) => (
|
{CATEGORIES.map(({ key, title }) => (
|
||||||
@@ -71,6 +77,7 @@ export function SetupWizard() {
|
|||||||
entries={catalog[key]}
|
entries={catalog[key]}
|
||||||
discoverableIds={catalog.discoverable}
|
discoverableIds={catalog.discoverable}
|
||||||
token={token}
|
token={token}
|
||||||
|
authBypass={catalog.authBypass}
|
||||||
selectedId={picked[key]}
|
selectedId={picked[key]}
|
||||||
onSelect={(id) => setPicked((p) => ({ ...p, [key]: id }))}
|
onSelect={(id) => setPicked((p) => ({ ...p, [key]: id }))}
|
||||||
/>
|
/>
|
||||||
@@ -84,6 +91,7 @@ function CategoryPicker({
|
|||||||
entries,
|
entries,
|
||||||
discoverableIds,
|
discoverableIds,
|
||||||
token,
|
token,
|
||||||
|
authBypass,
|
||||||
selectedId,
|
selectedId,
|
||||||
onSelect,
|
onSelect,
|
||||||
}: {
|
}: {
|
||||||
@@ -91,6 +99,7 @@ function CategoryPicker({
|
|||||||
entries: CatalogEntry[];
|
entries: CatalogEntry[];
|
||||||
discoverableIds: string[];
|
discoverableIds: string[];
|
||||||
token: string;
|
token: string;
|
||||||
|
authBypass: boolean;
|
||||||
selectedId: string | undefined;
|
selectedId: string | undefined;
|
||||||
onSelect: (id: string) => void;
|
onSelect: (id: string) => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -144,10 +153,12 @@ function CategoryPicker({
|
|||||||
|
|
||||||
{canDiscover && (
|
{canDiscover && (
|
||||||
<div style={{ margin: "0.5rem 0", padding: "0.5rem", background: "#f3f4f6", borderRadius: 6 }}>
|
<div style={{ margin: "0.5rem 0", padding: "0.5rem", background: "#f3f4f6", borderRadius: 6 }}>
|
||||||
<button type="button" onClick={scan} disabled={scanning || !token}>
|
<button type="button" onClick={scan} disabled={scanning || (!authBypass && !token)}>
|
||||||
{scanning ? "Scanning…" : "Scan for controllers"}
|
{scanning ? "Scanning…" : "Scan for controllers"}
|
||||||
</button>
|
</button>
|
||||||
{!token && <span style={{ marginLeft: 8, color: "#92400e" }}>enter an admin token to scan</span>}
|
{!authBypass && !token && (
|
||||||
|
<span style={{ marginLeft: 8, color: "#92400e" }}>enter an admin token to scan</span>
|
||||||
|
)}
|
||||||
{scanError && <span style={{ marginLeft: 8, color: "crimson" }}>{scanError}</span>}
|
{scanError && <span style={{ marginLeft: 8, color: "crimson" }}>{scanError}</span>}
|
||||||
{found && found.length === 0 && <p style={{ margin: "0.5rem 0 0" }}>No controllers found on the LAN.</p>}
|
{found && found.length === 0 && <p style={{ margin: "0.5rem 0 0" }}>No controllers found on the LAN.</p>}
|
||||||
{found && found.length > 0 && (
|
{found && found.length > 0 && (
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ export type DeviceCategory = "access" | "reader" | "camera" | "printer";
|
|||||||
export type Catalog = Record<DeviceCategory, CatalogEntry[]> & {
|
export type Catalog = Record<DeviceCategory, CatalogEntry[]> & {
|
||||||
/** Driver ids that support LAN discovery. */
|
/** Driver ids that support LAN discovery. */
|
||||||
discoverable: string[];
|
discoverable: string[];
|
||||||
|
/** True when setup endpoints skip admin auth (testing only) — no token needed. */
|
||||||
|
authBypass: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function fetchCatalog(): Promise<Catalog> {
|
export async function fetchCatalog(): Promise<Catalog> {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
"uhppoted": "0.9.0"
|
"uhppoted": "0.9.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/node": "25.9.3",
|
||||||
"typescript": "6.0.3"
|
"typescript": "6.0.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { networkInterfaces } from "node:os";
|
||||||
import uhppoted, { type Controller, type Ctx } from "uhppoted";
|
import uhppoted, { type Controller, type Ctx } from "uhppoted";
|
||||||
import type { AccessControlDevice, DeviceHealth } from "../interfaces.js";
|
import type { AccessControlDevice, DeviceHealth } from "../interfaces.js";
|
||||||
import type {
|
import type {
|
||||||
@@ -11,6 +12,82 @@ import { hostField, stubLog } from "./common.js";
|
|||||||
// don't resolve off a CJS module under NodeNext).
|
// don't resolve off a CJS module under NodeNext).
|
||||||
const { Config, getDevices, getStatus, openDoor } = uhppoted;
|
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.
|
// Real UHPPOTE access-control driver, backed by the official `uhppoted` lib.
|
||||||
// Implements AccessControlDevice (intent-only relay — "a barrier is not a door";
|
// Implements AccessControlDevice (intent-only relay — "a barrier is not a door";
|
||||||
// the controller/barrier operator owns physical safety). See
|
// 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.
|
// assumes the controller sits on an isolated VLAN reachable only by the host.
|
||||||
// See wiki/concepts/uhppote-udp-protocol.md and network-isolation.md.
|
// 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 {
|
return {
|
||||||
config: new Config(
|
config: new Config(
|
||||||
"parking",
|
"parking",
|
||||||
"0.0.0.0",
|
"0.0.0.0",
|
||||||
"255.255.255.255:60000",
|
`${broadcast}:60000`,
|
||||||
"0.0.0.0:60001",
|
"0.0.0.0:60001",
|
||||||
timeoutMs,
|
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 {
|
class UhppoteAccessControl implements AccessControlDevice {
|
||||||
readonly driverId = "uhppote";
|
readonly driverId = "uhppote";
|
||||||
readonly #controller: Controller;
|
readonly #controller: Controller;
|
||||||
@@ -49,7 +146,13 @@ class UhppoteAccessControl implements AccessControlDevice {
|
|||||||
// Addressable descriptor when a host is given; otherwise rely on UDP
|
// Addressable descriptor when a host is given; otherwise rely on UDP
|
||||||
// broadcast discovery by serial.
|
// broadcast discovery by serial.
|
||||||
this.#controller = address ? { id: serial, address, protocol } : 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<void> {
|
async connect(): Promise<void> {
|
||||||
@@ -63,7 +166,7 @@ class UhppoteAccessControl implements AccessControlDevice {
|
|||||||
|
|
||||||
async healthCheck(): Promise<DeviceHealth> {
|
async healthCheck(): Promise<DeviceHealth> {
|
||||||
try {
|
try {
|
||||||
await getStatus(this.#ctx, this.#controller);
|
await serialize(() => getStatus(this.#ctx, this.#controller));
|
||||||
return { status: "ready" };
|
return { status: "ready" };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return { status: "offline", detail: (err as Error).message };
|
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.
|
* vehicle — auto-close/anti-crush is the barrier operator's firmware.
|
||||||
*/
|
*/
|
||||||
async pulseOpen(doorId: number): Promise<void> {
|
async pulseOpen(doorId: number): Promise<void> {
|
||||||
const res = await openDoor(this.#ctx, this.#controller, doorId);
|
const res = await serialize(() =>
|
||||||
|
openDoor(this.#ctx, this.#controller, doorId),
|
||||||
|
);
|
||||||
if (!res.opened) {
|
if (!res.opened) {
|
||||||
throw new Error(`uhppote: door ${doorId} not opened (deviceId ${res.deviceId})`);
|
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
|
// The UHPPOTE status payload carries per-door state; without a confirmed
|
||||||
// wiring of door sensors we report the safe default until the real status
|
// wiring of door sensors we report the safe default until the real status
|
||||||
// mapping is added. (Status is fetched to prove reachability.)
|
// mapping is added. (Status is fetched to prove reachability.)
|
||||||
await getStatus(this.#ctx, this.#controller);
|
await serialize(() => getStatus(this.#ctx, this.#controller));
|
||||||
return "closed";
|
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.",
|
"UHPPOTE Wiegand 26/34 network controller via the official uhppoted lib. Unauthenticated UDP — isolate the VLAN.",
|
||||||
transports: ["udp", "tcp"],
|
transports: ["udp", "tcp"],
|
||||||
// UDP broadcast discovery (get-devices): every controller on the LAN answers
|
// 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<DiscoveredDevice[]> {
|
async discover(): Promise<DiscoveredDevice[]> {
|
||||||
const found = await getDevices(buildCtx(3000));
|
const bySerial = new Map<number, DiscoveredDevice>();
|
||||||
return found.map((d) => ({
|
// Serial, not parallel: each getDevices binds :60001, so concurrent scans
|
||||||
id: String(d.device.serialNumber),
|
// across interfaces collide (EACCES / dropped replies).
|
||||||
label: `UHPPOTE ${d.device.serialNumber} @ ${d.device.address}`,
|
for (const bcast of discoveryBroadcasts()) {
|
||||||
config: { serial: d.device.serialNumber, host: d.device.address, protocol: "udp" },
|
let found;
|
||||||
info: {
|
try {
|
||||||
address: d.device.address,
|
found = await serialize(() => getDevices(buildCtxFor(bcast, 3000)));
|
||||||
netmask: d.device.netmask,
|
} catch {
|
||||||
gateway: d.device.gateway,
|
continue; // a dead interface shouldn't fail the whole scan
|
||||||
MAC: d.device.MAC,
|
}
|
||||||
firmware: d.device.version,
|
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: [
|
configFields: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"rootDir": "./src",
|
"rootDir": "./src",
|
||||||
"outDir": "./dist",
|
"outDir": "./dist",
|
||||||
"composite": true
|
"composite": true,
|
||||||
|
"types": ["node"]
|
||||||
},
|
},
|
||||||
"references": [{ "path": "../shared" }],
|
"references": [{ "path": "../shared" }],
|
||||||
"include": ["src/**/*"]
|
"include": ["src/**/*"]
|
||||||
|
|||||||
Generated
+6
@@ -47,6 +47,9 @@ 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
|
||||||
@@ -117,6 +120,9 @@ importers:
|
|||||||
specifier: 0.9.0
|
specifier: 0.9.0
|
||||||
version: 0.9.0
|
version: 0.9.0
|
||||||
devDependencies:
|
devDependencies:
|
||||||
|
'@types/node':
|
||||||
|
specifier: 25.9.3
|
||||||
|
version: 25.9.3
|
||||||
typescript:
|
typescript:
|
||||||
specifier: 6.0.3
|
specifier: 6.0.3
|
||||||
version: 6.0.3
|
version: 6.0.3
|
||||||
|
|||||||
@@ -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).
|
||||||
@@ -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
|
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.)
|
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
|
> **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
|
> `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
|
> `uhppote` access driver ([[device-registry]]). It exposes exactly the protocol commands this
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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).
|
- [[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).
|
||||||
- [[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
|
||||||
@@ -68,3 +69,4 @@ Counts: 1 source · 14 entities · 10 concepts · 2 decision records.
|
|||||||
## 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.
|
||||||
|
|||||||
+20
@@ -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);
|
uhppote; discover runs and fails gracefully without hardware (broadcast EACCES);
|
||||||
non-discoverable driver → 400; no token → 401. Modeled generically so cameras
|
non-discoverable driver → 400; no token → 401. Modeled generically so cameras
|
||||||
(ONVIF) can add discovery later.
|
(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.
|
||||||
|
|||||||
Reference in New Issue
Block a user