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:
@@ -22,6 +22,7 @@
|
||||
"uhppoted": "0.9.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "25.9.3",
|
||||
"typescript": "6.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<unknown> = Promise.resolve();
|
||||
function serialize<T>(fn: () => Promise<T>): Promise<T> {
|
||||
const run = chain.then(fn, fn);
|
||||
// keep the chain alive regardless of this call's outcome
|
||||
chain = run.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
return run;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute subnet-directed broadcast addresses (e.g. 10.0.10.255) for every
|
||||
* non-internal IPv4 interface.
|
||||
*
|
||||
* Why this matters: the uhppoted lib only enables SO_BROADCAST when the target
|
||||
* matches a *subnet-directed* broadcast of a local interface — it does NOT
|
||||
* recognise the global 255.255.255.255, so broadcasting there fails with EACCES.
|
||||
* We must broadcast to the per-interface address (e.g. 10.0.10.255) instead.
|
||||
*/
|
||||
interface Iface {
|
||||
network: number[]; // ip & mask, per octet
|
||||
mask: number[];
|
||||
broadcast: string;
|
||||
}
|
||||
|
||||
function localIfaces(): Iface[] {
|
||||
const out: Iface[] = [];
|
||||
for (const ifaces of Object.values(networkInterfaces())) {
|
||||
for (const i of ifaces ?? []) {
|
||||
if (i.family !== "IPv4" || i.internal) continue;
|
||||
const ip = i.address.split(".").map(Number);
|
||||
const mask = i.netmask.split(".").map(Number);
|
||||
if (ip.length !== 4 || mask.length !== 4) continue;
|
||||
out.push({
|
||||
network: ip.map((o, k) => o & mask[k]!),
|
||||
mask,
|
||||
broadcast: ip.map((o, k) => (o & mask[k]!) | (~mask[k]! & 0xff)).join("."),
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function subnetBroadcastAddrs(): string[] {
|
||||
return localIfaces().map((i) => i.broadcast);
|
||||
}
|
||||
|
||||
/** Broadcast target for discovery: explicit override, else first subnet bcast. */
|
||||
function discoveryBroadcast(): string {
|
||||
return (
|
||||
process.env.UHPPOTE_BROADCAST ?? subnetBroadcastAddrs()[0] ?? "255.255.255.255"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The subnet-directed broadcast for the interface that `host` belongs to. The
|
||||
* uhppoted Config's broadcast address governs reply routing even for unicast
|
||||
* ops, so it must match the TARGET's subnet (not just the first interface) or
|
||||
* the reply is missed → timeout.
|
||||
*/
|
||||
function broadcastForHost(host: string): string {
|
||||
const ip = host.split(".").map(Number);
|
||||
if (ip.length === 4) {
|
||||
for (const i of localIfaces()) {
|
||||
if (ip.every((o, k) => (o & i.mask[k]!) === i.network[k])) return i.broadcast;
|
||||
}
|
||||
}
|
||||
return discoveryBroadcast();
|
||||
}
|
||||
|
||||
// Real UHPPOTE access-control driver, backed by the official `uhppoted` lib.
|
||||
// Implements AccessControlDevice (intent-only relay — "a barrier is not a door";
|
||||
// the controller/barrier operator owns physical safety). See
|
||||
@@ -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<void> {
|
||||
@@ -63,7 +166,7 @@ class UhppoteAccessControl implements AccessControlDevice {
|
||||
|
||||
async healthCheck(): Promise<DeviceHealth> {
|
||||
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<void> {
|
||||
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<DiscoveredDevice[]> {
|
||||
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<number, DiscoveredDevice>();
|
||||
// Serial, not parallel: each getDevices binds :60001, so concurrent scans
|
||||
// across interfaces collide (EACCES / dropped replies).
|
||||
for (const bcast of discoveryBroadcasts()) {
|
||||
let found;
|
||||
try {
|
||||
found = await serialize(() => getDevices(buildCtxFor(bcast, 3000)));
|
||||
} catch {
|
||||
continue; // a dead interface shouldn't fail the whole scan
|
||||
}
|
||||
for (const d of found) {
|
||||
bySerial.set(d.device.serialNumber, {
|
||||
id: String(d.device.serialNumber),
|
||||
label: `UHPPOTE ${d.device.serialNumber} @ ${d.device.address}`,
|
||||
config: { serial: d.device.serialNumber, host: d.device.address, protocol: "udp" },
|
||||
info: {
|
||||
address: d.device.address,
|
||||
netmask: d.device.netmask,
|
||||
gateway: d.device.gateway,
|
||||
MAC: d.device.MAC,
|
||||
firmware: d.device.version,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
return [...bySerial.values()];
|
||||
},
|
||||
configFields: [
|
||||
{
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"composite": true
|
||||
"composite": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"references": [{ "path": "../shared" }],
|
||||
"include": ["src/**/*"]
|
||||
|
||||
Reference in New Issue
Block a user