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"
|
"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,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
@@ -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;
|
||||||
|
}
|
||||||
Generated
+16
@@ -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: {}
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user