diff --git a/packages/devices/package.json b/packages/devices/package.json index c40d611..28b4277 100644 --- a/packages/devices/package.json +++ b/packages/devices/package.json @@ -18,7 +18,8 @@ "lint": "tsc --noEmit" }, "dependencies": { - "@parking/shared": "workspace:*" + "@parking/shared": "workspace:*", + "uhppoted": "0.9.0" }, "devDependencies": { "typescript": "6.0.3" diff --git a/packages/devices/src/drivers/access-uhppote.ts b/packages/devices/src/drivers/access-uhppote.ts new file mode 100644 index 0000000..e29d1a0 --- /dev/null +++ b/packages/devices/src/drivers/access-uhppote.ts @@ -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 { + // 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 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 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), +}; diff --git a/packages/devices/src/drivers/index.ts b/packages/devices/src/drivers/index.ts index bff4293..c13ce3e 100644 --- a/packages/devices/src/drivers/index.ts +++ b/packages/devices/src/drivers/index.ts @@ -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, diff --git a/packages/devices/src/drivers/uhppoted.d.ts b/packages/devices/src/drivers/uhppoted.d.ts new file mode 100644 index 0000000..c15e808 --- /dev/null +++ b/packages/devices/src/drivers/uhppoted.d.ts @@ -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>; + + export function getEvent( + ctx: Ctx, + controller: Controller, + index: number, + ): Promise>; + + export function getEventIndex( + ctx: Ctx, + controller: Controller, + ): Promise<{ deviceId: number; index: number }>; + + export function setListener( + ctx: Ctx, + controller: Controller, + address: string, + port: number, + ): Promise; + + // 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; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ec15f52..494e932 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -113,6 +113,9 @@ importers: '@parking/shared': specifier: workspace:* version: link:../shared + uhppoted: + specifier: 0.9.0 + version: 0.9.0 devDependencies: typescript: specifier: 6.0.3 @@ -1244,6 +1247,9 @@ packages: once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + os@0.1.2: + resolution: {integrity: sha512-ZoXJkvAnljwvc56MbvhtKVWmSkzV712k42Is2mA0+0KTSRakq5XXuXpjZjgAt9ctzl51ojhQWakQQpmOvXWfjQ==} + path-scurry@2.0.2: resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} engines: {node: 18 || 20 || >=22} @@ -1446,6 +1452,10 @@ packages: engines: {node: '>=14.17'} hasBin: true + uhppoted@0.9.0: + resolution: {integrity: sha512-7VDPNg4x31TETgMD3xp9NwVr+NvmZJ6CO8gTpyuRrdHu/UBGXw9/9kq8yiB0vR4opaUQPdvR8Gj373Ac/QWPwQ==} + engines: {node: '>=14.18.3'} + undici-types@7.24.6: resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} @@ -2327,6 +2337,8 @@ snapshots: dependencies: wrappy: 1.0.2 + os@0.1.2: {} + path-scurry@2.0.2: dependencies: lru-cache: 11.5.1 @@ -2554,6 +2566,10 @@ snapshots: typescript@6.0.3: {} + uhppoted@0.9.0: + dependencies: + os: 0.1.2 + undici-types@7.24.6: {} util-deprecate@1.0.2: {} diff --git a/wiki/entities/uhppote-controller.md b/wiki/entities/uhppote-controller.md index 9330192..0f6f009 100644 --- a/wiki/entities/uhppote-controller.md +++ b/wiki/entities/uhppote-controller.md @@ -2,7 +2,7 @@ type: entity tags: [parking, hardware, access-control, current-choice] sources: [parking-system-architecture] -updated: 2026-06-14 +updated: 2026-06-15 --- # 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 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 - Combines reader input ([[wiegand]]) and door relays, with an onboard card list enabling diff --git a/wiki/log.md b/wiki/log.md index 419bd9e..f68d3ae 100644 --- a/wiki/log.md +++ b/wiki/log.md @@ -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 for now (no real vendor protocols yet). Verified catalog + assign + validation + 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.