Compare commits
13 Commits
031cf0f25a
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 84569ec9ae | |||
| 74c9e0f8fa | |||
| c2b3eda649 | |||
| a399542a6e | |||
| b357b5755a | |||
| 04f0ab9efa | |||
| bbd2861897 | |||
| 882b89d6da | |||
| c720f4ca67 | |||
| 15b43d476a | |||
| a54b79e488 | |||
| c0a354b98e | |||
| 48e800adc1 |
+4
-3
@@ -5,8 +5,9 @@ build/
|
||||
dist/
|
||||
.venv/
|
||||
venv/
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
# Release build outputs: generated from the source tree, not source
|
||||
rdm-card-reader-release/
|
||||
rdm-card-reader-release-*.zip
|
||||
|
||||
@@ -9,18 +9,28 @@ expected and correct.** To see the app work without hardware, use `--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
|
||||
pseudo-terminal pretending to be an RDM6300:
|
||||
|
||||
```bash
|
||||
.venv/bin/python -m pytest tests/ -q # 152 tests, includes e2e PTY tests
|
||||
```
|
||||
To see the reader path end-to-end without hardware, drive a pseudo-terminal
|
||||
pretending to be an RDM6300 — see **Testing the reader path without a reader**
|
||||
below.
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
.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
|
||||
```
|
||||
|
||||
After `pip install .` the same thing is available as `rdm-card-reader …`.
|
||||
`pip install .` needs the network — see **Troubleshooting** below for the
|
||||
offline route.
|
||||
That is the whole install: the runtime libraries come from the bundled wheels
|
||||
and `src/` is on the path via the `.pth` file. `pip install .` also works, but
|
||||
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
|
||||
|
||||
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
|
||||
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.
|
||||
whole chain: write real RDM6300 frames into a PTY and watch the printed events.
|
||||
|
||||
To hand-feed a single frame yourself:
|
||||
|
||||
|
||||
Binary file not shown.
@@ -1,9 +1,13 @@
|
||||
# rdm-card-reader
|
||||
|
||||
Read 125 kHz RFID cards (RDM6300 reader, EM4100 cards) on a serial port and
|
||||
print what happens: **card inserted** with the card data, then **card removed**.
|
||||
Read 125 kHz RFID cards (RDM6300 reader, EM4100 cards) on a serial port. Two
|
||||
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)**
|
||||
> — 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
|
||||
|
||||
```
|
||||
@@ -99,7 +118,8 @@ wheels on first start.
|
||||
|
||||
```bash
|
||||
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 --heartbeat 1.5 # slower card-removed detection
|
||||
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` |
|
||||
| `--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 |
|
||||
| `--list-ports` | – | List candidate and identified ports, then exit |
|
||||
| `--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 |
|
||||
| `--version` | – | Print the version |
|
||||
|
||||
With `--serve`: `--host` (`127.0.0.1`), `--bridge-port` (`8765`), `--mode`
|
||||
(`login`), `--tap-ttl` (`10`), `--allow-origin` (repeatable), `--token`,
|
||||
`--allow-remote`, `--verbose`.
|
||||
With `--serve`:
|
||||
|
||||
When `--port` is omitted the ports are tried in this order, the same order the
|
||||
workstation app uses:
|
||||
| Option | Default | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `--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
|
||||
@@ -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.
|
||||
|
||||
```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
|
||||
@@ -222,9 +251,9 @@ sequenceDiagram
|
||||
C->>B: tap
|
||||
B->>B: latch the tap (tap-ttl seconds)
|
||||
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
|
||||
B-->>K: {card: {id: ...}} (once)
|
||||
B-->>K: {card: {id: ...}, consumed: true} (once, then 409)
|
||||
K->>W: POST /login {card_id}
|
||||
W->>W: card_id -> user
|
||||
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
|
||||
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
|
||||
second tab gets `409 Conflict` instead of a second login, so one physical tap
|
||||
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
|
||||
|
||||
| Method | Path | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `GET` | `/` | Status page (unless `--no-page`) |
|
||||
| `GET` | `/api/card` | The latched tap: `present`, `claimable`, `consumed`, `age_seconds`, `card` |
|
||||
| `POST` | `/api/card/claim` | Spend the tap on one login. `200`, or `409` if used/none, `410` if expired |
|
||||
| `GET` | `/api/events` | Server-Sent Events stream, pushes each event as it happens |
|
||||
| `GET` | `/api/last` | Most recent card event, or `204` |
|
||||
| `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"}` |
|
||||
| `GET` | `/api/led` | Both LEDs: pin, polarity, on/off, whether held manually |
|
||||
| `POST` | `/api/led` | `{"led": "status"\|"error", "on": true\|false}`, or `{"release": true}` |
|
||||
|
||||
`login` mode (default) publishes taps only — a tap is an authentication
|
||||
attempt. `monitor` mode also publishes removals, which is what you want for
|
||||
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
|
||||
|
||||
```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:
|
||||
|
||||
```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. |
|
||||
| Token | `--token` (or `RDM_BRIDGE_TOKEN`) is required from non-loopback callers. |
|
||||
| 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
|
||||
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
|
||||
sudo ./deploy/install.sh --origin https://your.web.app
|
||||
```
|
||||
|
||||
That installs a service account, a virtualenv, a systemd unit and a udev rule,
|
||||
then enables the service. Afterwards:
|
||||
`deploy/install.sh` is the only installer — there is no `deploy.sh`. It sets up
|
||||
a service account, a virtualenv, a udev rule and a systemd unit, then enables
|
||||
the service.
|
||||
|
||||
Tell it which device you have, since the two are not interchangeable:
|
||||
|
||||
```bash
|
||||
sudo ./deploy/install.sh --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
|
||||
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
|
||||
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
|
||||
|
||||
`/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:
|
||||
|
||||
```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 AB0XYZ12 # by serial number
|
||||
--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
|
||||
than one — silently choosing between two readers is exactly the failure this
|
||||
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
|
||||
|
||||
An LED driven by card events, on **GPIO 23** by default — the same pin the
|
||||
workstation app used.
|
||||
Two LEDs, each saying one thing. Both optional: everything works without
|
||||
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
|
||||
rdm-card-reader # lit while running, blinks on a bad read
|
||||
rdm-card-reader --led-pin 17 # a different pin
|
||||
rdm-card-reader --led-mode presence # lit only while a card is on the reader
|
||||
rdm-card-reader --no-led # no LED at all
|
||||
rdm-card-reader # status LED tracks the card; error LED pulses on a bad read
|
||||
rdm-card-reader --led-mode error # status LED lit whenever running, blinks on a bad read
|
||||
rdm-card-reader --error-led-pin 25 # error LED on a different pin
|
||||
rdm-card-reader --no-error-led # no error LED; blink the status LED instead
|
||||
rdm-card-reader --no-led # no LEDs at all
|
||||
```
|
||||
|
||||
| Flag | Default | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `--no-led` | off | Do not drive a LED |
|
||||
| `--led-pin` | `23` | GPIO pin (matches the workstation app) |
|
||||
| `--led-mode` | `error` | See the two readings below |
|
||||
| `--led-no-error-blink` | off | Do not blink on a bad read |
|
||||
| `--no-led` | off | Do not drive any LED |
|
||||
| `--led-pin` | `23` | **BCM** GPIO number of the status LED — not the physical header pin |
|
||||
| `--led-mode` | `presence` | See the two readings below |
|
||||
| `--led-active-low` | off | Status LED wired active-low (cathode to the pin) |
|
||||
| `--error-led-pin` | `24` | BCM GPIO of the error LED |
|
||||
| `--error-led-active-low` | off | Error LED wired active-low |
|
||||
| `--error-pulse` | `1.0` | Seconds to hold the error LED on a bad read |
|
||||
| `--no-error-led` | off | No error LED; blink the status LED instead |
|
||||
| `--led-no-error-blink` | off | With `--no-error-led`, do not blink on a bad read |
|
||||
|
||||
`--led-pin` takes the **BCM GPIO number**. The two are easy to confuse, and it
|
||||
is the second most common reason a LED "does not work" — the default `23` is
|
||||
**physical pin 16**, not pin 23. The full mapping is under **Wiring** below.
|
||||
|
||||
Giving the error LED the same pin as the status LED is treated as "no error
|
||||
LED": two lights on one pin cannot be driven independently, so the app says so
|
||||
and falls back to the status blink rather than failing.
|
||||
|
||||
Two modes, because the light can mean two different things:
|
||||
|
||||
| Mode | Behaviour | Use when |
|
||||
| --- | --- | --- |
|
||||
| `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` | **Lit only while a card is on the reader** | The light should mean "a card is here" |
|
||||
| `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 |
|
||||
| `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.
|
||||
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
|
||||
|
||||
@@ -393,18 +807,156 @@ point:
|
||||
refusing to run.
|
||||
* **Never crashes on a dead pin.** A failed toggle is logged and ignored.
|
||||
|
||||
Startup reports which of the three states it is in:
|
||||
Startup reports the state of each LED (the mode shown is `presence` by
|
||||
default):
|
||||
|
||||
```
|
||||
LED on GPIO 23 (error mode, gpiozero) # working
|
||||
LED unavailable: gpiozero not installed # library missing
|
||||
LED unavailable: no usable GPIO on pin 23: ... # no pin factory / pin busy
|
||||
LED on GPIO 23 (presence mode, active-high, gpiozero) # status LED working
|
||||
Error LED on GPIO 24 (lit for 1.0s on a bad read, ...) # error LED working
|
||||
LED unavailable: gpiozero not installed # library missing
|
||||
LED unavailable: no usable GPIO on pin 23: ... # no pin factory / pin busy
|
||||
```
|
||||
|
||||
That distinction matters: `BadPinFactory` subclasses `ImportError`, so a naive
|
||||
check reports "not installed" when the library is present but has no usable pin
|
||||
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
|
||||
> 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
|
||||
|
||||
The RDM6300 talks plain serial at **9600 baud, 8N1**. Connect it to the Pi's
|
||||
UART (pins 8/10 for TXD/RXD, 5 V and GND) or through a USB-to-serial adapter.
|
||||
The RDM6300 talks plain serial at **9600 baud, 8N1**.
|
||||
|
||||
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:
|
||||
|
||||
@@ -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
|
||||
`/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 |
|
||||
| `INVALID CARD` repeatedly | Noisy supply or long/unshielded wiring; shorten it |
|
||||
| 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
|
||||
pip install -e ".[dev]"
|
||||
pytest
|
||||
rdm-card-reader --demo # simulated reads, no hardware needed
|
||||
rdm-card-reader --list-ports # candidate and identified ports
|
||||
```
|
||||
|
||||
The suite needs no RFID hardware: the end-to-end tests open a pseudo-terminal,
|
||||
write real RDM6300 frames into it and assert on the CLI output. `--demo` also
|
||||
works with no hardware at all.
|
||||
To drive the real reader path with no hardware, feed RDM6300 frames into a
|
||||
pseudo-terminal — see the snippet in **[LAUNCH.md](LAUNCH.md)**.
|
||||
|
||||
### Layout
|
||||
|
||||
@@ -500,12 +1180,12 @@ src/rdm_card_reader/
|
||||
reader.py serial port handling, event callbacks
|
||||
cli.py argument parsing and program flow
|
||||
demo.py simulated reads
|
||||
tests/
|
||||
test_protocol.py RDM6300 wire format, byte for byte
|
||||
test_card.py event model and formatting
|
||||
test_reader.py port selection, using a fake reader library
|
||||
test_cli.py exit codes and output modes
|
||||
test_e2e_pty.py full stack over a pseudo-terminal
|
||||
ports.py stable serial port identification
|
||||
bridge.py the HTTP bridge behind --serve
|
||||
webui.py the status page served at GET / by the bridge
|
||||
led.py status LED
|
||||
bootstrap.py offline dependency install
|
||||
wheels/ the bundled wheels (offline install source)
|
||||
```
|
||||
|
||||
### Using it as a library
|
||||
|
||||
+220
-11
@@ -10,10 +10,31 @@
|
||||
# Options:
|
||||
# --origin URL web app origin allowed to read the API (repeatable)
|
||||
# --port PORT HTTP port for the bridge (default 8765)
|
||||
# --host ADDR bind address (default 127.0.0.1, loopback only)
|
||||
# --allow-remote permit binding a non-loopback address. Anyone who can
|
||||
# reach the port can then read taps and switch the LEDs;
|
||||
# pair it with --token
|
||||
# --token SECRET pre-shared token required from non-loopback callers
|
||||
# (or set RDM_BRIDGE_TOKEN in the environment)
|
||||
# --device PATH serial device the service opens (default /dev/serial0)
|
||||
# --led-pin N BCM GPIO number of the status LED (default 23)
|
||||
# --led-mode M 'presence' (default) or 'error'
|
||||
# --led-active-low LED wired active-low (cathode to the pin, anode to
|
||||
# 3.3 V, or an inverting driver); or the light will be on
|
||||
# with no card and off with one
|
||||
# --error-led-pin N BCM GPIO of a second LED for bad reads (default 24)
|
||||
# --no-error-led do not use an error LED; blink the status LED instead
|
||||
# --error-pulse S seconds to hold the error LED on a bad read (default 1)
|
||||
# --heartbeat S seconds before a silent reader counts as card removed
|
||||
# (default 2.0; this is also the presence/LED window)
|
||||
# --dir PATH install location (default /opt/rdm-card-reader)
|
||||
# --user NAME service account (default rdmcard)
|
||||
# --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
|
||||
|
||||
ORIGINS=()
|
||||
@@ -21,28 +42,73 @@ BRIDGE_PORT=8765
|
||||
INSTALL_DIR=/opt/rdm-card-reader
|
||||
SERVICE_USER=rdmcard
|
||||
INSTALL_SERVICE=1
|
||||
SERIAL_DEVICE=/dev/serial0
|
||||
LED_PIN=23
|
||||
LED_MODE=presence
|
||||
LED_ACTIVE_LOW=0
|
||||
ERROR_LED_PIN=24
|
||||
ERROR_LED_ACTIVE_LOW=0
|
||||
ERROR_PULSE=1.0
|
||||
HEARTBEAT=2.0
|
||||
# Loopback by default: an open card endpoint on a LAN lets anyone read who is
|
||||
# tapping, and now also toggle the LEDs. Reaching it from another machine needs
|
||||
# --allow-remote, and a token once it is off loopback.
|
||||
BRIDGE_HOST=127.0.0.1
|
||||
ALLOW_REMOTE=0
|
||||
BRIDGE_TOKEN=""
|
||||
SOURCE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--origin) ORIGINS+=("$2"); shift 2 ;;
|
||||
--port) BRIDGE_PORT="$2"; shift 2 ;;
|
||||
--host) BRIDGE_HOST="$2"; shift 2 ;;
|
||||
--allow-remote) ALLOW_REMOTE=1; shift ;;
|
||||
--token) BRIDGE_TOKEN="$2"; shift 2 ;;
|
||||
--dir) INSTALL_DIR="$2"; shift 2 ;;
|
||||
--user) SERVICE_USER="$2"; shift 2 ;;
|
||||
--device) SERIAL_DEVICE="$2"; shift 2 ;;
|
||||
--led-pin) LED_PIN="$2"; shift 2 ;;
|
||||
--led-mode) LED_MODE="$2"; shift 2 ;;
|
||||
--led-active-low) LED_ACTIVE_LOW=1; shift ;;
|
||||
--error-led-pin) ERROR_LED_PIN="$2"; shift 2 ;;
|
||||
--error-led-active-low) ERROR_LED_ACTIVE_LOW=1; shift ;;
|
||||
--error-pulse) ERROR_PULSE="$2"; shift 2 ;;
|
||||
--no-error-led) ERROR_LED_PIN=""; shift ;;
|
||||
--heartbeat) HEARTBEAT="$2"; shift 2 ;;
|
||||
--no-service) INSTALL_SERVICE=0; shift ;;
|
||||
-h|--help) sed -n '2,20p' "$0"; exit 0 ;;
|
||||
-h|--help) sed -n '2,33p' "$0"; exit 0 ;;
|
||||
*) echo "unknown option: $1" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
log() { printf '\n\033[1m==> %s\033[0m\n' "$*"; }
|
||||
warn() { printf '\033[33mwarning: %s\033[0m\n' "$*" >&2; }
|
||||
note() { printf ' %s\n' "$*"; }
|
||||
|
||||
if [[ $EUID -ne 0 ]]; then
|
||||
echo "Please run as root: sudo $0 $*" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# A non-loopback bind is opt-in, and the app refuses it without --allow-remote
|
||||
# too. Mirror that here so the failure happens at install time with an
|
||||
# explanation, rather than as a crash-looping unit.
|
||||
if [[ "$BRIDGE_HOST" != "127.0.0.1" && "$BRIDGE_HOST" != "::1" \
|
||||
&& "$BRIDGE_HOST" != "localhost" ]]; then
|
||||
if [[ "$ALLOW_REMOTE" -eq 0 ]]; then
|
||||
warn "--host $BRIDGE_HOST binds beyond loopback, which is not the default."
|
||||
warn "Re-run with --allow-remote to confirm, e.g."
|
||||
warn " sudo $0 --host $BRIDGE_HOST --allow-remote --token SECRET"
|
||||
exit 1
|
||||
fi
|
||||
if [[ -z "$BRIDGE_TOKEN" ]]; then
|
||||
warn "--host $BRIDGE_HOST with no --token: anyone who can reach port"
|
||||
warn "$BRIDGE_PORT can read card taps AND switch the status LEDs."
|
||||
warn "Add --token SECRET, and open the page with that token."
|
||||
fi
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. System packages
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -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 \
|
||||
|| 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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -60,14 +149,21 @@ log "Installing to $INSTALL_DIR"
|
||||
mkdir -p "$INSTALL_DIR"
|
||||
|
||||
# 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
|
||||
cp -a "$SOURCE_DIR/$item" "$INSTALL_DIR/"
|
||||
fi
|
||||
done
|
||||
|
||||
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 "On Debian/Raspberry Pi OS this usually means python3-venv is missing:"
|
||||
warn " sudo apt-get install python3-venv"
|
||||
@@ -172,6 +268,17 @@ fi
|
||||
usermod -aG dialout "$SERVICE_USER"
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -182,15 +289,49 @@ udevadm control --reload-rules
|
||||
udevadm trigger --subsystem-match=tty || true
|
||||
sleep 1
|
||||
|
||||
if [[ -e /dev/rdm-reader ]]; then
|
||||
echo " /dev/rdm-reader -> $(readlink -f /dev/rdm-reader)"
|
||||
else
|
||||
if [[ "$SERIAL_DEVICE" == "/dev/rdm-reader" && ! -e /dev/rdm-reader ]]; then
|
||||
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 "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
|
||||
|
||||
# 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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -204,10 +345,15 @@ for origin in "${ORIGINS[@]:-}"; do
|
||||
[[ -n "$origin" ]] && ORIGIN_ARGS+=" --allow-origin $origin"
|
||||
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
|
||||
|
||||
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()
|
||||
|
||||
# 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):
|
||||
wrapper = f"{install_dir}/.venv/bin/python -m rdm_card_reader"
|
||||
|
||||
# The token goes into an environment file, not the unit: a value on the
|
||||
# ExecStart line is readable by anything that can run 'systemctl cat', and
|
||||
# shows up in the process list. The '-' prefix makes the file optional.
|
||||
env_file = os.path.join(install_dir, "bridge.env")
|
||||
text = re.sub(r"^EnvironmentFile=.*\n?", "", text, count=1, flags=re.M)
|
||||
if bridge_token:
|
||||
with open(env_file, "w") as handle:
|
||||
handle.write(f"RDM_BRIDGE_TOKEN={bridge_token}\n")
|
||||
os.chmod(env_file, 0o600)
|
||||
text = re.sub(r"^ExecStart=", f"EnvironmentFile=-{env_file}\\nExecStart=",
|
||||
text, count=1, flags=re.M)
|
||||
print(f" token stored in {env_file} (mode 0600)")
|
||||
|
||||
led_active_args = " --led-active-low" if led_active_low == "1" else ""
|
||||
# An empty pin means --no-error-led: the status LED blinks instead.
|
||||
if error_led_pin:
|
||||
error_args = f" --error-led-pin {error_led_pin}"
|
||||
if error_led_active_low == "1":
|
||||
error_args += " --error-led-active-low"
|
||||
if error_pulse:
|
||||
error_args += f" --error-pulse {error_pulse}"
|
||||
else:
|
||||
error_args = " --no-error-led"
|
||||
|
||||
# Exposing the bridge beyond loopback is explicit: --allow-remote is required,
|
||||
# and the token protects the API once the caller is not on this machine.
|
||||
host_args = ""
|
||||
if bridge_host != "127.0.0.1":
|
||||
host_args = f" --host {bridge_host}"
|
||||
if allow_remote == "1":
|
||||
host_args += " --allow-remote"
|
||||
|
||||
command = (
|
||||
f"{wrapper} --serve "
|
||||
f"--port /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.
|
||||
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)
|
||||
# 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)
|
||||
PY
|
||||
|
||||
@@ -248,7 +453,11 @@ cat <<EOF
|
||||
|
||||
$(log "Installed")
|
||||
|
||||
Bridge : http://127.0.0.1:$BRIDGE_PORT
|
||||
Bridge : http://127.0.0.1:$BRIDGE_PORT$( [[ "$BRIDGE_HOST" != "127.0.0.1" ]] && echo " (also on http://$BRIDGE_HOST:$BRIDGE_PORT)" )
|
||||
Device : $SERIAL_DEVICE
|
||||
LED : GPIO $LED_PIN, $LED_MODE mode$( [[ "$LED_ACTIVE_LOW" -eq 1 ]] && echo ", active-low" )
|
||||
Error LED : $( [[ -n "$ERROR_LED_PIN" ]] && echo "GPIO $ERROR_LED_PIN, ${ERROR_PULSE}s on a bad read" || echo "none (status LED blinks)" )
|
||||
Heartbeat : ${HEARTBEAT}s (presence/LED window)
|
||||
Card state : curl http://127.0.0.1:$BRIDGE_PORT/api/card
|
||||
Health : curl http://127.0.0.1:$BRIDGE_PORT/api/health
|
||||
Logs : journalctl -u rdm-card-reader -f
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
Description=RDM card reader HTTP bridge (serves card taps for web login)
|
||||
Documentation=file:/opt/rdm-card-reader/README.md
|
||||
# The serial device must exist before we start, otherwise the reader opens the
|
||||
# wrong tty or none at all.
|
||||
After=dev-serial.device network-online.target
|
||||
# wrong tty or none at all. install.sh rewrites this line for the device given
|
||||
# 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
|
||||
|
||||
# Do not restart forever if the reader is genuinely absent. These are [Unit]
|
||||
@@ -17,22 +19,29 @@ User=rdmcard
|
||||
Group=dialout
|
||||
WorkingDirectory=/opt/rdm-card-reader
|
||||
|
||||
# --serve exposes the card on http://127.0.0.1:8765/api/card
|
||||
# --port points at the udev alias, so the reader is the same device every boot.
|
||||
# --serve runs the HTTP bridge, which is what makes the reader available as an
|
||||
# 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
|
||||
# the safe behaviour: a wrong guess means authenticating the wrong person.
|
||||
# The wheels for rdm6300 and pyserial are bundled inside the package, so no
|
||||
# --wheel-dir is needed here: the install works with no internet.
|
||||
ExecStart=/opt/rdm-card-reader/.venv/bin/rdm-card-reader \
|
||||
--serve \
|
||||
--port /dev/rdm-reader \
|
||||
--port /dev/serial0 \
|
||||
--mode login \
|
||||
--allow-origin https://your.web.app
|
||||
|
||||
--led-pin 23 \
|
||||
--led-mode presence \
|
||||
--heartbeat 2.0
|
||||
Restart=always
|
||||
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
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
@@ -44,7 +53,9 @@ RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
|
||||
RestrictNamespaces=true
|
||||
LockPersonality=true
|
||||
MemoryDenyWriteExecute=true
|
||||
DeviceAllow=/dev/rdm-reader rw
|
||||
DeviceAllow=/dev/serial0 rw
|
||||
DeviceAllow=/dev/gpiochip0 rw
|
||||
DeviceAllow=/dev/gpiochip4 rw
|
||||
DevicePolicy=closed
|
||||
|
||||
# stdout/stderr go here; watch with: journalctl -u rdm-card-reader -f
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
+1
-6
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
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."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.8"
|
||||
@@ -24,7 +24,6 @@ dependencies = [
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = ["pytest>=7.0"]
|
||||
# Status LED support. Installed automatically on first use by the app, or
|
||||
# explicitly with: pip install '.[led]'
|
||||
led = ["gpiozero>=2.0", "colorzero>=2.0"]
|
||||
@@ -37,7 +36,3 @@ where = ["src"]
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
rdm_card_reader = ["py.typed", "wheels/*.whl", "wheels/SHA256SUMS"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
addopts = "-q"
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.7 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 232 KiB |
@@ -30,7 +30,12 @@ from .ports import (
|
||||
discover,
|
||||
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
|
||||
|
||||
__version__ = "0.4.0"
|
||||
@@ -41,6 +46,7 @@ __all__ = [
|
||||
"CardData",
|
||||
"CardEvent",
|
||||
"AmbiguousPort",
|
||||
"DEFAULT_LED_MODE",
|
||||
"DEFAULT_LED_PIN",
|
||||
"EventHub",
|
||||
"PortError",
|
||||
|
||||
@@ -46,10 +46,6 @@ LED_PACKAGES = (
|
||||
("colorzero", "colorzero>=2.0"),
|
||||
)
|
||||
|
||||
#: The LED extras are optional: refusing to start because a LED cannot be
|
||||
#: driven would be absurd.
|
||||
OPTIONAL_IMPORTS = frozenset({"gpiozero", "colorzero"})
|
||||
|
||||
#: Shown when an install fails, so the operator knows what to do by hand.
|
||||
MANUAL_HINT = (
|
||||
"Install manually with: {python} -m pip install {requirements}"
|
||||
@@ -221,12 +217,6 @@ def install(
|
||||
return True
|
||||
|
||||
|
||||
def _is_optional(packages: Sequence[tuple]) -> bool:
|
||||
"""True when every package in the group is an optional extra."""
|
||||
names = {name for name, _ in packages}
|
||||
return bool(names) and names <= OPTIONAL_IMPORTS
|
||||
|
||||
|
||||
def ensure_dependencies(
|
||||
packages: Sequence[tuple] = REQUIRED_PACKAGES,
|
||||
wheel_dir: Optional[str] = None,
|
||||
|
||||
+186
-22
@@ -55,8 +55,10 @@ import time
|
||||
from collections import deque
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from typing import Any, Callable, Deque, Dict, List, Optional
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from .card import EVENT_INSERTED, EVENT_REMOVED, CardEvent
|
||||
from .card import EVENT_INVALID, EVENT_INSERTED, EVENT_REMOVED, CardEvent
|
||||
from .webui import render_page
|
||||
|
||||
#: Reader modes. ``login`` publishes inserts only (a tap = an authentication
|
||||
#: attempt). ``monitor`` publishes inserts and removals both.
|
||||
@@ -75,6 +77,12 @@ DEFAULT_HISTORY = 50
|
||||
#: replaced.
|
||||
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:
|
||||
"""Tracks reader state and fans card events out to waiting subscribers.
|
||||
@@ -96,6 +104,7 @@ class EventHub:
|
||||
history: int = DEFAULT_HISTORY,
|
||||
mode: str = MODE_LOGIN,
|
||||
tap_ttl: float = DEFAULT_TAP_TTL,
|
||||
claimed_ttl: float = DEFAULT_CLAIMED_TTL,
|
||||
):
|
||||
self._lock = threading.Lock()
|
||||
self._subscribers: List["deque"] = []
|
||||
@@ -104,9 +113,10 @@ class EventHub:
|
||||
self._last_at: Optional[float] = None
|
||||
self._mode = mode if mode in VALID_MODES else MODE_LOGIN
|
||||
self._started_at = time.time()
|
||||
self._counts = {EVENT_INSERTED: 0, EVENT_REMOVED: 0, "invalid_card": 0}
|
||||
self._counts = {EVENT_INSERTED: 0, EVENT_REMOVED: 0, EVENT_INVALID: 0}
|
||||
self._port: Optional[str] = None
|
||||
self._tap_ttl = float(tap_ttl if tap_ttl else 0.0)
|
||||
self._claimed_ttl = float(claimed_ttl if claimed_ttl else 0.0)
|
||||
|
||||
# The latched tap. It deliberately outlives the card leaving the
|
||||
# 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;
|
||||
``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:
|
||||
self._drop_dead_tap()
|
||||
|
||||
if self._tap is None:
|
||||
return {
|
||||
"present": False,
|
||||
@@ -200,6 +218,32 @@ class EventHub:
|
||||
"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):
|
||||
"""Atomically claim the latched tap for one authentication.
|
||||
|
||||
@@ -207,6 +251,7 @@ class EventHub:
|
||||
this call succeeds, so two concurrent requests cannot both win.
|
||||
"""
|
||||
with self._lock:
|
||||
self._drop_dead_tap()
|
||||
if self._tap is None:
|
||||
return None, "no card read yet", 409
|
||||
|
||||
@@ -245,6 +290,7 @@ class EventHub:
|
||||
|
||||
def health(self) -> Dict[str, Any]:
|
||||
with self._lock:
|
||||
self._drop_dead_tap()
|
||||
last_age = (
|
||||
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_consumed": self._consumed_at is not None,
|
||||
"tap_ttl_seconds": self._tap_ttl or None,
|
||||
"claimed_ttl_seconds": self._claimed_ttl or None,
|
||||
}
|
||||
|
||||
def last(self) -> Optional[Dict[str, Any]]:
|
||||
@@ -280,14 +327,31 @@ class _Handler(BaseHTTPRequestHandler):
|
||||
|
||||
# -- helpers ----------------------------------------------------------
|
||||
|
||||
def _origin_is_loopback(self, origin: str) -> bool:
|
||||
"""True when *origin* is this server itself or a loopback address.
|
||||
|
||||
A browser sends ``Origin`` even for same-origin requests, so without
|
||||
this the status page served from this very port could not call its own
|
||||
API. A remote site cannot forge ``Origin: http://127.0.0.1``, so
|
||||
trusting loopback here does not widen what an attacker can reach.
|
||||
"""
|
||||
host = self.headers.get("Host")
|
||||
if host and origin in ("http://" + host, "https://" + host):
|
||||
return True
|
||||
try:
|
||||
hostname = (urlsplit(origin).hostname or "").lower()
|
||||
except ValueError:
|
||||
return False
|
||||
return hostname in ("127.0.0.1", "localhost", "::1", "0.0.0.0")
|
||||
|
||||
def _cors_ok(self) -> bool:
|
||||
"""Refuse cross-origin reads unless the origin was allow-listed."""
|
||||
origin = self.headers.get("Origin")
|
||||
if not origin:
|
||||
return True # same-origin fetch or a non-browser client
|
||||
if self._origin_is_loopback(origin):
|
||||
return True
|
||||
allowed: List[str] = getattr(self.server, "allowed_origins", [])
|
||||
if not allowed:
|
||||
return False # browsers only; opt out entirely
|
||||
return origin in allowed
|
||||
|
||||
def _token_ok(self) -> bool:
|
||||
@@ -306,26 +370,52 @@ class _Handler(BaseHTTPRequestHandler):
|
||||
supplied = pair.split("=", 1)[1]
|
||||
return secrets.compare_digest(supplied, required)
|
||||
|
||||
def _allowed_origin(self) -> Optional[str]:
|
||||
"""The Origin to echo in ``Access-Control-Allow-Origin``, or None.
|
||||
|
||||
Same-origin and loopback callers are echoed so the dashboard can read
|
||||
its own API; any other origin must be on the allow-list.
|
||||
"""
|
||||
origin = self.headers.get("Origin")
|
||||
if not origin:
|
||||
return None
|
||||
if self._origin_is_loopback(origin):
|
||||
return origin
|
||||
if origin in getattr(self.server, "allowed_origins", []):
|
||||
return origin
|
||||
return None
|
||||
|
||||
def _send_json(self, payload: Any, status: int = 200) -> None:
|
||||
body = json.dumps(payload).encode("utf-8")
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
origin = self.headers.get("Origin")
|
||||
if origin and origin in getattr(self.server, "allowed_origins", []):
|
||||
self.send_header("Access-Control-Allow-Origin", origin)
|
||||
allowed = self._allowed_origin()
|
||||
if allowed:
|
||||
self.send_header("Access-Control-Allow-Origin", allowed)
|
||||
self.send_header("Vary", "Origin")
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def _send_bytes(self, body: bytes, content_type: str, status: int = 200) -> None:
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", content_type)
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def _send_page(self) -> None:
|
||||
self._send_bytes(render_page(), "text/html; charset=utf-8")
|
||||
|
||||
def _send_no_content(self) -> None:
|
||||
self.send_response(204)
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.send_header("Content-Length", "0")
|
||||
origin = self.headers.get("Origin")
|
||||
if origin and origin in getattr(self.server, "allowed_origins", []):
|
||||
self.send_header("Access-Control-Allow-Origin", origin)
|
||||
allowed = self._allowed_origin()
|
||||
if allowed:
|
||||
self.send_header("Access-Control-Allow-Origin", allowed)
|
||||
self.send_header("Vary", "Origin")
|
||||
self.end_headers()
|
||||
|
||||
@@ -343,6 +433,16 @@ class _Handler(BaseHTTPRequestHandler):
|
||||
def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API
|
||||
path = self.path.split("?", 1)[0].rstrip("/") or "/"
|
||||
|
||||
# The status page is served before the CORS and token checks, on
|
||||
# purpose: it is static, reveals nothing on its own, and has to be able
|
||||
# to load so it can prompt for a token when the API demands one.
|
||||
if path == "/":
|
||||
if getattr(self.server, "serve_page", True):
|
||||
self._send_page()
|
||||
else:
|
||||
self._send_json({"error": "not found", "path": "/"}, status=404)
|
||||
return
|
||||
|
||||
if path == "/api/health":
|
||||
self._send_json(self._hub.health())
|
||||
return
|
||||
@@ -366,6 +466,15 @@ class _Handler(BaseHTTPRequestHandler):
|
||||
self._send_json(self._hub.current_card())
|
||||
return
|
||||
|
||||
if path == "/api/led":
|
||||
controller = getattr(self.server, "led", None)
|
||||
if controller is None:
|
||||
self._send_json({"error": "LED control not available"},
|
||||
status=503)
|
||||
return
|
||||
self._send_json(controller.snapshot())
|
||||
return
|
||||
|
||||
if path == "/api/taps":
|
||||
limit = 20
|
||||
if "?" in self.path:
|
||||
@@ -387,6 +496,58 @@ class _Handler(BaseHTTPRequestHandler):
|
||||
def do_POST(self) -> None: # noqa: N802
|
||||
path = self.path.split("?", 1)[0].rstrip("/") or "/"
|
||||
|
||||
if path == "/api/led":
|
||||
if not self._cors_ok():
|
||||
self._send_json({"error": "origin not allowed"}, status=403)
|
||||
return
|
||||
if not self._token_ok():
|
||||
self._send_json({"error": "missing or invalid token"}, status=401)
|
||||
return
|
||||
controller = getattr(self.server, "led", None)
|
||||
if controller is None:
|
||||
self._send_json({"error": "LED control not available"},
|
||||
status=503)
|
||||
return
|
||||
|
||||
length = int(self.headers.get("Content-Length") or 0)
|
||||
raw = self.rfile.read(length) if length else b"{}"
|
||||
try:
|
||||
payload = json.loads(raw.decode("utf-8") or "{}")
|
||||
except ValueError:
|
||||
self._send_json({"error": "invalid JSON body"}, status=400)
|
||||
return
|
||||
|
||||
which = str(payload.get("led", ""))
|
||||
if which == "release" or payload.get("release"):
|
||||
controller.release()
|
||||
self._send_json(controller.snapshot())
|
||||
return
|
||||
|
||||
if "on" not in payload:
|
||||
self._send_json(
|
||||
{"error": "expected {\"led\": \"status|error\", "
|
||||
"\"on\": true|false}, or {\"release\": true}"},
|
||||
status=400,
|
||||
)
|
||||
return
|
||||
if which not in ("status", "error"):
|
||||
self._send_json(
|
||||
{"error": "unknown led", "valid": ["status", "error"]},
|
||||
status=400,
|
||||
)
|
||||
return
|
||||
if not controller.set_led(which, bool(payload.get("on"))):
|
||||
# The LED exists in configuration but has no usable pin, so
|
||||
# there is nothing to switch. Say so rather than 200.
|
||||
self._send_json(
|
||||
{"error": "that LED is not available",
|
||||
"state": controller.snapshot()},
|
||||
status=409,
|
||||
)
|
||||
return
|
||||
self._send_json(controller.snapshot())
|
||||
return
|
||||
|
||||
if path == "/api/card/claim":
|
||||
if not self._cors_ok():
|
||||
self._send_json({"error": "origin not allowed"}, status=403)
|
||||
@@ -431,10 +592,10 @@ class _Handler(BaseHTTPRequestHandler):
|
||||
self._send_json({"mode": mode})
|
||||
|
||||
def do_OPTIONS(self) -> None: # noqa: N802
|
||||
origin = self.headers.get("Origin")
|
||||
self.send_response(204)
|
||||
if origin and origin in getattr(self.server, "allowed_origins", []):
|
||||
self.send_header("Access-Control-Allow-Origin", origin)
|
||||
allowed = self._allowed_origin()
|
||||
if allowed:
|
||||
self.send_header("Access-Control-Allow-Origin", allowed)
|
||||
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||
self.send_header("Access-Control-Allow-Headers", "Content-Type, X-Auth-Token")
|
||||
self.send_header("Vary", "Origin")
|
||||
@@ -450,9 +611,9 @@ class _Handler(BaseHTTPRequestHandler):
|
||||
self.send_header("Content-Type", "text/event-stream")
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.send_header("Connection", "keep-alive")
|
||||
origin = self.headers.get("Origin")
|
||||
if origin and origin in getattr(self.server, "allowed_origins", []):
|
||||
self.send_header("Access-Control-Allow-Origin", origin)
|
||||
allowed = self._allowed_origin()
|
||||
if allowed:
|
||||
self.send_header("Access-Control-Allow-Origin", allowed)
|
||||
self.send_header("Vary", "Origin")
|
||||
self.end_headers()
|
||||
|
||||
@@ -487,17 +648,20 @@ def create_server(
|
||||
allowed_origins: Optional[List[str]] = None,
|
||||
token: Optional[str] = None,
|
||||
logger: Optional[Callable[[str], None]] = None,
|
||||
serve_page: bool = True,
|
||||
led: Any = None,
|
||||
) -> ThreadingHTTPServer:
|
||||
"""Build (but do not start) the HTTP server around *hub*."""
|
||||
"""Build (but do not start) the HTTP server around *hub*.
|
||||
|
||||
*led* is the LED controller, exposed for the manual override at
|
||||
``/api/led``. It is optional: without it the endpoint reports 503.
|
||||
"""
|
||||
server = ThreadingHTTPServer((host, port), _Handler)
|
||||
server.daemon_threads = True
|
||||
server.hub = hub # type: ignore[attr-defined]
|
||||
server.led = led # type: ignore[attr-defined]
|
||||
server.serve_page = serve_page # type: ignore[attr-defined]
|
||||
server.allowed_origins = list(allowed_origins or []) # type: ignore[attr-defined]
|
||||
server.token = token or os.environ.get("RDM_BRIDGE_TOKEN") or None # type: ignore[attr-defined]
|
||||
server.logger = logger # type: ignore[attr-defined]
|
||||
return server
|
||||
|
||||
|
||||
def is_loopback(host: str) -> bool:
|
||||
"""True for hosts that are not reachable off-box."""
|
||||
return host in ("127.0.0.1", "::1", "localhost")
|
||||
|
||||
@@ -18,10 +18,13 @@ except ImportError: # pragma: no cover - fallback so the model stays importable
|
||||
CardData = namedtuple("CardData", ["value", "checksum", "type", "is_valid"])
|
||||
|
||||
#: 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 = (
|
||||
"/dev/ttyS0",
|
||||
"/dev/ttyAMA0",
|
||||
"/dev/serial0",
|
||||
"/dev/ttyUSB0",
|
||||
"/dev/ttyACM0",
|
||||
)
|
||||
@@ -32,7 +35,15 @@ CONFIG_CARD_VALUE = 12886709
|
||||
|
||||
#: Seconds without a read before a card is considered removed. Set to ``None``
|
||||
#: 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
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ from typing import Optional, Sequence
|
||||
from . import __version__
|
||||
from .bootstrap import LED_PACKAGES, ensure_dependencies
|
||||
from .bridge import (
|
||||
DEFAULT_CLAIMED_TTL,
|
||||
DEFAULT_HOST,
|
||||
DEFAULT_PORT,
|
||||
DEFAULT_TAP_TTL,
|
||||
@@ -19,15 +20,22 @@ from .bridge import (
|
||||
create_server,
|
||||
)
|
||||
from .card import (
|
||||
BAR,
|
||||
CANDIDATE_PORTS,
|
||||
DEFAULT_HEARTBEAT_INTERVAL,
|
||||
EVENT_INVALID,
|
||||
EVENT_REMOVED,
|
||||
CardEvent,
|
||||
format_event,
|
||||
)
|
||||
from .demo import run_demo
|
||||
from .led import DEFAULT_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 .reader import available, explain_error, list_ports, open_reader, reload_library
|
||||
|
||||
@@ -137,6 +145,11 @@ def _build_led(args):
|
||||
if args.no_led:
|
||||
return create_led(enabled=False)
|
||||
|
||||
# A dedicated error LED is the default. The old single-LED behaviour is
|
||||
# still reachable: --no-error-led blinks the status LED instead, and
|
||||
# --error-led-pin with the status pin's number does the same thing.
|
||||
error_pin = None if args.no_error_led else args.error_led_pin
|
||||
|
||||
# Only pull in gpiozero when a LED was actually asked for, so a headless
|
||||
# install does not grow the dependency set.
|
||||
ensure_dependencies(
|
||||
@@ -150,7 +163,11 @@ def _build_led(args):
|
||||
pin=args.led_pin,
|
||||
enabled=True,
|
||||
mode=args.led_mode,
|
||||
active_high=not args.led_active_low,
|
||||
error_blink=not args.led_no_error_blink,
|
||||
error_pin=error_pin,
|
||||
error_active_high=not args.error_led_active_low,
|
||||
error_pulse=args.error_pulse,
|
||||
logger=lambda message: print(" {}".format(message)),
|
||||
)
|
||||
|
||||
@@ -169,7 +186,8 @@ def _run_bridge(args) -> int:
|
||||
|
||||
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)
|
||||
|
||||
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.
|
||||
if event.event == EVENT_INVALID:
|
||||
led.error()
|
||||
elif event.event == "card_removed":
|
||||
elif event.event == EVENT_REMOVED:
|
||||
led.remove()
|
||||
else:
|
||||
led.insert()
|
||||
@@ -205,6 +223,8 @@ def _run_bridge(args) -> int:
|
||||
allowed_origins=args.allow_origin,
|
||||
token=args.token,
|
||||
logger=print if args.verbose else None,
|
||||
serve_page=not args.no_page,
|
||||
led=led,
|
||||
)
|
||||
|
||||
server_thread = threading.Thread(target=server.serve_forever, daemon=True,
|
||||
@@ -213,7 +233,10 @@ def _run_bridge(args) -> int:
|
||||
|
||||
print(" OK reader on {} ; bridge on http://{}:{} (mode: {})".format(
|
||||
device, args.host, args.bridge_port, args.mode))
|
||||
if not args.no_page:
|
||||
print(" GET / <- status page in a browser")
|
||||
print(" GET /api/card <- the card on the reader right now")
|
||||
print(" GET /api/led <- LED state; POST to switch one by hand")
|
||||
print(" POST /api/card/claim (single use, for one login)")
|
||||
print(" GET /api/events (SSE stream)")
|
||||
print(" GET /api/last /api/health")
|
||||
@@ -285,6 +308,10 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
bridge.add_argument("--tap-ttl", type=float, default=DEFAULT_TAP_TTL,
|
||||
help="seconds an unclaimed tap stays claimable; 0 keeps it "
|
||||
"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",
|
||||
help="permit this browser origin to call the API; repeatable")
|
||||
bridge.add_argument("--token",
|
||||
@@ -292,6 +319,8 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
"(or set RDM_BRIDGE_TOKEN)")
|
||||
bridge.add_argument("--allow-remote", action="store_true",
|
||||
help="permit binding a non-loopback address")
|
||||
bridge.add_argument("--no-page", action="store_true",
|
||||
help="do not serve the status page at GET /")
|
||||
bridge.add_argument("--verbose", action="store_true",
|
||||
help="also print each event and each HTTP request")
|
||||
|
||||
@@ -301,12 +330,27 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
led.add_argument("--led-pin", type=int, default=DEFAULT_LED_PIN,
|
||||
help="GPIO pin of the status LED (default: %(default)s, "
|
||||
"matching the workstation app)")
|
||||
led.add_argument("--led-mode", choices=list(LED_MODES), default=LED_MODES[0],
|
||||
help="'error' (default): lit whenever the reader is running, "
|
||||
"blinking means a bad read; 'presence': lit only while "
|
||||
"a card is on the reader")
|
||||
led.add_argument("--led-mode", choices=list(LED_MODES), default=DEFAULT_LED_MODE,
|
||||
help="'presence' (default): lit only while a card is on the "
|
||||
"reader; 'error': lit whenever the reader is running, "
|
||||
"blinking means a bad read")
|
||||
led.add_argument("--led-no-error-blink", action="store_true",
|
||||
help="do not blink the LED on a bad read")
|
||||
led.add_argument("--led-active-low", action="store_true",
|
||||
help="LED is wired active-low (cathode to the pin, anode "
|
||||
"to 3.3 V); inverts the signal")
|
||||
led.add_argument("--error-led-pin", type=int, default=DEFAULT_ERROR_LED_PIN,
|
||||
metavar="N",
|
||||
help="BCM GPIO of a dedicated error LED, lit briefly on a "
|
||||
"bad read (default: %(default)s, matching the "
|
||||
"workstation app)")
|
||||
led.add_argument("--no-error-led", action="store_true",
|
||||
help="no dedicated error LED; blink the status LED instead")
|
||||
led.add_argument("--error-led-active-low", action="store_true",
|
||||
help="the error LED is wired active-low")
|
||||
led.add_argument("--error-pulse", type=float, default=ERROR_BLINK_SECONDS,
|
||||
help="seconds to hold the error LED on a bad read "
|
||||
"(default: %(default)s)")
|
||||
return parser
|
||||
|
||||
|
||||
@@ -337,7 +381,7 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
|
||||
# LED first: it is queued, so it never delays the reader loop.
|
||||
if event.event == EVENT_INVALID:
|
||||
led.error()
|
||||
elif event.event == "card_removed":
|
||||
elif event.event == EVENT_REMOVED:
|
||||
led.remove()
|
||||
else:
|
||||
led.insert()
|
||||
|
||||
@@ -7,6 +7,9 @@ import time
|
||||
|
||||
from .card import (
|
||||
DEFAULT_HEARTBEAT_INTERVAL,
|
||||
EVENT_INVALID,
|
||||
EVENT_INSERTED,
|
||||
EVENT_REMOVED,
|
||||
CardData,
|
||||
CardEvent,
|
||||
format_event,
|
||||
@@ -37,12 +40,12 @@ def run_demo(
|
||||
|
||||
for _ in range(max(1, count)):
|
||||
card = rng.choice(DEMO_CARDS)
|
||||
event_name = "card_inserted" if card.is_valid else "invalid_card"
|
||||
event_name = EVENT_INSERTED if card.is_valid else EVENT_INVALID
|
||||
_emit(CardEvent.from_card(event_name, card, "demo"), as_json)
|
||||
|
||||
time.sleep(max(0.1, heartbeat))
|
||||
if card.is_valid:
|
||||
_emit(CardEvent.from_card("card_removed", card, "demo"), as_json)
|
||||
_emit(CardEvent.from_card(EVENT_REMOVED, card, "demo"), as_json)
|
||||
time.sleep(0.3)
|
||||
|
||||
print("\nDemo finished.", flush=True)
|
||||
|
||||
+266
-55
@@ -12,21 +12,35 @@ differences:
|
||||
* It is explicit. Whether an LED is actually attached is reported, so "no light
|
||||
came on" is distinguishable from "the card was not read".
|
||||
|
||||
The default pin (23) matches the workstation app. **Use a separate LED from the
|
||||
workstation's if both run on one device**, since two processes driving one pin
|
||||
will fight.
|
||||
Two LEDs, because one cannot say two things at once:
|
||||
|
||||
* the **status** LED (default GPIO 23) tracks the card: lit while one is on the
|
||||
reader, in ``presence`` mode.
|
||||
* the **error** LED (default GPIO 24) is for a bad read: dark normally, lit
|
||||
briefly on a checksum failure. Without it, ``presence`` mode - the default -
|
||||
has no way at all to report an invalid card, because the status light is
|
||||
already carrying the card's presence.
|
||||
|
||||
The default status pin (23) matches the workstation app. **Use a separate LED
|
||||
from the workstation's if both run on one device**, since two processes driving
|
||||
one pin will fight.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import queue
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable, Optional
|
||||
from typing import Any, Callable, Optional, Tuple
|
||||
|
||||
#: GPIO pin of the status LED. Chosen to match the workstation app.
|
||||
DEFAULT_LED_PIN = 23
|
||||
|
||||
#: GPIO pin of the error LED. Separate from the status pin so the two can be
|
||||
#: read independently: one says "a card is here", the other "the read failed".
|
||||
DEFAULT_ERROR_LED_PIN = 24
|
||||
|
||||
#: How long to hold an error indication, in seconds.
|
||||
ERROR_BLINK_SECONDS = 1.0
|
||||
|
||||
@@ -34,19 +48,20 @@ OFF = "off"
|
||||
ON = "on"
|
||||
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
|
||||
#: indicator: light on means the reader is alive and ready for a card.
|
||||
ERROR = "error"
|
||||
|
||||
#: 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".
|
||||
PRESENCE = "presence"
|
||||
|
||||
MODES = (ERROR, PRESENCE)
|
||||
|
||||
#: Kept for the occasional caller that only cares about the concrete commands.
|
||||
_VALID_MODES = (OFF, ON, BLINK)
|
||||
MODES = (PRESENCE, ERROR)
|
||||
|
||||
#: What the light means unless told otherwise.
|
||||
DEFAULT_LED_MODE = PRESENCE
|
||||
|
||||
@dataclass
|
||||
class LEDStatus:
|
||||
@@ -58,6 +73,8 @@ class LEDStatus:
|
||||
mode: str = ERROR
|
||||
state: bool = False
|
||||
reason: str = ""
|
||||
#: False when the LED is wired active-low (cathode to the pin).
|
||||
active_high: bool = True
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
@@ -67,6 +84,7 @@ class LEDStatus:
|
||||
"mode": self.mode,
|
||||
"state": "on" if self.state else "off",
|
||||
"reason": self.reason,
|
||||
"active_high": self.active_high,
|
||||
}
|
||||
|
||||
|
||||
@@ -147,6 +165,39 @@ class _Blinker(threading.Thread):
|
||||
self._stop.set()
|
||||
|
||||
|
||||
def _open_led(
|
||||
pin: int, active_high: bool, log: Callable[[str], None], label: str
|
||||
) -> Tuple[Any, bool, str]:
|
||||
"""Build the pin object for *pin*.
|
||||
|
||||
Returns ``(led, usable, reason)``. *led* is a :class:`NullLED` when no pin
|
||||
is usable, so callers never have to special-case it.
|
||||
|
||||
The library import is checked **separately** from the pin construction on
|
||||
purpose: ``gpiozero`` raises ``BadPinFactory`` when no pin provider works,
|
||||
and that is a subclass of ``ImportError``. Catching ``ImportError`` around
|
||||
the pin construction would therefore report "gpiozero is not installed"
|
||||
even when it is installed and simply has no usable pin library - on a Pi
|
||||
missing ``lgpio`` or ``RPi.GPIO`` that message sends you to the wrong fix.
|
||||
"""
|
||||
try:
|
||||
import gpiozero # noqa: F401
|
||||
except ImportError as exc:
|
||||
reason = "gpiozero not installed: {}".format(exc)
|
||||
log("{} unavailable: {}".format(label, reason))
|
||||
return NullLED(pin, reason), False, reason
|
||||
|
||||
try:
|
||||
built = GPIOLED(pin, active_high=active_high)
|
||||
except Exception as exc:
|
||||
# No usable pin factory (not a Pi), pin already in use, bad pin.
|
||||
reason = "no usable GPIO on pin {}: {}".format(pin, exc)
|
||||
log("{} unavailable: {}".format(label, reason))
|
||||
return NullLED(pin, reason), False, reason
|
||||
|
||||
return built, True, "ready"
|
||||
|
||||
|
||||
class StatusLED:
|
||||
"""Non-blocking LED controller driven by card events.
|
||||
|
||||
@@ -158,56 +209,83 @@ class StatusLED:
|
||||
self,
|
||||
pin: int = DEFAULT_LED_PIN,
|
||||
enabled: bool = True,
|
||||
mode: str = ERROR,
|
||||
mode: str = DEFAULT_LED_MODE,
|
||||
active_high: bool = True,
|
||||
error_blink: bool = True,
|
||||
logger: Optional[Callable[[str], None]] = None,
|
||||
error_pin: Optional[int] = None,
|
||||
error_active_high: bool = True,
|
||||
error_pulse: float = ERROR_BLINK_SECONDS,
|
||||
):
|
||||
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._enabled = enabled
|
||||
self._blinker: Optional[_Blinker] = None
|
||||
self._error_pulse = max(0.0, float(error_pulse))
|
||||
self._state = False
|
||||
|
||||
self._error_until: Optional[float] = None
|
||||
self._error_state = False
|
||||
#: Set while the status page is forcing a LED, so card events and the
|
||||
#: error pulse leave it alone.
|
||||
self._manual_status = False
|
||||
self._manual_error = False
|
||||
self._queue: "queue.Queue[Optional[str]]" = queue.Queue()
|
||||
self._thread: Optional[threading.Thread] = None
|
||||
|
||||
if not enabled:
|
||||
self.led: Any = NullLED(None, "LED disabled (--no-led)")
|
||||
self.status = LEDStatus(False, None, "null", self._mode, False,
|
||||
"disabled")
|
||||
"disabled", active_high)
|
||||
self.error_led: Any = NullLED(None, "LED disabled (--no-led)")
|
||||
self.error_status = LEDStatus(False, None, "null", self._mode,
|
||||
False, "disabled", error_active_high)
|
||||
return
|
||||
|
||||
# Check the library separately from building the pin.
|
||||
#
|
||||
# gpiozero raises BadPinFactory when no pin provider works, and that is
|
||||
# a subclass of ImportError, so catching ImportError around the pin
|
||||
# construction would report "gpiozero is not installed" even when it is
|
||||
# installed and simply has no usable pin library. On a Pi missing lgpio
|
||||
# or RPi.GPIO that message sends you to the wrong fix.
|
||||
try:
|
||||
import gpiozero # noqa: F401
|
||||
except ImportError as exc:
|
||||
reason = "gpiozero not installed: {}".format(exc)
|
||||
self.led = NullLED(pin, reason)
|
||||
self.status = LEDStatus(False, pin, "null", self._mode, False,
|
||||
reason)
|
||||
self._log("LED unavailable: {}".format(reason))
|
||||
self.led, usable, reason = _open_led(pin, active_high, self._log, "LED")
|
||||
self.status = LEDStatus(usable, pin,
|
||||
"gpiozero" if usable else "null", self._mode,
|
||||
False, reason, active_high)
|
||||
if usable:
|
||||
self._log("LED on GPIO {} ({} mode, {}, gpiozero)".format(
|
||||
pin, self._mode,
|
||||
"active-high" if active_high else "active-low"))
|
||||
|
||||
# A dedicated error LED, when one is wired. It reports a bad read on its
|
||||
# own pin, so the status light stays free to mean "a card is here".
|
||||
if error_pin is not None and error_pin == pin:
|
||||
# Two LEDs on one pin cannot be driven independently, and opening
|
||||
# the pin twice makes the second open fail anyway. Treat it as "no
|
||||
# dedicated error LED" so the status LED blink is used instead.
|
||||
self._log("Error LED pin {} is the status pin; using the status "
|
||||
"LED to indicate bad reads".format(error_pin))
|
||||
error_pin = None
|
||||
|
||||
if error_pin is None:
|
||||
self.error_led = NullLED(None, "no error LED configured")
|
||||
self.error_status = LEDStatus(False, None, "null", ERROR, False,
|
||||
"not configured", error_active_high)
|
||||
else:
|
||||
try:
|
||||
self.led = GPIOLED(pin, active_high=active_high)
|
||||
self.status = LEDStatus(True, pin, "gpiozero", self._mode,
|
||||
False, "ready")
|
||||
self._log("LED on GPIO {} ({} mode, gpiozero)".format(
|
||||
pin, self._mode))
|
||||
except Exception as exc:
|
||||
# No usable pin factory (not a Pi), pin already in use, bad pin.
|
||||
reason = ("no usable GPIO on pin {}: {}".format(pin, exc))
|
||||
self.led = NullLED(pin, reason)
|
||||
self.status = LEDStatus(False, pin, "null", self._mode, False,
|
||||
reason)
|
||||
self._log("LED unavailable: {}".format(reason))
|
||||
self.error_led, e_usable, e_reason = _open_led(
|
||||
error_pin, error_active_high, self._log, "Error LED")
|
||||
self.error_status = LEDStatus(e_usable, error_pin,
|
||||
"gpiozero" if e_usable else "null",
|
||||
ERROR, False, e_reason,
|
||||
error_active_high)
|
||||
if e_usable:
|
||||
self._log("Error LED on GPIO {} (lit for {}s on a bad read, "
|
||||
"{}, gpiozero)".format(
|
||||
error_pin, self._error_pulse,
|
||||
"active-high" if error_active_high
|
||||
else "active-low"))
|
||||
|
||||
self._thread = threading.Thread(target=self._worker, daemon=True,
|
||||
name="led-worker")
|
||||
# The worker only needs to wake on a timer when there is an error pulse
|
||||
# to expire. Without one it blocks on the queue, as before.
|
||||
self._poll = (0.05 if (self.error_status.enabled
|
||||
and self._error_pulse > 0) else None)
|
||||
self._thread.start()
|
||||
|
||||
if self.status.enabled and self._mode == ERROR:
|
||||
# A health indicator shows 'alive' immediately. In presence mode the
|
||||
@@ -216,23 +294,108 @@ class StatusLED:
|
||||
self._set_state(True)
|
||||
self.status.state = True
|
||||
|
||||
self._thread = threading.Thread(target=self._worker, daemon=True,
|
||||
name="led-worker")
|
||||
self._thread.start()
|
||||
|
||||
# -- event hooks ------------------------------------------------------
|
||||
|
||||
def insert(self) -> None:
|
||||
"""A valid card was read: off in 'error' mode, on in 'presence' mode."""
|
||||
if self._manual_status:
|
||||
return
|
||||
self._send(OFF if self._mode == ERROR else ON)
|
||||
|
||||
def remove(self) -> None:
|
||||
"""The card left the reader: dark in 'presence' mode; 'error' mode stays on."""
|
||||
if self._manual_status:
|
||||
return
|
||||
self._send(ON if self._mode == ERROR else OFF)
|
||||
|
||||
def error(self) -> None:
|
||||
"""A bad read: blink, if blinking is enabled."""
|
||||
self._send(BLINK if self._error_blink else ON)
|
||||
"""A bad read: blink the status LED, or pulse the error LED if wired.
|
||||
|
||||
With a dedicated error LED the indication goes there and the status
|
||||
light is left alone, so the two LEDs stay independent: one says "a card
|
||||
is here", the other "the read failed". Without one, the status LED
|
||||
blinks as before - but only in ``error`` mode, since a blink and "card
|
||||
present" cannot both be shown on one light.
|
||||
"""
|
||||
if self._manual_status or self._manual_error:
|
||||
return
|
||||
if self.error_status.enabled and self._error_pulse > 0:
|
||||
self._error_until = time.monotonic() + self._error_pulse
|
||||
return
|
||||
if self._error_blink:
|
||||
self._send(BLINK)
|
||||
|
||||
def _pulse_done(self, now: float) -> bool:
|
||||
return self._error_until is not None and now >= self._error_until
|
||||
|
||||
def _error_indicate(self, on: bool) -> None:
|
||||
"""Set the error LED, remembering the state for :meth:`close`."""
|
||||
if on:
|
||||
self.error_led.on()
|
||||
else:
|
||||
self.error_led.off()
|
||||
self._error_state = on
|
||||
self.error_status.state = on
|
||||
|
||||
# -- manual control (diagnostics) -------------------------------------
|
||||
|
||||
def set_led(self, which: str, on: bool) -> bool:
|
||||
"""Force one LED on or off by hand. Returns False for an unknown name.
|
||||
|
||||
This exists to answer "can the software drive the pin at all?", which
|
||||
separates a wiring fault from a configuration one. It latches: the
|
||||
forced state holds until :meth:`release` or the next card event, so a
|
||||
light turned on from the status page does not go dark when the next
|
||||
poll runs.
|
||||
"""
|
||||
if which == "status":
|
||||
if not self.status.enabled:
|
||||
return False
|
||||
self._manual_status = True
|
||||
self._send(ON if on else OFF)
|
||||
# Record the requested state now: the worker applies it
|
||||
# asynchronously, and a snapshot taken before it runs would
|
||||
# otherwise contradict the response we just sent.
|
||||
self._set_state(bool(on))
|
||||
return True
|
||||
if which == "error":
|
||||
if not self.error_status.enabled:
|
||||
return False
|
||||
self._manual_error = True
|
||||
# Cancel any pulse in flight so it cannot fight the manual state.
|
||||
self._error_until = None
|
||||
self._error_indicate(bool(on))
|
||||
return True
|
||||
return False
|
||||
|
||||
def release(self) -> None:
|
||||
"""Drop any manual override and let card events drive the LEDs again."""
|
||||
self._manual_status = False
|
||||
self._manual_error = False
|
||||
self._error_until = None
|
||||
if self.status.enabled:
|
||||
# Return to the resting state for the current mode, recorded now so
|
||||
# a snapshot taken before the worker runs agrees with it.
|
||||
resting = self._mode == ERROR
|
||||
self._send(ON if resting else OFF)
|
||||
self._set_state(resting)
|
||||
if self.error_status.enabled:
|
||||
self._error_indicate(False)
|
||||
|
||||
def snapshot(self) -> dict:
|
||||
"""Both LEDs' state, for ``/api/led`` and the status page."""
|
||||
return {
|
||||
"manual": self._manual_status or self._manual_error,
|
||||
"status": self._led_info(self.status, self._manual_status),
|
||||
"error": self._led_info(self.error_status, self._manual_error),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _led_info(status: "LEDStatus", manual: bool) -> dict:
|
||||
info = status.to_dict()
|
||||
info["manual"] = manual
|
||||
info["on"] = bool(status.state)
|
||||
return info
|
||||
|
||||
# -- control ----------------------------------------------------------
|
||||
|
||||
@@ -246,7 +409,12 @@ class StatusLED:
|
||||
|
||||
def _worker(self) -> None:
|
||||
while True:
|
||||
command = self._queue.get()
|
||||
try:
|
||||
command = self._queue.get(timeout=self._poll)
|
||||
except queue.Empty:
|
||||
# No command pending: run the time-based error pulse, if any.
|
||||
self._tick_error_pulse()
|
||||
continue
|
||||
if command is None:
|
||||
return
|
||||
try:
|
||||
@@ -257,6 +425,20 @@ class StatusLED:
|
||||
finally:
|
||||
self._queue.task_done()
|
||||
|
||||
def _tick_error_pulse(self) -> None:
|
||||
"""Light the error LED for its window, then darken it again."""
|
||||
if self._error_until is None or self._manual_error:
|
||||
return
|
||||
now = time.monotonic()
|
||||
if now < self._error_until:
|
||||
if not self._error_state:
|
||||
self._error_indicate(True)
|
||||
elif self._error_state:
|
||||
self._error_indicate(False)
|
||||
else:
|
||||
# Window passed and already dark: stop tracking it.
|
||||
self._error_until = None
|
||||
|
||||
def _apply(self, command: str) -> None:
|
||||
# Blinking is used only for a bad read, and only by 'error' mode.
|
||||
if command == BLINK and self._mode == ERROR:
|
||||
@@ -289,26 +471,55 @@ class StatusLED:
|
||||
|
||||
def close(self) -> None:
|
||||
self._stop_blink()
|
||||
self._manual_status = False
|
||||
self._manual_error = False
|
||||
if self._thread is not None:
|
||||
self._queue.put(None)
|
||||
try:
|
||||
self._queue.put_nowait(None)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
self.led.off()
|
||||
except Exception:
|
||||
pass
|
||||
self._set_state(False)
|
||||
try:
|
||||
self.error_led.off()
|
||||
except Exception:
|
||||
pass
|
||||
self.error_status.state = False
|
||||
try:
|
||||
self.led.close()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
self.error_led.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def create(
|
||||
enabled: bool = True,
|
||||
pin: int = DEFAULT_LED_PIN,
|
||||
mode: str = ERROR,
|
||||
mode: str = DEFAULT_LED_MODE,
|
||||
active_high: bool = True,
|
||||
error_blink: bool = True,
|
||||
logger: Optional[Callable[[str], None]] = None,
|
||||
error_pin: Optional[int] = None,
|
||||
error_active_high: bool = True,
|
||||
error_pulse: float = ERROR_BLINK_SECONDS,
|
||||
) -> StatusLED:
|
||||
"""Build the LED controller, degrading to a no-op when GPIO is unusable."""
|
||||
"""Build the LED controller, degrading to a no-op when GPIO is unusable.
|
||||
|
||||
*active_high* is False for an LED wired active-low - cathode to the GPIO
|
||||
pin, anode to 3.3 V, or a driver that inverts. Getting this wrong does not
|
||||
fail: the light simply does the opposite of what is wanted.
|
||||
|
||||
*error_pin* enables a second, dedicated LED for bad reads. Pass the same
|
||||
value as *pin* to fall back to the single-LED behaviour.
|
||||
"""
|
||||
return StatusLED(pin=pin, enabled=enabled, mode=mode,
|
||||
error_blink=error_blink, logger=logger)
|
||||
active_high=active_high,
|
||||
error_blink=error_blink, logger=logger,
|
||||
error_pin=error_pin, error_active_high=error_active_high,
|
||||
error_pulse=error_pulse)
|
||||
|
||||
@@ -10,7 +10,14 @@ import importlib
|
||||
import os
|
||||
from typing import Callable, Dict, List, Optional, Sequence
|
||||
|
||||
from .card import CANDIDATE_PORTS, DEFAULT_HEARTBEAT_INTERVAL, CardEvent
|
||||
from .card import (
|
||||
CANDIDATE_PORTS,
|
||||
DEFAULT_HEARTBEAT_INTERVAL,
|
||||
EVENT_INVALID,
|
||||
EVENT_INSERTED,
|
||||
EVENT_REMOVED,
|
||||
CardEvent,
|
||||
)
|
||||
|
||||
try: # pragma: no cover - depends on the environment
|
||||
import rdm6300
|
||||
@@ -58,15 +65,15 @@ def _make_reader_class(callback: EventCallback):
|
||||
"""Fires *callback* for each insert, removal and checksum failure."""
|
||||
|
||||
def card_inserted(self, card) -> None:
|
||||
callback(CardEvent.from_card("card_inserted", card, self.port))
|
||||
callback(CardEvent.from_card(EVENT_INSERTED, card, self.port))
|
||||
|
||||
def card_removed(self, card) -> None:
|
||||
if card is None:
|
||||
return
|
||||
callback(CardEvent.from_card("card_removed", card, self.port))
|
||||
callback(CardEvent.from_card(EVENT_REMOVED, card, self.port))
|
||||
|
||||
def invalid_card(self, card) -> None:
|
||||
callback(CardEvent.from_card("invalid_card", card, self.port))
|
||||
callback(CardEvent.from_card(EVENT_INVALID, card, self.port))
|
||||
|
||||
return CardReader
|
||||
|
||||
|
||||
@@ -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…</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">—</dd>
|
||||
<dt>port</dt> <dd id="port">—</dd>
|
||||
<dt>uptime</dt> <dd id="uptime">—</dd>
|
||||
<dt>card present</dt><dd id="present">—</dd>
|
||||
<dt>claimable</dt> <dd id="claimable">—</dd>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Current card</h2>
|
||||
<dl>
|
||||
<dt>id</dt> <dd id="c-id">—</dd>
|
||||
<dt>hex</dt> <dd id="c-hex">—</dd>
|
||||
<dt>type</dt> <dd id="c-type">—</dd>
|
||||
<dt>valid</dt> <dd id="c-valid">—</dd>
|
||||
<dt>age</dt> <dd id="c-age">—</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">—</dd>
|
||||
<dt>removed</dt> <dd id="n-removed">—</dd>
|
||||
<dt>invalid</dt> <dd id="n-invalid">—</dd>
|
||||
<dt>subscribers</dt> <dd id="n-subs">—</dd>
|
||||
<dt>tap / claimed TTL</dt><dd id="n-ttl">—</dd>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>LEDs</h2>
|
||||
<dl>
|
||||
<dt>green (status)</dt> <dd id="led-st-state">—</dd>
|
||||
<dt>red (error)</dt> <dd id="led-er-state">—</dd>
|
||||
<dt>status pin</dt> <dd id="led-st-pin">—</dd>
|
||||
<dt>error pin</dt> <dd id="led-er-pin">—</dd>
|
||||
<dt>driven by</dt> <dd id="led-auto">—</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…</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> · " + event.hex +
|
||||
" · " + hex2(event.type) +
|
||||
(event.is_valid ? "" : " · 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")
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
)
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user