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 = 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 // 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 { // No persistent socket to open (request/response over UDP); verify reachability. await this.healthCheck(); } async disconnect(): Promise { stubLog(this.driverId, "disconnect (stateless udp — nothing to close)"); } async healthCheck(): Promise { 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 { 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; } = { 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 { 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: [ { 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), };