16 Commits

Author SHA1 Message Date
julian 59bfe2013f Event log: resolve input_received lane from the firing device
Replace the hardcoded lane: 0 on input_received events with a real
device->lane lookup. A new LaneMap caches lane_devices.id -> lane,
built at startup and refreshed by the setup routes on assign/unassign.
An unmapped device logs lane: -1 + a warning (0 is a real lane) and is
still recorded faithfully (append-only chain).

source stays null for raw inputs by design: it's an IdentitySource
(how a vehicle was identified), not a device field; device provenance
remains in identity. Documented both in the wiki.
2026-06-15 12:51:21 +02:00
julian f5fd61984a Dingtian web password: set the admin's chosen password, verified
Fix two bugs found running the real assign flow: the saved web password
didn't match the device (login stayed admin/admin), and the UDP2 warning
never reached the admin.

Web password:
- Split the conflated field into webPassword (the DESIRED login; blank ->
  auto-generate) and webPasswordCurrent (the device's EXISTING password used
  as the old cred, default admin). Before, an admin typing a desired password
  made harden send it as the old cred -> rotation failed -> but the DB still
  saved the typed value, so it claimed a password the device never accepted.
- harden() now rotates current -> desired, VERIFIES by re-authenticating with
  the new password, and only returns secrets.webPassword on success (else a
  warning, nothing saved). Stores webPasswordCurrent for future re-runs.
- assign strips the typed webPassword/webPasswordCurrent and persists only the
  verified secret -- the DB never claims an unapplied password.

Warnings to the UI:
- assignDevice returns warnings[]; SetupWizard shows them in an amber
  "saved, but action needed" banner per category. This is how the admin learns
  the firmware wouldn't disable UDP2 (finish in the device web UI).

Verified on hardware: after harden the device rejects admin/admin and accepts
the chosen password; the UDP2 warning surfaces.
2026-06-15 12:26:34 +02:00
julian 7db5cfa0e4 Dingtian: close password-less string-protocol relay-fire hole
The string protocol (UDP 60001) has no password field but can fire relays
("11" = relay 1 on), bypassing relay_pw entirely. Proven on hardware: an
unauthenticated packet opened a relay. harden() had left it enabled "for
status reads".

- #status() now reads via the authenticated binary command (relay cmd 0x00)
  instead of the string protocol, so the string protocol is no longer needed.
- harden() disables the string protocol (udp2.p=255). BEST-EFFORT: firmware
  V3.6J's config API silently refuses to disable udp2 (the device web UI can),
  so it's not part of the blocking verify -- harden() re-checks and returns a
  warning instead of throwing. After a web-UI disable, the attack is dead and
  binary control/status still work (verified on hardware).
- HardenResult gains an optional `warnings[]`; the assign route surfaces them
  to the admin and logs them.
- Corrected the false comment claiming relay_pw stops an attacker (it is
  defence-in-depth on plaintext UDP, not a boundary).
- Thread localAddress through the driver's UDP/HTTP calls so a multi-homed
  host sources device traffic from the device-facing NIC.
- Device web login (webUser/webPassword) is no longer redacted from setup
  state -- it's an operational credential for the admin-only device area;
  pushPassword/relayPassword stay machine-only.

Wiki: document the vuln + fix, the firmware caveat, and the out-of-band
actuation gap (the log captures host actions only; reconciliation vs. an
independent witness is the real control and is not yet built).
2026-06-15 11:29:55 +02:00
julian add5fc0166 Append-only signed event log; persist Dingtian input pushes
Implement the core anti-fraud primitive: an append-only, hash-chained,
signed event log (the schema + types predated this; the writer/signer are new).

- EventLog (apps/server): serialized append, monotonic index, prevHash chain,
  signature; verifyChain() detects tamper/reorder/delete. No update/delete paths.
- Signer abstraction (packages/shared) over the ATECC608 secure element, with a
  SoftwareSigner (HMAC, EVENT_SIGNING_KEY) shipped now since the chip is still
  open-question #6. Documented: software signer is tamper-evident but NOT
  unforgeable-by-owner.
- Add ParkingEventType "input_received" for raw device inputs (not yet a
  vehicle_entry, which the entry flow will append later).
- Read API: GET /api/events; integrity self-check: GET /api/events/verify (admin).

Verified on hardware: shorting the Dingtian inputs produced signed, chained
input_received events; verifyChain ok; direct DB tamper/delete detected.

NOTE: the log captures host-originated actions only. Out-of-band relay
actuation (sniffed relay_pw, string protocol, ip_watchdog) produces no event
by design -- the control is reconciliation vs. an independent witness, which is
not yet built. See wiki/concepts/append-only-event-chain.md.
2026-06-15 11:29:36 +02:00
julian 39d4bac419 Setup: manage multiple device instances per category (add/remove)
The data model was already multi-instance (lane_devices = one row per
instance; assign always inserts) -- the limitation was UI-only. Make the
whole flow support more than one of every category:

- Backend: add DELETE /api/setup/assign/:id (unassign by id). /state now
  redacts secrets (pushPassword/webPassword/relayPassword) via a shared
  redactSecrets() also used by /assign -- it was returning raw config rows.
- Web: SetupWizard reworked from one fixed slot per category into a list of
  assigned instances (driver/role/host + Remove) plus an "Add another" form.
  select-type config fields (e.g. printer role) now render as dropdowns.
- api.ts: add fetchState(), unassignDevice(), Assignment/SetupState types.

Verified via Fastify inject: two printers assigned to one lane both list,
no secret leak, delete -> 204, delete unknown -> 404, count drops to 1.
Full repo typechecks.

Wiki: first-run-setup documents multi-instance + delete + redaction.
2026-06-14 20:39:39 +02:00
julian b2a0471b08 Rongta 80mm printer: driver, role-based failover, live status monitoring
Add the rongta PrinterDevice driver (ESC/POS over raw TCP 9100) and the
device-agnostic pieces around it:

- Roles + failover: each printer declares a role (entry-dispenser/booth-
  receipt) and failoverRank; printer-routing.ts picks the best healthy printer
  and falls back outside->booth for entry tickets (never the reverse).
- Live status: MonitorableDevice.readStatus()/PrinterStatus capability. The
  Rongta driver scrapes the device's own /prn_stat.htm (Cover/Cutter/Paper
  End/Near End/Off-Line) rather than hand-decoding DLE EOT, whose reply bytes
  on this clone don't match the canonical ESC/POS bit layout (verified on
  hardware) -- avoids a false-healthy. Maps to ready/degraded/offline, fail
  safe on an unreachable or unexpected page.
- Server PrinterMonitor polls enabled printers (PRINTER_POLL_MS, default 5s),
  caches latest, emits "printer-status" on change. Exposed via
  GET /api/printers/status and an SSE stream for the booth UI.

Verified against 10.0.10.6: ready when healthy, offline when unreachable
(no throw), bus emits on change and suppresses unchanged reads.

Wiki: new rongta-printer entity, printer-roles-failover and
printer-status-monitoring concepts; BOM/index/log updated.
2026-06-14 20:26:45 +02:00
julian 2a86e578a8 Dingtian harden(): rotate the admin/admin web login (cosmetic)
harden() now rotates the device's default admin/admin web-UI login via
GET /userset.cgi?<old>&<old>&<new>&<new>& (best-effort: a failure logs
and doesn't fail the assign). The new password is stored back in config
(webUser/webPassword) so a re-run can rotate again, and is stripped from
the assign response like the push secret.

Documented the load-bearing caveat: this device's CGI API is fully
UNAUTHENTICATED — config read/write, relay fire, and userset.cgi itself
all return 200 with no credentials (verified on hardware). admin/admin
gates only the browser UI, and there's no inbound-auth setting (only
session_en, which bricks the read API). So the rotation is defence-in-
depth for the UI, NOT a boundary; the signed event log remains the real
anti-fraud guarantee. Verified rotation end-to-end on 10.0.10.5
(success &0&, wrong-old-pw &2&); device left at admin/admin.
2026-06-14 19:00:42 +02:00
julian 382c32f2bc Setup wizard: show + override the backend push IP (multi-NIC hosts)
The backend IP baked into a push-capable device at assign time is
auto-derived by subnet-matching a local NIC. That's non-deterministic
when two NICs match the device subnet, and null when none does. Surface
it: backendIpCandidates() lists all local IPv4 NICs (on-subnet first),
GET /api/setup/backend-ips serves them, and the wizard renders an
editable Backend push IP dropdown after a successful test (pre-filled
with the auto-pick, warns when no NIC is on the device subnet). The
chosen IP overrides the auto-pick on assign and is recorded in config.
2026-06-14 18:47:23 +02:00
julian 7fd407ac82 Harden Dingtian: authenticated binary relay + disable unused channels
Lock down the relay device for the flat (no-VLAN) network.

Relay control:
- pulseOpen/setRelay now use the Dingtian BINARY protocol (:60000) with a
  relay password — the only relay option with auth (string :60001 has none, and
  is kept only for the read-only status query). Frame verified on hardware.

HardenableDevice capability (driver harden()):
- set a random relay_pw (1-9999); disable unused channels (rs485/can/tcp x2/mqtt
  -> p:255), keeping UDP1 binary (control) + UDP2 string (status).
- write-verified (device reboots on apply).

Assign/Save flow now does: fix preconditions -> harden -> set up input push;
the relay password is stored in lane_devices so the runtime device can command
the relay.

DELIBERATELY NOT touching the device's HTTP CGI session check (session_en):
enabling it on this firmware breaks the config-READ API (ECONNRESET) and locked
the backend out — required a factory reset to recover. The open CGI API is
accepted as flat-network reality; the signed event log is the real guarantee.

Verified end to end on hardware: assign hardens + configures the device, config
API stays reachable, pulseOpen with the stored password fires the relay, without
it is rejected. wiki: device-input-flow + dingtian-relay updated.
2026-06-14 18:34:35 +02:00
julian 0375227a16 Setup wizard: Test connection + Save & configure
Two-step device setup so the admin verifies before committing — and never touches
the device's own web UI.

- POST /api/setup/test (admin-only): healthCheck + checkPreconditions, no save and
  no device change. Returns device health + precondition issues.
- assign (Save) now also runs fixPreconditions (e.g. disables input_link_relay so
  a button press doesn't auto-fire its relay) before configuring the input push.
  Closes a gap where an assigned device could still auto-open. Fails the save with
  no DB row if device configuration fails (no orphan/half-configured rows).
- SetupWizard: wires config fields -> Test connection (health badge + precondition
  warnings) -> Save & configure; editing config resets prior test/save status.

Verified in-browser against the real device: Test -> ● ready + preconditions OK;
Save -> row persisted AND the device's Input Link URL written (push path matches
the saved device id). wiki/first-run-setup updated.
2026-06-14 16:59:36 +02:00
julian 3294f188dd Dingtian input push: HTTP Digest auth + auto-config on assign
Secure the device→backend input push, and configure it automatically when the
admin assigns the device (no manual URL/secret entry).

Auth — HTTP Digest (chosen by hardware testing: the device can't push to a
self-signed HTTPS backend, but does Digest correctly; a URL token is sniffable/
logged):
- digest-auth.ts: MD5 qop=auth challenge/verify, single-use nonces (replay
  resistance). Password never crosses the wire.
- push route: Digest + source-IP allowlist; per-device pushUser/pushPassword from
  lane_devices. Still not behind the SPA cookie/CSRF auth (machine call). The
  signed event log remains the real anti-fraud guarantee.

Auto-config on assign:
- setup assign: for push-capable devices, generate Digest creds, call
  configureInputPush to write them + the push URLs to the device, store the creds
  (password not echoed back). net.ts derives the backend IP on the device's
  subnet (BACKEND_HOST_IP override).
- driver configureInputPush sets auth=2 + creds; PushConfig carries the creds.
- removed the earlier URL-token approach.

Two hard-won device-write bugs fixed in the driver:
- configApi now sets an explicit Content-Length — the device silently ignores
  chunked request bodies (Node's default without Content-Length), so every config
  write looked successful ({"status":0}) but did nothing. This was the root cause
  of the session's "writes don't apply" mystery.
- #writeConfig polls until the change is verified, retrying (the device reboots on
  apply; back-to-back writes were lost). The `pass` field caps at 31 chars, so the
  generated password is 24 hex chars.

Verified on hardware: assign auto-configures the device; all 4 inputs then push
with Digest auth, zero failures. wiki/device-input-flow updated.
2026-06-14 16:39:08 +02:00
julian 23919164ee Dingtian input HTTP-push to backend (no polling)
The device pushes button events to the backend via its Input Link URL feature;
the backend decides. No polling — the chosen entry architecture.

packages/devices:
- dingtian driver: configureInputPush() writes the device's input_link_url
  config (per-input server/port/path, en=1, active-LOW, plain HTTP) so each
  input HTTP-GETs the backend on press/release. Extracted #readConfig/#writeConfig
  (with the required command:setconfig injection + post-write reset tolerance).

apps/server:
- routes/devices.ts: public GET/POST
  /api/devices/dingtian/:deviceId/input/:n/{on,off} — translates a device push
  into an internal device event. Not behind cookie/CSRF (machine call from the
  device); trust comes from the signed event log, not this request.
- device-events.ts: internal EventEmitter bus so the entry flow subscribes to
  input events without coupling to HTTP. Wired into the server.

Verified on hardware: configured the device, then real presses on all 4 inputs
pushed to the backend (input N on+off, source = device IP). No polling.

wiki: device-input-flow concept (path + trust model for the flat/no-VLAN
network); dingtian-relay updated; index + log.
2026-06-14 15:10:50 +02:00
julian 355026dcf7 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.
2026-06-14 14:28:52 +02:00
julian 1b55e2034d Dingtian relay driver — resolves the ticket-first entry blocker
The Dingtian board's inputs are independent of its relays (configurable), so a
button on an input can report to the host WITHOUT auto-firing a relay — solving
the access-controller-button-flow blocker the UHPPOTE/ZKTeco couldn't.

packages/devices:
- access-dingtian.ts: `dingtian` access driver implementing AccessControlDevice
  (relay pulse/latch via UDP string protocol :60001), InputDevice (read inputs +
  poll-based press/release events, active-LOW), and the new PreconditionDevice.
- PreconditionDevice capability on the interface: a device can report config it
  requires for parking and optionally fix it. Dingtian checks input_link_relay
  via the HTTP config API and can disable it.
- httpPort config field — the web/config API port is separate from UDP control
  (this unit uses 8080, not the default 80).
- Register dingtian; export driver objects from the package.

Verified on real hardware (DT-R004 @ 10.0.10.172): status read, relay pulse,
input events; disabled input_link_relay via the driver, then confirmed pressing
inputs fires NO relay (0000) — host-in-the-loop entry works.

Config-write gotcha recorded: config_set.cgi requires "command":"setconfig"
injected after "status" (GET omits it) or the POST silently no-ops.

apps/server/scripts/dingtian-test.mjs: status / watch / pulse hardware test.
wiki: dingtian-relay verified; button-flow marked RESOLVED; index + log.
2026-06-14 14:15:00 +02:00
julian 4319fb86dc wiki: Dingtian relay decision, HTTP-over-MQTT, unmanned direction
- dingtian-relay: relay+input controller (4ch on hand). Inputs are decoupled
  from relays (configurable via input_link_relay) — solves the
  access-controller-button-flow blocker the UHPPOTE couldn't. Full protocol from
  the SDK (UDP string control :60001, `00` status parse, input_link_url push,
  multicast discovery). Driver + hardware test still to build.
- dingtian-vs-mqtt: use direct HTTP/UDP now; MQTT skipped (broker = extra infra
  + failure mode + overkill at one-host/few-devices scale) but kept for later
  multi-lane scale.
- autonomous-direction: record the roadmap to fully unmanned (no booth) and how
  it reshapes the threat model (operator-fraud -> unattended-machine threats),
  makes host-in-the-loop entry mandatory, and raises fail-state stakes.
- threat-model: note the unmanned shift. index + log.

gitignore the vendor SDK (dingtian/, 71MB of binaries/examples) — reference
only, protocol captured in the wiki.
2026-06-14 13:27:01 +02:00
julian dbf1fa17d7 wiki: document dev environment (WSL networking, workflow)
Capture hard-won dev knowledge that was only in commit messages:

- wsl-dev-networking: WSL2 NAT blocks UDP broadcast (device discovery can't
  reach the LAN); fix is mirrored networking (.wslconfig, Win11 22H2+), plus the
  gotchas that remained after — multiple interfaces, subnet-directed broadcast,
  localhost->IPv6 stall. Alternatives for non-mirrored setups.
- local-dev-workflow: first-time setup, pnpm dev, and the gotchas (the
  strip-types dev-server hang -> tsx, the 127.0.0.1 proxy fix, .env loading,
  seeding into the right DB).
- device-discovery: corrected the old "broadcast permission (EACCES)" note — the
  real cause was the lib not enabling SO_BROADCAST for global 255.255.255.255;
  documented the three verified broadcast gotchas + I/O serialization.
- schema: add a `reference` page type; new "Dev environment" index section; log.

