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
@@ -0,0 +1,86 @@
---
type: decision
tags: [parking, hardware, access-control, blocker, open]
sources: [parking-system-architecture]
updated: 2026-06-15
status: open
---
# Blocker: Push-Button → Auto-Open Defeats the Ticket-First Entry Flow
> **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]].
## The required flow
```
car arrives → driver presses button → TICKET PRINTS → then barrier opens
```
The ticket must print **before** the barrier opens, and entry must **fail closed**: if the
ticket can't print (printer offline / out of paper), the barrier must **stay shut** — no
untracked car enters (the anti-fraud core, see [[threat-model]], [[append-only-event-chain]]).
## The problem (verified on real hardware)
The push-button is wired into the controller's **push-button / request-to-exit (REX) input**.
On that input the controller **firmware auto-fires the relay immediately** — the door opens on
press. The host only **observes** the event *after* the relay has already actuated, so there is
no point at which it can insert the ticket-print step. The host is structurally too late.
This is the [[entry-exit-readers]] principle in the negative: a button on the controller's own
input is decided **by the controller**, not the host. For host-in-the-loop entry, the button
must be a **host-side input** and the controller demoted to a **commanded relay**.
### Verified facts (UHPPOTE 225088491, firmware 09120)
Tested live via the `uhppoted` lib (see `apps/server/scripts/`):
- **Relays work**, host-commanded: `openDoor` doors 1 & 2 → `{opened:true}`, physically
actuated, logged as events with `reason="remote open door"`.
- **Buttons fire events** — but only **after** auto-opening: press logs an event with
`reason="push button ok"` (door 1 button → door 1, door 2 button → door 2).
- Both doors are in `control: "controlled"` mode with a 3 s delay.
- **No UDP command exists** to stop the push-button input from auto-opening the relay. The
`uhppoted` protocol (and the UHPPOTE firmware, which is **not changeable** —
[[uhppote-udp-protocol]]) has no "report-but-don't-open" mode for that input.
## Per-device assessment
- **UHPPOTE** — push-button input is **firmware-hardwired to auto-open**; not configurable.
Cannot do ticket-first entry while the button is on that input. Fine as a **commanded relay**
(host `openDoor`) and for permit/[[wiegand]] lanes; **not suitable for the button-driven entry
lane** without rewiring the button to a host-side input. See [[uhppote-controller]].
- **ZKTeco (C3 / inBio)** — *better positioned but unverified by us.* Its **auxiliary inputs**
have **programmable linkage** (via ZKBioSecurity / the PULL SDK) and need **not** be tied to
"open door", and its SDK streams real-time events + an explicit open command — so
`button → aux input → host event → print → host opens` is achievable **in principle**. BUT:
(a) wiring the button to the door's *exit-switch* input still auto-opens, same as UHPPOTE — it
only works on a properly-configured **aux** input; (b) ZKTeco speaks its **own PULL SDK
protocol**, not the UHPPOTE one — no verified MIT-licensed Node lib exists (mature open
implementations are Python: `zkaccess-c3-py`, `pyzkaccess`), so it needs a **new driver**.
See [[zkteco-controller]].
**Bottom line:** *neither controller currently on hand* delivers ticket-first entry as wired.
UHPPOTE can't at all on that input; ZKTeco might with an aux-input reconfig + a new driver, but
that is unverified.
## Options (unresolved — to settle later)
1. **Rewire the button off the controller's REX/exit input** to a host-readable input (spare
GPIO, a USB/IP digital-input module, or a non-auto-open input) so the host sees the press,
prints, then commands `openDoor`. Keeps UHPPOTE as a commanded relay.
2. **ZKTeco aux-input path** — wire the button to a programmable aux input, host decides via SDK.
Requires building a `[[zkteco-controller|zkteco]]` driver (PULL SDK) and validating the
no-auto-open linkage on real hardware.
3. **Custom [[esp32-custom-controller|ESP32]] controller** for the entry lane — full control of
button logic; the documented (currently deferred) prevention-grade path.
## Status
**Open / paused.** Recorded so the constraint isn't rediscovered. The relay + event-log command
path is otherwise **proven on hardware** (discovery, open, event capture all work) — the gap is
specifically the **button-before-ticket ordering** on the entry lane. Revisit when the entry-lane
hardware decision is taken. Related: [[open-questions]] (lane topology, failure modes).