Kiosk screens on the floor identify people by badge. The card is a contactless smartcard, the reader is a USB device on a Windows desktop, and the thing that needs the number is a page in a browser. Browsers do not get to talk to that hardware. One browser engine has since added a smartcard API, but switching it on needs a flag or an enterprise policy applied per machine — a per-machine dependency in a place where per-machine anything is the thing you are trying to avoid.
Then the reader started failing, and the way it failed was worse than not working at all. It would read badges for days and then quietly stop, while the service's own health check kept answering normally. Separately, after a long idle stretch — a weekend with nobody at the kiosk — it would come back dead and stay dead until somebody physically unplugged it and plugged it back in.
- The browser cannot reach the card stack without a flag or policy set on each machine
- A contactless card needs a beat in the field before it answers; the first read after a tap can land while it is still coupling
- Polling the reader by connecting to it lets the antenna power down between taps, so the next tap is missed entirely
- A fast pull-and-replace produces no card-present event, because the empty state in between is never sampled
- Windows suspends idle USB devices, and the reader frequently fails to re-enumerate on resume
- A user-space process cannot force a USB re-enumeration without administrator rights
A small local service on the desktop. It holds the card stack open, reads card identifiers, and exposes them on loopback with two endpoints: one that reports health, one that hands over the next scan. The kiosk page polls it roughly twice a second. The server also carries proxy routes to the relay behind an authenticated session — documented as a way around browser cross- origin restrictions — with a two-second timeout, so a relay that is not answering returns a 503 rather than hanging the page waiting on it.
The reading loop took the longest to get right, and every decision in it came from a symptom on the real hardware rather than from the documentation. Connect- polling was replaced by a continuous status-change wait after it turned out that only the latter keeps the reader's field powered through a long idle. The direct read was then put back on every pass, because an event- only loop silently drops a card that is pulled and replaced too quickly to register as absent.
An unregistered badge is handled twice over. The kiosk opens a register-it dialog for whoever is standing there, and independently alerts site leadership — because the person who tapped may well walk away from the dialog. That alert is throttled to once per badge per twelve hours so a badge left sitting on a reader cannot flood a mailbox, and it is dropped entirely if the badge turns out to have been registered on another reader in the meantime.
- One scan per presentation, keyed off how long the card was genuinely absent rather than a fixed timer — a fob resting on the reader stays one scan, a deliberate lift-and-replace scans again
- Scans expire after thirty seconds, timed on a monotonic clock so a system clock change cannot make a stale badge look fresh
- Name lookup and desktop notification run on their own bounded queue; when it fills, the notification is dropped rather than stalling card reading
- Magnetic-track wrappers are stripped on every input path, so one card yields one identifier however it was read
- Check-in is guarded by a pre-check and a uniqueness constraint, and the constraint violation is reported as a duplicate rather than an error
- One route is deliberately left unauthenticated — the one the relay itself calls to put a name to a badge — with the reasoning written beside it
When the reader stops answering
The symptom reported from the floor was precise, and damning once you sit with it: badges stop being read, and the health endpoint keeps returning 200. That is exactly what you would expect. The web thread was fine. The reader thread was blocked inside a synchronous card-stack call that cannot be interrupted from Python, and a health check that only proves the process is alive proves nothing about the one function anybody cares about.
The service's own recovery could not catch it either. That check sat after the blocking call, and it keyed off whether the reader was still enumerated — and a suspended reader still enumerates.
So the reader loop stamps a heartbeat at the top of every iteration, and a second thread that never touches card hardware watches that timestamp. If it stops advancing past the timeout, the process relaunches itself detached and exits, which from user space is the only reliable way to get a clean card stack back. The age of that heartbeat is also published on the health endpoint in milliseconds, so the failure is now visible from outside instead of merely inferable.
- Three independent paths bring the relay back: the reader loop's own recovery, the out-of-thread watchdog, and the deployment task's restart-on-failure
- Both in-process paths refuse to fire until a reader has actually been seen this session, so a desktop with nothing plugged in cannot storm restarts
- If the replacement process cannot be spawned, the timer resets rather than hot-looping on the attempt
- The replacement retries its port bind ten times, because the instance it is replacing may still hold the port for a moment
- The service asks Windows to stay out of idle standby while it runs, with a documented switch to give that up and a note on exactly what you lose by setting it
One desktop is a script. A fleet is a packaging problem. The relay builds into a single windowed executable and ships as a managed Win32 application: the installer runs with machine privilege, drops the payload, and registers a logon task that runs the relay as whichever ordinary user is signed in, with least privilege. It has to be the interactive session — desktop notifications cannot be raised from the service session, and the browser reaches the relay on the user's own loopback address.
Version is one constant in the relay source. The build script reads it from there and prints the exact value the deployment detection rule has to be set to, and the health endpoint reports the same constant at runtime.
Idle suspend is a machine setting rather than an application one, so it is a separate elevated script. It pins eight power settings across the USB, PCI Express, sleep and disk groups on every power scheme on the box — every scheme, so switching plans cannot quietly undo it — unticks the Device Manager checkbox that lets Windows power down every USB hub and smart card reader on the machine, with a switch to narrow that to just the readers and the hub chain above them, and sets the smartcard service to start automatically.
- Everything goes through supported interfaces — the power CLI, the management class behind the Device Manager checkbox, the service manager — and never by writing device registry keys directly
- A dry-run switch prints every change and makes none
- The first apply records a baseline file; revert restores only what that file records, and says loudly when it has to fall back to documented defaults instead
- Drivers that do not implement the power checkbox are counted as unsupported and left alone rather than forced
- Uninstall is idempotent, so a re-run against a half- removed machine still succeeds
- The build fails hard if the virtual environment is not the pinned interpreter version — it had silently built against the wrong one once already
- The known limitation is documented rather than hidden: the fixed loopback port means two simultaneous interactive sessions would collide
The kiosk reads real badges with no browser flag, no per-machine setup and no site visit. When the reader wedges, the relay notices and restarts itself. When the machine would have powered the reader down, the settings that allowed it are already off. When a new build ships, the fleet detects it and takes it.
The transferable lesson here is about health checks. The original endpoint returned 200 whenever the process was alive, which is the default thing to build and is worse than useless — it produced confident green status while the only function that mattered had stopped. A health check is only worth having if it can report the specific failure you are actually afraid of.