77606da2c9
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.
264 lines
9.2 KiB
TypeScript
264 lines
9.2 KiB
TypeScript
import { networkInterfaces } from "node:os";
|
||
import uhppoted, { type Controller, type Ctx } from "uhppoted";
|
||
import type { AccessControlDevice, DeviceHealth } from "../interfaces.js";
|
||
import type {
|
||
AccessDriver,
|
||
DeviceConfig,
|
||
DiscoveredDevice,
|
||
} from "../registry.js";
|
||
import { hostField, stubLog } from "./common.js";
|
||
|
||
// `uhppoted` is CommonJS — import the default and destructure (named ESM imports
|
||
// don't resolve off a CJS module under NodeNext).
|
||
const { Config, getDevices, getStatus, openDoor } = uhppoted;
|
||
|
||
// Every uhppoted call binds a UDP listener on :60001 for replies. Concurrent
|
||
// calls collide on that port (EACCES / dropped replies → spurious timeouts), so
|
||
// we serialize ALL controller I/O through one queue. UDP request/response is
|
||
// fast, so serial throughput is fine for a parking host. This is why parallel
|
||
// discovery + health checks were timing out.
|
||
let chain: Promise<unknown> = Promise.resolve();
|
||
function serialize<T>(fn: () => Promise<T>): Promise<T> {
|
||
const run = chain.then(fn, fn);
|
||
// keep the chain alive regardless of this call's outcome
|
||
chain = run.then(
|
||
() => undefined,
|
||
() => undefined,
|
||
);
|
||
return run;
|
||
}
|
||
|
||
/**
|
||
* Compute subnet-directed broadcast addresses (e.g. 10.0.10.255) for every
|
||
* non-internal IPv4 interface.
|
||
*
|
||
* Why this matters: the uhppoted lib only enables SO_BROADCAST when the target
|
||
* matches a *subnet-directed* broadcast of a local interface — it does NOT
|
||
* recognise the global 255.255.255.255, so broadcasting there fails with EACCES.
|
||
* We must broadcast to the per-interface address (e.g. 10.0.10.255) instead.
|
||
*/
|
||
interface Iface {
|
||
network: number[]; // ip & mask, per octet
|
||
mask: number[];
|
||
broadcast: string;
|
||
}
|
||
|
||
function localIfaces(): Iface[] {
|
||
const out: Iface[] = [];
|
||
for (const ifaces of Object.values(networkInterfaces())) {
|
||
for (const i of ifaces ?? []) {
|
||
if (i.family !== "IPv4" || i.internal) continue;
|
||
const ip = i.address.split(".").map(Number);
|
||
const mask = i.netmask.split(".").map(Number);
|
||
if (ip.length !== 4 || mask.length !== 4) continue;
|
||
out.push({
|
||
network: ip.map((o, k) => o & mask[k]!),
|
||
mask,
|
||
broadcast: ip.map((o, k) => (o & mask[k]!) | (~mask[k]! & 0xff)).join("."),
|
||
});
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function subnetBroadcastAddrs(): string[] {
|
||
return localIfaces().map((i) => i.broadcast);
|
||
}
|
||
|
||
/** Broadcast target for discovery: explicit override, else first subnet bcast. */
|
||
function discoveryBroadcast(): string {
|
||
return (
|
||
process.env.UHPPOTE_BROADCAST ?? subnetBroadcastAddrs()[0] ?? "255.255.255.255"
|
||
);
|
||
}
|
||
|
||
/**
|
||
* The subnet-directed broadcast for the interface that `host` belongs to. The
|
||
* uhppoted Config's broadcast address governs reply routing even for unicast
|
||
* ops, so it must match the TARGET's subnet (not just the first interface) or
|
||
* the reply is missed → timeout.
|
||
*/
|
||
function broadcastForHost(host: string): string {
|
||
const ip = host.split(".").map(Number);
|
||
if (ip.length === 4) {
|
||
for (const i of localIfaces()) {
|
||
if (ip.every((o, k) => (o & i.mask[k]!) === i.network[k])) return i.broadcast;
|
||
}
|
||
}
|
||
return discoveryBroadcast();
|
||
}
|
||
|
||
// Real UHPPOTE access-control driver, backed by the official `uhppoted` lib.
|
||
// Implements AccessControlDevice (intent-only relay — "a barrier is not a door";
|
||
// the controller/barrier operator owns physical safety). See
|
||
// wiki/entities/uhppote-controller.md and wiki/concepts/barrier-not-a-door.md.
|
||
//
|
||
// SECURITY: the UHPPOTE protocol is unauthenticated UDP (port 60000). This driver
|
||
// assumes the controller sits on an isolated VLAN reachable only by the host.
|
||
// See wiki/concepts/uhppote-udp-protocol.md and network-isolation.md.
|
||
|
||
/**
|
||
* uhppoted context broadcasting to a specific address on :60000, listening for
|
||
* replies on :60001.
|
||
*/
|
||
function buildCtxFor(broadcast: string, timeoutMs = 5000): Ctx {
|
||
return {
|
||
config: new Config(
|
||
"parking",
|
||
"0.0.0.0",
|
||
`${broadcast}:60000`,
|
||
"0.0.0.0:60001",
|
||
timeoutMs,
|
||
[],
|
||
false,
|
||
),
|
||
locale: "en-US",
|
||
};
|
||
}
|
||
|
||
/** Default context for non-discovery ops (status/open use a unicast host). */
|
||
function buildCtx(timeoutMs = 5000): Ctx {
|
||
return buildCtxFor(discoveryBroadcast(), timeoutMs);
|
||
}
|
||
|
||
/**
|
||
* Broadcast targets to try for discovery. An explicit UHPPOTE_BROADCAST wins;
|
||
* otherwise every local subnet-directed broadcast (a host may have several
|
||
* interfaces — LAN, VPN, docker — and the controller is on only one).
|
||
*/
|
||
function discoveryBroadcasts(): string[] {
|
||
const override = process.env.UHPPOTE_BROADCAST;
|
||
if (override) return [override];
|
||
const addrs = subnetBroadcastAddrs();
|
||
return addrs.length > 0 ? addrs : ["255.255.255.255"];
|
||
}
|
||
|
||
class UhppoteAccessControl implements AccessControlDevice {
|
||
readonly driverId = "uhppote";
|
||
readonly #controller: Controller;
|
||
readonly #ctx: Ctx;
|
||
|
||
constructor(config: DeviceConfig) {
|
||
const serial = Number(config.serial);
|
||
const address = config.host ? String(config.host) : undefined;
|
||
const protocol = config.protocol === "tcp" ? "tcp" : "udp";
|
||
|
||
// Addressable descriptor when a host is given; otherwise rely on UDP
|
||
// broadcast discovery by serial.
|
||
this.#controller = address ? { id: serial, address, protocol } : serial;
|
||
|
||
// The Config broadcast must match the target host's subnet (it governs
|
||
// reply routing even for unicast), else replies are missed → timeout.
|
||
const timeoutMs = config.timeoutMs ? Number(config.timeoutMs) : 5000;
|
||
this.#ctx = address
|
||
? buildCtxFor(broadcastForHost(address), timeoutMs)
|
||
: buildCtx(timeoutMs);
|
||
}
|
||
|
||
async connect(): Promise<void> {
|
||
// No persistent socket to open (request/response over UDP); verify reachability.
|
||
await this.healthCheck();
|
||
}
|
||
|
||
async disconnect(): Promise<void> {
|
||
stubLog(this.driverId, "disconnect (stateless udp — nothing to close)");
|
||
}
|
||
|
||
async healthCheck(): Promise<DeviceHealth> {
|
||
try {
|
||
await serialize(() => getStatus(this.#ctx, this.#controller));
|
||
return { status: "ready" };
|
||
} catch (err) {
|
||
return { status: "offline", detail: (err as Error).message };
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Express intent to open a door (1–4). NEVER timed/forced closed against a
|
||
* vehicle — auto-close/anti-crush is the barrier operator's firmware.
|
||
*/
|
||
async pulseOpen(doorId: number): Promise<void> {
|
||
const res = await serialize(() =>
|
||
openDoor(this.#ctx, this.#controller, doorId),
|
||
);
|
||
if (!res.opened) {
|
||
throw new Error(`uhppote: door ${doorId} not opened (deviceId ${res.deviceId})`);
|
||
}
|
||
}
|
||
|
||
async getDoorStatus(): Promise<"open" | "closed"> {
|
||
// The UHPPOTE status payload carries per-door state; without a confirmed
|
||
// wiring of door sensors we report the safe default until the real status
|
||
// mapping is added. (Status is fetched to prove reachability.)
|
||
await serialize(() => getStatus(this.#ctx, this.#controller));
|
||
return "closed";
|
||
}
|
||
}
|
||
|
||
export const uhppoteDriver: AccessDriver & {
|
||
discover(): Promise<DiscoveredDevice[]>;
|
||
} = {
|
||
id: "uhppote",
|
||
category: "access",
|
||
label: "UHPPOTE controller",
|
||
description:
|
||
"UHPPOTE Wiegand 26/34 network controller via the official uhppoted lib. Unauthenticated UDP — isolate the VLAN.",
|
||
transports: ["udp", "tcp"],
|
||
// UDP broadcast discovery (get-devices): every controller on the LAN answers
|
||
// with its serial, IP, and firmware. Broadcasts on every local subnet (the
|
||
// controller is on only one interface) and dedupes by serial.
|
||
// See wiki/concepts/device-discovery.md.
|
||
async discover(): Promise<DiscoveredDevice[]> {
|
||
const bySerial = new Map<number, DiscoveredDevice>();
|
||
// Serial, not parallel: each getDevices binds :60001, so concurrent scans
|
||
// across interfaces collide (EACCES / dropped replies).
|
||
for (const bcast of discoveryBroadcasts()) {
|
||
let found;
|
||
try {
|
||
found = await serialize(() => getDevices(buildCtxFor(bcast, 3000)));
|
||
} catch {
|
||
continue; // a dead interface shouldn't fail the whole scan
|
||
}
|
||
for (const d of found) {
|
||
bySerial.set(d.device.serialNumber, {
|
||
id: String(d.device.serialNumber),
|
||
label: `UHPPOTE ${d.device.serialNumber} @ ${d.device.address}`,
|
||
config: { serial: d.device.serialNumber, host: d.device.address, protocol: "udp" },
|
||
info: {
|
||
address: d.device.address,
|
||
netmask: d.device.netmask,
|
||
gateway: d.device.gateway,
|
||
MAC: d.device.MAC,
|
||
firmware: d.device.version,
|
||
},
|
||
});
|
||
}
|
||
}
|
||
return [...bySerial.values()];
|
||
},
|
||
configFields: [
|
||
{
|
||
key: "serial",
|
||
label: "Controller serial number",
|
||
type: "number",
|
||
required: true,
|
||
help: "Printed on the controller (e.g. 405419896).",
|
||
},
|
||
{ ...hostField, required: false, help: "Optional: target a specific IP instead of UDP broadcast. Isolated VLAN only." },
|
||
{
|
||
key: "protocol",
|
||
label: "Protocol",
|
||
type: "select",
|
||
required: false,
|
||
default: "udp",
|
||
options: [
|
||
{ value: "udp", label: "UDP (default)" },
|
||
{ value: "tcp", label: "TCP (newer firmware)" },
|
||
],
|
||
},
|
||
{ key: "doors", label: "Door count", type: "number", required: true, default: 4 },
|
||
{ key: "timeoutMs", label: "Timeout (ms)", type: "number", required: false, default: 5000 },
|
||
],
|
||
create: (c) => new UhppoteAccessControl(c),
|
||
};
|