Links lint clean; both new pages well-connected.
2026-06-14 13:09:35 +02:00
58 changed files with 4025 additions and 787 deletions
+2
View File
@@ -18,3 +18,5 @@ dist/
.playwright-mcp/
# stray hardware/UI test screenshots
/*.png
# Vendor device SDKs (reference only — protocol captured in wiki, not committed)
/dingtian/
+1 -2
View File
@@ -21,8 +21,7 @@
"@parking/shared": "workspace:*",
"bcrypt": "6.0.0",
"fastify": "5.8.5",
"fastify-plugin": "6.0.0",
"uhppoted": "0.9.0"
"fastify-plugin": "6.0.0"
},
"devDependencies": {
"@types/bcrypt": "6.0.0",
+73
View File
@@ -0,0 +1,73 @@
// Dingtian relay+input hardware test.
//
// node apps/server/scripts/dingtian-test.mjs # status only (safe)
// node apps/server/scripts/dingtian-test.mjs watch # live input/button monitor
// node apps/server/scripts/dingtian-test.mjs pulse 1 # pulse relay 1 (prompts)
//
// Env: DINGTIAN_HOST (default 10.0.10.172), DINGTIAN_PORT (60001).
//
// SAFETY: `pulse` fires a relay → the barrier may move. It prompts first unless
// YES=1. pulseOpen is momentary (the device self-releases).
import { createInterface } from "node:readline/promises";
import { stdin, stdout } from "node:process";
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const { dingtianDriver } = require("@parking/devices");
const host = process.env.DINGTIAN_HOST ?? "10.0.10.172";
const port = process.env.DINGTIAN_PORT ? Number(process.env.DINGTIAN_PORT) : 60001;
const dev = dingtianDriver.create({ host, port, channels: 4 });
const mode = process.argv[2] ?? "status";
console.log(`dingtian @ ${host}:${port}\n`);
async function showStatus() {
const health = await dev.healthCheck();
console.log("health:", JSON.stringify(health));
const inputs = await dev.readInputs();
console.log("inputs (active=pressed):", inputs.map((v, i) => `in${i + 1}=${v ? "ON" : "off"}`).join(" "));
for (let ch = 1; ch <= 4; ch++) {
console.log(`relay ${ch}:`, await dev.getDoorStatus(ch));
}
}
if (mode === "status") {
await showStatus();
process.exit(0);
}
if (mode === "watch") {
console.log("── press the buttons on the inputs — Ctrl-C to stop ──\n");
const unsub = dev.onInput((e) => {
console.log(`[${e.at}] input ${e.input} ${e.edge.toUpperCase()}`);
});
process.on("SIGINT", () => {
unsub();
console.log("\nstopped.");
process.exit(0);
});
// keep alive
await new Promise(() => {});
}
if (mode === "pulse") {
const ch = Number(process.argv[3] ?? 1);
if (process.env.YES !== "1") {
const rl = createInterface({ input: stdin, output: stdout });
const ans = (await rl.question(`Pulse relay ${ch}? (barrier may move) [y/N] `)).trim();
rl.close();
if (ans.toLowerCase() !== "y") {
console.log("aborted.");
process.exit(0);
}
}
await dev.pulseOpen(ch);
console.log(`pulsed relay ${ch}.`);
// show the relay state right after (likely back off — pulse is momentary)
setTimeout(async () => {
console.log(`relay ${ch} now:`, await dev.getDoorStatus(ch));
process.exit(0);
}, 300);
}
-72
View File
@@ -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 };
-100
View File
@@ -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);
});
-44
View File
@@ -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.");
+47
View File
@@ -0,0 +1,47 @@
import { EventEmitter } from "node:events";
import type { PrinterStatus } from "@parking/devices";
// Internal event bus for device-originated events (button presses, etc.).
// Hardware drivers / inbound device pushes emit here; business logic (entry
// flow, event-log) subscribes — keeping the HTTP/transport layer thin and the
// app device-agnostic. See wiki/entities/fastify.md.
export interface DeviceInputEvent {
readonly driverId: string; // e.g. "dingtian"
readonly deviceId: string; // which configured device (lane_devices id)
readonly input: number; // 1-based input/channel
readonly edge: "on" | "off"; // active / inactive
readonly at: string; // ISO-8601 (server receive time)
readonly source: "push" | "poll";
}
/** A printer's status as tracked by the live monitor (status + identity). */
export interface PrinterStatusEvent {
readonly deviceId: string; // lane_devices id
readonly lane: number;
readonly driverId: string;
readonly role?: string; // entry-dispenser | booth-receipt
readonly status: PrinterStatus;
}
class DeviceEventBus extends EventEmitter {
emitInput(event: DeviceInputEvent): void {
this.emit("input", event);
}
onInput(cb: (event: DeviceInputEvent) => void): () => void {
this.on("input", cb);
return () => this.off("input", cb);
}
/** Emitted by the printer monitor whenever a printer's status CHANGES. */
emitPrinterStatus(event: PrinterStatusEvent): void {
this.emit("printer-status", event);
}
onPrinterStatus(cb: (event: PrinterStatusEvent) => void): () => void {
this.on("printer-status", cb);
return () => this.off("printer-status", cb);
}
}
/** Process-wide device event bus. */
export const deviceEvents = new DeviceEventBus();
+97
View File
@@ -0,0 +1,97 @@
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
import type { FastifyReply, FastifyRequest } from "fastify";
// HTTP Digest auth (RFC 2617, MD5, qop=auth) — verified against the Dingtian
// device, which CAN do Digest but CANNOT do HTTPS to a self-signed cert. On
// this flat network Digest is the strongest available push auth: the password
// is never sent (only a nonce-keyed hash). It is defence-in-depth; the signed
// event log is the real anti-fraud guarantee. See wiki/concepts/device-input-flow.md.
export const DIGEST_REALM = "parking";
const md5 = (s: string) => createHash("md5").update(s).digest("hex");
/** Nonces we've issued and not yet consumed (single-use → replay resistance). */
const issuedNonces = new Map<string, number>(); // nonce → issuedAt (ms epoch is unavailable in scripts but fine at runtime)
const NONCE_TTL_MS = 5 * 60_000;
function issueNonce(): string {
const nonce = randomBytes(16).toString("hex");
issuedNonces.set(nonce, Date.now());
// opportunistic cleanup
if (issuedNonces.size > 1000) {
const cutoff = Date.now() - NONCE_TTL_MS;
for (const [n, t] of issuedNonces) if (t < cutoff) issuedNonces.delete(n);
}
return nonce;
}
function parseDigest(header: string): Record<string, string> {
const out: Record<string, string> = {};
const re = /(\w+)=(?:"([^"]*)"|([^,]*))/g;
let m: RegExpExecArray | null;
while ((m = re.exec(header))) out[m[1]!] = (m[2] ?? m[3] ?? "").trim();
return out;
}
function eq(a: string, b: string): boolean {
const ab = Buffer.from(a);
const bb = Buffer.from(b);
return ab.length === bb.length && timingSafeEqual(ab, bb);
}
export interface DigestCreds {
readonly user: string;
readonly password: string;
}
/**
* Verify a Digest Authorization header. Returns true on success. On failure (or
* a missing/expired header) sets a 401 challenge on `reply` and returns false —
* the caller should stop. `creds` is the device's stored push credentials.
*/
export function verifyDigest(
req: FastifyRequest,
reply: FastifyReply,
creds: DigestCreds,
): boolean {
const header = req.headers["authorization"];
if (!header || !/^Digest /i.test(header)) {
challenge(reply);
return false;
}
const p = parseDigest(header.replace(/^Digest /i, ""));
// Nonce must be one we issued and not yet consumed (single-use).
const issuedAt = p.nonce ? issuedNonces.get(p.nonce) : undefined;
if (!p.nonce || issuedAt === undefined || Date.now() - issuedAt > NONCE_TTL_MS) {
challenge(reply, true);
return false;
}
const ha1 = md5(`${creds.user}:${DIGEST_REALM}:${creds.password}`);
const ha2 = md5(`${req.method}:${p.uri ?? req.url}`);
const expected =
p.qop === "auth"
? md5(`${ha1}:${p.nonce}:${p.nc}:${p.cnonce}:${p.qop}:${ha2}`)
: md5(`${ha1}:${p.nonce}:${ha2}`);
if (!p.response || !eq(expected, p.response) || !eq(p.username ?? "", creds.user)) {
challenge(reply);
return false;
}
// Consume the nonce so it can't be replayed.
issuedNonces.delete(p.nonce);
return true;
}
function challenge(reply: FastifyReply, stale = false): void {
const nonce = issueNonce();
reply.header(
"www-authenticate",
`Digest realm="${DIGEST_REALM}", qop="auth", nonce="${nonce}", algorithm=MD5${stale ? ", stale=true" : ""}`,
);
reply.code(401).send("authentication required");
}
+147
View File
@@ -0,0 +1,147 @@
import { createHash, randomUUID } from "node:crypto";
import { desc, events, type Db, type EventRow } from "@parking/db";
import type { Direction, IdentitySource, ParkingEventType, Signer } from "@parking/shared";
// The append-only, hash-chained, signed event log — the system's core anti-fraud
// primitive (see wiki/concepts/append-only-event-chain.md). Entry/exit and device
// events are NEVER edited or deleted; a correction/void is a new appended row.
//
// Integrity rules enforced here:
// - monotonic `index` (prev + 1; the unique constraint is the backstop),
// - `prevHash` = hash of the previous row's canonical form (genesis = null),
// - `signature` = signer.sign(canonical) over a STABLE field ordering,
// - appends are SERIALIZED: read-prev -> compute-hash -> insert must not
// interleave, or two events could claim the same index / chain off a stale
// prev. SQLite is single-writer, but the read+compute+insert is multi-step,
// so we guard it with an in-process async lock as well.
export interface AppendInput {
readonly type: ParkingEventType;
readonly lane: number;
readonly direction?: Direction | null;
readonly source?: IdentitySource | null;
readonly identity?: string | null;
/** Event time (ISO-8601). Defaults to now. */
readonly occurredAt?: string;
}
/**
* Canonical serialization of an event's signed/hashed content. Order is FIXED
* and explicit — the hash chain and signatures depend on byte-stable output, so
* this must never change for already-written events (versioned via keyId if it
* ever must). The volatile DB row id is deliberately excluded; identity in the
* chain is `index` + content.
*/
export function canonicalize(e: {
index: number;
type: string;
direction: string | null;
lane: number;
source: string | null;
identity: string | null;
occurredAt: string;
prevHash: string | null;
}): string {
return JSON.stringify([
e.index,
e.type,
e.direction ?? null,
e.lane,
e.source ?? null,
e.identity ?? null,
e.occurredAt,
e.prevHash ?? null,
]);
}
/** SHA-256 of an event's canonical form (hex) — what the NEXT event chains to. */
export function hashEvent(canonical: string): string {
return createHash("sha256").update(canonical, "utf8").digest("hex");
}
export class EventLog {
readonly #db: Db;
readonly #signer: Signer;
/** Serialize appends: each waits for the previous to finish. */
#tail: Promise<unknown> = Promise.resolve();
constructor(db: Db, signer: Signer) {
this.#db = db;
this.#signer = signer;
}
/** Append one event to the chain. Returns the persisted row. Serialized. */
append(input: AppendInput): Promise<EventRow> {
const run = this.#tail.then(() => this.#appendNow(input));
// Keep the chain going even if one append rejects (don't wedge the lock).
this.#tail = run.catch(() => undefined);
return run;
}
#appendNow(input: AppendInput): EventRow {
const prev = this.#db
.select()
.from(events)
.orderBy(desc(events.index))
.limit(1)
.get();
const index = (prev?.index ?? 0) + 1;
const prevHash = prev ? hashEvent(canonicalize(prev)) : null;
const occurredAt = input.occurredAt ?? new Date().toISOString();
const canonical = canonicalize({
index,
type: input.type,
direction: input.direction ?? null,
lane: input.lane,
source: input.source ?? null,
identity: input.identity ?? null,
occurredAt,
prevHash,
});
const row = {
id: randomUUID(),
index,
type: input.type,
direction: input.direction ?? null,
lane: input.lane,
source: input.source ?? null,
identity: input.identity ?? null,
occurredAt,
prevHash,
signature: this.#signer.sign(canonical),
};
this.#db.insert(events).values(row).run();
return row as EventRow;
}
/**
* Walk the chain oldest→newest and recompute hashes + signatures. Returns the
* first detected break, or { ok: true }. This is what reconciliation and an
* integrity self-check call. Catches: tampered content, reordering, a deleted
* row (index gap), and a forged/invalid signature.
*/
verifyChain(): { ok: true } | { ok: false; index: number; reason: string } {
const rows = this.#db.select().from(events).orderBy(events.index).all();
let expectedIndex = 1;
let prevHash: string | null = null;
for (const row of rows) {
if (row.index !== expectedIndex) {
return { ok: false, index: row.index, reason: `index gap: expected ${expectedIndex}` };
}
if ((row.prevHash ?? null) !== prevHash) {
return { ok: false, index: row.index, reason: "prevHash does not match chain" };
}
const canonical = canonicalize(row);
if (!this.#signer.verify(canonical, row.signature)) {
return { ok: false, index: row.index, reason: "signature invalid (content tampered or wrong key)" };
}
prevHash = hashEvent(canonical);
expectedIndex += 1;
}
return { ok: true };
}
}
+30
View File
@@ -0,0 +1,30 @@
import { laneDevices, type Db } from "@parking/db";
// Resolves a device instance id (lane_devices.id) to its lane number.
//
// Device pushes/events carry the `lane_devices` id (which device fired), not a
// lane. The event log wants the lane, so we keep a small in-memory id->lane map
// rebuilt from the DB at startup and refreshed whenever assignments change
// (assign/unassign). It's tiny (one row per device) and read on the hot path of
// every input event, so a cached map beats a per-event DB lookup.
export class LaneMap {
readonly #db: Db;
#byDeviceId = new Map<string, number>();
constructor(db: Db) {
this.#db = db;
}
/** (Re)load the id->lane map from the lane_devices table. */
refresh(): void {
const rows = this.#db.select().from(laneDevices).all();
const next = new Map<string, number>();
for (const r of rows) next.set(r.id, r.lane);
this.#byDeviceId = next;
}
/** Lane for a device instance id, or null if the device isn't known. */
laneFor(deviceId: string): number | null {
return this.#byDeviceId.get(deviceId) ?? null;
}
}
+69
View File
@@ -0,0 +1,69 @@
import { networkInterfaces } from "node:os";
// Figure out which local IP a device should call back on. For input-push, the
// device needs OUR address on ITS subnet — pick the local IPv4 interface whose
// network contains the device's IP. Override with BACKEND_HOST_IP if the
// auto-pick is wrong (e.g. multi-homed host). See wiki/concepts/device-input-flow.md.
export function backendIpForDevice(deviceHost: string): string | null {
if (process.env.BACKEND_HOST_IP) return process.env.BACKEND_HOST_IP;
const ip = deviceHost.split(".").map(Number);
if (ip.length !== 4 || ip.some((o) => Number.isNaN(o))) return null;
for (const ifaces of Object.values(networkInterfaces())) {
for (const i of ifaces ?? []) {
if (i.family !== "IPv4" || i.internal) continue;
const addr = i.address.split(".").map(Number);
const mask = i.netmask.split(".").map(Number);
if (addr.length !== 4 || mask.length !== 4) continue;
const sameNet = ip.every((o, k) => (o & mask[k]!) === (addr[k]! & mask[k]!));
if (sameNet) return i.address;
}
}
return null;
}
/** Backend port the device should call (the server's listen port). */
export function backendPort(): number {
return Number(process.env.PORT ?? 3000);
}
export interface BackendIpCandidate {
ip: string;
iface: string;
/** True if this interface's subnet contains the device IP (the likely one). */
onDeviceSubnet: boolean;
}
/**
* List local IPv4 addresses the device could call back on, with the ones on the
* device's own subnet flagged + sorted first. Lets the admin see/override the
* auto-pick (important on multi-NIC hosts). BACKEND_HOST_IP, if set, is the only
* candidate (the deterministic override).
*/
export function backendIpCandidates(deviceHost: string): BackendIpCandidate[] {
if (process.env.BACKEND_HOST_IP) {
return [{ ip: process.env.BACKEND_HOST_IP, iface: "BACKEND_HOST_IP", onDeviceSubnet: true }];
}
const dev = deviceHost.split(".").map(Number);
const validDev = dev.length === 4 && !dev.some((o) => Number.isNaN(o));
const out: BackendIpCandidate[] = [];
for (const [iface, ifaces] of Object.entries(networkInterfaces())) {
for (const i of ifaces ?? []) {
if (i.family !== "IPv4" || i.internal) continue;
const addr = i.address.split(".").map(Number);
const mask = i.netmask.split(".").map(Number);
const onDeviceSubnet =
validDev &&
addr.length === 4 &&
mask.length === 4 &&
dev.every((o, k) => (o & mask[k]!) === (addr[k]! & mask[k]!));
out.push({ ip: i.address, iface, onDeviceSubnet });
}
}
// On-subnet candidates first.
return out.sort((a, b) => Number(b.onDeviceSubnet) - Number(a.onDeviceSubnet));
}
+159
View File
@@ -0,0 +1,159 @@
import type { FastifyBaseLogger } from "fastify";
import { eq, laneDevices, type Db } from "@parking/db";
import {
isMonitorable,
registry,
type PrinterStatus,
} from "@parking/devices";
import { deviceEvents, type PrinterStatusEvent } from "./device-events.js";
// Live printer-status monitor. Polls every enabled printer that supports
// readStatus() on an interval, caches the latest status in memory, and emits a
// "printer-status" event on the device bus whenever a printer's status CHANGES
// (so the UI/SSE stream and any future entry-flow logic react without polling
// the device themselves). See wiki/concepts/printer-status-monitoring.md.
//
// The poll is the booth's early warning: it surfaces "paper out" / "cover open"
// BEFORE a driver presses the entry button and no ticket prints. Reachability
// failures degrade to status "offline" — the same signal as a dead printer.
const POLL_MS = Number(process.env.PRINTER_POLL_MS ?? 5000);
/** A cached entry: the last status plus the device's identity for the UI. */
interface CachedStatus extends PrinterStatusEvent {}
export class PrinterMonitor {
readonly #db: Db;
readonly #log: FastifyBaseLogger;
readonly #pollMs: number;
/** Latest status per device id. */
readonly #latest = new Map<string, CachedStatus>();
/** Live adapter per device id (rebuilt when the set of printers changes). */
readonly #devices = new Map<string, { build: () => ReturnType<typeof registry.create>; meta: Omit<PrinterStatusEvent, "status"> }>();
#timer: ReturnType<typeof setInterval> | null = null;
#ticking = false;
constructor(db: Db, log: FastifyBaseLogger, pollMs = POLL_MS) {
this.#db = db;
this.#log = log;
this.#pollMs = pollMs;
}
/** Begin polling. Idempotent. */
start(): void {
if (this.#timer) return;
// Kick an immediate pass so status is populated without waiting a full cycle.
void this.#tick();
this.#timer = setInterval(() => void this.#tick(), this.#pollMs);
// Don't keep the event loop alive solely for the monitor.
this.#timer.unref?.();
this.#log.info(`printer-monitor: polling every ${this.#pollMs}ms`);
}
stop(): void {
if (this.#timer) {
clearInterval(this.#timer);
this.#timer = null;
}
}
/** Current snapshot for the API. */
snapshot(): CachedStatus[] {
return [...this.#latest.values()];
}
/** Reload the set of monitored printers from lane_devices (call after assign). */
async refreshDevices(): Promise<void> {
const rows = await this.#db
.select()
.from(laneDevices)
.where(eq(laneDevices.category, "printer"))
.all();
const seen = new Set<string>();
for (const row of rows) {
if (!row.enabled) continue;
const driver = registry.get(row.driverId);
if (!driver) continue;
const cfg = row.config as Record<string, unknown>;
// Probe-build once to check the driver yields a monitorable device.
let monitorable: boolean;
try {
monitorable = isMonitorable(driver.create(cfg as never));
} catch {
monitorable = false;
}
if (!monitorable) continue;
seen.add(row.id);
this.#devices.set(row.id, {
build: () => driver.create(cfg as never),
meta: {
deviceId: row.id,
lane: row.lane,
driverId: row.driverId,
role: typeof cfg.role === "string" ? cfg.role : undefined,
},
});
}
// Drop devices that are no longer present/enabled.
for (const id of [...this.#devices.keys()]) {
if (!seen.has(id)) {
this.#devices.delete(id);
this.#latest.delete(id);
}
}
}
async #tick(): Promise<void> {
if (this.#ticking) return; // never overlap polls
this.#ticking = true;
try {
await this.refreshDevices();
await Promise.all(
[...this.#devices.entries()].map(([id, entry]) => this.#poll(id, entry)),
);
} catch (err) {
this.#log.warn(`printer-monitor tick failed: ${(err as Error).message}`);
} finally {
this.#ticking = false;
}
}
async #poll(id: string, entry: { build: () => ReturnType<typeof registry.create>; meta: Omit<PrinterStatusEvent, "status"> }): Promise<void> {
let status: PrinterStatus;
try {
const device = entry.build();
if (!isMonitorable(device)) return;
status = await device.readStatus();
} catch (err) {
status = {
status: "offline",
detail: (err as Error).message,
checkedAt: new Date().toISOString(),
};
}
const event: PrinterStatusEvent = { ...entry.meta, status };
const prev = this.#latest.get(id);
this.#latest.set(id, event);
if (!prev || statusChanged(prev.status, status)) {
this.#log.info(
`printer-monitor: ${entry.meta.role ?? "printer"} ${id} (lane ${entry.meta.lane}) -> ${status.status}${status.detail ? ` (${status.detail})` : ""}`,
);
deviceEvents.emitPrinterStatus(event);
}
}
}
/** Did the operator-meaningful status change between two reads? */
function statusChanged(a: PrinterStatus, b: PrinterStatus): boolean {
return (
a.status !== b.status ||
a.paperEnd !== b.paperEnd ||
a.paperNearEnd !== b.paperNearEnd ||
a.coverOpen !== b.coverOpen ||
a.cutterError !== b.cutterError ||
a.offline !== b.offline
);
}
+82
View File
@@ -0,0 +1,82 @@
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { eq, laneDevices, type Db } from "@parking/db";
import { deviceEvents } from "../device-events.js";
import { verifyDigest } from "../digest-auth.js";
// Inbound device push endpoints. The Dingtian board's "Input Link URL" feature
// HTTP-calls us when an input (button) fires — no polling. We translate the
// push into an internal device event; the entry flow decides what to do
// (print a ticket, then command the relay). See wiki/concepts/device-input-flow.md.
//
// AUTH: HTTP Digest (the device can do Digest but not HTTPS-to-self-signed —
// both tested on hardware). The password is never sent on the wire; the secret
// is NOT in the URL. Per-device credentials live in lane_devices (written on
// assign). This is defence-in-depth on a flat network; the signed event log is
// the real anti-fraud guarantee (an open with no matching signed event is an
// anomaly). Source-IP is also checked. NOT behind the SPA cookie/CSRF auth
// (machine call from the device).
interface InputParams {
deviceId: string;
n: string;
edge: string;
}
interface DingtianDeviceConfig {
host?: string;
pushUser?: string;
pushPassword?: string;
}
function clientIp(req: FastifyRequest): string {
return req.ip.replace(/^::ffff:/, "");
}
export async function deviceRoutes(app: FastifyInstance, db: Db): Promise<void> {
const handle = async (req: FastifyRequest<{ Params: InputParams }>, reply: FastifyReply) => {
const { deviceId, n, edge } = req.params;
const row = await db.select().from(laneDevices).where(eq(laneDevices.id, deviceId)).get();
const cfg = row?.config as DingtianDeviceConfig | undefined;
// Unknown device / not a dingtian / no push creds / wrong source IP → 404.
if (
!row ||
row.driverId !== "dingtian" ||
!cfg?.pushUser ||
!cfg.pushPassword ||
!cfg.host ||
clientIp(req) !== cfg.host
) {
app.log.warn(`rejected device push: device=${deviceId} ip=${clientIp(req)}`);
return reply.code(404).send({ error: "not found" });
}
// Digest auth — issues a 401 challenge on first hit; the device retries with
// the hashed response (verifyDigest sends the challenge + returns false).
if (!verifyDigest(req, reply, { user: cfg.pushUser, password: cfg.pushPassword })) {
return; // 401 already sent
}
const input = Number(n);
const ed = edge === "off" ? "off" : "on";
app.log.info(`[dingtian:${deviceId}] input ${input} ${ed} (push)`);
deviceEvents.emitInput({
driverId: "dingtian",
deviceId,
input,
edge: ed,
at: new Date().toISOString(),
source: "push",
});
return { ok: true };
};
for (const method of ["GET", "POST"] as const) {
app.route({
method,
url: "/api/devices/dingtian/:deviceId/input/:n/:edge",
handler: handle,
});
}
}
+38
View File
@@ -0,0 +1,38 @@
import type { FastifyInstance } from "fastify";
import { desc, events, type Db } from "@parking/db";
import { requireRole } from "../auth.js";
import type { EventLog } from "../event-log.js";
// Read access to the append-only signed event log. NO write/update/delete routes
// exist by design — events are only ever appended internally (entry flow, device
// pushes). Corrections are new appended events, never edits. See
// wiki/concepts/append-only-event-chain.md.
export async function eventRoutes(
app: FastifyInstance,
db: Db,
eventLog: EventLog,
): Promise<void> {
// Any authenticated role may read the log (it's the audit trail).
const guard = requireRole("admin", "operator", "cashier", "readonly");
// Recent events, newest first. `limit` caps the page (default 100, max 1000).
app.get<{ Querystring: { limit?: string } }>(
"/api/events",
{ preHandler: guard },
async (req) => {
const limit = Math.min(Math.max(Number(req.query.limit) || 100, 1), 1000);
const rows = db.select().from(events).orderBy(desc(events.index)).limit(limit).all();
return { events: rows };
},
);
// Integrity self-check: walk the chain and verify hashes + signatures. Admin-
// only (it's an audit action). Returns the first break, or ok. This is what a
// reconciliation job / "is the log intact?" check calls.
app.get(
"/api/events/verify",
{ preHandler: requireRole("admin") },
async () => eventLog.verifyChain(),
);
}
+50
View File
@@ -0,0 +1,50 @@
import type { FastifyInstance } from "fastify";
import { requireRole } from "../auth.js";
import { deviceEvents } from "../device-events.js";
import type { PrinterMonitor } from "../printer-monitor.js";
// Live printer-status API. The PrinterMonitor polls printers in the background;
// these endpoints expose its cache (snapshot) and a live push stream (SSE) so the
// booth UI shows paper-out / cover-open / offline in real time. Any authenticated
// operator may read status (it's operational, not a setup action).
export async function printerRoutes(
app: FastifyInstance,
monitor: PrinterMonitor,
): Promise<void> {
const guard = requireRole("admin", "operator", "cashier", "readonly");
// Current status of every monitored printer (cached — no device round-trip).
app.get("/api/printers/status", { preHandler: guard }, async () => ({
printers: monitor.snapshot(),
}));
// Live stream: emits the full snapshot on connect, then one event per change.
// Server-Sent Events — one-way, survives proxies, trivially consumed by the SPA.
app.get("/api/printers/status/stream", { preHandler: guard }, (req, reply) => {
reply.raw.writeHead(200, {
"content-type": "text/event-stream",
"cache-control": "no-cache",
connection: "keep-alive",
});
const send = (event: string, data: unknown) => {
reply.raw.write(`event: ${event}\n`);
reply.raw.write(`data: ${JSON.stringify(data)}\n\n`);
};
// Initial state so a fresh client doesn't wait for the next change.
send("snapshot", { printers: monitor.snapshot() });
const unsubscribe = deviceEvents.onPrinterStatus((e) => send("status", e));
// Heartbeat keeps intermediaries from closing an idle connection.
const heartbeat = setInterval(() => reply.raw.write(": ping\n\n"), 25000);
heartbeat.unref?.();
req.raw.on("close", () => {
clearInterval(heartbeat);
unsubscribe();
});
});
}
+200 -13
View File
@@ -1,14 +1,18 @@
import { randomUUID } from "node:crypto";
import { randomBytes, randomUUID } from "node:crypto";
import type { FastifyInstance } from "fastify";
import { eq, laneDevices, setupState, type Db } from "@parking/db";
import {
hasPreconditions,
hasPushConfig,
isDiscoverable,
isHardenable,
registerBuiltinDrivers,
registry,
setDeviceLogSink,
type DeviceCategory,
} from "@parking/devices";
import { requireRole } from "../auth.js";
import { backendIpCandidates, backendIpForDevice, backendPort } from "../net.js";
// First-run setup API. The admin reads the driver catalog and assigns devices
// per lane. See wiki/concepts/first-run-setup.md.
@@ -18,9 +22,39 @@ interface AssignBody {
category: DeviceCategory;
driverId: string;
config: Record<string, string | number | boolean>;
/** Optional: the backend IP the device should push to (overrides auto-pick;
* matters on multi-NIC hosts). */
backendIp?: string;
}
export async function setupRoutes(app: FastifyInstance, db: Db): Promise<void> {
interface TestBody {
driverId: string;
config: Record<string, string | number | boolean>;
}
// Config keys that hold MACHINE-ONLY secrets — never sent back to the client.
// No human ever uses these to log in: `pushPassword` is the device→backend Digest
// secret, `relayPassword` is the binary-protocol relay_pw. They stay redacted.
//
// NOTE: the device web-UI login (`webUser`/`webPassword`) is deliberately NOT
// redacted. It's an operational credential an admin needs to reach the device's
// own web page, and the whole device-management area is admin-only — so it's
// surfaced in the admin device view rather than hidden. See first-run-setup.md.
const SECRET_CONFIG_KEYS = ["pushPassword", "relayPassword"] as const;
function redactSecrets(config: Record<string, unknown>): Record<string, unknown> {
const out = { ...config };
for (const k of SECRET_CONFIG_KEYS) delete out[k];
return out;
}
export async function setupRoutes(
app: FastifyInstance,
db: Db,
// Called after the set of assignments changes (assign/unassign) so the caller
// can refresh anything derived from it — e.g. the device id->lane map.
onAssignmentsChanged: () => void = () => {},
): Promise<void> {
registerBuiltinDrivers();
setDeviceLogSink((line) => app.log.info(line));
@@ -28,14 +62,14 @@ export async function setupRoutes(app: FastifyInstance, db: Db): Promise<void> {
const adminGuard = requireRole("admin");
// 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 () => {
const catalog = registry.catalog();
const discoverable = registry.list().filter(isDiscoverable).map((d) => d.id);
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
// assigning. Admin-only. See wiki/concepts/device-discovery.md.
app.get<{ Params: { driverId: string } }>(
@@ -67,43 +101,196 @@ export async function setupRoutes(app: FastifyInstance, db: Db): Promise<void> {
},
);
// Current setup status + assignments.
// Current setup status + assignments. Secrets are stripped from each config
// (the UI lists devices; it never needs the stored push/relay/web passwords).
app.get(
"/api/setup/state",
{ preHandler: adminGuard },
async () => {
const state = await db.select().from(setupState).where(eq(setupState.id, 1)).get();
const assignments = await db.select().from(laneDevices).all();
const rows = await db.select().from(laneDevices).all();
const assignments = rows.map((r) => ({ ...r, config: redactSecrets(r.config) }));
return { completedAt: state?.completedAt ?? null, assignments };
},
);
// Assign a device to a lane. Validates the chosen driver + config against the
// registry before persisting; rejects unknown drivers / missing config.
// Test a device config WITHOUT saving or changing the device: validate the
// config, probe reachability (healthCheck), and report preconditions
// (e.g. input_link_relay state). Lets the admin verify before committing.
app.post<{ Body: TestBody }>(
"/api/setup/test",
{ preHandler: adminGuard },
async (req, reply) => {
const { driverId, config } = req.body;
const driver = registry.get(driverId);
if (!driver) return reply.code(400).send({ error: `unknown driver: ${driverId}` });
let device;
try {
device = registry.create(driverId, config);
} catch (err) {
return reply.code(400).send({ error: (err as Error).message });
}
const health = await device.healthCheck();
const preconditions = hasPreconditions(device)
? await device.checkPreconditions()
: { ok: true, issues: [] };
return { health, preconditions };
},
);
// Candidate backend IPs the device can push to, for a given device host. The
// wizard pre-fills with the on-subnet one and lets the admin override (matters
// on multi-NIC hosts). See net.ts / wiki/concepts/device-input-flow.md.
app.get<{ Querystring: { host?: string } }>(
"/api/setup/backend-ips",
{ preHandler: adminGuard },
async (req) => {
const candidates = backendIpCandidates(req.query.host ?? "");
return { candidates, port: backendPort() };
},
);
// Assign a device to a lane. Validates the chosen driver + config, configures
// the device (fix preconditions + set up Digest-authenticated input push — no
// manual device-web-UI step by the admin), then persists. Fails the save if
// the device can't be configured. See wiki/concepts/device-input-flow.md.
app.post<{ Body: AssignBody }>(
"/api/setup/assign",
{ preHandler: adminGuard },
async (req, reply) => {
const { lane, category, driverId, config } = req.body;
const { lane, category, driverId, config, backendIp } = req.body;
const driver = registry.get(driverId);
if (!driver || driver.category !== category) {
return reply.code(400).send({ error: `invalid driver for ${category}: ${driverId}` });
}
const id = randomUUID();
const fullConfig: Record<string, unknown> = { ...config };
// The web password the admin typed is a DESIRED value, not a stored fact:
// it's passed to the driver (via create(config) below) as the rotation
// target, but we do NOT persist it from the form. Only harden()'s VERIFIED
// secrets.webPassword gets saved — otherwise a failed rotation would leave
// the DB claiming a password the device never accepted (login stays old).
delete fullConfig.webPassword;
// webPasswordCurrent is an input-only credential (the OLD password used to
// authorize the change) — never persist it as typed.
delete fullConfig.webPasswordCurrent;
// Residual-risk warnings from device hardening (shown to the admin; the
// save still succeeds — these are "configured, but note X" advisories).
const hardenWarnings: string[] = [];
let device;
try {
registry.create(driverId, config); // validates required fields
device = registry.create(driverId, config); // validates required fields
} catch (err) {
return reply.code(400).send({ error: (err as Error).message });
}
// Configure the device on save (before persisting, so we don't store a row
// for a device we couldn't configure):
// 1. fix preconditions (e.g. disable input_link_relay so a button press
// doesn't auto-fire its relay — host must decide first),
// 2. harden (relay password + disable unused protocol channels), and
// 3. set up input push (Digest creds + push URLs).
// Each step is a device config write (the device reboots on apply).
try {
if (hasPreconditions(device)) {
const fixed = await device.fixPreconditions();
if (!fixed.ok) {
const unfixable = fixed.issues.find((i) => !i.fixable);
return reply.code(502).send({
error: `device precondition not satisfied: ${unfixable?.message ?? fixed.issues[0]?.message}`,
});
}
}
if (isHardenable(device)) {
const { secrets, warnings } = await device.harden();
Object.assign(fullConfig, secrets); // e.g. relayPassword
// Surface residual-risk warnings (e.g. firmware that won't disable the
// password-less string protocol) so the admin can act (web-UI step).
for (const w of warnings ?? []) {
app.log.warn(`harden(${driverId} ${id}): ${w}`);
hardenWarnings.push(w);
}
}
if (hasPushConfig(device)) {
const host = String(config.host ?? "");
// Admin-provided backend IP wins; else auto-derive (on-subnet NIC).
const pushHost = backendIp ?? backendIpForDevice(host);
if (!pushHost) {
return reply.code(400).send({
error: `cannot determine the backend IP on the device's subnet (${host}). Pick one in setup or set BACKEND_HOST_IP.`,
});
}
const pushUser = "dingtian";
// 24 hex chars = 96 bits. The Dingtian `pass` field caps at 31 chars
// (longer is silently truncated → auth mismatch), so keep it short.
const pushPassword = randomBytes(12).toString("hex");
await device.configureInputPush({
host: pushHost,
port: backendPort(),
pathBase: `/api/devices/${driverId}/${id}/input`,
auth: { user: pushUser, password: pushPassword },
});
fullConfig.pushUser = pushUser;
fullConfig.pushPassword = pushPassword;
// Record the backend IP the device was told to push to — lets us detect
// a later mismatch if the host's IP changes.
fullConfig.backendIp = pushHost;
}
} catch (err) {
return reply
.code(502)
.send({ error: `device configuration failed: ${(err as Error).message}` });
}
const row = {
id: randomUUID(),
id,
lane,
category,
driverId,
config,
config: fullConfig,
enabled: true,
};
await db.insert(laneDevices).values(row);
return reply.code(201).send(row);
onAssignmentsChanged(); // refresh derived state (device->lane map)
// Don't echo device secrets back (push Digest password, web-UI login, …).
return reply.code(201).send({
...row,
config: redactSecrets(fullConfig),
...(hardenWarnings.length ? { warnings: hardenWarnings } : {}),
});
},
);
// Unassign (remove) a device instance. The schema is multi-instance — one row
// per (lane, category, instance) — so removing one is just deleting its row by
// id. Lets the admin manage a LIST of devices per category (add/remove), not a
// fixed one-per-category slot. Admin-only. See wiki/concepts/first-run-setup.md.
//
// NOTE: we only drop our row; we do NOT un-harden / un-configure the device
// itself (e.g. clear the Dingtian push URL). The device keeps its last config
// harmlessly — pushes from an unknown device id are already rejected (see
// routes/devices.ts), and re-assigning reconfigures it. A future "factory
// reset on unassign" can hook here if needed.
app.delete<{ Params: { id: string } }>(
"/api/setup/assign/:id",
{ preHandler: adminGuard },
async (req, reply) => {
const existing = await db
.select()
.from(laneDevices)
.where(eq(laneDevices.id, req.params.id))
.get();
if (!existing) return reply.code(404).send({ error: "no such device assignment" });
await db.delete(laneDevices).where(eq(laneDevices.id, req.params.id));
onAssignmentsChanged(); // refresh derived state (device->lane map)
app.log.info(`unassigned device ${req.params.id} (${existing.category}/${existing.driverId}, lane ${existing.lane})`);
return reply.code(204).send();
},
);
+61 -2
View File
@@ -3,7 +3,15 @@ import jwt from "@fastify/jwt";
import Fastify, { type FastifyInstance } from "fastify";
import { createDb, type Db } from "@parking/db";
import { TOKEN_COOKIE, requireJwtSecret } from "./auth.js";
import { deviceEvents } from "./device-events.js";
import { EventLog } from "./event-log.js";
import { LaneMap } from "./lane-map.js";
import { PrinterMonitor } from "./printer-monitor.js";
import { buildSigner } from "./signer.js";
import { authRoutes } from "./routes/auth.js";
import { deviceRoutes } from "./routes/devices.js";
import { eventRoutes } from "./routes/events.js";
import { printerRoutes } from "./routes/printers.js";
import { setupRoutes } from "./routes/setup.js";
// The backend is Fastify (Node). Hardware drivers live as isolated Fastify
@@ -39,11 +47,62 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
// Local username/password login → JWT in an HttpOnly cookie + CSRF cookie.
await authRoutes(app, db);
// device id -> lane resolver. Built from lane_devices at startup and refreshed
// by setupRoutes on assign/unassign, so device events can be stamped with the
// lane the device belongs to (events carry the device id, not a lane).
const laneMap = new LaneMap(db);
laneMap.refresh();
// Device-agnostic setup: the admin selects devices per lane from the driver
// catalog at first-run. See wiki/concepts/first-run-setup.md.
await setupRoutes(app, db);
await setupRoutes(app, db, () => laneMap.refresh());
// TODO: device-driver runtime plugins, append-only event-log routes.
// Inbound device pushes (e.g. Dingtian Input Link URL → button events),
// guarded by source-IP allowlist + a shared-secret path token, both read from
// the device's lane_devices config (written on assign).
await deviceRoutes(app, db);
// Live printer-status monitor: polls printers (paper/cover/cutter/offline) and
// pushes changes to the booth UI. setupRoutes() has already registered the
// built-in drivers the monitor needs. See wiki/concepts/printer-status-monitoring.md.
const printerMonitor = new PrinterMonitor(db, app.log);
await printerRoutes(app, printerMonitor);
app.addHook("onReady", async () => printerMonitor.start());
app.addHook("onClose", async () => printerMonitor.stop());
// Append-only signed event log. Subscribe device pushes (e.g. Dingtian button
// presses) into the hash-chained, signed `events` table — the anti-fraud audit
// trail. The device is NOT trusted; the host record is the source of truth, and
// a relay open with no matching signed event is itself the anomaly. We record
// the raw input faithfully as `input_received` (not yet a `vehicle_entry` — that
// comes with the full entry flow). See wiki/concepts/append-only-event-chain.md.
const eventLog = new EventLog(db, buildSigner(app.log));
await eventRoutes(app, db, eventLog);
const unsubscribeInput = deviceEvents.onInput((e) => {
// Resolve which lane the device belongs to. -1 marks "device fired but isn't
// mapped to a lane" (assigned without a lane, or a stale id) — still recorded
// faithfully (the chain is append-only) rather than silently dropped or
// mis-stamped as lane 0, which is a real lane.
const lane = laneMap.laneFor(e.deviceId) ?? -1;
if (lane === -1) {
app.log.warn(`input from unmapped device ${e.driverId}:${e.deviceId} — logged as lane -1`);
}
eventLog
.append({
type: "input_received",
lane,
// `source` is an IdentitySource (wiegand/lpr/qr/ticket/manual) — how a
// VEHICLE was identified. A raw input has none, so it stays null. The
// device provenance lives in `identity` instead.
source: null,
identity: `${e.driverId}:${e.deviceId} input:${e.input}/${e.edge}`,
occurredAt: e.at,
})
.catch((err) => app.log.error(`event-log append failed: ${(err as Error).message}`));
});
app.addHook("onClose", async () => unsubscribeInput());
// TODO: entry flow (input event → signed event → print → relay).
return app;
}
+57
View File
@@ -0,0 +1,57 @@
import { createHmac, timingSafeEqual } from "node:crypto";
import type { Signer } from "@parking/shared";
// Concrete signers for the append-only event chain. The Signer interface is the
// abstraction over the ATECC608 secure element (open-question #6 — chip not yet
// confirmed wired). Until the chip is present we use a software HMAC signer:
// it makes the chain self-consistent + tamper-evident, but is NOT unforgeable by
// someone who owns the host (only the ATECC608's non-extractable key is). The
// swap to hardware is a new Signer impl — no event-log changes.
// See wiki/concepts/append-only-event-chain.md and wiki/entities/atecc608.md.
/** HMAC-SHA256 software signer. Key from env; fail fast if missing in prod. */
export class SoftwareSigner implements Signer {
readonly keyId: string;
readonly #key: Buffer;
constructor(secret: string, keyId = "sw-hmac-v1") {
this.#key = Buffer.from(secret, "utf8");
this.keyId = keyId;
}
sign(payload: string): string {
return createHmac("sha256", this.#key).update(payload, "utf8").digest("hex");
}
verify(payload: string, signature: string): boolean {
const expected = this.sign(payload);
// Constant-time compare; bail on length mismatch (timingSafeEqual throws).
if (expected.length !== signature.length) return false;
return timingSafeEqual(Buffer.from(expected, "hex"), Buffer.from(signature, "hex"));
}
}
/**
* Build the process signer. Uses EVENT_SIGNING_KEY (HMAC secret). Falls back to
* the JWT secret only as a last resort so dev works out of the box — logged as a
* warning, because reusing the auth secret for event signing is not ideal.
*
* TODO(atecc608): when the secure element is wired, return an Atecc608Signer here
* (keyId "atecc608-slotN"); existing events stay verifiable via their stored keyId.
*/
export function buildSigner(log?: { warn: (msg: string) => void }): Signer {
const dedicated = process.env.EVENT_SIGNING_KEY;
if (dedicated && dedicated.length >= 16) {
return new SoftwareSigner(dedicated);
}
const jwtSecret = process.env.JWT_SECRET;
if (jwtSecret && jwtSecret.length >= 16) {
log?.warn(
"event signing: EVENT_SIGNING_KEY unset — falling back to JWT_SECRET. Set a dedicated key (and wire the ATECC608) before production.",
);
return new SoftwareSigner(jwtSecret, "sw-hmac-jwtfallback");
}
throw new Error(
"event signing: no signing key. Set EVENT_SIGNING_KEY (>=16 chars) for the append-only event chain.",
);
}
+377 -35
View File
@@ -1,39 +1,54 @@
import { useEffect, useState } from "react";
import { useState, useEffect, useCallback } from "react";
import {
assignDevice,
discoverDevices,
fetchBackendIps,
fetchCatalog,
fetchState,
testDevice,
unassignDevice,
type Assignment,
type BackendIpCandidate,
type Catalog,
type CatalogEntry,
type DeviceCategory,
type DiscoveredDevice,
type TestResult,
} from "./api.js";
// First-run setup wizard (scaffold). The admin picks a device per category for a
// lane from the driver catalog and fills in its connection config. Drivers that
// support LAN discovery (e.g. UHPPOTE) get a "Scan" button that lists found
// devices; selecting one auto-fills the config. Auth is via the admin's session
// cookie (the SPA only renders this for admins). See wiki/concepts/first-run-setup.md
// and device-discovery.md.
// First-run setup wizard (scaffold). The admin assigns devices per lane from the
// driver catalog. The data model is multi-instance — one lane_devices row per
// instance — so EVERY category supports more than one device: each section lists
// the already-assigned instances (with Remove) and an "Add" form. Drivers that
// support LAN discovery get a "Scan" button. Auth is via the admin's session
// cookie. See wiki/concepts/first-run-setup.md and device-discovery.md.
const CATEGORIES: { key: DeviceCategory; title: string }[] = [
{ key: "access", title: "Access controller" },
{ key: "reader", title: "Reader" },
{ key: "camera", title: "Camera (entry/exit snapshot)" },
{ key: "printer", title: "Printer" },
const CATEGORIES: { key: DeviceCategory; title: string; noun: string }[] = [
{ key: "access", title: "Access controllers", noun: "access controller" },
{ key: "reader", title: "Readers", noun: "reader" },
{ key: "camera", title: "Cameras (entry/exit snapshot)", noun: "camera" },
{ key: "printer", title: "Printers", noun: "printer" },
];
export function SetupWizard() {
const [catalog, setCatalog] = useState<Catalog | null>(null);
const [assignments, setAssignments] = useState<Assignment[] | null>(null);
const [lane, setLane] = useState(1);
const [picked, setPicked] = useState<Partial<Record<DeviceCategory, string>>>({});
const [error, setError] = useState<string | null>(null);
const reloadState = useCallback(() => {
return fetchState()
.then((s) => setAssignments(s.assignments))
.catch((e: Error) => setError(e.message));
}, []);
useEffect(() => {
fetchCatalog().then(setCatalog).catch((e: Error) => setError(e.message));
}, []);
reloadState();
}, [reloadState]);
if (error) return <p style={{ color: "crimson" }}>Failed to load catalog: {error}</p>;
if (!catalog) return <p>Loading device catalog…</p>;
if (error) return <p style={{ color: "crimson" }}>Failed to load setup: {error}</p>;
if (!catalog || !assignments) return <p>Loading device catalog…</p>;
return (
<section>
@@ -49,44 +64,232 @@ export function SetupWizard() {
style={{ width: "4rem" }}
/>
</label>
<span style={{ color: "#666", fontSize: "0.85em" }}>
Devices are added per lane. Switch lanes to configure another.
</span>
</div>
{CATEGORIES.map(({ key, title }) => (
<CategoryPicker
{CATEGORIES.map(({ key, title, noun }) => (
<CategorySection
key={key}
lane={lane}
category={key}
title={title}
noun={noun}
entries={catalog[key]}
discoverableIds={catalog.discoverable}
selectedId={picked[key]}
onSelect={(id) => setPicked((p) => ({ ...p, [key]: id }))}
assignments={assignments.filter((a) => a.category === key && a.lane === lane)}
onChanged={reloadState}
/>
))}
</section>
);
}
function CategoryPicker({
function CategorySection({
lane,
category,
title,
noun,
entries,
discoverableIds,
selectedId,
onSelect,
assignments,
onChanged,
}: {
lane: number;
category: DeviceCategory;
title: string;
noun: string;
entries: CatalogEntry[];
discoverableIds: string[];
selectedId: string | undefined;
onSelect: (id: string) => void;
assignments: Assignment[];
onChanged: () => Promise<void> | void;
}) {
// Show the add-form automatically when nothing is assigned yet; otherwise it's
// collapsed behind "Add another" so the list stays the focus.
const [adding, setAdding] = useState(false);
// Warnings from the most recent save (e.g. "string protocol could not be
// disabled — finish in the device web UI"). Persist after the form closes.
const [warnings, setWarnings] = useState<string[]>([]);
const showForm = adding || assignments.length === 0;
return (
<fieldset style={{ marginTop: "1rem" }}>
<legend>
{title} <span style={{ color: "#888", fontWeight: 400 }}>· lane {lane}</span>
</legend>
{warnings.length > 0 && (
<div
style={{
margin: "0 0 0.75rem",
padding: "0.5rem 0.75rem",
background: "#fef3c7",
border: "1px solid #f59e0b",
borderRadius: 6,
}}
>
<strong style={{ color: "#92400e" }}>⚠ Saved, but action needed:</strong>
<ul style={{ margin: "0.25rem 0 0", paddingLeft: "1.25rem", color: "#92400e" }}>
{warnings.map((w, i) => (
<li key={i}>{w}</li>
))}
</ul>
<button type="button" onClick={() => setWarnings([])} style={{ marginTop: "0.5rem" }}>
Dismiss
</button>
</div>
)}
{assignments.length > 0 && (
<ul style={{ listStyle: "none", padding: 0, margin: "0 0 0.75rem" }}>
{assignments.map((a) => (
<AssignmentRow key={a.id} assignment={a} onChanged={onChanged} />
))}
</ul>
)}
{showForm ? (
<DeviceForm
lane={lane}
category={category}
entries={entries}
discoverableIds={discoverableIds}
onSaved={async (w) => {
setWarnings(w);
await onChanged();
setAdding(false);
}}
onCancel={assignments.length > 0 ? () => setAdding(false) : undefined}
/>
) : (
<button type="button" onClick={() => setAdding(true)}>
+ Add another {noun}
</button>
)}
</fieldset>
);
}
function AssignmentRow({
assignment,
onChanged,
}: {
assignment: Assignment;
onChanged: () => Promise<void> | void;
}) {
const [removing, setRemoving] = useState(false);
const [error, setError] = useState<string | null>(null);
// A short, human summary of the instance: role (if any) + host.
const cfg = assignment.config;
const role = typeof cfg.role === "string" ? cfg.role : null;
const host = typeof cfg.host === "string" ? cfg.host : null;
async function remove() {
if (!confirm(`Remove this ${assignment.driverId} device?`)) return;
setRemoving(true);
setError(null);
try {
await unassignDevice(assignment.id);
await onChanged();
} catch (e) {
setError((e as Error).message);
setRemoving(false);
}
}
return (
<li
style={{
display: "flex",
alignItems: "center",
gap: "0.5rem",
padding: "0.4rem 0.5rem",
borderBottom: "1px solid #eee",
}}
>
<strong>{assignment.driverId}</strong>
{role && <span style={{ color: "#0369a1" }}>{role}</span>}
{host && <span style={{ color: "#666" }}>{host}</span>}
{!assignment.enabled && <span style={{ color: "#b45309" }}>(disabled)</span>}
<span style={{ flex: 1 }} />
{error && <span style={{ color: "crimson" }}>{error}</span>}
<button type="button" onClick={remove} disabled={removing}>
{removing ? "Removing…" : "Remove"}
</button>
</li>
);
}
function DeviceForm({
lane,
category,
entries,
discoverableIds,
onSaved,
onCancel,
}: {
lane: number;
category: DeviceCategory;
entries: CatalogEntry[];
discoverableIds: string[];
onSaved: (warnings: string[]) => Promise<void> | void;
onCancel?: () => void;
}) {
const [selectedId, setSelectedId] = useState<string>("");
const selected = entries.find((e) => e.id === selectedId);
const canDiscover = selected != null && discoverableIds.includes(selected.id);
// Config values (auto-filled by discovery, editable by hand).
const [config, setConfig] = useState<Record<string, string | number>>({});
const [tested, setTested] = useState<TestResult | null>(null);
const [testing, setTesting] = useState(false);
const [testError, setTestError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [found, setFound] = useState<DiscoveredDevice[] | null>(null);
const [scanning, setScanning] = useState(false);
const [scanError, setScanError] = useState<string | null>(null);
// Backend push IP: which of OUR addresses the device should call back on. We
// auto-pick the NIC on the device's subnet, but surface it editable here so a
// multi-NIC host can be corrected (the chosen IP is baked into the device on
// save). Only relevant for drivers that push (the field hides if no candidates).
const [backendIps, setBackendIps] = useState<BackendIpCandidate[] | null>(null);
const [backendIp, setBackendIp] = useState<string>("");
// (Re)load backend-IP candidates whenever the device host changes after a
// successful test (the test confirms the host is real + reachable).
const testedHost = tested ? String(mergedConfig().host ?? "") : "";
useEffect(() => {
if (!testedHost) {
setBackendIps(null);
return;
}
let live = true;
fetchBackendIps(testedHost)
.then(({ candidates }) => {
if (!live) return;
setBackendIps(candidates);
setBackendIp((cur) => cur || candidates.find((c) => c.onDeviceSubnet)?.ip || "");
})
.catch(() => {
if (live) setBackendIps(null);
});
return () => {
live = false;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [testedHost]);
function selectDriver(id: string) {
setSelectedId(id);
setConfig({});
setFound(null);
resetStatus();
}
async function scan() {
if (!selected) return;
setScanning(true);
@@ -102,15 +305,67 @@ function CategoryPicker({
function applyDiscovered(d: DiscoveredDevice) {
setConfig((c) => ({ ...c, ...(d.config as Record<string, string | number>) }));
resetStatus();
}
// Config the user actually entered, merged over driver defaults.
function mergedConfig(): Record<string, string | number> {
const out: Record<string, string | number> = {};
for (const f of selected?.configFields ?? []) {
const v = config[f.key] ?? (f.default as string | number | undefined);
if (v !== undefined && v !== "") out[f.key] = v;
}
return out;
}
// Editing config invalidates a prior test.
function resetStatus() {
setTested(null);
setTestError(null);
setSaveError(null);
}
async function test() {
if (!selected) return;
setTesting(true);
setTestError(null);
setTested(null);
try {
setTested(await testDevice(selected.id, mergedConfig()));
} catch (e) {
setTestError((e as Error).message);
} finally {
setTesting(false);
}
}
async function save() {
if (!selected) return;
setSaving(true);
setSaveError(null);
try {
const result = await assignDevice({
lane,
category,
driverId: selected.id,
config: mergedConfig(),
...(backendIp ? { backendIp } : {}),
});
// Hand warnings to the parent so they persist after this form unmounts.
await onSaved(result.warnings ?? []);
} catch (e) {
setSaveError((e as Error).message);
} finally {
setSaving(false);
}
}
return (
<fieldset style={{ marginTop: "1rem" }}>
<legend>{title}</legend>
<div style={{ padding: "0.5rem", background: "#fafafa", borderRadius: 6 }}>
{entries.length === 0 ? (
<em>No drivers registered.</em>
) : (
<select value={selectedId ?? ""} onChange={(e) => onSelect(e.target.value)}>
<select value={selectedId} onChange={(e) => selectDriver(e.target.value)}>
<option value="" disabled>
Choose a device…
</option>
@@ -155,18 +410,105 @@ function CategoryPicker({
<label>
{f.label}
{f.required ? " *" : ""}{" "}
<input
type={f.type === "secret" ? "password" : f.type === "number" || f.type === "port" ? "number" : "text"}
value={config[f.key] ?? (f.default as string | number | undefined) ?? ""}
placeholder={f.help}
onChange={(e) => setConfig((c) => ({ ...c, [f.key]: e.target.value }))}
/>
{f.type === "select" ? (
<select
value={String(config[f.key] ?? (f.default as string | number | undefined) ?? "")}
onChange={(e) => {
const v = e.target.value;
setConfig((c) => ({ ...c, [f.key]: v }));
resetStatus();
}}
>
{f.options?.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
) : (
<input
type={f.type === "secret" ? "password" : f.type === "number" || f.type === "port" ? "number" : "text"}
value={config[f.key] ?? (f.default as string | number | undefined) ?? ""}
placeholder={f.help}
onChange={(e) => {
const v = e.target.value;
setConfig((c) => ({ ...c, [f.key]: v }));
resetStatus();
}}
/>
)}
</label>
</div>
))}
{/* Test (no save/no device change) then Save (configures + persists). */}
<div style={{ marginTop: "0.75rem", display: "flex", gap: "0.5rem", alignItems: "center" }}>
<button type="button" onClick={test} disabled={testing}>
{testing ? "Testing…" : "Test connection"}
</button>
<button type="button" onClick={save} disabled={saving}>
{saving ? "Saving…" : "Save & configure"}
</button>
{onCancel && (
<button type="button" onClick={onCancel} disabled={saving}>
Cancel
</button>
)}
</div>
{testError && <p style={{ color: "crimson", margin: "0.5rem 0 0" }}>Test failed: {testError}</p>}
{tested && (
<div style={{ margin: "0.5rem 0 0" }}>
<div>
Device: <HealthBadge status={tested.health.status} />
{tested.health.detail && <span style={{ color: "#666" }}> — {tested.health.detail}</span>}
</div>
{tested.preconditions.ok ? (
<div style={{ color: "#16a34a" }}>● preconditions OK</div>
) : (
tested.preconditions.issues.map((i) => (
<div key={i.key} style={{ color: "#d97706" }}>
⚠ {i.message}
{i.fixable && <span style={{ color: "#666" }}> (auto-fixed on save)</span>}
</div>
))
)}
</div>
)}
{/* Backend push IP — only for push-capable devices (candidates present).
Pre-filled with the auto-pick; editable for multi-NIC hosts. */}
{backendIps && backendIps.length > 0 && (
<div style={{ margin: "0.5rem 0 0" }}>
<label>
Backend push IP{" "}
<select value={backendIp} onChange={(e) => setBackendIp(e.target.value)}>
{!backendIps.some((c) => c.onDeviceSubnet) && (
<option value="" disabled>
Choose an address…
</option>
)}
{backendIps.map((c) => (
<option key={c.ip} value={c.ip}>
{c.ip} ({c.iface}){c.onDeviceSubnet ? " — on device subnet" : ""}
</option>
))}
</select>
</label>
{!backendIps.some((c) => c.onDeviceSubnet) && (
<span style={{ marginLeft: 8, color: "#d97706" }}>
⚠ no NIC on the device's subnet — the device may not reach the backend
</span>
)}
<p style={{ margin: "0.25rem 0 0", color: "#666", fontSize: "0.85em" }}>
The address this device will POST input events to.
</p>
</div>
)}
{saveError && <p style={{ color: "crimson", margin: "0.5rem 0 0" }}>Save failed: {saveError}</p>}
</div>
)}
</fieldset>
</div>
);
}
+70 -3
View File
@@ -110,7 +110,7 @@ export interface DiscoveredDevice {
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[]> {
const body = await apiFetch<{ devices: DiscoveredDevice[] }>(
`/api/setup/discover/${driverId}`,
@@ -118,13 +118,80 @@ export async function discoverDevices(driverId: string): Promise<DiscoveredDevic
return body.devices;
}
export type DeviceConfig = Record<string, string | number | boolean>;
export interface TestResult {
health: { status: string; detail?: string };
preconditions: {
ok: boolean;
issues: { key: string; message: string; fixable: boolean }[];
};
}
/** Test a device config (reachability + preconditions) without saving. */
export function testDevice(driverId: string, config: DeviceConfig): Promise<TestResult> {
return apiFetch<TestResult>("/api/setup/test", {
method: "POST",
body: JSON.stringify({ driverId, config }),
});
}
export interface BackendIpCandidate {
ip: string;
iface: string;
onDeviceSubnet: boolean;
}
/** Local IPs the device could push to (on-subnet first), for the wizard to
* pre-fill/override. Matters on multi-NIC hosts. */
export function fetchBackendIps(
host: string,
): Promise<{ candidates: BackendIpCandidate[]; port: number }> {
return apiFetch(`/api/setup/backend-ips?host=${encodeURIComponent(host)}`);
}
export interface AssignBody {
lane: number;
category: DeviceCategory;
driverId: string;
config: Record<string, string | number | boolean>;
config: DeviceConfig;
/** Backend IP the device should push to (overrides auto-pick). */
backendIp?: string;
}
export function assignDevice(body: AssignBody): Promise<unknown> {
/** Save + configure the device (preconditions, push setup), then persist. */
export function assignDevice(body: AssignBody): Promise<AssignResult> {
return apiFetch("/api/setup/assign", { method: "POST", body: JSON.stringify(body) });
}
/** A persisted device assignment (one per instance; machine-only secrets stripped). */
export interface Assignment {
id: string;
lane: number;
category: DeviceCategory;
driverId: string;
config: DeviceConfig;
enabled: boolean;
createdAt?: string;
}
/** Assign response = the saved assignment plus any residual-risk warnings
* (e.g. "string protocol could not be disabled — finish in the device web UI"). */
export interface AssignResult extends Assignment {
warnings?: string[];
}
export interface SetupState {
completedAt: string | null;
assignments: Assignment[];
}
/** Current setup status + all assigned device instances. */
export function fetchState(): Promise<SetupState> {
return apiFetch<SetupState>("/api/setup/state");
}
/** Remove one assigned device instance by id. */
export function unassignDevice(id: string): Promise<void> {
return apiFetch(`/api/setup/assign/${id}`, { method: "DELETE" });
}
+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",
@@ -0,0 +1,757 @@
import { randomBytes } from "node:crypto";
import { createSocket } from "node:dgram";
import { request as httpRequest } from "node:http";
import type {
AccessControlDevice,
DeviceHealth,
HardenableDevice,
HardenResult,
InputDevice,
InputEvent,
PreconditionDevice,
PreconditionResult,
PushConfig,
PushConfigurableDevice,
} from "../interfaces.js";
import type { AccessDriver, DeviceConfig } from "../registry.js";
import { hostField, portField, stubLog } from "./common.js";
// Dingtian relay+input controller driver. Backed by the "Dingtian string"
// protocol over UDP. Implements AccessControlDevice (relay/barrier) AND the
// optional InputDevice capability (host-readable buttons, decoupled from relays)
// — which is what makes host-in-the-loop entry possible. See
// wiki/entities/dingtian-relay.md and access-controller-button-flow.md.
//
// SAFETY: pulseOpen expresses INTENT only. It uses the device's jog/pulse
// (momentary) so the relay self-releases; we never time a close against a
// vehicle — anti-crush/auto-reverse is the barrier operator's firmware.
// See wiki/concepts/barrier-not-a-door.md.
//
// SECURITY: unauthenticated UDP — the board must sit on an isolated VLAN
// reachable only by the host. See wiki/concepts/network-isolation.md.
//
// NOTE: by default Dingtian links each input to auto-fire its relay
// (input_link_relay). That must be DISABLED on the device for ticket-first
// entry, else the button opens the barrier before the host can act.
// (The string-protocol UDP helper was removed: harden() now disables the
// password-less string protocol entirely, and status reads use the
// authenticated binary read — see #status() / readStatusFrame.)
/**
* Send a Dingtian *binary* protocol frame (UDP, default port 60000) and await
* the reply. Used for ALL relay traffic — control AND status read — because,
* unlike the string protocol, the binary protocol carries a password (`relay_pw`).
* harden() disables the string protocol precisely because it has NO password and
* can fire relays (an unauthenticated `"11"` opens relay 1). With the string path
* closed, relay_pw actually gates control. Frame verified on hardware:
*
* FF AA <session> <relayCmd> <pwLo> <pwHi> <data...>
*
* FF = command "set relay"
* AA = result xor (0x00 ^ 0xAA, pc→device)
* session = echoed back
* relayCmd = 0 read status, 1 write, 3 jogging, …
* pwLo,pwHi = relay password, 16-bit LSB-first (0 = none)
* data = command-specific
*
* NOTE: relay_pw + plaintext UDP is defence-in-depth, NOT a boundary. An attacker
* who sniffs the VLAN can replay the password. The real guarantee is the signed
* event log (relay open with no signed command = fraud) + VLAN isolation.
*/
function binaryUdp(
host: string,
port: number,
frame: Buffer,
timeoutMs: number,
localAddress?: string,
): Promise<Buffer> {
return new Promise((resolve, reject) => {
const sock = createSocket("udp4");
let settled = false;
const done = (err: Error | null, val: Buffer | null) => {
if (settled) return;
settled = true;
clearTimeout(timer);
sock.close();
err ? reject(err) : resolve(val!);
};
const timer = setTimeout(() => done(new Error("timeout"), null), timeoutMs);
sock.on("error", (e) => done(e, null));
sock.on("message", (m) => done(null, m));
// Bind to a specific local address (the device-facing NIC) on multi-homed
// hosts, so the device replies to the right source IP. See net.ts.
const onBound = () => {
sock.send(frame, port, host, (e) => {
if (e) done(e, null);
});
};
if (localAddress) sock.bind({ address: localAddress }, onBound);
else sock.bind(onBound);
});
}
let binarySession = 0;
/**
* Build a binary "read relay status" frame (relay command 0x00). The device
* replies `FF AA <session> 00 <relayBytes> <inputBytes>` (status widths scale
* with channel count). This is the *authenticated* status read — unlike the
* string protocol's `00`, it carries the relay password, so we can disable the
* password-less string protocol entirely. Frame: `FF AA <session> 00 <pwLo> <pwHi>`.
* Verified on hardware (4ch): reply `ff aa 00 00 01 0f` = relay1 on, inputs 1111.
*/
function readStatusFrame(password: number): Buffer {
const session = binarySession++ & 0xff;
return Buffer.from([0xff, 0xaa, session, 0x00, password & 0xff, (password >> 8) & 0xff]);
}
/** Build a binary "write relay with jogging" frame (relay on, auto-off). */
function jogFrame(channel: number, password: number, jogMs: number): Buffer {
const session = binarySession++ & 0xff;
// relay index + on/off: bit0 = on, bits1..7 = (channel-1)
const relayByte = (((channel - 1) & 0x7f) << 1) | 0x01;
const units = Math.max(1, Math.round(jogMs / 100)); // 100ms units
return Buffer.from([
0xff,
0xaa,
session,
0x03, // jogging
password & 0xff,
(password >> 8) & 0xff,
relayByte,
units & 0xff,
(units >> 8) & 0xff,
]);
}
/** Build a binary "write relay" frame (latch on/off via mask+set). */
function writeRelayFrame(channel: number, on: boolean, password: number, channels: number): Buffer {
const session = binarySession++ & 0xff;
const bit = 1 << (channel - 1);
const mask = bit; // only this channel updates
const set = on ? bit : 0;
// 4ch: mask + set are 1 byte each (bit0→relay1).
const widthBytes = channels <= 8 ? 1 : channels <= 16 ? 2 : channels <= 24 ? 3 : 4;
const maskBuf = Buffer.alloc(widthBytes);
const setBuf = Buffer.alloc(widthBytes);
maskBuf.writeUIntLE(mask, 0, widthBytes);
setBuf.writeUIntLE(set, 0, widthBytes);
return Buffer.concat([
Buffer.from([0xff, 0xaa, session, 0x01, password & 0xff, (password >> 8) & 0xff]),
maskBuf,
setBuf,
]);
}
const rand16 = () => randomBytes(2).readUInt16BE(0);
/** GET a CGI path on the device's HTTP server and return the raw response text. */
function cgiGet(host: string, httpPort: number, path: string, timeoutMs: number, localAddress?: string): Promise<string> {
return new Promise((resolve, reject) => {
const req = httpRequest({ host, port: httpPort, path, method: "GET", timeout: timeoutMs, localAddress }, (res) => {
let data = "";
res.on("data", (c) => (data += c));
res.on("end", () => resolve(data));
});
req.on("error", reject);
req.on("timeout", () => req.destroy(new Error("cgi timeout")));
req.end();
});
}
interface DingtianStatus {
relays: boolean[]; // true = on
inputs: boolean[]; // true = active (after resting-level normalisation)
channels: number;
}
const INPUT_LINK_ISSUE = {
key: "input_link_relay",
message:
"input_link_relay is ENABLED — a button press will auto-fire its relay (opening the barrier before the host can act). Disable it for ticket-first entry.",
fixable: true,
} as const;
/** GET/POST the device's JSON config API (HTTP; port is configurable). */
function configApi(
host: string,
httpPort: number,
path: string,
method: "GET" | "POST",
body: string | null,
timeoutMs: number,
sessionId?: number, // device session check: sent as Cookie: session=<id>
localAddress?: string, // bind outbound to the device-facing NIC (multi-homed hosts)
): Promise<string> {
return new Promise((resolve, reject) => {
// The device's embedded HTTP server does NOT support chunked request bodies.
// Node uses chunked encoding when Content-Length is absent, so the device
// silently ignores the body (POST returns {"status":0} but nothing changes).
// Always set Content-Length explicitly.
const headers: Record<string, string | number> = {};
if (body) {
headers["content-type"] = "application/json";
headers["content-length"] = Buffer.byteLength(body);
}
// When the device's HTTP session check is enabled, the CGI API requires a
// matching session cookie (a numeric magic id). See programming manual §3.8.
if (sessionId) headers["cookie"] = `session=${sessionId}`;
const req = httpRequest(
{
host,
port: httpPort,
path,
method,
timeout: timeoutMs,
localAddress,
headers: Object.keys(headers).length ? headers : undefined,
},
(res) => {
let data = "";
res.on("data", (c) => (data += c));
res.on("end", () => resolve(data));
},
);
req.on("error", reject);
req.on("timeout", () => req.destroy(new Error("config api timeout")));
if (body) req.write(body);
req.end();
});
}
class DingtianController
implements
AccessControlDevice,
InputDevice,
PreconditionDevice,
PushConfigurableDevice,
HardenableDevice
{
readonly driverId = "dingtian";
readonly #host: string;
readonly #port: number; // legacy string-protocol port (60001) — protocol now disabled by harden(); kept for config compat
readonly #binaryPort: number; // binary protocol (relay control) — UDP 60000
readonly #relayPassword: number; // relay_pw (0 = none)
readonly #sessionId: number; // device CGI session id (0 = session check off)
readonly #httpPort: number;
readonly #timeout: number;
// Local IP to source outbound device traffic from (the device-facing NIC on a
// multi-homed host). undefined = let the OS choose. See net.ts / device-facing-ip.
readonly #localAddress: string | undefined;
readonly #channels: number;
/** Input level at rest; an input is "active" when it differs from this. */
readonly #restingHigh: boolean;
readonly #pulseMs: number;
/** Device web-UI login user (gates the browser UI only, not the CGI API). */
readonly #webUser: string;
/** The password the admin WANTS the device to have (the rotation target). If
* blank, harden() generates a random one. */
readonly #webPassword: string | undefined;
/** The device's CURRENT password, used as the OLD cred for userset.cgi. Defaults
* to "admin" (factory). Distinct from #webPassword (the desired new value) so an
* admin typing a desired password doesn't break rotation. */
readonly #webPasswordCurrent: string;
#poll: ReturnType<typeof setInterval> | null = null;
#last: boolean[] | null = null;
#subs = new Set<(e: InputEvent) => void>();
constructor(config: DeviceConfig) {
this.#host = String(config.host);
this.#port = config.port ? Number(config.port) : 60001;
this.#binaryPort = config.binaryPort ? Number(config.binaryPort) : 60000;
this.#relayPassword = config.relayPassword ? Number(config.relayPassword) : 0;
this.#sessionId = config.sessionId ? Number(config.sessionId) : 0;
this.#httpPort = config.httpPort ? Number(config.httpPort) : 80;
this.#localAddress = config.localAddress ? String(config.localAddress) : undefined;
this.#timeout = config.timeoutMs ? Number(config.timeoutMs) : 2000;
this.#channels = config.channels ? Number(config.channels) : 4;
// This unit idles with inputs HIGH (status "1111"); a press pulls LOW.
this.#restingHigh = config.inputRestingHigh !== false;
this.#pulseMs = config.pulseMs ? Number(config.pulseMs) : 500;
this.#webUser = config.webUser ? String(config.webUser) : "admin";
// webPassword = the DESIRED login (admin's choice; blank → harden generates).
this.#webPassword = config.webPassword ? String(config.webPassword) : undefined;
// webPasswordCurrent = the device's EXISTING password (the old cred userset.cgi
// checks). Defaults to admin (factory). After a successful rotation, assign
// stores the new value back here so a re-run can rotate again.
this.#webPasswordCurrent = config.webPasswordCurrent
? String(config.webPasswordCurrent)
: "admin";
}
async connect(): Promise<void> {
await this.healthCheck();
}
async disconnect(): Promise<void> {
this.#stopPolling();
stubLog(this.driverId, "disconnect");
}
async healthCheck(): Promise<DeviceHealth> {
try {
await this.#status();
return { status: "ready" };
} catch (err) {
return { status: "offline", detail: (err as Error).message };
}
}
// --- relay / barrier ----------------------------------------------------
/**
* Pulse a relay open (momentary). Channel is 1-based. Intent only — the device
* jogs the relay ON then auto-releases after pulseMs, so we never time a close
* against a vehicle. Uses the binary protocol + relay password (authenticated).
*/
async pulseOpen(doorId: number): Promise<void> {
this.#assertChannel(doorId);
const frame = jogFrame(doorId, this.#relayPassword, this.#pulseMs);
await binaryUdp(this.#host, this.#binaryPort, frame, this.#timeout, this.#localAddress);
}
/** Latch a relay on/off (e.g. for a held-open mode). Channel is 1-based. */
async setRelay(doorId: number, on: boolean): Promise<void> {
this.#assertChannel(doorId);
const frame = writeRelayFrame(doorId, on, this.#relayPassword, this.#channels);
await binaryUdp(this.#host, this.#binaryPort, frame, this.#timeout, this.#localAddress);
}
async getDoorStatus(doorId: number): Promise<"open" | "closed"> {
this.#assertChannel(doorId);
const { relays } = await this.#status();
// "open" here = relay energised. Physical door state needs a sensor input.
return relays[doorId - 1] ? "open" : "closed";
}
// --- inputs (buttons) ---------------------------------------------------
async readInputs(): Promise<boolean[]> {
return (await this.#status()).inputs;
}
onInput(cb: (event: InputEvent) => void): () => void {
this.#subs.add(cb);
this.#startPolling();
return () => {
this.#subs.delete(cb);
if (this.#subs.size === 0) this.#stopPolling();
};
}
// --- preconditions ------------------------------------------------------
/**
* Parking requires `input_link_relay` DISABLED: otherwise a button press
* auto-fires its relay, opening the barrier before the host can act (print a
* ticket / decide). This is the configurable version of the UHPPOTE blocker.
*/
async checkPreconditions(): Promise<PreconditionResult> {
let cfg: Record<string, unknown>;
try {
cfg = await this.#readConfig();
} catch (err) {
return {
ok: false,
issues: [
{
key: "config_unreachable",
message: `could not read device config: ${(err as Error).message}`,
fixable: false,
},
],
};
}
return { ok: this.#linkDisabled(cfg), issues: this.#linkDisabled(cfg) ? [] : [INPUT_LINK_ISSUE] };
}
async fixPreconditions(): Promise<PreconditionResult> {
const cfg = await this.#readConfig();
if (this.#linkDisabled(cfg)) return { ok: true, issues: [] };
// Disable the master flag AND clear the per-input action maps.
const ilr = cfg.input_link_relay as Record<string, unknown>;
ilr.input_link_relay = 0;
if (Array.isArray(ilr.on_action_on)) {
ilr.on_action_on = (ilr.on_action_on as unknown[]).map(() => []);
}
await this.#writeConfig(cfg, (after) => this.#linkDisabled(after));
return this.checkPreconditions();
}
/**
* Configure the device to HTTP-push input (button) events to our backend —
* the "Input Link URL" feature. Each input N calls `${pathBase}/<N>/on` (and
* `/off`) on host:port via GET, authenticated with **HTTP Digest** (the device
* does Digest but not HTTPS-to-self-signed; both verified on hardware). The
* password is never sent on the wire and the secret is not in the URL.
* Enables the feature, plain HTTP, active-LOW. Replaces polling.
*/
async configureInputPush(opts: PushConfig): Promise<void> {
const cfg = await this.#readConfig();
const ilu = cfg.input_link_url as Record<string, unknown>;
const n = Number((ilu.cnt as number) ?? this.#channels);
const fill = (v: unknown) => Array.from({ length: n }, () => v);
ilu.en = 1;
ilu.active_level = fill(0); // active-LOW (matches this board's wiring)
ilu.tls = fill(0); // plain HTTP (device can't do HTTPS to self-signed)
ilu.auth = fill(2); // 2 = Digest
ilu.server = fill(opts.host);
ilu.port = fill(opts.port);
ilu.user = fill(opts.auth.user);
ilu.pass = fill(opts.auth.password);
ilu.on_method = fill(0); // GET
ilu.off_method = fill(0);
ilu.on_path = Array.from({ length: n }, (_, i) => `${opts.pathBase}/${i + 1}/on`);
ilu.off_path = Array.from({ length: n }, (_, i) => `${opts.pathBase}/${i + 1}/off`);
ilu.on_body = fill("");
ilu.off_body = fill("");
const wantPath = `${opts.pathBase}/1/on`;
await this.#writeConfig(cfg, (after) => {
const a = after.input_link_url as Record<string, unknown> | undefined;
const paths = a?.on_path as string[] | undefined;
const pass = a?.pass as string[] | undefined;
// Verify both the path and the (secret) password landed — the password is
// what the backend's Digest check depends on.
return (
a?.en === 1 &&
Array.isArray(paths) &&
paths[0] === wantPath &&
Array.isArray(pass) &&
pass[0] === opts.auth.password
);
});
}
// --- hardening ----------------------------------------------------------
/**
* Lock the device down for a flat (no-VLAN) network:
* - set a random relay password (`relay_pw`) so binary relay commands need it,
* - keep ONLY UDP1 binary (password-protected relay control + status read),
* - disable every other protocol channel: string, rs485, can, tcp×2, mqtt.
* Returns the relay password for the backend to persist (required to keep
* commanding the device afterwards).
*
* SECURITY — why the string protocol (UDP2) is now DISABLED (was a real hole):
* the Dingtian string protocol has NO password field and can *fire* relays
* (`"11"` = relay 1 on, `"21"` = off, `"11*"` = jog). Leaving it enabled — even
* "just for status reads" — let anyone on the network open any barrier with one
* unauthenticated UDP packet, completely bypassing relay_pw. Confirmed by
* sending `"11"` to port 60001 with no credentials and watching relay 1 close.
* So harden() sets udp2.p=255 and status reads move to the authenticated binary
* read (relay command 0x00 — see #status()).
*
* NOTE: deliberately does NOT touch the device's HTTP CGI session check
* (`session_en`). On this firmware enabling it makes the config-read API drop
* connections, locking us out of the very API we depend on (verified the hard
* way — required a factory reset). So we leave the config API as-is and rely on
* relay_pw + fewer open channels + the signed event log.
*
* Even with the string hole closed, all of this is plaintext over UDP/HTTP →
* defence-in-depth, NOT a boundary. The real guarantee is the signed event log
* (a relay open with no matching signed command is the fraud signal) plus VLAN
* isolation. See device-input-flow / network-isolation.
*/
async harden(): Promise<HardenResult> {
const cfg = await this.#readConfig();
const rc = cfg.relay_connect as Record<string, unknown>;
const relayPassword = 1 + (rand16() % 9999); // 1..9999 (0 = none)
rc.relay_pw = relayPassword;
// Keep ONLY UDP1=Binary (p:1) — it carries relay_pw for both control AND the
// status read. Disable everything else (p:255 = None), INCLUDING the string
// protocol (udp2), which is password-less and can fire relays.
(rc.udp1 as Record<string, unknown>).p = 1;
(rc.udp2 as Record<string, unknown>).p = 255;
(rc.rs485 as Record<string, unknown>).p = 255;
(rc.can as Record<string, unknown>).p = 255;
(rc.tcpc as Record<string, unknown>).p = 255;
(rc.tcps as Record<string, unknown>).p = 255;
(rc.mqtt as Record<string, unknown>).p = 255;
// NOTE: udp2 (string protocol) is set to 255 here, but it is NOT part of the
// blocking verify. On some firmware (e.g. V3.6J) the CONFIG API silently
// refuses to disable udp2 — it accepts the write, reboots, and clamps it back
// to enabled — even though every other channel applies and the device's own
// web UI CAN disable it. We don't want assign to hard-fail over a firmware
// quirk, so we attempt it, then re-check below and warn if it didn't stick.
const afterCfg = await this.#writeConfig(cfg, (after) => {
const a = after.relay_connect as Record<string, unknown> | undefined;
return (
a?.relay_pw === relayPassword &&
(a?.rs485 as Record<string, unknown> | undefined)?.p === 255 &&
(a?.mqtt as Record<string, unknown> | undefined)?.p === 255
);
});
const applied = [
"set relay password",
"disabled rs485/can/tcp/mqtt channels (kept password-protected UDP binary)",
];
const warnings: string[] = [];
const stringDisabled =
((afterCfg.relay_connect as Record<string, unknown>)?.udp2 as Record<string, unknown> | undefined)?.p === 255;
if (stringDisabled) {
applied.push("disabled the password-less string protocol (udp2)");
} else {
warnings.push(
"could not disable the string protocol (udp2) via the config API — this firmware ignores it. " +
"An unauthenticated UDP packet to the string port can still fire relays. " +
"Disable UDP2 in the device web UI, and rely on VLAN isolation + the signed event log. See dingtian-relay.md.",
);
}
const secrets: Record<string, string | number> = { relayPassword };
// Set the device web login to the admin's chosen password (or a random one).
// NOTE: cosmetic for the control plane — the CGI API needs NO auth (config
// read/write + relay fire all work unauthenticated), so the login only gates
// the interactive browser UI. We set it anyway (defence-in-depth) but it is
// NOT a boundary; the signed event log is. See dingtian-relay.md.
//
// CRITICAL: only persist webPassword if the rotation VERIFIABLY took effect.
// Otherwise the DB would claim a password the device doesn't have (the bug:
// admin types a new pw, rotation fails on the wrong old-cred, DB still saves
// the typed value, login stays admin/admin). On failure we warn instead.
try {
const newPassword = await this.#rotateWebLogin();
secrets.webUser = this.#webUser;
secrets.webPassword = newPassword;
// The new password is now the device's CURRENT one — store it so a future
// re-harden uses the right old cred.
secrets.webPasswordCurrent = newPassword;
applied.push("set the device web-UI login (verified on the device)");
} catch (err) {
warnings.push(
`could not set the device web-UI login: ${(err as Error).message} ` +
`The device login is UNCHANGED (still its previous password). The saved web password was NOT updated.`,
);
}
return { secrets, applied, warnings: warnings.length ? warnings : undefined };
}
/**
* Set the device web-UI login to the DESIRED password (the admin's choice, or a
* random one if none was given) via
* `userset.cgi?<user>&<old_pass>&<user>&<new_pass>&`. The device validates the
* OLD credentials, so we send #webPasswordCurrent (admin on a fresh device).
* Response `&<code>&…&`, code 0 = success.
*
* After the rotation we VERIFY by attempting a no-op rotate using the NEW
* password as the old cred — if that succeeds, the device really has the new
* password (this is what catches the "DB says X but device is still admin/admin"
* bug: a wrong old-cred makes the first call fail, and we never claim success).
* Returns the password now live on the device.
*/
async #rotateWebLogin(): Promise<string> {
const newPassword = this.#webPassword ?? randomBytes(12).toString("hex");
const u = encodeURIComponent(this.#webUser);
const setPath = (oldP: string, newP: string) =>
`/userset.cgi?${u}&${encodeURIComponent(oldP)}&${u}&${encodeURIComponent(newP)}&`;
const res = await cgiGet(this.#host, this.#httpPort, setPath(this.#webPasswordCurrent, newPassword), this.#timeout, this.#localAddress);
const code = res.split("&")[1];
if (code !== "0") {
throw new Error(
`userset.cgi rejected (response "${res.trim()}") — the device's current password is probably not "${this.#webPasswordCurrent}". ` +
`Set the correct current password, or factory-reset the device.`,
);
}
// VERIFY: a no-op rotate (new → new) only succeeds if the device truly has it.
const verify = await cgiGet(this.#host, this.#httpPort, setPath(newPassword, newPassword), this.#timeout, this.#localAddress);
if (verify.split("&")[1] !== "0") {
throw new Error(`web-login change did not take effect (verify response "${verify.trim()}")`);
}
return newPassword;
}
// --- config api internals ----------------------------------------------
async #readConfig(): Promise<Record<string, unknown>> {
const raw = await configApi(this.#host, this.#httpPort, "/api/v2/config.cgi", "GET", null, this.#timeout, this.#sessionId, this.#localAddress);
return JSON.parse(raw) as Record<string, unknown>;
}
/**
* Write full config back, then WAIT for the device to apply it. The device
* reboots on apply (~10s) and back-to-back writes onto a rebooting device are
* silently lost — so we poll until the device is reachable again AND `verify`
* confirms the change landed, retrying the write if needed.
*
* @param verify predicate over the re-read config; should return true once the
* intended change is present.
*/
async #writeConfig(
cfg: Record<string, unknown>,
verify: (after: Record<string, unknown>) => boolean,
): Promise<Record<string, unknown>> {
// The set endpoint requires `"command":"setconfig"` injected after `status`
// (the GET payload omits it). Rebuild preserving node order, command second.
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(cfg)) {
out[k] = v;
if (k === "status") out.command = "setconfig";
}
if (!("command" in out)) out.command = "setconfig";
const payload = JSON.stringify(out);
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
for (let attempt = 1; attempt <= 3; attempt++) {
// POST. The device resets on apply, so the connection may drop — that's
// expected, not failure.
try {
await configApi(this.#host, this.#httpPort, "/api/v2/config_set.cgi", "POST", payload, this.#timeout, this.#sessionId, this.#localAddress);
} catch {
// device likely reset on apply
}
// Poll for the device to come back and the change to be present.
for (let i = 0; i < 12; i++) {
await sleep(2000);
try {
const after = await this.#readConfig();
if (verify(after)) return after; // applied — return the landed config
} catch {
// still rebooting / unreachable — keep polling
}
}
// Not applied within the window — likely the POST hit a rebooting device.
// Loop and re-POST (now that it's reachable again).
}
throw new Error("dingtian: config write did not apply after retries");
}
#linkDisabled(cfg: Record<string, unknown>): boolean {
const ilr = cfg.input_link_relay as Record<string, unknown> | undefined;
if (!ilr) return true; // no such block → nothing to link
const flagOff = ilr.input_link_relay === 0;
const mapsEmpty =
!Array.isArray(ilr.on_action_on) ||
(ilr.on_action_on as unknown[]).every((a) => Array.isArray(a) && a.length === 0);
return flagOff || mapsEmpty;
}
// --- internals ----------------------------------------------------------
#assertChannel(ch: number): void {
if (!Number.isInteger(ch) || ch < 1 || ch > this.#channels) {
throw new Error(`dingtian: channel ${ch} out of range (1..${this.#channels})`);
}
}
/**
* Read relay + input status via the AUTHENTICATED binary protocol (relay
* command 0x00). Reply: `FF AA <session> 00 <relayBytes...> <inputBytes...>`,
* each field `ceil(channels/8)` bytes, LSB-first (bit0 → relay/input 1).
*
* SECURITY: deliberately NOT the string protocol's `00` — that query has no
* password field AND the string protocol can also *fire* relays, so leaving it
* enabled defeats relay_pw entirely (an attacker sends `"11"` to open relay 1
* with no auth). harden() disables the string protocol; status reads come here.
*/
async #status(): Promise<DingtianStatus> {
const frame = readStatusFrame(this.#relayPassword);
const reply = await binaryUdp(this.#host, this.#binaryPort, frame, this.#timeout, this.#localAddress);
const width = Math.max(1, Math.ceil(this.#channels / 8));
// header: FF AA session 00 (4 bytes) + relay field + input field
if (reply.length < 4 + width * 2) {
throw new Error(`dingtian: short binary status reply (${reply.length} bytes)`);
}
const relayVal = reply.readUIntLE(4, width);
const inputVal = reply.readUIntLE(4 + width, width);
const relays: boolean[] = [];
const inputs: boolean[] = [];
for (let i = 0; i < this.#channels; i++) {
const high = (inputVal & (1 << i)) !== 0;
relays.push((relayVal & (1 << i)) !== 0);
// active = differs from the resting level (a press pulls the line).
inputs.push(high !== this.#restingHigh);
}
return { relays, inputs, channels: this.#channels };
}
#startPolling(): void {
if (this.#poll) return;
const tick = async () => {
let inputs: boolean[];
try {
inputs = await this.readInputs();
} catch {
return; // transient; try again next tick
}
const prev = this.#last;
this.#last = inputs;
if (!prev) return; // first sample establishes a baseline, no events
const at = new Date().toISOString();
for (let i = 0; i < inputs.length; i++) {
if (inputs[i] === prev[i]) continue;
const event: InputEvent = {
input: i + 1,
edge: inputs[i] ? "pressed" : "released",
at,
};
for (const cb of this.#subs) cb(event);
}
};
// ~50ms poll: a button press is held well longer than this.
this.#poll = setInterval(() => void tick(), 50);
}
#stopPolling(): void {
if (this.#poll) {
clearInterval(this.#poll);
this.#poll = null;
this.#last = null;
}
}
}
export const dingtianDriver: AccessDriver = {
id: "dingtian",
category: "access",
label: "Dingtian relay controller",
description:
"Dingtian network relay+input board (UDP). Inputs are decoupled from relays — enables host-in-the-loop entry. Unauthenticated UDP: isolate the VLAN.",
transports: ["udp"],
configFields: [
hostField,
{ ...portField(60001), required: false, help: "Dingtian string protocol UDP port — status read (default 60001)." },
{ key: "binaryPort", label: "Binary protocol port", type: "port", required: false, default: 60000, help: "Dingtian binary protocol UDP port — authenticated relay control (default 60000)." },
{ key: "httpPort", label: "HTTP config port", type: "port", required: false, default: 80, help: "Device web/config-API port (default 80)." },
{ key: "channels", label: "Channels (relays/inputs)", type: "number", required: true, default: 4 },
{
key: "pulseMs",
label: "Pulse open (ms)",
type: "number",
required: false,
default: 500,
help: "Momentary relay pulse; the barrier operator owns the close.",
},
{
key: "inputRestingHigh",
label: "Inputs idle HIGH",
type: "boolean",
required: false,
default: true,
help: "This board idles inputs HIGH (status 1111); a press pulls LOW.",
},
{ key: "timeoutMs", label: "Timeout (ms)", type: "number", required: false, default: 2000 },
// Device web-UI login. webPassword = the password you WANT (blank → a random
// one is generated). webPasswordCurrent = the device's EXISTING password, used
// as the old credential to change it (defaults to "admin" on a fresh device).
// On a verified change, the new password is stored as both the saved login and
// the current one. (Gates only the browser UI — CGI control plane is open.)
{ key: "webUser", label: "Device web username", type: "string", required: false, default: "admin", help: "Device web-UI login user (default admin)." },
{ key: "webPassword", label: "New device web password", type: "secret", required: false, help: "The password to SET on the device web UI. Leave blank to auto-generate. Applied + verified on save." },
{ key: "webPasswordCurrent", label: "Current device web password", type: "secret", required: false, help: "The device's existing web password (default admin on a fresh device). Needed to change it." },
],
create: (c) => new DingtianController(c),
};
@@ -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),
};
+6 -8
View File
@@ -2,9 +2,9 @@
// module wires the catalog. Add a new device by registering it here.
import { registry } from "../registry.js";
import { esp32RelayDriver, zktecoDriver } from "./access.js";
import { uhppoteDriver } from "./access-uhppote.js";
import { dingtianDriver } from "./access-dingtian.js";
import { dahuaDriver, hikvisionDriver } from "./camera.js";
import { rongtaDriver } from "./printer-rongta.js";
import { tcpipReaderDriver, wiegandReaderDriver } from "./reader.js";
let registered = false;
@@ -13,21 +13,19 @@ let registered = false;
export function registerBuiltinDrivers(): void {
if (registered) return;
registered = true;
registry.register(uhppoteDriver);
registry.register(zktecoDriver);
registry.register(esp32RelayDriver);
registry.register(dingtianDriver);
registry.register(wiegandReaderDriver);
registry.register(tcpipReaderDriver);
registry.register(hikvisionDriver);
registry.register(dahuaDriver);
registry.register(rongtaDriver);
}
export {
uhppoteDriver,
zktecoDriver,
esp32RelayDriver,
dingtianDriver,
wiegandReaderDriver,
tcpipReaderDriver,
hikvisionDriver,
dahuaDriver,
rongtaDriver,
};
@@ -0,0 +1,301 @@
import { Socket } from "node:net";
import { request as httpRequest } from "node:http";
import type {
Device,
DeviceHealth,
MonitorableDevice,
PrinterDevice,
PrinterStatus,
TicketData,
} from "../interfaces.js";
import type { ConfigField, DeviceConfig, PrinterDriver } from "../registry.js";
import { hostField, portField, stubLog } from "./common.js";
// Rongta 80mm network thermal printer driver. Rongta RP-series printers (and the
// many OEM clones that share their firmware) speak ESC/POS over a raw TCP socket
// on port 9100 — the JetDirect/RAW convention. There is no auth on the print
// socket; like the other field devices it lives on the isolated device VLAN.
// See wiki/entities/rongta-printer.md and wiki/concepts/network-isolation.md.
//
// ROLES + FAILOVER: a lane has more than one printer. Each instance declares a
// `role` (entry-dispenser at the lane / booth-receipt in the booth) and a
// `failoverRank`. The entry flow prints on the highest-rank healthy printer for
// the wanted role and falls back to the next — so if the outside dispenser is
// offline, the booth printer prints the entry ticket as a backup. The driver
// itself is role-agnostic; the role/rank live in config and the caller (server)
// owns the failover selection. See wiki/concepts/printer-roles-failover.md.
// --- ESC/POS command bytes ----------------------------------------------------
const ESC = 0x1b;
const GS = 0x1d;
const LF = 0x0a;
const INIT = Buffer.from([ESC, 0x40]); // ESC @ — reset to power-on defaults
const ALIGN_CENTER = Buffer.from([ESC, 0x61, 0x01]); // ESC a 1
const ALIGN_LEFT = Buffer.from([ESC, 0x61, 0x00]); // ESC a 0
const BOLD_ON = Buffer.from([ESC, 0x45, 0x01]); // ESC E 1
const BOLD_OFF = Buffer.from([ESC, 0x45, 0x00]); // ESC E 0
const DOUBLE_ON = Buffer.from([GS, 0x21, 0x11]); // GS ! — double width+height
const DOUBLE_OFF = Buffer.from([GS, 0x21, 0x00]);
const FEED_AND_CUT = Buffer.from([ESC, 0x64, 0x04, GS, 0x56, 0x42, 0x00]); // feed 4, GS V B 0 partial cut
/** Encode a printable line as bytes (CP437/ASCII subset) + a line feed. */
function line(text = ""): Buffer {
return Buffer.concat([Buffer.from(text, "ascii"), Buffer.from([LF])]);
}
/** Build the full ESC/POS byte stream for an entry ticket. */
function renderTicket(data: TicketData): Buffer {
return Buffer.concat([
INIT,
ALIGN_CENTER,
BOLD_ON,
DOUBLE_ON,
line("PARKING"),
DOUBLE_OFF,
BOLD_OFF,
line(),
line(`Lane ${data.lane}`),
line(),
BOLD_ON,
line(data.ticketId),
BOLD_OFF,
ALIGN_LEFT,
line(),
line(`Issued: ${data.issuedAt}`),
FEED_AND_CUT,
]);
}
/** Open a TCP socket, write the bytes, wait for flush, then close. */
function sendRaw(host: string, port: number, payload: Buffer, timeoutMs: number): Promise<void> {
return new Promise((resolve, reject) => {
const sock = new Socket();
let settled = false;
const done = (err?: Error) => {
if (settled) return;
settled = true;
sock.destroy();
err ? reject(err) : resolve();
};
sock.setTimeout(timeoutMs);
sock.on("timeout", () => done(new Error("timeout")));
sock.on("error", done);
sock.connect(port, host, () => {
sock.write(payload, (err) => (err ? done(err) : done()));
});
});
}
// --- live status via the device's own status web page -------------------------
// The Rongta board serves /prn_stat.htm, a small HTML table where the DEVICE has
// already decoded the ESC/POS status bits into labelled Yes/No rows. We scrape
// that rather than send raw `DLE EOT` ourselves: on this clone the DLE EOT reply
// bytes don't follow the canonical bit layout (verified on hardware), so trusting
// the device's own decode is the safe choice. See printer-status-monitoring.md.
/** The fault flags the status page reports (a subset of PrinterStatus). */
type StatusFlag = "coverOpen" | "cutterError" | "paperEnd" | "paperNearEnd" | "offline";
type StatusFlags = Partial<Record<StatusFlag, boolean>>;
/** Label text on the status page (NBSP/space-normalised, lowercased) → our key. */
const STATUS_FIELDS: Record<string, StatusFlag> = {
"cover is open": "coverOpen",
"cutter error": "cutterError",
"paper end": "paperEnd",
"paper near end": "paperNearEnd",
"printer off-line": "offline",
};
/** GET the status page over HTTP and return the raw HTML. */
function fetchStatusPage(host: string, httpPort: number, timeoutMs: number): Promise<string> {
return new Promise((resolve, reject) => {
const req = httpRequest(
{ host, port: httpPort, path: "/prn_stat.htm", method: "GET", timeout: timeoutMs },
(res) => {
let data = "";
res.on("data", (c) => (data += c));
res.on("end", () =>
res.statusCode === 200
? resolve(data)
: reject(new Error(`status page HTTP ${res.statusCode}`)),
);
},
);
req.on("error", reject);
req.on("timeout", () => req.destroy(new Error("status page timeout")));
req.end();
});
}
/**
* Parse /prn_stat.htm into boolean flags. Each fault is a `<TD>label</TD>
* <TD>Yes|No</TD>` pair. Returns only the recognised fields; a missing field is
* left undefined so the caller can detect an unexpected page (fail safe, not a
* false "ok").
*/
function parseStatusPage(html: string): StatusFlags {
const out: StatusFlags = {};
const rowRe = /<TD[^>]*>([^<]*?)<\/TD>\s*<TD[^>]*>([^<]*?)<\/TD>/gi;
let m: RegExpExecArray | null;
while ((m = rowRe.exec(html))) {
if (m[1] === undefined || m[2] === undefined) continue;
const label = m[1].replace(/&nbsp;/gi, " ").replace(/\s+/g, " ").trim().toLowerCase();
const value = m[2].replace(/&nbsp;/gi, " ").trim().toLowerCase();
const key = STATUS_FIELDS[label];
if (key && (value === "yes" || value === "no")) {
out[key] = value === "yes";
}
}
return out;
}
/** TCP connect probe — the print socket has no status protocol we rely on. */
function probe(host: string, port: number, timeoutMs: number): Promise<void> {
return new Promise((resolve, reject) => {
const sock = new Socket();
let settled = false;
const done = (err?: Error) => {
if (settled) return;
settled = true;
sock.destroy();
err ? reject(err) : resolve();
};
sock.setTimeout(timeoutMs);
sock.on("timeout", () => done(new Error("timeout")));
sock.on("error", done);
sock.connect(port, host, () => done());
});
}
class RongtaPrinter implements PrinterDevice, MonitorableDevice {
readonly driverId = "rongta";
readonly #host: string;
readonly #port: number;
readonly #httpPort: number;
readonly #timeout: number;
constructor(config: DeviceConfig) {
this.#host = String(config.host);
this.#port = config.port ? Number(config.port) : 9100;
this.#httpPort = config.httpPort ? Number(config.httpPort) : 80;
this.#timeout = config.timeoutMs ? Number(config.timeoutMs) : 3000;
}
async connect(): Promise<void> {
await this.healthCheck();
}
async disconnect(): Promise<void> {
stubLog(this.driverId, "disconnect");
}
async healthCheck(): Promise<DeviceHealth> {
try {
await probe(this.#host, this.#port, this.#timeout);
return { status: "ready" };
} catch (err) {
return { status: "offline", detail: (err as Error).message };
}
}
async printTicket(data: TicketData): Promise<void> {
await sendRaw(this.#host, this.#port, renderTicket(data), this.#timeout);
stubLog(this.driverId, `printed ticket ${data.ticketId} (lane ${data.lane})`);
}
/**
* Live operator-actionable status, scraped from the device's own status page.
* The board decodes the ESC/POS status bits itself, so we trust its Yes/No
* over hand-decoding this clone's non-standard DLE EOT reply.
*
* - status page unreachable → offline (the same signal as a dead printer),
* - page reachable but a recognised field missing → degraded (don't claim
* "ready" off a page we didn't fully understand — fail safe),
* - any fault flag true → degraded,
* - otherwise → ready.
*/
async readStatus(): Promise<PrinterStatus> {
const checkedAt = new Date().toISOString();
let html: string;
try {
html = await fetchStatusPage(this.#host, this.#httpPort, this.#timeout);
} catch (err) {
return { status: "offline", detail: (err as Error).message, checkedAt };
}
const flags = parseStatusPage(html);
const expected: StatusFlag[] = ["coverOpen", "cutterError", "paperEnd", "paperNearEnd", "offline"];
const missing = expected.filter((k) => flags[k] === undefined);
if (missing.length > 0) {
return {
status: "degraded",
detail: `unexpected status page (missing: ${missing.join(", ")})`,
checkedAt,
};
}
const faults = expected.filter((k) => flags[k] === true);
const labels: Record<StatusFlag, string> = {
paperEnd: "paper out",
coverOpen: "cover open",
cutterError: "cutter error",
offline: "printer off-line",
paperNearEnd: "paper low",
};
return {
status: faults.length > 0 ? "degraded" : "ready",
...flags,
detail: faults.length > 0 ? faults.map((f) => labels[f]).join(", ") : undefined,
checkedAt,
};
}
}
/** Type guard: does this device carry a printer role (entry vs. booth)? */
export type PrinterRole = "entry-dispenser" | "booth-receipt";
const roleField: ConfigField = {
key: "role",
label: "Role",
type: "select",
required: true,
default: "entry-dispenser",
options: [
{ value: "entry-dispenser", label: "Entry dispenser (outside / at the lane)" },
{ value: "booth-receipt", label: "Booth printer (receipts + backup)" },
],
help: "Entry tickets print on the entry dispenser, falling back to the booth printer if it is offline.",
};
const rankField: ConfigField = {
key: "failoverRank",
label: "Failover rank",
type: "number",
required: false,
default: 0,
help: "Higher = tried first within the same role. The booth printer also backs up the entry dispenser.",
};
export const rongtaDriver: PrinterDriver = {
id: "rongta",
category: "printer",
label: "Rongta 80mm thermal printer",
description:
"Rongta RP-series 80mm thermal printer (and ESC/POS-compatible clones) over raw TCP (port 9100). No auth on the print socket — isolate the VLAN.",
transports: ["tcp-ip"],
configFields: [
hostField,
{ ...portField(9100), required: false, help: "Raw print socket (ESC/POS over JetDirect/RAW, default 9100)." },
{ key: "httpPort", label: "Status web port", type: "port", required: false, default: 80, help: "Device status page (/prn_stat.htm) port for live monitoring (default 80)." },
roleField,
rankField,
{ key: "timeoutMs", label: "Timeout (ms)", type: "number", required: false, default: 3000 },
],
create: (c) => new RongtaPrinter(c),
};
/** Type guard exposed for callers that need to read a device's printer role. */
export function isPrinter(device: Device): device is PrinterDevice {
return typeof (device as Partial<PrinterDevice>).printTicket === "function";
}
-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;
}
+18 -1
View File
@@ -5,5 +5,22 @@
export * from "./interfaces.js";
export * from "./registry.js";
export { registerBuiltinDrivers } from "./drivers/index.js";
export { setDeviceLogSink, type DeviceLogSink } from "./drivers/common.js";
// Built-in drivers: the registrar plus the individual driver objects (used by
// hardware test scripts and any direct/programmatic device access).
export {
registerBuiltinDrivers,
dingtianDriver,
wiegandReaderDriver,
tcpipReaderDriver,
hikvisionDriver,
dahuaDriver,
rongtaDriver,
} from "./drivers/index.js";
export { isPrinter, type PrinterRole } from "./drivers/printer-rongta.js";
export {
orderForRole,
printWithFailover,
NoPrinterAvailableError,
type PrinterInstance,
} from "./printer-routing.js";
+153 -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,13 +28,130 @@ 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>;
getDoorStatus(doorId: number): Promise<"open" | "closed">;
}
// --- Inputs (buttons / dry contacts) -------------------------------------
// Optional capability for controllers that expose host-readable inputs SEPARATE
// from their relays — e.g. the Dingtian board. This is what enables host-in-the-
// loop entry: a button press is reported to the host, which decides (print a
// ticket) before commanding the relay — instead of the input auto-firing the
// relay. See wiki/decisions/access-controller-button-flow.md.
export interface InputDevice {
/** Read the current state of all inputs (true = active/pressed). */
readInputs(): Promise<boolean[]>;
/**
* Subscribe to input edges. Returns an unsubscribe fn. Implementations may
* back this with hardware push or polling — the consumer doesn't care.
*/
onInput(cb: (event: InputEvent) => void): () => void;
}
export interface InputEvent {
/** 1-based input/channel index. */
readonly input: number;
/** Edge: pressed = went active, released = went inactive. */
readonly edge: "pressed" | "released";
readonly at: string; // ISO-8601
}
/** Type guard: does this device expose host-readable inputs? */
export function hasInputs(device: Device): device is Device & InputDevice {
return (
typeof (device as Partial<InputDevice>).readInputs === "function" &&
typeof (device as Partial<InputDevice>).onInput === "function"
);
}
// --- Preconditions (device must be configured a certain way) -------------
// Optional capability: a device that depends on specific on-device configuration
// to work correctly for parking can report it. Example: the Dingtian board must
// have `input_link_relay` DISABLED, else a button press auto-fires the relay and
// defeats host-in-the-loop entry (the same trap as the UHPPOTE, but fixable here).
// The app does not own full device config (that's the vendor's web UI) — it only
// checks the few preconditions our flow depends on, and optionally fixes them.
// See wiki/decisions/access-controller-button-flow.md.
export interface PreconditionDevice {
checkPreconditions(): Promise<PreconditionResult>;
/** Apply automatic fixes for fixable issues; returns the re-checked result. */
fixPreconditions(): Promise<PreconditionResult>;
}
export interface PreconditionResult {
readonly ok: boolean;
readonly issues: PreconditionIssue[];
}
export interface PreconditionIssue {
readonly key: string;
readonly message: string;
/** True if fixPreconditions() can correct this automatically. */
readonly fixable: boolean;
}
export function hasPreconditions(
device: Device,
): device is Device & PreconditionDevice {
return typeof (device as Partial<PreconditionDevice>).checkPreconditions === "function";
}
// --- Push configuration (device → backend) -------------------------------
// Optional capability: a device that can be told to HTTP-push its input/button
// events to our backend (vs. the host polling it). The backend configures the
// device with where to call and a shared-secret token embedded in the path.
// The Dingtian board implements this via its "Input Link URL" feature.
// See wiki/concepts/device-input-flow.md.
export interface PushConfigurableDevice {
configureInputPush(opts: PushConfig): Promise<void>;
}
export interface PushConfig {
/** Backend host the device should call (our IP on the device's subnet). */
readonly host: string;
readonly port: number;
/** Path prefix the device appends `/<input>/<on|off>` to,
* e.g. `/api/devices/dingtian/<deviceId>/input`. */
readonly pathBase: string;
/** HTTP Digest credentials the device authenticates the push with. */
readonly auth: { user: string; password: string };
}
export function hasPushConfig(
device: Device,
): device is Device & PushConfigurableDevice {
return typeof (device as Partial<PushConfigurableDevice>).configureInputPush === "function";
}
// --- Hardening (lock the device down) ------------------------------------
// Optional capability: a device that can be hardened against a flat (no-VLAN)
// network — disable unused protocols/channels, set a relay password, and change
// the default web/config login. Returns any secrets the backend must persist to
// keep talking to the device. See wiki/concepts/device-input-flow.md.
export interface HardenableDevice {
harden(): Promise<HardenResult>;
}
export interface HardenResult {
/** Secrets to persist in lane_devices so the backend can keep operating the
* device (relay password, new web login). The backend merges these into the
* stored config. */
readonly secrets: Record<string, string | number>;
/** Human-readable summary of what was changed (for logging/UI). */
readonly applied: string[];
/** Hardening steps that could NOT be applied (e.g. a firmware quirk), so the
* admin knows a residual risk remains. Best-effort steps report here instead
* of failing the whole harden. */
readonly warnings?: string[];
}
export function isHardenable(device: Device): device is Device & HardenableDevice {
return typeof (device as Partial<HardenableDevice>).harden === "function";
}
// --- Readers (RF / optical; TCP-IP or Wiegand) ---------------------------
export interface ReaderDevice extends Device {
/** Emits when a credential is read (card number, plate, QR payload, …). */
@@ -78,3 +195,37 @@ export interface TicketData {
export interface PrinterDevice extends Device {
printTicket(data: TicketData): Promise<void>;
}
// --- Live printer status (consumable / mechanical faults) ----------------
// Optional capability: a printer that reports the operator-actionable faults a
// basic `healthCheck` (reachability) can't see — paper out, cover open, cutter
// jam. Used by the live status monitor so the booth knows BEFORE a driver presses
// the entry button and no ticket comes out. The Rongta board exposes these via
// its own status web page (it decodes the ESC/POS bits for us — more reliable
// than trusting a clone's DLE EOT bit layout). See wiki/concepts/printer-status-monitoring.md.
export interface PrinterStatus {
/** Reachable + no fault = ready; reachable + fault = degraded; unreachable = offline. */
readonly status: "ready" | "degraded" | "offline";
/** Out of paper — the printer cannot print. */
readonly paperEnd?: boolean;
/** Paper low — still prints, but warn the operator to reload. */
readonly paperNearEnd?: boolean;
/** Cover/lid open — will not print. */
readonly coverOpen?: boolean;
/** Cutter jammed/errored. */
readonly cutterError?: boolean;
/** Printer reports itself off-line (its own flag, distinct from unreachable). */
readonly offline?: boolean;
/** Human-readable summary (e.g. "paper out", or the unreachable error). */
readonly detail?: string;
readonly checkedAt: string; // ISO-8601
}
export interface MonitorableDevice {
/** Richer, operator-actionable status beyond reachability. */
readStatus(): Promise<PrinterStatus>;
}
export function isMonitorable(device: Device): device is Device & MonitorableDevice {
return typeof (device as Partial<MonitorableDevice>).readStatus === "function";
}
+91
View File
@@ -0,0 +1,91 @@
// Printer routing: pick which printer prints a given job across a lane's
// printers, with automatic failover. A lane has more than one printer — an
// entry dispenser outside (where the driver takes the ticket) and a booth
// printer inside (receipts, and a BACKUP for entry tickets if the dispenser is
// offline). See wiki/concepts/printer-roles-failover.md.
//
// This is pure selection logic over (config, health) — no device I/O — so the
// entry/exit flow can decide where to print without coupling to a transport.
import type { PrinterDevice } from "./interfaces.js";
import type { PrinterRole } from "./drivers/printer-rongta.js";
/** A configured printer instance + its live adapter, as the caller holds them. */
export interface PrinterInstance {
readonly id: string;
readonly role: PrinterRole;
/** Higher = preferred within a role. Ties broken by id for determinism. */
readonly failoverRank: number;
readonly device: PrinterDevice;
}
/**
* Order the candidate printers for a job targeting `wantRole`, best-first.
*
* Rule: printers of the wanted role come first (highest rank first); the booth
* printer is also a fallback for entry tickets, so when an entry ticket is
* routed, booth-receipt printers follow the entry dispensers. The reverse is
* deliberately NOT done — a receipt never prints on the outside dispenser.
*/
export function orderForRole(
printers: readonly PrinterInstance[],
wantRole: PrinterRole,
): PrinterInstance[] {
const fallbackRole: PrinterRole | null =
wantRole === "entry-dispenser" ? "booth-receipt" : null;
const rank = (p: PrinterInstance): number => {
if (p.role === wantRole) return 2;
if (p.role === fallbackRole) return 1;
return 0;
};
return printers
.filter((p) => rank(p) > 0)
.sort((a, b) => {
if (rank(a) !== rank(b)) return rank(b) - rank(a); // wanted role first
if (a.failoverRank !== b.failoverRank) return b.failoverRank - a.failoverRank;
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0; // stable tiebreak
});
}
export class NoPrinterAvailableError extends Error {
constructor(public readonly attempts: { id: string; error: string }[]) {
super(
attempts.length === 0
? "no printer configured for this job"
: `all ${attempts.length} candidate printer(s) failed: ${attempts
.map((a) => `${a.id} (${a.error})`)
.join(", ")}`,
);
this.name = "NoPrinterAvailableError";
}
}
/**
* Print `job` on the best healthy printer for `wantRole`, failing over down the
* ordered list. Tries each candidate's print directly: a healthCheck race is
* pointless when the print itself is the real reachability test, so we just
* attempt the print and move on if it throws. Returns the id that succeeded.
*
* Throws {@link NoPrinterAvailableError} if every candidate fails — the caller
* (entry flow) decides what that means (e.g. raise the barrier without a paper
* ticket vs. hold). That policy is the flow's, not the printer's.
*/
export async function printWithFailover(
printers: readonly PrinterInstance[],
wantRole: PrinterRole,
job: (device: PrinterDevice) => Promise<void>,
): Promise<string> {
const ordered = orderForRole(printers, wantRole);
const attempts: { id: string; error: string }[] = [];
for (const p of ordered) {
try {
await job(p.device);
return p.id;
} catch (err) {
attempts.push({ id: p.id, error: (err as Error).message });
}
}
throw new NoPrinterAvailableError(attempts);
}
+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[]>;
+27
View File
@@ -34,6 +34,10 @@ export interface ParkingEvent {
}
export type ParkingEventType =
// A raw device input (e.g. a Dingtian button press) was received and recorded.
// NOT a confirmed entry — the richer `vehicle_entry` is appended later by the
// entry flow once a ticket prints and the barrier is commanded.
| "input_received"
| "vehicle_entry"
| "vehicle_exit"
| "void"
@@ -48,3 +52,26 @@ export const ROLES: readonly Role[] = [
"cashier",
"readonly",
] as const;
/**
* Signs the canonical bytes of an event for the append-only chain. This is the
* abstraction over the [[atecc608]] secure element: the real, non-extractable
* hardware key is ONE implementation. Whether the chip is wired is still
* open-question #6, so the server ships a software signer in the meantime —
* same interface, swappable with no business-logic change (the device-adapter
* philosophy applied to signing). See wiki/concepts/append-only-event-chain.md.
*
* IMPORTANT: a software signer makes the chain self-consistent and detectably
* tamper-evident, but NOT unforgeable by someone who owns the machine — only the
* ATECC608 provides that. Don't conflate the two.
*/
export interface Signer {
/** Stable id of the signer/key (e.g. "sw-hmac-v1", "atecc608-slot0"). Stored
* alongside events so verification knows which key to check against. */
readonly keyId: string;
/** Sign the canonical payload; returns a hex signature. */
sign(payload: string): string;
/** Verify a signature over the payload (software signers can; the ATECC608
* verifies via its public key). */
verify(payload: string, signature: string): boolean;
}
-19
View File
@@ -50,9 +50,6 @@ importers:
fastify-plugin:
specifier: 6.0.0
version: 6.0.0
uhppoted:
specifier: 0.9.0
version: 0.9.0
devDependencies:
'@types/bcrypt':
specifier: 6.0.0
@@ -122,9 +119,6 @@ importers:
'@parking/shared':
specifier: workspace:*
version: link:../shared
uhppoted:
specifier: 0.9.0
version: 0.9.0
devDependencies:
'@types/node':
specifier: 25.9.3
@@ -1262,9 +1256,6 @@ packages:
once@1.4.0:
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
os@0.1.2:
resolution: {integrity: sha512-ZoXJkvAnljwvc56MbvhtKVWmSkzV712k42Is2mA0+0KTSRakq5XXuXpjZjgAt9ctzl51ojhQWakQQpmOvXWfjQ==}
path-scurry@2.0.2:
resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==}
engines: {node: 18 || 20 || >=22}
@@ -1467,10 +1458,6 @@ packages:
engines: {node: '>=14.17'}
hasBin: true
uhppoted@0.9.0:
resolution: {integrity: sha512-7VDPNg4x31TETgMD3xp9NwVr+NvmZJ6CO8gTpyuRrdHu/UBGXw9/9kq8yiB0vR4opaUQPdvR8Gj373Ac/QWPwQ==}
engines: {node: '>=14.18.3'}
undici-types@7.24.6:
resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==}
@@ -2357,8 +2344,6 @@ snapshots:
dependencies:
wrappy: 1.0.2
os@0.1.2: {}
path-scurry@2.0.2:
dependencies:
lru-cache: 11.5.1
@@ -2586,10 +2571,6 @@ snapshots:
typescript@6.0.3: {}
uhppoted@0.9.0:
dependencies:
os: 0.1.2
undici-types@7.24.6: {}
util-deprecate@1.0.2: {}
+2 -2
View File
@@ -35,9 +35,9 @@ wiki/
- **Frontmatter** (YAML) on every wiki page:
```yaml
---
type: source | entity | concept | decision | overview
type: source | entity | concept | decision | overview | reference
tags: [parking, ...]
sources: [parking-system-architecture] # raw source slugs this draws from
sources: [parking-system-architecture] # raw source slugs (omit/[] if not source-derived)
updated: 2026-06-14
status: settled | open # decisions only
---
+72 -1
View File
@@ -2,7 +2,7 @@
type: concept
tags: [parking, security, integrity]
sources: [parking-system-architecture]
updated: 2026-06-14
updated: 2026-06-15
---
# Append-Only Event Chain
@@ -24,3 +24,74 @@ It only becomes trustworthy as an external fraud control when paired with [[reco
against an authority the operator can't alter. Every device event — including those ingested
from the [[uhppote-controller]] via [[event-log-ingestion]] — should land in this host-side
chain.
## Implementation (apps/server)
> Implementation-derived. The schema (`packages/db` `events`) and types
> (`packages/shared` `ParkingEvent`) predate this; the writer/signer are new.
- **`EventLog`** (`apps/server/src/event-log.ts`) is the append primitive. `append()` reads the
latest row, sets `index = prev + 1`, `prevHash = sha256(canonical(prev))` (genesis = null),
signs the canonical form, and inserts. There are **no update/delete paths**.
- **Serialized appends.** SQLite is single-writer, but read-prev → compute-hash → insert is
multi-step, so `EventLog` also guards it with an in-process async lock — otherwise two near-
simultaneous events could claim the same `index` or chain off a stale `prevHash`. Verified:
5 concurrent appends produced indices 1..5 with an intact chain.
- **Canonical form** is a fixed-order JSON array (`index,type,direction,lane,source,identity,
occurredAt,prevHash`) — byte-stable, since the chain + signatures depend on it. The volatile
row `id` is excluded; chain identity is `index` + content.
- **`verifyChain()`** walks oldest→newest, recomputing hashes + signatures. Catches tampered
content (bad signature), reordering / a deleted row (`index` gap), and a `prevHash` mismatch.
Exposed at `GET /api/events/verify` (admin). Read access to the log: `GET /api/events`.
### The `Signer` abstraction (software now, ATECC608 later)
Signing goes through a **`Signer`** interface (`packages/shared`) — the abstraction over the
[[atecc608]]. Because the chip being wired is still [[open-questions|open-question #6]], the
server ships a **`SoftwareSigner`** (HMAC-SHA256, key from `EVENT_SIGNING_KEY`). Swapping to the
secure element is a new `Signer` impl with no `EventLog` change; each event stores its `keyId`
so old events stay verifiable.
> ⚠️ The software signer makes the chain **self-consistent + tamper-evident**, but **not
> unforgeable by someone who owns the host** — only the ATECC608's non-extractable key gives
> property (3) above. Until the chip is wired, the chain detects tampering by *outsiders* and
> *accidental* corruption, but an operator with the signing key + DB access could re-sign a
> forged chain. This is the central reason #6 matters.
### What currently feeds the log
Dingtian **input (button) pushes** → bus → `input_received` events (see [[device-input-flow]],
[[dingtian-relay]]). These are recorded faithfully as raw inputs, **not** as `vehicle_entry` —
the richer entry event waits for the entry flow (ticket print + barrier command).
- **`lane`** is now resolved from the firing device. A `LaneMap` (`apps/server/src/lane-map.ts`)
caches `lane_devices.id → lane`, built at startup and refreshed by the setup routes on every
assign/unassign. Device events carry the device instance id, not a lane; the handler looks it
up. A device with no mapping (assigned without a lane, or a stale id) logs **`lane: -1`** and a
warning — never `0`, which is a real lane — and is still recorded (the chain is append-only;
nothing is dropped).
- **`source` stays `null`** for `input_received`, and deliberately so: `source` is an
`IdentitySource` (`wiegand | lpr | qr | ticket | manual`) — *how a vehicle was identified* — not
a device/IP field. A raw button push has no vehicle identity. The device provenance lives in
**`identity`** (e.g. `dingtian:<id> input:1/on`).
### ⚠️ Limitation: the log captures HOST-ORIGINATED actions only
The event log records what the **host** did (inputs it received, opens it commanded). It is
**blind to out-of-band relay actuation** — anything that fires a relay without going through the
host. **Proven on hardware**: a binary relay command sent directly to the device with the
(sniffable) `relay_pw` fired a relay and produced **zero** events. Out-of-band paths include:
- the **password-less string protocol** (until disabled — see [[dingtian-relay]]),
- a **sniffed/replayed `relay_pw`** binary command (plaintext UDP — relay control is
defence-in-depth, **not** a boundary),
- the device's own **`ip_watchdog`** (auto-toggles a relay on ping-failure — must stay disabled),
- a future **`barrier_open_command`** path is host-side and *would* log; these bypass it.
So the log alone does **not** detect operator/attacker fraud at the relay. That is **by design** —
the actual control is [[reconciliation]]: compare the host's signed *commanded* opens against an
**independent witness** of opens that physically happened (a door/loop sensor on a Dingtian input
→ which DOES push + log; the [[lpr-camera]]; payment/Z-report). **A physical open with no matching
signed command is the fraud signal.** Both the witness sources and the reconciliation logic are
**NOT yet built** — this is the main open gap. Prevention (VLAN isolation so the attacker can't
reach UDP 60000) is the necessary first line; detection-via-reconciliation is the backstop.
+35 -9
View File
@@ -20,13 +20,39 @@ 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
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
`255.255.255.255: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)`; the `uhppote` driver maps each result into a `DiscoveredDevice` (serial → id,
IP → host).
## 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,
MAC, firmware version, and date. The `uhppoted` lib exposed this as `getDevices(ctx)`; the
(now-removed) `uhppote` driver mapped each result into a `DiscoveredDevice` (serial → id, IP →
host). **Was verified on real hardware** (serial 225088491).
### Broadcast gotchas (learned the hard way — see [[wsl-dev-networking]])
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
`uhppoted` lib only calls `setBroadcast(true)` when the target matches a **local interface's
subnet broadcast** (e.g. `10.0.10.255`). For the global address it skips it, so the `send`
fails with **`EACCES`**. The driver computes the subnet broadcast from `os.networkInterfaces()`.
2. **A host with multiple interfaces must broadcast on *all* subnets.** With several NICs (LAN,
VPN/Tailscale, docker bridges) the controller is on only one. Picking the first interface
misses it; the driver broadcasts on every subnet and dedupes by serial.
3. **For *unicast* ops (status / open), the lib's `Config` broadcast must match the target's
subnet** — it governs reply routing, so a mismatched broadcast makes `getStatus` time out even
though `openDoor` "succeeds". The driver sets the broadcast per the target host's subnet. (This
was the health-check "offline/timeout" bug: a 5 s timeout dropped to 24 ms once fixed.)
Also: concurrent `uhppoted` calls collide on the `:60001` reply-listener port (EACCES / dropped
replies), so the driver **serializes** all controller I/O. Override the broadcast with
`UHPPOTE_BROADCAST` for unusual setups.
## Flow
@@ -38,9 +64,9 @@ IP → host).
## Deployment notes
- UHPPOTE discovery is a **broadcast** — the host socket needs broadcast permission (a raw
`send EACCES …:60000` means the OS blocked it). Works on the isolated device VLAN
([[network-isolation]]) where the controller and host share an L2 segment.
- Discovery is an **L2 broadcast**: the host and controller must share a layer-2 segment. Works
on the isolated device VLAN ([[network-isolation]]). A routed/NAT'd network (e.g. WSL2 NAT mode
— see [[wsl-dev-networking]]) blocks it entirely.
- Discovery shares the same unauthenticated UDP exposure as everything else UHPPOTE — another
reason the controllers live on an isolated VLAN ([[uhppote-udp-protocol]]).
- Cameras (Hikvision/Dahua via ONVIF/WS-Discovery) could implement the same interface later.
+106
View File
@@ -0,0 +1,106 @@
---
type: concept
tags: [parking, architecture, devices, entry-flow]
sources: []
updated: 2026-06-15
---
# Device Input Flow (button → backend → relay)
How a physical button press drives the entry lane. The **backend is the source of truth**: the
device only *reports* the press; the host decides and commands the relay. This is the host-in-the-
loop flow the [[dingtian-relay]] makes possible (and the [[uhppote-controller]] could not).
## The path (no polling)
```
car arrives → driver presses button (input I_N, dry contact to GND)
→ device HTTP-pushes GET …/api/devices/dingtian/<deviceId>/input/<N>/on
→ backend: emit internal device event (device-events bus)
→ backend entry flow: create + sign an entry event, print the ticket
→ backend: pulseOpen(N) over UDP → barrier opens
→ (on release) device pushes …/input/<N>/off
```
- **Push, not poll.** The device's `input_link_url` feature is configured (by the driver's
`configureInputPush()`) to call the backend on each input edge — see [[dingtian-relay]]. The
driver's poll path remains only as a dev/fallback aid.
- **Per-input path** carries the input number in the URL (`…/input/3/on`), so routing needs no
body parsing. Both edges (`on`/`off`) are sent.
- **Internal event bus** (`device-events.ts`, a Node `EventEmitter`) decouples the HTTP/transport
layer from business logic — drivers/pushes emit; the entry flow subscribes. Keeps the app
[[device-adapter-pattern|device-agnostic]].
## Trust model (important — flat network, no VLAN)
The site is a **flat network with no VLAN** ([[network-isolation]] is not yet enforceable here),
so we do **not** trust the device or the network. Both directions now have defence-in-depth, but
neither is the real boundary:
- **Relay control (host → device)** — UDP, now via the Dingtian **binary protocol on :60000 with a
`relay_pw`** (the only authenticated relay option; the string protocol has none). Set on the
device + stored in `lane_devices` by the harden step (below).
- **Input push (device → host)** — guarded by **HTTP Digest auth** + a **source-IP allowlist**.
- **The real guarantee is the signed log:** every barrier open is a host decision, recorded as a
signed event BEFORE the relay fires ([[append-only-event-chain]]). An out-of-band open (which a
flat network allows) has **no matching signed event → a detectable anomaly**. Device/network
auth is just speed bumps; both are plaintext over a sniffable network.
- This sharpens under the [[autonomous-direction|unmanned]] roadmap: with no operator, tamper
detection via the signed log matters more than perimeter auth.
## Device hardening (on assign)
The assign/Save step configures the device end-to-end (admin never touches the device web UI):
fix preconditions (disable `input_link_relay`) → **harden** → set up input push. The `harden`
capability ([[device-registry|HardenableDevice]]):
- **Sets a random `relay_pw`** (1–9999) so binary relay commands need it; stores it in
`lane_devices` so the backend can keep commanding the relay.
- **Disables unused protocol channels** (rs485, can, tcp×2, mqtt → `p:255`), keeping only UDP1
binary (relay control) + UDP2 string (status read) — fewer open doors.
> **⚠️ Lesson (the hard way):** do **NOT** enable the device's HTTP CGI session check
> (`session_en`). On this firmware (DT-R004) it makes the config-**read** API drop connections
> (`ECONNRESET`), locking the backend out of the very API it depends on — it required a **factory
> reset** to recover. The harden step deliberately leaves `session_en` off. The CGI config API
> being open is accepted as part of the flat-network reality (the signed log is the guarantee);
> the proper fix is network isolation, not this fragile device feature.
## Push authentication — Digest (decided by hardware testing)
The secret must not be in the URL (sniffable, logged) and the password must not cross the wire in
the clear. We **empirically tested the device** to pick the strongest achievable option:
| Option | Device result |
| --- | --- |
| HTTPS (self-signed) | ❌ device won't push to a self-signed cert |
| **Digest auth** (`auth=2`) | ✅ **works** — full 401-nonce challenge/response |
| Basic auth | ✅ works (but password base64 on the wire) |
| URL token | rejected by design (visible in URL/logs) |
→ **HTTP Digest** (MD5, qop=auth). The password is never sent (only a nonce-keyed hash); nonces
are **single-use** (replay resistance). Per-device credentials (`pushUser`/`pushPassword`) are
generated by the backend on **device assign**, written to the device's `input_link_url` config,
and stored in `lane_devices` — the admin never types a URL or secret. HTTPS would be stronger but
the device can't do it here; Digest + the signed log is the practical answer on a flat network.
See `apps/server/src/digest-auth.ts`.
## Dingtian config-write gotchas (cost a lot of debugging)
Writing the device's config API (`/api/v2/config_set.cgi`) has two non-obvious traps — both now
handled in the driver:
1. **Content-Length is mandatory.** The device's embedded HTTP server does **not** accept chunked
request bodies. Node uses chunked encoding when `Content-Length` is absent, so the device
silently ignores the body and returns `{"status":0}` anyway — the write looks successful but
nothing changes. Always set `Content-Length`.
2. **The `pass` field caps at 31 chars** (longer is silently truncated → Digest mismatch). The
generated push password is 24 hex chars (96 bits).
3. (Also: the device reboots on apply, so the driver writes then **polls until the change is
verified**, retrying — back-to-back writes onto a rebooting device are lost.)
## Status
Input push **verified on hardware** with Digest auth (all 4 inputs, real presses authenticated, no
failures). The entry
flow itself (signed event + ticket print + `pulseOpen`) is the next build — see [[dingtian-relay]].
+2 -1
View File
@@ -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.
- Selections persist in the `lane_devices` table and drive runtime adapter construction.
- 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
stored and referenced from the signed event as an **independent record** — a fraud-control input
+32 -11
View File
@@ -18,21 +18,42 @@ each device's connection config.
1. **Read the catalog** — `GET /api/setup/catalog` returns supported drivers per category (no
secrets, just schema) plus a `discoverable` list. The web `SetupWizard` renders a picker + the
driver's config fields, and a **Scan** button for discoverable drivers ([[device-discovery]]).
2. **Assign per lane** — `POST /api/setup/assign` (admin-only, role-guarded; see
[[local-jwt-auth]]). The server validates the chosen driver + config against the registry
before persisting to the `lane_devices` table; unknown drivers / missing required fields are
rejected.
3. **Complete** — `POST /api/setup/complete` marks the single-row `setup_state`.
2. **Test** (optional, no save) — `POST /api/setup/test` (admin-only). Validates the config,
probes reachability (`healthCheck`), and reports preconditions (e.g. `input_link_relay`) —
**without** saving or changing the device. The wizard's **Test connection** button shows a
health badge + any precondition warnings.
3. **Save & configure** — `POST /api/setup/assign` (admin-only). Validates, then **configures the
device**: fixes preconditions (e.g. disables `input_link_relay`) and sets up the Digest-
authenticated input push ([[device-input-flow]]) — the admin never touches the device's own web
UI. **Fails the save** (no DB row) if the device can't be configured, so there are no
orphan/half-configured rows. On success persists to `lane_devices`.
4. **Remove** — `DELETE /api/setup/assign/:id` (admin-only) drops one instance's row. Only our
row is removed; the device itself is not un-hardened/un-configured (a stale push from an
unknown device id is already rejected, and re-assigning reconfigures it).
5. **Complete** — `POST /api/setup/complete` marks the single-row `setup_state`.
## Config granularity
## Config granularity — multi-instance per category
Organized **per lane** — each lane gets an access controller, reader(s), and camera(s), each with
its own connection settings. Matches the architecture's "mixable per lane" reality (a lane can
serve permit holders via [[wiegand]] and casual via host-side reads on one relay — see
[[entry-exit-readers]]).
The data model is **multi-instance**: `lane_devices` holds **one row per instance**, keyed by a
generated `id`, with no one-per-(lane, category) constraint. So a lane can have **more than one of
every category** — e.g. two printers (an entry dispenser + a booth printer; see
[[printer-roles-failover]]), multiple readers, multiple cameras. `assign` always inserts a new row
(never an upsert), and `state` returns the full list.
The `SetupWizard` reflects this: each category shows the **list of assigned instances** for the
current lane (with **Remove**) plus an **Add another** form — not a single fixed slot. `select`-type
config fields (e.g. a printer's role) render as dropdowns.
Organized **per lane** — each lane gets its access controller(s), reader(s), camera(s), and
printer(s), each with its own connection settings. Matches the architecture's "mixable per lane"
reality (a lane can serve permit holders via [[wiegand]] and casual via host-side reads on one
relay — see [[entry-exit-readers]]).
## Security notes
- The assign/state/complete endpoints require the **admin** role ([[local-jwt-auth]]).
- The assign/state/delete/complete endpoints require the **admin** role ([[local-jwt-auth]]).
- Device **credentials are stored in `lane_devices.config`** — protect at rest
([[disk-os-hardening]]); device hosts belong on the isolated VLAN ([[network-isolation]]).
- **Secrets are stripped on the way out**: `assign` and `state` both redact `pushPassword`,
`webPassword`, and `relayPassword` from the returned config (the UI lists devices; it never
needs the stored secrets).
+56
View File
@@ -0,0 +1,56 @@
---
type: reference
tags: [parking, dev-environment, workflow]
sources: []
updated: 2026-06-15
---
# Local Dev Workflow
> Dev-environment reference, not product architecture. How to run the stack locally and the
> gotchas that have bitten us. For device testing under WSL also read [[wsl-dev-networking]].
## First-time setup
```bash
pnpm install
cp apps/server/.env.example apps/server/.env # then fill in JWT_SECRET
# JWT_SECRET=$(openssl rand -hex 32) # server refuses to start without a strong one
pnpm --filter @parking/db exec drizzle-kit migrate # create the SQLite schema
pnpm seed:admin # create the first admin (see [[local-jwt-auth]])
```
`apps/server/.env` and the `*.sqlite` files are **gitignored** (local-only). Leave `NODE_ENV`
**unset** in dev so the auth cookies aren't `Secure`-only (Vite dev is plain http).
## Running
```bash
pnpm dev # turbo runs both: Vite (web, :5173) + Fastify (server, :3000)
```
Open `http://localhost:5173`. The Vite dev proxy forwards `/api` + `/health` to the backend, so
the SPA and API are **same-origin** and the [[local-jwt-auth|cookie auth]] works without CORS.
Production uses an **nginx** reverse proxy (`deploy/nginx.conf`) for the same same-origin setup.
## Gotchas (all fixed, recorded so they don't recur)
- **Server dev must not be `node --experimental-strip-types src/index.ts`.** Type-stripping does
**not** rewrite `.js` import specifiers to `.ts`, so it crashed with `ERR_MODULE_NOT_FOUND` and
silently never started — the symptom was the SPA hanging for *minutes* (the Vite proxy waiting
on a dead backend), then finally erroring. The `dev` script uses **`tsx watch`** instead.
- **Vite proxy → `127.0.0.1`, not `localhost`.** `localhost` resolves to IPv6 `::1` first while
the backend binds IPv4; Node's proxy can stall on the v6 attempt. Same class of "slow then
works" hang, worse under WSL2 mirrored mode ([[wsl-dev-networking]]).
- **`.env` must actually be loaded.** The server reads `process.env` only; the dev/start scripts
load the file via Node's `--env-file-if-exists=.env`. An empty `JWT_SECRET=` makes the server
fail-fast at boot.
- **Seed into the DB the server reads.** `seed:admin` and the server must use the same
`DATABASE_URL`; running via `pnpm seed:admin` (which loads `apps/server/.env`) keeps them aligned.
## Useful one-offs
- First admin: `pnpm seed:admin` (prompts; blank username → `admin`). Non-interactive:
`ADMIN_USER=.. ADMIN_PASS=.. pnpm seed:admin`. Reset a password: add `FORCE=1`.
- Hardware test scripts (UHPPOTE): `apps/server/scripts/uhppote-listen.mjs` (live events),
`uhppote-relay.mjs` (guarded door-open). See [[uhppote-controller]].
+53
View File
@@ -0,0 +1,53 @@
---
type: concept
tags: [parking, printer, device, reliability]
sources: []
updated: 2026-06-14
---
# Printer roles & failover
A lane runs **more than one printer**, and the system knows each one's job so it can fail over
automatically. This is a reliability decision, not a threat-model one: an entry ticket must
still print when the outside dispenser jams or drops off the network.
## Roles
Each printer instance (a `lane_devices` row, category `printer`) declares a **role** in its
config:
- **`entry-dispenser`** — outside, at the lane. Prints the entry ticket the driver takes.
- **`booth-receipt`** — inside the booth. Prints receipts at exit/payment, AND serves as the
**backup** for entry tickets.
It also declares a **`failoverRank`** (higher = preferred within a role) to order multiple
printers of the same role deterministically (ties broken by id).
## Failover rule (asymmetric, on purpose)
For an **entry ticket** (`wantRole = entry-dispenser`): try the entry dispensers (best rank
first), then fall back to the **booth printer**. So a driver still gets a ticket when the
outside unit is offline — the operator hands it over from the booth.
The reverse is **deliberately not** done: a **receipt** never prints on the outside dispenser.
Receipts are a booth-only job; an entry dispenser falling back to print receipts makes no
physical sense.
## Where the logic lives
- The driver (`rongta`) is **role-agnostic** — role/rank are just config; the transport doesn't
care. Keeps [[device-adapter-pattern|adapters]] swappable.
- Selection is pure logic in `packages/devices/printer-routing.ts`: `orderForRole()` ranks
candidates; `printWithFailover()` attempts the print down the list and throws
`NoPrinterAvailableError` only when every candidate fails.
- It **attempts the print directly** rather than racing a `healthCheck` first — the print is
the real reachability test, and a health probe that passes can still be followed by a failed
print.
## Open: the all-printers-down policy
When `printWithFailover` exhausts every candidate, what should entry do — raise the barrier
with no paper ticket (the plate/[[lpr-camera]] is the independent record), or hold? That policy
belongs to the **entry flow** ([[device-input-flow]], [[fail-state-safety]]), not the printer
layer, and is **not yet decided**. The signed event ([[append-only-event-chain]]) is created
regardless of whether paper prints.
@@ -0,0 +1,74 @@
---
type: concept
tags: [parking, printer, device, monitoring, reliability]
sources: []
updated: 2026-06-14
---
# Printer status monitoring
The booth must know a printer is in trouble **before** a driver presses the entry button and no
ticket comes out. So the system polls each printer's live status (paper out, cover open, cutter
jam, off-line) and pushes changes to the operator UI. A reliability control, like
[[printer-roles-failover]] — not a threat-model one.
## Where the status comes from (the safe-decode decision)
The raw print socket (TCP 9100) is write-only for us — it returns no paper/cover feedback. ESC/POS
printers expose status via real-time queries (`DLE EOT n`). On the [[rongta-printer]] clone we
probed, **`DLE EOT` replies do NOT follow the canonical ESC/POS bit layout** (the spec's fixed
validation bits were wrong, verified on hardware 2026-06-14). Decoding those bits ourselves risked
a **false-healthy** — reporting "paper OK" when it's empty — which is the dangerous direction for
an entry lane.
Instead we scrape the device's **own status web page** (`http://<host>/prn_stat.htm`). The board
decodes the bits itself into labelled Yes/No rows (Cover Is Open, Cutter Error, Paper End, Paper
Near End, Printer Off-Line). We trust the device's decode over hand-decoding an undocumented clone.
This is captured as a device capability: `MonitorableDevice.readStatus(): PrinterStatus` in
`packages/devices`. The Rongta driver implements it; the monitor is device-agnostic via
`isMonitorable()`. A future printer with a different status mechanism just implements the same
interface.
## Status mapping (fail safe)
`readStatus()` maps to `ready | degraded | offline`:
- status page unreachable / times out → **offline** (same signal as a dead printer; never throws),
- page reachable but a recognised field is missing → **degraded** ("unexpected status page") —
we do NOT claim "ready" off a page we didn't fully parse,
- any fault flag true (paper end, cover open, cutter error, off-line) → **degraded** + a detail
string ("paper out", …),
- all five clear → **ready**.
## The monitor (server)
`PrinterMonitor` (`apps/server/src/printer-monitor.ts`):
- reloads the monitored set from `lane_devices` each tick (so a newly-assigned printer is picked
up without a restart), keeping only enabled, monitorable printers;
- polls every `PRINTER_POLL_MS` (default 5000ms), never overlapping ticks;
- caches the latest status per device id;
- emits a `printer-status` event on the device bus **only when status changes** (deduped).
## API / live UI
- `GET /api/printers/status` — cached snapshot of all printers (no device round-trip).
- `GET /api/printers/status/stream` — **Server-Sent Events**: full snapshot on connect, then one
event per change. The booth SPA subscribes for real-time paper-out / offline indicators.
- Any authenticated role may read (operational, not a setup action).
## Verified on hardware (2026-06-14)
`readStatus()` against 10.0.10.6 → `ready` (all flags false); against an unreachable host →
`offline` with "status page timeout" (no throw); bus emits on change and suppresses unchanged
reads. Full repo typechecks.
## Open / not yet done
- **Fault-state capture**: we've only observed the all-clear page. The exact label text for an
active fault (e.g. does "Paper End" flip to "Yes"?) should be confirmed by physically removing
paper / opening the cover, to be 100% sure the scrape catches it. The parser is built to match
Yes/No and degrade on anything unexpected, so this is a confidence check, not a blocker.
- Tying a `degraded`/`offline` entry-dispenser into [[printer-roles-failover]] failover and the
(not-yet-built) entry flow's all-printers-down policy ([[device-input-flow]]).
+6
View File
@@ -35,3 +35,9 @@ The controls that actually address insider/operator fraud are different in kind:
The same reframing recurs at the device layer: the [[uhppote-controller]]'s real problem is
unauthenticated commands ([[uhppote-udp-protocol]]), addressed by detection
([[event-log-ingestion]]) or prevention ([[esp32-custom-controller]]).
> **Direction shift:** the system is heading toward **fully unmanned operation** — no operator, no
> booth ([[autonomous-direction]]). That removes the booth-operator as the *primary* adversary, but
> swaps in **unattended-machine threats** (tailgating, plate spoofing, physical tampering, forced
> entry). The append-only signed log + reconciliation controls carry over; the emphasis moves from
> "catch the cashier" to "trust the automated record and detect tampering."
+10 -4
View File
@@ -7,6 +7,11 @@ updated: 2026-06-14
# 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
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
- The UHPPOTE is the **current choice**: good enough as a detection/audit layer **when 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
attacker on the wire. They're **mixable per lane**.
- The UHPPOTE was the **detection-grade** option: good enough as a detection/audit layer **when
only the host can reach it** (isolation) and every event lands in the [[append-only-event-chain]]
— but it was rejected for the entry lane (the button blocker).
- 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
[[reconciliation]] as the ultimate anti-fraud control.
+65
View File
@@ -0,0 +1,65 @@
---
type: reference
tags: [parking, dev-environment, networking, wsl, troubleshooting]
sources: []
updated: 2026-06-15
---
# WSL2 Dev Networking (for device testing)
> Dev-environment note, not product architecture. Recorded because reaching real
> hardware (the [[uhppote-controller]]) from a dev box running under **WSL2** took
> significant debugging. If you test devices from WSL, read this first.
## The problem
By default WSL2 uses **NAT networking**: the Linux VM sits on its own virtual subnet
(e.g. `172.x`), not the Windows host's LAN. Consequences for device work:
- **UDP broadcast (UHPPOTE discovery) cannot leave the VM** — a `get-devices` broadcast gets
`EACCES` / never reaches a controller on the physical LAN. The device is reachable from
*Windows* but not from *inside WSL*.
- Even unicast to a LAN device may not route, depending on setup.
## The fix: mirrored networking
Switch WSL to **mirrored** mode so it shares the Windows host's interfaces (and thus the real
LAN). Requires **Windows 11 22H2+** and **WSL ≥ 2.0**.
`%UserProfile%\.wslconfig` (create it; it doesn't exist by default):
```ini
[wsl2]
networkingMode=mirrored
firewall=false # Windows Firewall otherwise filters WSL traffic (can drop UDP replies)
[experimental]
hostAddressLoopback=true # host <-> WSL over the host's IP
```
Apply: in **PowerShell** `wsl --shutdown`, wait ~10 s, reopen WSL. Verify with `ip -4 addr` —
interfaces should now show the **real LAN subnet** (e.g. `10.0.10.x`) instead of `172.x`.
(Microsoft recommends editing via the **WSL Settings** GUI rather than the file by hand.)
> `wsl --shutdown` kills the dev servers — restart `pnpm dev` afterward.
## After mirrored mode: app-level gotchas that remained
Mirrored networking is necessary but **not sufficient** — these still bit us:
- **Multiple interfaces.** Mirrored WSL exposes *all* host NICs (LAN, Tailscale/CGNAT `100.x`,
docker bridges). UHPPOTE discovery must broadcast on **every** subnet, not the first one — see
[[device-discovery]].
- **Subnet-directed broadcast** (`10.0.10.255`, not `255.255.255.255`) — the lib won't enable
`SO_BROADCAST` otherwise. See [[device-discovery]].
- **`localhost` → IPv6 first.** `localhost` resolves to `::1`, but the backend binds IPv4
(`127.0.0.1`). Node's Vite proxy can stall on the v6 attempt before falling back — point the
proxy at `127.0.0.1` explicitly. (See [[local-dev-workflow]].)
## Alternative if you can't use mirrored mode
Windows 10 / old WSL can't do mirrored mode. Options: run the **backend natively on Windows**
(shares the LAN), or use **unicast by IP** instead of broadcast discovery (target the controller's
known IP — the driver supports an explicit host). On the real **appliance** (a dedicated hardened
Linux box, [[disk-os-hardening]]) none of this applies — it's bare-metal on the device VLAN
([[network-isolation]]).
@@ -1,17 +1,22 @@
---
type: decision
tags: [parking, hardware, access-control, blocker, open]
tags: [parking, hardware, access-control, resolved]
sources: [parking-system-architecture]
updated: 2026-06-15
status: open
status: settled
---
# Blocker: Push-Button → Auto-Open Defeats the Ticket-First Entry Flow
# Push-Button → Auto-Open: the Ticket-First Entry Blocker (RESOLVED)
> **Procurement-blocking finding (2026-06-15), from on-hardware testing.** The UHPPOTE and
> ZKTeco access controllers **on hand** cannot, as wired/configured, deliver the required entry
> flow. This blocks the entry lane and needs a hardware/wiring resolution before that lane ships.
> Work paused here to focus on the business side. See [[entry-exit-readers]], [[trust-boundary]].
> **✅ RESOLVED (2026-06-15) by the [[dingtian-relay]] controller.** Its inputs are decoupled from
> its relays (`input_link_relay` configurable off — done & verified on hardware), so a button on an
> input reports to the host **without** firing a relay. Host-in-the-loop entry
> (`button → host → ticket → host opens relay`) now works. The original blocker (below) stands as
> the record of why the UHPPOTE/ZKTeco units couldn't do it.
>
> **Original procurement-blocking finding (2026-06-15), from on-hardware testing:** the UHPPOTE and
> ZKTeco controllers on hand could not, as wired/configured, deliver the required entry flow.
> See [[entry-exit-readers]], [[trust-boundary]].
## The required flow
+45
View File
@@ -0,0 +1,45 @@
---
type: decision
tags: [parking, direction, roadmap]
sources: []
updated: 2026-06-15
status: open
---
# Project Direction: Toward Fully Autonomous (Unmanned)
> Stated goal (2026-06-15): the system will evolve to **fully automatic operation — no human
> operator, no booth at all**. Recorded because "unmanned" is an architectural force that shapes
> several existing decisions, not just a feature.
## What "unmanned" changes
- **Threat model shift.** The original primary adversary was *"the legitimate operator at the
booth"* ([[threat-model]]). Remove the operator and that specific fraud vector (take cash → void
the record) largely disappears — but it's replaced by **unattended-machine threats**: tailgating,
plate spoofing/obscuring, physical tampering with a box nobody is watching, and forced entry.
The [[append-only-event-chain]] + [[reconciliation]] controls still apply; the emphasis moves
from "catch the cashier" to "trust the automated record + detect tampering."
- **Host-in-the-loop entry becomes mandatory, not optional.** With no person to hand over a ticket
or wave a car through, the machine must own the whole flow: detect arrival → issue ticket / read
plate → open. This is exactly why the [[access-controller-button-flow]] blocker matters and why
a controller whose input does **not** auto-fire the relay (see [[dingtian-relay]]) is required.
- **Reliability / fail-state get more critical** ([[fail-state-safety]]). No operator to recover a
stuck barrier or a trapped car ⇒ watchdogs, **exit-fails-open**, and hardware manual override
stop being nice-to-haves. Unattended uptime is a hard requirement.
- **Payment goes unmanned.** Pay-station / pay-on-foot or in-lane unmanned terminal rather than a
booth P2PE + cash drawer — sharpens [[open-questions]] #3 toward the unmanned option (PCI scope
still kept out of the app via a certified terminal).
- **Identity leans on automation.** Plate recognition ([[lpr-camera]]) and permit reads become the
primary identity sources, since there's no one to issue/inspect a paper ticket by hand.
## Near-term stance
Build for the unmanned target but don't over-engineer ahead of it. Current concrete step: the
**[[dingtian-relay]]** controller over **HTTP** (device pushes input events to the host; host
commands relays) — see [[dingtian-vs-mqtt]] for why HTTP over a message bus for now.
## Open
Lane topology, payment subsystem, and reconciliation channel ([[open-questions]]) should all be
(re)evaluated through the **unmanned** lens before procurement.
+46
View File
@@ -0,0 +1,46 @@
---
type: decision
tags: [parking, decision, devices, transport]
sources: []
updated: 2026-06-15
status: settled
---
# Transport for the Relay Controller: HTTP/UDP now, MQTT parked
**Decision (2026-06-15): use direct HTTP + UDP for the [[dingtian-relay]] controller now. MQTT is
deliberately skipped, but kept on the radar** for when the system scales.
## Options the device supports
The Dingtian relay board speaks several protocols: Dingtian string (UDP/TCP), Dingtian binary
(UDP, optional multicast/password), **HTTP CGI**, **HTTP input-link push** (`input_link_url`),
**Modbus** (RTU/TCP/ASCII), and **MQTT**.
## Why not MQTT (yet)
- **A broker is new infrastructure** on a deliberately **single-purpose hardened appliance**
([[disk-os-hardening]]) — another service to install, secure, supervise, and keep alive.
- **Extra failure mode on the critical path.** Today host→UDP→relay. MQTT inserts a broker on both
control and event paths; if it stalls, the lane stalls — and there are 3 processes to debug, not 2.
- **Doesn't fit [[offline-first]] for this scale.** MQTT earns its keep with *many* devices/consumers
and intermittent links. Here it's **one host + a few devices on one isolated LAN, metres apart** —
request/response control + a single input event, no fleet.
- **The device's MQTT input publish is periodic** ("default every 30 s"), so it's not even a clean
on-press event without relying on unverified on-change behaviour.
## Why HTTP/UDP fits
- **Relay control:** direct **UDP string protocol** (port 60001) — `11`=relay1 on, `21`=off,
`T1`=toggle, `11*`=jog/pulse. No deps, no broker.
- **Input/button events:** the device's **`input_link_url`** can **HTTP POST to the host backend
when an input fires** — real push, device calls our existing Fastify server directly, no broker.
(Polling `00` status over UDP every ~50 ms is the self-contained fallback.)
- Fewest moving parts; matches the local same-origin model already in use.
## When to revisit MQTT
If the system grows to **many lanes / many controllers**, or multiple subsystems (LPR, payment,
signage) all need to share events, a broker becomes a worthwhile central event bus. That aligns
with the [[autonomous-direction|unmanned]] roadmap at multi-lane scale — re-evaluate then. Until
then, direct HTTP/UDP wins on simplicity and reliability.
+1 -1
View File
@@ -27,7 +27,7 @@ status: open
later" currently leaves a disk failure as **total revenue-history loss**.
6. **Secure-element integration.** Confirm [[atecc608]] wiring/usage on the host (event
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.
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
+6 -4
View File
@@ -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
([[append-only-event-chain]]); **[[reconciliation]] is the anti-fraud control**; encryption
protects only at-rest (see [[threat-model]]).
- **Access control:** [[uhppote-controller]] for now, on an **isolated VLAN**
([[network-isolation]]); event log used as a tamper-evident audit source with host-side index
tracking ([[event-log-ingestion]]). The [[esp32-custom-controller]] is the documented
prevention-grade upgrade path (the [[trust-boundary]] fork).
- **Access control:** the **[[dingtian-relay]]** relay+input controller, on an **isolated VLAN**
([[network-isolation]]). Chosen because its **inputs are decoupled from its relays**, enabling
host-in-the-loop ticket-first entry — the resolution to [[access-controller-button-flow]].
(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
for [[lpr-camera|LPR]]/QR/pure-network readers; both can share a relay (see
[[entry-exit-readers]]).
+3 -3
View File
@@ -14,11 +14,11 @@ 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]]) |
| 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 |
| Casual identity | [[lpr-camera]] (Milesight, edge AI) | Plate = ticket + independent record |
| Ticket dispenser | Custom VKP80 | Parking-grade thermal/ESC-POS |
| Booth printer | Epson TM / Citizen (USB or network) | ESC/POS; one adapter covers both transports |
| Ticket dispenser | [[rongta-printer]] 80mm (entry-dispenser role) | ESC/POS over raw TCP 9100; driver written |
| Booth printer | [[rongta-printer]] 80mm (booth-receipt role) | Receipts + backup for entry tickets ([[printer-roles-failover]]) |
| Payment | Bank-certified P2PE standalone terminal + cash drawer | Keeps the app out of **PCI-DSS scope** |
| Host machine | Fanless industrial PC + UPS + [[atecc608]] | Reliability, power-loss safety, offline signing |
| Network | Managed VLAN switch, PoE+ | Isolate the open control protocol |
+156
View File
@@ -0,0 +1,156 @@
---
type: entity
tags: [parking, hardware, access-control, relay]
sources: []
updated: 2026-06-14
---
# Dingtian Relay Controller
A network **relay + input** board (the unit on hand is the **4-channel** variant: 4 relays + 4
inputs). Chosen to drive the entry/exit lane because — unlike the [[uhppote-controller]] — its
**inputs are independent of its relays**, which solves the [[access-controller-button-flow]]
blocker (a button on an input does not auto-open a relay; the host decides).
SDK: `dingtian/4ch/sdk_v2_0_0/` (programming manual, examples). MIT-compatible use; no vendor
runtime needed.
## ⚠️ The one gotcha: `input_link_relay`
By **default the device links each input to auto-fire its matching relay** (`input_link_relay: 1`,
`on_action_on: [[0],[1],…]` in the config) — i.e. the *same* auto-open problem as the UHPPOTE.
The difference: **it is configurable.** Set `input_link_relay: 0` (or clear the action mappings)
so an input only *reports* and the host commands the relay. **This config step is mandatory** for
the ticket-first entry flow. See [[autonomous-direction]].
## Protocol (Dingtian string — what we use)
Transport options: UDP/TCP string, UDP binary, HTTP CGI, Modbus, MQTT. We use **HTTP + UDP** —
see [[dingtian-vs-mqtt]].
- **Relay control — UDP *binary*, port 60000 (authenticated):** the driver's `pulseOpen` sends a
binary "write relay with jogging" frame carrying the `relay_pw` (the only relay option with a
password). Frame (verified on hardware):
`FF AA <session> 03 <pwLo> <pwHi> <relayByte> <jogLo> <jogHi>` — relayByte bit0=on, bits1-7=
channel-1; jog is 100 ms units, LSB-first; password 16-bit LSB-first (0 = none). The relay jogs
ON then auto-releases, so we never time a close ([[barrier-not-a-door]]). *(The simpler string
protocol — `1`+ch on, `2`+ch off, `11*` jog — works too but has no auth; we use it only for the
read-only status query.)*
- **Status / inputs — send `00`** → `「relays」:「inputs」:「count」`, e.g. **`0000:1111:4`** (4ch:
relays off, inputs high). `0` = OFF/Low, `1` = ON/High. Poll-based.
- **Input push — `input_link_url`:** device **HTTP POSTs to a host URL on input change** — the
push path for button events without a broker.
- **Discovery:** UDP multicast `224.0.2.11:60000`, send `\x05\xAA` (devices reply). Defaults:
IP `192.168.1.100`, UDP `60000` (binary) / `60001` (string).
- Binary protocol (port 60000) adds optional **password** + multicast; bitmask relay/input maps.
## Driver & config API
The `dingtian` driver ([[device-registry]]) implements three capabilities:
`AccessControlDevice` (relay pulse/latch over UDP), `InputDevice` (read inputs + poll-based
press/release events ~50 ms), and `PreconditionDevice` (below). Config fields include a separate
**`httpPort`** — the device's web/config API is on a configurable HTTP port (default **80**),
distinct from the UDP control port 60001.
### Precondition: input_link_relay must be OFF
The driver reads the device's JSON config (`GET /api/v2/config.cgi`) and **checks
`input_link_relay`**; if enabled it reports a fixable issue, and `fixPreconditions()` writes the
correction (`POST /api/v2/config_set.cgi`) — setting the flag to 0 and clearing `on_action_on`,
preserving everything else (network, etc.). This is the generic [[device-registry|precondition]]
capability: the app doesn't own full device config (that's the vendor web UI), only the few
settings our flow depends on.
> **Write gotcha (cost real debugging):** the GET config payload **omits** a `"command"` field, but
> the set endpoint **requires `"command":"setconfig"`** injected right after `"status"`. Without it
> the POST returns/looks like success but silently does nothing (and the device may reset). With it,
> POST returns `{"status":0}` and the change sticks. JSON node order must be preserved.
## Input push (no polling) — the chosen architecture
The device **pushes** button events to the backend; the backend decides. **No polling.** The
driver's `configureInputPush()` writes the device's `input_link_url` config to point each input at
the backend: input N HTTP-GETs `…/api/devices/dingtian/<deviceId>/input/<N>/on` (and `/off`) on
press/release. The backend ([[fastify]] route `routes/devices.ts`) translates each push into an
internal device event ([[device-input-flow]]); the entry flow then prints a ticket and commands
the relay via UDP. See [[device-input-flow]] for the full path + trust model.
> The input-poll path in the driver (`onInput`) remains as a dev/fallback aid, but **push is the
> real path** — lower latency, and it can be authenticated (the device supports Basic/Digest +
> HTTPS on the push), unlike the open UDP control direction.
### What it pushes vs. doesn't (logging)
- **Inputs (buttons): YES, pushed.** Input changes are HTTP-pushed via `input_link_url` and now
land in the host's signed [[append-only-event-chain]] as `input_received` events (bus →
`EventLog`). That is the audit trail for "a button fired."
- **Relay / barrier opens: NO push, no log.** The device has **no event log of its own** and does
not report when a relay fires — relay control is one-way UDP that the *host* initiates. So
"the barrier opened" is not something to scrape from the device. The host records what it
*commanded* (a future `barrier_open_command` event); a relay open with **no matching signed
host event is itself the anomaly** to alarm on ([[threat-model]]). Do not treat the Dingtian as
a log source — it is a dumb relay+input board; the host is the source of truth.
## Hardening (`harden()`) — and why HTTP auth is not a boundary here
On assign the driver runs `harden()` (the [[device-registry|HardenableDevice]] capability):
1. **`relay_pw`** — set a random relay password so binary relay commands (UDP 60000) need it.
2. **Disable EVERY other channel** — set `p:255` on the string protocol (udp2), rs485, can,
tcp×2, mqtt; keep **only** UDP1 binary, which carries `relay_pw` for both control AND status.
3. **Rotate the `admin`/`admin` web login** — `GET /userset.cgi?<old_u>&<old_p>&<new_u>&<new_p>&`
(response `&0&…&` = success, verified on hardware). The new password is stored back in
config (`webUser`/`webPassword`) so a re-run can rotate again (the device checks the *old*
creds). This step is **best-effort** — a failure logs and does not fail the assign.
> ⚠️ **The string protocol (udp2) is a password-less relay-fire path — the original `harden()`
> left it ENABLED "for status reads", which was a real hole.** The Dingtian string protocol has
> NO password field and can fire relays (`"11"` = relay 1 on, `"21"` = off, `"11*"` = jog).
> **Proven on hardware**: sending `"11"` to UDP 60001 with no credentials opened relay 1,
> completely bypassing `relay_pw`. Fixes: (a) status reads moved to the **authenticated binary
> read** (relay command `0x00`) so the string protocol is no longer needed; (b) `harden()` now
> sets `udp2.p=255` to disable it. **Firmware caveat (V3.6J):** the CONFIG API silently refuses
> to disable udp2 — it accepts the write, reboots, and clamps it back — even though the device's
> **web UI can** disable it. So the udp2 disable is **best-effort + warns** (it is NOT part of the
> blocking verify); if it doesn't stick, `harden()` returns a warning telling the admin to flip
> UDP2 off in the device web UI. Verified: after the web-UI disable, the `"11"` attack gets no
> reply and the relay stays off, while authenticated binary control/status still work.
> 🔑 **Web-login model (bug fixed).** The login set has TWO distinct config keys:
> `webPassword` = the password the admin WANTS (blank → harden generates a random one), and
> `webPasswordCurrent` = the device's EXISTING password (the old cred `userset.cgi` checks;
> defaults to `admin`). The original code conflated them — an admin typing a *desired* password
> made harden send it as the *old* cred, the rotation failed, yet the DB still saved the typed
> value: **the DB claimed a password the device never accepted (login stayed admin/admin).**
> Fix: harden now rotates `current → desired`, **verifies** by re-authenticating with the new
> password, and only then returns `secrets.webPassword`; assign strips the typed inputs and
> persists only the verified value (else a warning, no save). Verified on hardware: device
> rejects `admin/admin` (`&2&`) and accepts the chosen password (`&0&`) after harden.
>
> ⚠️ **The device CGI API is UNAUTHENTICATED.** Verified on hardware: `GET /api/v2/config.cgi`,
> `/`, and even `/userset.cgi` all return **200 with no credentials**. The `admin`/`admin` login
> gates only the interactive **browser UI** — the CGI control plane (read/write full config, fire
> relays, change the password) bypasses it entirely. The `http` config block has **no** setting to
> require Basic/Digest on inbound requests; the only inbound gate is `session_en`, which **bricks
> the config-read API on this firmware** (the factory-reset incident — *do not enable it*). So
> **rotating the login is cosmetic** (stops a casual browser reaching settings); it is **not** a
> boundary. On this flat, no-VLAN network the device control plane is effectively open — the
> **signed event log is the real anti-fraud guarantee**. See [[device-input-flow]].
> ⚠️ **`session_en` must stay OFF.** Enabling the HTTP CGI session check makes the config-read API
> drop connections (ECONNRESET), locking out the API the driver depends on — recoverable only by
> factory reset. `harden()` deliberately never touches it.
## Status — VERIFIED on hardware (DT-R004, sw V3.1.5461A, 10.0.10.172)
- ✅ status read (`0000:1111:4`), relay pulse, input press/release events (active-LOW, idle HIGH).
- ✅ **`input_link_relay` disabled via the driver** → pressing an input reports the event and
**fires NO relay** (`0000` after presses). The [[access-controller-button-flow]] blocker is
**solved**.
- ✅ **Input HTTP-push end to end** — configured the device via `configureInputPush()`, then real
button presses (all 4 inputs) **pushed to the backend** (`/input/N/on` + `/off` per press,
source = the device IP). No polling. Host-in-the-loop entry (`button → backend → ticket →
backend opens relay`) is real.
- ✅ **Web-login rotation** — `userset.cgi` rotates `admin`/`admin` (response `&0&/&`; wrong old
password → `&2&/&`). Confirmed the device validates the old creds. **Also confirmed the CGI API
needs NO auth** (config dump + `userset.cgi` return 200 unauthenticated) → rotation is cosmetic.
- ⬜ Next: wire the actual entry flow (input event → signed event + print ticket → `pulseOpen`).
+48
View File
@@ -0,0 +1,48 @@
---
type: entity
tags: [parking, hardware, printer, device]
sources: []
updated: 2026-06-14
---
# Rongta 80mm thermal printer
The chosen ticket/receipt printer: a **Rongta RP-series 80mm network thermal printer** (and the
many ESC/POS-compatible OEM clones that share its firmware). Driver `rongta` in
`packages/devices` implements [[device-adapter-pattern|PrinterDevice]].
## Transport & protocol
- **ESC/POS over a raw TCP socket on port 9100** (the JetDirect/RAW convention). The driver
opens the socket, writes the ESC/POS byte stream, waits for flush, closes.
- **No authentication** on the print socket — anyone who can reach port 9100 can print. Like
every other field device it must sit on the **isolated device VLAN** ([[network-isolation]]).
There is no real HTTP/control boundary on the device (same posture as [[dingtian-relay]]).
- **Health check** is a TCP connect probe to 9100. The print socket exposes no status protocol
we rely on; the print itself is the real reachability test (failover attempts the print).
- **Live status** comes from the device's own web page `http://<host>/prn_stat.htm` (port 80),
which decodes Cover Open / Cutter Error / Paper End / Paper Near End / Off-Line into Yes/No.
We scrape that rather than hand-decode `DLE EOT` — this clone's DLE EOT reply bytes do **not**
match the canonical ESC/POS bit layout (verified on hardware), so trusting the device's own
decode avoids a false-healthy. Implemented as `readStatus()`; see [[printer-status-monitoring]].
## Deployment (this site)
- First printer verified reachable at **10.0.10.6:9100** from the host (TCP connect OK,
2026-06-14).
- **At least two printers**, by role — see [[printer-roles-failover]]:
- **entry-dispenser** — outside, at the lane; the driver takes the entry ticket.
- **booth-receipt** — inside the booth; receipts, AND the backup that prints the entry
ticket if the outside dispenser is offline.
## Ticket rendering
`printTicket(TicketData)` builds ESC/POS: `ESC @` init, centered/bold/double-size header,
lane, ticket id, issued-at, feed + partial cut (`GS V B`). CP437/ASCII subset.
## Status
Driver written and compiles; entry-ticket layout is a first pass; live status monitoring is
implemented and verified ([[printer-status-monitoring]]). The receipt/exit layout and the
cash-drawer kick (ESC/POS `ESC p`) are **not yet implemented** — they arrive with the
exit/payment flow. Replaces the generic "Epson TM / Citizen" booth-printer line in [[bom]].
+24 -20
View File
@@ -1,33 +1,37 @@
---
type: entity
tags: [parking, hardware, access-control, current-choice]
tags: [parking, hardware, access-control, rejected, historical]
sources: [parking-system-architecture]
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)** —
a cheap reader-plus-relay frontend, acceptable **provided you understand its limits**. The plan
is UHPPOTE now → ZKTeco later (see [[bom]]). (See [[parking-system-architecture]] §6.)
> **❌ NOT USED. Replaced by the [[dingtian-relay]] controller** (and its driver/test code
> removed). Kept as the record of *why* — its firmware-fixed push-button blocker
> ([[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
> in firmware** — there's no command to make it report-without-opening — so it **cannot** do
> 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
> options: [[access-controller-button-flow]].
The original starting hardware: a **UHPPOTE Wiegand 26/34 network controller (4-door)** — a cheap
reader-plus-relay frontend. (See [[parking-system-architecture]] §6.)
> **⚠️ The fatal limit (verified on hardware):** the push-button input **auto-opens the relay in
> 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`
> on doors 1 & 2 (physically actuated, `reason="remote open door"`); button presses captured live
> (`reason="push button ok"`); [[device-discovery]] scan. Test scripts: `apps/server/scripts/`.
> **What was verified on the real unit** (serial 225088491, fw 09120) before retiring it:
> host-commanded `openDoor` on doors 1 & 2 (physically actuated, `reason="remote open door"`);
> 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
> `uhppoted` org — `github.com/uhppoted/uhppoted-lib-nodejs`), added to `@parking/devices` as the
> `uhppote` access driver ([[device-registry]]). It exposes exactly the protocol commands this
> design needs: `openDoor`, `getStatus`, and the event-log set (`getEvent`, `getEventIndex`,
> `setEventIndex`, `recordSpecialEvents`) plus `setListener`/`listen` for auto-push — see
> [[event-log-ingestion]]. Transport defaults to **UDP** (broadcast `…:60000`), with optional
> per-call TCP on newer firmware. The driver also implements **[[device-discovery]]**
> **Past implementation (removed):** was integrated via the official **`uhppoted`** npm package
> (MIT — `github.com/uhppoted/uhppoted-lib-nodejs`) as the `uhppote` access driver. It exposed
> exactly the protocol commands the design needs: `openDoor`, `getStatus`, and the event-log set
> (`getEvent`, `getEventIndex`, `setEventIndex`, `recordSpecialEvents`) plus `setListener`/`listen`
> for auto-push — see [[event-log-ingestion]]. Transport defaulted to **UDP** (broadcast `…:60000`),
> with optional per-call TCP on newer firmware. The driver also implemented **[[device-discovery]]**
> (`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
> permission on the host.
+11 -7
View File
@@ -1,20 +1,24 @@
---
type: entity
tags: [parking, hardware, access-control]
tags: [parking, hardware, access-control, rejected, historical]
sources: [parking-system-architecture]
updated: 2026-06-15
---
# ZKTeco Controller
# ZKTeco Controller (rejected — historical)
A network access controller (C3 / inBio families) — the documented "UHPPOTE now → ZKTeco later"
upgrade in the [[bom]]. A `zkteco` driver **stub** exists in the [[device-registry]] but the
**real protocol is not implemented** (see below).
> **❌ NOT USED.** Was considered as the access controller; the **[[dingtian-relay]]** board was
> chosen instead (decoupled inputs, already verified). The `zkteco` driver **stub** has been
> **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
[[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
are **not** hardwired to "open door" — so a button on an *aux* input can raise a host event
+16 -5
View File
@@ -7,7 +7,7 @@ updated: 2026-06-14
# Index
Content catalog for the wiki. Start at [[overview]]. Maintained on every ingest.
Counts: 1 source · 14 entities · 10 concepts · 2 decision records.
Counts: 1 source · 15 entities · 12 concepts · 2 decision records.
## Overview & navigation
- [[overview]] — the top-level synthesis and entry point.
@@ -32,12 +32,14 @@ Counts: 1 source · 14 entities · 10 concepts · 2 decision records.
- [[logto-zitadel-oidc]] — OIDC providers ruled out by offline-first.
## 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.
- [[atecc608]] — secure element; non-extractable signing key (host events + controller auth).
- [[wiegand]] — reader standard feeding the controller directly (autonomous permit-holder path).
- [[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]] — ✅ CHOSEN access controller; decoupled inputs solve the button blocker (driver verified on hardware).
- [[rongta-printer]] — ✅ CHOSEN 80mm thermal printer; ESC/POS over raw TCP 9100; driver written, one unit reachable at 10.0.10.6.
- [[bom]] — reference bill of materials (barrier, loops, controller, readers, payment, host, network).
## Concepts — foundational forces
@@ -53,8 +55,11 @@ Counts: 1 source · 14 entities · 10 concepts · 2 decision records.
- [[device-adapter-pattern]] — business logic talks to interfaces; swap hardware → new adapter.
- [[device-registry]] — catalog of selectable drivers per category (admin-configurable).
- [[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-input-flow]] — button → device push → backend decides → relay; backend is source of truth.
- [[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.
- [[printer-roles-failover]] — ≥2 printers per lane by role; entry ticket falls back outside→booth.
- [[printer-status-monitoring]] — live poll of paper/cover/cutter/offline via the device's status page; SSE to the booth UI.
- [[trust-boundary]] — the core fork: network vs. device; auditable vs. unforgeable.
- [[fail-state-safety]] — entry fails closed, exit fails open; manual override; watchdog.
@@ -66,7 +71,13 @@ Counts: 1 source · 14 entities · 10 concepts · 2 decision records.
- [[entry-exit-readers]] — two populations, two integration paths; both can share a relay.
- [[uhppote-vs-esp32]] — comparison: detection vs. prevention.
## Dev environment (reference)
- [[local-dev-workflow]] — running the stack locally; setup, the dev-hang gotchas, seed:admin.
- [[wsl-dev-networking]] — WSL2 NAT blocks device broadcast; use mirrored mode + the gotchas after.
## Decisions
- [[standing-decisions]] — settled decisions (stack, platform, integrity, access control, readers).
- [[open-questions]] — 7 open items (6 procurement + JWT key choice); ESP32 device auth deferred.
- [[access-controller-button-flow]] — ⚠️ BLOCKER: UHPPOTE/ZKTeco on hand can't do ticket-first entry as wired.
- [[access-controller-button-flow]] — ✅ RESOLVED: Dingtian decoupled inputs enable ticket-first entry (was a UHPPOTE/ZKTeco blocker).
- [[autonomous-direction]] — roadmap: toward fully unmanned (no booth); reshapes threat model + fail-state.
- [[dingtian-vs-mqtt]] — transport choice: direct HTTP/UDP now, MQTT parked until multi-lane scale.
+214
View File
@@ -83,3 +83,217 @@ admins. Same-origin via the Vite dev proxy and a new prod nginx config
(deploy/nginx.conf). Verified end to end (curl + browser): wrong pass→401,
login→cookies set, me→admin, assign without CSRF→403 / with→201, no cookie→401,
session persists across reload. Updated [[local-jwt-auth]].
## [2026-06-15] lint+docs | Dev-environment pages (WSL networking, workflow)
Captured hard-won dev knowledge that was only in commit messages: new
[[wsl-dev-networking]] (WSL2 NAT blocks UDP broadcast → mirrored mode + the
multi-interface / subnet-broadcast / IPv6-localhost gotchas that remained) and
[[local-dev-workflow]] (setup, seed:admin, the dev-server-hang from the broken
strip-types script → tsx, the 127.0.0.1 proxy fix, .env loading). Corrected the
earlier "broadcast permission (EACCES)" note in [[device-discovery]] — the real
cause was the lib not enabling SO_BROADCAST for the global 255.255.255.255;
documented the three verified broadcast gotchas + serialization. Added a `reference`
page type to the schema; new "Dev environment" index section.
## [2026-06-15] decision | Dingtian relay chosen; HTTP over MQTT; unmanned direction
New relay+input controller on hand (Dingtian 4ch). Its inputs are decoupled from
relays (configurable via input_link_relay) — solves the [[access-controller-button-flow]]
blocker the UHPPOTE couldn't. Transport decision [[dingtian-vs-mqtt]]: direct
HTTP/UDP now (UDP string for relay control on :60001; device input_link_url HTTP
push for button events), MQTT skipped (broker = infra + failure mode + overkill at
this scale) but kept for later multi-lane scale. Recorded the stated roadmap to
**fully unmanned, no-booth** operation in [[autonomous-direction]] and its threat-model
shift (operator-fraud → unattended-machine threats). New stub [[dingtian-relay]]
with the full protocol from the SDK. Driver + on-hardware test still to build.
## [2026-06-15] driver+test | Dingtian driver built; button blocker RESOLVED
Built the `dingtian` access driver (AccessControlDevice relay control + InputDevice
poll-based button events + new PreconditionDevice capability). Verified end to end on
real hardware (DT-R004 @ 10.0.10.172, HTTP config on :8080, UDP control :60001):
status read, relay pulse, input press/release. Disabled `input_link_relay` via the
driver's fixPreconditions (GET config → flag 0 + clear maps → POST config_set), then
confirmed: pressing inputs now fires NO relay (0000 status) — host-in-the-loop entry
works. The [[access-controller-button-flow]] blocker is RESOLVED. Gotcha recorded in
[[dingtian-relay]]: config_set requires injecting "command":"setconfig" after "status"
(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
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).
## [2026-06-15] feature | Dingtian input HTTP-push → backend (no polling)
Wired the device's "Input Link URL" feature so it HTTP-pushes button events to
our backend — no polling. Driver `configureInputPush()` writes input_link_url
(per-input server/port/path, en=1, active-LOW, plain HTTP) via the config API
(reusing the #writeConfig + command:setconfig helper). New backend route
`routes/devices.ts`: public `GET/POST /api/devices/dingtian/:deviceId/input/:n/{on,off}`
→ emits onto an internal device-events bus (device-events.ts, EventEmitter) for
the entry flow to consume. VERIFIED on hardware: configured device, real presses
on all 4 inputs pushed to the backend (input N on+off, source = device IP). Trust
model recorded in [[device-input-flow]]: flat network / no VLAN → backend is source
of truth, every open is a signed event (out-of-band open = anomaly); push endpoint
not behind cookie auth (machine call), shared-secret available as defence-in-depth.
Next: wire signed event + ticket print + pulseOpen.
## [2026-06-15] feature | Dingtian push auth via HTTP Digest (hardware-tested)
Secured the device→backend input push. Empirically tested auth options on the
device: HTTPS-to-self-signed FAILS, Basic works, **Digest works** → chose Digest
(MD5, qop=auth): password never on the wire, single-use nonces. Backend
digest-auth.ts (challenge/verify) + source-IP allowlist on the push route;
per-device pushUser/pushPassword generated on assign, written to the device and
stored in lane_devices (admin never types a URL/secret). Driver
configureInputPush now sets auth=2 + creds; the assign flow auto-configures the
device and persists the creds (net.ts derives the backend IP on the device's
subnet). Removed the earlier URL-token approach (token in URL is sniffable/logged).
TWO HARD-WON DEVICE BUGS fixed: (1) config_set requires an explicit Content-Length
— the device silently ignores chunked bodies (Node's default), which masqueraded
as "writes don't apply" all session; (2) the `pass` field caps at 31 chars →
use a 24-char password. Driver #writeConfig now polls-until-verified (device
reboots on apply). VERIFIED on hardware: assign auto-configures the device, then
all 4 inputs push with Digest auth, zero failures. Recorded in [[device-input-flow]].
## [2026-06-15] feature | Setup wizard: Test connection + Save & configure
Two-step device setup UX. New admin-only POST /api/setup/test (healthCheck +
checkPreconditions, no save / no device change). The assign (Save) step now also
fixes preconditions (disables input_link_relay) before configuring push — closing
a gap where assigned devices could still auto-fire relays; fails the save with no
DB row if device config fails (no orphan rows). SetupWizard wires the config
fields → Test button (health badge + precondition warnings) → Save & configure
button. Verified in-browser against the real device: Test shows ● ready +
preconditions OK; Save persists the row AND writes the device's Input Link URL
(push path matches the saved device id). Admin never logs into the device web UI.
Updated [[first-run-setup]].
## [2026-06-15] feature | Device hardening: binary relay + relay_pw + disable channels
Hardened the Dingtian relay control for the flat (no-VLAN) network. Switched
pulseOpen from the unauthenticated string protocol (:60001) to the **binary
protocol (:60000) with a relay password** — the only authenticated relay option
(frame verified on hardware: FF AA <sess> 03 <pwLE> <relayByte> <jogLE>). New
HardenableDevice capability: harden() sets a random relay_pw + disables unused
channels (rs485/can/tcp×2/mqtt → p:255, keep UDP binary+string). Folded into the
assign/Save flow (preconditions → harden → push); relayPassword stored in
lane_devices. Verified end to end: assign configures + hardens the device, config
API stays reachable, pulseOpen with the stored password fires the relay, without
it is rejected.
⚠️ LESSON: enabling the device's HTTP CGI session check (session_en) on this
firmware breaks the config-READ API (ECONNRESET) — locked us out, needed a FACTORY
RESET to recover. harden() deliberately does NOT touch session_en. The open CGI
API is accepted as flat-network reality; the signed log is the real guarantee.
Recorded in [[device-input-flow]] + [[dingtian-relay]].
## [2026-06-14] query | Dingtian web-login rotation + CGI API is unauthenticated
While addressing "change the device's default admin/admin", traced the device web
UI JS (system.js) → the change-login endpoint is
`GET /userset.cgi?<old_u>&<old_p>&<new_u>&<new_p>&` (response `&0&/&` = success,
`&2&/&` = wrong old pw). Added a best-effort `setWebLogin`/`#rotateWebLogin` step
to `harden()` (new pw stored back as config `webPassword`, stripped from API
responses). KEY FINDING: the device CGI API needs NO authentication — config dump,
config write, relay fire, and userset.cgi itself all return 200 unauthenticated
(verified on 10.0.10.5). admin/admin gates only the browser UI; there is no
inbound-auth setting (only session_en, which bricks the read API). So rotating the
login is COSMETIC, not a boundary — the signed event log remains the real
guarantee. Recorded in [[dingtian-relay]] (new Hardening section).
## [2026-06-14] ingest | Rongta 80mm printer driver + printer roles/failover
- Added `rongta` PrinterDevice driver (ESC/POS over raw TCP 9100); registered in registry.
- Decision: ≥2 printers per lane by role (entry-dispenser outside, booth-receipt inside);
entry ticket fails over outside→booth (asymmetric — receipts never print outside).
- Selection logic lives in packages/devices/printer-routing.ts (orderForRole, printWithFailover).
- One unit verified reachable at 10.0.10.6:9100 from host (TCP connect OK).
- New pages: [[rongta-printer]], [[printer-roles-failover]]. Updated [[bom]], [[index]].
- Open: all-printers-down policy belongs to the (not-yet-built) entry flow, not the printer layer.
## [2026-06-14] ingest | Live printer status monitoring
- Added MonitorableDevice.readStatus()/PrinterStatus capability in packages/devices.
- Rongta readStatus() scrapes the device's own /prn_stat.htm (Cover/Cutter/Paper End/Near End/
Off-Line) — chosen over hand-decoding DLE EOT because this clone's DLE EOT bytes don't match
the canonical ESC/POS bit layout (verified on hardware; risk of false-healthy).
- Server PrinterMonitor: polls enabled monitorable printers (PRINTER_POLL_MS, default 5s),
caches latest, emits "printer-status" on change. API: GET /api/printers/status + SSE stream.
- Verified live: 10.0.10.6 -> ready (all flags clear); unreachable host -> offline (no throw);
bus emits on change, suppresses unchanged. Full repo typechecks (8/8).
- New page: [[printer-status-monitoring]]. Updated [[rongta-printer]], [[index]].
- Open: capture the page's actual text for an ACTIVE fault (pull paper / open cover) to confirm
the Yes flip; wire degraded/offline into failover + entry-flow all-down policy.
## [2026-06-15] ingest | Multi-instance device setup (add/remove per category)
- Confirmed the data model was already multi-instance (lane_devices = one row per instance,
assign always inserts); the limitation was UI-only (one slot per category).
- Backend: added DELETE /api/setup/assign/:id (unassign); /state now redacts secrets
(pushPassword/webPassword/relayPassword) via a shared redactSecrets() also used by /assign.
- Web: SetupWizard reworked — each category lists assigned instances (with Remove) + "Add
another" form; select-type config fields now render as dropdowns (fixes printer role input).
- Verified via Fastify inject: 2 printers assigned to one lane -> both listed, no secret leak,
delete -> 204, delete unknown -> 404, count drops to 1. Full repo typechecks (8/8).
- Updated [[first-run-setup]].
## [2026-06-15] ingest | Append-only signed event log (Dingtian input pushes persist)
- Q: does the Dingtian push events? -> inputs YES (input_link_url), relay opens NO (device keeps
no log). Host is the source of truth; a relay open w/o matching signed event is the anomaly.
- Implemented EventLog (apps/server/event-log.ts): serialized append, monotonic index, prevHash
chain, signature; verifyChain() detects tamper/reorder/delete. Read: GET /api/events;
integrity: GET /api/events/verify (admin).
- Signer abstraction (packages/shared) over the ATECC608; SoftwareSigner (HMAC, EVENT_SIGNING_KEY)
shipped now since chip wiring is open-question #6. Caveat documented: software signer is
tamper-evident but NOT unforgeable-by-owner.
- Wired bus -> log: Dingtian input pushes become input_received events (lane mapping TODO).
- Added ParkingEventType 'input_received'.
- Verified via inject: push w/o digest -> 401; pushes -> 2 signed+chained events; verify -> ok;
direct DB tamper -> verifyChain catches at the right index; deleted row -> index gap. 5 concurrent
appends -> indices 1..5 intact. Full repo typechecks.
- Updated [[append-only-event-chain]], [[dingtian-relay]].
## [2026-06-15] ingest | Event log + Dingtian string-protocol security fix
- Append-only signed event log shipped (EventLog, Signer abstraction over ATECC608 w/ SoftwareSigner
HMAC; GET /api/events + /api/events/verify). Dingtian input pushes persist as input_received.
Verified on hardware: shorting I1-I4 -> 8 signed+chained events, verifyChain ok.
- SECURITY (verified on hardware): the password-less string protocol (udp2) can fire relays
("11" -> relay1 on) with NO auth, bypassing relay_pw. Fixes: status reads moved to authenticated
binary read (cmd 0x00); harden() disables udp2 BEST-EFFORT (firmware V3.6J config API refuses,
but web UI works) and returns a warning instead of throwing. After web-UI disable, the "11" attack
is dead and binary control/status still work.
- GAP (user-identified): event log captures host-originated actions only; out-of-band relay
actuation (sniffed relay_pw, string protocol, ip_watchdog) produces NO event — proven on hardware.
Real control is reconciliation vs. an independent witness; witness+reconciliation NOT yet built.
- Device web login (webUser/webPassword) now un-redacted in setup state (admin-only device area);
pushPassword/relayPassword stay machine-only.
- harden() warnings surfaced via the assign response.
- localAddress threaded through the Dingtian driver (device-facing-IP foundation; multi-homed hosts).
- INCIDENT: probing default.cgi factory-reset the bench device (now at 192.168.1.100, defaults).
Re-provisioning is the ADMIN's job via First-run setup (app must not hardcode site IPs).
- Updated [[append-only-event-chain]], [[dingtian-relay]].
## [2026-06-15] fix | Dingtian web-password: desired-vs-current split + verify + UI warnings
- BUG (found in real assign): admin typed a web password; harden used it as the OLD cred, rotation
failed silently, DB saved the typed value but device login stayed admin/admin. Also UDP2 warning
never reached the admin (frontend discarded the assign response).
- FIX: split config into webPassword (desired; blank→random) and webPasswordCurrent (existing old
cred, default admin). harden() rotates current→desired, VERIFIES by re-auth with the new pw, and
only returns secrets.webPassword on success (else warning, no save). assign strips typed
webPassword/webPasswordCurrent and persists only verified secrets.
- SetupWizard now shows assign-response warnings (amber banner, per category) — closes the
feedback loop for the UDP2-can't-disable case.
- Verified on hardware (192.168.1.100): harden set login to a chosen pw; device then rejects
admin/admin (&2&) and accepts the chosen pw (&0&). UDP2 warning surfaced as designed.
- Updated [[dingtian-relay]].
## [2026-06-15] update | input_received lane resolution + source semantics
- Wired device→lane resolution: `LaneMap` (`apps/server/src/lane-map.ts`) caches
`lane_devices.id → lane`, refreshed by setup routes on assign/unassign. `input_received`
events now carry the firing device's lane instead of a hardcoded `lane: 0`. Unmapped device →
`lane: -1` + warn (0 is a real lane; never mis-stamp).
- Documented that `source` stays null for raw inputs by design (it's an IdentitySource, not a
device field); device provenance is in `identity`.
- Updated [[append-only-event-chain]].
+8 -7
View File
@@ -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.
- **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.
- **Access control** hinges on the [[trust-boundary]] fork:
[[uhppote-vs-esp32|detection vs. prevention]]. Today: [[uhppote-controller]] behind
[[network-isolation]], its open [[uhppote-udp-protocol]] contained, its log made trustworthy by
[[event-log-ingestion]]. Upgrade path: the [[esp32-custom-controller]] with
[[challenge-response-auth]] and [[fail-state-safety]].
- **Access control** today is the **[[dingtian-relay]]** relay+input controller behind
[[network-isolation]] — chosen because its inputs are **decoupled from its relays**, enabling
host-in-the-loop ticket-first entry (resolving [[access-controller-button-flow]]). The
[[uhppote-controller]] and [[zkteco-controller]] were evaluated and **rejected** (historical).
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]]
(autonomous), casual/transient via host-side [[lpr-camera]] / QR; both can share a relay.
- 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]] →
[[uhppote-vs-esp32]].
- *Hardware-first:* [[bom]] → [[uhppote-controller]] → [[entry-exit-readers]] →
[[esp32-custom-controller]].
- *Hardware-first:* [[bom]] → [[dingtian-relay]] → [[access-controller-button-flow]] →
[[entry-exit-readers]].
- *Stack-first:* [[technology-stack]] → [[offline-first]] → [[device-adapter-pattern]].