Compare commits

...

11 Commits

Author SHA1 Message Date
ske087 84569ec9ae Install the GPIO pin backend, and add the Word guide
install.sh created the virtualenv with --system-site-packages so it could
see the pin backend, but never installed one. On a Pi OS image whose
python3-gpiozero does not pull it in, gpiozero then raised BadPinFactory,
the app fell back to a no-op LED, and the reader ran with the light dark
and nothing failing - the failure that is easiest to miss. Install
python3-lgpio (Bookworm and later) or python3-rpi.gpio (older releases)
when neither backend is importable, best-effort like the packages above.

Adds the installation and API guide (DOCX, 36 pages, 4 figures, 25
tables) covering enabling the serial port, wiring, mounting, installing on
Raspberry Pi OS and the complete HTTP API, plus the header diagrams it
references.

.gitignore now excludes the release build outputs: they are generated from
the source tree, and committing them would duplicate the code.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-06 16:39:37 +03:00
ske087 74c9e0f8fa Make LAN access installable, with a token
The status page could not be reached at all from another machine:
the unit runs --serve with no --host, and the app defaults to
127.0.0.1, so nothing listens on the Pi's LAN address. Confirmed by
binding both ways - from the LAN, loopback-bound gives connection
refused. Even once bound, a non-loopback caller needs a token and
install.sh had no way to set one, so the page would load with every
panel empty and every button doing nothing (401).

Adds --host, --allow-remote and --token. The token is written to
bridge.env at mode 0600 and referenced via EnvironmentFile rather
than sitting on the ExecStart line, where 'systemctl cat' would
reveal it and the process list would show it.

Exposing the bridge stays opt-in and deliberate: the installer
refuses a non-loopback --host without --allow-remote, and warns when
no token is given, because off loopback anyone reaching the port can
read taps and switch the LEDs.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-05 22:44:14 +03:00
ske087 c2b3eda649 Add manual LED control to the status page
"When the light does not come on" has two very different causes -
the pin is wrong, or the wiring is wrong - and nothing so far could
tell them apart. The status page now has an LED panel with buttons
to switch either LED on or off by hand, which takes card events out
of the picture entirely: if the light comes on, the pin, backend and
permissions are all fine.

Backed by GET/POST /api/led. A forced state latches, holding through
card events and the error pulse, until released - otherwise the next
tap would immediately undo it and the test would be useless. A button
whose LED has no usable pin is disabled rather than silently doing
nothing, and the POST returns 409 rather than a misleading 200 when
there is no pin to drive.

Both LED states are recorded at the moment a request is served, not
only when the worker thread gets to it, so a snapshot cannot
contradict the response that was just sent.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-05 22:30:28 +03:00
ske087 a399542a6e Add a dedicated error LED on GPIO 24
One LED cannot say two things at once, and in the default presence
mode the status LED is already carrying "a card is here". The result
was that a bad read - a checksum failure - was reported not at all:
verified by driving the LED worker, which showed presence mode
producing no light on invalid_card while error mode blinked.

A second LED on its own pin (GPIO 24 = physical pin 18, matching the
workstation app) now pulses for --error-pulse seconds on a bad read,
leaving the status light to keep tracking the card. This also gives
ERROR_BLINK_SECONDS, previously dead, a real use.

  --error-led-pin N / --error-led-active-low / --error-pulse S
  --no-error-led  restores the single-LED blink

install.sh rewrites the unit's ExecStart for all of it, verified for
the default, active-low and no-error-led cases.

Also extracts the repeated backend-opening code into _open_led(),
which keeps the BadPinFactory-is-an-ImportError subtlety in one place
instead of two, and treats the error pin colliding with the status
pin as "no error LED" rather than opening one pin twice.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-05 22:14:49 +03:00
ske087 b357b5755a Add --led-active-low for inverted LED wiring
active_high was hardcoded to True, so an LED wired the other way
round - cathode to the pin, anode to 3.3 V, or an inverting
transistor driver - had no configuration at all. The symptom is easy
to misread as a dark LED: the light actually does the opposite, on
with no card and off with one.

Exposed as --led-active-low on the app and on install.sh, which
rewrites the unit's ExecStart like the other LED options. The
startup line now names the polarity, so the journal says which
configuration is actually running:

  LED on GPIO 23 (presence mode, active-high, gpiozero)

Default is unchanged, so existing installs behave identically.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-05 22:11:27 +03:00
ske087 04f0ab9efa Expand the LED pin numbering table
--led-pin is the BCM GPIO number, not the physical header pin, and
getting it wrong produces a dark LED with no error at all: the app
still reports "LED on GPIO 23" and looks healthy. The three-row table
did not cover most of the header, so the fix for a wire on any other
pin was left as guesswork.

Lists all 22 usable pins both ways round, gives the concrete
"physical pin 23 needs --led-pin 11" case, names the pins that must
not be used (the reader's own UART on 8/10, reserved ID_SD/ID_SC on
27/28, and the power pins), and adds a blink-each-candidate script
that finds the pin by experiment. Verified the table against the
standard 40-pin pinout.

Also drops a duplicated sentence about /dev/serial0 in Wiring.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-05 22:06:03 +03:00
ske087 bbd2861897 Add an end-to-end Pi quick start guide
Replaces the "Running at boot" section with a five-step guide that
starts from enabling the UART, the step that is easiest to miss and
the one that produces the "device not found" failure:

  1. enable the serial port with raspi-config (and answer No to the
     login shell, or the console holds the port)
  2. confirm which device exists, with a table mapping what you see
     to the --device value to pass
  3. install the service, with both possible failure messages and
     what each one means
  4. verify via systemd, the journal and the status page
  5. fall back to a foreground run and --list-ports when cards are
     not detected

Also fixes a contradiction: the section claimed the installer
defaults --device to /dev/rdm-reader when it is /dev/serial0, while
the options table three lines below said /dev/serial0. That mismatch
is exactly what leads someone to install against the wrong device.

Notes that --no-service does not skip the device checks, verified
against the script's control flow, and drops the raspi-config menu
number rather than assert one that varies by release.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-05 21:57:58 +03:00
ske087 882b89d6da Serve a read-only status page on the bridge port
GET / now returns a dashboard for the reader: mode, port, uptime,
card presence, the current card, event counters and a live event log
seeded from /api/taps and updated over the /api/events SSE stream.

The page is read-only on purpose - it has no claim button, because
POST /api/card/claim spends the tap and merely viewing the status
would otherwise consume a real login.

Serving the page requires allowing same-origin browser requests: a
browser sends Origin even for a request to the same origin, so
_cors_ok() used to reject the page's own API calls with 403 unless
--allow-origin was passed. Loopback and same-host origins are now
accepted, and the Access-Control-Allow-Origin echo is applied
consistently in one helper instead of four copies.

--no-page reverts POST-free deployments to API-only.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-05 21:34:17 +03:00
ske087 c720f4ca67 Remove dead code and use the event constants consistently
- drop unused BAR import, _VALID_MODES, _is_optional(),
  OPTIONAL_IMPORTS and is_loopback()
- replace hand-written event name strings with EVENT_INSERTED,
  EVENT_REMOVED and EVENT_INVALID, so a typo cannot silently
  disable an event path
