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:
@@ -21,8 +21,7 @@
|
|||||||
"@parking/shared": "workspace:*",
|
"@parking/shared": "workspace:*",
|
||||||
"bcrypt": "6.0.0",
|
"bcrypt": "6.0.0",
|
||||||
"fastify": "5.8.5",
|
"fastify": "5.8.5",
|
||||||
"fastify-plugin": "6.0.0",
|
"fastify-plugin": "6.0.0"
|
||||||
"uhppoted": "0.9.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bcrypt": "6.0.0",
|
"@types/bcrypt": "6.0.0",
|
||||||
|
|||||||
@@ -1,72 +0,0 @@
|
|||||||
// Shared helpers for the UHPPOTE hardware test scripts.
|
|
||||||
// Run directly against the device (independent of the HTTP server).
|
|
||||||
//
|
|
||||||
// node apps/server/scripts/uhppote-listen.mjs
|
|
||||||
// node apps/server/scripts/uhppote-relay.mjs
|
|
||||||
//
|
|
||||||
// Env overrides:
|
|
||||||
// UHPPOTE_SERIAL controller serial (default 225088491)
|
|
||||||
// UHPPOTE_HOST controller IP (default 10.0.10.3)
|
|
||||||
// UHPPOTE_BCAST Config broadcast (default derived from HOST subnet)
|
|
||||||
// HOST_IP this host's IP the controller pushes events to
|
|
||||||
// (default: auto-detected interface on the controller's subnet)
|
|
||||||
|
|
||||||
import { networkInterfaces } from "node:os";
|
|
||||||
import { createRequire } from "node:module";
|
|
||||||
|
|
||||||
const require = createRequire(import.meta.url);
|
|
||||||
const uhppoted = require("uhppoted");
|
|
||||||
|
|
||||||
export const SERIAL = Number(process.env.UHPPOTE_SERIAL ?? 225088491);
|
|
||||||
export const HOST = process.env.UHPPOTE_HOST ?? "10.0.10.3";
|
|
||||||
|
|
||||||
/** Subnet-directed broadcast for the interface that owns `ip`. */
|
|
||||||
function broadcastForHost(ip) {
|
|
||||||
const o = ip.split(".").map(Number);
|
|
||||||
for (const ifaces of Object.values(networkInterfaces())) {
|
|
||||||
for (const i of ifaces ?? []) {
|
|
||||||
if (i.family !== "IPv4" || i.internal) continue;
|
|
||||||
const a = i.address.split(".").map(Number);
|
|
||||||
const m = i.netmask.split(".").map(Number);
|
|
||||||
if (o.every((x, k) => (x & m[k]) === (a[k] & m[k]))) {
|
|
||||||
return a.map((x, k) => (x & m[k]) | (~m[k] & 0xff)).join(".");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "255.255.255.255";
|
|
||||||
}
|
|
||||||
|
|
||||||
/** This host's own IP on the controller's subnet (where it should push events). */
|
|
||||||
export function hostIpOnControllerSubnet(ip = HOST) {
|
|
||||||
if (process.env.HOST_IP) return process.env.HOST_IP;
|
|
||||||
const o = ip.split(".").map(Number);
|
|
||||||
for (const ifaces of Object.values(networkInterfaces())) {
|
|
||||||
for (const i of ifaces ?? []) {
|
|
||||||
if (i.family !== "IPv4" || i.internal) continue;
|
|
||||||
const a = i.address.split(".").map(Number);
|
|
||||||
const m = i.netmask.split(".").map(Number);
|
|
||||||
if (o.every((x, k) => (x & m[k]) === (a[k] & m[k]))) return i.address;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const BCAST = process.env.UHPPOTE_BCAST ?? broadcastForHost(HOST);
|
|
||||||
|
|
||||||
export function makeCtx(timeoutMs = 5000) {
|
|
||||||
return {
|
|
||||||
config: new uhppoted.Config(
|
|
||||||
"parking",
|
|
||||||
"0.0.0.0",
|
|
||||||
`${BCAST}:60000`,
|
|
||||||
"0.0.0.0:60001",
|
|
||||||
timeoutMs,
|
|
||||||
[],
|
|
||||||
false,
|
|
||||||
),
|
|
||||||
locale: "en-US",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export const controller = { id: SERIAL, address: HOST, protocol: "udp" };
|
|
||||||
export { uhppoted };
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
// Live button/event listener for the UHPPOTE controller.
|
|
||||||
//
|
|
||||||
// Points the controller's event listener at THIS host, then prints each pushed
|
|
||||||
// event in real time. Press the door buttons on the controller and watch them
|
|
||||||
// appear. Ctrl-C to stop.
|
|
||||||
//
|
|
||||||
// node apps/server/scripts/uhppote-listen.mjs
|
|
||||||
|
|
||||||
import {
|
|
||||||
controller,
|
|
||||||
hostIpOnControllerSubnet,
|
|
||||||
makeCtx,
|
|
||||||
uhppoted,
|
|
||||||
} from "./uhppote-common.mjs";
|
|
||||||
|
|
||||||
const ctx = makeCtx();
|
|
||||||
|
|
||||||
const hostIp = hostIpOnControllerSubnet();
|
|
||||||
if (!hostIp) {
|
|
||||||
console.error("Could not determine this host's IP on the controller's subnet.");
|
|
||||||
console.error("Set HOST_IP=<your-ip-on-the-controller-LAN> and retry.");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`controller : ${controller.id} @ ${controller.address}`);
|
|
||||||
console.log(`this host : ${hostIp} (events will be pushed here on :60001)`);
|
|
||||||
|
|
||||||
// 0) Remember the controller's current listener so we can restore it on exit
|
|
||||||
// (it was pointing somewhere else, e.g. 10.0.10.241).
|
|
||||||
let prevListener = null;
|
|
||||||
try {
|
|
||||||
prevListener = await uhppoted.getListener(ctx, controller);
|
|
||||||
console.log(`prior listener: ${prevListener.address}:${prevListener.port} (will restore on exit)`);
|
|
||||||
} catch (e) {
|
|
||||||
console.warn("getListener (non-fatal):", e.code ?? e.message);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1) Tell the controller to push events to us.
|
|
||||||
try {
|
|
||||||
const r = await uhppoted.setListener(ctx, controller, hostIp, 60001);
|
|
||||||
console.log("setListener:", JSON.stringify(r));
|
|
||||||
} catch (e) {
|
|
||||||
console.error("setListener failed:", e.code ?? e.message);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2) (Best-effort) ensure door open/close + button events are recorded.
|
|
||||||
try {
|
|
||||||
await uhppoted.recordSpecialEvents(ctx, controller, true);
|
|
||||||
console.log("recordSpecialEvents: enabled");
|
|
||||||
} catch (e) {
|
|
||||||
console.warn("recordSpecialEvents (non-fatal):", e.code ?? e.message);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("\n── listening — press the door buttons on the controller ──\n");
|
|
||||||
|
|
||||||
function describe(ev) {
|
|
||||||
const e = ev?.state?.event ?? ev?.event;
|
|
||||||
const buttons = ev?.state?.buttons;
|
|
||||||
const doors = ev?.state?.doors;
|
|
||||||
const parts = [];
|
|
||||||
if (e) {
|
|
||||||
parts.push(
|
|
||||||
`event#${e.index} type=${e.type?.event ?? e.type?.code} door=${e.door} granted=${e.granted} reason="${e.reason?.reason ?? e.reason?.code}" @${e.timestamp}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (buttons) {
|
|
||||||
const pressed = Object.entries(buttons).filter(([, v]) => v).map(([k]) => k);
|
|
||||||
parts.push(`buttons=[${pressed.join(",") || "none"}]`);
|
|
||||||
}
|
|
||||||
if (doors) {
|
|
||||||
const open = Object.entries(doors).filter(([, v]) => v).map(([k]) => k);
|
|
||||||
parts.push(`doorsOpen=[${open.join(",") || "none"}]`);
|
|
||||||
}
|
|
||||||
return parts.join(" ");
|
|
||||||
}
|
|
||||||
|
|
||||||
uhppoted.listen(
|
|
||||||
ctx,
|
|
||||||
(event) => {
|
|
||||||
console.log(`[${new Date().toISOString()}] ${describe(event)}`);
|
|
||||||
},
|
|
||||||
(err) => {
|
|
||||||
console.error("listen error:", err?.message ?? err);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
process.on("SIGINT", async () => {
|
|
||||||
// Restore the controller's previous listener so we don't hijack it.
|
|
||||||
if (prevListener && prevListener.address && prevListener.address !== "0.0.0.0") {
|
|
||||||
try {
|
|
||||||
await uhppoted.setListener(ctx, controller, prevListener.address, prevListener.port);
|
|
||||||
console.log(`\nrestored listener -> ${prevListener.address}:${prevListener.port}`);
|
|
||||||
} catch (e) {
|
|
||||||
console.warn("\ncould not restore listener:", e.code ?? e.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
console.log("stopped.");
|
|
||||||
process.exit(0);
|
|
||||||
});
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
// Guarded relay (door-open) test for the UHPPOTE controller.
|
|
||||||
//
|
|
||||||
// Prompts before firing each relay so a door only opens when you're ready and
|
|
||||||
// watching. This is a 2-door controller, so it tests doors 1 and 2 by default.
|
|
||||||
//
|
|
||||||
// node apps/server/scripts/uhppote-relay.mjs # doors 1,2 (prompted)
|
|
||||||
// node apps/server/scripts/uhppote-relay.mjs 1 # only door 1
|
|
||||||
// YES=1 node apps/server/scripts/uhppote-relay.mjs # no prompts (fires!)
|
|
||||||
//
|
|
||||||
// SAFETY: openDoor only expresses INTENT to open. The controller / barrier
|
|
||||||
// operator owns the close timing and anti-crush — we never time a close.
|
|
||||||
|
|
||||||
import { createInterface } from "node:readline/promises";
|
|
||||||
import { stdin, stdout } from "node:process";
|
|
||||||
import { controller, makeCtx, uhppoted } from "./uhppote-common.mjs";
|
|
||||||
|
|
||||||
const ctx = makeCtx();
|
|
||||||
const doors = process.argv.slice(2).map(Number).filter((n) => n >= 1 && n <= 4);
|
|
||||||
const targets = doors.length ? doors : [1, 2];
|
|
||||||
const autoYes = process.env.YES === "1";
|
|
||||||
|
|
||||||
console.log(`controller : ${controller.id} @ ${controller.address}`);
|
|
||||||
console.log(`testing doors: ${targets.join(", ")}${autoYes ? " (auto, no prompts)" : ""}\n`);
|
|
||||||
|
|
||||||
const rl = autoYes ? null : createInterface({ input: stdin, output: stdout });
|
|
||||||
|
|
||||||
for (const door of targets) {
|
|
||||||
if (rl) {
|
|
||||||
const ans = await rl.question(`Open door ${door}? [y/N] `);
|
|
||||||
if (ans.trim().toLowerCase() !== "y") {
|
|
||||||
console.log(` skipped door ${door}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const res = await uhppoted.openDoor(ctx, controller, door);
|
|
||||||
console.log(` door ${door}: openDoor -> ${JSON.stringify(res)}`);
|
|
||||||
} catch (e) {
|
|
||||||
console.log(` door ${door}: ERROR ${e.code ?? e.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
rl?.close();
|
|
||||||
console.log("\ndone.");
|
|
||||||
@@ -28,14 +28,14 @@ export async function setupRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
|||||||
const adminGuard = requireRole("admin");
|
const adminGuard = requireRole("admin");
|
||||||
|
|
||||||
// Catalog of selectable drivers per category (no secrets — schema only).
|
// Catalog of selectable drivers per category (no secrets — schema only).
|
||||||
// `discoverable` flags drivers that can scan the LAN (e.g. UHPPOTE).
|
// `discoverable` flags drivers that can scan the LAN.
|
||||||
app.get("/api/setup/catalog", async () => {
|
app.get("/api/setup/catalog", async () => {
|
||||||
const catalog = registry.catalog();
|
const catalog = registry.catalog();
|
||||||
const discoverable = registry.list().filter(isDiscoverable).map((d) => d.id);
|
const discoverable = registry.list().filter(isDiscoverable).map((d) => d.id);
|
||||||
return { ...catalog, discoverable };
|
return { ...catalog, discoverable };
|
||||||
});
|
});
|
||||||
|
|
||||||
// Scan the LAN for devices a driver can discover (UHPPOTE UDP broadcast, etc).
|
// Scan the LAN for devices a driver can discover (UDP broadcast, etc).
|
||||||
// Each found device is health-checked so the admin sees reachability before
|
// Each found device is health-checked so the admin sees reachability before
|
||||||
// assigning. Admin-only. See wiki/concepts/device-discovery.md.
|
// assigning. Admin-only. See wiki/concepts/device-discovery.md.
|
||||||
app.get<{ Params: { driverId: string } }>(
|
app.get<{ Params: { driverId: string } }>(
|
||||||
|
|||||||
+1
-1
@@ -110,7 +110,7 @@ export interface DiscoveredDevice {
|
|||||||
health: { status: string; detail?: string };
|
health: { status: string; detail?: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Scan the LAN for devices a driver can discover (e.g. UHPPOTE). Admin-only. */
|
/** Scan the LAN for devices a driver can discover. Admin-only. */
|
||||||
export async function discoverDevices(driverId: string): Promise<DiscoveredDevice[]> {
|
export async function discoverDevices(driverId: string): Promise<DiscoveredDevice[]> {
|
||||||
const body = await apiFetch<{ devices: DiscoveredDevice[] }>(
|
const body = await apiFetch<{ devices: DiscoveredDevice[] }>(
|
||||||
`/api/setup/discover/${driverId}`,
|
`/api/setup/discover/${driverId}`,
|
||||||
|
|||||||
@@ -18,8 +18,7 @@
|
|||||||
"lint": "tsc --noEmit"
|
"lint": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@parking/shared": "workspace:*",
|
"@parking/shared": "workspace:*"
|
||||||
"uhppoted": "0.9.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "25.9.3",
|
"@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.
|
// module wires the catalog. Add a new device by registering it here.
|
||||||
|
|
||||||
import { registry } from "../registry.js";
|
import { registry } from "../registry.js";
|
||||||
import { esp32RelayDriver, zktecoDriver } from "./access.js";
|
|
||||||
import { dingtianDriver } from "./access-dingtian.js";
|
import { dingtianDriver } from "./access-dingtian.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";
|
||||||
|
|
||||||
@@ -14,10 +12,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(dingtianDriver);
|
registry.register(dingtianDriver);
|
||||||
registry.register(zktecoDriver);
|
|
||||||
registry.register(esp32RelayDriver);
|
|
||||||
registry.register(wiegandReaderDriver);
|
registry.register(wiegandReaderDriver);
|
||||||
registry.register(tcpipReaderDriver);
|
registry.register(tcpipReaderDriver);
|
||||||
registry.register(hikvisionDriver);
|
registry.register(hikvisionDriver);
|
||||||
@@ -25,10 +20,7 @@ export function registerBuiltinDrivers(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
uhppoteDriver,
|
|
||||||
dingtianDriver,
|
dingtianDriver,
|
||||||
zktecoDriver,
|
|
||||||
esp32RelayDriver,
|
|
||||||
wiegandReaderDriver,
|
wiegandReaderDriver,
|
||||||
tcpipReaderDriver,
|
tcpipReaderDriver,
|
||||||
hikvisionDriver,
|
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).
|
// hardware test scripts and any direct/programmatic device access).
|
||||||
export {
|
export {
|
||||||
registerBuiltinDrivers,
|
registerBuiltinDrivers,
|
||||||
uhppoteDriver,
|
|
||||||
dingtianDriver,
|
dingtianDriver,
|
||||||
zktecoDriver,
|
|
||||||
esp32RelayDriver,
|
|
||||||
wiegandReaderDriver,
|
wiegandReaderDriver,
|
||||||
tcpipReaderDriver,
|
tcpipReaderDriver,
|
||||||
hikvisionDriver,
|
hikvisionDriver,
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export type DeviceCategory = "access" | "reader" | "camera" | "printer";
|
|||||||
|
|
||||||
/** Lifecycle shared by every device adapter. */
|
/** Lifecycle shared by every device adapter. */
|
||||||
export interface Device {
|
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;
|
readonly driverId: string;
|
||||||
connect(): Promise<void>;
|
connect(): Promise<void>;
|
||||||
disconnect(): Promise<void>;
|
disconnect(): Promise<void>;
|
||||||
@@ -28,7 +28,7 @@ export interface DeviceHealth {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- Access control (barrier relay) --------------------------------------
|
// --- 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 {
|
export interface AccessControlDevice extends Device {
|
||||||
/** Express intent to open. NEVER timed/forced closed against a vehicle. */
|
/** Express intent to open. NEVER timed/forced closed against a vehicle. */
|
||||||
pulseOpen(doorId: number): Promise<void>;
|
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.
|
* fields the admin must supply, and a factory that builds a live adapter.
|
||||||
*/
|
*/
|
||||||
export interface DeviceDriver<T extends Device = Device> {
|
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 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;
|
readonly description: string;
|
||||||
/** Transports/notes surfaced in the UI, e.g. ["tcp-ip"], ["wiegand"]. */
|
/** Transports/notes surfaced in the UI, e.g. ["tcp-ip"], ["wiegand"]. */
|
||||||
readonly transports: readonly string[];
|
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. */
|
/** A device found on the LAN by a driver's discovery scan. */
|
||||||
export interface DiscoveredDevice {
|
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 id: string;
|
||||||
readonly label: string;
|
readonly label: string;
|
||||||
/** Config values to auto-fill into the setup form (host, serial, …). */
|
/** 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
|
* Optional capability: a driver that can find devices on the LAN (e.g. UDP
|
||||||
* implements this via the protocol's UDP broadcast discovery (get-devices);
|
* broadcast discovery). No bundled driver implements this yet — the Dingtian
|
||||||
* cameras (ONVIF) and others may add it later. See wiki/concepts/device-discovery.md.
|
* 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 {
|
export interface DiscoverableDriver {
|
||||||
discover(): Promise<DiscoveredDevice[]>;
|
discover(): Promise<DiscoveredDevice[]>;
|
||||||
|
|||||||
Generated
-19
@@ -50,9 +50,6 @@ importers:
|
|||||||
fastify-plugin:
|
fastify-plugin:
|
||||||
specifier: 6.0.0
|
specifier: 6.0.0
|
||||||
version: 6.0.0
|
version: 6.0.0
|
||||||
uhppoted:
|
|
||||||
specifier: 0.9.0
|
|
||||||
version: 0.9.0
|
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@types/bcrypt':
|
'@types/bcrypt':
|
||||||
specifier: 6.0.0
|
specifier: 6.0.0
|
||||||
@@ -122,9 +119,6 @@ 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:
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: 25.9.3
|
specifier: 25.9.3
|
||||||
@@ -1262,9 +1256,6 @@ 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}
|
||||||
@@ -1467,10 +1458,6 @@ 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==}
|
||||||
|
|
||||||
@@ -2357,8 +2344,6 @@ 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
|
||||||
@@ -2586,10 +2571,6 @@ 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: {}
|
||||||
|
|||||||
@@ -20,17 +20,23 @@ device carries an `id`, a `label`, a `config` blob to **auto-fill** the setup fo
|
|||||||
(firmware, MAC, …). The [[device-registry]]'s `isDiscoverable()` guard lets the system treat it
|
(firmware, MAC, …). The [[device-registry]]'s `isDiscoverable()` guard lets the system treat it
|
||||||
as optional; the setup catalog returns a `discoverable` list of driver ids.
|
as optional; the setup catalog returns a `discoverable` list of driver ids.
|
||||||
|
|
||||||
## UHPPOTE discovery
|
> **No current driver implements discovery.** The [[dingtian-relay]] board uses a fixed IP
|
||||||
|
> (entered/known at setup). The capability remains for future UDP-discoverable devices (cameras
|
||||||
|
> via ONVIF, etc.). The worked example below is the (removed) UHPPOTE driver — kept because the
|
||||||
|
> **broadcast gotchas are transferable** to any UDP discovery we add later.
|
||||||
|
|
||||||
The [[uhppote-controller]] supports discovery natively: a **UDP broadcast** (`get-devices` on
|
## UHPPOTE discovery (historical example)
|
||||||
|
|
||||||
|
The [[uhppote-controller]] supported discovery natively: a **UDP broadcast** (`get-devices` on
|
||||||
port `60000`) that **every controller on the LAN answers** with its serial, IP, netmask, gateway,
|
port `60000`) that **every controller on the LAN answers** with its serial, IP, netmask, gateway,
|
||||||
MAC, firmware version, and date. The official `uhppoted` lib exposes this as `getDevices(ctx)`;
|
MAC, firmware version, and date. The `uhppoted` lib exposed this as `getDevices(ctx)`; the
|
||||||
the `uhppote` driver maps each result into a `DiscoveredDevice` (serial → id, IP → host).
|
(now-removed) `uhppote` driver mapped each result into a `DiscoveredDevice` (serial → id, IP →
|
||||||
**Verified on real hardware** (serial 225088491).
|
host). **Was verified on real hardware** (serial 225088491).
|
||||||
|
|
||||||
### Broadcast gotchas (learned the hard way — see [[wsl-dev-networking]])
|
### Broadcast gotchas (learned the hard way — see [[wsl-dev-networking]])
|
||||||
|
|
||||||
These cost real debugging time; the `uhppote` driver now handles all three:
|
These cost real debugging time; the (removed) `uhppote` driver handled all three, and any future
|
||||||
|
UDP-discovery driver will need to as well:
|
||||||
|
|
||||||
1. **Broadcast to the *subnet-directed* address, not the global `255.255.255.255`.** The
|
1. **Broadcast to the *subnet-directed* address, not the global `255.255.255.255`.** The
|
||||||
`uhppoted` lib only calls `setBroadcast(true)` when the target matches a **local interface's
|
`uhppoted` lib only calls `setBroadcast(true)` when the target matches a **local interface's
|
||||||
|
|||||||
@@ -38,7 +38,8 @@ driver; **no business-logic change** — this is the [[device-adapter-pattern]]
|
|||||||
- Config is **validated against the driver's declared fields** before persisting.
|
- Config is **validated against the driver's declared fields** before persisting.
|
||||||
- Selections persist in the `lane_devices` table and drive runtime adapter construction.
|
- Selections persist in the `lane_devices` table and drive runtime adapter construction.
|
||||||
- Drivers may optionally implement **[[device-discovery]]** (`discover()`), so the admin can scan
|
- Drivers may optionally implement **[[device-discovery]]** (`discover()`), so the admin can scan
|
||||||
the LAN instead of typing connection details — UHPPOTE does this today.
|
the LAN instead of typing connection details — no current driver uses it (the UHPPOTE did,
|
||||||
|
before removal; the [[dingtian-relay]] uses a fixed IP).
|
||||||
|
|
||||||
Cameras are modelled as **snapshot-on-event**: the host requests an image at entry/exit; it's
|
Cameras are modelled as **snapshot-on-event**: the host requests an image at entry/exit; it's
|
||||||
stored and referenced from the signed event as an **independent record** — a fraud-control input
|
stored and referenced from the signed event as an **independent record** — a fraud-control input
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ updated: 2026-06-14
|
|||||||
|
|
||||||
# UHPPOTE vs. Custom ESP32 — Detection vs. Prevention
|
# UHPPOTE vs. Custom ESP32 — Detection vs. Prevention
|
||||||
|
|
||||||
|
> **Historical comparison.** Neither is the current device — the [[uhppote-controller]] was
|
||||||
|
> **rejected** (entry-flow blocker → [[dingtian-relay]] chosen) and the [[esp32-custom-controller]]
|
||||||
|
> is **deferred**. Kept because the **detection-vs-prevention** framing on the [[trust-boundary]]
|
||||||
|
> fork is a durable lens that applies to any access device.
|
||||||
|
|
||||||
A head-to-head on the [[trust-boundary]] fork: the off-the-shelf [[uhppote-controller]] versus
|
A head-to-head on the [[trust-boundary]] fork: the off-the-shelf [[uhppote-controller]] versus
|
||||||
the [[esp32-custom-controller]]. (Synthesized from [[parking-system-architecture]] §6–7.)
|
the [[esp32-custom-controller]]. (Synthesized from [[parking-system-architecture]] §6–7.)
|
||||||
|
|
||||||
@@ -23,9 +28,10 @@ the [[esp32-custom-controller]]. (Synthesized from [[parking-system-architecture
|
|||||||
|
|
||||||
## Bottom line
|
## Bottom line
|
||||||
|
|
||||||
- The UHPPOTE is the **current choice**: good enough as a detection/audit layer **when only the
|
- The UHPPOTE was the **detection-grade** option: good enough as a detection/audit layer **when
|
||||||
host can reach it** (isolation) and every event lands in the [[append-only-event-chain]].
|
only the host can reach it** (isolation) and every event lands in the [[append-only-event-chain]]
|
||||||
- The ESP32 is the **documented upgrade** when you need a control path that holds even against an
|
— but it was rejected for the entry lane (the button blocker).
|
||||||
attacker on the wire. They're **mixable per lane**.
|
- The ESP32 is the **prevention-grade** option when you need a control path that holds even against
|
||||||
|
an attacker on the wire. Deferred.
|
||||||
- Both still rely on host-side integrity ([[append-only-event-chain]]) and external
|
- Both still rely on host-side integrity ([[append-only-event-chain]]) and external
|
||||||
[[reconciliation]] as the ultimate anti-fraud control.
|
[[reconciliation]] as the ultimate anti-fraud control.
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ status: open
|
|||||||
later" currently leaves a disk failure as **total revenue-history loss**.
|
later" currently leaves a disk failure as **total revenue-history loss**.
|
||||||
6. **Secure-element integration.** Confirm [[atecc608]] wiring/usage on the host (event
|
6. **Secure-element integration.** Confirm [[atecc608]] wiring/usage on the host (event
|
||||||
signing). The [[esp32-custom-controller]] command-authentication use is **deferred — not
|
signing). The [[esp32-custom-controller]] command-authentication use is **deferred — not
|
||||||
being implemented for now** (access control stays on the [[uhppote-controller]] behind
|
being implemented for now** (access control is the [[dingtian-relay]] behind
|
||||||
[[network-isolation]]); revisit only if prevention-grade device auth becomes a requirement.
|
[[network-isolation]]); revisit only if prevention-grade device auth becomes a requirement.
|
||||||
7. **JWT signing: symmetric vs. asymmetric key.** _(Raised by the commit security review, not the
|
7. **JWT signing: symmetric vs. asymmetric key.** _(Raised by the commit security review, not the
|
||||||
source doc.)_ Auth currently uses a symmetric HMAC secret (`@fastify/jwt`, see
|
source doc.)_ Auth currently uses a symmetric HMAC secret (`@fastify/jwt`, see
|
||||||
|
|||||||
@@ -19,10 +19,12 @@ The decisions treated as settled in the design notes. (See [[parking-system-arch
|
|||||||
- **Integrity:** append-only, hash-chained, [[atecc608]]-signed event log
|
- **Integrity:** append-only, hash-chained, [[atecc608]]-signed event log
|
||||||
([[append-only-event-chain]]); **[[reconciliation]] is the anti-fraud control**; encryption
|
([[append-only-event-chain]]); **[[reconciliation]] is the anti-fraud control**; encryption
|
||||||
protects only at-rest (see [[threat-model]]).
|
protects only at-rest (see [[threat-model]]).
|
||||||
- **Access control:** [[uhppote-controller]] for now, on an **isolated VLAN**
|
- **Access control:** the **[[dingtian-relay]]** relay+input controller, on an **isolated VLAN**
|
||||||
([[network-isolation]]); event log used as a tamper-evident audit source with host-side index
|
([[network-isolation]]). Chosen because its **inputs are decoupled from its relays**, enabling
|
||||||
tracking ([[event-log-ingestion]]). The [[esp32-custom-controller]] is the documented
|
host-in-the-loop ticket-first entry — the resolution to [[access-controller-button-flow]].
|
||||||
prevention-grade upgrade path (the [[trust-boundary]] fork).
|
(The [[uhppote-controller]] and [[zkteco-controller]] were evaluated and **rejected** — kept as
|
||||||
|
historical record. The [[esp32-custom-controller]] remains the documented prevention-grade
|
||||||
|
alternative — the [[trust-boundary]] fork.)
|
||||||
- **Readers:** prefer [[wiegand]]-into-controller for permit holders (autonomous); host-in-the-loop
|
- **Readers:** prefer [[wiegand]]-into-controller for permit holders (autonomous); host-in-the-loop
|
||||||
for [[lpr-camera|LPR]]/QR/pure-network readers; both can share a relay (see
|
for [[lpr-camera|LPR]]/QR/pure-network readers; both can share a relay (see
|
||||||
[[entry-exit-readers]]).
|
[[entry-exit-readers]]).
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ payment terminal is dictated by the acquiring bank. (See [[parking-system-archit
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| Barrier operator | Magnetic Autocontrol / FAAC / CAME / Nice | Owns physical safety in firmware ([[barrier-not-a-door]]) |
|
| Barrier operator | Magnetic Autocontrol / FAAC / CAME / Nice | Owns physical safety in firmware ([[barrier-not-a-door]]) |
|
||||||
| Induction loops | Feig / BEA / EMX | Safety + free-exit detection |
|
| Induction loops | Feig / BEA / EMX | Safety + free-exit detection |
|
||||||
| Access controller | [[uhppote-controller]] now → ZKTeco later | Reader + relay; **isolate the VLAN** ([[network-isolation]]) |
|
| Access controller | [[dingtian-relay]] relay+input board | Decoupled inputs (host-in-the-loop); **isolate the VLAN** ([[network-isolation]]). ([[uhppote-controller]]/[[zkteco-controller]] rejected) |
|
||||||
| Permit readers | Nedap/Kathrein UHF, or Mifare → [[wiegand]] | Hands-free, or autonomous offline decisions |
|
| Permit readers | Nedap/Kathrein UHF, or Mifare → [[wiegand]] | Hands-free, or autonomous offline decisions |
|
||||||
| Casual identity | [[lpr-camera]] (Milesight, edge AI) | Plate = ticket + independent record |
|
| Casual identity | [[lpr-camera]] (Milesight, edge AI) | Plate = ticket + independent record |
|
||||||
| Ticket dispenser | Custom VKP80 | Parking-grade thermal/ESC-POS |
|
| Ticket dispenser | Custom VKP80 | Parking-grade thermal/ESC-POS |
|
||||||
|
|||||||
@@ -1,33 +1,37 @@
|
|||||||
---
|
---
|
||||||
type: entity
|
type: entity
|
||||||
tags: [parking, hardware, access-control, current-choice]
|
tags: [parking, hardware, access-control, rejected, historical]
|
||||||
sources: [parking-system-architecture]
|
sources: [parking-system-architecture]
|
||||||
updated: 2026-06-15
|
updated: 2026-06-15
|
||||||
---
|
---
|
||||||
|
|
||||||
# UHPPOTE Controller (current choice)
|
# UHPPOTE Controller (rejected — historical)
|
||||||
|
|
||||||
The starting access-control hardware: a **UHPPOTE Wiegand 26/34 network controller (4-door)** —
|
> **❌ NOT USED. Replaced by the [[dingtian-relay]] controller** (and its driver/test code
|
||||||
a cheap reader-plus-relay frontend, acceptable **provided you understand its limits**. The plan
|
> removed). Kept as the record of *why* — its firmware-fixed push-button blocker
|
||||||
is UHPPOTE now → ZKTeco later (see [[bom]]). (See [[parking-system-architecture]] §6.)
|
> ([[access-controller-button-flow]]) is what drove the switch to a board with decoupled inputs.
|
||||||
|
> The transferable lessons below (network isolation, append-only log ingestion, "a barrier is not
|
||||||
|
> a door") still apply to any access device.
|
||||||
|
|
||||||
> **⚠️ Entry-flow blocker (verified on hardware):** the push-button input **auto-opens the relay
|
The original starting hardware: a **UHPPOTE Wiegand 26/34 network controller (4-door)** — a cheap
|
||||||
> in firmware** — there's no command to make it report-without-opening — so it **cannot** do
|
reader-plus-relay frontend. (See [[parking-system-architecture]] §6.)
|
||||||
> ticket-first entry (`button → print → open`). Fine as a host-**commanded relay** and for
|
|
||||||
> [[wiegand]]/permit lanes, but **not** the button-driven entry lane as wired. Full detail and
|
> **⚠️ The fatal limit (verified on hardware):** the push-button input **auto-opens the relay in
|
||||||
> options: [[access-controller-button-flow]].
|
> firmware** — no command makes it report-without-opening — so it **cannot** do ticket-first entry
|
||||||
|
> (`button → print → open`). This is *the* reason it was dropped: full detail and the resolution in
|
||||||
|
> [[access-controller-button-flow]].
|
||||||
>
|
>
|
||||||
> **Verified working on the real unit** (serial 225088491, fw 09120): host-commanded `openDoor`
|
> **What was verified on the real unit** (serial 225088491, fw 09120) before retiring it:
|
||||||
> on doors 1 & 2 (physically actuated, `reason="remote open door"`); button presses captured live
|
> host-commanded `openDoor` on doors 1 & 2 (physically actuated, `reason="remote open door"`);
|
||||||
> (`reason="push button ok"`); [[device-discovery]] scan. Test scripts: `apps/server/scripts/`.
|
> button presses captured live (`reason="push button ok"`); UDP-broadcast [[device-discovery]].
|
||||||
|
> The driver, `uhppoted` dependency, and test scripts have since been removed from the codebase.
|
||||||
|
|
||||||
> **Implementation:** integrated via the official **`uhppoted`** npm package (MIT, by the
|
> **Past implementation (removed):** was integrated via the official **`uhppoted`** npm package
|
||||||
> `uhppoted` org — `github.com/uhppoted/uhppoted-lib-nodejs`), added to `@parking/devices` as the
|
> (MIT — `github.com/uhppoted/uhppoted-lib-nodejs`) as the `uhppote` access driver. It exposed
|
||||||
> `uhppote` access driver ([[device-registry]]). It exposes exactly the protocol commands this
|
> exactly the protocol commands the design needs: `openDoor`, `getStatus`, and the event-log set
|
||||||
> design needs: `openDoor`, `getStatus`, and the event-log set (`getEvent`, `getEventIndex`,
|
> (`getEvent`, `getEventIndex`, `setEventIndex`, `recordSpecialEvents`) plus `setListener`/`listen`
|
||||||
> `setEventIndex`, `recordSpecialEvents`) plus `setListener`/`listen` for auto-push — see
|
> for auto-push — see [[event-log-ingestion]]. Transport defaulted to **UDP** (broadcast `…:60000`),
|
||||||
> [[event-log-ingestion]]. Transport defaults to **UDP** (broadcast `…:60000`), with optional
|
> with optional per-call TCP on newer firmware. The driver also implemented **[[device-discovery]]**
|
||||||
> per-call TCP on newer firmware. The driver also implements **[[device-discovery]]**
|
|
||||||
> (`getDevices` broadcast) so the setup wizard can scan for controllers. Note: the lib pulls one
|
> (`getDevices` broadcast) so the setup wizard can scan for controllers. Note: the lib pulls one
|
||||||
> trivial extra dep (the npm `os` shim) and uses UDP broadcast, which needs socket broadcast
|
> trivial extra dep (the npm `os` shim) and uses UDP broadcast, which needs socket broadcast
|
||||||
> permission on the host.
|
> permission on the host.
|
||||||
|
|||||||
@@ -1,20 +1,24 @@
|
|||||||
---
|
---
|
||||||
type: entity
|
type: entity
|
||||||
tags: [parking, hardware, access-control]
|
tags: [parking, hardware, access-control, rejected, historical]
|
||||||
sources: [parking-system-architecture]
|
sources: [parking-system-architecture]
|
||||||
updated: 2026-06-15
|
updated: 2026-06-15
|
||||||
---
|
---
|
||||||
|
|
||||||
# ZKTeco Controller
|
# ZKTeco Controller (rejected — historical)
|
||||||
|
|
||||||
A network access controller (C3 / inBio families) — the documented "UHPPOTE now → ZKTeco later"
|
> **❌ NOT USED.** Was considered as the access controller; the **[[dingtian-relay]]** board was
|
||||||
upgrade in the [[bom]]. A `zkteco` driver **stub** exists in the [[device-registry]] but the
|
> chosen instead (decoupled inputs, already verified). The `zkteco` driver **stub** has been
|
||||||
**real protocol is not implemented** (see below).
|
> **removed** from the codebase. Kept for the record of the comparison below.
|
||||||
|
|
||||||
## Relevance to the entry-flow blocker
|
A network access controller (C3 / inBio families), originally the documented "UHPPOTE now →
|
||||||
|
ZKTeco later" upgrade in the [[bom]].
|
||||||
|
|
||||||
|
## Why it was a contender (vs. UHPPOTE)
|
||||||
|
|
||||||
ZKTeco is **better positioned** than the [[uhppote-controller]] for host-in-the-loop entry (the
|
ZKTeco is **better positioned** than the [[uhppote-controller]] for host-in-the-loop entry (the
|
||||||
[[access-controller-button-flow]] blocker), but this is **unverified on our hardware**:
|
[[access-controller-button-flow]] blocker) — but it was **never verified on our hardware**, and the
|
||||||
|
Dingtian solved the problem first with less effort:
|
||||||
|
|
||||||
- Its **auxiliary inputs** have **programmable linkage** (ZKBioSecurity software / PULL SDK) and
|
- Its **auxiliary inputs** have **programmable linkage** (ZKBioSecurity software / PULL SDK) and
|
||||||
are **not** hardwired to "open door" — so a button on an *aux* input can raise a host event
|
are **not** hardwired to "open door" — so a button on an *aux* input can raise a host event
|
||||||
|
|||||||
+4
-4
@@ -32,13 +32,13 @@ Counts: 1 source · 14 entities · 10 concepts · 2 decision records.
|
|||||||
- [[logto-zitadel-oidc]] — OIDC providers ruled out by offline-first.
|
- [[logto-zitadel-oidc]] — OIDC providers ruled out by offline-first.
|
||||||
|
|
||||||
## Entities — hardware & devices
|
## Entities — hardware & devices
|
||||||
- [[uhppote-controller]] — current access controller; cheap, tamper-evident, open-UDP, fixed firmware.
|
- [[uhppote-controller]] — ❌ rejected/historical; firmware auto-open blocker drove the switch to Dingtian.
|
||||||
- [[esp32-custom-controller]] — prevention-grade upgrade; device-level auth.
|
- [[esp32-custom-controller]] — prevention-grade upgrade; device-level auth.
|
||||||
- [[atecc608]] — secure element; non-extractable signing key (host events + controller auth).
|
- [[atecc608]] — secure element; non-extractable signing key (host events + controller auth).
|
||||||
- [[wiegand]] — reader standard feeding the controller directly (autonomous permit-holder path).
|
- [[wiegand]] — reader standard feeding the controller directly (autonomous permit-holder path).
|
||||||
- [[lpr-camera]] — edge-AI plate recognition; host-side casual-identity source.
|
- [[lpr-camera]] — edge-AI plate recognition; host-side casual-identity source.
|
||||||
- [[zkteco-controller]] — C3/inBio controller; aux-input path may enable host-in-the-loop (driver TBD).
|
- [[zkteco-controller]] — ❌ rejected/historical; aux-input path was a contender, not pursued.
|
||||||
- [[dingtian-relay]] — relay+input board; inputs decoupled from relays → solves the button blocker (driver TBD).
|
- [[dingtian-relay]] — ✅ CHOSEN access controller; decoupled inputs solve the button blocker (driver verified on hardware).
|
||||||
- [[bom]] — reference bill of materials (barrier, loops, controller, readers, payment, host, network).
|
- [[bom]] — reference bill of materials (barrier, loops, controller, readers, payment, host, network).
|
||||||
|
|
||||||
## Concepts — foundational forces
|
## Concepts — foundational forces
|
||||||
@@ -54,7 +54,7 @@ Counts: 1 source · 14 entities · 10 concepts · 2 decision records.
|
|||||||
- [[device-adapter-pattern]] — business logic talks to interfaces; swap hardware → new adapter.
|
- [[device-adapter-pattern]] — business logic talks to interfaces; swap hardware → new adapter.
|
||||||
- [[device-registry]] — catalog of selectable drivers per category (admin-configurable).
|
- [[device-registry]] — catalog of selectable drivers per category (admin-configurable).
|
||||||
- [[first-run-setup]] — admin assigns devices per lane from the catalog at install.
|
- [[first-run-setup]] — admin assigns devices per lane from the catalog at install.
|
||||||
- [[device-discovery]] — optional driver capability to scan the LAN (UHPPOTE UDP broadcast).
|
- [[device-discovery]] — optional driver capability to scan the LAN (no current driver uses it; UHPPOTE was the example).
|
||||||
- [[barrier-not-a-door]] — never timed-close a barrier; safety lives in barrier firmware.
|
- [[barrier-not-a-door]] — never timed-close a barrier; safety lives in barrier firmware.
|
||||||
- [[trust-boundary]] — the core fork: network vs. device; auditable vs. unforgeable.
|
- [[trust-boundary]] — the core fork: network vs. device; auditable vs. unforgeable.
|
||||||
- [[fail-state-safety]] — entry fails closed, exit fails open; manual override; watchdog.
|
- [[fail-state-safety]] — entry fails closed, exit fails open; manual override; watchdog.
|
||||||
|
|||||||
+13
@@ -118,3 +118,16 @@ works. The [[access-controller-button-flow]] blocker is RESOLVED. Gotcha recorde
|
|||||||
(GET omits it) or the write silently no-ops. Added httpPort config field (port 8080 ≠
|
(GET omits it) or the write silently no-ops. Added httpPort config field (port 8080 ≠
|
||||||
default 80). Test script apps/server/scripts/dingtian-test.mjs. Next: input HTTP-push
|
default 80). Test script apps/server/scripts/dingtian-test.mjs. Next: input HTTP-push
|
||||||
endpoint + wiring input→ticket→pulseOpen.
|
endpoint + wiring input→ticket→pulseOpen.
|
||||||
|
|
||||||
|
## [2026-06-15] cleanup | Remove UHPPOTE/ZKTeco code; wiki → rejected/historical
|
||||||
|
Neither UHPPOTE nor ZKTeco is used (Dingtian chosen). Removed their code:
|
||||||
|
deleted access-uhppote.ts, uhppoted.d.ts, access.ts (zkteco/esp32 stubs), the
|
||||||
|
three uhppote-*.mjs scripts; dropped the `uhppoted` npm dep from both packages;
|
||||||
|
unregistered uhppote/zkteco/esp32-relay from the driver registry; updated example
|
||||||
|
comments. Catalog access drivers now = dingtian only. Build green.
|
||||||
|
Wiki: kept the pages but marked [[uhppote-controller]] + [[zkteco-controller]]
|
||||||
|
rejected/historical, [[uhppote-vs-esp32]] historical; re-pointed all "current
|
||||||
|
device" framing (standing-decisions, bom, overview, open-questions) to
|
||||||
|
[[dingtian-relay]]; noted no current driver uses [[device-discovery]]. Transferable
|
||||||
|
concepts (network-isolation, event-log-ingestion, barrier-not-a-door, threat-model)
|
||||||
|
kept as-is. Links lint clean; raw source untouched (immutable).
|
||||||
|
|||||||
+8
-7
@@ -29,11 +29,12 @@ deployed on-site at a parking facility. Two forces shape nearly every decision:
|
|||||||
rest ([[disk-os-hardening]]) defends a secondary threat.
|
rest ([[disk-os-hardening]]) defends a secondary threat.
|
||||||
- **Devices** sit behind a [[device-adapter-pattern]] (swap hardware → new adapter only), with
|
- **Devices** sit behind a [[device-adapter-pattern]] (swap hardware → new adapter only), with
|
||||||
the [[barrier-not-a-door]] safety principle keeping physical safety in barrier-operator firmware.
|
the [[barrier-not-a-door]] safety principle keeping physical safety in barrier-operator firmware.
|
||||||
- **Access control** hinges on the [[trust-boundary]] fork:
|
- **Access control** today is the **[[dingtian-relay]]** relay+input controller behind
|
||||||
[[uhppote-vs-esp32|detection vs. prevention]]. Today: [[uhppote-controller]] behind
|
[[network-isolation]] — chosen because its inputs are **decoupled from its relays**, enabling
|
||||||
[[network-isolation]], its open [[uhppote-udp-protocol]] contained, its log made trustworthy by
|
host-in-the-loop ticket-first entry (resolving [[access-controller-button-flow]]). The
|
||||||
[[event-log-ingestion]]. Upgrade path: the [[esp32-custom-controller]] with
|
[[uhppote-controller]] and [[zkteco-controller]] were evaluated and **rejected** (historical).
|
||||||
[[challenge-response-auth]] and [[fail-state-safety]].
|
The deeper fork is still the [[trust-boundary]] ([[uhppote-vs-esp32|detection vs. prevention]]);
|
||||||
|
the [[esp32-custom-controller]] remains the prevention-grade alternative.
|
||||||
- **Readers** split two ways ([[entry-exit-readers]]): permit holders via [[wiegand]]
|
- **Readers** split two ways ([[entry-exit-readers]]): permit holders via [[wiegand]]
|
||||||
(autonomous), casual/transient via host-side [[lpr-camera]] / QR; both can share a relay.
|
(autonomous), casual/transient via host-side [[lpr-camera]] / QR; both can share a relay.
|
||||||
- A reference [[bom]] lists recommended devices.
|
- A reference [[bom]] lists recommended devices.
|
||||||
@@ -47,6 +48,6 @@ modes (fail-open on exit)**, the **reconciliation channel**, and **backup/durabi
|
|||||||
|
|
||||||
- *Security-first:* [[threat-model]] → [[append-only-event-chain]] → [[reconciliation]] →
|
- *Security-first:* [[threat-model]] → [[append-only-event-chain]] → [[reconciliation]] →
|
||||||
[[uhppote-vs-esp32]].
|
[[uhppote-vs-esp32]].
|
||||||
- *Hardware-first:* [[bom]] → [[uhppote-controller]] → [[entry-exit-readers]] →
|
- *Hardware-first:* [[bom]] → [[dingtian-relay]] → [[access-controller-button-flow]] →
|
||||||
[[esp32-custom-controller]].
|
[[entry-exit-readers]].
|
||||||
- *Stack-first:* [[technology-stack]] → [[offline-first]] → [[device-adapter-pattern]].
|
- *Stack-first:* [[technology-stack]] → [[offline-first]] → [[device-adapter-pattern]].
|
||||||
|
|||||||
Reference in New Issue
Block a user