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:
@@ -18,7 +18,8 @@
|
||||
"lint": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@parking/shared": "workspace:*"
|
||||
"@parking/shared": "workspace:*",
|
||||
"uhppoted": "0.9.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "6.0.3"
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
import { registry } from "../registry.js";
|
||||
import { esp32RelayDriver, zktecoDriver } from "./access.js";
|
||||
import { uhppoteDriver } from "./access-uhppote.js";
|
||||
import { dahuaDriver, hikvisionDriver } from "./camera.js";
|
||||
import { tcpipReaderDriver, wiegandReaderDriver } from "./reader.js";
|
||||
|
||||
@@ -12,6 +13,7 @@ let registered = false;
|
||||
export function registerBuiltinDrivers(): void {
|
||||
if (registered) return;
|
||||
registered = true;
|
||||
registry.register(uhppoteDriver);
|
||||
registry.register(zktecoDriver);
|
||||
registry.register(esp32RelayDriver);
|
||||
registry.register(wiegandReaderDriver);
|
||||
@@ -21,6 +23,7 @@ export function registerBuiltinDrivers(): void {
|
||||
}
|
||||
|
||||
export {
|
||||
uhppoteDriver,
|
||||
zktecoDriver,
|
||||
esp32RelayDriver,
|
||||
wiegandReaderDriver,
|
||||
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
// Minimal ambient types for the `uhppoted` CommonJS module (no bundled types).
|
||||
// Only the surface we use; extend as we adopt more of the API.
|
||||
// Upstream: https://github.com/uhppoted/uhppoted-lib-nodejs
|
||||
declare module "uhppoted" {
|
||||
export class Config {
|
||||
constructor(
|
||||
name?: string,
|
||||
bindAddr?: string,
|
||||
broadcastAddr?: string,
|
||||
listenAddr?: string,
|
||||
timeout?: number,
|
||||
controllers?: unknown[],
|
||||
debug?: boolean,
|
||||
);
|
||||
}
|
||||
|
||||
/** Either a bare controller serial, or an addressable descriptor. */
|
||||
export type Controller =
|
||||
| number
|
||||
| { id: number; address?: string; protocol?: "udp" | "tcp" };
|
||||
|
||||
export interface Ctx {
|
||||
config: Config;
|
||||
locale?: string;
|
||||
}
|
||||
|
||||
export function openDoor(
|
||||
ctx: Ctx,
|
||||
controller: Controller,
|
||||
door: number,
|
||||
): Promise<{ deviceId: number; opened: boolean }>;
|
||||
|
||||
export function getStatus(
|
||||
ctx: Ctx,
|
||||
controller: Controller,
|
||||
): Promise<Record<string, unknown>>;
|
||||
|
||||
export function getEvent(
|
||||
ctx: Ctx,
|
||||
controller: Controller,
|
||||
index: number,
|
||||
): Promise<Record<string, unknown>>;
|
||||
|
||||
export function getEventIndex(
|
||||
ctx: Ctx,
|
||||
controller: Controller,
|
||||
): Promise<{ deviceId: number; index: number }>;
|
||||
|
||||
export function setListener(
|
||||
ctx: Ctx,
|
||||
controller: Controller,
|
||||
address: string,
|
||||
port: number,
|
||||
): Promise<unknown>;
|
||||
|
||||
// CommonJS default export (module.exports = { ... }). Destructure from this.
|
||||
const uhppoted: {
|
||||
Config: typeof Config;
|
||||
openDoor: typeof openDoor;
|
||||
getStatus: typeof getStatus;
|
||||
getEvent: typeof getEvent;
|
||||
getEventIndex: typeof getEventIndex;
|
||||
setListener: typeof setListener;
|
||||
};
|
||||
export default uhppoted;
|
||||
}
|
||||
Reference in New Issue
Block a user