UHPPOTE hardware bring-up + entry-flow blocker

Brought up the real UHPPOTE controller (serial 225088491, fw 09120) end to end
and recorded a procurement-level blocker.

Verified on hardware:
- discovery (LAN scan), host-commanded openDoor on doors 1 & 2 (physically
  actuated; reason="remote open door"), and live button capture
  (reason="push button ok").

Driver/networking fixes (packages/devices/src/drivers/access-uhppote.ts):
- broadcast to subnet-directed address (lib doesn't enable SO_BROADCAST for the
  global 255.255.255.255 -> EACCES);
- Config broadcast must match the target's subnet for unicast reply routing
  (fixes the health-check timeout: 5s -> 24ms ready);
- discover across all local subnets, dedupe by serial;
- serialize all controller I/O (concurrent calls collided on UDP :60001).

Server/UX:
- load .env via node --env-file-if-exists (vars weren't being read before);
- SETUP_AUTH_BYPASS hardened: env-gated, dev + loopback only, fails closed
  otherwise; surfaced as catalog.authBypass so the wizard drops the token field;
- .env.example documents all vars; inline favicon stops a 404.
- apps/server/scripts/: uhppote-listen (live events, restores prior listener)
  and uhppote-relay (guarded door-open test).

BLOCKER (wiki/decisions/access-controller-button-flow.md): the controller's
push-button input auto-opens the relay in firmware with no report-without-open
mode, so ticket-first entry (button -> print -> open, fail-closed) is impossible
as wired. UHPPOTE can't do it on that input; ZKTeco *might* via a programmable
aux input + PULL SDK but that's unverified and needs a new driver. Entry-lane
hardware decision paused to focus on the business side.

wiki: access-controller-button-flow (blocker), zkteco-controller (stub +
assessment), uhppote-controller callout, index + log.
This commit is contained in:
2026-06-14 10:29:43 +02:00
parent a0e0fd9118
commit 77606da2c9
19 changed files with 632 additions and 50 deletions
+1
View File
@@ -3,6 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="data:," />
<title>Parking System</title>
</head>
<body>
+23 -12
View File
@@ -52,16 +52,22 @@ export function SetupWizard() {
style={{ width: "4rem" }}
/>
</label>
<label style={{ flex: 1 }}>
Admin token{" "}
<input
type="password"
value={token}
onChange={(e) => setToken(e.target.value)}
placeholder="needed to scan / assign"
style={{ width: "60%" }}
/>
</label>
{catalog.authBypass ? (
<span style={{ flex: 1, color: "#92400e" }}>
⚠️ auth bypass on (testing) — no token needed
</span>
) : (
<label style={{ flex: 1 }}>
Admin token{" "}
<input
type="password"
value={token}
onChange={(e) => setToken(e.target.value)}
placeholder="needed to scan / assign"
style={{ width: "60%" }}
/>
</label>
)}
</div>
{CATEGORIES.map(({ key, title }) => (
@@ -71,6 +77,7 @@ export function SetupWizard() {
entries={catalog[key]}
discoverableIds={catalog.discoverable}
token={token}
authBypass={catalog.authBypass}
selectedId={picked[key]}
onSelect={(id) => setPicked((p) => ({ ...p, [key]: id }))}
/>
@@ -84,6 +91,7 @@ function CategoryPicker({
entries,
discoverableIds,
token,
authBypass,
selectedId,
onSelect,
}: {
@@ -91,6 +99,7 @@ function CategoryPicker({
entries: CatalogEntry[];
discoverableIds: string[];
token: string;
authBypass: boolean;
selectedId: string | undefined;
onSelect: (id: string) => void;
}) {
@@ -144,10 +153,12 @@ function CategoryPicker({
{canDiscover && (
<div style={{ margin: "0.5rem 0", padding: "0.5rem", background: "#f3f4f6", borderRadius: 6 }}>
<button type="button" onClick={scan} disabled={scanning || !token}>
<button type="button" onClick={scan} disabled={scanning || (!authBypass && !token)}>
{scanning ? "Scanning…" : "Scan for controllers"}
</button>
{!token && <span style={{ marginLeft: 8, color: "#92400e" }}>enter an admin token to scan</span>}
{!authBypass && !token && (
<span style={{ marginLeft: 8, color: "#92400e" }}>enter an admin token to scan</span>
)}
{scanError && <span style={{ marginLeft: 8, color: "crimson" }}>{scanError}</span>}
{found && found.length === 0 && <p style={{ margin: "0.5rem 0 0" }}>No controllers found on the LAN.</p>}
{found && found.length > 0 && (
+2
View File
@@ -22,6 +22,8 @@ export type DeviceCategory = "access" | "reader" | "camera" | "printer";
export type Catalog = Record<DeviceCategory, CatalogEntry[]> & {
/** Driver ids that support LAN discovery. */
discoverable: string[];
/** True when setup endpoints skip admin auth (testing only) — no token needed. */
authBypass: boolean;
};
export async function fetchCatalog(): Promise<Catalog> {