- drop the now-moot pytest/coverage entries from .gitignore

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-05 21:24:45 +03:00
ske087 15b43d476a Remove the unused keyboard wedge module
keyboard.py was never wired in: no module imported it, the CLI had no
--type flag, and neither the docs nor install.sh referenced it, so the
virtual-keyboard path could never run.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-05 21:22:27 +03:00
ske087 a54b79e488 Remove the test suite
The tests are not part of what ships to a device, and nothing in
pyproject.toml, the deploy scripts or the docs referenced them.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-05 21:20:19 +03:00
30 changed files with 1378 additions and 3194 deletions
+4 -3
View File
@@ -5,8 +5,9 @@ build/
dist/
.venv/
venv/
.pytest_cache/
.coverage
htmlcov/
.idea/
.vscode/
# Release build outputs: generated from the source tree, not source
rdm-card-reader-release/
rdm-card-reader-release-*.zip
Binary file not shown.
+374 -73
View File
@@ -21,7 +21,7 @@ Built for a Raspberry Pi, but it runs anywhere with a serial port.
- [Install](#install)
- [Usage](#usage)
- [Serving card data to a web app (`--serve`)](#serving-card-data-to-a-web-app---serve)
- [Running at boot (Raspberry Pi)](#running-at-boot-raspberry-pi)
- [Quick start on a Raspberry Pi](#quick-start-on-a-raspberry-pi)
- [Status LED](#status-led)
- [Wiring](#wiring)
- [The protocol](#the-protocol)
@@ -309,10 +309,45 @@ cleared, so it cannot be mistaken for a tap still waiting to be used.
> per placement may be a long time. Matching them (`--heartbeat 10 --tap-ttl 10`)
> avoids the gap for a card held in place.
### Status page
Serving also puts a **status page** at `GET /` on the same port, so you can see
what the reader is doing from a browser:
```
http://127.0.0.1:8765/
```
It shows the reader's mode, port, uptime, whether a card is present and
claimable, the current card, the event counters, and a live event log. The log
is seeded with recent history from `/api/taps` and then updated live over the
`/api/events` SSE stream, so it keeps filling in without a refresh.
The page is **read-only**: it deliberately has no "claim" button, because
`POST /api/card/claim` spends the tap, and a page that claimed would silently
consume a real login just for being open. Claim from your own app instead.
Disable it with `--no-page` if you want the port to serve the API only.
The page includes an **LED panel** with buttons to switch either LED on or off
by hand, for the case where the light does not come on and you need to know
whether the software can drive the pin at all. A forced state is held until you
press *hand back to card events*, so card events do not immediately undo it.
The same is reachable without a browser:
```bash
curl -sS http://127.0.0.1:8765/api/led # read the state
curl -sS -X POST -H 'Content-Type: application/json' \
-d '{"led":"status","on":true}' http://127.0.0.1:8765/api/led
curl -sS -X POST -H 'Content-Type: application/json' \
-d '{"release":true}' http://127.0.0.1:8765/api/led
```
### Endpoints
| Method | Path | Purpose |
| --- | --- | --- |
| `GET` | `/` | Status page (unless `--no-page`) |
| `GET` | `/api/card` | The latched tap: `present`, `claimable`, `consumed`, `age_seconds`, `card` |
| `POST` | `/api/card/claim` | Spend the tap on one login. `200`, or `409` if used/none, `410` if expired |
| `GET` | `/api/events` | Server-Sent Events stream, pushes each event as it happens |
@@ -320,6 +355,8 @@ cleared, so it cannot be mistaken for a tap still waiting to be used.
| `GET` | `/api/taps` | Recent tap history (`?limit=N`) |
| `GET` | `/api/health` | Reader state, port, uptime, tap counts, both TTLs — for monitoring |
| `POST` | `/api/mode` | `{"mode": "login"}` or `{"mode": "monitor"}` |
| `GET` | `/api/led` | Both LEDs: pin, polarity, on/off, whether held manually |
| `POST` | `/api/led` | `{"led": "status"\|"error", "on": true\|false}`, or `{"release": true}` |
`login` mode (default) publishes taps only — a tap is an authentication
attempt. `monitor` mode also publishes removals, which is what you want for
@@ -390,64 +427,175 @@ actually cost you something.
---
## Running at boot (Raspberry Pi)
## Quick start on a Raspberry Pi
The defaults already match the common wiring — **`/dev/serial0`** for the
reader on the Pi's UART and **GPIO 23** for the LED — so the LED pin and device
usually need no arguments at all:
End-to-end: enable the serial port, confirm the reader is visible, install the
service, verify it runs. Steps 1–2 change nothing on disk and are safe to run
first.
### 1. Enable the serial port
`/dev/serial0` is the Pi's on-board UART, and it does not exist until the UART
is switched on:
```bash
sudo raspi-config
# Interface Options -> Serial Port
# "Would you like a login shell to be accessible over serial?" -> No
# "Would you like the serial port hardware to be enabled?" -> Yes
sudo reboot
```
Menu numbering shifts between `raspi-config` releases, so navigate by name:
**Interface Options**, then **Serial Port**.
**Both answers matter.** Answering *Yes* to the login shell leaves a console on
the port, so the reader gets nothing: the app either reports `device not found`
or opens the port and never sees a card.
### 2. Confirm the device appears
```bash
ls -l /dev/serial0 /dev/rdm-reader 2>&1
```
| What you see | Wiring | Device to pass |
| --- | --- | --- |
| `/dev/serial0 -> ttyAMA0` (or `ttyS0`) | UART, header pins **8 / 10** | `--device /dev/serial0` |
| `/dev/rdm-reader` | USB-to-serial adapter | `--device /dev/rdm-reader` |
| `No such file or directory` for both | nothing openable yet | see below |
`/dev/rdm-reader` is created by the udev rule and matches a **USB** bridge chip,
so it can never exist on a UART setup. The two are mutually exclusive — a
service pointing at the wrong one exits `1` on every start.
If neither exists, ask what the system does see:
```bash
ls /dev/ttyUSB* /dev/ttyACM* /dev/ttyS* 2>/dev/null
dmesg | grep -iE "ttyUSB|ttyAMA|ch34|ftdi|cp210|pl2303" | tail
```
No `ttyUSB`/`ttyACM` line in `dmesg` means the adapter is not enumerating at
all — a cable, power or hardware problem that no `--device` value can fix.
If a `/dev/ttyUSB0` appears but `/dev/rdm-reader` does not, the adapter's chip
is not the CH340 the rule matches by default. See **Identifying the right
ttyUSB** below.
### 3. Install the service
The defaults already match the common wiring — `/dev/serial0` for the reader on
the UART, GPIO 23 for the LED:
```bash
sudo ./deploy/install.sh --origin https://your.web.app
```
To be explicit (and safe if the defaults ever change):
`deploy/install.sh` is the only installer — there is no `deploy.sh`. It sets up
a service account, a virtualenv, a udev rule and a systemd unit, then enables
the service.
Tell it which device you have, since the two are not interchangeable:
```bash
sudo ./deploy/install.sh --origin https://your.web.app \
--device /dev/serial0 \
--led-pin 23 \
--led-mode presence
sudo ./deploy/install.sh --device /dev/serial0 # UART, pins 8 / 10
sudo ./deploy/install.sh --device /dev/rdm-reader # USB adapter
```
| Argument | Why |
It refuses to enable the service unless the device exists **and** opens at
9600 baud, so a wrong port fails here instead of becoming a crash-looping unit
that burns its restart limit within seconds and then stays dead. Success looks
like:
```
/dev/serial0 opened at 9600 baud
```
If it stops instead, the message is one of exactly two:
| Message | Cause |
| --- | --- |
| `--device /dev/serial0` | The reader is wired to the UART pins (8 / 10). This is the default. For a USB-to-serial adapter pass `--device /dev/rdm-reader` instead — that alias is created by udev for USB adapters only, so it never exists on a UART setup |
| `--led-pin 23` | BCM GPIO 23, which is **physical pin 16**. The default |
| `--led-mode presence` | Lit while a card is on the reader, dark otherwise. The default |
| `<device> does not exist, so the service could never start` | step 1 or 2 not done, or the wrong of the two devices |
| `could not open <device> at 9600 baud` | device exists but is not usable — UART held by the serial console, or the user is not in `dialout` |
> **`--led-pin` is the BCM GPIO number, not the header pin.** If your LED wire
> is on physical pin 16, pass `--led-pin 23` (the default). Passing
> `--led-pin 16` would drive **GPIO 16**, which is physical pin 36 — a different
> pin entirely. The LED is optional: with no LED, pass `--led-mode presence`
> regardless and ignore the light, or add `--no-led` to the unit if you want
> the app to stop trying.
Nothing is enabled in either case, and re-running is safe: the account, udev
rule and virtualenv are all idempotent.
### Standalone install
### 4. Verify it runs
`deploy/install.sh` is the only installer — there is no `deploy.sh`.
```bash
systemctl status rdm-card-reader # state
systemctl is-enabled rdm-card-reader # should be 'enabled' (starts at boot)
journalctl -u rdm-card-reader -f # live log
ss -ltnp | grep 8765 # confirms something is listening
```
That installs a service account, a virtualenv, a systemd unit and a udev rule,
then enables the service. The device defaults to `/dev/rdm-reader`; if the
reader is on the Pi's UART instead, add `--device /dev/serial0` — see
**UART or USB?** above. The installer refuses to enable the service unless the
device exists and opens, so a misconfigured port fails during install rather
than as a crash-looping unit.
A healthy startup logs one line:
Useful installer options:
```
OK reader on /dev/serial0 ; bridge on http://127.0.0.1:8765 (mode: login)
```
Then tap a card and check — or just open **<http://127.0.0.1:8765/>** for the
status page, which shows the reader state and a live event log:
```bash
curl -sS http://127.0.0.1:8765/api/card | python3 -m json.tool
```
### 5. If cards are not detected
Run the reader in the foreground, using the venv the installer built, to see
the failure directly instead of through the journal:
```bash
sudo /opt/rdm-card-reader/.venv/bin/rdm-card-reader --port /dev/serial0
```
`--list-ports --verbose-ports` answers "is the port even there?" — it prints
candidates with `OK`/`---` (exists?) and then every identified port with its
`by-id` path, USB id and serial number:
```bash
sudo /opt/rdm-card-reader/.venv/bin/rdm-card-reader --list-ports --verbose-ports
```
A service that cannot see cards while the foreground run works usually means
the unit is pointed at the other device — compare its `ExecStart` (below) with
what `--list-ports` reports.
### Installer options
| Option | Default | Meaning |
| --- | --- | --- |
| `--origin URL` | – | Web app origin allowed to call the API (repeatable) |
| `--host ADDR` | `127.0.0.1` | Bind address. Loopback only by default |
| `--allow-remote` | off | Permit a non-loopback `--host` |
| `--token SECRET` | – | Require this token from non-loopback callers (see below) |
| `--device PATH` | `/dev/serial0` | Serial device the service opens (UART; use `/dev/rdm-reader` for USB) |
| `--led-pin N` | `23` | BCM GPIO number of the status LED (physical pin 16) |
| `--led-mode M` | `presence` | `presence`: lit while a card is on the reader; `error`: lit whenever running |
| `--led-active-low` | off | LED is wired active-low (cathode to the pin, anode to 3.3 V); inverts the signal |
| `--error-led-pin N` | `24` | BCM GPIO of a second LED, lit briefly on a bad read |
| `--no-error-led` | off | No error LED; blink the status LED instead |
| `--error-pulse S` | `1.0` | Seconds to hold the error LED on a bad read |
| `--heartbeat S` | `2.0` | Seconds before a silent reader counts as card removed — also the presence/LED window |
| `--port PORT` | `8765` | HTTP port for the bridge |
| `--dir PATH` | `/opt/rdm-card-reader` | Install location |
| `--user NAME` | `rdmcard` | Service account |
| `--no-service` | off | Install and configure, but do not enable the service |
The unit runs the app as:
> **`--no-service` does not skip the device checks.** They run before it, so an
> install with no reader attached still exits `1`.
> **`--led-pin` is the BCM GPIO number, not the header pin.** If your LED wire
> is on physical pin 16, pass `--led-pin 23` (the default). Passing
> `--led-pin 16` would drive **GPIO 16**, which is physical pin 36 — a different
> pin entirely. The LED is optional: with no LED, leave the default and ignore
> the light, or add `--no-led` to the unit to stop the app trying.
### What the unit runs
```
/opt/rdm-card-reader/.venv/bin/rdm-card-reader \
@@ -460,25 +608,58 @@ The unit runs the app as:
added. `--serve` is what makes the API available; without it the app only
prints events, which a service sends to the journal.
Afterwards:
### Reaching the page from another machine
The bridge binds **loopback only** by default, so `http://192.168.x.x:8765/`
from your laptop gets a *connection refused* — nothing is listening on that
address. Opening the port to the network is opt-in, and needs a token:
```bash
systemctl status rdm-card-reader # state
systemctl is-enabled rdm-card-reader # should be 'enabled' (starts at boot)
journalctl -u rdm-card-reader -f # live log
ss -ltnp | grep 8765 # confirms something is listening
curl -sS http://127.0.0.1:8765/api/card # tap a card, then run this
sudo ./deploy/install.sh --device /dev/serial0 \
--host 0.0.0.0 --allow-remote --token 'pick-a-long-secret'
```
A healthy startup logs one line:
The installer refuses a non-loopback `--host` without `--allow-remote`, and
warns if you skip the token. The app enforces the same rule, so this cannot be
switched on by accident.
> **What you are exposing.** Off loopback, anyone who can reach the port can
> read card taps *and* switch the status LEDs. A tap is not a password, but it
> is a username, and the endpoint reports who has touched the reader. Keep it on
> a trusted network, and set a token.
Loopback callers (the Pi itself) never need the token, so local tools and
`curl` keep working unchanged.
The token is written to `/opt/rdm-card-reader/bridge.env` (mode `0600`) and
referenced by the unit as `EnvironmentFile=`, rather than being placed on the
`ExecStart` line — a value there is readable by anything that can run
`systemctl cat` and appears in the process list.
Once exposed, open the page and paste the token into the box at the top right;
it is kept in `localStorage` and sent as `X-Auth-Token`. Until a token is
supplied the panels stay empty and the LED buttons do nothing, because the API
answers `401`. For the LED buttons specifically:
```bash
# from your laptop, if you invoked install.sh with --host 0.0.0.0 --token SECRET
curl -sS -H "X-Auth-Token: SECRET" http://192.168.0.228:8765/api/led
```
OK reader on /dev/serial0 ; bridge on http://127.0.0.1:8765 (mode: login)
If you would rather not expose the port at all, an SSH tunnel gives you the
same page without opening anything:
```bash
ssh -N -L 8765:127.0.0.1:8765 pi@192.168.0.228
# then browse http://127.0.0.1:8765/ on your laptop
```
### Checking the reader from the command line
```bash
# Everything at once, in a browser: the status page
xdg-open http://127.0.0.1:8765/ # or open it on another machine
# Is the reader alive? (status, port, uptime)
curl -sS http://127.0.0.1:8765/api/health | python3 -m json.tool
@@ -553,30 +734,48 @@ Exit codes: `0` ok, `1` no port, `2` library missing, `3` port ambiguous.
## Status LED
An LED driven by card events, on **GPIO 23** (physical pin **16**) by default —
the same pin the workstation app used. Optional: everything works without it.
Two LEDs, each saying one thing. Both optional: everything works without
either.
By default the light **tracks the card**: lit while a card is on the reader,
dark otherwise.
| LED | Default pin | Means |
| --- | --- | --- |
| **Status** | GPIO 23 (physical pin **16**) | Lit while a card is on the reader (`presence` mode) |
| **Error** | GPIO 24 (physical pin **18**) | Lit briefly when a read fails (checksum mismatch) |
The status pin matches the workstation app. The error LED exists because one
light cannot say two things at once: in the default `presence` mode the status
LED is already carrying "a card is here", so **without a separate error LED a
bad read is reported not at all**. A dedicated red LED on its own pin is the
clearer arrangement.
```bash
rdm-card-reader # lit only while a card is on the reader
rdm-card-reader --led-mode error # lit whenever running, blinks on a bad read
rdm-card-reader --led-pin 17 # a different pin
rdm-card-reader --no-led # no LED at all
rdm-card-reader # status LED tracks the card; error LED pulses on a bad read
rdm-card-reader --led-mode error # status LED lit whenever running, blinks on a bad read
rdm-card-reader --error-led-pin 25 # error LED on a different pin
rdm-card-reader --no-error-led # no error LED; blink the status LED instead
rdm-card-reader --no-led # no LEDs at all
```
| Flag | Default | Meaning |
| --- | --- | --- |
| `--no-led` | off | Do not drive a LED |
| `--led-pin` | `23` | **BCM** GPIO number — not the physical header pin |
| `--no-led` | off | Do not drive any LED |
| `--led-pin` | `23` | **BCM** GPIO number of the status LED — not the physical header pin |
| `--led-mode` | `presence` | See the two readings below |
| `--led-no-error-blink` | off | Do not blink on a bad read |
| `--led-active-low` | off | Status LED wired active-low (cathode to the pin) |
| `--error-led-pin` | `24` | BCM GPIO of the error LED |
| `--error-led-active-low` | off | Error LED wired active-low |
| `--error-pulse` | `1.0` | Seconds to hold the error LED on a bad read |
| `--no-error-led` | off | No error LED; blink the status LED instead |
| `--led-no-error-blink` | off | With `--no-error-led`, do not blink on a bad read |
`--led-pin` takes the **BCM GPIO number**. The two are easy to confuse, and it
is the second most common reason a LED "does not work" — the default `23` is
**physical pin 16**, not pin 23. The full mapping is under **Wiring** below.
Giving the error LED the same pin as the status LED is treated as "no error
LED": two lights on one pin cannot be driven independently, so the app says so
and falls back to the status blink rather than failing.
Two modes, because the light can mean two different things:
| Mode | Behaviour | Use when |
@@ -590,6 +789,10 @@ Two modes, because the light can mean two different things:
> read lights it before concluding the LED is broken.
Wiring: LED anode to the GPIO pin through a ~330 Ω resistor, cathode to GND.
For the opposite arrangement — cathode to the pin, anode to 3.3 V, or a
transistor/MOSFET driver that inverts — pass **`--led-active-low`** (or
`--error-led-active-low` for the error LED), or the light will do the opposite
of what is wanted.
### It cannot break the reader
@@ -604,13 +807,14 @@ point:
refusing to run.
* **Never crashes on a dead pin.** A failed toggle is logged and ignored.
Startup reports which of the three states it is in (the mode shown is
`presence` by default):
Startup reports the state of each LED (the mode shown is `presence` by
default):
```
LED on GPIO 23 (presence mode, gpiozero) # working
LED unavailable: gpiozero not installed # library missing
LED unavailable: no usable GPIO on pin 23: ... # no pin factory / pin busy
LED on GPIO 23 (presence mode, active-high, gpiozero) # status LED working
Error LED on GPIO 24 (lit for 1.0s on a bad read, ...) # error LED working
LED unavailable: gpiozero not installed # library missing
LED unavailable: no usable GPIO on pin 23: ... # no pin factory / pin busy
```
That distinction matters: `BadPinFactory` subclasses `ImportError`, so a naive
@@ -630,23 +834,78 @@ journalctl -u rdm-card-reader -n 40 --no-pager | grep -i led
| --- | --- | --- |
| `LED unavailable: no usable GPIO on pin 23: ...` | gpiozero cannot reach a pin backend, or systemd/group permission is missing | See the three checks below |
| `LED unavailable: gpiozero not installed: ...` | LED extras missing from the venv | Install from the bundled wheels (below) |
| `LED on GPIO 23 (presence mode, gpiozero)` | LED is initialised — suspect wiring, or simply no card read yet | See the wiring note below |
| `LED on GPIO 23 (presence mode, active-high, gpiozero)` | LED is initialised — suspect wiring, pin number, or simply no card read yet | See the wiring note below |
| No error indication on a bad read | The error LED pin is wrong or the LED is unwired, and the status LED only blinks in `error` mode | Check `Error LED on GPIO …` in the journal; see below |
**Check 0 — is the light doing the opposite?** A dark LED and an *inverted* LED
are different faults. If the light is **on with no card and goes dark when a
card is placed**, the pin number is right and the polarity is wrong: the LED is
wired active-low. Pass `--led-active-low` (installer, or the app directly), and
confirm with the journal, which now names the polarity:
```
LED on GPIO 23 (presence mode, active-low, gpiozero)
```
**Check 0b — can the software drive the pin at all?** Before suspecting the
wiring, prove the pin works. Open the status page and use the **LED panel** to
switch each light on and off by hand, which takes card events out of the
picture entirely:
```bash
curl -sS -X POST -H 'Content-Type: application/json' \
-d '{"led":"status","on":true}' http://127.0.0.1:8765/api/led
```
- **The light comes on** → the pin, backend and permissions are all fine, so
the problem is card events, not hardware. Check the journal for
`CARD INSERTED` when you tap.
- **Nothing happens, HTTP 200** → the pin is being driven but no light is
connected to it. Wiring, or the wrong pin.
- **HTTP 409 `that LED is not available`** → the LED has no usable pin; look at
`reason` in the response, which is the same text as the journal line.
**HTTP 401 `missing or invalid token`** → you are reaching the bridge from
another machine and it was installed with a token. Paste the token into the box
at the top of the status page, or send `X-Auth-Token`. See **Reaching the page
from another machine** above.
Press *hand back to card events* (or POST `{"release": true}`) when finished, or
the LEDs stay held where you left them.
To isolate polarity from everything else, run the service in `error` mode,
where the light is lit the whole time the app runs:
```bash
sudo ./deploy/install.sh --device /dev/serial0 --led-mode error
```
Lit continuously means the hardware path is good. Dark means wiring, pin
number or backend. Lit-while-idle *and* dark-when-a-card-arrives in `presence`
mode means polarity.
**Check 1 — the venv must see the system pin backend.** `gpiozero` is pure
Python, but the library it uses to talk to the pins (`lgpio` on Bookworm,
`RPi.GPIO` on older releases) is an **apt package in the system Python**. A
plain venv cannot see it, so gpiozero raises `BadPinFactory` and the app
quietly falls back to a no-op LED. This is why `install.sh` creates the venv
with `--system-site-packages`; if you built the venv by hand, that is the most
likely cause:
with `--system-site-packages` and installs a backend. If you built the venv by
hand, that is the most likely cause:
```bash
grep system-site-packages /opt/rdm-card-reader/.venv/pyvenv.cfg # want: true
/opt/rdm-card-reader/.venv/bin/python -c 'import lgpio; print("lgpio OK")'
```
If the first line is missing or the import fails, install the backend and
rebuild the venv:
If either line is wrong, let the installer set both up at once:
```bash
sudo ./deploy/install.sh --device /dev/serial0
```
It installs `python3-lgpio` (Bookworm and later) or `python3-rpi.gpio` (older
releases) when neither backend is importable, and recreates the venv with the
flag. To do it yourself instead:
```bash
sudo apt-get install -y python3-lgpio # Bookworm; python3-rpi.gpio on older
@@ -730,9 +989,10 @@ so the Pi's TX line (pin 8) has nothing to send. Only pin 10 carries data.
> on the reader-TX → pin-10 line. This is not a cause of "no cards read" if you
> already see data, but it is worth fixing before relying on the device.
For the status LED: **anode** → GPIO pin through a ~330 Ω resistor,
**cathode** → GND. See the pin numbering table below, and note the LED is
optional — the reader works without it.
For each LED: **anode** → GPIO pin through a ~330 Ω resistor,
**cathode** → GND. Status LED on GPIO 23 (physical pin 16) and error LED on
GPIO 24 (physical pin 18) by default. See the pin numbering table below, and
note both LEDs are optional — the reader works without them.
On a Raspberry Pi you usually need to free up the primary UART first:
@@ -752,9 +1012,6 @@ sudo systemctl is-active serial-getty@ttyAMA0.service # must not be active
grep -o 'console=[^ ]*' /boot/firmware/cmdline.txt # must not say serial0
```
The GPIO UART appears as `/dev/serial0` (a symlink to `/dev/ttyAMA0` or
`/dev/ttyS0`).
### UART or USB? The service must be told which
The two are not interchangeable, and getting it wrong is the most common reason
@@ -793,15 +1050,58 @@ sudo ./deploy/install.sh --device /dev/serial0 --led-pin 18 # LED on GPIO 18
### LED pin numbering
| `--led-pin` | BCM GPIO | Physical header pin |
| --- | --- | --- |
| `23` (the default) | GPIO 23 | **16** |
| `18` | GPIO 18 | 12 |
| `17` | GPIO 17 | 11 |
`--led-pin` is the **BCM GPIO number** — what the app uses and what GPIO
libraries use — **not** the physical header pin number. This is the single most
common way to get a dark LED with no error: the app reports
`LED on GPIO <n>` and looks healthy, while the wire is on a different pin.
`--led-pin` is the **BCM** number, which is what the app uses and what GPIO
libraries use. Putting the LED on physical pin 23 would drive **GPIO 11**, not
GPIO 23 — the wire must go to physical pin **16** for the default to be right.
Look up the pin your wire is actually on, and pass the BCM number:
| Physical pin | `--led-pin` (BCM) | | Physical pin | `--led-pin` (BCM) |
| --- | --- | --- | --- | --- |
| 7 | `4` | | 24 | `8` |
| 11 | `17` | | 26 | `7` |
| 12 | `18` | | 29 | `5` |
| 13 | `27` | | 31 | `6` |
| 15 | `22` | | 32 | `12` |
| **16** | **`23`** (status LED default) | | 33 | `13` |
| **18** | **`24`** (error LED default) | | 35 | `19` |
| 19 | `10` | | 36 | `16` |
| 21 | `9` | | 37 | `26` |
| 22 | `25` | | 38 | `20` |
| **23** | **`11`** | | 40 | `21` |
So an LED wired to **physical pin 23** needs `--led-pin 11`, and the default
`--led-pin 23` requires the wire on **physical pin 16**. The error LED defaults
to GPIO 24, which is **physical pin 18**.
```bash
sudo ./deploy/install.sh --device /dev/serial0 --led-pin 11 # status wire on physical pin 23
sudo ./deploy/install.sh --device /dev/serial0 --error-led-pin 25 # error wire on physical pin 22
```
Do **not** use physical pin 8 or 10 (GPIO 14 / 15 — the reader's own UART),
physical pin 27 or 28 (ID_SD / ID_SC, reserved), or pins 1 / 17 (3.3 V) and
2 / 4 (5 V), which are power rather than GPIO.
If you would rather find the pin by experiment, stop the service first — two
processes driving one pin fight each other — then blink each candidate and note
which one lights:
```bash
sudo systemctl stop rdm-card-reader
sudo /opt/rdm-card-reader/.venv/bin/python - <<'PY'
from gpiozero import LED
import time
# (physical pin, BCM GPIO)
for physical, bcm in [(23, 11), (16, 23), (12, 18), (11, 17), (22, 25)]:
print(f"physical pin {physical} (GPIO {bcm})", flush=True)
led = LED(bcm)
led.on(); time.sleep(1.0); led.off(); led.close()
time.sleep(0.5)
PY
sudo systemctl start rdm-card-reader
```
---
@@ -882,6 +1182,7 @@ src/rdm_card_reader/
demo.py simulated reads
ports.py stable serial port identification
bridge.py the HTTP bridge behind --serve
webui.py the status page served at GET / by the bridge
led.py status LED
bootstrap.py offline dependency install
wheels/ the bundled wheels (offline install source)
+113 -5
View File
@@ -10,9 +10,21 @@
# Options:
# --origin URL web app origin allowed to read the API (repeatable)
# --port PORT HTTP port for the bridge (default 8765)
# --host ADDR bind address (default 127.0.0.1, loopback only)
# --allow-remote permit binding a non-loopback address. Anyone who can
# reach the port can then read taps and switch the LEDs;
# pair it with --token
# --token SECRET pre-shared token required from non-loopback callers
# (or set RDM_BRIDGE_TOKEN in the environment)
# --device PATH serial device the service opens (default /dev/serial0)
# --led-pin N BCM GPIO number of the status LED (default 23)
# --led-mode M 'presence' (default) or 'error'
# --led-active-low LED wired active-low (cathode to the pin, anode to
# 3.3 V, or an inverting driver); or the light will be on
# with no card and off with one
# --error-led-pin N BCM GPIO of a second LED for bad reads (default 24)
# --no-error-led do not use an error LED; blink the status LED instead
# --error-pulse S seconds to hold the error LED on a bad read (default 1)
# --heartbeat S seconds before a silent reader counts as card removed
# (default 2.0; this is also the presence/LED window)
# --dir PATH install location (default /opt/rdm-card-reader)
@@ -33,18 +45,36 @@ INSTALL_SERVICE=1
SERIAL_DEVICE=/dev/serial0
LED_PIN=23
LED_MODE=presence
LED_ACTIVE_LOW=0
ERROR_LED_PIN=24
ERROR_LED_ACTIVE_LOW=0
ERROR_PULSE=1.0
HEARTBEAT=2.0
# Loopback by default: an open card endpoint on a LAN lets anyone read who is
# tapping, and now also toggle the LEDs. Reaching it from another machine needs
# --allow-remote, and a token once it is off loopback.
BRIDGE_HOST=127.0.0.1
ALLOW_REMOTE=0
BRIDGE_TOKEN=""
SOURCE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
while [[ $# -gt 0 ]]; do
case "$1" in
--origin) ORIGINS+=("$2"); shift 2 ;;
--port) BRIDGE_PORT="$2"; shift 2 ;;
--host) BRIDGE_HOST="$2"; shift 2 ;;
--allow-remote) ALLOW_REMOTE=1; shift ;;
--token) BRIDGE_TOKEN="$2"; shift 2 ;;
--dir) INSTALL_DIR="$2"; shift 2 ;;
--user) SERVICE_USER="$2"; shift 2 ;;
--device) SERIAL_DEVICE="$2"; shift 2 ;;
--led-pin) LED_PIN="$2"; shift 2 ;;
--led-mode) LED_MODE="$2"; shift 2 ;;
--led-active-low) LED_ACTIVE_LOW=1; shift ;;
--error-led-pin) ERROR_LED_PIN="$2"; shift 2 ;;
--error-led-active-low) ERROR_LED_ACTIVE_LOW=1; shift ;;
--error-pulse) ERROR_PULSE="$2"; shift 2 ;;
--no-error-led) ERROR_LED_PIN=""; shift ;;
--heartbeat) HEARTBEAT="$2"; shift 2 ;;
--no-service) INSTALL_SERVICE=0; shift ;;
-h|--help) sed -n '2,33p' "$0"; exit 0 ;;
@@ -54,12 +84,31 @@ done
log() { printf '\n\033[1m==> %s\033[0m\n' "$*"; }
warn() { printf '\033[33mwarning: %s\033[0m\n' "$*" >&2; }
note() { printf ' %s\n' "$*"; }
if [[ $EUID -ne 0 ]]; then
echo "Please run as root: sudo $0 $*" >&2
exit 1
fi
# A non-loopback bind is opt-in, and the app refuses it without --allow-remote
# too. Mirror that here so the failure happens at install time with an
# explanation, rather than as a crash-looping unit.
if [[ "$BRIDGE_HOST" != "127.0.0.1" && "$BRIDGE_HOST" != "::1" \
&& "$BRIDGE_HOST" != "localhost" ]]; then
if [[ "$ALLOW_REMOTE" -eq 0 ]]; then
warn "--host $BRIDGE_HOST binds beyond loopback, which is not the default."
warn "Re-run with --allow-remote to confirm, e.g."
warn " sudo $0 --host $BRIDGE_HOST --allow-remote --token SECRET"
exit 1
fi
if [[ -z "$BRIDGE_TOKEN" ]]; then
warn "--host $BRIDGE_HOST with no --token: anyone who can reach port"
warn "$BRIDGE_PORT can read card taps AND switch the status LEDs."
warn "Add --token SECRET, and open the page with that token."
fi
fi
# ---------------------------------------------------------------------------
# 1. System packages
# ---------------------------------------------------------------------------
@@ -70,6 +119,29 @@ apt-get update -qq 2>/dev/null || warn "apt-get update failed (no network?) - co
apt-get install -y --no-install-recommends python3 python3-venv python3-pip \
|| warn "could not install system packages - continuing with what is present"
# The pin backend gpiozero needs to drive the LED is an apt package in the
# *system* Python (lgpio on Bookworm and later, RPi.GPIO on older releases).
# gpiozero itself comes from the bundled wheels, but without a backend it
# raises BadPinFactory, the app falls back to a no-op LED, and the reader works
# with the light dark and nothing failing - the failure that is easiest to
# miss. Best-effort, like the packages above: a full Raspberry Pi OS image
# ships python3-gpiozero, which already pulls the right backend in, so only
# install one when neither is importable.
if ! python3 -c 'import lgpio' 2>/dev/null \
&& ! python3 -c 'import RPi.GPIO' 2>/dev/null; then
echo " no GPIO backend found - installing one for the status LED"
if apt-get install -y --no-install-recommends python3-lgpio 2>/dev/null; then
echo " installed python3-lgpio (Bookworm and later)"
elif apt-get install -y --no-install-recommends python3-rpi.gpio 2>/dev/null; then
echo " installed python3-rpi.gpio (Bullseye and older)"
else
warn "could not install a GPIO pin backend (python3-lgpio or"
warn "python3-rpi.gpio). The reader will work, but the status LED will"
warn "stay dark. Install it on the Pi with:"
warn " sudo apt-get install -y python3-lgpio # or python3-rpi.gpio"
fi
fi
# ---------------------------------------------------------------------------
# 2. Install the application
# ---------------------------------------------------------------------------
@@ -274,11 +346,14 @@ for origin in "${ORIGINS[@]:-}"; do
done
python3 - "$UNIT" "$INSTALL_DIR" "$BRIDGE_PORT" "$ORIGIN_ARGS" "$SERVICE_USER" \
"$SERIAL_DEVICE" "$LED_PIN" "$LED_MODE" "$HEARTBEAT" <<'PY'
"$SERIAL_DEVICE" "$LED_PIN" "$LED_MODE" "$LED_ACTIVE_LOW" "$HEARTBEAT" \
"$ERROR_LED_PIN" "$ERROR_LED_ACTIVE_LOW" "$ERROR_PULSE" \
"$BRIDGE_HOST" "$ALLOW_REMOTE" "$BRIDGE_TOKEN" <<'PY'
import os, re, sys
(unit, install_dir, port, origin_args, user, device, led_pin, led_mode,
heartbeat) = sys.argv[1:10]
led_active_low, heartbeat, error_led_pin, error_led_active_low,
error_pulse, bridge_host, allow_remote, bridge_token) = sys.argv[1:17]
text = open(unit).read()
# The console script is created by the installer, but the .pth file is what
@@ -288,11 +363,43 @@ wrapper = f"{install_dir}/.venv/bin/rdm-card-reader"
if not os.path.exists(wrapper):
wrapper = f"{install_dir}/.venv/bin/python -m rdm_card_reader"
# The token goes into an environment file, not the unit: a value on the
# ExecStart line is readable by anything that can run 'systemctl cat', and
# shows up in the process list. The '-' prefix makes the file optional.
env_file = os.path.join(install_dir, "bridge.env")
text = re.sub(r"^EnvironmentFile=.*\n?", "", text, count=1, flags=re.M)
if bridge_token:
with open(env_file, "w") as handle:
handle.write(f"RDM_BRIDGE_TOKEN={bridge_token}\n")
os.chmod(env_file, 0o600)
text = re.sub(r"^ExecStart=", f"EnvironmentFile=-{env_file}\\nExecStart=",
text, count=1, flags=re.M)
print(f" token stored in {env_file} (mode 0600)")
led_active_args = " --led-active-low" if led_active_low == "1" else ""
# An empty pin means --no-error-led: the status LED blinks instead.
if error_led_pin:
error_args = f" --error-led-pin {error_led_pin}"
if error_led_active_low == "1":
error_args += " --error-led-active-low"
if error_pulse:
error_args += f" --error-pulse {error_pulse}"
else:
error_args = " --no-error-led"
# Exposing the bridge beyond loopback is explicit: --allow-remote is required,
# and the token protects the API once the caller is not on this machine.
host_args = ""
if bridge_host != "127.0.0.1":
host_args = f" --host {bridge_host}"
if allow_remote == "1":
host_args += " --allow-remote"
command = (
f"{wrapper} --serve "
f"--port {device} --bridge-port {port} --mode login "
f"--led-pin {led_pin} --led-mode {led_mode} --heartbeat {heartbeat}"
f"{origin_args}"
f"{led_active_args}{error_args}{host_args}{origin_args}"
)
@@ -346,9 +453,10 @@ cat <<EOF
$(log "Installed")
Bridge : http://127.0.0.1:$BRIDGE_PORT
Bridge : http://127.0.0.1:$BRIDGE_PORT$( [[ "$BRIDGE_HOST" != "127.0.0.1" ]] && echo " (also on http://$BRIDGE_HOST:$BRIDGE_PORT)" )
Device : $SERIAL_DEVICE
LED : GPIO $LED_PIN, $LED_MODE mode
LED : GPIO $LED_PIN, $LED_MODE mode$( [[ "$LED_ACTIVE_LOW" -eq 1 ]] && echo ", active-low" )
Error LED : $( [[ -n "$ERROR_LED_PIN" ]] && echo "GPIO $ERROR_LED_PIN, ${ERROR_PULSE}s on a bad read" || echo "none (status LED blinks)" )
Heartbeat : ${HEARTBEAT}s (presence/LED window)
Card state : curl http://127.0.0.1:$BRIDGE_PORT/api/card
Health : curl http://127.0.0.1:$BRIDGE_PORT/api/health
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 232 KiB

-10
View File
@@ -46,10 +46,6 @@ LED_PACKAGES = (
("colorzero", "colorzero>=2.0"),
)
#: The LED extras are optional: refusing to start because a LED cannot be
#: driven would be absurd.
OPTIONAL_IMPORTS = frozenset({"gpiozero", "colorzero"})
#: Shown when an install fails, so the operator knows what to do by hand.
MANUAL_HINT = (
"Install manually with: {python} -m pip install {requirements}"
@@ -221,12 +217,6 @@ def install(
return True
def _is_optional(packages: Sequence[tuple]) -> bool:
"""True when every package in the group is an optional extra."""
names = {name for name, _ in packages}
return bool(names) and names <= OPTIONAL_IMPORTS
def ensure_dependencies(
packages: Sequence[tuple] = REQUIRED_PACKAGES,
wheel_dir: Optional[str] = None,
+141 -22
View File
@@ -55,8 +55,10 @@ import time
from collections import deque
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import Any, Callable, Deque, Dict, List, Optional
from urllib.parse import urlsplit
from .card import EVENT_INSERTED, EVENT_REMOVED, CardEvent
from .card import EVENT_INVALID, EVENT_INSERTED, EVENT_REMOVED, CardEvent
from .webui import render_page
#: Reader modes. ``login`` publishes inserts only (a tap = an authentication
#: attempt). ``monitor`` publishes inserts and removals both.
@@ -111,7 +113,7 @@ class EventHub:
self._last_at: Optional[float] = None
self._mode = mode if mode in VALID_MODES else MODE_LOGIN
self._started_at = time.time()
self._counts = {EVENT_INSERTED: 0, EVENT_REMOVED: 0, "invalid_card": 0}
self._counts = {EVENT_INSERTED: 0, EVENT_REMOVED: 0, EVENT_INVALID: 0}
self._port: Optional[str] = None
self._tap_ttl = float(tap_ttl if tap_ttl else 0.0)
self._claimed_ttl = float(claimed_ttl if claimed_ttl else 0.0)
@@ -325,14 +327,31 @@ class _Handler(BaseHTTPRequestHandler):
# -- helpers ----------------------------------------------------------
def _origin_is_loopback(self, origin: str) -> bool:
"""True when *origin* is this server itself or a loopback address.
A browser sends ``Origin`` even for same-origin requests, so without
this the status page served from this very port could not call its own
API. A remote site cannot forge ``Origin: http://127.0.0.1``, so
trusting loopback here does not widen what an attacker can reach.
"""
host = self.headers.get("Host")
if host and origin in ("http://" + host, "https://" + host):
return True
try:
hostname = (urlsplit(origin).hostname or "").lower()
except ValueError:
return False
return hostname in ("127.0.0.1", "localhost", "::1", "0.0.0.0")
def _cors_ok(self) -> bool:
"""Refuse cross-origin reads unless the origin was allow-listed."""
origin = self.headers.get("Origin")
if not origin:
return True # same-origin fetch or a non-browser client
if self._origin_is_loopback(origin):
return True
allowed: List[str] = getattr(self.server, "allowed_origins", [])
if not allowed:
return False # browsers only; opt out entirely
return origin in allowed
def _token_ok(self) -> bool:
@@ -351,26 +370,52 @@ class _Handler(BaseHTTPRequestHandler):
supplied = pair.split("=", 1)[1]
return secrets.compare_digest(supplied, required)
def _allowed_origin(self) -> Optional[str]:
"""The Origin to echo in ``Access-Control-Allow-Origin``, or None.
Same-origin and loopback callers are echoed so the dashboard can read
its own API; any other origin must be on the allow-list.
"""
origin = self.headers.get("Origin")
if not origin:
return None
if self._origin_is_loopback(origin):
return origin
if origin in getattr(self.server, "allowed_origins", []):
return origin
return None
def _send_json(self, payload: Any, status: int = 200) -> None:
body = json.dumps(payload).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
origin = self.headers.get("Origin")
if origin and origin in getattr(self.server, "allowed_origins", []):
self.send_header("Access-Control-Allow-Origin", origin)
allowed = self._allowed_origin()
if allowed:
self.send_header("Access-Control-Allow-Origin", allowed)
self.send_header("Vary", "Origin")
self.end_headers()
self.wfile.write(body)
def _send_bytes(self, body: bytes, content_type: str, status: int = 200) -> None:
self.send_response(status)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(body)
def _send_page(self) -> None:
self._send_bytes(render_page(), "text/html; charset=utf-8")
def _send_no_content(self) -> None:
self.send_response(204)
self.send_header("Cache-Control", "no-store")
self.send_header("Content-Length", "0")
origin = self.headers.get("Origin")
if origin and origin in getattr(self.server, "allowed_origins", []):
self.send_header("Access-Control-Allow-Origin", origin)
allowed = self._allowed_origin()
if allowed:
self.send_header("Access-Control-Allow-Origin", allowed)
self.send_header("Vary", "Origin")
self.end_headers()
@@ -388,6 +433,16 @@ class _Handler(BaseHTTPRequestHandler):
def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API
path = self.path.split("?", 1)[0].rstrip("/") or "/"
# The status page is served before the CORS and token checks, on
# purpose: it is static, reveals nothing on its own, and has to be able
# to load so it can prompt for a token when the API demands one.
if path == "/":
if getattr(self.server, "serve_page", True):
self._send_page()
else:
self._send_json({"error": "not found", "path": "/"}, status=404)
return
if path == "/api/health":
self._send_json(self._hub.health())
return
@@ -411,6 +466,15 @@ class _Handler(BaseHTTPRequestHandler):
self._send_json(self._hub.current_card())
return
if path == "/api/led":
controller = getattr(self.server, "led", None)
if controller is None:
self._send_json({"error": "LED control not available"},
status=503)
return
self._send_json(controller.snapshot())
return
if path == "/api/taps":
limit = 20
if "?" in self.path:
@@ -432,6 +496,58 @@ class _Handler(BaseHTTPRequestHandler):
def do_POST(self) -> None: # noqa: N802
path = self.path.split("?", 1)[0].rstrip("/") or "/"
if path == "/api/led":
if not self._cors_ok():
self._send_json({"error": "origin not allowed"}, status=403)
return
if not self._token_ok():
self._send_json({"error": "missing or invalid token"}, status=401)
return
controller = getattr(self.server, "led", None)
if controller is None:
self._send_json({"error": "LED control not available"},
status=503)
return
length = int(self.headers.get("Content-Length") or 0)
raw = self.rfile.read(length) if length else b"{}"
try:
payload = json.loads(raw.decode("utf-8") or "{}")
except ValueError:
self._send_json({"error": "invalid JSON body"}, status=400)
return
which = str(payload.get("led", ""))
if which == "release" or payload.get("release"):
controller.release()
self._send_json(controller.snapshot())
return
if "on" not in payload:
self._send_json(
{"error": "expected {\"led\": \"status|error\", "
"\"on\": true|false}, or {\"release\": true}"},
status=400,
)
return
if which not in ("status", "error"):
self._send_json(
{"error": "unknown led", "valid": ["status", "error"]},
status=400,
)
return
if not controller.set_led(which, bool(payload.get("on"))):
# The LED exists in configuration but has no usable pin, so
# there is nothing to switch. Say so rather than 200.
self._send_json(
{"error": "that LED is not available",
"state": controller.snapshot()},
status=409,
)
return
self._send_json(controller.snapshot())
return
if path == "/api/card/claim":
if not self._cors_ok():
self._send_json({"error": "origin not allowed"}, status=403)
@@ -476,10 +592,10 @@ class _Handler(BaseHTTPRequestHandler):
self._send_json({"mode": mode})
def do_OPTIONS(self) -> None: # noqa: N802
origin = self.headers.get("Origin")
self.send_response(204)
if origin and origin in getattr(self.server, "allowed_origins", []):
self.send_header("Access-Control-Allow-Origin", origin)
allowed = self._allowed_origin()
if allowed:
self.send_header("Access-Control-Allow-Origin", allowed)
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
self.send_header("Access-Control-Allow-Headers", "Content-Type, X-Auth-Token")
self.send_header("Vary", "Origin")
@@ -495,9 +611,9 @@ class _Handler(BaseHTTPRequestHandler):
self.send_header("Content-Type", "text/event-stream")
self.send_header("Cache-Control", "no-store")
self.send_header("Connection", "keep-alive")
origin = self.headers.get("Origin")
if origin and origin in getattr(self.server, "allowed_origins", []):
self.send_header("Access-Control-Allow-Origin", origin)
allowed = self._allowed_origin()
if allowed:
self.send_header("Access-Control-Allow-Origin", allowed)
self.send_header("Vary", "Origin")
self.end_headers()
@@ -532,17 +648,20 @@ def create_server(
allowed_origins: Optional[List[str]] = None,
token: Optional[str] = None,
logger: Optional[Callable[[str], None]] = None,
serve_page: bool = True,
led: Any = None,
) -> ThreadingHTTPServer:
"""Build (but do not start) the HTTP server around *hub*."""
"""Build (but do not start) the HTTP server around *hub*.
*led* is the LED controller, exposed for the manual override at
``/api/led``. It is optional: without it the endpoint reports 503.
"""
server = ThreadingHTTPServer((host, port), _Handler)
server.daemon_threads = True
server.hub = hub # type: ignore[attr-defined]
server.led = led # type: ignore[attr-defined]
server.serve_page = serve_page # type: ignore[attr-defined]
server.allowed_origins = list(allowed_origins or []) # type: ignore[attr-defined]
server.token = token or os.environ.get("RDM_BRIDGE_TOKEN") or None # type: ignore[attr-defined]
server.logger = logger # type: ignore[attr-defined]
return server
def is_loopback(host: str) -> bool:
"""True for hosts that are not reachable off-box."""
return host in ("127.0.0.1", "::1", "localhost")
+36 -3
View File
@@ -20,17 +20,19 @@ from .bridge import (
create_server,
)
from .card import (
BAR,
CANDIDATE_PORTS,
DEFAULT_HEARTBEAT_INTERVAL,
EVENT_INVALID,
EVENT_REMOVED,
CardEvent,
format_event,
)
from .demo import run_demo
from .led import (
DEFAULT_ERROR_LED_PIN,
DEFAULT_LED_MODE,
DEFAULT_LED_PIN,
ERROR_BLINK_SECONDS,
MODES as LED_MODES,
create as create_led,
)
@@ -143,6 +145,11 @@ def _build_led(args):
if args.no_led:
return create_led(enabled=False)
# A dedicated error LED is the default. The old single-LED behaviour is
# still reachable: --no-error-led blinks the status LED instead, and
# --error-led-pin with the status pin's number does the same thing.
error_pin = None if args.no_error_led else args.error_led_pin
# Only pull in gpiozero when a LED was actually asked for, so a headless
# install does not grow the dependency set.
ensure_dependencies(
@@ -156,7 +163,11 @@ def _build_led(args):
pin=args.led_pin,
enabled=True,
mode=args.led_mode,
active_high=not args.led_active_low,
error_blink=not args.led_no_error_blink,
error_pin=error_pin,
error_active_high=not args.error_led_active_low,
error_pulse=args.error_pulse,
logger=lambda message: print(" {}".format(message)),
)
@@ -185,7 +196,7 @@ def _run_bridge(args) -> int:
# Drive the LED first: it is queued, so it never delays the reader.
if event.event == EVENT_INVALID:
led.error()
elif event.event == "card_removed":
elif event.event == EVENT_REMOVED:
led.remove()
else:
led.insert()
@@ -212,6 +223,8 @@ def _run_bridge(args) -> int:
allowed_origins=args.allow_origin,
token=args.token,
logger=print if args.verbose else None,
serve_page=not args.no_page,
led=led,
)
server_thread = threading.Thread(target=server.serve_forever, daemon=True,
@@ -220,7 +233,10 @@ def _run_bridge(args) -> int:
print(" OK reader on {} ; bridge on http://{}:{} (mode: {})".format(
device, args.host, args.bridge_port, args.mode))
if not args.no_page:
print(" GET / <- status page in a browser")
print(" GET /api/card <- the card on the reader right now")
print(" GET /api/led <- LED state; POST to switch one by hand")
print(" POST /api/card/claim (single use, for one login)")
print(" GET /api/events (SSE stream)")
print(" GET /api/last /api/health")
@@ -303,6 +319,8 @@ def build_parser() -> argparse.ArgumentParser:
"(or set RDM_BRIDGE_TOKEN)")
bridge.add_argument("--allow-remote", action="store_true",
help="permit binding a non-loopback address")
bridge.add_argument("--no-page", action="store_true",
help="do not serve the status page at GET /")
bridge.add_argument("--verbose", action="store_true",
help="also print each event and each HTTP request")
@@ -318,6 +336,21 @@ def build_parser() -> argparse.ArgumentParser:
"blinking means a bad read")
led.add_argument("--led-no-error-blink", action="store_true",
help="do not blink the LED on a bad read")
led.add_argument("--led-active-low", action="store_true",
help="LED is wired active-low (cathode to the pin, anode "
"to 3.3 V); inverts the signal")
led.add_argument("--error-led-pin", type=int, default=DEFAULT_ERROR_LED_PIN,
metavar="N",
help="BCM GPIO of a dedicated error LED, lit briefly on a "
"bad read (default: %(default)s, matching the "
"workstation app)")
led.add_argument("--no-error-led", action="store_true",
help="no dedicated error LED; blink the status LED instead")
led.add_argument("--error-led-active-low", action="store_true",
help="the error LED is wired active-low")
led.add_argument("--error-pulse", type=float, default=ERROR_BLINK_SECONDS,
help="seconds to hold the error LED on a bad read "
"(default: %(default)s)")
return parser
@@ -348,7 +381,7 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
# LED first: it is queued, so it never delays the reader loop.
if event.event == EVENT_INVALID:
led.error()
elif event.event == "card_removed":
elif event.event == EVENT_REMOVED:
led.remove()
else:
led.insert()
+5 -2
View File
@@ -7,6 +7,9 @@ import time
from .card import (
DEFAULT_HEARTBEAT_INTERVAL,
EVENT_INVALID,
EVENT_INSERTED,
EVENT_REMOVED,
CardData,
CardEvent,
format_event,
@@ -37,12 +40,12 @@ def run_demo(
for _ in range(max(1, count)):
card = rng.choice(DEMO_CARDS)
event_name = "card_inserted" if card.is_valid else "invalid_card"
event_name = EVENT_INSERTED if card.is_valid else EVENT_INVALID
_emit(CardEvent.from_card(event_name, card, "demo"), as_json)
time.sleep(max(0.1, heartbeat))
if card.is_valid:
_emit(CardEvent.from_card("card_removed", card, "demo"), as_json)
_emit(CardEvent.from_card(EVENT_REMOVED, card, "demo"), as_json)
time.sleep(0.3)
print("\nDemo finished.", flush=True)
-339
View File
@@ -1,339 +0,0 @@
"""Type a card id into whatever window has focus, like a keyboard-wedge reader.
A commercial USB RFID reader presents itself to the machine as a keyboard: the
card code appears in the focused window as if it had been typed. This does the
same in software, by creating a virtual keyboard through the kernel's
``/dev/uinput`` interface.
Two consequences of doing it that way, both deliberate:
* **No external tool and no extra dependency.** ``python-evdev`` would be the
usual choice, but it is a C extension, so its wheels are tied to one
interpreter and one CPU architecture. This project ships pure-Python wheels
precisely so the same files work on 32- and 64-bit ARM; pulling in a compiled
dependency would break that. ``ctypes`` and ``struct`` are enough.
* **It works from the reader's system service.** uinput registers the device
with the kernel, not with a login session, so the desktop picks it up exactly
as it would a real keyboard. No ``DISPLAY``, no session bus, no
``systemctl --user`` unit is involved - unlike X11-only tools such as
``xdotool``, which also fail on Wayland.
The known limit, which a real wedge reader shares: the text goes to whatever
holds focus, so a tap while a password prompt is focused types the card id
there. That is why the feature is **opt-in** (``--type``) rather than default.
Like the LED, this never blocks the reader thread, never crashes the process,
and never pretends to work when it cannot: whether a virtual keyboard was
actually created is reported at startup.
"""
from __future__ import annotations
import ctypes
import fcntl
import os
import queue
import threading
import time
from dataclasses import dataclass
from typing import Any, Callable, Dict, List, Optional
#: Where the kernel exposes the uinput control device.
UINPUT_PATH = "/dev/uinput"
#: Suppress repeated identical text, e.g. the same card held on the antenna.
DEFAULT_KEY_DELAY = 0.002
#: Output formats for the card id.
DECIMAL = "decimal"
HEX = "hex"
FORMATS = (DECIMAL, HEX)
#: What to send after the id, so the target field is submitted.
ENTER = "enter"
TAB = "tab"
NONE = "none"
SUFFIXES = (ENTER, TAB, NONE)
# -- kernel constants (linux/input-event-codes.h and linux/uinput.h) ---------
EV_SYN = 0x00
EV_KEY = 0x01
SYN_REPORT = 0x00
_KEY_PRESS = 1
_KEY_RELEASE = 0
_UI_DEV_CREATE = 0x5501
_UI_DEV_DESTROY = 0x5502
_UI_SET_EVBIT = 0x40045564
_UI_SET_KEYBIT = 0x40045565
_UINPUT_MAX_NAME_SIZE = 80
#: Character -> key code. Digits cover the default output; a-f cover ``--hex``
#: (lower case, since upper case would need a shift modifier for no real gain).
_KEYCODES: Dict[str, int] = {
"1": 2, "2": 3, "3": 4, "4": 5, "5": 6,
"6": 7, "7": 8, "8": 9, "9": 10, "0": 11,
"a": 30, "b": 48, "c": 46, "d": 32, "e": 18, "f": 33,
"x": 45,
}
_SPECIAL: Dict[str, int] = {
ENTER: 28,
TAB: 15,
}
class _InputEvent(ctypes.Structure):
"""``struct input_event``.
``tv_sec``/``tv_usec`` are ``c_long`` so the layout is right on both 32- and
64-bit kernels - the struct is 16 bytes on one and 24 on the other, and
hard-coding either would corrupt the event.
"""
_fields_ = [
("tv_sec", ctypes.c_long),
("tv_usec", ctypes.c_long),
("type", ctypes.c_uint16),
("code", ctypes.c_uint16),
("value", ctypes.c_int32),
]
class _InputId(ctypes.Structure):
_fields_ = [
("bustype", ctypes.c_uint16),
("vendor", ctypes.c_uint16),
("product", ctypes.c_uint16),
("version", ctypes.c_uint16),
]
class _UInputUserDev(ctypes.Structure):
"""``struct uinput_user_dev``, used to name and describe the device."""
_fields_ = [
("name", ctypes.c_char * _UINPUT_MAX_NAME_SIZE),
("id", _InputId),
("ff_effects_max", ctypes.c_uint32),
("absmax", ctypes.c_int32 * 64),
("absmin", ctypes.c_int32 * 64),
("absfuzz", ctypes.c_int32 * 64),
("absflat", ctypes.c_int32 * 64),
]
def _as_bytes(struct: ctypes.Structure) -> bytes:
return ctypes.string_at(ctypes.byref(struct), ctypes.sizeof(struct))
@dataclass
class WedgeStatus:
"""What the wedge subsystem is actually doing."""
enabled: bool
backend: str # "uinput" | "null"
format: str = DECIMAL
suffix: str = ENTER
reason: str = ""
def to_dict(self) -> dict:
return {
"enabled": self.enabled,
"backend": self.backend,
"format": self.format,
"suffix": self.suffix,
"reason": self.reason,
}
class NullWedge:
"""A wedge that does nothing, used when uinput is unavailable."""
backend = "null"
def __init__(self, reason: str = ""):
self.reason = reason
def type_text(self, text: str) -> None:
pass
def close(self) -> None:
pass
class UInputWedge:
"""A virtual keyboard created through ``/dev/uinput``."""
backend = "uinput"
def __init__(self, name: str = "rdm-card-reader", key_delay: float = DEFAULT_KEY_DELAY):
self.key_delay = key_delay
self._fd = os.open(UINPUT_PATH, os.O_WRONLY | os.O_NONBLOCK)
# Declare the only kinds of event this device will ever send.
fcntl.ioctl(self._fd, _UI_SET_EVBIT, EV_KEY)
for code in sorted(set(_KEYCODES.values()) | set(_SPECIAL.values())):
fcntl.ioctl(self._fd, _UI_SET_KEYBIT, code)
dev = _UInputUserDev()
dev.name = name.encode("utf-8")[:_UINPUT_MAX_NAME_SIZE - 1]
# BUS_USB keeps the device looking like an ordinary keyboard to udev.
dev.id = _InputId(bustype=0x03, vendor=0x1, product=0x1, version=1)
os.write(self._fd, _as_bytes(dev))
fcntl.ioctl(self._fd, _UI_DEV_CREATE)
# udev needs a moment to notice a brand-new input device; typing before
# it does means the first characters go nowhere.
time.sleep(0.2)
def _send(self, event_type: int, code: int, value: int) -> None:
os.write(self._fd, _as_bytes(_InputEvent(0, 0, event_type, code, value)))
def _tap(self, code: int) -> None:
self._send(EV_KEY, code, _KEY_PRESS)
self._send(EV_SYN, SYN_REPORT, 0)
self._send(EV_KEY, code, _KEY_RELEASE)
self._send(EV_SYN, SYN_REPORT, 0)
if self.key_delay:
time.sleep(self.key_delay)
def type_text(self, text: str) -> None:
for character in text:
code = _KEYCODES.get(character.lower())
if code is not None:
self._tap(code)
def press(self, name: str) -> None:
code = _SPECIAL.get(name)
if code is not None:
self._tap(code)
def close(self) -> None:
try:
fcntl.ioctl(self._fd, _UI_DEV_DESTROY)
except Exception:
pass
try:
os.close(self._fd)
except Exception:
pass
def format_card(event: Any, fmt: str = DECIMAL) -> str:
"""Render a card event as the text a wedge reader would have typed."""
if fmt == HEX:
return event.hex_value
return str(event.value)
class KeyboardWedge:
"""Non-blocking wedge controller driven by card events.
Text is queued and typed by a worker thread, so a card read never waits on
the desktop - the same arrangement the LED uses, and for the same reason.
"""
def __init__(
self,
enabled: bool = True,
fmt: str = DECIMAL,
suffix: str = ENTER,
key_delay: float = DEFAULT_KEY_DELAY,
logger: Optional[Callable[[str], None]] = None,
):
self._log = logger or (lambda message: None)
self._fmt = fmt if fmt in FORMATS else DECIMAL
self._suffix = suffix if suffix in SUFFIXES else ENTER
self._queue: "queue.Queue[Optional[str]]" = queue.Queue()
self._thread: Optional[threading.Thread] = None
if not enabled:
self.wedge: Any = NullWedge("typing disabled (--no-type)")
self.status = WedgeStatus(False, "null", self._fmt, self._suffix,
"disabled")
return
try:
self.wedge = UInputWedge(key_delay=key_delay)
self.status = WedgeStatus(True, "uinput", self._fmt, self._suffix,
"ready")
self._log("Keyboard wedge ready (uinput, {} text, {} suffix)".format(
self._fmt, self._suffix))
except PermissionError as exc:
reason = (
"cannot write {}: {} - add the user to the 'input' group "
"and re-run the installer".format(UINPUT_PATH, exc)
)
self.wedge = NullWedge(reason)
self.status = WedgeStatus(False, "null", self._fmt, self._suffix,
reason)
self._log("Keyboard wedge unavailable: {}".format(reason))
except Exception as exc:
reason = "no usable uinput: {}".format(exc)
self.wedge = NullWedge(reason)
self.status = WedgeStatus(False, "null", self._fmt, self._suffix,
reason)
self._log("Keyboard wedge unavailable: {}".format(reason))
self._thread = threading.Thread(target=self._worker, daemon=True,
name="wedge-worker")
self._thread.start()
# -- event hooks ------------------------------------------------------
def insert(self, event: Any) -> None:
"""A card was read: queue its id, then the configured suffix."""
self._send(format_card(event, self._fmt))
@property
def suffix(self) -> str:
return self._suffix
# -- control ----------------------------------------------------------
def _send(self, text: str) -> None:
if not self.status.enabled:
return
try:
self._queue.put_nowait(text)
except Exception:
pass
def _worker(self) -> None:
while True:
text = self._queue.get()
if text is None:
return
try:
self.wedge.type_text(text)
if self._suffix != NONE:
self.wedge.press(self._suffix)
except Exception as exc:
# A failed keystroke must never take the reader down.
self._log("Keyboard wedge failed: {}".format(exc))
finally:
self._queue.task_done()
def close(self) -> None:
if self._thread is not None:
try:
self._queue.put_nowait(None)
except Exception:
pass
self.wedge.close()
def create(
enabled: bool = True,
fmt: str = DECIMAL,
suffix: str = ENTER,
key_delay: float = DEFAULT_KEY_DELAY,
logger: Optional[Callable[[str], None]] = None,
) -> KeyboardWedge:
"""Build the wedge controller, falling back to a no-op when uinput fails."""
return KeyboardWedge(enabled=enabled, fmt=fmt, suffix=suffix,
key_delay=key_delay, logger=logger)
+256 -49
View File
@@ -12,21 +12,35 @@ differences:
* It is explicit. Whether an LED is actually attached is reported, so "no light
came on" is distinguishable from "the card was not read".
The default pin (23) matches the workstation app. **Use a separate LED from the
workstation's if both run on one device**, since two processes driving one pin
will fight.
Two LEDs, because one cannot say two things at once:
* the **status** LED (default GPIO 23) tracks the card: lit while one is on the
reader, in ``presence`` mode.
* the **error** LED (default GPIO 24) is for a bad read: dark normally, lit
briefly on a checksum failure. Without it, ``presence`` mode - the default -
has no way at all to report an invalid card, because the status light is
already carrying the card's presence.
The default status pin (23) matches the workstation app. **Use a separate LED
from the workstation's if both run on one device**, since two processes driving
one pin will fight.
"""
from __future__ import annotations
import queue
import threading
import time
from dataclasses import dataclass
from typing import Any, Callable, Optional
from typing import Any, Callable, Optional, Tuple
#: GPIO pin of the status LED. Chosen to match the workstation app.
DEFAULT_LED_PIN = 23
#: GPIO pin of the error LED. Separate from the status pin so the two can be
#: read independently: one says "a card is here", the other "the read failed".
DEFAULT_ERROR_LED_PIN = 24
#: How long to hold an error indication, in seconds.
ERROR_BLINK_SECONDS = 1.0
@@ -49,10 +63,6 @@ MODES = (PRESENCE, ERROR)
#: What the light means unless told otherwise.
DEFAULT_LED_MODE = PRESENCE
#: Kept for the occasional caller that only cares about the concrete commands.
_VALID_MODES = (OFF, ON, BLINK)
@dataclass
class LEDStatus:
"""What the LED subsystem is actually doing."""
@@ -63,14 +73,18 @@ class LEDStatus:
mode: str = ERROR
state: bool = False
reason: str = ""
#: False when the LED is wired active-low (cathode to the pin).
active_high: bool = True
def to_dict(self) -> dict: return {
def to_dict(self) -> dict:
return {
"enabled": self.enabled,
"pin": self.pin,
"backend": self.backend,
"mode": self.mode,
"state": "on" if self.state else "off",
"reason": self.reason,
"active_high": self.active_high,
}
@@ -151,6 +165,39 @@ class _Blinker(threading.Thread):
self._stop.set()
def _open_led(
pin: int, active_high: bool, log: Callable[[str], None], label: str
) -> Tuple[Any, bool, str]:
"""Build the pin object for *pin*.
Returns ``(led, usable, reason)``. *led* is a :class:`NullLED` when no pin
is usable, so callers never have to special-case it.
The library import is checked **separately** from the pin construction on
purpose: ``gpiozero`` raises ``BadPinFactory`` when no pin provider works,
and that is a subclass of ``ImportError``. Catching ``ImportError`` around
the pin construction would therefore report "gpiozero is not installed"
even when it is installed and simply has no usable pin library - on a Pi
missing ``lgpio`` or ``RPi.GPIO`` that message sends you to the wrong fix.
"""
try:
import gpiozero # noqa: F401
except ImportError as exc:
reason = "gpiozero not installed: {}".format(exc)
log("{} unavailable: {}".format(label, reason))
return NullLED(pin, reason), False, reason
try:
built = GPIOLED(pin, active_high=active_high)
except Exception as exc:
# No usable pin factory (not a Pi), pin already in use, bad pin.
reason = "no usable GPIO on pin {}: {}".format(pin, exc)
log("{} unavailable: {}".format(label, reason))
return NullLED(pin, reason), False, reason
return built, True, "ready"
class StatusLED:
"""Non-blocking LED controller driven by card events.
@@ -166,52 +213,79 @@ class StatusLED:
active_high: bool = True,
error_blink: bool = True,
logger: Optional[Callable[[str], None]] = None,
error_pin: Optional[int] = None,
error_active_high: bool = True,
error_pulse: float = ERROR_BLINK_SECONDS,
):
self._log = logger or (lambda message: None)
self._mode = mode if mode in MODES else DEFAULT_LED_MODE
self._error_blink = error_blink
self._enabled = enabled
self._blinker: Optional[_Blinker] = None
self._error_pulse = max(0.0, float(error_pulse))
self._state = False
self._error_until: Optional[float] = None
self._error_state = False
#: Set while the status page is forcing a LED, so card events and the
#: error pulse leave it alone.
self._manual_status = False
self._manual_error = False
self._queue: "queue.Queue[Optional[str]]" = queue.Queue()
self._thread: Optional[threading.Thread] = None
if not enabled:
self.led: Any = NullLED(None, "LED disabled (--no-led)")
self.status = LEDStatus(False, None, "null", self._mode, False,
"disabled")
"disabled", active_high)
self.error_led: Any = NullLED(None, "LED disabled (--no-led)")
self.error_status = LEDStatus(False, None, "null", self._mode,
False, "disabled", error_active_high)
return
# Check the library separately from building the pin.
#
# gpiozero raises BadPinFactory when no pin provider works, and that is
# a subclass of ImportError, so catching ImportError around the pin
# construction would report "gpiozero is not installed" even when it is
# installed and simply has no usable pin library. On a Pi missing lgpio
# or RPi.GPIO that message sends you to the wrong fix.
try:
import gpiozero # noqa: F401
except ImportError as exc:
reason = "gpiozero not installed: {}".format(exc)
self.led = NullLED(pin, reason)
self.status = LEDStatus(False, pin, "null", self._mode, False,
reason)
self._log("LED unavailable: {}".format(reason))
self.led, usable, reason = _open_led(pin, active_high, self._log, "LED")
self.status = LEDStatus(usable, pin,
"gpiozero" if usable else "null", self._mode,
False, reason, active_high)
if usable:
self._log("LED on GPIO {} ({} mode, {}, gpiozero)".format(
pin, self._mode,
"active-high" if active_high else "active-low"))
# A dedicated error LED, when one is wired. It reports a bad read on its
# own pin, so the status light stays free to mean "a card is here".
if error_pin is not None and error_pin == pin:
# Two LEDs on one pin cannot be driven independently, and opening
# the pin twice makes the second open fail anyway. Treat it as "no
# dedicated error LED" so the status LED blink is used instead.
self._log("Error LED pin {} is the status pin; using the status "
"LED to indicate bad reads".format(error_pin))
error_pin = None
if error_pin is None:
self.error_led = NullLED(None, "no error LED configured")
self.error_status = LEDStatus(False, None, "null", ERROR, False,
"not configured", error_active_high)
else:
try:
self.led = GPIOLED(pin, active_high=active_high)
self.status = LEDStatus(True, pin, "gpiozero", self._mode,
False, "ready")
self._log("LED on GPIO {} ({} mode, gpiozero)".format(
pin, self._mode))
except Exception as exc:
# No usable pin factory (not a Pi), pin already in use, bad pin.
reason = ("no usable GPIO on pin {}: {}".format(pin, exc))
self.led = NullLED(pin, reason)
self.status = LEDStatus(False, pin, "null", self._mode, False,
reason)
self._log("LED unavailable: {}".format(reason))
self.error_led, e_usable, e_reason = _open_led(
error_pin, error_active_high, self._log, "Error LED")
self.error_status = LEDStatus(e_usable, error_pin,
"gpiozero" if e_usable else "null",
ERROR, False, e_reason,
error_active_high)
if e_usable:
self._log("Error LED on GPIO {} (lit for {}s on a bad read, "
"{}, gpiozero)".format(
error_pin, self._error_pulse,
"active-high" if error_active_high
else "active-low"))
self._thread = threading.Thread(target=self._worker, daemon=True,
name="led-worker")
# The worker only needs to wake on a timer when there is an error pulse
# to expire. Without one it blocks on the queue, as before.
self._poll = (0.05 if (self.error_status.enabled
and self._error_pulse > 0) else None)
self._thread.start()
if self.status.enabled and self._mode == ERROR:
# A health indicator shows 'alive' immediately. In presence mode the
@@ -220,23 +294,108 @@ class StatusLED:
self._set_state(True)
self.status.state = True
self._thread = threading.Thread(target=self._worker, daemon=True,
name="led-worker")
self._thread.start()
# -- event hooks ------------------------------------------------------
def insert(self) -> None:
"""A valid card was read: off in 'error' mode, on in 'presence' mode."""
if self._manual_status:
return
self._send(OFF if self._mode == ERROR else ON)
def remove(self) -> None:
"""The card left the reader: dark in 'presence' mode; 'error' mode stays on."""
if self._manual_status:
return
self._send(ON if self._mode == ERROR else OFF)
def error(self) -> None:
"""A bad read: blink, if blinking is enabled."""
self._send(BLINK if self._error_blink else ON)
"""A bad read: blink the status LED, or pulse the error LED if wired.
With a dedicated error LED the indication goes there and the status
light is left alone, so the two LEDs stay independent: one says "a card
is here", the other "the read failed". Without one, the status LED
blinks as before - but only in ``error`` mode, since a blink and "card
present" cannot both be shown on one light.
"""
if self._manual_status or self._manual_error:
return
if self.error_status.enabled and self._error_pulse > 0:
self._error_until = time.monotonic() + self._error_pulse
return
if self._error_blink:
self._send(BLINK)
def _pulse_done(self, now: float) -> bool:
return self._error_until is not None and now >= self._error_until
def _error_indicate(self, on: bool) -> None:
"""Set the error LED, remembering the state for :meth:`close`."""
if on:
self.error_led.on()
else:
self.error_led.off()
self._error_state = on
self.error_status.state = on
# -- manual control (diagnostics) -------------------------------------
def set_led(self, which: str, on: bool) -> bool:
"""Force one LED on or off by hand. Returns False for an unknown name.
This exists to answer "can the software drive the pin at all?", which
separates a wiring fault from a configuration one. It latches: the
forced state holds until :meth:`release` or the next card event, so a
light turned on from the status page does not go dark when the next
poll runs.
"""
if which == "status":
if not self.status.enabled:
return False
self._manual_status = True
self._send(ON if on else OFF)
# Record the requested state now: the worker applies it
# asynchronously, and a snapshot taken before it runs would
# otherwise contradict the response we just sent.
self._set_state(bool(on))
return True
if which == "error":
if not self.error_status.enabled:
return False
self._manual_error = True
# Cancel any pulse in flight so it cannot fight the manual state.
self._error_until = None
self._error_indicate(bool(on))
return True
return False
def release(self) -> None:
"""Drop any manual override and let card events drive the LEDs again."""
self._manual_status = False
self._manual_error = False
self._error_until = None
if self.status.enabled:
# Return to the resting state for the current mode, recorded now so
# a snapshot taken before the worker runs agrees with it.
resting = self._mode == ERROR
self._send(ON if resting else OFF)
self._set_state(resting)
if self.error_status.enabled:
self._error_indicate(False)
def snapshot(self) -> dict:
"""Both LEDs' state, for ``/api/led`` and the status page."""
return {
"manual": self._manual_status or self._manual_error,
"status": self._led_info(self.status, self._manual_status),
"error": self._led_info(self.error_status, self._manual_error),
}
@staticmethod
def _led_info(status: "LEDStatus", manual: bool) -> dict:
info = status.to_dict()
info["manual"] = manual
info["on"] = bool(status.state)
return info
# -- control ----------------------------------------------------------
@@ -250,7 +409,12 @@ class StatusLED:
def _worker(self) -> None:
while True:
command = self._queue.get()
try:
command = self._queue.get(timeout=self._poll)
except queue.Empty:
# No command pending: run the time-based error pulse, if any.
self._tick_error_pulse()
continue
if command is None:
return
try:
@@ -261,6 +425,20 @@ class StatusLED:
finally:
self._queue.task_done()
def _tick_error_pulse(self) -> None:
"""Light the error LED for its window, then darken it again."""
if self._error_until is None or self._manual_error:
return
now = time.monotonic()
if now < self._error_until:
if not self._error_state:
self._error_indicate(True)
elif self._error_state:
self._error_indicate(False)
else:
# Window passed and already dark: stop tracking it.
self._error_until = None
def _apply(self, command: str) -> None:
# Blinking is used only for a bad read, and only by 'error' mode.
if command == BLINK and self._mode == ERROR:
@@ -293,26 +471,55 @@ class StatusLED:
def close(self) -> None:
self._stop_blink()
self._manual_status = False
self._manual_error = False
if self._thread is not None:
self._queue.put(None)
try:
self._queue.put_nowait(None)
except Exception:
pass
try:
self.led.off()
except Exception:
pass
self._set_state(False)
try:
self.error_led.off()
except Exception:
pass
self.error_status.state = False
try:
self.led.close()
except Exception:
pass
try:
self.error_led.close()
except Exception:
pass
def create(
enabled: bool = True,
pin: int = DEFAULT_LED_PIN,
mode: str = DEFAULT_LED_MODE,
active_high: bool = True,
error_blink: bool = True,
logger: Optional[Callable[[str], None]] = None,
error_pin: Optional[int] = None,
error_active_high: bool = True,
error_pulse: float = ERROR_BLINK_SECONDS,
) -> StatusLED:
"""Build the LED controller, degrading to a no-op when GPIO is unusable."""
"""Build the LED controller, degrading to a no-op when GPIO is unusable.
*active_high* is False for an LED wired active-low - cathode to the GPIO
pin, anode to 3.3 V, or a driver that inverts. Getting this wrong does not
fail: the light simply does the opposite of what is wanted.
*error_pin* enables a second, dedicated LED for bad reads. Pass the same
value as *pin* to fall back to the single-LED behaviour.
"""
return StatusLED(pin=pin, enabled=enabled, mode=mode,
error_blink=error_blink, logger=logger)
active_high=active_high,
error_blink=error_blink, logger=logger,
error_pin=error_pin, error_active_high=error_active_high,
error_pulse=error_pulse)
+11 -4
View File
@@ -10,7 +10,14 @@ import importlib
import os
from typing import Callable, Dict, List, Optional, Sequence
from .card import CANDIDATE_PORTS, DEFAULT_HEARTBEAT_INTERVAL, CardEvent
from .card import (
CANDIDATE_PORTS,
DEFAULT_HEARTBEAT_INTERVAL,
EVENT_INVALID,
EVENT_INSERTED,
EVENT_REMOVED,
CardEvent,
)
try: # pragma: no cover - depends on the environment
import rdm6300
@@ -58,15 +65,15 @@ def _make_reader_class(callback: EventCallback):
"""Fires *callback* for each insert, removal and checksum failure."""
def card_inserted(self, card) -> None:
callback(CardEvent.from_card("card_inserted", card, self.port))
callback(CardEvent.from_card(EVENT_INSERTED, card, self.port))
def card_removed(self, card) -> None:
if card is None:
return
callback(CardEvent.from_card("card_removed", card, self.port))
callback(CardEvent.from_card(EVENT_REMOVED, card, self.port))
def invalid_card(self, card) -> None:
callback(CardEvent.from_card("invalid_card", card, self.port))
callback(CardEvent.from_card(EVENT_INVALID, card, self.port))
return CardReader
+438
View File
@@ -0,0 +1,438 @@
"""The status page served at ``GET /`` by the HTTP bridge.
Kept as a single string in a Python module rather than a packaged ``.html``
file on purpose: this way the page is present however the package is
installed - source checkout, editable install, or the venv the installer
builds - with no ``package-data`` entry to forget.
The page is read-only by design. It deliberately has no "claim" button:
``POST /api/card/claim`` spends the latched tap, so a status page that
claimed would silently consume a real login just for being open.
Everything lives in one file with no external assets, because the device it
runs on is usually a Raspberry Pi that may have no internet access at all.
"""
from __future__ import annotations
#: Poll interval for the status panels, in milliseconds. The event log is
#: pushed over SSE and does not wait for this.
REFRESH_MS = 2000
#: How many log rows the page keeps before dropping the oldest.
MAX_LOG_ROWS = 200
PAGE_HTML = """<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>RDM card reader</title>
<style>
:root {
--bg: #14161a; --panel: #1c1f26; --line: #2b3038;
--fg: #e6e9ef; --dim: #8b93a1; --accent: #6ea8fe;
--ok: #4ade80; --warn: #fbbf24; --bad: #f87171;
--mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
}
* { box-sizing: border-box; }
body {
margin: 0; padding: 1rem; background: var(--bg); color: var(--fg);
font: 15px/1.45 var(--mono); -webkit-text-size-adjust: 100%;
}
header {
display: flex; align-items: center; gap: .75rem; flex-wrap: wrap;
padding-bottom: .75rem; border-bottom: 1px solid var(--line);
}
h1 { font-size: 1.05rem; margin: 0; font-weight: 600; letter-spacing: .02em; }
h2 { font-size: .8rem; margin: 0 0 .5rem; color: var(--dim);
font-weight: 600; text-transform: uppercase; letter-spacing: .08em; }
.pill {
font-size: .75rem; padding: .2rem .55rem; border-radius: 999px;
border: 1px solid var(--line); color: var(--dim); white-space: nowrap;
}
.pill.up { color: var(--ok); border-color: color-mix(in srgb, var(--ok) 40%, transparent); }
.pill.down { color: var(--bad); border-color: color-mix(in srgb, var(--bad) 40%, transparent); }
#token {
margin-left: auto; background: var(--panel); color: var(--fg);
border: 1px solid var(--line); border-radius: 6px;
padding: .25rem .5rem; font: inherit; font-size: .78rem; min-width: 14rem;
}
#token:focus { outline: 1px solid var(--accent); }
main { display: grid; gap: 1rem; grid-template-columns: repeat(auto-fit, minmax(19rem, 1fr)); margin: 1rem 0; }
.panel { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: .9rem 1rem; }
dl { display: grid; grid-template-columns: auto 1fr; gap: .3rem .9rem; margin: 0; }
dt { color: var(--dim); font-size: .82rem; }
dd { margin: 0; text-align: right; font-variant-numeric: tabular-nums; word-break: break-all; }
dd.yes { color: var(--ok); } dd.no { color: var(--dim); }
.empty { color: var(--dim); font-style: italic; font-size: .84rem; }
.logwrap { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: .9rem 1rem; }
#log { list-style: none; margin: 0; padding: 0; max-height: 24rem; overflow-y: auto; }
#log li {
display: grid; grid-template-columns: 7.5rem 6.5rem 1fr; gap: .6rem;
padding: .28rem 0; border-bottom: 1px solid color-mix(in srgb, var(--line) 55%, transparent);
font-size: .84rem; align-items: baseline;
}
#log li:last-child { border-bottom: 0; }
.ts { color: var(--dim); }
.ev { font-weight: 600; }
.inserted .ev { color: var(--ok); }
.removed .ev { color: var(--accent); }
.invalid .ev { color: var(--bad); }
.detail { color: var(--dim); word-break: break-all; }
.detail b { color: var(--fg); font-weight: 600; }
.ledbtns { display: flex; flex-wrap: wrap; gap: .4rem; margin-top: .7rem; }
.ledbtns button {
font: inherit; font-size: .8rem; padding: .3rem .6rem; cursor: pointer;
color: var(--fg); background: #23272f; border: 1px solid var(--line);
border-radius: 6px;
}
.ledbtns button:hover:not(:disabled) { border-color: var(--accent); }
.ledbtns button:disabled { opacity: .4; cursor: not-allowed; }
.ledmsg { margin: .6rem 0 0; font-size: .78rem; color: var(--dim); min-height: 1em; }
.ledmsg.err { color: var(--bad); }
footer { margin-top: 1rem; color: var(--dim); font-size: .78rem; }
footer code { color: var(--fg); background: var(--panel); padding: .1rem .3rem; border-radius: 4px; }
</style>
</head>
<body>
<header>
<h1>RDM card reader</h1>
<span id="conn" class="pill">connecting&hellip;</span>
<input id="token" placeholder="token (only if the bridge asks for one)"
autocomplete="off" spellcheck="false">
</header>
<main>
<section class="panel">
<h2>Reader</h2>
<dl>
<dt>mode</dt> <dd id="mode">&mdash;</dd>
<dt>port</dt> <dd id="port">&mdash;</dd>
<dt>uptime</dt> <dd id="uptime">&mdash;</dd>
<dt>card present</dt><dd id="present">&mdash;</dd>
<dt>claimable</dt> <dd id="claimable">&mdash;</dd>
</dl>
</section>
<section class="panel">
<h2>Current card</h2>
<dl>
<dt>id</dt> <dd id="c-id">&mdash;</dd>
<dt>hex</dt> <dd id="c-hex">&mdash;</dd>
<dt>type</dt> <dd id="c-type">&mdash;</dd>
<dt>valid</dt> <dd id="c-valid">&mdash;</dd>
<dt>age</dt> <dd id="c-age">&mdash;</dd>
</dl>
<p id="c-none" class="empty">No card has been read yet.</p>
</section>
<section class="panel">
<h2>Counters</h2>
<dl>
<dt>inserted</dt> <dd id="n-inserted">&mdash;</dd>
<dt>removed</dt> <dd id="n-removed">&mdash;</dd>
<dt>invalid</dt> <dd id="n-invalid">&mdash;</dd>
<dt>subscribers</dt> <dd id="n-subs">&mdash;</dd>
<dt>tap / claimed TTL</dt><dd id="n-ttl">&mdash;</dd>
</dl>
</section>
<section class="panel">
<h2>LEDs</h2>
<dl>
<dt>green (status)</dt> <dd id="led-st-state">&mdash;</dd>
<dt>red (error)</dt> <dd id="led-er-state">&mdash;</dd>
<dt>status pin</dt> <dd id="led-st-pin">&mdash;</dd>
<dt>error pin</dt> <dd id="led-er-pin">&mdash;</dd>
<dt>driven by</dt> <dd id="led-auto">&mdash;</dd>
</dl>
<div class="ledbtns">
<button type="button" data-led="status" data-on="1">Green on</button>
<button type="button" data-led="status" data-on="0">Green off</button>
<button type="button" data-led="error" data-on="1">Red on</button>
<button type="button" data-led="error" data-on="0">Red off</button>
<button type="button" id="led-release">Hand back to card events</button>
</div>
<p class="ledmsg" id="led-msg">Switching here holds until you hand it back.</p>
</section>
</main>
<section class="logwrap">
<h2>Event log</h2>
<ol id="log"></ol>
<p id="log-none" class="empty">Waiting for card events&hellip;</p>
</section>
<footer>
Recent history from <code>GET /api/taps</code>, then live via
<code>GET /api/events</code> (SSE); status polled every 2s.
Read-only: this page never claims a tap. The API is documented in the README.
</footer>
<script>
(function () {
"use strict";
var REFRESH_MS = __REFRESH_MS__;
var MAX_ROWS = __MAX_ROWS__;
var el = function (id) { return document.getElementById(id); };
var tokenInput = el("token");
tokenInput.value = localStorage.getItem("rdm-token") || "";
function token() { return tokenInput.value.trim(); }
function authHeaders() {
var t = token();
return t ? { "X-Auth-Token": t } : {};
}
function setConn(up) {
var c = el("conn");
c.className = "pill " + (up ? "up" : "down");
c.textContent = up ? "live" : "disconnected";
}
function fmtDuration(seconds) {
if (seconds === null || seconds === undefined) return "\\u2014";
var s = Math.max(0, Math.round(seconds));
var h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), r = s % 60;
if (h) return h + "h " + m + "m";
if (m) return m + "m " + r + "s";
return r + "s";
}
// card_type is an integer (0x0F arrives as 15), so it must be re-encoded
// as hex rather than printed as-is.
function hex2(value) {
return "0x" + Number(value).toString(16).toUpperCase().padStart(2, "0");
}
function setYesNo(id, value) {
var node = el(id);
node.textContent = value ? "yes" : "no";
node.className = value ? "yes" : "no";
}
function setText(id, value) {
el(id).textContent = (value === null || value === undefined || value === "")
? "\\u2014" : String(value);
}
// ---- LEDs -----------------------------------------------------------
function ledMsg(text, isError) {
var node = el("led-msg");
node.textContent = text;
node.className = "ledmsg" + (isError ? " err" : "");
}
function renderLEDs(data) {
var st = data.status || {}, er = data.error || {};
var stState = !st.enabled ? "off (no pin)"
: (st.on ? "on" : "off") + (st.manual ? " (manual)" : "");
var erState = !er.enabled ? "off (no pin)"
: (er.on ? "on" : "off") + (er.manual ? " (manual)" : "");
setText("led-st-state", stState);
setText("led-er-state", erState);
setText("led-st-pin", st.pin === null || st.pin === undefined
? "\\u2014" : "GPIO " + st.pin + (st.active_high === false ? ", active-low" : ""));
setText("led-er-pin", er.pin === null || er.pin === undefined
? "\\u2014" : "GPIO " + er.pin + (er.active_high === false ? ", active-low" : ""));
setText("led-auto", data.manual ? "held (manual)" : "card events");
// Disable a button when its LED has no usable pin, so a dead control is
// visibly dead rather than silently doing nothing.
var buttons = document.querySelectorAll(".ledbtns button[data-led]");
Array.prototype.forEach.call(buttons, function (b) {
var name = b.getAttribute("data-led");
if (!name) { b.disabled = false; return; }
b.disabled = name === "status" ? !st.enabled : !er.enabled;
});
el("led-release").disabled = !data.manual;
}
function ledCommand(body, message) {
fetch("/api/led", {
method: "POST",
headers: Object.assign({ "Content-Type": "application/json" },
authHeaders()),
body: JSON.stringify(body)
}).then(function (r) {
return r.json().then(function (data) { return { ok: r.ok, status: r.status, data: data }; });
}).then(function (res) {
if (!res.ok) {
ledMsg(res.data && res.data.error ? res.data.error : "failed (" + res.status + ")", true);
if (res.data && res.data.state) renderLEDs(res.data.state);
return;
}
renderLEDs(res.data);
ledMsg(message);
}).catch(function () { ledMsg("request failed", true); });
}
document.querySelectorAll(".ledbtns button[data-led]").forEach(function (button) {
button.addEventListener("click", function () {
var name = button.getAttribute("data-led");
var on = button.getAttribute("data-on") === "1";
ledCommand({ led: name, on: on },
name + " LED forced " + (on ? "on" : "off") +
" - press \\"hand back\\" to restore");
});
});
el("led-release").addEventListener("click", function () {
ledCommand({ release: true }, "back under card-event control");
});
function pollLEDs() {
fetch("/api/led", { headers: authHeaders() })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (data) { if (data) renderLEDs(data); })
.catch(function () { /* the reader panel already reports failures */ });
}
function renderStatus(health, card) {
setText("mode", health.mode);
setText("port", health.port || "not open");
setText("uptime", fmtDuration(health.uptime_seconds));
setYesNo("present", !!health.card_present);
setYesNo("claimable", !!(card && card.claimable));
var counts = health.counts || {};
setText("n-inserted", counts.card_inserted || 0);
setText("n-removed", counts.card_removed || 0);
setText("n-invalid", counts.invalid_card || 0);
setText("n-subs", health.subscribers);
setText("n-ttl", (health.tap_ttl_seconds || "\\u221e") + " / " +
(health.claimed_ttl_seconds || "\\u221e"));
var shown = (card && card.card) || health.last_tap;
el("c-none").hidden = !!shown;
if (shown) {
setText("c-id", shown.id);
setText("c-hex", shown.hex);
setText("c-type", hex2(shown.type));
setText("c-valid", shown.is_valid ? "yes" : "no");
setText("c-age", fmtDuration(card ? card.age_seconds : health.last_tap_age_seconds));
}
}
var KIND = { card_inserted: "inserted", card_removed: "removed", invalid_card: "invalid" };
// Guards against showing the same event twice: recent history is seeded from
// /api/taps on load, and the first SSE frames can overlap with it.
var seen = Object.create(null);
function pushLog(event) {
var key = (event.timestamp || "") + "|" + (event.event || "") + "|" + event.id;
if (seen[key]) return;
seen[key] = true;
var list = el("log");
var li = document.createElement("li");
li.className = KIND[event.event] || "";
var ts = document.createElement("span");
ts.className = "ts";
ts.textContent = event.timestamp || "";
var name = document.createElement("span");
name.className = "ev";
name.textContent = (event.event || "").replace(/_/g, " ");
var detail = document.createElement("span");
detail.className = "detail";
if (event.event === "card_removed") {
detail.innerHTML = "id " + event.id;
} else {
detail.innerHTML = "id <b>" + event.id + "</b> &middot; " + event.hex +
" &middot; " + hex2(event.type) +
(event.is_valid ? "" : " &middot; checksum mismatch");
}
li.appendChild(ts); li.appendChild(name); li.appendChild(detail);
list.insertBefore(li, list.firstChild);
while (list.childElementCount > MAX_ROWS) list.removeChild(list.lastChild);
el("log-none").hidden = true;
}
function seedLog() {
fetch("/api/taps?limit=" + MAX_ROWS, { headers: authHeaders() })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (data) {
if (!data || !data.taps) return;
// taps() is chronological, oldest first; prepending leaves newest on top.
data.taps.forEach(pushLog);
})
.catch(function () { /* history is a convenience, not required */ });
}
function onError() {
setConn(false);
}
function poll() {
Promise.all([
fetch("/api/health", { headers: authHeaders() }),
fetch("/api/card", { headers: authHeaders() })
]).then(function (responses) {
if (responses[0].status === 401 || responses[1].status === 401) {
// The bridge wants a token for this caller. Say so instead of
// reporting the reader as disconnected.
el("conn").className = "pill down";
el("conn").textContent = "token required";
return null;
}
if (!responses[0].ok) throw new Error("health " + responses[0].status);
return Promise.all([responses[0].json(), responses[1].json()]);
}).then(function (data) {
if (!data) return;
renderStatus(data[0], data[1]);
if (es && es.readyState === 1) setConn(true);
}).catch(onError);
}
var es = null;
function connectEvents() { if (es) es.close();
var t = token();
// EventSource cannot set headers, so the token goes in the query string -
// the same mechanism the API already accepts for non-local callers.
var url = "/api/events" + (t ? "?token=" + encodeURIComponent(t) : "");
es = new EventSource(url);
es.onopen = function () { setConn(true); };
es.onmessage = function (message) {
try { pushLog(JSON.parse(message.data)); } catch (e) { /* ignore */ }
};
es.onerror = onError; // EventSource reconnects on its own
}
tokenInput.addEventListener("change", function () {
localStorage.setItem("rdm-token", token());
connectEvents();
seedLog();
poll();
pollLEDs();
});
connectEvents();
seedLog();
poll();
pollLEDs();
setInterval(poll, REFRESH_MS);
setInterval(pollLEDs, REFRESH_MS);
})();
</script>
</body>
</html>
"""
def render_page() -> bytes:
"""The dashboard as UTF-8 bytes, ready to write to the socket."""
page = (
PAGE_HTML
.replace("__REFRESH_MS__", str(REFRESH_MS))
.replace("__MAX_ROWS__", str(MAX_LOG_ROWS))
)
return page.encode("utf-8")
View File
-8
View File
@@ -1,8 +0,0 @@
import os
import sys
SRC = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "src"
)
if SRC not in sys.path:
sys.path.insert(0, SRC)
-201
View File
@@ -1,201 +0,0 @@
"""Tests for the dependency bootstrap.
The behaviour that matters: a normal start must not touch the network, a
missing library should be installed once, and a failed install must report
clearly instead of crashing.
"""
import os
import sys
import tempfile
SRC = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "src"
)
if SRC not in sys.path:
sys.path.insert(0, SRC)
import pytest # noqa: E402
from rdm_card_reader import bootstrap # noqa: E402
from rdm_card_reader.bootstrap import ( # noqa: E402
_pip_command,
ensure_dependencies,
install,
missing_packages,
)
class FakeCompleted:
def __init__(self, returncode=0, stdout="", stderr=""):
self.returncode = returncode
self.stdout = stdout
self.stderr = stderr
# -- detection --------------------------------------------------------------
def test_nothing_missing_for_installed_packages():
assert missing_packages([("sys", "sys")]) == []
def test_importable_name_is_reported_missing():
result = missing_packages([("definitely_not_a_real_module_xyz", "pkg>=1")])
assert result == [("definitely_not_a_real_module_xyz", "pkg>=1")]
# -- pip command ------------------------------------------------------------
def test_pip_command_uses_this_interpreter():
command = _pip_command(["pkg>=1"], wheel_dir=None)
assert command[:3] == [sys.executable, "-m", "pip"]
assert "pkg>=1" in command
def test_pip_command_prefers_local_wheels_when_given():
command = _pip_command(["pkg>=1"], wheel_dir="/opt/wheels")
assert "--no-index" in command
assert "--find-links" in command
assert "/opt/wheels" in command
def test_pip_command_adds_break_system_packages_outside_venv(monkeypatch):
"""Debian's pip refuses to touch system Python without this flag."""
monkeypatch.delenv("VIRTUAL_ENV", raising=False)
monkeypatch.setattr(bootstrap, "in_virtualenv", lambda: False)
assert "--break-system-packages" in _pip_command(["pkg"], None)
def test_pip_command_omits_flag_inside_venv(monkeypatch):
monkeypatch.setattr(bootstrap, "in_virtualenv", lambda: True)
assert "--break-system-packages" not in _pip_command(["pkg"], None)
def test_in_virtualenv_detects_env_var(monkeypatch):
monkeypatch.setenv("VIRTUAL_ENV", "/tmp/venv")
assert bootstrap.in_virtualenv() is True
# -- install ----------------------------------------------------------------
def test_install_reports_success(monkeypatch):
monkeypatch.setattr(
bootstrap.subprocess, "run", lambda *a, **k: FakeCompleted(0)
)
assert install(["pkg"]) is True
def test_install_returns_false_on_nonzero_exit(monkeypatch):
monkeypatch.setattr(
bootstrap.subprocess, "run",
lambda *a, **k: FakeCompleted(1, stderr="ERROR: no matching distribution"),
)
lines = []
assert install(["pkg"], logger=lines.append) is False
assert any("no matching distribution" in line for line in lines)
def test_install_survives_timeout(monkeypatch):
def boom(*args, **kwargs):
raise bootstrap.subprocess.TimeoutExpired(cmd="pip", timeout=1)
monkeypatch.setattr(bootstrap.subprocess, "run", boom)
assert install(["pkg"]) is False
def test_install_survives_pip_being_unrunnable(monkeypatch):
def boom(*args, **kwargs):
raise OSError("no such file or directory")
monkeypatch.setattr(bootstrap.subprocess, "run", boom)
assert install(["pkg"]) is False
# -- ensure_dependencies ----------------------------------------------------
def test_no_install_when_everything_present(monkeypatch):
"""A normal start must not shell out to pip at all."""
called = []
monkeypatch.setattr(bootstrap, "missing_packages", lambda packages=None: [])
monkeypatch.setattr(bootstrap, "install", lambda *a, **k: called.append(1))
assert ensure_dependencies() is True
assert called == []
def test_installs_when_missing(monkeypatch):
state = {"installed": False}
monkeypatch.setattr(
bootstrap, "missing_packages",
lambda packages=None: [] if state["installed"] else [("rdm6300", "rdm6300>=0.1.1")],
)
def fake_install(requirements, wheel_dir=None, logger=None, timeout=300, no_deps=False):
state["installed"] = True
return True
monkeypatch.setattr(bootstrap, "install", fake_install)
assert ensure_dependencies() is True
def test_reports_failure_when_still_missing(monkeypatch):
monkeypatch.setattr(
bootstrap, "missing_packages",
lambda packages=None: [("rdm6300", "rdm6300>=0.1.1")],
)
monkeypatch.setattr(bootstrap, "install", lambda *a, **k: False)
lines = []
assert ensure_dependencies(logger=lines.append) is False
assert any("Install manually with" in line for line in lines)
def test_no_install_flag_reports_manual_hint(monkeypatch):
monkeypatch.setattr(
bootstrap, "missing_packages",
lambda packages=None: [("rdm6300", "rdm6300>=0.1.1")],
)
called = []
monkeypatch.setattr(bootstrap, "install", lambda *a, **k: called.append(1))
lines = []
assert ensure_dependencies(auto_install=False, logger=lines.append) is False
assert called == [] # never shelled out
assert any("disabled" in line for line in lines)
def test_wheel_dir_is_passed_through(monkeypatch):
"""An explicit --wheel-dir that covers everything is used as given."""
seen = {}
monkeypatch.setattr(
bootstrap, "missing_packages",
lambda packages=None: [] if seen.get("done") else [("x", "x>=1")],
)
def fake_install(requirements, wheel_dir=None, logger=None, timeout=300, no_deps=False):
seen["wheel_dir"] = wheel_dir
seen["done"] = True
return True
monkeypatch.setattr(bootstrap, "install", fake_install)
with tempfile.TemporaryDirectory() as folder:
open(os.path.join(folder, "x-1.0-py3-none-any.whl"), "wb").close()
ensure_dependencies(wheel_dir=folder)
assert seen["wheel_dir"] == folder
def test_incomplete_explicit_wheel_dir_is_flagged(monkeypatch):
"""An unusable --wheel-dir should be called out, not silently ignored."""
seen = {}
monkeypatch.setattr(
bootstrap, "missing_packages",
lambda packages=None: [] if seen.get("done") else [("x", "x>=1")],
)
def fake_install(requirements, wheel_dir=None, logger=None, timeout=300, no_deps=False):
seen["done"] = True
return True
monkeypatch.setattr(bootstrap, "install", fake_install)
lines = []
with tempfile.TemporaryDirectory() as folder:
ensure_dependencies(wheel_dir=folder, logger=lines.append)
assert any("you gave is incomplete" in line for line in lines)
-501
View File
@@ -1,501 +0,0 @@
"""Tests for the HTTP bridge.
Two things matter most here:
1. The bridge publishes card events without claiming to authenticate anyone.
2. A page on another origin must not be able to read your taps.
"""
import json
import os
import sys
import threading
import time
import urllib.error
import urllib.request
SRC = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "src"
)
if SRC not in sys.path:
sys.path.insert(0, SRC)
import pytest # noqa: E402
from rdm_card_reader import bridge # noqa: E402
from rdm_card_reader.bridge import ( # noqa: E402
MODE_LOGIN,
MODE_MONITOR,
EventHub,
create_server,
)
from rdm_card_reader.card import CardEvent # noqa: E402
def make_event(event="card_inserted", value=12886197):
return CardEvent(
event=event,
timestamp="2026-10-01 12:00:00",
value=value,
checksum=222,
card_type=15,
is_valid=True,
port="/dev/ttyUSB0",
)
# -- hub --------------------------------------------------------------------
def test_hub_publishes_to_subscriber():
hub = EventHub()
queue = hub.subscribe()
hub.publish(make_event())
assert len(queue) == 1
assert queue[0]["id"] == 12886197
def test_hub_login_mode_hides_removals():
"""Removal must not look like a second authentication attempt."""
hub = EventHub(mode=MODE_LOGIN)
queue = hub.subscribe()
hub.publish(make_event("card_removed"))
assert len(queue) == 0
def test_hub_monitor_mode_publishes_removals():
hub = EventHub(mode=MODE_MONITOR)
queue = hub.subscribe()
hub.publish(make_event("card_removed"))
assert len(queue) == 1
def test_hub_unsubscribe_stops_delivery():
hub = EventHub()
queue = hub.subscribe()
hub.unsubscribe(queue)
hub.publish(make_event())
assert len(queue) == 0
def test_hub_tracks_last_tap_only_on_insert():
hub = EventHub(mode=MODE_MONITOR)
hub.publish(make_event("card_inserted", value=111))
hub.publish(make_event("card_removed", value=111))
assert hub.last()["id"] == 111
def test_hub_history_is_bounded():
hub = EventHub(history=3)
for value in range(10):
hub.publish(make_event(value=value))
assert len(hub.taps(limit=99)) == 3
def test_hub_health_reports_state():
hub = EventHub()
hub.set_port("/dev/ttyUSB0")
hub.publish(make_event())
health = hub.health()
assert health["port"] == "/dev/ttyUSB0"
assert health["counts"]["card_inserted"] == 1
assert health["last_tap"]["id"] == 12886197
assert health["last_tap_age_seconds"] is not None
def test_hub_rejects_unknown_mode():
hub = EventHub()
assert hub.set_mode("bogus") is False
assert hub.mode == MODE_LOGIN
def test_hub_accepts_valid_mode_switch():
hub = EventHub()
assert hub.set_mode(MODE_MONITOR) is True
assert hub.mode == MODE_MONITOR
# -- HTTP -------------------------------------------------------------------
@pytest.fixture
def server():
"""A live bridge on an ephemeral port, torn down after each test."""
hub = EventHub()
hub.set_port("/dev/ttyUSB0")
srv = create_server(hub, host="127.0.0.1", port=0)
thread = threading.Thread(target=srv.serve_forever, daemon=True)
thread.start()
yield srv, hub
srv.shutdown()
srv.server_close()
def base_url(srv):
host, port = srv.server_address[:2]
return "http://{}:{}".format(host, port)
def get(srv, path, headers=None):
request = urllib.request.Request(base_url(srv) + path, headers=headers or {})
with urllib.request.urlopen(request, timeout=5) as response:
return response.status, response.headers, response.read()
def test_health_endpoint(server):
srv, hub = server
status, _, body = get(srv, "/api/health")
assert status == 200
payload = json.loads(body)
assert payload["status"] == "ok"
assert payload["port"] == "/dev/ttyUSB0"
assert payload["mode"] == MODE_LOGIN
def test_last_returns_204_when_nothing_read(server):
srv, _ = server
status, _, _ = get(srv, "/api/last")
assert status == 204
def test_last_returns_the_tap(server):
srv, hub = server
hub.publish(make_event())
status, _, body = get(srv, "/api/last")
assert status == 200
assert json.loads(body)["id"] == 12886197
def test_taps_endpoint_honours_limit(server):
srv, hub = server
for value in range(5):
hub.publish(make_event(value=value))
_, _, body = get(srv, "/api/taps?limit=2")
assert len(json.loads(body)["taps"]) == 2
def test_unknown_path_is_404(server):
srv, _ = server
with pytest.raises(urllib.error.HTTPError) as excinfo:
get(srv, "/api/nope")
assert excinfo.value.code == 404
def test_health_available_without_origin(server):
"""Monitoring must work for a plain client with no Origin header."""
srv, _ = server
status, _, _ = get(srv, "/api/health")
assert status == 200
# -- origin policy ----------------------------------------------------------
def test_cross_origin_refused_when_no_allowlist(server):
"""A third-party page must not be able to read card ids."""
srv, _ = server
with pytest.raises(urllib.error.HTTPError) as excinfo:
get(srv, "/api/last", headers={"Origin": "https://evil.example"})
assert excinfo.value.code == 403
def test_allowlisted_origin_permitted_and_echoed():
hub = EventHub()
srv = create_server(hub, host="127.0.0.1", port=0,
allowed_origins=["https://kiosk.example"])
thread = threading.Thread(target=srv.serve_forever, daemon=True)
thread.start()
try:
hub.publish(make_event())
status, headers, body = get(
srv, "/api/last", headers={"Origin": "https://kiosk.example"}
)
assert status == 200
assert headers.get("Access-Control-Allow-Origin") == "https://kiosk.example"
assert json.loads(body)["id"] == 12886197
finally:
srv.shutdown()
srv.server_close()
def test_unlisted_origin_still_refused_when_allowlist_set():
hub = EventHub()
srv = create_server(hub, host="127.0.0.1", port=0,
allowed_origins=["https://kiosk.example"])
thread = threading.Thread(target=srv.serve_forever, daemon=True)
thread.start()
try:
with pytest.raises(urllib.error.HTTPError) as excinfo:
get(srv, "/api/last", headers={"Origin": "https://other.example"})
assert excinfo.value.code == 403
finally:
srv.shutdown()
srv.server_close()
def test_token_not_required_from_loopback():
hub = EventHub()
srv = create_server(hub, host="127.0.0.1", port=0, token="sekrit")
thread = threading.Thread(target=srv.serve_forever, daemon=True)
thread.start()
try:
status, _, _ = get(srv, "/api/health")
assert status == 200
finally:
srv.shutdown()
srv.server_close()
# -- mode switching ---------------------------------------------------------
def post(srv, path, payload, headers=None):
data = json.dumps(payload).encode()
request = urllib.request.Request(
base_url(srv) + path, data=data, method="POST",
headers={"Content-Type": "application/json", **(headers or {})},
)
with urllib.request.urlopen(request, timeout=5) as response:
return response.status, response.read()
def test_mode_can_be_switched(server):
srv, hub = server
status, body = post(srv, "/api/mode", {"mode": MODE_MONITOR})
assert status == 200
assert json.loads(body)["mode"] == MODE_MONITOR
assert hub.mode == MODE_MONITOR
def test_invalid_mode_rejected(server):
srv, _ = server
with pytest.raises(urllib.error.HTTPError) as excinfo:
post(srv, "/api/mode", {"mode": "nonsense"})
assert excinfo.value.code == 400
def test_malformed_json_rejected(server):
srv, _ = server
request = urllib.request.Request(
base_url(srv) + "/api/mode", data=b"{not json",
method="POST", headers={"Content-Type": "application/json"},
)
with pytest.raises(urllib.error.HTTPError) as excinfo:
urllib.request.urlopen(request, timeout=5)
assert excinfo.value.code == 400
# -- SSE --------------------------------------------------------------------
def test_sse_stream_delivers_a_tap(server):
"""The stream used by the web page must push a tap as it happens."""
srv, hub = server
received = []
def listen():
with urllib.request.urlopen(base_url(srv) + "/api/events", timeout=10) as resp:
for raw in resp:
line = raw.decode("utf-8").strip()
if line.startswith("data: "):
received.append(json.loads(line[6:]))
return
thread = threading.Thread(target=listen, daemon=True)
thread.start()
time.sleep(0.4) # let the subscription register
hub.publish(make_event())
thread.join(timeout=6)
assert received and received[0]["id"] == 12886197
def test_loopback_helper():
assert bridge.is_loopback("127.0.0.1") is True
assert bridge.is_loopback("0.0.0.0") is False
# -- latched card (the "always available" behaviour) ------------------------
def test_card_not_present_before_any_tap():
hub = EventHub()
state = hub.current_card()
assert state["present"] is False
assert state["card"] is None
def test_card_stays_available_after_the_page_loads():
"""The tap must outlive the page that reads it."""
hub = EventHub()
hub.publish(make_event(value=555))
for _ in range(3): # simulated later page loads
state = hub.current_card()
assert state["present"] is True
assert state["card"]["id"] == 555
def test_card_cleared_when_removed():
"""Lifting the card must NOT lose the tap - only mark it as gone."""
hub = EventHub(tap_ttl=30)
hub.publish(make_event())
hub.publish(make_event("card_removed"))
state = hub.current_card()
assert state["present"] is False # no longer physically on the reader
assert state["claimable"] is True # but still spendable
assert state["card"]["id"] == 12886197
def test_quick_tap_is_not_lost():
"""The real-world case: card lifted before the page polls."""
hub = EventHub(tap_ttl=10)
hub.publish(make_event(value=31337))
hub.publish(make_event("card_removed")) # lifted immediately
payload, error, _ = hub.consume_card()
assert error is None
assert payload["card"]["id"] == 31337
def test_claim_returns_the_card_once():
hub = EventHub()
hub.publish(make_event(value=777))
payload, error, status = hub.consume_card()
assert error is None and status == 200
assert payload["card"]["id"] == 777
assert payload["consumed"] is True
def test_claim_is_single_use():
"""A page reload must not turn one physical tap into two logins."""
hub = EventHub()
hub.publish(make_event())
assert hub.consume_card()[1] is None
payload, error, status = hub.consume_card()
assert payload is None
assert error == "card already used"
assert status == 409
def test_claim_without_a_card_is_conflict():
hub = EventHub()
payload, error, status = hub.consume_card()
assert payload is None
assert error == "no card read yet"
assert status == 409
def test_removing_and_replacing_the_card_allows_a_new_claim():
"""Lifting the card and tapping it again is a fresh authentication."""
hub = EventHub(tap_ttl=30)
hub.publish(make_event(value=111))
assert hub.consume_card()[1] is None
hub.publish(make_event("card_removed"))
hub.publish(make_event(value=111))
payload, error, _ = hub.consume_card()
assert error is None
assert payload["card"]["id"] == 111
def test_card_endpoint_reports_claimable_after_lift(server):
srv, hub = server
hub.publish(make_event(value=8080))
hub.publish(make_event("card_removed"))
_, _, body = get(srv, "/api/card")
payload = json.loads(body)
assert payload["present"] is False
assert payload["claimable"] is True
assert payload["card"]["id"] == 8080
def test_claim_expires_after_ttl():
hub = EventHub(tap_ttl=0.05)
hub.publish(make_event())
time.sleep(0.12)
payload, error, status = hub.consume_card()
assert payload is None
assert error == "card tap expired"
assert status == 410
def test_ttl_zero_never_expires_while_present():
hub = EventHub(tap_ttl=0)
hub.publish(make_event())
state = hub.current_card()
assert state["expired"] is False
assert state["claimable"] is True
def test_tap_becomes_unclaimable_after_ttl():
hub = EventHub(tap_ttl=0.05)
hub.publish(make_event())
time.sleep(0.12)
assert hub.current_card()["claimable"] is False
def test_a_new_tap_replaces_an_unclaimed_one():
hub = EventHub(tap_ttl=30)
hub.publish(make_event(value=111))
hub.publish(make_event(value=222))
payload, error, _ = hub.consume_card()
assert error is None
assert payload["card"]["id"] == 222
def test_health_shows_card_presence():
hub = EventHub()
hub.publish(make_event())
health = hub.health()
assert health["card_present"] is True
assert health["card_consumed"] is False
# -- latched card over HTTP -------------------------------------------------
def test_card_endpoint_reports_presence(server):
srv, hub = server
_, _, body = get(srv, "/api/card")
assert json.loads(body)["present"] is False
hub.publish(make_event(value=4242))
_, _, body = get(srv, "/api/card")
payload = json.loads(body)
assert payload["present"] is True
assert payload["card"]["id"] == 4242
def test_claim_endpoint_works_once(server):
srv, hub = server
hub.publish(make_event(value=9999))
status, body = post(srv, "/api/card/claim", {})
assert status == 200
assert json.loads(body)["card"]["id"] == 9999
with pytest.raises(urllib.error.HTTPError) as excinfo:
post(srv, "/api/card/claim", {})
assert excinfo.value.code == 409
detail = json.loads(excinfo.value.read())
assert detail["error"] == "card already used"
def test_claim_endpoint_without_card_conflicts(server):
srv, _ = server
with pytest.raises(urllib.error.HTTPError) as excinfo:
post(srv, "/api/card/claim", {})
assert excinfo.value.code == 409
def test_claim_refused_from_other_origin(server):
"""Another site must not be able to claim the card either."""
srv, hub = server
hub.publish(make_event())
with pytest.raises(urllib.error.HTTPError) as excinfo:
post(srv, "/api/card/claim", {},
headers={"Origin": "https://evil.example"})
assert excinfo.value.code == 403
# the tap must still be claimable by the real caller
assert hub.current_card()["consumed"] is False
def test_claim_does_not_consume_on_refused_origin(server):
srv, hub = server
hub.publish(make_event(value=1234))
with pytest.raises(urllib.error.HTTPError):
post(srv, "/api/card/claim", {},
headers={"Origin": "https://evil.example"})
payload, error, _ = hub.consume_card()
assert error is None
assert payload["card"]["id"] == 1234
-111
View File
@@ -1,111 +0,0 @@
"""The event model and its output formatting."""
import json
import pytest
from rdm_card_reader.card import (
CONFIG_CARD_VALUE,
CardEvent,
format_event,
)
SAMPLE = CardEvent(
event="card_inserted",
timestamp="2026-10-01 12:00:00",
value=0x00C4A0B5,
checksum=0xDE,
card_type=0x0F,
is_valid=True,
port="/dev/ttyS0",
)
def test_hex_value_is_zero_padded_to_eight_digits():
assert SAMPLE.hex_value == "0x00C4A0B5"
def test_from_card_accepts_any_object_with_the_right_fields():
class FakeCard:
value = 1234
checksum = 5
type = 15
is_valid = True
event = CardEvent.from_card("card_inserted", FakeCard(), "/dev/ttyUSB0")
assert event.value == 1234
assert event.hex_value == "0x000004D2"
assert event.port == "/dev/ttyUSB0"
def test_to_dict_has_the_documented_keys():
assert set(SAMPLE.to_dict()) == {
"event", "timestamp", "id", "hex", "type", "checksum", "is_valid", "port",
}
def test_to_json_is_parseable():
assert json.loads(SAMPLE.to_json())["hex"] == "0x00C4A0B5"
def test_inserted_block_lists_the_card_data():
text = format_event(SAMPLE)
assert "CARD INSERTED" in text
assert "12886197" in text
assert "0x00C4A0B5" in text
assert "0x0F" in text
assert "222" in text
assert "True" in text
def test_config_card_is_flagged_in_the_note():
event = CardEvent(
event="card_inserted",
timestamp="2026-10-01 12:00:00",
value=CONFIG_CARD_VALUE,
checksum=0,
card_type=0x0F,
is_valid=True,
port="/dev/ttyS0",
)
text = format_event(event)
assert "config card" in text
assert str(CONFIG_CARD_VALUE) in text
def test_ordinary_card_has_no_config_note():
assert "config card" not in format_event(SAMPLE)
def test_removed_line_is_single_line():
event = CardEvent(
event="card_removed",
timestamp="2026-10-01 12:00:00",
value=0x00C4A0B5,
checksum=0xDE,
card_type=0x0F,
is_valid=True,
port="/dev/ttyS0",
)
text = format_event(event)
assert text.count("\n") == 0
assert "CARD REMOVED" in text
def test_invalid_line_mentions_checksum():
event = CardEvent(
event="invalid_card",
timestamp="2026-10-01 12:00:00",
value=0xDEADBEEF,
checksum=0,
card_type=0x0F,
is_valid=False,
port="/dev/ttyS0",
)
assert "checksum mismatch" in format_event(event)
@pytest.mark.parametrize("value,expected", [(0, "0x00000000"), (0xFFFFFFFF, "0xFFFFFFFF")])
def test_hex_value_boundaries(value, expected):
event = CardEvent("card_inserted", "-", value, 0, 0x0F, True, "p")
assert event.hex_value == expected
-93
View File
@@ -1,93 +0,0 @@
"""CLI behaviour: exit codes, output modes and argument handling."""
import json
import pytest
from rdm_card_reader import cli
def test_list_ports_exits_zero(capsys):
assert cli.main(["--list-ports"]) == cli.EXIT_OK
out = capsys.readouterr().out
assert "/dev/ttyS0" in out
def test_demo_exits_zero(capsys):
assert cli.main(["--demo", "--demo-count", "1"]) == cli.EXIT_OK
assert "CARD INSERTED" in capsys.readouterr().out
def test_demo_json_emits_one_object_per_event(capsys):
cli.main(["--demo", "--demo-count", "1", "--json"])
lines = [line for line in capsys.readouterr().out.splitlines() if line.strip()]
events = [json.loads(line) for line in lines if line.startswith("{")]
assert events
assert {"event", "timestamp", "id", "hex"} <= set(events[0])
def test_demo_count_is_respected(capsys):
cli.main(["--demo", "--demo-count", "4"])
assert capsys.readouterr().out.count("CARD INSERTED") >= 1
def test_missing_library_exits_two(monkeypatch, capsys):
monkeypatch.setattr(cli, "available", lambda: False)
assert cli.main([]) == cli.EXIT_NO_LIBRARY
assert "rdm6300" in capsys.readouterr().err
def test_no_port_available_exits_one(monkeypatch, capsys):
monkeypatch.setattr(cli, "available", lambda: True)
monkeypatch.setattr(cli, "open_reader", lambda *a, **kw: None)
assert cli.main(["--port", "/dev/nope"]) == cli.EXIT_NO_PORT
assert "Could not open any serial port" in capsys.readouterr().err
def test_explicit_port_is_the_only_one_tried(monkeypatch):
monkeypatch.setattr(cli, "available", lambda: True)
tried = []
def fake_open(callback, ports=None, **kwargs):
tried.extend(ports or [])
return None
monkeypatch.setattr(cli, "open_reader", fake_open)
cli.main(["--port", "/dev/ttyUSB0"])
assert tried == ["/dev/ttyUSB0"]
def test_keyboard_interrupt_is_handled(monkeypatch, capsys):
monkeypatch.setattr(cli, "available", lambda: True)
class Reader:
port = "/dev/ttyS0"
def start(self):
raise KeyboardInterrupt
def close(self):
self.closed = True
reader = Reader()
monkeypatch.setattr(cli, "open_reader", lambda *a, **kw: reader)
assert cli.main([]) == cli.EXIT_OK
assert reader.closed is True
assert "Stopped by user" in capsys.readouterr().out
def test_version_flag_exits_zero(capsys):
with pytest.raises(SystemExit) as exc:
cli.main(["--version"])
assert exc.value.code == 0
assert "rdm-card-reader" in capsys.readouterr().out
def test_json_mode_prints_json_from_the_callback(capsys):
printer = cli._make_printer(as_json=True)
from rdm_card_reader.card import CardEvent
printer(CardEvent("card_inserted", "-", 1, 2, 15, True, "p"))
payload = json.loads(capsys.readouterr().out.strip())
assert payload["id"] == 1
-199
View File
@@ -1,199 +0,0 @@
"""Tests for the deploy artifacts.
These are the files that only ever run on the target device, so a mistake in
them is found late and painfully. The checks here are the ones that would have
caught real bugs: the install must not depend on the network, and the service
must not reference something the installer does not create.
"""
import os
import re
import stat
HERE = os.path.dirname(os.path.abspath(__file__))
PROJECT = os.path.dirname(HERE)
DEPLOY = os.path.join(PROJECT, "deploy")
INSTALL_SH = os.path.join(DEPLOY, "install.sh")
UNIT = os.path.join(DEPLOY, "rdm-card-reader.service")
RULES = os.path.join(DEPLOY, "99-rdm-reader.rules")
def read(path):
with open(path, "r") as handle:
return handle.read()
# -- install.sh -------------------------------------------------------------
def test_installer_exists_and_is_executable():
assert os.path.exists(INSTALL_SH)
mode = os.stat(INSTALL_SH).st_mode
assert mode & stat.S_IXUSR, "install.sh should be executable"
def test_installer_does_not_pip_install_the_project():
"""`pip install .` needs setuptools from the network, so it breaks offline.
This is the bug that made installation fail on an air-gapped Pi with
"Could not find a version that satisfies the requirement setuptools>=68".
"""
text = read(INSTALL_SH)
code = "\n".join(
line for line in text.splitlines() if not line.strip().startswith("#")
)
assert not re.search(r'pip"?\s+install[^\n]*"\$INSTALL_DIR"', code), (
"install.sh must not 'pip install' the project directory: it requires "
"downloading setuptools and fails with no network"
)
assert not re.search(r"pip\s+install\s+\.\s*$", code, re.M)
def test_installer_installs_runtime_deps_from_bundled_wheels():
text = read(INSTALL_SH)
assert "--no-index" in text
assert "--find-links" in text
assert "wheels" in text
def test_installer_adds_src_to_path():
"""Without this the package is not importable at all."""
text = read(INSTALL_SH)
assert ".pth" in text
def test_installer_network_steps_are_not_fatal():
"""A device with no internet must still get a working install."""
text = read(INSTALL_SH)
for line in text.splitlines():
stripped = line.strip()
if stripped.startswith("apt-get"):
assert "|| " in line or line.rstrip().endswith("\\"), (
"network-dependent step should not abort the install: " + stripped
)
# The pip self-upgrade is best-effort too. The fallback may sit on the
# following line (line continuation), so join continuations first.
joined = text.replace("\\\n", " ")
assert re.search(r"upgrade pip[^\n]*\|\|", joined), (
"'pip install --upgrade pip' needs the network, so it must not abort "
"the install; add '|| ...' to it"
)
def test_installer_copies_the_wheels_with_the_source():
"""The bundled wheels live under src/, so src/ must be copied."""
text = read(INSTALL_SH)
match = re.search(r"for item in ([^\n;]+);", text)
assert match, "could not find the copy list in install.sh"
assert "src" in match.group(1)
def test_installer_checks_venv_creation_can_fail_clearly():
text = read(INSTALL_SH)
assert "python3-venv" in text, "should mention the usual cause of venv failure"
def test_installer_verifies_the_install_before_enabling_service():
text = read(INSTALL_SH)
assert "--version" in text, "installer should sanity-check the install"
def test_installer_copies_launch_doc():
text = read(INSTALL_SH)
assert "LAUNCH.md" in text
# -- service unit -----------------------------------------------------------
def test_unit_exists():
assert os.path.exists(UNIT)
def test_unit_restarts_on_failure():
text = read(UNIT)
assert "Restart=always" in text
assert "RestartSec=" in text
def test_unit_runs_as_a_non_root_user_in_dialout():
text = read(UNIT)
assert re.search(r"^User=", text, re.M)
assert re.search(r"^Group=dialout", text, re.M)
def _sections(text):
"""Split an INI-style unit into {section: body}.
Splitting on the literal string "[Service]" is wrong: the file mentions it
in a comment above the real section header.
"""
sections = {}
current = None
for line in text.splitlines():
header = re.match(r"^\[([^\]]+)\]\s*$", line)
if header:
current = header.group(1)
sections[current] = []
elif current is not None:
sections[current].append(line)
return {name: "\n".join(body) for name, body in sections.items()}
def test_unit_start_limits_are_in_the_unit_section():
"""systemd silently ignores these in [Service]."""
sections = _sections(read(UNIT))
assert "Unit" in sections and "Service" in sections
for key in ("StartLimitIntervalSec", "StartLimitBurst"):
assert key not in sections["Service"], (
"{} belongs in [Unit]; systemd ignores it in [Service]".format(key)
)
assert "StartLimitBurst" in sections["Unit"]
def test_unit_has_the_expected_sections():
sections = _sections(read(UNIT))
assert set(sections) >= {"Unit", "Service", "Install"}
def test_unit_uses_loopback_by_default():
text = read(UNIT)
# No --host flag means the CLI default (127.0.0.1) applies.
assert "--host" not in text
def test_unit_serves_in_login_mode():
text = read(UNIT)
assert "--serve" in text
assert "--mode login" in text
def test_unit_points_at_the_stable_device_alias():
text = read(UNIT)
assert "/dev/rdm-reader" in text
# -- udev rule --------------------------------------------------------------
def test_udev_rule_creates_the_alias_the_unit_expects():
text = read(RULES)
assert 'SYMLINK+="rdm-reader"' in text
unit = read(UNIT)
assert "/dev/rdm-reader" in unit, "unit and rule must agree on the alias name"
def test_udev_rule_sets_group_and_mode():
text = read(RULES)
assert 'GROUP="dialout"' in text
assert 'MODE="0660"' in text
def test_udev_rule_only_has_one_active_match():
"""Multiple active rules would fight over the same alias."""
active = [
line
for line in read(RULES).splitlines()
if line.strip().startswith('SUBSYSTEM=="tty"')
]
assert len(active) == 1, "exactly one rule should be active, found {}".format(
len(active)
)
-177
View File
@@ -1,177 +0,0 @@
"""End-to-end test: a real tap must be claimable by a web page at any time.
Simulates the RDM6300 on a pseudo-terminal, starts the actual bridge the way
systemd would, then walks the exact flow a web page performs:
card placed -> page loads -> polls /api/card -> claims it -> hand over once
Also asserts the two things that matter for security: the tap survives until
claimed, and it cannot be claimed twice.
"""
import json
import os
import pty
import signal
import struct
import subprocess
import sys
import threading
import time
import urllib.error
import urllib.request
HERE = os.path.dirname(os.path.abspath(__file__))
PROJECT = os.path.dirname(HERE)
VENV_PY = os.path.join(PROJECT, ".venv", "bin", "python")
PYTHON = VENV_PY if os.path.exists(VENV_PY) else sys.executable
CARD_VALUE = 0x00C4A0B5
EXPECTED_ID = 12886197
def em4100_frame(value, card_type=0x0F):
"""12 hex nibbles: type + 4 id bytes + XOR checksum of those 5 bytes."""
id_bytes = struct.pack(">I", value)
checksum = card_type
for b in id_bytes:
checksum ^= b
frame = (bytes([card_type]) + id_bytes + bytes([checksum])).hex().upper()
assert len(frame) == 12, frame
return frame.encode("ascii")
def main():
master, slave = pty.openpty()
serial_port = os.ttyname(slave)
# Port 0 would be cleaner but the CLI needs a real number; pick a free one.
import socket
probe = socket.socket()
probe.bind(("127.0.0.1", 0))
http_port = probe.getsockname()[1]
probe.close()
proc = subprocess.Popen(
[PYTHON, "-m", "rdm_card_reader", "--serve",
"--port", serial_port, "--bridge-port", str(http_port),
"--mode", "login", "--tap-ttl", "15",
"--allow-origin", "https://kiosk.example"],
cwd=PROJECT, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
text=True, bufsize=1, start_new_session=True,
)
output = []
lock = threading.Lock()
def pump():
for line in proc.stdout:
with lock:
output.append(line.rstrip())
threading.Thread(target=pump, daemon=True).start()
base = "http://127.0.0.1:{}".format(http_port)
results = {}
def api(path, method="GET", headers=None):
request = urllib.request.Request(
base + path, method=method,
headers=headers or {},
data=b"" if method == "POST" else None,
)
with urllib.request.urlopen(request, timeout=5) as response:
body = response.read()
return response.status, (json.loads(body) if body else None)
try:
# Wait for the HTTP server to come up.
deadline = time.time() + 15
while time.time() < deadline:
try:
api("/api/health")
break
except Exception:
time.sleep(0.3)
results["bridge started"] = True
# The child needs a moment to open the serial port; frames written
# before that are discarded, which would look like a reader bug.
time.sleep(2.0)
# --- no card yet -------------------------------------------------
_, state = api("/api/card")
results["no card -> nothing claimable"] = state["claimable"] is False
# --- a single tap, as a person would -------------------------------
frame = em4100_frame(CARD_VALUE)
os.write(master, b"\x02" + frame + b"\x03")
time.sleep(0.3)
# --- a page loads later and polls; the card must still be there ----
time.sleep(1.0)
_, state = api("/api/card")
with lock:
print("state after tap: {}".format(json.dumps(state)), flush=True)
for line in output[-12:]:
print(" " + line)
results["card still available later"] = bool(
state["claimable"] and state["card"]["id"] == EXPECTED_ID
)
results["card not consumed yet"] = state["consumed"] is False
# --- claim it, as the web page does -------------------------------
status, claimed = api(
"/api/card/claim", method="POST",
headers={"Origin": "https://kiosk.example"},
)
results["claim returns 200"] = status == 200
results["claim gives the right card"] = claimed["card"]["id"] == EXPECTED_ID
# --- single use ---------------------------------------------------
try:
api("/api/card/claim", method="POST",
headers={"Origin": "https://kiosk.example"})
results["second claim refused"] = False
except urllib.error.HTTPError as exc:
results["second claim refused"] = exc.code == 409
# --- a foreign origin cannot grab it either ------------------------
_, state = api("/api/card")
results["card marked consumed"] = state["consumed"] is True
finally:
try:
os.killpg(os.getpgid(proc.pid), signal.SIGINT)
except Exception:
pass
try:
proc.wait(timeout=8)
except Exception:
proc.kill()
try:
os.close(master)
os.close(slave)
except Exception:
pass
with lock:
lines = list(output)
print("======== bridge output ========")
for line in lines[:14]:
print(line)
print("===============================")
print()
ok = True
for name, passed in results.items():
print(" [{}] {}".format("PASS" if passed else "FAIL", name))
ok = ok and bool(passed)
return 0 if ok else 1
if __name__ == "__main__":
sys.exit(main())
-209
View File
@@ -1,209 +0,0 @@
"""End-to-end: a real card read must drive the LED on and off.
Uses a pseudo-terminal for the reader and a fake pin for the LED, so the whole
chain runs — serial frame -> card event -> LED worker -> pin state — without a
Pi and without GPIO hardware.
"""
import os
import pty
import struct
import subprocess
import sys
import threading
import time
HERE = os.path.dirname(os.path.abspath(__file__))
PROJECT = os.path.dirname(HERE)
VENV_PY = os.path.join(PROJECT, ".venv", "bin", "python")
PYTHON = VENV_PY if os.path.exists(VENV_PY) else sys.executable
CARD_VALUE = 0x00C4A0B5
# Runs the app's own CLI with gpiozero's LED replaced by a recorder, so we can
# observe what the app would have done to the pin.
DRIVER = r'''
import os, sys, threading, time, json
sys.path.insert(0, os.environ["PROJECT_SRC"])
from rdm_card_reader import led as led_module
TRANSITIONS = []
_LOCK = threading.Lock()
class RecordingLED:
def __init__(self, pin=None, active_high=True):
self.pin = pin
self._state = False
def _record(self, value):
self._state = value
with _LOCK:
TRANSITIONS.append(value)
# Write through immediately: the test terminates this process, so
# anything buffered for the end would be lost.
sys.stderr.write("TRANSITION=" + value + "\n")
sys.stderr.flush()
def on(self):
self._record("on")
def off(self):
self._record("off")
def close(self):
pass
class FakeGPIOLED(led_module.GPIOLED):
def __init__(self, pin, active_high=True):
self.pin = pin
self._led = RecordingLED(pin, active_high)
self._is_on = False
def on(self):
self._led.on()
self._is_on = True
def off(self):
self._led.off()
self._is_on = False
def close(self):
self._led.close()
led_module.GPIOLED = FakeGPIOLED
from rdm_card_reader.cli import main
code = main(["--port", os.environ["SERIAL_PORT"], "--heartbeat", "0.3",
"--led-mode", os.environ.get("LED_MODE", "error"), "--json"])
sys.exit(code)
'''
def em4100_frame(value, card_type=0x0F):
id_bytes = struct.pack(">I", value)
checksum = card_type
for b in id_bytes:
checksum ^= b
frame = (bytes([card_type]) + id_bytes + bytes([checksum])).hex().upper()
assert len(frame) == 12, frame
return frame.encode("ascii")
def pump(stream, sink):
"""Read a stream on its own thread.
Text-mode pipes buffer, so select() would not report this data.
"""
for line in stream:
sink.append(line.rstrip())
def run_case(mode):
"""Drive a card through the app and return (events, led transitions)."""
master, slave = pty.openpty()
serial_port = os.ttyname(slave)
env = dict(os.environ)
env["SERIAL_PORT"] = serial_port
env["PROJECT_SRC"] = os.path.join(PROJECT, "src")
env["LED_MODE"] = mode
env.pop("PYTHONPATH", None)
proc = subprocess.Popen(
[PYTHON, "-u", "-c", DRIVER],
cwd=PROJECT, env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
text=True, bufsize=1,
)
stdout, stderr = [], []
t1 = threading.Thread(target=pump, args=(proc.stdout, stdout), daemon=True)
t2 = threading.Thread(target=pump, args=(proc.stderr, stderr), daemon=True)
t1.start()
t2.start()
frame = em4100_frame(CARD_VALUE)
try:
time.sleep(2.0) # let the reader open the port
for _ in range(6): # card present
os.write(master, b"\x02" + frame + b"\x03")
time.sleep(0.1)
time.sleep(1.2) # stop writing -> card removed
finally:
try:
proc.terminate()
except Exception:
pass
try:
proc.wait(timeout=5)
except Exception:
proc.kill()
for t in (t1, t2):
t.join(timeout=3)
try:
os.close(master)
os.close(slave)
except Exception:
pass
transitions = [
line.split("=", 1)[1]
for line in stderr
if line.startswith("TRANSITION=")
]
return stdout, stderr, transitions
def main():
failures = 0
# --- error mode: lit while running, dark while a card is read ----------
stdout, stderr, transitions = run_case("error")
print("======== error mode ========")
for line in stdout[:2]:
print(" " + line)
print(" events: {}".format(
[l.split('"event": "')[1].split('"')[0]
for l in stdout if '"event"' in l]))
print(" transitions: {}".format(transitions))
checks = {
"card was read": any("card_inserted" in l for l in stdout),
"card removal seen": any("card_removed" in l for l in stdout),
"lit at startup": transitions[:1] == ["on"],
"dark while reading": "off" in transitions,
"lit again after": transitions[-1:] == ["on"],
}
# --- presence mode: dark until a card arrives -------------------------
stdout2, stderr2, transitions2 = run_case("presence")
print("\n======== presence mode ========")
print(" transitions: {}".format(transitions2))
checks.update({
"presence: read still works":
any("card_inserted" in l for l in stdout2),
"presence: dark at startup": transitions2[:1] == ["on"] or not transitions2,
"presence: lit on the card": "on" in transitions2,
"presence: dark after": transitions2[-1:] == ["off"],
})
print()
ok = True
for name, passed in checks.items():
print(" [{}] {}".format("PASS" if passed else "FAIL", name))
ok = ok and bool(passed)
if not ok:
failures = 1
print("\n--- driver stderr (error mode) ---")
print("\n".join(stderr)[:1200])
return failures
if __name__ == "__main__":
sys.exit(main())
-165
View File
@@ -1,165 +0,0 @@
"""End-to-end test over a pseudo-terminal, with no RFID hardware.
The test owns the master side of a PTY, points the CLI at the slave side and
writes real RDM6300 frames. This exercises the same code path a physical
reader would: serial open -> byte stream -> parse -> callback -> stdout.
"""
import json
import os
import pty
import signal
import subprocess
import sys
import threading
import time
import pytest
from rdm_card_reader.card import CONFIG_CARD_VALUE
from .test_protocol import encode_stream, encode_frame # noqa: F401
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
SRC = os.path.join(ROOT, "src")
TIMEOUT = 15.0
class ReaderProcess:
"""Run the CLI against a PTY and collect its stdout in the background."""
def __init__(self, extra_args=()):
self.master, slave = pty.openpty()
self.port = os.ttyname(slave)
env = dict(os.environ)
env["PYTHONPATH"] = SRC + os.pathsep + env.get("PYTHONPATH", "")
self.proc = subprocess.Popen(
[sys.executable, "-u", "-m", "rdm_card_reader",
"--port", self.port, "--heartbeat", "0.3", *extra_args],
cwd=ROOT, env=env, stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, text=True, bufsize=1,
start_new_session=True,
)
self.lines = []
self._lock = threading.Lock()
self._thread = threading.Thread(target=self._pump, daemon=True)
self._thread.start()
def _pump(self):
for line in self.proc.stdout:
with self._lock:
self.lines.append(line.rstrip())
def output(self):
with self._lock:
return "\n".join(self.lines)
def send(self, frame):
os.write(self.master, b"\x02" + frame + b"\x03")
def wait_for(self, needle, timeout=TIMEOUT):
deadline = time.time() + timeout
while time.time() < deadline:
if needle in self.output():
return True
time.sleep(0.05)
return False
def __enter__(self):
self.wait_for("waiting for cards")
return self
def __exit__(self, *exc):
try:
os.killpg(os.getpgid(self.proc.pid), signal.SIGINT)
except Exception:
pass
try:
self.proc.wait(timeout=5)
except Exception:
self.proc.kill()
self._thread.join(timeout=3)
for fd in (self.master, self.proc.stdout):
try:
fd.close()
except Exception:
pass
return False
@pytest.mark.skipif(sys.platform != "linux", reason="PTY test is Linux only")
def test_card_insert_and_remove_are_reported():
card_id = 0x00C4A0B5
frame = encode_frame(card_id)
with ReaderProcess() as rp:
deadline = time.time() + TIMEOUT
while time.time() < deadline and not rp.wait_for("CARD INSERTED", 0.2):
rp.send(frame)
assert rp.wait_for("CARD INSERTED"), rp.output()
assert rp.wait_for("CARD REMOVED")
text = rp.output()
assert rp.port in text
assert str(card_id) in text
assert "0x00C4A0B5" in text
assert "222" in text # checksum = 0xDE
assert "0x0F" in text # type
assert "True" in text # valid
@pytest.mark.skipif(sys.platform != "linux", reason="PTY test is Linux only")
def test_corrupt_checksum_is_reported_as_invalid():
good = bytearray(encode_frame(0x1A2B3C4D))
good[-1] = ord("0") # break the checksum
bad = bytes(good)
with ReaderProcess() as rp:
deadline = time.time() + TIMEOUT
while time.time() < deadline and not rp.wait_for("INVALID CARD", 0.2):
rp.send(bad)
assert "INVALID CARD" in rp.output()
assert "checksum mismatch" in rp.output()
@pytest.mark.skipif(sys.platform != "linux", reason="PTY test is Linux only")
def test_json_mode_emits_machine_readable_events():
with ReaderProcess(extra_args=("--json",)) as rp:
deadline = time.time() + TIMEOUT
while time.time() < deadline and not rp.wait_for('"event"', 0.2):
rp.send(encode_frame(0x00C4A0B5))
payloads = [
json.loads(line)
for line in rp.output().splitlines()
if line.strip().startswith("{")
]
assert payloads, rp.output()
assert any(p["event"] == "card_inserted" for p in payloads)
assert any(p["id"] == 0x00C4A0B5 for p in payloads)
assert all(p["port"] == rp.port for p in payloads)
@pytest.mark.skipif(sys.platform != "linux", reason="PTY test is Linux only")
def test_config_card_is_annotated():
with ReaderProcess() as rp:
deadline = time.time() + TIMEOUT
while time.time() < deadline and not rp.wait_for("config card", 0.2):
rp.send(encode_frame(CONFIG_CARD_VALUE))
assert "config card" in rp.output()
@pytest.mark.skipif(sys.platform != "linux", reason="PTY test is Linux only")
def test_removal_after_heartbeat_expires():
with ReaderProcess() as rp:
deadline = time.time() + TIMEOUT
while time.time() < deadline and not rp.wait_for("CARD INSERTED", 0.2):
rp.send(encode_frame(0x00C4A0B5))
# stop sending and let the 0.3 s heartbeat lapse
assert rp.wait_for("CARD REMOVED", timeout=5)
assert "CARD REMOVED" in rp.output()
-306
View File
@@ -1,306 +0,0 @@
"""Tests for the status LED.
Two things matter: the LED must never be able to break the reader, and it must
behave the way the workstation app did (on with the card, off without). Real
GPIO is unavailable here, so a fake pin is injected to test the logic.
"""
import os
import sys
import threading
import time
SRC = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "src"
)
if SRC not in sys.path:
sys.path.insert(0, SRC)
import pytest # noqa: E402
from rdm_card_reader import led as led_module # noqa: E402
from rdm_card_reader.led import ( # noqa: E402
BLINK,
DEFAULT_LED_PIN,
ERROR,
OFF,
ON,
PRESENCE,
NullLED,
StatusLED,
create,
)
class FakeLED:
"""Stands in for gpiozero.LED, recording what it was told to do."""
def __init__(self, pin, active_high=True):
self.pin = pin
self.active_high = active_high
self.state = False
self.transitions = []
self.closed = False
def on(self):
self.state = True
self.transitions.append("on")
def off(self):
self.state = False
self.transitions.append("off")
def close(self):
self.closed = True
@pytest.fixture
def fake_gpio(monkeypatch):
"""Make StatusLED build a FakeLED instead of touching GPIO."""
created = {}
class FakeGPIOLED(led_module.GPIOLED):
def __init__(self, pin, active_high=True):
self.pin = pin
self._led = FakeLED(pin, active_high)
self._is_on = False
created["led"] = self._led
def on(self):
self._led.on()
self._is_on = True
def off(self):
self._led.off()
self._is_on = False
def close(self):
self._led.close()
monkeypatch.setattr(led_module, "GPIOLED", FakeGPIOLED)
return created
def wait_for_state(led_obj, expected, timeout=3.0):
"""Wait for the worker thread to apply a command."""
deadline = time.time() + timeout
while time.time() < deadline:
if led_obj.state is expected:
return True
time.sleep(0.01)
return False
# -- defaults ---------------------------------------------------------------
def test_default_pin_matches_the_workstation_app():
assert DEFAULT_LED_PIN == 23
# -- graceful degradation ---------------------------------------------------
def test_no_gpio_gives_a_working_no_op(monkeypatch):
"""On a machine without GPIO the LED must be inert, not fatal."""
def boom(pin, active_high=True):
raise RuntimeError("Unable to load any default pin factory!")
monkeypatch.setattr(led_module, "GPIOLED", boom)
status = StatusLED(pin=23)
assert status.status.enabled is False
assert status.status.backend == "null"
# Every hook must still be safe to call.
status.insert()
status.remove()
status.error()
status.close()
def test_missing_gpiozero_is_reported_not_raised(monkeypatch):
"""gpiozero absent: say so, and do not raise."""
import builtins
real_import = builtins.__import__
def blocked(name, *args, **kwargs):
if name == "gpiozero":
raise ImportError("No module named 'gpiozero'")
return real_import(name, *args, **kwargs)
monkeypatch.setattr(builtins, "__import__", blocked)
status = StatusLED(pin=23)
assert status.status.enabled is False
assert "not installed" in status.status.reason
def test_no_pin_factory_is_not_reported_as_missing_library(monkeypatch):
"""BadPinFactory subclasses ImportError, so the two must be distinguished.
Reporting 'gpiozero is not installed' when it is installed but has no
usable pin library would send you to the wrong fix.
"""
class FakeBadPinFactory(ImportError):
pass
def boom(pin, active_high=True):
raise FakeBadPinFactory(
"Unable to load any default pin factory!"
)
monkeypatch.setattr(led_module, "GPIOLED", boom)
status = StatusLED(pin=23)
assert status.status.enabled is False
assert "not installed" not in status.status.reason
assert "pin factory" in status.status.reason
def test_disabled_led_is_inert(fake_gpio):
status = StatusLED(pin=23, enabled=False)
assert status.status.enabled is False
assert isinstance(status.led, NullLED)
status.insert()
time.sleep(0.05)
assert "led" not in fake_gpio
def test_null_led_methods_are_safe():
null = NullLED(23, "no gpio")
null.on()
null.off()
null.close()
# -- behaviour --------------------------------------------------------------
def test_error_mode_lights_up_at_startup(fake_gpio):
"""A health indicator should show 'alive' without waiting for a card."""
status = StatusLED(pin=23, mode=ERROR)
assert fake_gpio["led"].state is True
status.close()
def test_error_mode_blinks_on_a_bad_read(fake_gpio):
status = StatusLED(pin=23, mode=ERROR, error_blink=True)
status.error()
time.sleep(0.25)
assert len(fake_gpio["led"].transitions) >= 2
status.close()
def test_error_mode_settles_back_after_a_blink(fake_gpio):
status = StatusLED(pin=23, mode=ERROR, error_blink=True)
status.error()
time.sleep(0.25)
status.remove() # any steady command cancels blinking
assert wait_for_state(fake_gpio["led"], True)
status.close()
def test_error_mode_without_blink_stays_lit(fake_gpio):
status = StatusLED(pin=23, mode=ERROR, error_blink=False)
status.error()
assert wait_for_state(fake_gpio["led"], True)
status.close()
def test_presence_mode_is_dark_until_a_card_arrives(fake_gpio):
status = StatusLED(pin=23, mode=PRESENCE)
assert fake_gpio["led"].state is False
status.insert()
assert wait_for_state(fake_gpio["led"], True)
status.remove()
assert wait_for_state(fake_gpio["led"], False)
status.close()
def test_close_turns_the_led_off(fake_gpio):
status = StatusLED(pin=23, mode=ERROR)
assert fake_gpio["led"].state is True
status.close()
assert fake_gpio["led"].closed is True
assert fake_gpio["led"].state is False
def test_pin_and_active_high_are_passed_through(fake_gpio):
status = StatusLED(pin=17, active_high=False)
assert fake_gpio["led"].pin == 17
assert fake_gpio["led"].active_high is False
status.close()
# -- never blocks or crashes ------------------------------------------------
def test_insert_does_not_block(monkeypatch):
"""The reader thread calls this, so it must return immediately."""
class SlowLED(led_module.GPIOLED):
def __init__(self, pin, active_high=True):
self.pin = pin
self._is_on = False
def on(self):
time.sleep(0.5) # deliberately slow hardware
def off(self):
pass
def close(self):
pass
monkeypatch.setattr(led_module, "GPIOLED", SlowLED)
# mode=presence so the startup light-up does not pay the slow 'on' cost
status = StatusLED(pin=23, mode=PRESENCE)
start = time.time()
for _ in range(20):
status.insert()
elapsed = time.time() - start
assert elapsed < 0.2, "insert() blocked for {:.2f}s".format(elapsed)
status.close()
def test_failing_toggle_does_not_raise(fake_gpio, monkeypatch):
"""A dead pin must not take the reader down."""
status = StatusLED(pin=23)
assert fake_gpio["led"] is not None
def boom():
raise OSError("pin went away")
monkeypatch.setattr(status.led, "on", boom)
status.insert()
time.sleep(0.15) # worker swallows the error
status.close() # and close() still works
def test_hardware_error_is_reported_to_the_logger(monkeypatch):
lines = []
def boom(pin, active_high=True):
raise RuntimeError("no pin factory")
monkeypatch.setattr(led_module, "GPIOLED", boom)
StatusLED(pin=23, logger=lines.append)
assert any("LED unavailable" in line for line in lines)
# -- factory ----------------------------------------------------------------
def test_create_defaults_to_error_mode_on_the_workstation_pin(fake_gpio):
status = create()
assert status.status.enabled is True
assert status.status.pin == DEFAULT_LED_PIN
assert status.status.mode == ERROR
status.close()
def test_create_disabled_returns_a_no_op():
status = create(enabled=False)
assert status.status.enabled is False
status.close()
def test_status_dict_is_json_serialisable(fake_gpio):
import json
status = create()
payload = json.dumps(status.status.to_dict())
assert "gpiozero" in payload
status.close()
-228
View File
@@ -1,228 +0,0 @@
"""Tests for reliable serial-port identification.
The point of these tests is that picking the wrong ttyUSB silently is the
failure mode that matters, so ambiguity must raise rather than guess.
"""
import os
import sys
SRC = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "src"
)
if SRC not in sys.path:
sys.path.insert(0, SRC)
import pytest # noqa: E402
from rdm_card_reader import ports # noqa: E402
from rdm_card_reader.ports import ( # noqa: E402
AmbiguousPort,
PortInfo,
PortNotFound,
best_usb_candidates,
resolve,
)
def make_port(device, vid=None, pid=None, serial=None, description="",
aliases=None, manufacturer=None, product=None):
return PortInfo(
device=device,
vid=vid,
pid=pid,
serial_number=serial,
description=description,
aliases=list(aliases or []),
manufacturer=manufacturer,
product=product,
)
@pytest.fixture
def two_adapters(monkeypatch):
"""Two identical CH340 adapters plus an on-board UART."""
a = make_port("/dev/ttyUSB0", 0x1A86, 0x7523, "CH340A",
"USB Serial", ["/dev/serial/by-id/usb-1a86_A-if00-port0"])
b = make_port("/dev/ttyUSB1", 0x1A86, 0x7523, "CH340B",
"USB Serial", ["/dev/serial/by-id/usb-1a86_B-if00-port0"])
onboard = make_port("/dev/ttyS0", description="ttyS0")
monkeypatch.setattr(ports, "discover", lambda: [a, b, onboard])
return a, b, onboard
# -- identity ---------------------------------------------------------------
def test_usb_id_and_chip_name():
port = make_port("/dev/ttyUSB0", 0x1A86, 0x7523)
assert port.usb_id == "1a86:7523"
assert port.chip == "CH340"
assert port.is_usb is True
def test_unknown_chip_still_identified_as_usb():
port = make_port("/dev/ttyUSB0", 0xFFFF, 0x0001)
assert port.chip is None
assert port.is_usb is True
def test_stable_id_prefers_by_id_symlink():
port = make_port(
"/dev/ttyUSB0",
aliases=["/dev/serial/by-path/pci-1-usb-0:1.2:1.0-port0",
"/dev/serial/by-id/usb-1a86_USB-if00-port0"],
)
assert port.stable_id == "/dev/serial/by-id/usb-1a86_USB-if00-port0"
assert port.by_path.endswith("port0")
def test_onboard_uart_is_not_usb():
assert make_port("/dev/ttyS0").is_usb is False
# -- scoring ----------------------------------------------------------------
def test_usb_adapter_scores_above_onboard_uart():
adapter = make_port("/dev/ttyUSB0", 0x1A86, 0x7523, "SN1")
onboard = make_port("/dev/ttyS0")
assert adapter.score() > onboard.score()
def test_serial_number_raises_score():
with_sn = make_port("/dev/ttyUSB0", 0x1A86, 0x7523, "SN1")
without = make_port("/dev/ttyUSB0", 0x1A86, 0x7523, None)
assert with_sn.score() > without.score()
# -- matching ---------------------------------------------------------------
def test_match_by_device_path():
assert make_port("/dev/ttyUSB0").matches("/dev/ttyUSB0")
def test_match_by_serial_number():
port = make_port("/dev/ttyUSB0", 0x1A86, 0x7523, "CH340A")
assert port.matches("CH340A")
def test_match_by_vid_pid():
port = make_port("/dev/ttyUSB0", 0x1A86, 0x7523)
assert port.matches("1a86:7523")
def test_match_by_chip_name():
assert make_port("/dev/ttyUSB0", 0x1A86, 0x7523).matches("ch340")
def test_match_by_id_symlink():
port = make_port("/dev/ttyUSB0",
aliases=["/dev/serial/by-id/usb-1a86_A-if00-port0"])
assert port.matches("/dev/serial/by-id/usb-1a86_A-if00-port0")
def test_empty_selector_never_matches():
assert not make_port("/dev/ttyUSB0").matches("")
# -- resolution -------------------------------------------------------------
def test_resolve_by_serial_number_picks_right_adapter(two_adapters):
device, reason = resolve(selectors=["CH340B"])
assert device == "/dev/ttyUSB1"
assert "matched" in reason
def test_resolve_by_by_id_path(two_adapters):
device, _ = resolve(selectors=["/dev/serial/by-id/usb-1a86_B-if00-port0"])
assert device == "/dev/ttyUSB1"
def test_resolve_refuses_when_selector_matches_both(two_adapters):
"""A vid:pid shared by two adapters must not silently pick one."""
with pytest.raises(AmbiguousPort):
resolve(selectors=["1a86:7523"])
def test_resolve_unknown_selector_raises_not_found(two_adapters):
with pytest.raises(PortNotFound):
resolve(selectors=["NOSUCHDEVICE"])
def test_resolve_falls_back_to_raw_path_when_not_enumerable(monkeypatch, tmp_path):
fake = tmp_path / "myport"
fake.write_text("")
monkeypatch.setattr(ports, "discover", lambda: [])
device, reason = resolve(selectors=[str(fake)])
assert device == str(fake)
assert "explicit path" in reason
def test_autodetect_requires_exactly_one(monkeypatch):
single = make_port("/dev/ttyUSB3", 0x1A86, 0x7523, "ONLY")
monkeypatch.setattr(ports, "discover", lambda: [single, make_port("/dev/ttyS0")])
device, reason = resolve(autodetect=True)
assert device == "/dev/ttyUSB3"
assert "autodetected" in reason
def test_autodetect_refuses_with_two_adapters(two_adapters):
with pytest.raises(AmbiguousPort):
resolve(autodetect=True)
def test_autodetect_ignores_onboard_uart(monkeypatch):
"""An on-board UART must not make a single USB adapter look ambiguous."""
adapter = make_port("/dev/ttyUSB0", 0x1A86, 0x7523, "ONLY")
monkeypatch.setattr(
ports, "discover",
lambda: [adapter, make_port("/dev/ttyS0"), make_port("/dev/ttyAMA0")],
)
device, _ = resolve(autodetect=True)
assert device == "/dev/ttyUSB0"
def test_fallback_order_used_when_no_usb(monkeypatch):
"""Must not depend on the host actually having a serial port."""
monkeypatch.setattr(ports, "discover", lambda: [])
monkeypatch.setattr(ports.os.path, "exists", lambda path: path == "/dev/ttyS0")
device, reason = resolve(fallback_ports=["/dev/ttyS0", "/dev/ttyUSB0"])
assert device == "/dev/ttyS0"
assert "default order" in reason
def test_fallback_order_skips_missing_ports(monkeypatch):
monkeypatch.setattr(ports, "discover", lambda: [])
monkeypatch.setattr(
ports.os.path, "exists", lambda path: path == "/dev/ttyUSB0"
)
device, _ = resolve(fallback_ports=["/dev/ttyS0", "/dev/ttyUSB0"])
assert device == "/dev/ttyUSB0"
def test_no_port_at_all_raises(monkeypatch):
monkeypatch.setattr(ports, "discover", lambda: [])
with pytest.raises(PortNotFound):
resolve(fallback_ports=[])
# -- helper ----------------------------------------------------------------
def test_best_usb_candidates_dedupes_same_identity():
a = make_port("/dev/ttyUSB0", 0x1A86, 0x7523, "SAME")
dup = make_port("/dev/ttyUSB1", 0x1A86, 0x7523, "SAME")
result = best_usb_candidates([a, dup])
assert len(result) == 1
def test_best_usb_candidates_keeps_distinct_serial_numbers():
a = make_port("/dev/ttyUSB0", 0x1A86, 0x7523, "A")
b = make_port("/dev/ttyUSB1", 0x1A86, 0x7523, "B")
assert len(best_usb_candidates([a, b])) == 2
def test_describe_is_human_readable():
port = make_port("/dev/ttyUSB0", 0x1A86, 0x7523, "ABC", "USB Serial")
text = port.describe()
assert "/dev/ttyUSB0" in text
assert "CH340" in text
assert "ABC" in text
-108
View File
@@ -1,108 +0,0 @@
"""RDM6300 wire format: type + id + checksum, 12 hex nibbles per frame.
The checksum is the XOR of the type byte *and* the four id bytes. Getting this
wrong (e.g. XOR-ing only the id) makes every card read fail its checksum, so
the exact bytes are pinned here.
"""
import struct
import pytest
from rdm_card_reader.card import CardData
START = 0x02
END = 0x03
def encode_frame(value, card_type=0x0F):
"""Encode a card as the 12 nibbles the RDM6300 emits between STX and ETX."""
id_bytes = struct.pack(">I", value)
checksum = card_type
for byte in id_bytes:
checksum ^= byte
frame = bytes([card_type]) + id_bytes + bytes([checksum])
encoded = frame.hex().upper().encode("ascii")
assert len(encoded) == 12
return encoded
def encode_stream(value, card_type=0x0F):
return bytes([START]) + encode_frame(value, card_type) + bytes([END])
def decode_frame(frame):
"""Reference decoder mirroring rdm6300.BaseReader._parse_fragment."""
nibbles = [int(ch, 16) for ch in frame.decode("ascii")]
def to_int(chunk):
out = 0
for item in chunk:
out = (out << 4) | item
return out
calculated = 0
for i in range(0, 10, 2):
calculated ^= (nibbles[i] << 4) | nibbles[i + 1]
received = to_int(nibbles[10:12])
return CardData(
value=to_int(nibbles[2:10]),
checksum=received,
is_valid=received == calculated,
type=to_int(nibbles[0:2]),
)
@pytest.mark.parametrize(
"value,expected",
[
(0x00C4A0B5, b"0F00C4A0B5DE"),
(0x1A2B3C4D, b"0F1A2B3C4D4F"),
],
)
def test_encoded_frame_bytes_are_stable(value, expected):
assert encode_frame(value) == expected
def test_frame_is_twelve_nibbles():
assert len(encode_frame(0x00C4A0B5)) == 12
def test_checksum_covers_type_byte_not_only_id():
"""Same id with a different type byte must change the checksum."""
assert encode_frame(0x00C4A0B5, 0x0F) != encode_frame(0x00C4A0B5, 0x10)
def test_round_trip_yields_valid_card():
card = decode_frame(encode_frame(0x00C4A0B5))
assert card.value == 0x00C4A0B5
assert card.type == 0x0F
assert card.checksum == 0xDE
assert card.is_valid is True
def test_round_trip_of_second_sample():
card = decode_frame(encode_frame(0x1A2B3C4D))
assert card.value == 0x1A2B3C4D
assert card.is_valid is True
def test_corrupted_checksum_is_invalid():
frame = bytearray(encode_frame(0x00C4A0B5))
frame[-1] = ord("0")
assert decode_frame(bytes(frame)).is_valid is False
def test_frame_with_wrong_nibble_count_is_rejected():
"""A 14 nibble frame leaves 2 nibbles buffered in the real reader."""
assert len(encode_frame(0x00C4A0B5)) == 12
fourteen = b"0F00C4A0B5DE00"
assert len(fourteen) == 14
def test_stream_wrapper_uses_stx_and_etx():
stream = encode_stream(0x00C4A0B5)
assert stream[0] == START
assert stream[-1] == END
assert stream[1:-1] == encode_frame(0x00C4A0B5)
-149
View File
@@ -1,149 +0,0 @@
"""Port selection and error explanation, using a fake reader library."""
import types
import pytest
import rdm_card_reader.reader as reader_module
from rdm_card_reader.card import CANDIDATE_PORTS
from rdm_card_reader.reader import explain_error, list_ports, open_reader
class FakeBaseReader:
"""Stands in for rdm6300.BaseReader; records the port it was built with."""
fail_on = ()
def __init__(self, port, heartbeat_interval=0.5):
if port in type(self).fail_on:
raise OSError("could not open port {}".format(port))
self.port = port
self.heartbeat_interval = heartbeat_interval
self.started = False
self.closed = False
def start(self):
self.started = True
def close(self):
self.closed = True
@pytest.fixture
def fake_library(monkeypatch):
module = types.SimpleNamespace(BaseReader=FakeBaseReader)
monkeypatch.setattr(reader_module, "rdm6300", module)
return module
@pytest.fixture(autouse=True)
def restore_library():
original = reader_module.rdm6300
yield
reader_module.rdm6300 = original
def test_default_port_order_matches_the_workstation_app():
assert CANDIDATE_PORTS == (
"/dev/ttyS0", "/dev/ttyAMA0", "/dev/ttyUSB0", "/dev/ttyACM0",
)
def test_first_working_port_wins(fake_library):
FakeBaseReader.fail_on = ("/dev/ttyS0", "/dev/ttyAMA0")
reader = open_reader(lambda event: None,
ports=["/dev/ttyS0", "/dev/ttyAMA0", "/dev/ttyUSB0"])
assert reader is not None
assert reader.port == "/dev/ttyUSB0"
def test_returns_none_when_no_port_works(fake_library):
FakeBaseReader.fail_on = ("/dev/ttyS0", "/dev/ttyAMA0")
assert open_reader(lambda event: None,
ports=["/dev/ttyS0", "/dev/ttyAMA0"]) is None
def test_failures_are_reported_with_the_port(fake_library):
FakeBaseReader.fail_on = ("/dev/ttyS0",)
seen = []
open_reader(lambda event: None, ports=["/dev/ttyS0"],
on_error=lambda port, message: seen.append((port, message)))
assert seen and seen[0][0] == "/dev/ttyS0"
def test_heartbeat_interval_is_passed_through(fake_library):
FakeBaseReader.fail_on = ()
reader = open_reader(lambda event: None, ports=["/dev/ttyS0"],
heartbeat_interval=1.5)
assert reader.heartbeat_interval == 1.5
def test_heartbeat_none_disables_removal_detection(fake_library):
FakeBaseReader.fail_on = ()
reader = open_reader(lambda event: None, ports=["/dev/ttyS0"],
heartbeat_interval=None)
assert reader.heartbeat_interval is None
def test_events_are_forwarded_to_the_callback(fake_library):
FakeBaseReader.fail_on = ()
received = []
reader = open_reader(received.append, ports=["/dev/ttyS0"])
class Card:
value = 7
checksum = 1
type = 15
is_valid = True
reader.card_inserted(Card())
assert len(received) == 1
assert received[0].value == 7
assert received[0].event == "card_inserted"
def test_card_removed_with_none_is_ignored(fake_library):
FakeBaseReader.fail_on = ()
received = []
reader = open_reader(received.append, ports=["/dev/ttyS0"])
reader.card_removed(None)
assert received == []
def test_invalid_card_is_forwarded(fake_library):
FakeBaseReader.fail_on = ()
received = []
reader = open_reader(received.append, ports=["/dev/ttyS0"])
class Card:
value = 1
checksum = 2
type = 15
is_valid = False
reader.invalid_card(Card())
assert received[0].event == "invalid_card"
def test_missing_library_raises(monkeypatch):
monkeypatch.setattr(reader_module, "rdm6300", None)
with pytest.raises(reader_module.ReaderUnavailable):
open_reader(lambda event: None)
@pytest.mark.parametrize(
"message,expected_fragment",
[
("[Errno 13] Permission denied: '/dev/ttyS0'", "dialout"),
("could not open port /dev/nope", "device not found"),
("something else", "something else"),
],
)
def test_explain_error(message, expected_fragment):
assert expected_fragment in explain_error("/dev/ttyS0", message)
def test_list_ports_shape():
info = list_ports()
assert set(info) == {"candidates", "detected"}
assert [port for port, _ in info["candidates"]] == list(CANDIDATE_PORTS)
-229
View File
@@ -1,229 +0,0 @@
"""Tests for the vendored wheels and their verification.
The bundled wheels let a device with no internet set itself up, so the things
that matter are: they are actually present and pure-Python (portable), the
checksums match, and a tampered wheel is refused rather than installed.
"""
import os
import shutil
import sys
SRC = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "src"
)
if SRC not in sys.path:
sys.path.insert(0, SRC)
import pytest # noqa: E402
from rdm_card_reader import bootstrap # noqa: E402
from rdm_card_reader.bootstrap import ( # noqa: E402
bundled_wheel_dir,
ensure_dependencies,
local_wheel_missing,
verify_wheels,
)
# -- the wheels that ship with the package ---------------------------------
def test_bundled_wheel_dir_exists():
folder = bundled_wheel_dir()
assert folder is not None
assert os.path.isdir(folder)
def test_both_required_wheels_are_present():
folder = bundled_wheel_dir()
names = os.listdir(folder)
assert any(n.startswith("rdm6300-") and n.endswith(".whl") for n in names)
assert any(n.startswith("pyserial-") and n.endswith(".whl") for n in names)
def test_bundled_wheels_are_pure_python():
"""A compiled wheel would only work on one architecture; these must not."""
folder = bundled_wheel_dir()
wheels = [n for n in os.listdir(folder) if n.endswith(".whl")]
assert wheels
for name in wheels:
assert name.endswith("-none-any.whl"), (
"{} is architecture-specific and cannot be shipped".format(name)
)
def test_bundled_wheels_verify_against_their_checksums():
folder = bundled_wheel_dir()
assert verify_wheels(folder) == []
def test_checksums_file_is_shipped():
folder = bundled_wheel_dir()
assert os.path.exists(os.path.join(folder, "SHA256SUMS"))
def test_total_vendored_size_is_reasonable():
"""Guard against someone dropping a huge binary into the repo."""
folder = bundled_wheel_dir()
total = sum(
os.path.getsize(os.path.join(folder, name))
for name in os.listdir(folder)
)
assert total < 2 * 1024 * 1024
# -- verification -----------------------------------------------------------
def test_tampered_wheel_is_detected(tmp_path):
folder = tmp_path / "wheels"
folder.mkdir()
wheel = folder / "rdm6300-0.1.1-py3-none-any.whl"
wheel.write_bytes(b"not the real wheel")
(folder / "SHA256SUMS").write_text(
"afb067da8b8d23a477138ca67d4ff1d198f12d157b64668c9dd3cbd7df54e74e"
" rdm6300-0.1.1-py3-none-any.whl\n"
)
problems = verify_wheels(str(folder))
assert problems
assert any("checksum mismatch" in p for p in problems)
def test_missing_wheel_is_reported(tmp_path):
folder = tmp_path / "wheels"
folder.mkdir()
(folder / "SHA256SUMS").write_text(
"abc123 rdm6300-0.1.1-py3-none-any.whl\n"
)
problems = verify_wheels(str(folder))
assert any("missing" in p for p in problems)
def test_no_sums_file_means_nothing_to_check(tmp_path):
folder = tmp_path / "wheels"
folder.mkdir()
(folder / "x-1.0-py3-none-any.whl").write_bytes(b"x")
assert verify_wheels(str(folder)) == []
def test_verification_passes_for_untouched_copy(tmp_path):
source = bundled_wheel_dir()
dest = tmp_path / "wheels"
shutil.copytree(source, dest)
assert verify_wheels(str(dest)) == []
# -- coverage check ---------------------------------------------------------
def test_local_wheel_missing_detects_absent_requirement(tmp_path):
folder = tmp_path / "wheels"
folder.mkdir()
assert local_wheel_missing(["rdm6300>=0.1.1"], str(folder)) == ["rdm6300>=0.1.1"]
def test_local_wheel_missing_accepts_present_requirement():
folder = bundled_wheel_dir()
assert local_wheel_missing(["rdm6300>=0.1.1", "pyserial>=3.5"], folder) == []
def test_local_wheel_missing_normalises_underscores(tmp_path):
folder = tmp_path / "wheels"
folder.mkdir()
(folder / "charset_normalizer-3.4.1-py3-none-any.whl").write_bytes(b"x")
assert local_wheel_missing(["charset-normalizer>=3"], str(folder)) == []
# -- integration into ensure_dependencies -----------------------------------
def test_bundled_wheels_used_automatically(monkeypatch):
"""No --wheel-dir given, so the bundled ones must be picked up."""
seen = {}
monkeypatch.setattr(
bootstrap, "missing_packages",
lambda packages=None: [] if seen.get("done") else [("rdm6300", "rdm6300>=0.1.1")],
)
def fake_install(requirements, wheel_dir=None, logger=None, timeout=300, no_deps=False):
seen["wheel_dir"] = wheel_dir
seen["done"] = True
return True
monkeypatch.setattr(bootstrap, "install", fake_install)
lines = []
assert ensure_dependencies(logger=lines.append) is True
assert seen["wheel_dir"] == bundled_wheel_dir()
assert any("bundled" in line for line in lines)
def test_tampered_bundle_falls_back_to_pypi(monkeypatch, tmp_path):
"""A bad checksum must not block installation - but must be reported."""
folder = tmp_path / "wheels"
folder.mkdir()
(folder / "rdm6300-0.1.1-py3-none-any.whl").write_bytes(b"tampered")
(folder / "SHA256SUMS").write_text("deadbeef rdm6300-0.1.1-py3-none-any.whl\n")
seen = {}
monkeypatch.setattr(bootstrap, "bundled_wheel_dir", lambda: str(folder))
monkeypatch.setattr(
bootstrap, "missing_packages",
lambda packages=None: [] if seen.get("done") else [("rdm6300", "rdm6300>=0.1.1")],
)
def fake_install(requirements, wheel_dir=None, logger=None, timeout=300, no_deps=False):
seen["wheel_dir"] = wheel_dir
seen["done"] = True
return True
monkeypatch.setattr(bootstrap, "install", fake_install)
lines = []
assert ensure_dependencies(logger=lines.append) is True
assert seen["wheel_dir"] is None # fell back to PyPI
assert any("checksum mismatch" in line for line in lines)
def test_explicit_wheel_dir_overrides_bundled(monkeypatch, tmp_path):
folder = tmp_path / "wheels"
folder.mkdir()
(folder / "rdm6300-0.1.1-py3-none-any.whl").write_bytes(b"x")
(folder / "pyserial-3.5-py2.py3-none-any.whl").write_bytes(b"x")
seen = {}
monkeypatch.setattr(
bootstrap, "missing_packages",
lambda packages=None: [] if seen.get("done") else [("rdm6300", "rdm6300>=0.1.1")],
)
def fake_install(requirements, wheel_dir=None, logger=None, timeout=300, no_deps=False):
seen["wheel_dir"] = wheel_dir
seen["done"] = True
return True
monkeypatch.setattr(bootstrap, "install", fake_install)
ensure_dependencies(wheel_dir=str(folder))
assert seen["wheel_dir"] == str(folder)
def test_bundle_not_covering_a_requirement_falls_back(monkeypatch, tmp_path):
"""A bundle missing one package must not cause a guaranteed pip failure."""
folder = tmp_path / "wheels"
folder.mkdir()
(folder / "rdm6300-0.1.1-py3-none-any.whl").write_bytes(b"x")
seen = {}
monkeypatch.setattr(bootstrap, "bundled_wheel_dir", lambda: str(folder))
monkeypatch.setattr(
bootstrap, "missing_packages",
lambda packages=None: [] if seen.get("done")
else [("rdm6300", "rdm6300>=0.1.1"), ("serial", "pyserial>=3.5")],
)
def fake_install(requirements, wheel_dir=None, logger=None, timeout=300, no_deps=False):
seen["wheel_dir"] = wheel_dir
seen["wheel_dir_at_call"] = wheel_dir
seen["done"] = True
return True
monkeypatch.setattr(bootstrap, "install", fake_install)
lines = []
ensure_dependencies(logger=lines.append)
assert seen["wheel_dir"] is None
assert any("do not cover" in line for line in lines)