Remove UHPPOTE/ZKTeco; Dingtian is the only access driver

Neither UHPPOTE nor ZKTeco is used — the Dingtian relay controller was chosen
and verified. Remove their code and re-scope the wiki.

Code:
- delete access-uhppote.ts, uhppoted.d.ts, access.ts (zkteco/esp32-relay stubs),
  and the three uhppote-*.mjs hardware test scripts.
- remove the `uhppoted` npm dependency from @parking/devices and @parking/server.
- unregister uhppote/zkteco/esp32-relay from the driver registry; drop their
  exports. Catalog access drivers = dingtian only. Build green (5/5).
- refresh now-stale example comments (registry/interfaces/setup/api) to use
  current examples; keep the two "UHPPOTE blocker" references that explain why
  the precondition capability exists.

Wiki (kept pages, re-scoped):
- uhppote-controller, zkteco-controller -> rejected/historical with callouts;
  uhppote-vs-esp32 -> historical (detection-vs-prevention lens still useful).
- re-point all "current device" framing (standing-decisions, bom, overview,
  open-questions, device-registry, device-discovery, index) to dingtian-relay.
- transferable concepts (network-isolation, event-log-ingestion, barrier-not-a-
  door, threat-model) untouched. Raw source immutable. Links lint clean.
This commit is contained in:
2026-06-14 14:28:52 +02:00
parent 1b55e2034d
commit 355026dcf7
26 changed files with 106 additions and 711 deletions
+1 -2
View File
@@ -18,8 +18,7 @@
"lint": "tsc --noEmit"
},
"dependencies": {
"@parking/shared": "workspace:*",
"uhppoted": "0.9.0"
"@parking/shared": "workspace:*"
},
"devDependencies": {
"@types/node": "25.9.3",
@@ -1,263 +0,0 @@
import { networkInterfaces } from "node:os";
import uhppoted, { type Controller, type Ctx } from "uhppoted";
import type { AccessControlDevice, DeviceHealth } from "../interfaces.js";
import type {
AccessDriver,
DeviceConfig,
DiscoveredDevice,
} from "../registry.js";
import { hostField, stubLog } from "./common.js";
// `uhppoted` is CommonJS — import the default and destructure (named ESM imports
// don't resolve off a CJS module under NodeNext).
const { Config, getDevices, getStatus, openDoor } = uhppoted;
// Every uhppoted call binds a UDP listener on :60001 for replies. Concurrent
// calls collide on that port (EACCES / dropped replies → spurious timeouts), so
// we serialize ALL controller I/O through one queue. UDP request/response is
// fast, so serial throughput is fine for a parking host. This is why parallel
// discovery + health checks were timing out.
let chain: Promise<unknown> = Promise.resolve();
function serialize<T>(fn: () => Promise<T>): Promise<T> {
const run = chain.then(fn, fn);
// keep the chain alive regardless of this call's outcome
chain = run.then(
() => undefined,
() => undefined,
);
return run;
}
/**
* Compute subnet-directed broadcast addresses (e.g. 10.0.10.255) for every
* non-internal IPv4 interface.
*
* Why this matters: the uhppoted lib only enables SO_BROADCAST when the target
* matches a *subnet-directed* broadcast of a local interface — it does NOT
* recognise the global 255.255.255.255, so broadcasting there fails with EACCES.
* We must broadcast to the per-interface address (e.g. 10.0.10.255) instead.
*/
interface Iface {
network: number[]; // ip & mask, per octet
mask: number[];
broadcast: string;
}
function localIfaces(): Iface[] {
const out: Iface[] = [];
for (const ifaces of Object.values(networkInterfaces())) {
for (const i of ifaces ?? []) {
if (i.family !== "IPv4" || i.internal) continue;
const ip = i.address.split(".").map(Number);
const mask = i.netmask.split(".").map(Number);
if (ip.length !== 4 || mask.length !== 4) continue;
out.push({
network: ip.map((o, k) => o & mask[k]!),
mask,
broadcast: ip.map((o, k) => (o & mask[k]!) | (~mask[k]! & 0xff)).join("."),
});
}
}
return out;
}
function subnetBroadcastAddrs(): string[] {
return localIfaces().map((i) => i.broadcast);
}
/** Broadcast target for discovery: explicit override, else first subnet bcast. */
function discoveryBroadcast(): string {
return (
process.env.UHPPOTE_BROADCAST ?? subnetBroadcastAddrs()[0] ?? "255.255.255.255"
);
}
/**
* The subnet-directed broadcast for the interface that `host` belongs to. The
* uhppoted Config's broadcast address governs reply routing even for unicast
* ops, so it must match the TARGET's subnet (not just the first interface) or
* the reply is missed → timeout.
*/
function broadcastForHost(host: string): string {
const ip = host.split(".").map(Number);
if (ip.length === 4) {
for (const i of localIfaces()) {
if (ip.every((o, k) => (o & i.mask[k]!) === i.network[k])) return i.broadcast;
}
}
return discoveryBroadcast();
}
// 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.
/**
* uhppoted context broadcasting to a specific address on :60000, listening for
* replies on :60001.
*/
function buildCtxFor(broadcast: string, timeoutMs = 5000): Ctx {
return {
config: new Config(
"parking",
"0.0.0.0",
`${broadcast}:60000`,
"0.0.0.0:60001",
timeoutMs,
[],
false,
),
locale: "en-US",
};
}
/** Default context for non-discovery ops (status/open use a unicast host). */
function buildCtx(timeoutMs = 5000): Ctx {
return buildCtxFor(discoveryBroadcast(), timeoutMs);
}
/**
* Broadcast targets to try for discovery. An explicit UHPPOTE_BROADCAST wins;
* otherwise every local subnet-directed broadcast (a host may have several
* interfaces — LAN, VPN, docker — and the controller is on only one).
*/
function discoveryBroadcasts(): string[] {
const override = process.env.UHPPOTE_BROADCAST;
if (override) return [override];
const addrs = subnetBroadcastAddrs();
return addrs.length > 0 ? addrs : ["255.255.255.255"];
}
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;
// The Config broadcast must match the target host's subnet (it governs
// reply routing even for unicast), else replies are missed → timeout.
const timeoutMs = config.timeoutMs ? Number(config.timeoutMs) : 5000;
this.#ctx = address
? buildCtxFor(broadcastForHost(address), timeoutMs)
: buildCtx(timeoutMs);
}
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 serialize(() => 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 serialize(() =>
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 serialize(() => getStatus(this.#ctx, this.#controller));
return "closed";
}
}
export const uhppoteDriver: AccessDriver & {
discover(): Promise<DiscoveredDevice[]>;
} = {
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"],
// UDP broadcast discovery (get-devices): every controller on the LAN answers
// with its serial, IP, and firmware. Broadcasts on every local subnet (the
// controller is on only one interface) and dedupes by serial.
// See wiki/concepts/device-discovery.md.
async discover(): Promise<DiscoveredDevice[]> {
const bySerial = new Map<number, DiscoveredDevice>();
// Serial, not parallel: each getDevices binds :60001, so concurrent scans
// across interfaces collide (EACCES / dropped replies).
for (const bcast of discoveryBroadcasts()) {
let found;
try {
found = await serialize(() => getDevices(buildCtxFor(bcast, 3000)));
} catch {
continue; // a dead interface shouldn't fail the whole scan
}
for (const d of found) {
bySerial.set(d.device.serialNumber, {
id: String(d.device.serialNumber),
label: `UHPPOTE ${d.device.serialNumber} @ ${d.device.address}`,
config: { serial: d.device.serialNumber, host: d.device.address, protocol: "udp" },
info: {
address: d.device.address,
netmask: d.device.netmask,
gateway: d.device.gateway,
MAC: d.device.MAC,
firmware: d.device.version,
},
});
}
}
return [...bySerial.values()];
},
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),
};
-49
View File
@@ -1,49 +0,0 @@
import type { AccessControlDevice, DeviceHealth } from "../interfaces.js";
import type { AccessDriver, DeviceConfig } from "../registry.js";
import { hostField, portField, stubLog } from "./common.js";
// Access-control drivers. Each implements AccessControlDevice (intent-only relay
// — "a barrier is not a door"). STUBS: connect/log only, no real protocol yet.
class StubAccessControl implements AccessControlDevice {
constructor(
readonly driverId: string,
protected readonly config: DeviceConfig,
) {}
async connect(): Promise<void> {
stubLog(this.driverId, `connect ${this.config.host}:${this.config.port}`);
}
async disconnect(): Promise<void> {
stubLog(this.driverId, "disconnect");
}
async healthCheck(): Promise<DeviceHealth> {
return { status: "ready", detail: "stub" };
}
async pulseOpen(doorId: number): Promise<void> {
// Intent only — never times/forces a close against a vehicle.
stubLog(this.driverId, `pulseOpen door=${doorId}`);
}
async getDoorStatus(): Promise<"open" | "closed"> {
return "closed";
}
}
export const zktecoDriver: AccessDriver = {
id: "zkteco",
category: "access",
label: "ZKTeco controller",
description: "ZKTeco network access controller (TCP/IP). Reader + relay.",
transports: ["tcp-ip"],
configFields: [hostField, portField(4370), { key: "doors", label: "Door count", type: "number", required: true, default: 4 }],
create: (c) => new StubAccessControl("zkteco", c),
};
export const esp32RelayDriver: AccessDriver = {
id: "esp32-relay",
category: "access",
label: "ESP32 relay controller",
description: "Simple ESP32-based relay controller over the network.",
transports: ["tcp-ip"],
configFields: [hostField, portField(80), { key: "doors", label: "Relay channels", type: "number", required: true, default: 1 }],
create: (c) => new StubAccessControl("esp32-relay", c),
};
-8
View File
@@ -2,9 +2,7 @@
// module wires the catalog. Add a new device by registering it here.
import { registry } from "../registry.js";
import { esp32RelayDriver, zktecoDriver } from "./access.js";
import { dingtianDriver } from "./access-dingtian.js";
import { uhppoteDriver } from "./access-uhppote.js";
import { dahuaDriver, hikvisionDriver } from "./camera.js";
import { tcpipReaderDriver, wiegandReaderDriver } from "./reader.js";
@@ -14,10 +12,7 @@ let registered = false;
export function registerBuiltinDrivers(): void {
if (registered) return;
registered = true;
registry.register(uhppoteDriver);
registry.register(dingtianDriver);
registry.register(zktecoDriver);
registry.register(esp32RelayDriver);
registry.register(wiegandReaderDriver);
registry.register(tcpipReaderDriver);
registry.register(hikvisionDriver);
@@ -25,10 +20,7 @@ export function registerBuiltinDrivers(): void {
}
export {
uhppoteDriver,
dingtianDriver,
zktecoDriver,
esp32RelayDriver,
wiegandReaderDriver,
tcpipReaderDriver,
hikvisionDriver,
-83
View File
@@ -1,83 +0,0 @@
// 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 interface DiscoveredController {
deviceId: number;
device: {
serialNumber: number;
address: string;
netmask: string;
gateway: string;
MAC: string;
version: string;
date: string;
};
}
/** UDP broadcast discovery — returns every controller answering on the LAN. */
export function getDevices(ctx: Ctx): Promise<DiscoveredController[]>;
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;
getDevices: typeof getDevices;
openDoor: typeof openDoor;
getStatus: typeof getStatus;
getEvent: typeof getEvent;
getEventIndex: typeof getEventIndex;
setListener: typeof setListener;
};
export default uhppoted;
}
-3
View File
@@ -10,10 +10,7 @@ export { setDeviceLogSink, type DeviceLogSink } from "./drivers/common.js";
// hardware test scripts and any direct/programmatic device access).
export {
registerBuiltinDrivers,
uhppoteDriver,
dingtianDriver,
zktecoDriver,
esp32RelayDriver,
wiegandReaderDriver,
tcpipReaderDriver,
hikvisionDriver,
+2 -2
View File
@@ -14,7 +14,7 @@ export type DeviceCategory = "access" | "reader" | "camera" | "printer";
/** Lifecycle shared by every device adapter. */
export interface Device {
/** Stable id of the driver that produced this instance (e.g. "zkteco"). */
/** Stable id of the driver that produced this instance (e.g. "dingtian"). */
readonly driverId: string;
connect(): Promise<void>;
disconnect(): Promise<void>;
@@ -28,7 +28,7 @@ export interface DeviceHealth {
}
// --- Access control (barrier relay) --------------------------------------
// ZKTeco, an ESP32 relay controller, UHPPOTE, etc. all implement this.
// The Dingtian relay board (and any future relay controller) implements this.
export interface AccessControlDevice extends Device {
/** Express intent to open. NEVER timed/forced closed against a vehicle. */
pulseOpen(doorId: number): Promise<void>;
+7 -6
View File
@@ -35,9 +35,9 @@ export type DeviceConfig = Record<string, string | number | boolean>;
* fields the admin must supply, and a factory that builds a live adapter.
*/
export interface DeviceDriver<T extends Device = Device> {
readonly id: string; // stable, e.g. "zkteco", "esp32-relay", "hikvision"
readonly id: string; // stable, e.g. "dingtian", "hikvision"
readonly category: DeviceCategory;
readonly label: string; // human name for the picker, e.g. "ZKTeco controller"
readonly label: string; // human name for the picker, e.g. "Dingtian relay controller"
readonly description: string;
/** Transports/notes surfaced in the UI, e.g. ["tcp-ip"], ["wiegand"]. */
readonly transports: readonly string[];
@@ -53,7 +53,7 @@ export type PrinterDriver = DeviceDriver<PrinterDevice>;
/** A device found on the LAN by a driver's discovery scan. */
export interface DiscoveredDevice {
/** Identifier to pre-fill (e.g. UHPPOTE serial number). */
/** Identifier to pre-fill (e.g. a serial number). */
readonly id: string;
readonly label: string;
/** Config values to auto-fill into the setup form (host, serial, …). */
@@ -63,9 +63,10 @@ export interface DiscoveredDevice {
}
/**
* Optional capability: a driver that can find devices on the LAN. UHPPOTE
* implements this via the protocol's UDP broadcast discovery (get-devices);
* cameras (ONVIF) and others may add it later. See wiki/concepts/device-discovery.md.
* Optional capability: a driver that can find devices on the LAN (e.g. UDP
* broadcast discovery). No bundled driver implements this yet — the Dingtian
* board uses a fixed IP; cameras (ONVIF) or other UDP-discoverable devices may
* add it later. See wiki/concepts/device-discovery.md.
*/
export interface DiscoverableDriver {
discover(): Promise<DiscoveredDevice[]>;