Add real UHPPOTE access driver via official uhppoted lib
Researched Node options for the UHPPOTE controller and chose the official
`uhppoted` npm package (MIT, actively maintained, single trivial dep). Its API
covers the full design: openDoor, getStatus, the event-log set (getEvent,
getEventIndex, setEventIndex, recordSpecialEvents), and setListener/listen.
Rejected alternatives: raw-dgram DIY, node-red-contrib-uhppoted, Go sidecar.
- packages/devices: add uhppoted@0.9.0; new `uhppote` access driver implementing
AccessControlDevice (pulseOpen -> openDoor intent-only; healthCheck/getDoorStatus
-> getStatus). Registered in the catalog alongside zkteco/esp32-relay.
- Local ambient types (uhppoted ships none); CJS interop via default-import +
destructure under NodeNext.
- Config fields: controller serial (required), optional host/protocol (udp|tcp),
doors, timeout.
Security/safety unchanged: unauthenticated UDP -> isolated VLAN assumed; relay
is intent-only ("a barrier is not a door").
wiki: record the library choice on uhppote-controller + log entry.
Verified: builds; `uhppote` shows in the catalog with correct fields;
instantiates and degrades to "offline" gracefully without hardware (on-VLAN
test pending).
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
import uhppoted, { type Controller, type Ctx } from "uhppoted";
|
||||
import type { AccessControlDevice, DeviceHealth } from "../interfaces.js";
|
||||
|
||||
// `uhppoted` is CommonJS — import the default and destructure (named ESM imports
|
||||
// don't resolve off a CJS module under NodeNext).
|
||||
const { Config, getStatus, openDoor } = uhppoted;
|
||||
import type { AccessDriver, DeviceConfig } from "../registry.js";
|
||||
import { hostField, stubLog } from "./common.js";
|
||||
|
||||
// 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.
|
||||
|
||||
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;
|
||||
|
||||
const timeout = config.timeoutMs ? Number(config.timeoutMs) : 5000;
|
||||
this.#ctx = {
|
||||
config: new Config(
|
||||
"parking",
|
||||
"0.0.0.0",
|
||||
"255.255.255.255:60000",
|
||||
"0.0.0.0:60001",
|
||||
timeout,
|
||||
[],
|
||||
false,
|
||||
),
|
||||
locale: "en-US",
|
||||
};
|
||||
}
|
||||
|
||||
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 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 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 getStatus(this.#ctx, this.#controller);
|
||||
return "closed";
|
||||
}
|
||||
}
|
||||
|
||||
export const uhppoteDriver: AccessDriver = {
|
||||
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"],
|
||||
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),
|
||||
};
|
||||
Reference in New Issue
Block a user