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:
2026-06-14 08:12:27 +02:00
parent 72ba4099ea
commit 7438c0bdc2
7 changed files with 226 additions and 2 deletions
+2 -1
View File
@@ -18,7 +18,8 @@
"lint": "tsc --noEmit" "lint": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"@parking/shared": "workspace:*" "@parking/shared": "workspace:*",
"uhppoted": "0.9.0"
}, },
"devDependencies": { "devDependencies": {
"typescript": "6.0.3" "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
View File
@@ -3,6 +3,7 @@
import { registry } from "../registry.js"; import { registry } from "../registry.js";
import { esp32RelayDriver, zktecoDriver } from "./access.js"; import { esp32RelayDriver, zktecoDriver } from "./access.js";
import { uhppoteDriver } from "./access-uhppote.js";
import { dahuaDriver, hikvisionDriver } from "./camera.js"; import { dahuaDriver, hikvisionDriver } from "./camera.js";
import { tcpipReaderDriver, wiegandReaderDriver } from "./reader.js"; import { tcpipReaderDriver, wiegandReaderDriver } from "./reader.js";
@@ -12,6 +13,7 @@ let registered = false;
export function registerBuiltinDrivers(): void { export function registerBuiltinDrivers(): void {
if (registered) return; if (registered) return;
registered = true; registered = true;
registry.register(uhppoteDriver);
registry.register(zktecoDriver); registry.register(zktecoDriver);
registry.register(esp32RelayDriver); registry.register(esp32RelayDriver);
registry.register(wiegandReaderDriver); registry.register(wiegandReaderDriver);
@@ -21,6 +23,7 @@ export function registerBuiltinDrivers(): void {
} }
export { export {
uhppoteDriver,
zktecoDriver, zktecoDriver,
esp32RelayDriver, esp32RelayDriver,
wiegandReaderDriver, wiegandReaderDriver,
+66
View File
@@ -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;
}
+16
View File
@@ -113,6 +113,9 @@ importers:
'@parking/shared': '@parking/shared':
specifier: workspace:* specifier: workspace:*
version: link:../shared version: link:../shared
uhppoted:
specifier: 0.9.0
version: 0.9.0
devDependencies: devDependencies:
typescript: typescript:
specifier: 6.0.3 specifier: 6.0.3
@@ -1244,6 +1247,9 @@ packages:
once@1.4.0: once@1.4.0:
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
os@0.1.2:
resolution: {integrity: sha512-ZoXJkvAnljwvc56MbvhtKVWmSkzV712k42Is2mA0+0KTSRakq5XXuXpjZjgAt9ctzl51ojhQWakQQpmOvXWfjQ==}
path-scurry@2.0.2: path-scurry@2.0.2:
resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==}
engines: {node: 18 || 20 || >=22} engines: {node: 18 || 20 || >=22}
@@ -1446,6 +1452,10 @@ packages:
engines: {node: '>=14.17'} engines: {node: '>=14.17'}
hasBin: true hasBin: true
uhppoted@0.9.0:
resolution: {integrity: sha512-7VDPNg4x31TETgMD3xp9NwVr+NvmZJ6CO8gTpyuRrdHu/UBGXw9/9kq8yiB0vR4opaUQPdvR8Gj373Ac/QWPwQ==}
engines: {node: '>=14.18.3'}
undici-types@7.24.6: undici-types@7.24.6:
resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==}
@@ -2327,6 +2337,8 @@ snapshots:
dependencies: dependencies:
wrappy: 1.0.2 wrappy: 1.0.2
os@0.1.2: {}
path-scurry@2.0.2: path-scurry@2.0.2:
dependencies: dependencies:
lru-cache: 11.5.1 lru-cache: 11.5.1
@@ -2554,6 +2566,10 @@ snapshots:
typescript@6.0.3: {} typescript@6.0.3: {}
uhppoted@0.9.0:
dependencies:
os: 0.1.2
undici-types@7.24.6: {} undici-types@7.24.6: {}
util-deprecate@1.0.2: {} util-deprecate@1.0.2: {}
+10 -1
View File
@@ -2,7 +2,7 @@
type: entity type: entity
tags: [parking, hardware, access-control, current-choice] tags: [parking, hardware, access-control, current-choice]
sources: [parking-system-architecture] sources: [parking-system-architecture]
updated: 2026-06-14 updated: 2026-06-15
--- ---
# UHPPOTE Controller (current choice) # UHPPOTE Controller (current choice)
@@ -11,6 +11,15 @@ The starting access-control hardware: a **UHPPOTE Wiegand 26/34 network controll
a cheap reader-plus-relay frontend, acceptable **provided you understand its limits**. The plan a cheap reader-plus-relay frontend, acceptable **provided you understand its limits**. The plan
is UHPPOTE now → ZKTeco later (see [[bom]]). (See [[parking-system-architecture]] §6.) is UHPPOTE now → ZKTeco later (see [[bom]]). (See [[parking-system-architecture]] §6.)
> **Implementation:** integrated via the official **`uhppoted`** npm package (MIT, by the
> `uhppoted` org — `github.com/uhppoted/uhppoted-lib-nodejs`), added to `@parking/devices` as the
> `uhppote` access driver ([[device-registry]]). It exposes exactly the protocol commands this
> design needs: `openDoor`, `getStatus`, and the event-log set (`getEvent`, `getEventIndex`,
> `setEventIndex`, `recordSpecialEvents`) plus `setListener`/`listen` for auto-push — see
> [[event-log-ingestion]]. Transport defaults to **UDP** (broadcast `…:60000`), with optional
> per-call TCP on newer firmware. Note: the lib pulls one trivial extra dep (the npm `os`
> shim) and tends to use UDP broadcast, which needs socket broadcast permission on the host.
## What it is ## What it is
- Combines reader input ([[wiegand]]) and door relays, with an onboard card list enabling - Combines reader input ([[wiegand]]) and door relays, with an onboard card list enabling
+12
View File
@@ -28,3 +28,15 @@ snapshot-on-event), printer. Added a `CameraDevice` interface; new `lane_devices
+ `setup_state` tables (migration 0001); admin-only setup endpoints. Stub drivers + `setup_state` tables (migration 0001); admin-only setup endpoints. Stub drivers
for now (no real vendor protocols yet). Verified catalog + assign + validation + for now (no real vendor protocols yet). Verified catalog + assign + validation +
auth end to end. auth end to end.
## [2026-06-15] decision | UHPPOTE library chosen + real driver
Researched Node options for the UHPPOTE controller. Chose the official
**`uhppoted`** npm package (MIT, actively maintained, full API incl. openDoor,
get-event(s)/event-index, set-listener, restore-default — covers the whole
[[event-log-ingestion]] design). Rejected: raw-dgram DIY (reinvents the lib),
node-red-contrib-uhppoted (wrong model), Go REST sidecar (extra runtime). Added
it to @parking/devices and implemented a real `uhppote` [[uhppote-controller]]
access driver (pulseOpen→openDoor, healthCheck→getStatus), registered in the
catalog. CJS interop: default-import + destructure. Verified it builds, appears
in the catalog, and degrades to "offline" gracefully without hardware. Real
on-VLAN test still pending.