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:
@@ -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),
|
||||
};
|
||||
@@ -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),
|
||||
};
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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[]>;
|
||||
|
||||
Reference in New Issue
Block a user