Compare commits

..

13 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
ske087 c0a354b98e Add keyboard-wedge typing and LED event support
- keyboard.py: emulate a commercial USB RFID reader by typing card ids
  into the focused window via a /dev/uinput virtual keyboard
- led.py / bridge.py: drive the status LED from card events
- cli.py, card.py: wire up the new options and event flow
- deploy: harden install.sh and the systemd unit
- README: document keyboard mode, LED support, and deployment

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-05 21:15:00 +03:00
ske087 48e800adc1 Initializare dupa curatare folder 2026-10-05 11:46:46 +03:00
34 changed files with 1977 additions and 2883 deletions
+4 -3
View File
@@ -5,8 +5,9 @@ build/
dist/ dist/
.venv/ .venv/
venv/ venv/
.pytest_cache/
.coverage
htmlcov/
.idea/ .idea/
.vscode/ .vscode/
# Release build outputs: generated from the source tree, not source
rdm-card-reader-release/
rdm-card-reader-release-*.zip
+21 -13
View File
@@ -9,18 +9,28 @@ expected and correct.** To see the app work without hardware, use `--demo`:
.venv/bin/python -m rdm_card_reader --demo .venv/bin/python -m rdm_card_reader --demo
``` ```
To see the reader path run end-to-end without hardware, the test suite drives a To see the reader path end-to-end without hardware, drive a pseudo-terminal
pseudo-terminal pretending to be an RDM6300: pretending to be an RDM6300 — see **Testing the reader path without a reader**
below.
```bash
.venv/bin/python -m pytest tests/ -q # 152 tests, includes e2e PTY tests
```
--- ---
## Quick start ## Quick start
All commands run from the project root. The virtualenv is already built. Build the virtualenv once, from the wheels bundled in the source tree — no
network needed:
```bash
python3 -m venv .venv
.venv/bin/pip install --no-index \
--find-links src/rdm_card_reader/wheels rdm6300 pyserial
echo "$PWD/src" > "$(.venv/bin/python -c \
'import sysconfig; print(sysconfig.get_paths()["purelib"])')/rdm_card_reader_src.pth"
```
For the status LED, add `gpiozero colorzero` to the same `pip install` line.
Then, from the project root:
```bash ```bash
.venv/bin/python -m rdm_card_reader --demo # simulate a card, no hardware .venv/bin/python -m rdm_card_reader --demo # simulate a card, no hardware
@@ -29,9 +39,9 @@ All commands run from the project root. The virtualenv is already built.
.venv/bin/python -m rdm_card_reader --serve # HTTP bridge for a web page .venv/bin/python -m rdm_card_reader --serve # HTTP bridge for a web page
``` ```
After `pip install .` the same thing is available as `rdm-card-reader …`. That is the whole install: the runtime libraries come from the bundled wheels
`pip install .` needs the network — see **Troubleshooting** below for the and `src/` is on the path via the `.pth` file. `pip install .` also works, but
offline route. only with network — it builds the project. See **Troubleshooting** below.
--- ---
@@ -330,9 +340,7 @@ somewhere else, but `src/` must be present or the wheels will be missing.
## Testing the reader path without a reader ## Testing the reader path without a reader
Because the app talks plain serial, a pseudo-terminal is enough to exercise the Because the app talks plain serial, a pseudo-terminal is enough to exercise the
whole chain. `tests/test_e2e_pty.py` writes real RDM6300 frames into a PTY and whole chain: write real RDM6300 frames into a PTY and watch the printed events.
asserts on the printed events; `tests/test_e2e_bridge.py` does the same and then
claims the tap over HTTP exactly as a web page would.
To hand-feed a single frame yourself: To hand-feed a single frame yourself:
Binary file not shown.
+732 -52
View File
@@ -1,9 +1,13 @@
# rdm-card-reader # rdm-card-reader
Read 125 kHz RFID cards (RDM6300 reader, EM4100 cards) on a serial port and Read 125 kHz RFID cards (RDM6300 reader, EM4100 cards) on a serial port. Two
print what happens: **card inserted** with the card data, then **card removed**. ways to use it:
Built for a Raspberry Pi workstation, but it runs anywhere with a serial port. * **print events** — card inserted with the card data, then card removed
* **serve an API** (`--serve`) — expose the card on the reader over HTTP so a
web app can authenticate a card tap
Built for a Raspberry Pi, but it runs anywhere with a serial port.
> Starting it on a machine with no reader attached? See **[LAUNCH.md](LAUNCH.md)** > Starting it on a machine with no reader attached? See **[LAUNCH.md](LAUNCH.md)**
> — it explains the startup sequence, why it exits `1` with no hardware, and how > — it explains the startup sequence, why it exits `1` with no hardware, and how
@@ -11,6 +15,21 @@ Built for a Raspberry Pi workstation, but it runs anywhere with a serial port.
--- ---
## Contents
- [What it does](#what-it-does)
- [Install](#install)
- [Usage](#usage)
- [Serving card data to a web app (`--serve`)](#serving-card-data-to-a-web-app---serve)
- [Quick start on a Raspberry Pi](#quick-start-on-a-raspberry-pi)
- [Status LED](#status-led)
- [Wiring](#wiring)
- [The protocol](#the-protocol)
- [Troubleshooting](#troubleshooting)
- [Check it works](#check-it-works)
---
## What it does ## What it does
``` ```
@@ -99,7 +118,8 @@ wheels on first start.
```bash ```bash
rdm-card-reader # auto-detect the serial port rdm-card-reader # auto-detect the serial port
rdm-card-reader --port /dev/ttyUSB0 # use a specific port rdm-card-reader --port /dev/serial0 # the Pi's on-board UART
rdm-card-reader --port /dev/ttyUSB0 # a USB adapter
rdm-card-reader --json # one JSON object per event rdm-card-reader --json # one JSON object per event
rdm-card-reader --heartbeat 1.5 # slower card-removed detection rdm-card-reader --heartbeat 1.5 # slower card-removed detection
rdm-card-reader --list-ports # show which ports exist, then exit rdm-card-reader --list-ports # show which ports exist, then exit
@@ -113,7 +133,7 @@ Also works as a module: `python -m rdm_card_reader`.
| --- | --- | --- | | --- | --- | --- |
| `--port` | auto-detect | Serial port: device path, by-id symlink, serial number or `vid:pid` | | `--port` | auto-detect | Serial port: device path, by-id symlink, serial number or `vid:pid` |
| `--autodetect` | off | Pick the only USB adapter; refuse if there is more than one | | `--autodetect` | off | Pick the only USB adapter; refuse if there is more than one |
| `--heartbeat` | `0.5` | Seconds without a read before the card counts as removed | | `--heartbeat` | `2.0` | Seconds without a read before the card counts as removed. Also the presence/LED window |
| `--json` | off | Machine-readable output instead of the text block | | `--json` | off | Machine-readable output instead of the text block |
| `--list-ports` | – | List candidate and identified ports, then exit | | `--list-ports` | – | List candidate and identified ports, then exit |
| `--verbose-ports` | off | With `--list-ports`, show by-id / by-path detail | | `--verbose-ports` | off | With `--list-ports`, show by-id / by-path detail |
@@ -123,15 +143,24 @@ Also works as a module: `python -m rdm_card_reader`.
| `--wheel-dir` | bundled | Install from local wheels in this DIR instead of the bundled ones | | `--wheel-dir` | bundled | Install from local wheels in this DIR instead of the bundled ones |
| `--version` | – | Print the version | | `--version` | – | Print the version |
With `--serve`: `--host` (`127.0.0.1`), `--bridge-port` (`8765`), `--mode` With `--serve`:
(`login`), `--tap-ttl` (`10`), `--allow-origin` (repeatable), `--token`,
`--allow-remote`, `--verbose`.
When `--port` is omitted the ports are tried in this order, the same order the | Option | Default | Meaning |
workstation app uses: | --- | --- | --- |
| `--host` | `127.0.0.1` | Bind address; anything else needs `--allow-remote` |
| `--bridge-port` | `8765` | HTTP port |
| `--mode` | `login` | `login` publishes taps only; `monitor` also publishes removals |
| `--tap-ttl` | `10` | Seconds an unclaimed tap stays claimable; `0` keeps it until replaced. Keep it ≥ `--heartbeat` |
| `--claimed-ttl` | `30` | Seconds a claimed card stays readable; `0` keeps it indefinitely |
| `--allow-origin` | – | Permit this browser origin (repeatable) |
| `--token` | – | Pre-shared token for non-local callers (or `RDM_BRIDGE_TOKEN`) |
| `--allow-remote` | off | Permit binding a non-loopback address |
| `--verbose` | off | Also print each event and each HTTP request |
When `--port` is omitted the ports are tried in this order:
``` ```
/dev/ttyS0 -> /dev/ttyAMA0 -> /dev/ttyUSB0 -> /dev/ttyACM0 /dev/ttyS0 -> /dev/ttyAMA0 -> /dev/serial0 -> /dev/ttyUSB0 -> /dev/ttyACM0
``` ```
### Output modes ### Output modes
@@ -204,7 +233,7 @@ This is the mode for **authenticating a web page**: the reader runs as a service
on the device, and the page asks it for the card that was just tapped. on the device, and the page asks it for the card that was just tapped.
```bash ```bash
rdm-card-reader --serve --port /dev/rdm-reader --allow-origin https://your.web.app rdm-card-reader --serve --port /dev/serial0 --allow-origin https://your.web.app
``` ```
The bridge reports **card ids only**. Deciding which user a card belongs to is The bridge reports **card ids only**. Deciding which user a card belongs to is
@@ -222,9 +251,9 @@ sequenceDiagram
C->>B: tap C->>B: tap
B->>B: latch the tap (tap-ttl seconds) B->>B: latch the tap (tap-ttl seconds)
K->>B: GET /api/card K->>B: GET /api/card
B-->>K: {claimable: true, card: {id: ...}} B-->>K: {present: true, claimable: true, card: {id: ...}}
K->>B: POST /api/card/claim K->>B: POST /api/card/claim
B-->>K: {card: {id: ...}} (once) B-->>K: {card: {id: ...}, consumed: true} (once, then 409)
K->>W: POST /login {card_id} K->>W: POST /login {card_id}
W->>W: card_id -> user W->>W: card_id -> user
W-->>K: session W-->>K: session
@@ -234,26 +263,111 @@ The point of the *latch* is that the tap stays available for `--tap-ttl`
seconds (default 10), **including after the card is lifted**. A quick tap seconds (default 10), **including after the card is lifted**. A quick tap
therefore cannot be lost because the page happened to poll a moment later. therefore cannot be lost because the page happened to poll a moment later.
`present` and `card` answer different questions, which is the part that
surprises people:
| Field | Meaning |
| --- | --- |
| `present` | the card is physically on the reader **right now** — goes `false` the moment it is lifted |
| `card` | the **latched tap** — survives the card being lifted, so you still know which card it was |
So a card that was tapped and lifted a second ago reports `present: false` with
`card` still populated. That is correct, not a bug: if the id vanished with the
card, any poll arriving after the lift could not authenticate anything.
The point of *claim* is single use. Once a tap is spent, a page reload or a The point of *claim* is single use. Once a tap is spent, a page reload or a
second tab gets `409 Conflict` instead of a second login, so one physical tap second tab gets `409 Conflict` instead of a second login, so one physical tap
can never authenticate two sessions. can never authenticate two sessions.
Two independent windows govern how long the card stays readable:
| Window | Flag | Default | Governs |
| --- | --- | --- | --- |
| Unclaimed | `--tap-ttl` | 10 s | how long a tap can still be spent on a login |
| Claimed | `--claimed-ttl` | 30 s | how long a claimed card stays readable, so the page that claimed it can confirm which card logged in |
Put together, `/api/card` moves through these states:
| Moment | `present` | `card` | `claimable` | `consumed` |
| --- | --- | --- | --- | --- |
| Card held on the reader | `true` | id | `true` | `false` |
| Lifted, within `--tap-ttl` | `false` | id | `true` | `false` |
| Claimed, within `--claimed-ttl` | `false` | id | `false` | `true` |
| After the claimed window | `false` | `null` | `false` | `false` |
| Unclaimed and past `--tap-ttl` | `false` | `null` | `false` | `false` |
The last two rows are the point: an id that can no longer be acted on is
cleared, so it cannot be mistaken for a tap still waiting to be used.
`/api/last` and `/api/taps` keep the history either way. Setting either value to
`0` disables that expiry.
> **Set `--tap-ttl` at least as long as `--heartbeat`, or longer.**
> `--tap-ttl` applies to the tap, `--heartbeat` to presence, and they are
> independent. If the tap expires *before* the heartbeat declares the card
> removed, a card still sitting on the reader has its id cleared and reads as
> `"card": null` until the next re-read — which on a reader that reports once
> 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 ### Endpoints
| Method | Path | Purpose | | Method | Path | Purpose |
| --- | --- | --- | | --- | --- | --- |
| `GET` | `/` | Status page (unless `--no-page`) |
| `GET` | `/api/card` | The latched tap: `present`, `claimable`, `consumed`, `age_seconds`, `card` | | `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 | | `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 | | `GET` | `/api/events` | Server-Sent Events stream, pushes each event as it happens |
| `GET` | `/api/last` | Most recent card event, or `204` | | `GET` | `/api/last` | Most recent card event, or `204` |
| `GET` | `/api/taps` | Recent tap history (`?limit=N`) | | `GET` | `/api/taps` | Recent tap history (`?limit=N`) |
| `GET` | `/api/health` | Reader state, port, uptime, tap counts — for monitoring | | `GET` | `/api/health` | Reader state, port, uptime, tap counts, both TTLs — for monitoring |
| `POST` | `/api/mode` | `{"mode": "login"}` or `{"mode": "monitor"}` | | `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 `login` mode (default) publishes taps only — a tap is an authentication
attempt. `monitor` mode also publishes removals, which is what you want for attempt. `monitor` mode also publishes removals, which is what you want for
watching a reader rather than logging people in. watching a reader rather than logging people in.
`/api/health` is the liveness check: `status: "ok"` with a rising
`uptime_seconds` means the service is serving. It is answered **before** the
origin and token checks, so it stays readable by any client that can reach the
port — which is the intent on loopback, but means binding with `--allow-remote`
exposes the port, mode, uptime and tap counts to the network.
### Browser example ### Browser example
```js ```js
@@ -271,6 +385,15 @@ if (state.claimable) {
} }
``` ```
Already claimed, and you just want to know *which* card it was? The id stays
readable for `--claimed-ttl` (default 30 s) after the claim, with
`consumed: true`:
```js
const { card, consumed } = await (await fetch("http://127.0.0.1:8765/api/card")).json();
if (consumed && card) console.log("that tap was card", card.id);
```
Or push instead of poll, so a tap logs in immediately: Or push instead of poll, so a tap logs in immediately:
```js ```js
@@ -290,7 +413,8 @@ they do not make the card itself convincing as a sole credential.
| Origins | Cross-origin browser requests are **refused** unless `--allow-origin` lists that exact origin. Stops another site from reading your taps. | | Origins | Cross-origin browser requests are **refused** unless `--allow-origin` lists that exact origin. Stops another site from reading your taps. |
| Token | `--token` (or `RDM_BRIDGE_TOKEN`) is required from non-loopback callers. | | Token | `--token` (or `RDM_BRIDGE_TOKEN`) is required from non-loopback callers. |
| Single use | A tap can only ever produce one successful claim. | | Single use | A tap can only ever produce one successful claim. |
| Expiry | A tap older than `--tap-ttl` is refused with `410`. | | Expiry | An unclaimed tap older than `--tap-ttl` is refused with `410`. |
| Bounded retention | A claimed tap stays readable for `--claimed-ttl` (30 s), then the id is cleared — an id that can no longer be acted on is never left in the response. |
Add a second factor — a PIN after the tap — wherever a cloned card would Add a second factor — a PIN after the tap — wherever a cloned card would
actually cost you something. actually cost you something.
@@ -303,21 +427,273 @@ actually cost you something.
--- ---
## Running at boot (Raspberry Pi) ## Quick start on a Raspberry Pi
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 ```bash
sudo ./deploy/install.sh --origin https://your.web.app sudo ./deploy/install.sh --origin https://your.web.app
``` ```
That installs a service account, a virtualenv, a systemd unit and a udev rule, `deploy/install.sh` is the only installer — there is no `deploy.sh`. It sets up
then enables the service. Afterwards: 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 --device /dev/serial0 # UART, pins 8 / 10
sudo ./deploy/install.sh --device /dev/rdm-reader # USB adapter
```
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> 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` |
Nothing is enabled in either case, and re-running is safe: the account, udev
rule and virtualenv are all idempotent.
### 4. Verify it runs
```bash ```bash
systemctl status rdm-card-reader # state 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 journalctl -u rdm-card-reader -f # live log
curl http://127.0.0.1:8765/api/card # tap a card, then run this ss -ltnp | grep 8765 # confirms something is listening
``` ```
A healthy startup logs one line:
```
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 |
> **`--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 \
--serve --port <device> --bridge-port 8765 --mode login \
--led-pin <pin> --led-mode <mode> --heartbeat <seconds>
```
`WorkingDirectory=/opt/rdm-card-reader`, so this is equivalent to running
`.venv/bin/python -m rdm_card_reader --port <device>` there with `--serve`
added. `--serve` is what makes the API available; without it the app only
prints events, which a service sends to the journal.
### 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
sudo ./deploy/install.sh --device /dev/serial0 \
--host 0.0.0.0 --allow-remote --token 'pick-a-long-secret'
```
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
```
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
# Is a card on the reader right now?
curl -sS http://127.0.0.1:8765/api/card | python3 -m json.tool
```
Use `curl -sS` rather than plain `-s`: the extra `S` prints errors, so a
`Connection refused` is visible instead of an empty response that `json.tool`
then reports as `Expecting value: line 1 column 1 (char 0)`. That message means
*nothing answered*, which almost always means the service is not running —
check `ss -ltnp | grep 8765` first.
### The service gave up after a few failures
`StartLimitBurst=5` over `StartLimitIntervalSec=60` means systemd stops trying
after five failed starts — about 15 seconds. That is deliberate (an absent
reader should not restart forever), but it means fixing the wiring is not
enough on its own; the failed state has to be cleared:
```bash
sudo systemctl reset-failed rdm-card-reader
sudo systemctl restart rdm-card-reader
```
If a unit edit has no effect, you forgot `sudo systemctl daemon-reload` —
systemd caches unit files. Confirm what the service actually runs with:
```bash
systemctl cat rdm-card-reader | grep -E '^(ExecStart|DeviceAllow|After)'
```
That output, not the `.service` file on disk, is the truth the service runs.
### Identifying the right ttyUSB ### Identifying the right ttyUSB
`/dev/ttyUSB0` is **not** a stable identity — unplug something and the number `/dev/ttyUSB0` is **not** a stable identity — unplug something and the number
@@ -338,12 +714,16 @@ Identified serial ports (best candidate first):
Then pin it, most specific first: Then pin it, most specific first:
```bash ```bash
--port /dev/rdm-reader # udev alias --port /dev/rdm-reader # udev alias (USB adapters)
--port /dev/serial0 # on-board UART
--port /dev/serial/by-id/usb-1a86_USB_Serial-if00-port0 # by device --port /dev/serial/by-id/usb-1a86_USB_Serial-if00-port0 # by device
--port AB0XYZ12 # by serial number --port AB0XYZ12 # by serial number
--port 1a86:7523 # by chip --port 1a86:7523 # by chip
``` ```
Note the two are wired differently and the udev alias only exists for one of
them — see **UART or USB?** below.
`--autodetect` picks the only USB adapter, and **refuses** when there is more `--autodetect` picks the only USB adapter, and **refuses** when there is more
than one — silently choosing between two readers is exactly the failure this than one — silently choosing between two readers is exactly the failure this
avoids. Verify the wiring in `deploy/99-rdm-reader.rules`. avoids. Verify the wiring in `deploy/99-rdm-reader.rules`.
@@ -354,31 +734,65 @@ Exit codes: `0` ok, `1` no port, `2` library missing, `3` port ambiguous.
## Status LED ## Status LED
An LED driven by card events, on **GPIO 23** by default — the same pin the Two LEDs, each saying one thing. Both optional: everything works without
workstation app used. either.
| 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 ```bash
rdm-card-reader # lit while running, blinks on a bad read rdm-card-reader # status LED tracks the card; error LED pulses on a bad read
rdm-card-reader --led-pin 17 # a different pin rdm-card-reader --led-mode error # status LED lit whenever running, blinks on a bad read
rdm-card-reader --led-mode presence # lit only while a card is on the reader rdm-card-reader --error-led-pin 25 # error LED on a different pin
rdm-card-reader --no-led # no LED at all 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 | | Flag | Default | Meaning |
| --- | --- | --- | | --- | --- | --- |
| `--no-led` | off | Do not drive a LED | | `--no-led` | off | Do not drive any LED |
| `--led-pin` | `23` | GPIO pin (matches the workstation app) | | `--led-pin` | `23` | **BCM** GPIO number of the status LED — not the physical header pin |
| `--led-mode` | `error` | See the two readings below | | `--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: Two modes, because the light can mean two different things:
| Mode | Behaviour | Use when | | Mode | Behaviour | Use when |
| --- | --- | --- | | --- | --- | --- |
| `error` (default) | **Lit whenever the reader is running.** Dark while a card is being read, blinks on a bad read | The light should mean "the reader is alive and ready" | | `presence` (default) | **Lit only while a card is on the reader**, dark otherwise | The light should mean "a card is here" — the common choice, and unambiguous |
| `presence` | **Lit only while a card is on the reader** | The light should mean "a card is here" | | `error` | **Lit whenever the reader is running.** Dark while a card is being read, blinks on a bad read | The light should mean "the reader is alive and ready" |
> In `presence` mode the light is **dark at startup** and lights on the first
> valid card read, then goes dark when the card is lifted. A dark LED on a
> freshly started service is therefore correct, not a fault. Check that a card
> read lights it before concluding the LED is broken.
Wiring: LED anode to the GPIO pin through a ~330 Ω resistor, cathode to GND. 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 ### It cannot break the reader
@@ -393,18 +807,156 @@ point:
refusing to run. refusing to run.
* **Never crashes on a dead pin.** A failed toggle is logged and ignored. * **Never crashes on a dead pin.** A failed toggle is logged and ignored.
Startup reports which of the three states it is in: Startup reports the state of each LED (the mode shown is `presence` by
default):
``` ```
LED on GPIO 23 (error mode, gpiozero) # working LED on GPIO 23 (presence mode, active-high, gpiozero) # status LED working
LED unavailable: gpiozero not installed # library missing Error LED on GPIO 24 (lit for 1.0s on a bad read, ...) # error LED working
LED unavailable: no usable GPIO on pin 23: ... # no pin factory / pin busy 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 That distinction matters: `BadPinFactory` subclasses `ImportError`, so a naive
check reports "not installed" when the library is present but has no usable pin check reports "not installed" when the library is present but has no usable pin
provider — which would send you to the wrong fix. provider — which would send you to the wrong fix.
### The reader works but the light stays dark
Because a LED failure is never fatal, a misconfigured LED looks exactly like a
working reader with no light. Check the reason the app printed:
```bash
journalctl -u rdm-card-reader -n 40 --no-pager | grep -i led
```
| Message | Cause | Fix |
| --- | --- | --- |
| `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, 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` 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 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
sudo ./deploy/install.sh --device /dev/serial0 # recreates the venv with the flag
```
**Check 2 — systemd device policy.** The unit sets `DevicePolicy=closed`, so
only devices named in `DeviceAllow=` are reachable. It must list the GPIO chip
as well as the serial port:
```
DeviceAllow=/dev/serial0 rw
DeviceAllow=/dev/gpiochip0 rw
DeviceAllow=/dev/gpiochip4 rw
```
`gpiochip0` is the header on Pi 1–4, `gpiochip4` on Pi 5. A missing entry is
denied by systemd before any group permission is even consulted. Confirm what
the service actually runs with
`systemctl cat rdm-card-reader | grep DeviceAllow`.
**Check 3 — the `gpio` group.** On modern Raspberry Pi OS (Bookworm onward)
`gpiozero` drives GPIO through `lgpio`, which opens `/dev/gpiochip0` — owned
`root:gpio`, mode `0660`. The service account must be in `gpio`:
```bash
sudo usermod -aG gpio rdmcard
```
Older releases (Bullseye, `RPi.GPIO`) use `/dev/gpiomem` and need no extra
group.
After any of these changes: `sudo systemctl daemon-reload` (systemd caches unit
files) and `sudo systemctl restart rdm-card-reader`.
**If the LED extras are missing**, install them offline from the bundled wheels
— a Pi with no internet cannot reach PyPI:
```bash
/opt/rdm-card-reader/.venv/bin/pip install --no-index \
--find-links /opt/rdm-card-reader/src/rdm_card_reader/wheels \
--no-deps gpiozero colorzero
```
**If the journal says the LED is on GPIO 23**, the software side is fine.
Tap a card: in the default `presence` mode the light comes on while the card is
held and goes dark when it is lifted. If it stays dark with a card on the
reader, the problem is electrical — see the pin numbering note under
**Wiring**. GPIO 23 is physical pin **16**, and the anode needs a series
resistor to the pin with the cathode to GND.
> **If the workstation app also runs on this device, use a different pin** — two > **If the workstation app also runs on this device, use a different pin** — two
> processes driving GPIO 23 will fight, and the LED will flicker. > processes driving GPIO 23 will fight, and the LED will flicker.
@@ -417,8 +969,30 @@ it is never imported at runtime and would add 818 KB for nothing.
## Wiring ## Wiring
The RDM6300 talks plain serial at **9600 baud, 8N1**. Connect it to the Pi's The RDM6300 talks plain serial at **9600 baud, 8N1**.
UART (pins 8/10 for TXD/RXD, 5 V and GND) or through a USB-to-serial adapter.
A typical connection to the Pi's 40-pin header, using the on-board UART:
| RDM6300 | Pi pin | Function |
| --- | --- | --- |
| VCC (5 V) | 4 | power |
| GND | 6 | ground (must be common) |
| TX | **10** (GPIO 15, RXD) | card data → Pi |
| RX | 8 (GPIO 14, TXD) | not used — the reader only transmits |
The reader is **transmit-only**: it emits frames continuously and never reads,
so the Pi's TX line (pin 8) has nothing to send. Only pin 10 carries data.
> **Level warning.** The RDM6300's TX output is **5 V TTL**, while GPIO 15 is a
> **3.3 V** input. Feeding 5 V straight in often appears to work but stresses
> the pin. Use a divider (e.g. 1 kΩ in series, 2 kΩ to GND) or a level shifter
> 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 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: On a Raspberry Pi you usually need to free up the primary UART first:
@@ -430,7 +1004,104 @@ sudo usermod -aG dialout $USER # then log out and back in
``` ```
The GPIO UART appears as `/dev/serial0` (a symlink to `/dev/ttyAMA0` or The GPIO UART appears as `/dev/serial0` (a symlink to `/dev/ttyAMA0` or
`/dev/ttyS0`). `/dev/ttyS0`). If the serial console still owns the port the service will fail
to open it — check with:
```bash
sudo systemctl is-active serial-getty@ttyAMA0.service # must not be active
grep -o 'console=[^ ]*' /boot/firmware/cmdline.txt # must not say serial0
```
### UART or USB? The service must be told which
The two are not interchangeable, and getting it wrong is the most common reason
the service fails with `exit 1`:
| Wiring | Device to pass | udev alias |
| --- | --- | --- |
| Pi UART pins **8 / 10** (or 10/8) | `/dev/serial0` | **never created** |
| USB-to-serial adapter | `/dev/rdm-reader` | created by the udev rule |
`deploy/99-rdm-reader.rules` matches a USB bridge chip, so it **cannot** fire
for the on-board UART — there is no USB device to match. A service pointing at
`/dev/rdm-reader` with the reader on the UART therefore finds nothing and exits
`1` on every start.
Tell the installer which one you have:
```bash
sudo ./deploy/install.sh --origin https://your.web.app --device /dev/serial0
```
It verifies the device both exists *and* opens at 9600 baud before enabling the
service, so this class of failure is caught at install time rather than as a
crash-looping unit:
```
/dev/serial0 opened at 9600 baud
```
If the LED is not on GPIO 23, say so at the same time (`--led-pin` takes the
**BCM GPIO number**, not the physical header pin):
```bash
sudo ./deploy/install.sh --device /dev/serial0 --led-pin 18 # LED on GPIO 18
```
### LED pin numbering
`--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.
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
```
--- ---
@@ -478,19 +1149,28 @@ problem with the following card.
| Reader opens but no cards | Card must be EM4100 / 125 kHz; hold it ~2 cm from the coil | | Reader opens but no cards | Card must be EM4100 / 125 kHz; hold it ~2 cm from the coil |
| `INVALID CARD` repeatedly | Noisy supply or long/unshielded wiring; shorten it | | `INVALID CARD` repeatedly | Noisy supply or long/unshielded wiring; shorten it |
| Every card reported twice | Lower `--heartbeat` if the card is lifted quickly | | Every card reported twice | Lower `--heartbeat` if the card is lifted quickly |
| Card reported as **removed while still on the reader** | Raise `--heartbeat` — the reader reports a card once per placement, so a short window expires while the card sits still |
| LED blinks on and off instead of staying lit | Same cause: the LED follows `present`. Raise `--heartbeat` |
| Service `exit 1`, `no port matched '/dev/rdm-reader'` | Reader is on the UART — re-install with `--device /dev/serial0` |
| Service `exit 1`, `Could not open /dev/serial0` | UART held by the serial console, or the user is not in `dialout` |
| Service stuck in `failed`, restarts refused | `sudo systemctl reset-failed rdm-card-reader` |
| `curl` returns nothing, `Expecting value: line 1 column 1` | Nothing is listening — the service is not running; check `ss -ltnp \| grep 8765` |
| Reader works but the LED is dark | See **The reader works but the light stays dark** |
--- ---
## Development ## Check it works
With no RFID hardware attached, `--demo` exercises the formatting path and
`--list-ports` shows what serial ports exist:
```bash ```bash
pip install -e ".[dev]" rdm-card-reader --demo # simulated reads, no hardware needed
pytest rdm-card-reader --list-ports # candidate and identified ports
``` ```
The suite needs no RFID hardware: the end-to-end tests open a pseudo-terminal, To drive the real reader path with no hardware, feed RDM6300 frames into a
write real RDM6300 frames into it and assert on the CLI output. `--demo` also pseudo-terminal — see the snippet in **[LAUNCH.md](LAUNCH.md)**.
works with no hardware at all.
### Layout ### Layout
@@ -500,12 +1180,12 @@ src/rdm_card_reader/
reader.py serial port handling, event callbacks reader.py serial port handling, event callbacks
cli.py argument parsing and program flow cli.py argument parsing and program flow
demo.py simulated reads demo.py simulated reads
tests/ ports.py stable serial port identification
test_protocol.py RDM6300 wire format, byte for byte bridge.py the HTTP bridge behind --serve
test_card.py event model and formatting webui.py the status page served at GET / by the bridge
test_reader.py port selection, using a fake reader library led.py status LED
test_cli.py exit codes and output modes bootstrap.py offline dependency install
test_e2e_pty.py full stack over a pseudo-terminal wheels/ the bundled wheels (offline install source)
``` ```
### Using it as a library ### Using it as a library
+220 -11
View File
@@ -10,10 +10,31 @@
# Options: # Options:
# --origin URL web app origin allowed to read the API (repeatable) # --origin URL web app origin allowed to read the API (repeatable)
# --port PORT HTTP port for the bridge (default 8765) # --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) # --dir PATH install location (default /opt/rdm-card-reader)
# --user NAME service account (default rdmcard) # --user NAME service account (default rdmcard)
# --no-service install and configure, but do not enable the service # --no-service install and configure, but do not enable the service
# #
# The default device is /dev/serial0, the Raspberry Pi's on-board UART, because
# that is how the reader is normally wired (header pins 8/10). For a
# USB-to-serial adapter pass --device /dev/rdm-reader, the udev alias.
#
set -euo pipefail set -euo pipefail
ORIGINS=() ORIGINS=()
@@ -21,28 +42,73 @@ BRIDGE_PORT=8765
INSTALL_DIR=/opt/rdm-card-reader INSTALL_DIR=/opt/rdm-card-reader
SERVICE_USER=rdmcard SERVICE_USER=rdmcard
INSTALL_SERVICE=1 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)" SOURCE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
case "$1" in case "$1" in
--origin) ORIGINS+=("$2"); shift 2 ;; --origin) ORIGINS+=("$2"); shift 2 ;;
--port) BRIDGE_PORT="$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 ;; --dir) INSTALL_DIR="$2"; shift 2 ;;
--user) SERVICE_USER="$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 ;; --no-service) INSTALL_SERVICE=0; shift ;;
-h|--help) sed -n '2,20p' "$0"; exit 0 ;; -h|--help) sed -n '2,33p' "$0"; exit 0 ;;
*) echo "unknown option: $1" >&2; exit 2 ;; *) echo "unknown option: $1" >&2; exit 2 ;;
esac esac
done done
log() { printf '\n\033[1m==> %s\033[0m\n' "$*"; } log() { printf '\n\033[1m==> %s\033[0m\n' "$*"; }
warn() { printf '\033[33mwarning: %s\033[0m\n' "$*" >&2; } warn() { printf '\033[33mwarning: %s\033[0m\n' "$*" >&2; }
note() { printf ' %s\n' "$*"; }
if [[ $EUID -ne 0 ]]; then if [[ $EUID -ne 0 ]]; then
echo "Please run as root: sudo $0 $*" >&2 echo "Please run as root: sudo $0 $*" >&2
exit 1 exit 1
fi 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 # 1. System packages
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -53,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 \ apt-get install -y --no-install-recommends python3 python3-venv python3-pip \
|| warn "could not install system packages - continuing with what is present" || 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 # 2. Install the application
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -60,14 +149,21 @@ log "Installing to $INSTALL_DIR"
mkdir -p "$INSTALL_DIR" mkdir -p "$INSTALL_DIR"
# Copy the source tree (excluding local build/test artifacts). # Copy the source tree (excluding local build/test artifacts).
for item in src tests pyproject.toml README.md LAUNCH.md LICENSE deploy; do for item in src pyproject.toml README.md LAUNCH.md LICENSE deploy; do
if [[ -e "$SOURCE_DIR/$item" ]]; then if [[ -e "$SOURCE_DIR/$item" ]]; then
cp -a "$SOURCE_DIR/$item" "$INSTALL_DIR/" cp -a "$SOURCE_DIR/$item" "$INSTALL_DIR/"
fi fi
done done
log "Building the virtual environment" log "Building the virtual environment"
if ! python3 -m venv "$INSTALL_DIR/.venv"; then # --system-site-packages is what makes the status LED work. gpiozero is pure
# Python, but the pin backend it needs (lgpio on Bookworm, RPi.GPIO on older
# releases) is an apt package installed into the *system* Python. A plain venv
# cannot see it, so gpiozero finds no usable pin factory, raises BadPinFactory
# and the app falls back to a no-op LED: the reader works and the light stays
# dark, with nothing failing. Re-running this on an existing venv updates
# pyvenv.cfg in place, so an upgrade picks the flag up too.
if ! python3 -m venv --system-site-packages "$INSTALL_DIR/.venv"; then
warn "could not create the virtualenv." warn "could not create the virtualenv."
warn "On Debian/Raspberry Pi OS this usually means python3-venv is missing:" warn "On Debian/Raspberry Pi OS this usually means python3-venv is missing:"
warn " sudo apt-get install python3-venv" warn " sudo apt-get install python3-venv"
@@ -172,6 +268,17 @@ fi
usermod -aG dialout "$SERVICE_USER" usermod -aG dialout "$SERVICE_USER"
echo " $SERVICE_USER is in group dialout" echo " $SERVICE_USER is in group dialout"
# The status LED drives /dev/gpiochip*, which is root:gpio on modern Raspberry
# Pi OS (Bookworm onward, gpiozero via lgpio). Without this group the service
# cannot open the chip, gpiozero falls back to a no-op LED, and the reader
# works with no light - the failure that is easiest to miss. Older releases
# (Bullseye, RPi.GPIO) use /dev/gpiomem and need no extra group, hence the
# group check rather than an unconditional usermod.
if getent group gpio >/dev/null 2>&1; then
usermod -aG gpio "$SERVICE_USER"
echo " $SERVICE_USER is in group gpio (status LED)"
fi
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 4. Stable device name # 4. Stable device name
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -182,15 +289,49 @@ udevadm control --reload-rules
udevadm trigger --subsystem-match=tty || true udevadm trigger --subsystem-match=tty || true
sleep 1 sleep 1
if [[ -e /dev/rdm-reader ]]; then if [[ "$SERIAL_DEVICE" == "/dev/rdm-reader" && ! -e /dev/rdm-reader ]]; then
echo " /dev/rdm-reader -> $(readlink -f /dev/rdm-reader)"
else
warn "/dev/rdm-reader does not exist yet." warn "/dev/rdm-reader does not exist yet."
warn "The reader may not be plugged in, or it uses a different chip." warn "The USB adapter may not be plugged in, or it uses a different chip."
warn "Run '$INSTALL_DIR/.venv/bin/rdm-card-reader --list-ports --verbose-ports'" warn "Run '$INSTALL_DIR/.venv/bin/rdm-card-reader --list-ports --verbose-ports'"
warn "then edit /etc/udev/rules.d/99-rdm-reader.rules to match your adapter." warn "then edit /etc/udev/rules.d/99-rdm-reader.rules to match your adapter."
warn "Reader wired to the Pi's UART pins instead? That is the default device:"
warn "re-run without --device (or with --device /dev/serial0)."
fi fi
# Refuse to enable a service that can only exit 1. A unit that fails on every
# start burns its StartLimitBurst within seconds and then stays dead until
# someone runs 'systemctl reset-failed' by hand - a confusing failure to debug
# remotely, and one that silently returns "installed successfully".
if [[ ! -e "$SERIAL_DEVICE" ]]; then
warn "$SERIAL_DEVICE does not exist, so the service could never start."
warn "Nothing has been enabled. Fix the device, then re-run this installer."
exit 1
fi
# Prove the port can actually be opened, not merely that it exists. Existence
# alone is not enough: a node with no hardware behind it still fails to open.
if ! "$INSTALL_DIR/.venv/bin/python" - "$SERIAL_DEVICE" <<'PY'
import sys
import serial
try:
serial.Serial(sys.argv[1], 9600, timeout=1).close()
except Exception as exc:
print(" x {}: {}".format(sys.argv[1], exc))
raise SystemExit(1)
PY
then
warn "could not open $SERIAL_DEVICE at 9600 baud."
warn "For the on-board UART check that it is enabled:"
warn " sudo raspi-config # Interface Options -> Serial Port"
warn " 'login shell over serial?' -> No"
warn " 'serial port hardware?' -> Yes"
warn "Nothing has been enabled. Fix the above, then re-run this installer."
exit 1
fi
echo " $SERIAL_DEVICE opened at 9600 baud"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 5. Service # 5. Service
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -204,10 +345,15 @@ for origin in "${ORIGINS[@]:-}"; do
[[ -n "$origin" ]] && ORIGIN_ARGS+=" --allow-origin $origin" [[ -n "$origin" ]] && ORIGIN_ARGS+=" --allow-origin $origin"
done done
python3 - "$UNIT" "$INSTALL_DIR" "$BRIDGE_PORT" "$ORIGIN_ARGS" "$SERVICE_USER" <<'PY' python3 - "$UNIT" "$INSTALL_DIR" "$BRIDGE_PORT" "$ORIGIN_ARGS" "$SERVICE_USER" \
"$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 import os, re, sys
unit, install_dir, port, origin_args, user = sys.argv[1:6] (unit, install_dir, port, origin_args, user, device, led_pin, led_mode,
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() text = open(unit).read()
# The console script is created by the installer, but the .pth file is what # The console script is created by the installer, but the .pth file is what
@@ -217,13 +363,72 @@ wrapper = f"{install_dir}/.venv/bin/rdm-card-reader"
if not os.path.exists(wrapper): if not os.path.exists(wrapper):
wrapper = f"{install_dir}/.venv/bin/python -m rdm_card_reader" 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 = ( command = (
f"{wrapper} --serve " f"{wrapper} --serve "
f"--port /dev/rdm-reader --bridge-port {port} --mode login{origin_args}" f"--port {device} --bridge-port {port} --mode login "
f"--led-pin {led_pin} --led-mode {led_mode} --heartbeat {heartbeat}"
f"{led_active_args}{error_args}{host_args}{origin_args}"
) )
def device_unit(path):
"""systemd device unit for *path*, e.g. /dev/rdm-reader -> dev-rdm\\x2dreader.device."""
name = path[len("/dev/"):] if path.startswith("/dev/") else path
name = name.replace("-", "\\x2d").replace("/", "-")
return f"dev-{name}.device"
# Replace the whole (possibly line-continued) ExecStart definition. # Replace the whole (possibly line-continued) ExecStart definition.
text = re.sub(r"ExecStart=[\s\S]*?(?=\n\n|\nRestart=)", f"ExecStart={command}", text, count=1) text = re.sub(r"ExecStart=[\s\S]*?(?=\n\n|\nRestart=)", f"ExecStart={command}", text, count=1)
text = re.sub(r"^User=.*$", f"User={user}", text, count=1, flags=re.M) text = re.sub(r"^User=.*$", f"User={user}", text, count=1, flags=re.M)
# Order after the device we actually open. The shipped unit names a device
# that is not the one the service reads, so systemd never really waited for it.
text = re.sub(r"^After=.*$", f"After={device_unit(device)} network-online.target",
text, count=1, flags=re.M)
# DevicePolicy=closed means only the devices listed here are usable, so the
# list has to cover everything the service touches: the serial port, and the
# GPIO chip the status LED drives. Omitting the chip is a silent failure - the
# reader keeps working and only the LED stays dark - so it is easy to miss.
# gpiochip0 is the header on Pi 1-4; Pi 5 uses gpiochip4.
device_allow = "\n".join([
f"DeviceAllow={device} rw",
"DeviceAllow=/dev/gpiochip0 rw",
"DeviceAllow=/dev/gpiochip4 rw",
])
text = re.sub(r"^DeviceAllow=.*(?:\nDeviceAllow=.*)*", device_allow, text,
count=1, flags=re.M)
open(unit, "w").write(text) open(unit, "w").write(text)
PY PY
@@ -248,7 +453,11 @@ cat <<EOF
$(log "Installed") $(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_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 Card state : curl http://127.0.0.1:$BRIDGE_PORT/api/card
Health : curl http://127.0.0.1:$BRIDGE_PORT/api/health Health : curl http://127.0.0.1:$BRIDGE_PORT/api/health
Logs : journalctl -u rdm-card-reader -f Logs : journalctl -u rdm-card-reader -f
+20 -9
View File
@@ -2,8 +2,10 @@
Description=RDM card reader HTTP bridge (serves card taps for web login) Description=RDM card reader HTTP bridge (serves card taps for web login)
Documentation=file:/opt/rdm-card-reader/README.md Documentation=file:/opt/rdm-card-reader/README.md
# The serial device must exist before we start, otherwise the reader opens the # The serial device must exist before we start, otherwise the reader opens the
# wrong tty or none at all. # wrong tty or none at all. install.sh rewrites this line for the device given
After=dev-serial.device network-online.target # with --device, since only it knows whether the reader is on the on-board UART
# (/dev/serial0, the default) or a USB adapter (/dev/rdm-reader).
After=dev-serial0.device network-online.target
Wants=network-online.target Wants=network-online.target
# Do not restart forever if the reader is genuinely absent. These are [Unit] # Do not restart forever if the reader is genuinely absent. These are [Unit]
@@ -17,22 +19,29 @@ User=rdmcard
Group=dialout Group=dialout
WorkingDirectory=/opt/rdm-card-reader WorkingDirectory=/opt/rdm-card-reader
# --serve exposes the card on http://127.0.0.1:8765/api/card # --serve runs the HTTP bridge, which is what makes the reader available as an
# --port points at the udev alias, so the reader is the same device every boot. # API on http://127.0.0.1:8765. --port points at the device the reader is
# actually wired to; install.sh rewrites this line from --device, and only it
# knows whether that is a USB adapter (/dev/rdm-reader) or the on-board UART
# (/dev/serial0). --led-pin is rewritten from --led-pin for the same reason.
# Autodetect refuses to guess if more than one USB adapter is present, which is # Autodetect refuses to guess if more than one USB adapter is present, which is
# the safe behaviour: a wrong guess means authenticating the wrong person. # the safe behaviour: a wrong guess means authenticating the wrong person.
# The wheels for rdm6300 and pyserial are bundled inside the package, so no # The wheels for rdm6300 and pyserial are bundled inside the package, so no
# --wheel-dir is needed here: the install works with no internet. # --wheel-dir is needed here: the install works with no internet.
ExecStart=/opt/rdm-card-reader/.venv/bin/rdm-card-reader \ ExecStart=/opt/rdm-card-reader/.venv/bin/rdm-card-reader \
--serve \ --serve \
--port /dev/rdm-reader \ --port /dev/serial0 \
--mode login \ --mode login \
--allow-origin https://your.web.app --led-pin 23 \
--led-mode presence \
--heartbeat 2.0
Restart=always Restart=always
RestartSec=3 RestartSec=3
# The bridge only needs to read the serial port and listen on loopback. # The bridge reads the serial port, drives the status LED and listens on
# loopback. DeviceAllow is rewritten by install.sh alongside --port: with
# DevicePolicy=closed a device missing from this list is denied outright, and
# omitting the GPIO chip leaves the LED dark while everything else works.
NoNewPrivileges=true NoNewPrivileges=true
PrivateTmp=true PrivateTmp=true
ProtectSystem=strict ProtectSystem=strict
@@ -44,7 +53,9 @@ RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
RestrictNamespaces=true RestrictNamespaces=true
LockPersonality=true LockPersonality=true
MemoryDenyWriteExecute=true MemoryDenyWriteExecute=true
DeviceAllow=/dev/rdm-reader rw DeviceAllow=/dev/serial0 rw
DeviceAllow=/dev/gpiochip0 rw
DeviceAllow=/dev/gpiochip4 rw
DevicePolicy=closed DevicePolicy=closed
# stdout/stderr go here; watch with: journalctl -u rdm-card-reader -f # stdout/stderr go here; watch with: journalctl -u rdm-card-reader -f
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

+1 -6
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "rdm-card-reader" name = "rdm-card-reader"
version = "0.3.0" version = "0.4.0"
description = "RDM6300 / EM4100 RFID card reader: prints card events, or serves them over HTTP so a web app can authenticate a card tap." description = "RDM6300 / EM4100 RFID card reader: prints card events, or serves them over HTTP so a web app can authenticate a card tap."
readme = "README.md" readme = "README.md"
requires-python = ">=3.8" requires-python = ">=3.8"
@@ -24,7 +24,6 @@ dependencies = [
] ]
[project.optional-dependencies] [project.optional-dependencies]
dev = ["pytest>=7.0"]
# Status LED support. Installed automatically on first use by the app, or # Status LED support. Installed automatically on first use by the app, or
# explicitly with: pip install '.[led]' # explicitly with: pip install '.[led]'
led = ["gpiozero>=2.0", "colorzero>=2.0"] led = ["gpiozero>=2.0", "colorzero>=2.0"]
@@ -37,7 +36,3 @@ where = ["src"]
[tool.setuptools.package-data] [tool.setuptools.package-data]
rdm_card_reader = ["py.typed", "wheels/*.whl", "wheels/SHA256SUMS"] rdm_card_reader = ["py.typed", "wheels/*.whl", "wheels/SHA256SUMS"]
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-q"
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

+7 -1
View File
@@ -30,7 +30,12 @@ from .ports import (
discover, discover,
resolve, resolve,
) )
from .led import DEFAULT_LED_PIN, StatusLED, create as create_led from .led import (
DEFAULT_LED_MODE,
DEFAULT_LED_PIN,
StatusLED,
create as create_led,
)
from .reader import available, list_ports, open_reader, reload_library from .reader import available, list_ports, open_reader, reload_library
__version__ = "0.4.0" __version__ = "0.4.0"
@@ -41,6 +46,7 @@ __all__ = [
"CardData", "CardData",
"CardEvent", "CardEvent",
"AmbiguousPort", "AmbiguousPort",
"DEFAULT_LED_MODE",
"DEFAULT_LED_PIN", "DEFAULT_LED_PIN",
"EventHub", "EventHub",
"PortError", "PortError",
-10
View File
@@ -46,10 +46,6 @@ LED_PACKAGES = (
("colorzero", "colorzero>=2.0"), ("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. #: Shown when an install fails, so the operator knows what to do by hand.
MANUAL_HINT = ( MANUAL_HINT = (
"Install manually with: {python} -m pip install {requirements}" "Install manually with: {python} -m pip install {requirements}"
@@ -221,12 +217,6 @@ def install(
return True 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( def ensure_dependencies(
packages: Sequence[tuple] = REQUIRED_PACKAGES, packages: Sequence[tuple] = REQUIRED_PACKAGES,
wheel_dir: Optional[str] = None, wheel_dir: Optional[str] = None,
+186 -22
View File
@@ -55,8 +55,10 @@ import time
from collections import deque from collections import deque
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import Any, Callable, Deque, Dict, List, Optional 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 #: Reader modes. ``login`` publishes inserts only (a tap = an authentication
#: attempt). ``monitor`` publishes inserts and removals both. #: attempt). ``monitor`` publishes inserts and removals both.
@@ -75,6 +77,12 @@ DEFAULT_HISTORY = 50
#: replaced. #: replaced.
DEFAULT_TAP_TTL = 10.0 DEFAULT_TAP_TTL = 10.0
#: How long a *claimed* tap stays readable afterwards. The page that claimed it
#: needs a moment to confirm which card logged in, but an id that stays
#: readable forever is indistinguishable from a tap still waiting to be spent -
#: so it is cleared once this window passes. Set to 0 to keep it indefinitely.
DEFAULT_CLAIMED_TTL = 30.0
class EventHub: class EventHub:
"""Tracks reader state and fans card events out to waiting subscribers. """Tracks reader state and fans card events out to waiting subscribers.
@@ -96,6 +104,7 @@ class EventHub:
history: int = DEFAULT_HISTORY, history: int = DEFAULT_HISTORY,
mode: str = MODE_LOGIN, mode: str = MODE_LOGIN,
tap_ttl: float = DEFAULT_TAP_TTL, tap_ttl: float = DEFAULT_TAP_TTL,
claimed_ttl: float = DEFAULT_CLAIMED_TTL,
): ):
self._lock = threading.Lock() self._lock = threading.Lock()
self._subscribers: List["deque"] = [] self._subscribers: List["deque"] = []
@@ -104,9 +113,10 @@ class EventHub:
self._last_at: Optional[float] = None self._last_at: Optional[float] = None
self._mode = mode if mode in VALID_MODES else MODE_LOGIN self._mode = mode if mode in VALID_MODES else MODE_LOGIN
self._started_at = time.time() 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._port: Optional[str] = None
self._tap_ttl = float(tap_ttl if tap_ttl else 0.0) self._tap_ttl = float(tap_ttl if tap_ttl else 0.0)
self._claimed_ttl = float(claimed_ttl if claimed_ttl else 0.0)
# The latched tap. It deliberately outlives the card leaving the # The latched tap. It deliberately outlives the card leaving the
# reader, so a page that loads after the tap can still claim it. # reader, so a page that loads after the tap can still claim it.
@@ -177,8 +187,16 @@ class EventHub:
``present`` is whether the card is physically on the reader right now; ``present`` is whether the card is physically on the reader right now;
``claimable`` is whether this tap can still be spent on a login. ``claimable`` is whether this tap can still be spent on a login.
A claimed tap is reported for ``claimed_ttl`` seconds after it was
spent, so the page that claimed it can confirm which card logged in. An
expired unclaimed tap is cleared too: keeping it would leave an id that
can never be claimed again, which is indistinguishable from a tap that
is still waiting to be used.
""" """
with self._lock: with self._lock:
self._drop_dead_tap()
if self._tap is None: if self._tap is None:
return { return {
"present": False, "present": False,
@@ -200,6 +218,32 @@ class EventHub:
"card": self._tap.to_dict(), "card": self._tap.to_dict(),
} }
def _drop_dead_tap(self) -> None:
"""Clear the latched tap once it can no longer be acted on.
Called with the lock held. Two independent deadlines: an unclaimed tap
is dead after ``tap_ttl``, a claimed one after ``claimed_ttl``. A tap of
zero TTL means "keep until replaced", which the claimed case uses to
keep the id around indefinitely.
Crucially this drops only the *tap*, never the presence flag: whether a
card is on the reader is a physical fact learnt from insert/remove
events, and a tap ageing out says nothing about it. Clearing presence
here reported "no card" while a card was sitting on the antenna.
"""
if self._tap is None:
return
now = time.time()
if self._consumed_at is not None:
if self._claimed_ttl and now - self._consumed_at > self._claimed_ttl:
self._tap = None
self._tap_at = None
self._consumed_at = None
return
if self._tap_ttl and now - (self._tap_at or 0.0) > self._tap_ttl:
self._tap = None
self._tap_at = None
def consume_card(self): def consume_card(self):
"""Atomically claim the latched tap for one authentication. """Atomically claim the latched tap for one authentication.
@@ -207,6 +251,7 @@ class EventHub:
this call succeeds, so two concurrent requests cannot both win. this call succeeds, so two concurrent requests cannot both win.
""" """
with self._lock: with self._lock:
self._drop_dead_tap()
if self._tap is None: if self._tap is None:
return None, "no card read yet", 409 return None, "no card read yet", 409
@@ -245,6 +290,7 @@ class EventHub:
def health(self) -> Dict[str, Any]: def health(self) -> Dict[str, Any]:
with self._lock: with self._lock:
self._drop_dead_tap()
last_age = ( last_age = (
round(time.time() - self._last_at, 1) if self._last_at else None round(time.time() - self._last_at, 1) if self._last_at else None
) )
@@ -260,6 +306,7 @@ class EventHub:
"card_present": self._on_reader, "card_present": self._on_reader,
"card_consumed": self._consumed_at is not None, "card_consumed": self._consumed_at is not None,
"tap_ttl_seconds": self._tap_ttl or None, "tap_ttl_seconds": self._tap_ttl or None,
"claimed_ttl_seconds": self._claimed_ttl or None,
} }
def last(self) -> Optional[Dict[str, Any]]: def last(self) -> Optional[Dict[str, Any]]:
@@ -280,14 +327,31 @@ class _Handler(BaseHTTPRequestHandler):
# -- helpers ---------------------------------------------------------- # -- 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: def _cors_ok(self) -> bool:
"""Refuse cross-origin reads unless the origin was allow-listed.""" """Refuse cross-origin reads unless the origin was allow-listed."""
origin = self.headers.get("Origin") origin = self.headers.get("Origin")
if not origin: if not origin:
return True # same-origin fetch or a non-browser client 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", []) allowed: List[str] = getattr(self.server, "allowed_origins", [])
if not allowed:
return False # browsers only; opt out entirely
return origin in allowed return origin in allowed
def _token_ok(self) -> bool: def _token_ok(self) -> bool:
@@ -306,26 +370,52 @@ class _Handler(BaseHTTPRequestHandler):
supplied = pair.split("=", 1)[1] supplied = pair.split("=", 1)[1]
return secrets.compare_digest(supplied, required) 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: def _send_json(self, payload: Any, status: int = 200) -> None:
body = json.dumps(payload).encode("utf-8") body = json.dumps(payload).encode("utf-8")
self.send_response(status) self.send_response(status)
self.send_header("Content-Type", "application/json") self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body))) self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store") self.send_header("Cache-Control", "no-store")
origin = self.headers.get("Origin") allowed = self._allowed_origin()
if origin and origin in getattr(self.server, "allowed_origins", []): if allowed:
self.send_header("Access-Control-Allow-Origin", origin) self.send_header("Access-Control-Allow-Origin", allowed)
self.send_header("Vary", "Origin") self.send_header("Vary", "Origin")
self.end_headers() self.end_headers()
self.wfile.write(body) 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: def _send_no_content(self) -> None:
self.send_response(204) self.send_response(204)
self.send_header("Cache-Control", "no-store") self.send_header("Cache-Control", "no-store")
self.send_header("Content-Length", "0") self.send_header("Content-Length", "0")
origin = self.headers.get("Origin") allowed = self._allowed_origin()
if origin and origin in getattr(self.server, "allowed_origins", []): if allowed:
self.send_header("Access-Control-Allow-Origin", origin) self.send_header("Access-Control-Allow-Origin", allowed)
self.send_header("Vary", "Origin") self.send_header("Vary", "Origin")
self.end_headers() self.end_headers()
@@ -343,6 +433,16 @@ class _Handler(BaseHTTPRequestHandler):
def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API
path = self.path.split("?", 1)[0].rstrip("/") or "/" 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": if path == "/api/health":
self._send_json(self._hub.health()) self._send_json(self._hub.health())
return return
@@ -366,6 +466,15 @@ class _Handler(BaseHTTPRequestHandler):
self._send_json(self._hub.current_card()) self._send_json(self._hub.current_card())
return 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": if path == "/api/taps":
limit = 20 limit = 20
if "?" in self.path: if "?" in self.path:
@@ -387,6 +496,58 @@ class _Handler(BaseHTTPRequestHandler):
def do_POST(self) -> None: # noqa: N802 def do_POST(self) -> None: # noqa: N802
path = self.path.split("?", 1)[0].rstrip("/") or "/" 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 path == "/api/card/claim":
if not self._cors_ok(): if not self._cors_ok():
self._send_json({"error": "origin not allowed"}, status=403) self._send_json({"error": "origin not allowed"}, status=403)
@@ -431,10 +592,10 @@ class _Handler(BaseHTTPRequestHandler):
self._send_json({"mode": mode}) self._send_json({"mode": mode})
def do_OPTIONS(self) -> None: # noqa: N802 def do_OPTIONS(self) -> None: # noqa: N802
origin = self.headers.get("Origin")
self.send_response(204) self.send_response(204)
if origin and origin in getattr(self.server, "allowed_origins", []): allowed = self._allowed_origin()
self.send_header("Access-Control-Allow-Origin", 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-Methods", "GET, POST, OPTIONS")
self.send_header("Access-Control-Allow-Headers", "Content-Type, X-Auth-Token") self.send_header("Access-Control-Allow-Headers", "Content-Type, X-Auth-Token")
self.send_header("Vary", "Origin") self.send_header("Vary", "Origin")
@@ -450,9 +611,9 @@ class _Handler(BaseHTTPRequestHandler):
self.send_header("Content-Type", "text/event-stream") self.send_header("Content-Type", "text/event-stream")
self.send_header("Cache-Control", "no-store") self.send_header("Cache-Control", "no-store")
self.send_header("Connection", "keep-alive") self.send_header("Connection", "keep-alive")
origin = self.headers.get("Origin") allowed = self._allowed_origin()
if origin and origin in getattr(self.server, "allowed_origins", []): if allowed:
self.send_header("Access-Control-Allow-Origin", origin) self.send_header("Access-Control-Allow-Origin", allowed)
self.send_header("Vary", "Origin") self.send_header("Vary", "Origin")
self.end_headers() self.end_headers()
@@ -487,17 +648,20 @@ def create_server(
allowed_origins: Optional[List[str]] = None, allowed_origins: Optional[List[str]] = None,
token: Optional[str] = None, token: Optional[str] = None,
logger: Optional[Callable[[str], None]] = None, logger: Optional[Callable[[str], None]] = None,
serve_page: bool = True,
led: Any = None,
) -> ThreadingHTTPServer: ) -> 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 = ThreadingHTTPServer((host, port), _Handler)
server.daemon_threads = True server.daemon_threads = True
server.hub = hub # type: ignore[attr-defined] 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.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.token = token or os.environ.get("RDM_BRIDGE_TOKEN") or None # type: ignore[attr-defined]
server.logger = logger # type: ignore[attr-defined] server.logger = logger # type: ignore[attr-defined]
return server 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")
+13 -2
View File
@@ -18,10 +18,13 @@ except ImportError: # pragma: no cover - fallback so the model stays importable
CardData = namedtuple("CardData", ["value", "checksum", "type", "is_valid"]) CardData = namedtuple("CardData", ["value", "checksum", "type", "is_valid"])
#: Serial devices tried in order. ``/dev/ttyS0`` first matches the Raspberry Pi #: Serial devices tried in order. ``/dev/ttyS0`` first matches the Raspberry Pi
#: default, the others cover USB / GPIO UART dongles. #: default, the others cover USB / GPIO UART dongles. ``/dev/serial0`` is the
#: canonical Pi alias for the on-board UART - it resolves to ttyAMA0 or ttyS0
#: depending on the model, and is the device the docs tell you to use.
CANDIDATE_PORTS = ( CANDIDATE_PORTS = (
"/dev/ttyS0", "/dev/ttyS0",
"/dev/ttyAMA0", "/dev/ttyAMA0",
"/dev/serial0",
"/dev/ttyUSB0", "/dev/ttyUSB0",
"/dev/ttyACM0", "/dev/ttyACM0",
) )
@@ -32,7 +35,15 @@ CONFIG_CARD_VALUE = 12886709
#: Seconds without a read before a card is considered removed. Set to ``None`` #: Seconds without a read before a card is considered removed. Set to ``None``
#: to disable removal detection entirely. #: to disable removal detection entirely.
DEFAULT_HEARTBEAT_INTERVAL = 0.5 #:
#: This is also the presence window: the card counts as "present" until this
#: long after the last frame. RDM6300 modules typically report a card once per
#: placement rather than re-reading continuously, so a short value makes
#: ``present`` flicker - and the status LED with it - while the card sits still
#: on the antenna. Longer holds presence (and the light) on longer but makes
#: lifting the card take longer to notice; raise it if a card held in place is
#: reported as removed, lower it if removal feels sluggish.
DEFAULT_HEARTBEAT_INTERVAL = 2.0
BAR = "-" * 58 BAR = "-" * 58
+53 -9
View File
@@ -10,6 +10,7 @@ from typing import Optional, Sequence
from . import __version__ from . import __version__
from .bootstrap import LED_PACKAGES, ensure_dependencies from .bootstrap import LED_PACKAGES, ensure_dependencies
from .bridge import ( from .bridge import (
DEFAULT_CLAIMED_TTL,
DEFAULT_HOST, DEFAULT_HOST,
DEFAULT_PORT, DEFAULT_PORT,
DEFAULT_TAP_TTL, DEFAULT_TAP_TTL,
@@ -19,15 +20,22 @@ from .bridge import (
create_server, create_server,
) )
from .card import ( from .card import (
BAR,
CANDIDATE_PORTS, CANDIDATE_PORTS,
DEFAULT_HEARTBEAT_INTERVAL, DEFAULT_HEARTBEAT_INTERVAL,
EVENT_INVALID, EVENT_INVALID,
EVENT_REMOVED,
CardEvent, CardEvent,
format_event, format_event,
) )
from .demo import run_demo from .demo import run_demo
from .led import DEFAULT_LED_PIN, MODES as LED_MODES, create as create_led from .led import (
DEFAULT_ERROR_LED_PIN,
DEFAULT_LED_MODE,
DEFAULT_LED_PIN,
ERROR_BLINK_SECONDS,
MODES as LED_MODES,
create as create_led,
)
from .ports import AmbiguousPort, PortNotFound, discover, resolve from .ports import AmbiguousPort, PortNotFound, discover, resolve
from .reader import available, explain_error, list_ports, open_reader, reload_library from .reader import available, explain_error, list_ports, open_reader, reload_library
@@ -137,6 +145,11 @@ def _build_led(args):
if args.no_led: if args.no_led:
return create_led(enabled=False) 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 # Only pull in gpiozero when a LED was actually asked for, so a headless
# install does not grow the dependency set. # install does not grow the dependency set.
ensure_dependencies( ensure_dependencies(
@@ -150,7 +163,11 @@ def _build_led(args):
pin=args.led_pin, pin=args.led_pin,
enabled=True, enabled=True,
mode=args.led_mode, mode=args.led_mode,
active_high=not args.led_active_low,
error_blink=not args.led_no_error_blink, 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)), logger=lambda message: print(" {}".format(message)),
) )
@@ -169,7 +186,8 @@ def _run_bridge(args) -> int:
device = _resolve_port(args) device = _resolve_port(args)
hub = EventHub(mode=args.mode, tap_ttl=args.tap_ttl) hub = EventHub(mode=args.mode, tap_ttl=args.tap_ttl,
claimed_ttl=args.claimed_ttl)
hub.set_port(device) hub.set_port(device)
led = _build_led(args) led = _build_led(args)
@@ -178,7 +196,7 @@ def _run_bridge(args) -> int:
# Drive the LED first: it is queued, so it never delays the reader. # Drive the LED first: it is queued, so it never delays the reader.
if event.event == EVENT_INVALID: if event.event == EVENT_INVALID:
led.error() led.error()
elif event.event == "card_removed": elif event.event == EVENT_REMOVED:
led.remove() led.remove()
else: else:
led.insert() led.insert()
@@ -205,6 +223,8 @@ def _run_bridge(args) -> int:
allowed_origins=args.allow_origin, allowed_origins=args.allow_origin,
token=args.token, token=args.token,
logger=print if args.verbose else None, 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, server_thread = threading.Thread(target=server.serve_forever, daemon=True,
@@ -213,7 +233,10 @@ def _run_bridge(args) -> int:
print(" OK reader on {} ; bridge on http://{}:{} (mode: {})".format( print(" OK reader on {} ; bridge on http://{}:{} (mode: {})".format(
device, args.host, args.bridge_port, args.mode)) 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/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(" POST /api/card/claim (single use, for one login)")
print(" GET /api/events (SSE stream)") print(" GET /api/events (SSE stream)")
print(" GET /api/last /api/health") print(" GET /api/last /api/health")
@@ -285,6 +308,10 @@ def build_parser() -> argparse.ArgumentParser:
bridge.add_argument("--tap-ttl", type=float, default=DEFAULT_TAP_TTL, bridge.add_argument("--tap-ttl", type=float, default=DEFAULT_TAP_TTL,
help="seconds an unclaimed tap stays claimable; 0 keeps it " help="seconds an unclaimed tap stays claimable; 0 keeps it "
"claimable while the card is present (default: %(default)s)") "claimable while the card is present (default: %(default)s)")
bridge.add_argument("--claimed-ttl", type=float, default=DEFAULT_CLAIMED_TTL,
help="seconds a claimed card stays readable so the claiming "
"page can confirm which card logged in; 0 keeps it "
"indefinitely (default: %(default)s)")
bridge.add_argument("--allow-origin", action="append", metavar="ORIGIN", bridge.add_argument("--allow-origin", action="append", metavar="ORIGIN",
help="permit this browser origin to call the API; repeatable") help="permit this browser origin to call the API; repeatable")
bridge.add_argument("--token", bridge.add_argument("--token",
@@ -292,6 +319,8 @@ def build_parser() -> argparse.ArgumentParser:
"(or set RDM_BRIDGE_TOKEN)") "(or set RDM_BRIDGE_TOKEN)")
bridge.add_argument("--allow-remote", action="store_true", bridge.add_argument("--allow-remote", action="store_true",
help="permit binding a non-loopback address") 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", bridge.add_argument("--verbose", action="store_true",
help="also print each event and each HTTP request") help="also print each event and each HTTP request")
@@ -301,12 +330,27 @@ def build_parser() -> argparse.ArgumentParser:
led.add_argument("--led-pin", type=int, default=DEFAULT_LED_PIN, led.add_argument("--led-pin", type=int, default=DEFAULT_LED_PIN,
help="GPIO pin of the status LED (default: %(default)s, " help="GPIO pin of the status LED (default: %(default)s, "
"matching the workstation app)") "matching the workstation app)")
led.add_argument("--led-mode", choices=list(LED_MODES), default=LED_MODES[0], led.add_argument("--led-mode", choices=list(LED_MODES), default=DEFAULT_LED_MODE,
help="'error' (default): lit whenever the reader is running, " help="'presence' (default): lit only while a card is on the "
"blinking means a bad read; 'presence': lit only while " "reader; 'error': lit whenever the reader is running, "
"a card is on the reader") "blinking means a bad read")
led.add_argument("--led-no-error-blink", action="store_true", led.add_argument("--led-no-error-blink", action="store_true",
help="do not blink the LED on a bad read") 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 return parser
@@ -337,7 +381,7 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
# LED first: it is queued, so it never delays the reader loop. # LED first: it is queued, so it never delays the reader loop.
if event.event == EVENT_INVALID: if event.event == EVENT_INVALID:
led.error() led.error()
elif event.event == "card_removed": elif event.event == EVENT_REMOVED:
led.remove() led.remove()
else: else:
led.insert() led.insert()
+5 -2
View File
@@ -7,6 +7,9 @@ import time
from .card import ( from .card import (
DEFAULT_HEARTBEAT_INTERVAL, DEFAULT_HEARTBEAT_INTERVAL,
EVENT_INVALID,
EVENT_INSERTED,
EVENT_REMOVED,
CardData, CardData,
CardEvent, CardEvent,
format_event, format_event,
@@ -37,12 +40,12 @@ def run_demo(
for _ in range(max(1, count)): for _ in range(max(1, count)):
card = rng.choice(DEMO_CARDS) 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) _emit(CardEvent.from_card(event_name, card, "demo"), as_json)
time.sleep(max(0.1, heartbeat)) time.sleep(max(0.1, heartbeat))
if card.is_valid: 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) time.sleep(0.3)
print("\nDemo finished.", flush=True) print("\nDemo finished.", flush=True)
+266 -55
View File
@@ -12,21 +12,35 @@ differences:
* It is explicit. Whether an LED is actually attached is reported, so "no light * It is explicit. Whether an LED is actually attached is reported, so "no light
came on" is distinguishable from "the card was not read". came on" is distinguishable from "the card was not read".
The default pin (23) matches the workstation app. **Use a separate LED from the Two LEDs, because one cannot say two things at once:
workstation's if both run on one device**, since two processes driving one pin
will fight. * 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 from __future__ import annotations
import queue import queue
import threading import threading
import time
from dataclasses import dataclass 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. #: GPIO pin of the status LED. Chosen to match the workstation app.
DEFAULT_LED_PIN = 23 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. #: How long to hold an error indication, in seconds.
ERROR_BLINK_SECONDS = 1.0 ERROR_BLINK_SECONDS = 1.0
@@ -34,19 +48,20 @@ OFF = "off"
ON = "on" ON = "on"
BLINK = "blink" BLINK = "blink"
#: Lit only while a card is on the reader, and off otherwise. This is a card
#: indicator, for when the light should mean "a card is here". The default: a
#: light that tracks the card is unambiguous, whereas a light that is always on
#: says nothing about the card.
PRESENCE = "presence"
#: Lit the whole time the app runs, and blinks on a bad read. This is a health #: Lit the whole time the app runs, and blinks on a bad read. This is a health
#: indicator: light on means the reader is alive and ready for a card. #: indicator: light on means the reader is alive and ready for a card.
ERROR = "error" ERROR = "error"
#: Lit only while a card is on the reader, and off otherwise. This is a card MODES = (PRESENCE, ERROR)
#: indicator, for when the light should mean "a card is here".
PRESENCE = "presence"
MODES = (ERROR, PRESENCE)
#: Kept for the occasional caller that only cares about the concrete commands.
_VALID_MODES = (OFF, ON, BLINK)
#: What the light means unless told otherwise.
DEFAULT_LED_MODE = PRESENCE
@dataclass @dataclass
class LEDStatus: class LEDStatus:
@@ -58,6 +73,8 @@ class LEDStatus:
mode: str = ERROR mode: str = ERROR
state: bool = False state: bool = False
reason: str = "" reason: str = ""
#: False when the LED is wired active-low (cathode to the pin).
active_high: bool = True
def to_dict(self) -> dict: def to_dict(self) -> dict:
return { return {
@@ -67,6 +84,7 @@ class LEDStatus:
"mode": self.mode, "mode": self.mode,
"state": "on" if self.state else "off", "state": "on" if self.state else "off",
"reason": self.reason, "reason": self.reason,
"active_high": self.active_high,
} }
@@ -147,6 +165,39 @@ class _Blinker(threading.Thread):
self._stop.set() 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: class StatusLED:
"""Non-blocking LED controller driven by card events. """Non-blocking LED controller driven by card events.
@@ -158,56 +209,83 @@ class StatusLED:
self, self,
pin: int = DEFAULT_LED_PIN, pin: int = DEFAULT_LED_PIN,
enabled: bool = True, enabled: bool = True,
mode: str = ERROR, mode: str = DEFAULT_LED_MODE,
active_high: bool = True, active_high: bool = True,
error_blink: bool = True, error_blink: bool = True,
logger: Optional[Callable[[str], None]] = None, 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._log = logger or (lambda message: None)
self._mode = mode if mode in MODES else ERROR self._mode = mode if mode in MODES else DEFAULT_LED_MODE
self._error_blink = error_blink self._error_blink = error_blink
self._enabled = enabled self._enabled = enabled
self._blinker: Optional[_Blinker] = None self._blinker: Optional[_Blinker] = None
self._error_pulse = max(0.0, float(error_pulse))
self._state = False 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._queue: "queue.Queue[Optional[str]]" = queue.Queue()
self._thread: Optional[threading.Thread] = None self._thread: Optional[threading.Thread] = None
if not enabled: if not enabled:
self.led: Any = NullLED(None, "LED disabled (--no-led)") self.led: Any = NullLED(None, "LED disabled (--no-led)")
self.status = LEDStatus(False, None, "null", self._mode, False, 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 return
# Check the library separately from building the pin. self.led, usable, reason = _open_led(pin, active_high, self._log, "LED")
# self.status = LEDStatus(usable, pin,
# gpiozero raises BadPinFactory when no pin provider works, and that is "gpiozero" if usable else "null", self._mode,
# a subclass of ImportError, so catching ImportError around the pin False, reason, active_high)
# construction would report "gpiozero is not installed" even when it is if usable:
# installed and simply has no usable pin library. On a Pi missing lgpio self._log("LED on GPIO {} ({} mode, {}, gpiozero)".format(
# or RPi.GPIO that message sends you to the wrong fix. pin, self._mode,
try: "active-high" if active_high else "active-low"))
import gpiozero # noqa: F401
except ImportError as exc: # A dedicated error LED, when one is wired. It reports a bad read on its
reason = "gpiozero not installed: {}".format(exc) # own pin, so the status light stays free to mean "a card is here".
self.led = NullLED(pin, reason) if error_pin is not None and error_pin == pin:
self.status = LEDStatus(False, pin, "null", self._mode, False, # Two LEDs on one pin cannot be driven independently, and opening
reason) # the pin twice makes the second open fail anyway. Treat it as "no
self._log("LED unavailable: {}".format(reason)) # 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: else:
try: self.error_led, e_usable, e_reason = _open_led(
self.led = GPIOLED(pin, active_high=active_high) error_pin, error_active_high, self._log, "Error LED")
self.status = LEDStatus(True, pin, "gpiozero", self._mode, self.error_status = LEDStatus(e_usable, error_pin,
False, "ready") "gpiozero" if e_usable else "null",
self._log("LED on GPIO {} ({} mode, gpiozero)".format( ERROR, False, e_reason,
pin, self._mode)) error_active_high)
except Exception as exc: if e_usable:
# No usable pin factory (not a Pi), pin already in use, bad pin. self._log("Error LED on GPIO {} (lit for {}s on a bad read, "
reason = ("no usable GPIO on pin {}: {}".format(pin, exc)) "{}, gpiozero)".format(
self.led = NullLED(pin, reason) error_pin, self._error_pulse,
self.status = LEDStatus(False, pin, "null", self._mode, False, "active-high" if error_active_high
reason) else "active-low"))
self._log("LED unavailable: {}".format(reason))
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: if self.status.enabled and self._mode == ERROR:
# A health indicator shows 'alive' immediately. In presence mode the # A health indicator shows 'alive' immediately. In presence mode the
@@ -216,23 +294,108 @@ class StatusLED:
self._set_state(True) self._set_state(True)
self.status.state = True self.status.state = True
self._thread = threading.Thread(target=self._worker, daemon=True,
name="led-worker")
self._thread.start()
# -- event hooks ------------------------------------------------------ # -- event hooks ------------------------------------------------------
def insert(self) -> None: def insert(self) -> None:
"""A valid card was read: off in 'error' mode, on in 'presence' mode.""" """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) self._send(OFF if self._mode == ERROR else ON)
def remove(self) -> None: def remove(self) -> None:
"""The card left the reader: dark in 'presence' mode; 'error' mode stays on.""" """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) self._send(ON if self._mode == ERROR else OFF)
def error(self) -> None: def error(self) -> None:
"""A bad read: blink, if blinking is enabled.""" """A bad read: blink the status LED, or pulse the error LED if wired.
self._send(BLINK if self._error_blink else ON)
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 ---------------------------------------------------------- # -- control ----------------------------------------------------------
@@ -246,7 +409,12 @@ class StatusLED:
def _worker(self) -> None: def _worker(self) -> None:
while True: 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: if command is None:
return return
try: try:
@@ -257,6 +425,20 @@ class StatusLED:
finally: finally:
self._queue.task_done() 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: def _apply(self, command: str) -> None:
# Blinking is used only for a bad read, and only by 'error' mode. # Blinking is used only for a bad read, and only by 'error' mode.
if command == BLINK and self._mode == ERROR: if command == BLINK and self._mode == ERROR:
@@ -289,26 +471,55 @@ class StatusLED:
def close(self) -> None: def close(self) -> None:
self._stop_blink() self._stop_blink()
self._manual_status = False
self._manual_error = False
if self._thread is not None: if self._thread is not None:
self._queue.put(None) try:
self._queue.put_nowait(None)
except Exception:
pass
try: try:
self.led.off() self.led.off()
except Exception: except Exception:
pass pass
self._set_state(False) self._set_state(False)
try:
self.error_led.off()
except Exception:
pass
self.error_status.state = False
try: try:
self.led.close() self.led.close()
except Exception: except Exception:
pass pass
try:
self.error_led.close()
except Exception:
pass
def create( def create(
enabled: bool = True, enabled: bool = True,
pin: int = DEFAULT_LED_PIN, pin: int = DEFAULT_LED_PIN,
mode: str = ERROR, mode: str = DEFAULT_LED_MODE,
active_high: bool = True,
error_blink: bool = True, error_blink: bool = True,
logger: Optional[Callable[[str], None]] = None, logger: Optional[Callable[[str], None]] = None,
error_pin: Optional[int] = None,
error_active_high: bool = True,
error_pulse: float = ERROR_BLINK_SECONDS,
) -> StatusLED: ) -> 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, 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 import os
from typing import Callable, Dict, List, Optional, Sequence 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 try: # pragma: no cover - depends on the environment
import rdm6300 import rdm6300
@@ -58,15 +65,15 @@ def _make_reader_class(callback: EventCallback):
"""Fires *callback* for each insert, removal and checksum failure.""" """Fires *callback* for each insert, removal and checksum failure."""
def card_inserted(self, card) -> None: 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: def card_removed(self, card) -> None:
if card is None: if card is None:
return 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: 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 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)