Compare commits
6 Commits
main
..
031cf0f25a
| Author | SHA1 | Date | |
|---|---|---|---|
| 031cf0f25a | |||
| 2ded37e190 | |||
| 674d91a5ca | |||
| 23cd8f2df5 | |||
| eaf3f394fc | |||
| 576f22268f |
+3
-4
@@ -5,9 +5,8 @@ build/
|
|||||||
dist/
|
dist/
|
||||||
.venv/
|
.venv/
|
||||||
venv/
|
venv/
|
||||||
|
.pytest_cache/
|
||||||
|
.coverage
|
||||||
|
htmlcov/
|
||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
|
|
||||||
# Release build outputs: generated from the source tree, not source
|
|
||||||
rdm-card-reader-release/
|
|
||||||
rdm-card-reader-release-*.zip
|
|
||||||
|
|||||||
@@ -9,28 +9,18 @@ expected and correct.** To see the app work without hardware, use `--demo`:
|
|||||||
.venv/bin/python -m rdm_card_reader --demo
|
.venv/bin/python -m rdm_card_reader --demo
|
||||||
```
|
```
|
||||||
|
|
||||||
To see the reader path end-to-end without hardware, drive a pseudo-terminal
|
To see the reader path run end-to-end without hardware, the test suite drives a
|
||||||
pretending to be an RDM6300 — see **Testing the reader path without a reader**
|
pseudo-terminal pretending to be an RDM6300:
|
||||||
below.
|
|
||||||
|
```bash
|
||||||
|
.venv/bin/python -m pytest tests/ -q # 152 tests, includes e2e PTY tests
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Quick start
|
## Quick start
|
||||||
|
|
||||||
Build the virtualenv once, from the wheels bundled in the source tree — no
|
All commands run from the project root. The virtualenv is already built.
|
||||||
network needed:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python3 -m venv .venv
|
|
||||||
.venv/bin/pip install --no-index \
|
|
||||||
--find-links src/rdm_card_reader/wheels rdm6300 pyserial
|
|
||||||
echo "$PWD/src" > "$(.venv/bin/python -c \
|
|
||||||
'import sysconfig; print(sysconfig.get_paths()["purelib"])')/rdm_card_reader_src.pth"
|
|
||||||
```
|
|
||||||
|
|
||||||
For the status LED, add `gpiozero colorzero` to the same `pip install` line.
|
|
||||||
|
|
||||||
Then, from the project root:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
.venv/bin/python -m rdm_card_reader --demo # simulate a card, no hardware
|
.venv/bin/python -m rdm_card_reader --demo # simulate a card, no hardware
|
||||||
@@ -39,9 +29,9 @@ Then, from the project root:
|
|||||||
.venv/bin/python -m rdm_card_reader --serve # HTTP bridge for a web page
|
.venv/bin/python -m rdm_card_reader --serve # HTTP bridge for a web page
|
||||||
```
|
```
|
||||||
|
|
||||||
That is the whole install: the runtime libraries come from the bundled wheels
|
After `pip install .` the same thing is available as `rdm-card-reader …`.
|
||||||
and `src/` is on the path via the `.pth` file. `pip install .` also works, but
|
`pip install .` needs the network — see **Troubleshooting** below for the
|
||||||
only with network — it builds the project. See **Troubleshooting** below.
|
offline route.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -340,7 +330,9 @@ somewhere else, but `src/` must be present or the wheels will be missing.
|
|||||||
## Testing the reader path without a reader
|
## Testing the reader path without a reader
|
||||||
|
|
||||||
Because the app talks plain serial, a pseudo-terminal is enough to exercise the
|
Because the app talks plain serial, a pseudo-terminal is enough to exercise the
|
||||||
whole chain: write real RDM6300 frames into a PTY and watch the printed events.
|
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.
|
||||||
|
|
||||||
To hand-feed a single frame yourself:
|
To hand-feed a single frame yourself:
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
@@ -1,13 +1,9 @@
|
|||||||
# rdm-card-reader
|
# rdm-card-reader
|
||||||
|
|
||||||
Read 125 kHz RFID cards (RDM6300 reader, EM4100 cards) on a serial port. Two
|
Read 125 kHz RFID cards (RDM6300 reader, EM4100 cards) on a serial port and
|
||||||
ways to use it:
|
print what happens: **card inserted** with the card data, then **card removed**.
|
||||||
|
|
||||||
* **print events** — card inserted with the card data, then card removed
|
Built for a Raspberry Pi workstation, but it runs anywhere with a serial port.
|
||||||
* **serve an API** (`--serve`) — expose the card on the reader over HTTP so a
|
|
||||||
web app can authenticate a card tap
|
|
||||||
|
|
||||||
Built for a Raspberry Pi, but it runs anywhere with a serial port.
|
|
||||||
|
|
||||||
> Starting it on a machine with no reader attached? See **[LAUNCH.md](LAUNCH.md)**
|
> Starting it on a machine with no reader attached? See **[LAUNCH.md](LAUNCH.md)**
|
||||||
> — it explains the startup sequence, why it exits `1` with no hardware, and how
|
> — it explains the startup sequence, why it exits `1` with no hardware, and how
|
||||||
@@ -15,21 +11,6 @@ Built for a Raspberry Pi, but it runs anywhere with a serial port.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Contents
|
|
||||||
|
|
||||||
- [What it does](#what-it-does)
|
|
||||||
- [Install](#install)
|
|
||||||
- [Usage](#usage)
|
|
||||||
- [Serving card data to a web app (`--serve`)](#serving-card-data-to-a-web-app---serve)
|
|
||||||
- [Quick start on a Raspberry Pi](#quick-start-on-a-raspberry-pi)
|
|
||||||
- [Status LED](#status-led)
|
|
||||||
- [Wiring](#wiring)
|
|
||||||
- [The protocol](#the-protocol)
|
|
||||||
- [Troubleshooting](#troubleshooting)
|
|
||||||
- [Check it works](#check-it-works)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## What it does
|
## What it does
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -118,8 +99,7 @@ wheels on first start.
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
rdm-card-reader # auto-detect the serial port
|
rdm-card-reader # auto-detect the serial port
|
||||||
rdm-card-reader --port /dev/serial0 # the Pi's on-board UART
|
rdm-card-reader --port /dev/ttyUSB0 # use a specific port
|
||||||
rdm-card-reader --port /dev/ttyUSB0 # a USB adapter
|
|
||||||
rdm-card-reader --json # one JSON object per event
|
rdm-card-reader --json # one JSON object per event
|
||||||
rdm-card-reader --heartbeat 1.5 # slower card-removed detection
|
rdm-card-reader --heartbeat 1.5 # slower card-removed detection
|
||||||
rdm-card-reader --list-ports # show which ports exist, then exit
|
rdm-card-reader --list-ports # show which ports exist, then exit
|
||||||
@@ -133,7 +113,7 @@ Also works as a module: `python -m rdm_card_reader`.
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `--port` | auto-detect | Serial port: device path, by-id symlink, serial number or `vid:pid` |
|
| `--port` | auto-detect | Serial port: device path, by-id symlink, serial number or `vid:pid` |
|
||||||
| `--autodetect` | off | Pick the only USB adapter; refuse if there is more than one |
|
| `--autodetect` | off | Pick the only USB adapter; refuse if there is more than one |
|
||||||
| `--heartbeat` | `2.0` | Seconds without a read before the card counts as removed. Also the presence/LED window |
|
| `--heartbeat` | `0.5` | Seconds without a read before the card counts as removed |
|
||||||
| `--json` | off | Machine-readable output instead of the text block |
|
| `--json` | off | Machine-readable output instead of the text block |
|
||||||
| `--list-ports` | – | List candidate and identified ports, then exit |
|
| `--list-ports` | – | List candidate and identified ports, then exit |
|
||||||
| `--verbose-ports` | off | With `--list-ports`, show by-id / by-path detail |
|
| `--verbose-ports` | off | With `--list-ports`, show by-id / by-path detail |
|
||||||
@@ -143,24 +123,15 @@ Also works as a module: `python -m rdm_card_reader`.
|
|||||||
| `--wheel-dir` | bundled | Install from local wheels in this DIR instead of the bundled ones |
|
| `--wheel-dir` | bundled | Install from local wheels in this DIR instead of the bundled ones |
|
||||||
| `--version` | – | Print the version |
|
| `--version` | – | Print the version |
|
||||||
|
|
||||||
With `--serve`:
|
With `--serve`: `--host` (`127.0.0.1`), `--bridge-port` (`8765`), `--mode`
|
||||||
|
(`login`), `--tap-ttl` (`10`), `--allow-origin` (repeatable), `--token`,
|
||||||
|
`--allow-remote`, `--verbose`.
|
||||||
|
|
||||||
| Option | Default | Meaning |
|
When `--port` is omitted the ports are tried in this order, the same order the
|
||||||
| --- | --- | --- |
|
workstation app uses:
|
||||||
| `--host` | `127.0.0.1` | Bind address; anything else needs `--allow-remote` |
|
|
||||||
| `--bridge-port` | `8765` | HTTP port |
|
|
||||||
| `--mode` | `login` | `login` publishes taps only; `monitor` also publishes removals |
|
|
||||||
| `--tap-ttl` | `10` | Seconds an unclaimed tap stays claimable; `0` keeps it until replaced. Keep it ≥ `--heartbeat` |
|
|
||||||
| `--claimed-ttl` | `30` | Seconds a claimed card stays readable; `0` keeps it indefinitely |
|
|
||||||
| `--allow-origin` | – | Permit this browser origin (repeatable) |
|
|
||||||
| `--token` | – | Pre-shared token for non-local callers (or `RDM_BRIDGE_TOKEN`) |
|
|
||||||
| `--allow-remote` | off | Permit binding a non-loopback address |
|
|
||||||
| `--verbose` | off | Also print each event and each HTTP request |
|
|
||||||
|
|
||||||
When `--port` is omitted the ports are tried in this order:
|
|
||||||
|
|
||||||
```
|
```
|
||||||
/dev/ttyS0 -> /dev/ttyAMA0 -> /dev/serial0 -> /dev/ttyUSB0 -> /dev/ttyACM0
|
/dev/ttyS0 -> /dev/ttyAMA0 -> /dev/ttyUSB0 -> /dev/ttyACM0
|
||||||
```
|
```
|
||||||
|
|
||||||
### Output modes
|
### Output modes
|
||||||
@@ -233,7 +204,7 @@ This is the mode for **authenticating a web page**: the reader runs as a service
|
|||||||
on the device, and the page asks it for the card that was just tapped.
|
on the device, and the page asks it for the card that was just tapped.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
rdm-card-reader --serve --port /dev/serial0 --allow-origin https://your.web.app
|
rdm-card-reader --serve --port /dev/rdm-reader --allow-origin https://your.web.app
|
||||||
```
|
```
|
||||||
|
|
||||||
The bridge reports **card ids only**. Deciding which user a card belongs to is
|
The bridge reports **card ids only**. Deciding which user a card belongs to is
|
||||||
@@ -251,9 +222,9 @@ sequenceDiagram
|
|||||||
C->>B: tap
|
C->>B: tap
|
||||||
B->>B: latch the tap (tap-ttl seconds)
|
B->>B: latch the tap (tap-ttl seconds)
|
||||||
K->>B: GET /api/card
|
K->>B: GET /api/card
|
||||||
B-->>K: {present: true, claimable: true, card: {id: ...}}
|
B-->>K: {claimable: true, card: {id: ...}}
|
||||||
K->>B: POST /api/card/claim
|
K->>B: POST /api/card/claim
|
||||||
B-->>K: {card: {id: ...}, consumed: true} (once, then 409)
|
B-->>K: {card: {id: ...}} (once)
|
||||||
K->>W: POST /login {card_id}
|
K->>W: POST /login {card_id}
|
||||||
W->>W: card_id -> user
|
W->>W: card_id -> user
|
||||||
W-->>K: session
|
W-->>K: session
|
||||||
@@ -263,111 +234,26 @@ The point of the *latch* is that the tap stays available for `--tap-ttl`
|
|||||||
seconds (default 10), **including after the card is lifted**. A quick tap
|
seconds (default 10), **including after the card is lifted**. A quick tap
|
||||||
therefore cannot be lost because the page happened to poll a moment later.
|
therefore cannot be lost because the page happened to poll a moment later.
|
||||||
|
|
||||||
`present` and `card` answer different questions, which is the part that
|
|
||||||
surprises people:
|
|
||||||
|
|
||||||
| Field | Meaning |
|
|
||||||
| --- | --- |
|
|
||||||
| `present` | the card is physically on the reader **right now** — goes `false` the moment it is lifted |
|
|
||||||
| `card` | the **latched tap** — survives the card being lifted, so you still know which card it was |
|
|
||||||
|
|
||||||
So a card that was tapped and lifted a second ago reports `present: false` with
|
|
||||||
`card` still populated. That is correct, not a bug: if the id vanished with the
|
|
||||||
card, any poll arriving after the lift could not authenticate anything.
|
|
||||||
|
|
||||||
The point of *claim* is single use. Once a tap is spent, a page reload or a
|
The point of *claim* is single use. Once a tap is spent, a page reload or a
|
||||||
second tab gets `409 Conflict` instead of a second login, so one physical tap
|
second tab gets `409 Conflict` instead of a second login, so one physical tap
|
||||||
can never authenticate two sessions.
|
can never authenticate two sessions.
|
||||||
|
|
||||||
Two independent windows govern how long the card stays readable:
|
|
||||||
|
|
||||||
| Window | Flag | Default | Governs |
|
|
||||||
| --- | --- | --- | --- |
|
|
||||||
| Unclaimed | `--tap-ttl` | 10 s | how long a tap can still be spent on a login |
|
|
||||||
| Claimed | `--claimed-ttl` | 30 s | how long a claimed card stays readable, so the page that claimed it can confirm which card logged in |
|
|
||||||
|
|
||||||
Put together, `/api/card` moves through these states:
|
|
||||||
|
|
||||||
| Moment | `present` | `card` | `claimable` | `consumed` |
|
|
||||||
| --- | --- | --- | --- | --- |
|
|
||||||
| Card held on the reader | `true` | id | `true` | `false` |
|
|
||||||
| Lifted, within `--tap-ttl` | `false` | id | `true` | `false` |
|
|
||||||
| Claimed, within `--claimed-ttl` | `false` | id | `false` | `true` |
|
|
||||||
| After the claimed window | `false` | `null` | `false` | `false` |
|
|
||||||
| Unclaimed and past `--tap-ttl` | `false` | `null` | `false` | `false` |
|
|
||||||
|
|
||||||
The last two rows are the point: an id that can no longer be acted on is
|
|
||||||
cleared, so it cannot be mistaken for a tap still waiting to be used.
|
|
||||||
`/api/last` and `/api/taps` keep the history either way. Setting either value to
|
|
||||||
`0` disables that expiry.
|
|
||||||
|
|
||||||
> **Set `--tap-ttl` at least as long as `--heartbeat`, or longer.**
|
|
||||||
> `--tap-ttl` applies to the tap, `--heartbeat` to presence, and they are
|
|
||||||
> independent. If the tap expires *before* the heartbeat declares the card
|
|
||||||
> removed, a card still sitting on the reader has its id cleared and reads as
|
|
||||||
> `"card": null` until the next re-read — which on a reader that reports once
|
|
||||||
> per placement may be a long time. Matching them (`--heartbeat 10 --tap-ttl 10`)
|
|
||||||
> avoids the gap for a card held in place.
|
|
||||||
|
|
||||||
### Status page
|
|
||||||
|
|
||||||
Serving also puts a **status page** at `GET /` on the same port, so you can see
|
|
||||||
what the reader is doing from a browser:
|
|
||||||
|
|
||||||
```
|
|
||||||
http://127.0.0.1:8765/
|
|
||||||
```
|
|
||||||
|
|
||||||
It shows the reader's mode, port, uptime, whether a card is present and
|
|
||||||
claimable, the current card, the event counters, and a live event log. The log
|
|
||||||
is seeded with recent history from `/api/taps` and then updated live over the
|
|
||||||
`/api/events` SSE stream, so it keeps filling in without a refresh.
|
|
||||||
|
|
||||||
The page is **read-only**: it deliberately has no "claim" button, because
|
|
||||||
`POST /api/card/claim` spends the tap, and a page that claimed would silently
|
|
||||||
consume a real login just for being open. Claim from your own app instead.
|
|
||||||
|
|
||||||
Disable it with `--no-page` if you want the port to serve the API only.
|
|
||||||
|
|
||||||
The page includes an **LED panel** with buttons to switch either LED on or off
|
|
||||||
by hand, for the case where the light does not come on and you need to know
|
|
||||||
whether the software can drive the pin at all. A forced state is held until you
|
|
||||||
press *hand back to card events*, so card events do not immediately undo it.
|
|
||||||
The same is reachable without a browser:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -sS http://127.0.0.1:8765/api/led # read the state
|
|
||||||
curl -sS -X POST -H 'Content-Type: application/json' \
|
|
||||||
-d '{"led":"status","on":true}' http://127.0.0.1:8765/api/led
|
|
||||||
curl -sS -X POST -H 'Content-Type: application/json' \
|
|
||||||
-d '{"release":true}' http://127.0.0.1:8765/api/led
|
|
||||||
```
|
|
||||||
|
|
||||||
### Endpoints
|
### Endpoints
|
||||||
|
|
||||||
| Method | Path | Purpose |
|
| Method | Path | Purpose |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `GET` | `/` | Status page (unless `--no-page`) |
|
|
||||||
| `GET` | `/api/card` | The latched tap: `present`, `claimable`, `consumed`, `age_seconds`, `card` |
|
| `GET` | `/api/card` | The latched tap: `present`, `claimable`, `consumed`, `age_seconds`, `card` |
|
||||||
| `POST` | `/api/card/claim` | Spend the tap on one login. `200`, or `409` if used/none, `410` if expired |
|
| `POST` | `/api/card/claim` | Spend the tap on one login. `200`, or `409` if used/none, `410` if expired |
|
||||||
| `GET` | `/api/events` | Server-Sent Events stream, pushes each event as it happens |
|
| `GET` | `/api/events` | Server-Sent Events stream, pushes each event as it happens |
|
||||||
| `GET` | `/api/last` | Most recent card event, or `204` |
|
| `GET` | `/api/last` | Most recent card event, or `204` |
|
||||||
| `GET` | `/api/taps` | Recent tap history (`?limit=N`) |
|
| `GET` | `/api/taps` | Recent tap history (`?limit=N`) |
|
||||||
| `GET` | `/api/health` | Reader state, port, uptime, tap counts, both TTLs — for monitoring |
|
| `GET` | `/api/health` | Reader state, port, uptime, tap counts — for monitoring |
|
||||||
| `POST` | `/api/mode` | `{"mode": "login"}` or `{"mode": "monitor"}` |
|
| `POST` | `/api/mode` | `{"mode": "login"}` or `{"mode": "monitor"}` |
|
||||||
| `GET` | `/api/led` | Both LEDs: pin, polarity, on/off, whether held manually |
|
|
||||||
| `POST` | `/api/led` | `{"led": "status"\|"error", "on": true\|false}`, or `{"release": true}` |
|
|
||||||
|
|
||||||
`login` mode (default) publishes taps only — a tap is an authentication
|
`login` mode (default) publishes taps only — a tap is an authentication
|
||||||
attempt. `monitor` mode also publishes removals, which is what you want for
|
attempt. `monitor` mode also publishes removals, which is what you want for
|
||||||
watching a reader rather than logging people in.
|
watching a reader rather than logging people in.
|
||||||
|
|
||||||
`/api/health` is the liveness check: `status: "ok"` with a rising
|
|
||||||
`uptime_seconds` means the service is serving. It is answered **before** the
|
|
||||||
origin and token checks, so it stays readable by any client that can reach the
|
|
||||||
port — which is the intent on loopback, but means binding with `--allow-remote`
|
|
||||||
exposes the port, mode, uptime and tap counts to the network.
|
|
||||||
|
|
||||||
### Browser example
|
### Browser example
|
||||||
|
|
||||||
```js
|
```js
|
||||||
@@ -385,15 +271,6 @@ if (state.claimable) {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Already claimed, and you just want to know *which* card it was? The id stays
|
|
||||||
readable for `--claimed-ttl` (default 30 s) after the claim, with
|
|
||||||
`consumed: true`:
|
|
||||||
|
|
||||||
```js
|
|
||||||
const { card, consumed } = await (await fetch("http://127.0.0.1:8765/api/card")).json();
|
|
||||||
if (consumed && card) console.log("that tap was card", card.id);
|
|
||||||
```
|
|
||||||
|
|
||||||
Or push instead of poll, so a tap logs in immediately:
|
Or push instead of poll, so a tap logs in immediately:
|
||||||
|
|
||||||
```js
|
```js
|
||||||
@@ -413,8 +290,7 @@ they do not make the card itself convincing as a sole credential.
|
|||||||
| Origins | Cross-origin browser requests are **refused** unless `--allow-origin` lists that exact origin. Stops another site from reading your taps. |
|
| Origins | Cross-origin browser requests are **refused** unless `--allow-origin` lists that exact origin. Stops another site from reading your taps. |
|
||||||
| Token | `--token` (or `RDM_BRIDGE_TOKEN`) is required from non-loopback callers. |
|
| Token | `--token` (or `RDM_BRIDGE_TOKEN`) is required from non-loopback callers. |
|
||||||
| Single use | A tap can only ever produce one successful claim. |
|
| Single use | A tap can only ever produce one successful claim. |
|
||||||
| Expiry | An unclaimed tap older than `--tap-ttl` is refused with `410`. |
|
| Expiry | A tap older than `--tap-ttl` is refused with `410`. |
|
||||||
| Bounded retention | A claimed tap stays readable for `--claimed-ttl` (30 s), then the id is cleared — an id that can no longer be acted on is never left in the response. |
|
|
||||||
|
|
||||||
Add a second factor — a PIN after the tap — wherever a cloned card would
|
Add a second factor — a PIN after the tap — wherever a cloned card would
|
||||||
actually cost you something.
|
actually cost you something.
|
||||||
@@ -427,273 +303,21 @@ actually cost you something.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Quick start on a Raspberry Pi
|
## Running at boot (Raspberry Pi)
|
||||||
|
|
||||||
End-to-end: enable the serial port, confirm the reader is visible, install the
|
|
||||||
service, verify it runs. Steps 1–2 change nothing on disk and are safe to run
|
|
||||||
first.
|
|
||||||
|
|
||||||
### 1. Enable the serial port
|
|
||||||
|
|
||||||
`/dev/serial0` is the Pi's on-board UART, and it does not exist until the UART
|
|
||||||
is switched on:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo raspi-config
|
|
||||||
# Interface Options -> Serial Port
|
|
||||||
# "Would you like a login shell to be accessible over serial?" -> No
|
|
||||||
# "Would you like the serial port hardware to be enabled?" -> Yes
|
|
||||||
sudo reboot
|
|
||||||
```
|
|
||||||
|
|
||||||
Menu numbering shifts between `raspi-config` releases, so navigate by name:
|
|
||||||
**Interface Options**, then **Serial Port**.
|
|
||||||
|
|
||||||
**Both answers matter.** Answering *Yes* to the login shell leaves a console on
|
|
||||||
the port, so the reader gets nothing: the app either reports `device not found`
|
|
||||||
or opens the port and never sees a card.
|
|
||||||
|
|
||||||
### 2. Confirm the device appears
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ls -l /dev/serial0 /dev/rdm-reader 2>&1
|
|
||||||
```
|
|
||||||
|
|
||||||
| What you see | Wiring | Device to pass |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `/dev/serial0 -> ttyAMA0` (or `ttyS0`) | UART, header pins **8 / 10** | `--device /dev/serial0` |
|
|
||||||
| `/dev/rdm-reader` | USB-to-serial adapter | `--device /dev/rdm-reader` |
|
|
||||||
| `No such file or directory` for both | nothing openable yet | see below |
|
|
||||||
|
|
||||||
`/dev/rdm-reader` is created by the udev rule and matches a **USB** bridge chip,
|
|
||||||
so it can never exist on a UART setup. The two are mutually exclusive — a
|
|
||||||
service pointing at the wrong one exits `1` on every start.
|
|
||||||
|
|
||||||
If neither exists, ask what the system does see:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ls /dev/ttyUSB* /dev/ttyACM* /dev/ttyS* 2>/dev/null
|
|
||||||
dmesg | grep -iE "ttyUSB|ttyAMA|ch34|ftdi|cp210|pl2303" | tail
|
|
||||||
```
|
|
||||||
|
|
||||||
No `ttyUSB`/`ttyACM` line in `dmesg` means the adapter is not enumerating at
|
|
||||||
all — a cable, power or hardware problem that no `--device` value can fix.
|
|
||||||
|
|
||||||
If a `/dev/ttyUSB0` appears but `/dev/rdm-reader` does not, the adapter's chip
|
|
||||||
is not the CH340 the rule matches by default. See **Identifying the right
|
|
||||||
ttyUSB** below.
|
|
||||||
|
|
||||||
### 3. Install the service
|
|
||||||
|
|
||||||
The defaults already match the common wiring — `/dev/serial0` for the reader on
|
|
||||||
the UART, GPIO 23 for the LED:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo ./deploy/install.sh --origin https://your.web.app
|
sudo ./deploy/install.sh --origin https://your.web.app
|
||||||
```
|
```
|
||||||
|
|
||||||
`deploy/install.sh` is the only installer — there is no `deploy.sh`. It sets up
|
That installs a service account, a virtualenv, a systemd unit and a udev rule,
|
||||||
a service account, a virtualenv, a udev rule and a systemd unit, then enables
|
then enables the service. Afterwards:
|
||||||
the service.
|
|
||||||
|
|
||||||
Tell it which device you have, since the two are not interchangeable:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo ./deploy/install.sh --device /dev/serial0 # UART, pins 8 / 10
|
|
||||||
sudo ./deploy/install.sh --device /dev/rdm-reader # USB adapter
|
|
||||||
```
|
|
||||||
|
|
||||||
It refuses to enable the service unless the device exists **and** opens at
|
|
||||||
9600 baud, so a wrong port fails here instead of becoming a crash-looping unit
|
|
||||||
that burns its restart limit within seconds and then stays dead. Success looks
|
|
||||||
like:
|
|
||||||
|
|
||||||
```
|
|
||||||
/dev/serial0 opened at 9600 baud
|
|
||||||
```
|
|
||||||
|
|
||||||
If it stops instead, the message is one of exactly two:
|
|
||||||
|
|
||||||
| Message | Cause |
|
|
||||||
| --- | --- |
|
|
||||||
| `<device> does not exist, so the service could never start` | step 1 or 2 not done, or the wrong of the two devices |
|
|
||||||
| `could not open <device> at 9600 baud` | device exists but is not usable — UART held by the serial console, or the user is not in `dialout` |
|
|
||||||
|
|
||||||
Nothing is enabled in either case, and re-running is safe: the account, udev
|
|
||||||
rule and virtualenv are all idempotent.
|
|
||||||
|
|
||||||
### 4. Verify it runs
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
systemctl status rdm-card-reader # state
|
systemctl status rdm-card-reader # state
|
||||||
systemctl is-enabled rdm-card-reader # should be 'enabled' (starts at boot)
|
|
||||||
journalctl -u rdm-card-reader -f # live log
|
journalctl -u rdm-card-reader -f # live log
|
||||||
ss -ltnp | grep 8765 # confirms something is listening
|
curl http://127.0.0.1:8765/api/card # tap a card, then run this
|
||||||
```
|
```
|
||||||
|
|
||||||
A healthy startup logs one line:
|
|
||||||
|
|
||||||
```
|
|
||||||
OK reader on /dev/serial0 ; bridge on http://127.0.0.1:8765 (mode: login)
|
|
||||||
```
|
|
||||||
|
|
||||||
Then tap a card and check — or just open **<http://127.0.0.1:8765/>** for the
|
|
||||||
status page, which shows the reader state and a live event log:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -sS http://127.0.0.1:8765/api/card | python3 -m json.tool
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5. If cards are not detected
|
|
||||||
|
|
||||||
Run the reader in the foreground, using the venv the installer built, to see
|
|
||||||
the failure directly instead of through the journal:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo /opt/rdm-card-reader/.venv/bin/rdm-card-reader --port /dev/serial0
|
|
||||||
```
|
|
||||||
|
|
||||||
`--list-ports --verbose-ports` answers "is the port even there?" — it prints
|
|
||||||
candidates with `OK`/`---` (exists?) and then every identified port with its
|
|
||||||
`by-id` path, USB id and serial number:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo /opt/rdm-card-reader/.venv/bin/rdm-card-reader --list-ports --verbose-ports
|
|
||||||
```
|
|
||||||
|
|
||||||
A service that cannot see cards while the foreground run works usually means
|
|
||||||
the unit is pointed at the other device — compare its `ExecStart` (below) with
|
|
||||||
what `--list-ports` reports.
|
|
||||||
|
|
||||||
### Installer options
|
|
||||||
|
|
||||||
| Option | Default | Meaning |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `--origin URL` | – | Web app origin allowed to call the API (repeatable) |
|
|
||||||
| `--host ADDR` | `127.0.0.1` | Bind address. Loopback only by default |
|
|
||||||
| `--allow-remote` | off | Permit a non-loopback `--host` |
|
|
||||||
| `--token SECRET` | – | Require this token from non-loopback callers (see below) |
|
|
||||||
| `--device PATH` | `/dev/serial0` | Serial device the service opens (UART; use `/dev/rdm-reader` for USB) |
|
|
||||||
| `--led-pin N` | `23` | BCM GPIO number of the status LED (physical pin 16) |
|
|
||||||
| `--led-mode M` | `presence` | `presence`: lit while a card is on the reader; `error`: lit whenever running |
|
|
||||||
| `--led-active-low` | off | LED is wired active-low (cathode to the pin, anode to 3.3 V); inverts the signal |
|
|
||||||
| `--error-led-pin N` | `24` | BCM GPIO of a second LED, lit briefly on a bad read |
|
|
||||||
| `--no-error-led` | off | No error LED; blink the status LED instead |
|
|
||||||
| `--error-pulse S` | `1.0` | Seconds to hold the error LED on a bad read |
|
|
||||||
| `--heartbeat S` | `2.0` | Seconds before a silent reader counts as card removed — also the presence/LED window |
|
|
||||||
| `--port PORT` | `8765` | HTTP port for the bridge |
|
|
||||||
| `--dir PATH` | `/opt/rdm-card-reader` | Install location |
|
|
||||||
| `--user NAME` | `rdmcard` | Service account |
|
|
||||||
| `--no-service` | off | Install and configure, but do not enable the service |
|
|
||||||
|
|
||||||
> **`--no-service` does not skip the device checks.** They run before it, so an
|
|
||||||
> install with no reader attached still exits `1`.
|
|
||||||
|
|
||||||
> **`--led-pin` is the BCM GPIO number, not the header pin.** If your LED wire
|
|
||||||
> is on physical pin 16, pass `--led-pin 23` (the default). Passing
|
|
||||||
> `--led-pin 16` would drive **GPIO 16**, which is physical pin 36 — a different
|
|
||||||
> pin entirely. The LED is optional: with no LED, leave the default and ignore
|
|
||||||
> the light, or add `--no-led` to the unit to stop the app trying.
|
|
||||||
|
|
||||||
### What the unit runs
|
|
||||||
|
|
||||||
```
|
|
||||||
/opt/rdm-card-reader/.venv/bin/rdm-card-reader \
|
|
||||||
--serve --port <device> --bridge-port 8765 --mode login \
|
|
||||||
--led-pin <pin> --led-mode <mode> --heartbeat <seconds>
|
|
||||||
```
|
|
||||||
|
|
||||||
`WorkingDirectory=/opt/rdm-card-reader`, so this is equivalent to running
|
|
||||||
`.venv/bin/python -m rdm_card_reader --port <device>` there with `--serve`
|
|
||||||
added. `--serve` is what makes the API available; without it the app only
|
|
||||||
prints events, which a service sends to the journal.
|
|
||||||
|
|
||||||
### Reaching the page from another machine
|
|
||||||
|
|
||||||
The bridge binds **loopback only** by default, so `http://192.168.x.x:8765/`
|
|
||||||
from your laptop gets a *connection refused* — nothing is listening on that
|
|
||||||
address. Opening the port to the network is opt-in, and needs a token:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo ./deploy/install.sh --device /dev/serial0 \
|
|
||||||
--host 0.0.0.0 --allow-remote --token 'pick-a-long-secret'
|
|
||||||
```
|
|
||||||
|
|
||||||
The installer refuses a non-loopback `--host` without `--allow-remote`, and
|
|
||||||
warns if you skip the token. The app enforces the same rule, so this cannot be
|
|
||||||
switched on by accident.
|
|
||||||
|
|
||||||
> **What you are exposing.** Off loopback, anyone who can reach the port can
|
|
||||||
> read card taps *and* switch the status LEDs. A tap is not a password, but it
|
|
||||||
> is a username, and the endpoint reports who has touched the reader. Keep it on
|
|
||||||
> a trusted network, and set a token.
|
|
||||||
|
|
||||||
Loopback callers (the Pi itself) never need the token, so local tools and
|
|
||||||
`curl` keep working unchanged.
|
|
||||||
|
|
||||||
The token is written to `/opt/rdm-card-reader/bridge.env` (mode `0600`) and
|
|
||||||
referenced by the unit as `EnvironmentFile=`, rather than being placed on the
|
|
||||||
`ExecStart` line — a value there is readable by anything that can run
|
|
||||||
`systemctl cat` and appears in the process list.
|
|
||||||
|
|
||||||
Once exposed, open the page and paste the token into the box at the top right;
|
|
||||||
it is kept in `localStorage` and sent as `X-Auth-Token`. Until a token is
|
|
||||||
supplied the panels stay empty and the LED buttons do nothing, because the API
|
|
||||||
answers `401`. For the LED buttons specifically:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# from your laptop, if you invoked install.sh with --host 0.0.0.0 --token SECRET
|
|
||||||
curl -sS -H "X-Auth-Token: SECRET" http://192.168.0.228:8765/api/led
|
|
||||||
```
|
|
||||||
|
|
||||||
If you would rather not expose the port at all, an SSH tunnel gives you the
|
|
||||||
same page without opening anything:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ssh -N -L 8765:127.0.0.1:8765 pi@192.168.0.228
|
|
||||||
# then browse http://127.0.0.1:8765/ on your laptop
|
|
||||||
```
|
|
||||||
|
|
||||||
### Checking the reader from the command line
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Everything at once, in a browser: the status page
|
|
||||||
xdg-open http://127.0.0.1:8765/ # or open it on another machine
|
|
||||||
|
|
||||||
# Is the reader alive? (status, port, uptime)
|
|
||||||
curl -sS http://127.0.0.1:8765/api/health | python3 -m json.tool
|
|
||||||
|
|
||||||
# Is a card on the reader right now?
|
|
||||||
curl -sS http://127.0.0.1:8765/api/card | python3 -m json.tool
|
|
||||||
```
|
|
||||||
|
|
||||||
Use `curl -sS` rather than plain `-s`: the extra `S` prints errors, so a
|
|
||||||
`Connection refused` is visible instead of an empty response that `json.tool`
|
|
||||||
then reports as `Expecting value: line 1 column 1 (char 0)`. That message means
|
|
||||||
*nothing answered*, which almost always means the service is not running —
|
|
||||||
check `ss -ltnp | grep 8765` first.
|
|
||||||
|
|
||||||
### The service gave up after a few failures
|
|
||||||
|
|
||||||
`StartLimitBurst=5` over `StartLimitIntervalSec=60` means systemd stops trying
|
|
||||||
after five failed starts — about 15 seconds. That is deliberate (an absent
|
|
||||||
reader should not restart forever), but it means fixing the wiring is not
|
|
||||||
enough on its own; the failed state has to be cleared:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo systemctl reset-failed rdm-card-reader
|
|
||||||
sudo systemctl restart rdm-card-reader
|
|
||||||
```
|
|
||||||
|
|
||||||
If a unit edit has no effect, you forgot `sudo systemctl daemon-reload` —
|
|
||||||
systemd caches unit files. Confirm what the service actually runs with:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
systemctl cat rdm-card-reader | grep -E '^(ExecStart|DeviceAllow|After)'
|
|
||||||
```
|
|
||||||
|
|
||||||
That output, not the `.service` file on disk, is the truth the service runs.
|
|
||||||
|
|
||||||
### Identifying the right ttyUSB
|
### Identifying the right ttyUSB
|
||||||
|
|
||||||
`/dev/ttyUSB0` is **not** a stable identity — unplug something and the number
|
`/dev/ttyUSB0` is **not** a stable identity — unplug something and the number
|
||||||
@@ -714,16 +338,12 @@ Identified serial ports (best candidate first):
|
|||||||
Then pin it, most specific first:
|
Then pin it, most specific first:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
--port /dev/rdm-reader # udev alias (USB adapters)
|
--port /dev/rdm-reader # udev alias
|
||||||
--port /dev/serial0 # on-board UART
|
|
||||||
--port /dev/serial/by-id/usb-1a86_USB_Serial-if00-port0 # by device
|
--port /dev/serial/by-id/usb-1a86_USB_Serial-if00-port0 # by device
|
||||||
--port AB0XYZ12 # by serial number
|
--port AB0XYZ12 # by serial number
|
||||||
--port 1a86:7523 # by chip
|
--port 1a86:7523 # by chip
|
||||||
```
|
```
|
||||||
|
|
||||||
Note the two are wired differently and the udev alias only exists for one of
|
|
||||||
them — see **UART or USB?** below.
|
|
||||||
|
|
||||||
`--autodetect` picks the only USB adapter, and **refuses** when there is more
|
`--autodetect` picks the only USB adapter, and **refuses** when there is more
|
||||||
than one — silently choosing between two readers is exactly the failure this
|
than one — silently choosing between two readers is exactly the failure this
|
||||||
avoids. Verify the wiring in `deploy/99-rdm-reader.rules`.
|
avoids. Verify the wiring in `deploy/99-rdm-reader.rules`.
|
||||||
@@ -734,65 +354,31 @@ Exit codes: `0` ok, `1` no port, `2` library missing, `3` port ambiguous.
|
|||||||
|
|
||||||
## Status LED
|
## Status LED
|
||||||
|
|
||||||
Two LEDs, each saying one thing. Both optional: everything works without
|
An LED driven by card events, on **GPIO 23** by default — the same pin the
|
||||||
either.
|
workstation app used.
|
||||||
|
|
||||||
| LED | Default pin | Means |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| **Status** | GPIO 23 (physical pin **16**) | Lit while a card is on the reader (`presence` mode) |
|
|
||||||
| **Error** | GPIO 24 (physical pin **18**) | Lit briefly when a read fails (checksum mismatch) |
|
|
||||||
|
|
||||||
The status pin matches the workstation app. The error LED exists because one
|
|
||||||
light cannot say two things at once: in the default `presence` mode the status
|
|
||||||
LED is already carrying "a card is here", so **without a separate error LED a
|
|
||||||
bad read is reported not at all**. A dedicated red LED on its own pin is the
|
|
||||||
clearer arrangement.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
rdm-card-reader # status LED tracks the card; error LED pulses on a bad read
|
rdm-card-reader # lit while running, blinks on a bad read
|
||||||
rdm-card-reader --led-mode error # status LED lit whenever running, blinks on a bad read
|
rdm-card-reader --led-pin 17 # a different pin
|
||||||
rdm-card-reader --error-led-pin 25 # error LED on a different pin
|
rdm-card-reader --led-mode presence # lit only while a card is on the reader
|
||||||
rdm-card-reader --no-error-led # no error LED; blink the status LED instead
|
rdm-card-reader --no-led # no LED at all
|
||||||
rdm-card-reader --no-led # no LEDs at all
|
|
||||||
```
|
```
|
||||||
|
|
||||||
| Flag | Default | Meaning |
|
| Flag | Default | Meaning |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `--no-led` | off | Do not drive any LED |
|
| `--no-led` | off | Do not drive a LED |
|
||||||
| `--led-pin` | `23` | **BCM** GPIO number of the status LED — not the physical header pin |
|
| `--led-pin` | `23` | GPIO pin (matches the workstation app) |
|
||||||
| `--led-mode` | `presence` | See the two readings below |
|
| `--led-mode` | `error` | See the two readings below |
|
||||||
| `--led-active-low` | off | Status LED wired active-low (cathode to the pin) |
|
| `--led-no-error-blink` | off | Do not blink on a bad read |
|
||||||
| `--error-led-pin` | `24` | BCM GPIO of the error LED |
|
|
||||||
| `--error-led-active-low` | off | Error LED wired active-low |
|
|
||||||
| `--error-pulse` | `1.0` | Seconds to hold the error LED on a bad read |
|
|
||||||
| `--no-error-led` | off | No error LED; blink the status LED instead |
|
|
||||||
| `--led-no-error-blink` | off | With `--no-error-led`, do not blink on a bad read |
|
|
||||||
|
|
||||||
`--led-pin` takes the **BCM GPIO number**. The two are easy to confuse, and it
|
|
||||||
is the second most common reason a LED "does not work" — the default `23` is
|
|
||||||
**physical pin 16**, not pin 23. The full mapping is under **Wiring** below.
|
|
||||||
|
|
||||||
Giving the error LED the same pin as the status LED is treated as "no error
|
|
||||||
LED": two lights on one pin cannot be driven independently, so the app says so
|
|
||||||
and falls back to the status blink rather than failing.
|
|
||||||
|
|
||||||
Two modes, because the light can mean two different things:
|
Two modes, because the light can mean two different things:
|
||||||
|
|
||||||
| Mode | Behaviour | Use when |
|
| Mode | Behaviour | Use when |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `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` (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" |
|
||||||
| `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" |
|
| `presence` | **Lit only while a card is on the reader** | The light should mean "a card is here" |
|
||||||
|
|
||||||
> In `presence` mode the light is **dark at startup** and lights on the first
|
|
||||||
> valid card read, then goes dark when the card is lifted. A dark LED on a
|
|
||||||
> freshly started service is therefore correct, not a fault. Check that a card
|
|
||||||
> read lights it before concluding the LED is broken.
|
|
||||||
|
|
||||||
Wiring: LED anode to the GPIO pin through a ~330 Ω resistor, cathode to GND.
|
Wiring: LED anode to the GPIO pin through a ~330 Ω resistor, cathode to GND.
|
||||||
For the opposite arrangement — cathode to the pin, anode to 3.3 V, or a
|
|
||||||
transistor/MOSFET driver that inverts — pass **`--led-active-low`** (or
|
|
||||||
`--error-led-active-low` for the error LED), or the light will do the opposite
|
|
||||||
of what is wanted.
|
|
||||||
|
|
||||||
### It cannot break the reader
|
### It cannot break the reader
|
||||||
|
|
||||||
@@ -807,12 +393,10 @@ point:
|
|||||||
refusing to run.
|
refusing to run.
|
||||||
* **Never crashes on a dead pin.** A failed toggle is logged and ignored.
|
* **Never crashes on a dead pin.** A failed toggle is logged and ignored.
|
||||||
|
|
||||||
Startup reports the state of each LED (the mode shown is `presence` by
|
Startup reports which of the three states it is in:
|
||||||
default):
|
|
||||||
|
|
||||||
```
|
```
|
||||||
LED on GPIO 23 (presence mode, active-high, gpiozero) # status LED working
|
LED on GPIO 23 (error mode, gpiozero) # 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: gpiozero not installed # library missing
|
||||||
LED unavailable: no usable GPIO on pin 23: ... # no pin factory / pin busy
|
LED unavailable: no usable GPIO on pin 23: ... # no pin factory / pin busy
|
||||||
```
|
```
|
||||||
@@ -821,142 +405,6 @@ That distinction matters: `BadPinFactory` subclasses `ImportError`, so a naive
|
|||||||
check reports "not installed" when the library is present but has no usable pin
|
check reports "not installed" when the library is present but has no usable pin
|
||||||
provider — which would send you to the wrong fix.
|
provider — which would send you to the wrong fix.
|
||||||
|
|
||||||
### The reader works but the light stays dark
|
|
||||||
|
|
||||||
Because a LED failure is never fatal, a misconfigured LED looks exactly like a
|
|
||||||
working reader with no light. Check the reason the app printed:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
journalctl -u rdm-card-reader -n 40 --no-pager | grep -i led
|
|
||||||
```
|
|
||||||
|
|
||||||
| Message | Cause | Fix |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `LED unavailable: no usable GPIO on pin 23: ...` | gpiozero cannot reach a pin backend, or systemd/group permission is missing | See the three checks below |
|
|
||||||
| `LED unavailable: gpiozero not installed: ...` | LED extras missing from the venv | Install from the bundled wheels (below) |
|
|
||||||
| `LED on GPIO 23 (presence mode, active-high, gpiozero)` | LED is initialised — suspect wiring, pin number, or simply no card read yet | See the wiring note below |
|
|
||||||
| No error indication on a bad read | The error LED pin is wrong or the LED is unwired, and the status LED only blinks in `error` mode | Check `Error LED on GPIO …` in the journal; see below |
|
|
||||||
|
|
||||||
**Check 0 — is the light doing the opposite?** A dark LED and an *inverted* LED
|
|
||||||
are different faults. If the light is **on with no card and goes dark when a
|
|
||||||
card is placed**, the pin number is right and the polarity is wrong: the LED is
|
|
||||||
wired active-low. Pass `--led-active-low` (installer, or the app directly), and
|
|
||||||
confirm with the journal, which now names the polarity:
|
|
||||||
|
|
||||||
```
|
|
||||||
LED on GPIO 23 (presence mode, active-low, gpiozero)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Check 0b — can the software drive the pin at all?** Before suspecting the
|
|
||||||
wiring, prove the pin works. Open the status page and use the **LED panel** to
|
|
||||||
switch each light on and off by hand, which takes card events out of the
|
|
||||||
picture entirely:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -sS -X POST -H 'Content-Type: application/json' \
|
|
||||||
-d '{"led":"status","on":true}' http://127.0.0.1:8765/api/led
|
|
||||||
```
|
|
||||||
|
|
||||||
- **The light comes on** → the pin, backend and permissions are all fine, so
|
|
||||||
the problem is card events, not hardware. Check the journal for
|
|
||||||
`CARD INSERTED` when you tap.
|
|
||||||
- **Nothing happens, HTTP 200** → the pin is being driven but no light is
|
|
||||||
connected to it. Wiring, or the wrong pin.
|
|
||||||
- **HTTP 409 `that LED is not available`** → the LED has no usable pin; look at
|
|
||||||
`reason` in the response, which is the same text as the journal line.
|
|
||||||
|
|
||||||
**HTTP 401 `missing or invalid token`** → you are reaching the bridge from
|
|
||||||
another machine and it was installed with a token. Paste the token into the box
|
|
||||||
at the top of the status page, or send `X-Auth-Token`. See **Reaching the page
|
|
||||||
from another machine** above.
|
|
||||||
|
|
||||||
Press *hand back to card events* (or POST `{"release": true}`) when finished, or
|
|
||||||
the LEDs stay held where you left them.
|
|
||||||
|
|
||||||
To isolate polarity from everything else, run the service in `error` mode,
|
|
||||||
where the light is lit the whole time the app runs:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo ./deploy/install.sh --device /dev/serial0 --led-mode error
|
|
||||||
```
|
|
||||||
|
|
||||||
Lit continuously means the hardware path is good. Dark means wiring, pin
|
|
||||||
number or backend. Lit-while-idle *and* dark-when-a-card-arrives in `presence`
|
|
||||||
mode means polarity.
|
|
||||||
|
|
||||||
**Check 1 — the venv must see the system pin backend.** `gpiozero` is pure
|
|
||||||
Python, but the library it uses to talk to the pins (`lgpio` on Bookworm,
|
|
||||||
`RPi.GPIO` on older releases) is an **apt package in the system Python**. A
|
|
||||||
plain venv cannot see it, so gpiozero raises `BadPinFactory` and the app
|
|
||||||
quietly falls back to a no-op LED. This is why `install.sh` creates the venv
|
|
||||||
with `--system-site-packages` and installs a backend. If you built the venv by
|
|
||||||
hand, that is the most likely cause:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
grep system-site-packages /opt/rdm-card-reader/.venv/pyvenv.cfg # want: true
|
|
||||||
/opt/rdm-card-reader/.venv/bin/python -c 'import lgpio; print("lgpio OK")'
|
|
||||||
```
|
|
||||||
|
|
||||||
If either line is wrong, let the installer set both up at once:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo ./deploy/install.sh --device /dev/serial0
|
|
||||||
```
|
|
||||||
|
|
||||||
It installs `python3-lgpio` (Bookworm and later) or `python3-rpi.gpio` (older
|
|
||||||
releases) when neither backend is importable, and recreates the venv with the
|
|
||||||
flag. To do it yourself instead:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo apt-get install -y python3-lgpio # Bookworm; python3-rpi.gpio on older
|
|
||||||
sudo ./deploy/install.sh --device /dev/serial0 # recreates the venv with the flag
|
|
||||||
```
|
|
||||||
|
|
||||||
**Check 2 — systemd device policy.** The unit sets `DevicePolicy=closed`, so
|
|
||||||
only devices named in `DeviceAllow=` are reachable. It must list the GPIO chip
|
|
||||||
as well as the serial port:
|
|
||||||
|
|
||||||
```
|
|
||||||
DeviceAllow=/dev/serial0 rw
|
|
||||||
DeviceAllow=/dev/gpiochip0 rw
|
|
||||||
DeviceAllow=/dev/gpiochip4 rw
|
|
||||||
```
|
|
||||||
|
|
||||||
`gpiochip0` is the header on Pi 1–4, `gpiochip4` on Pi 5. A missing entry is
|
|
||||||
denied by systemd before any group permission is even consulted. Confirm what
|
|
||||||
the service actually runs with
|
|
||||||
`systemctl cat rdm-card-reader | grep DeviceAllow`.
|
|
||||||
|
|
||||||
**Check 3 — the `gpio` group.** On modern Raspberry Pi OS (Bookworm onward)
|
|
||||||
`gpiozero` drives GPIO through `lgpio`, which opens `/dev/gpiochip0` — owned
|
|
||||||
`root:gpio`, mode `0660`. The service account must be in `gpio`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo usermod -aG gpio rdmcard
|
|
||||||
```
|
|
||||||
|
|
||||||
Older releases (Bullseye, `RPi.GPIO`) use `/dev/gpiomem` and need no extra
|
|
||||||
group.
|
|
||||||
|
|
||||||
After any of these changes: `sudo systemctl daemon-reload` (systemd caches unit
|
|
||||||
files) and `sudo systemctl restart rdm-card-reader`.
|
|
||||||
|
|
||||||
**If the LED extras are missing**, install them offline from the bundled wheels
|
|
||||||
— a Pi with no internet cannot reach PyPI:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
/opt/rdm-card-reader/.venv/bin/pip install --no-index \
|
|
||||||
--find-links /opt/rdm-card-reader/src/rdm_card_reader/wheels \
|
|
||||||
--no-deps gpiozero colorzero
|
|
||||||
```
|
|
||||||
|
|
||||||
**If the journal says the LED is on GPIO 23**, the software side is fine.
|
|
||||||
Tap a card: in the default `presence` mode the light comes on while the card is
|
|
||||||
held and goes dark when it is lifted. If it stays dark with a card on the
|
|
||||||
reader, the problem is electrical — see the pin numbering note under
|
|
||||||
**Wiring**. GPIO 23 is physical pin **16**, and the anode needs a series
|
|
||||||
resistor to the pin with the cathode to GND.
|
|
||||||
|
|
||||||
> **If the workstation app also runs on this device, use a different pin** — two
|
> **If the workstation app also runs on this device, use a different pin** — two
|
||||||
> processes driving GPIO 23 will fight, and the LED will flicker.
|
> processes driving GPIO 23 will fight, and the LED will flicker.
|
||||||
|
|
||||||
@@ -969,30 +417,8 @@ it is never imported at runtime and would add 818 KB for nothing.
|
|||||||
|
|
||||||
## Wiring
|
## Wiring
|
||||||
|
|
||||||
The RDM6300 talks plain serial at **9600 baud, 8N1**.
|
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.
|
||||||
A typical connection to the Pi's 40-pin header, using the on-board UART:
|
|
||||||
|
|
||||||
| RDM6300 | Pi pin | Function |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| VCC (5 V) | 4 | power |
|
|
||||||
| GND | 6 | ground (must be common) |
|
|
||||||
| TX | **10** (GPIO 15, RXD) | card data → Pi |
|
|
||||||
| RX | 8 (GPIO 14, TXD) | not used — the reader only transmits |
|
|
||||||
|
|
||||||
The reader is **transmit-only**: it emits frames continuously and never reads,
|
|
||||||
so the Pi's TX line (pin 8) has nothing to send. Only pin 10 carries data.
|
|
||||||
|
|
||||||
> **Level warning.** The RDM6300's TX output is **5 V TTL**, while GPIO 15 is a
|
|
||||||
> **3.3 V** input. Feeding 5 V straight in often appears to work but stresses
|
|
||||||
> the pin. Use a divider (e.g. 1 kΩ in series, 2 kΩ to GND) or a level shifter
|
|
||||||
> on the reader-TX → pin-10 line. This is not a cause of "no cards read" if you
|
|
||||||
> already see data, but it is worth fixing before relying on the device.
|
|
||||||
|
|
||||||
For each LED: **anode** → GPIO pin through a ~330 Ω resistor,
|
|
||||||
**cathode** → GND. Status LED on GPIO 23 (physical pin 16) and error LED on
|
|
||||||
GPIO 24 (physical pin 18) by default. See the pin numbering table below, and
|
|
||||||
note both LEDs are optional — the reader works without them.
|
|
||||||
|
|
||||||
On a Raspberry Pi you usually need to free up the primary UART first:
|
On a Raspberry Pi you usually need to free up the primary UART first:
|
||||||
|
|
||||||
@@ -1004,104 +430,7 @@ sudo usermod -aG dialout $USER # then log out and back in
|
|||||||
```
|
```
|
||||||
|
|
||||||
The GPIO UART appears as `/dev/serial0` (a symlink to `/dev/ttyAMA0` or
|
The GPIO UART appears as `/dev/serial0` (a symlink to `/dev/ttyAMA0` or
|
||||||
`/dev/ttyS0`). If the serial console still owns the port the service will fail
|
`/dev/ttyS0`).
|
||||||
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
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -1149,28 +478,19 @@ problem with the following card.
|
|||||||
| Reader opens but no cards | Card must be EM4100 / 125 kHz; hold it ~2 cm from the coil |
|
| Reader opens but no cards | Card must be EM4100 / 125 kHz; hold it ~2 cm from the coil |
|
||||||
| `INVALID CARD` repeatedly | Noisy supply or long/unshielded wiring; shorten it |
|
| `INVALID CARD` repeatedly | Noisy supply or long/unshielded wiring; shorten it |
|
||||||
| Every card reported twice | Lower `--heartbeat` if the card is lifted quickly |
|
| Every card reported twice | Lower `--heartbeat` if the card is lifted quickly |
|
||||||
| Card reported as **removed while still on the reader** | Raise `--heartbeat` — the reader reports a card once per placement, so a short window expires while the card sits still |
|
|
||||||
| LED blinks on and off instead of staying lit | Same cause: the LED follows `present`. Raise `--heartbeat` |
|
|
||||||
| Service `exit 1`, `no port matched '/dev/rdm-reader'` | Reader is on the UART — re-install with `--device /dev/serial0` |
|
|
||||||
| Service `exit 1`, `Could not open /dev/serial0` | UART held by the serial console, or the user is not in `dialout` |
|
|
||||||
| Service stuck in `failed`, restarts refused | `sudo systemctl reset-failed rdm-card-reader` |
|
|
||||||
| `curl` returns nothing, `Expecting value: line 1 column 1` | Nothing is listening — the service is not running; check `ss -ltnp \| grep 8765` |
|
|
||||||
| Reader works but the LED is dark | See **The reader works but the light stays dark** |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Check it works
|
## Development
|
||||||
|
|
||||||
With no RFID hardware attached, `--demo` exercises the formatting path and
|
|
||||||
`--list-ports` shows what serial ports exist:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
rdm-card-reader --demo # simulated reads, no hardware needed
|
pip install -e ".[dev]"
|
||||||
rdm-card-reader --list-ports # candidate and identified ports
|
pytest
|
||||||
```
|
```
|
||||||
|
|
||||||
To drive the real reader path with no hardware, feed RDM6300 frames into a
|
The suite needs no RFID hardware: the end-to-end tests open a pseudo-terminal,
|
||||||
pseudo-terminal — see the snippet in **[LAUNCH.md](LAUNCH.md)**.
|
write real RDM6300 frames into it and assert on the CLI output. `--demo` also
|
||||||
|
works with no hardware at all.
|
||||||
|
|
||||||
### Layout
|
### Layout
|
||||||
|
|
||||||
@@ -1180,12 +500,12 @@ src/rdm_card_reader/
|
|||||||
reader.py serial port handling, event callbacks
|
reader.py serial port handling, event callbacks
|
||||||
cli.py argument parsing and program flow
|
cli.py argument parsing and program flow
|
||||||
demo.py simulated reads
|
demo.py simulated reads
|
||||||
ports.py stable serial port identification
|
tests/
|
||||||
bridge.py the HTTP bridge behind --serve
|
test_protocol.py RDM6300 wire format, byte for byte
|
||||||
webui.py the status page served at GET / by the bridge
|
test_card.py event model and formatting
|
||||||
led.py status LED
|
test_reader.py port selection, using a fake reader library
|
||||||
bootstrap.py offline dependency install
|
test_cli.py exit codes and output modes
|
||||||
wheels/ the bundled wheels (offline install source)
|
test_e2e_pty.py full stack over a pseudo-terminal
|
||||||
```
|
```
|
||||||
|
|
||||||
### Using it as a library
|
### Using it as a library
|
||||||
|
|||||||
+11
-220
@@ -10,31 +10,10 @@
|
|||||||
# Options:
|
# Options:
|
||||||
# --origin URL web app origin allowed to read the API (repeatable)
|
# --origin URL web app origin allowed to read the API (repeatable)
|
||||||
# --port PORT HTTP port for the bridge (default 8765)
|
# --port PORT HTTP port for the bridge (default 8765)
|
||||||
# --host ADDR bind address (default 127.0.0.1, loopback only)
|
|
||||||
# --allow-remote permit binding a non-loopback address. Anyone who can
|
|
||||||
# reach the port can then read taps and switch the LEDs;
|
|
||||||
# pair it with --token
|
|
||||||
# --token SECRET pre-shared token required from non-loopback callers
|
|
||||||
# (or set RDM_BRIDGE_TOKEN in the environment)
|
|
||||||
# --device PATH serial device the service opens (default /dev/serial0)
|
|
||||||
# --led-pin N BCM GPIO number of the status LED (default 23)
|
|
||||||
# --led-mode M 'presence' (default) or 'error'
|
|
||||||
# --led-active-low LED wired active-low (cathode to the pin, anode to
|
|
||||||
# 3.3 V, or an inverting driver); or the light will be on
|
|
||||||
# with no card and off with one
|
|
||||||
# --error-led-pin N BCM GPIO of a second LED for bad reads (default 24)
|
|
||||||
# --no-error-led do not use an error LED; blink the status LED instead
|
|
||||||
# --error-pulse S seconds to hold the error LED on a bad read (default 1)
|
|
||||||
# --heartbeat S seconds before a silent reader counts as card removed
|
|
||||||
# (default 2.0; this is also the presence/LED window)
|
|
||||||
# --dir PATH install location (default /opt/rdm-card-reader)
|
# --dir PATH install location (default /opt/rdm-card-reader)
|
||||||
# --user NAME service account (default rdmcard)
|
# --user NAME service account (default rdmcard)
|
||||||
# --no-service install and configure, but do not enable the service
|
# --no-service install and configure, but do not enable the service
|
||||||
#
|
#
|
||||||
# The default device is /dev/serial0, the Raspberry Pi's on-board UART, because
|
|
||||||
# that is how the reader is normally wired (header pins 8/10). For a
|
|
||||||
# USB-to-serial adapter pass --device /dev/rdm-reader, the udev alias.
|
|
||||||
#
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
ORIGINS=()
|
ORIGINS=()
|
||||||
@@ -42,73 +21,28 @@ BRIDGE_PORT=8765
|
|||||||
INSTALL_DIR=/opt/rdm-card-reader
|
INSTALL_DIR=/opt/rdm-card-reader
|
||||||
SERVICE_USER=rdmcard
|
SERVICE_USER=rdmcard
|
||||||
INSTALL_SERVICE=1
|
INSTALL_SERVICE=1
|
||||||
SERIAL_DEVICE=/dev/serial0
|
|
||||||
LED_PIN=23
|
|
||||||
LED_MODE=presence
|
|
||||||
LED_ACTIVE_LOW=0
|
|
||||||
ERROR_LED_PIN=24
|
|
||||||
ERROR_LED_ACTIVE_LOW=0
|
|
||||||
ERROR_PULSE=1.0
|
|
||||||
HEARTBEAT=2.0
|
|
||||||
# Loopback by default: an open card endpoint on a LAN lets anyone read who is
|
|
||||||
# tapping, and now also toggle the LEDs. Reaching it from another machine needs
|
|
||||||
# --allow-remote, and a token once it is off loopback.
|
|
||||||
BRIDGE_HOST=127.0.0.1
|
|
||||||
ALLOW_REMOTE=0
|
|
||||||
BRIDGE_TOKEN=""
|
|
||||||
SOURCE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
SOURCE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
--origin) ORIGINS+=("$2"); shift 2 ;;
|
--origin) ORIGINS+=("$2"); shift 2 ;;
|
||||||
--port) BRIDGE_PORT="$2"; shift 2 ;;
|
--port) BRIDGE_PORT="$2"; shift 2 ;;
|
||||||
--host) BRIDGE_HOST="$2"; shift 2 ;;
|
|
||||||
--allow-remote) ALLOW_REMOTE=1; shift ;;
|
|
||||||
--token) BRIDGE_TOKEN="$2"; shift 2 ;;
|
|
||||||
--dir) INSTALL_DIR="$2"; shift 2 ;;
|
--dir) INSTALL_DIR="$2"; shift 2 ;;
|
||||||
--user) SERVICE_USER="$2"; shift 2 ;;
|
--user) SERVICE_USER="$2"; shift 2 ;;
|
||||||
--device) SERIAL_DEVICE="$2"; shift 2 ;;
|
|
||||||
--led-pin) LED_PIN="$2"; shift 2 ;;
|
|
||||||
--led-mode) LED_MODE="$2"; shift 2 ;;
|
|
||||||
--led-active-low) LED_ACTIVE_LOW=1; shift ;;
|
|
||||||
--error-led-pin) ERROR_LED_PIN="$2"; shift 2 ;;
|
|
||||||
--error-led-active-low) ERROR_LED_ACTIVE_LOW=1; shift ;;
|
|
||||||
--error-pulse) ERROR_PULSE="$2"; shift 2 ;;
|
|
||||||
--no-error-led) ERROR_LED_PIN=""; shift ;;
|
|
||||||
--heartbeat) HEARTBEAT="$2"; shift 2 ;;
|
|
||||||
--no-service) INSTALL_SERVICE=0; shift ;;
|
--no-service) INSTALL_SERVICE=0; shift ;;
|
||||||
-h|--help) sed -n '2,33p' "$0"; exit 0 ;;
|
-h|--help) sed -n '2,20p' "$0"; exit 0 ;;
|
||||||
*) echo "unknown option: $1" >&2; exit 2 ;;
|
*) echo "unknown option: $1" >&2; exit 2 ;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
log() { printf '\n\033[1m==> %s\033[0m\n' "$*"; }
|
log() { printf '\n\033[1m==> %s\033[0m\n' "$*"; }
|
||||||
warn() { printf '\033[33mwarning: %s\033[0m\n' "$*" >&2; }
|
warn() { printf '\033[33mwarning: %s\033[0m\n' "$*" >&2; }
|
||||||
note() { printf ' %s\n' "$*"; }
|
|
||||||
|
|
||||||
if [[ $EUID -ne 0 ]]; then
|
if [[ $EUID -ne 0 ]]; then
|
||||||
echo "Please run as root: sudo $0 $*" >&2
|
echo "Please run as root: sudo $0 $*" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# A non-loopback bind is opt-in, and the app refuses it without --allow-remote
|
|
||||||
# too. Mirror that here so the failure happens at install time with an
|
|
||||||
# explanation, rather than as a crash-looping unit.
|
|
||||||
if [[ "$BRIDGE_HOST" != "127.0.0.1" && "$BRIDGE_HOST" != "::1" \
|
|
||||||
&& "$BRIDGE_HOST" != "localhost" ]]; then
|
|
||||||
if [[ "$ALLOW_REMOTE" -eq 0 ]]; then
|
|
||||||
warn "--host $BRIDGE_HOST binds beyond loopback, which is not the default."
|
|
||||||
warn "Re-run with --allow-remote to confirm, e.g."
|
|
||||||
warn " sudo $0 --host $BRIDGE_HOST --allow-remote --token SECRET"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
if [[ -z "$BRIDGE_TOKEN" ]]; then
|
|
||||||
warn "--host $BRIDGE_HOST with no --token: anyone who can reach port"
|
|
||||||
warn "$BRIDGE_PORT can read card taps AND switch the status LEDs."
|
|
||||||
warn "Add --token SECRET, and open the page with that token."
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 1. System packages
|
# 1. System packages
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -119,29 +53,6 @@ apt-get update -qq 2>/dev/null || warn "apt-get update failed (no network?) - co
|
|||||||
apt-get install -y --no-install-recommends python3 python3-venv python3-pip \
|
apt-get install -y --no-install-recommends python3 python3-venv python3-pip \
|
||||||
|| warn "could not install system packages - continuing with what is present"
|
|| warn "could not install system packages - continuing with what is present"
|
||||||
|
|
||||||
# The pin backend gpiozero needs to drive the LED is an apt package in the
|
|
||||||
# *system* Python (lgpio on Bookworm and later, RPi.GPIO on older releases).
|
|
||||||
# gpiozero itself comes from the bundled wheels, but without a backend it
|
|
||||||
# raises BadPinFactory, the app falls back to a no-op LED, and the reader works
|
|
||||||
# with the light dark and nothing failing - the failure that is easiest to
|
|
||||||
# miss. Best-effort, like the packages above: a full Raspberry Pi OS image
|
|
||||||
# ships python3-gpiozero, which already pulls the right backend in, so only
|
|
||||||
# install one when neither is importable.
|
|
||||||
if ! python3 -c 'import lgpio' 2>/dev/null \
|
|
||||||
&& ! python3 -c 'import RPi.GPIO' 2>/dev/null; then
|
|
||||||
echo " no GPIO backend found - installing one for the status LED"
|
|
||||||
if apt-get install -y --no-install-recommends python3-lgpio 2>/dev/null; then
|
|
||||||
echo " installed python3-lgpio (Bookworm and later)"
|
|
||||||
elif apt-get install -y --no-install-recommends python3-rpi.gpio 2>/dev/null; then
|
|
||||||
echo " installed python3-rpi.gpio (Bullseye and older)"
|
|
||||||
else
|
|
||||||
warn "could not install a GPIO pin backend (python3-lgpio or"
|
|
||||||
warn "python3-rpi.gpio). The reader will work, but the status LED will"
|
|
||||||
warn "stay dark. Install it on the Pi with:"
|
|
||||||
warn " sudo apt-get install -y python3-lgpio # or python3-rpi.gpio"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 2. Install the application
|
# 2. Install the application
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -149,21 +60,14 @@ log "Installing to $INSTALL_DIR"
|
|||||||
mkdir -p "$INSTALL_DIR"
|
mkdir -p "$INSTALL_DIR"
|
||||||
|
|
||||||
# Copy the source tree (excluding local build/test artifacts).
|
# Copy the source tree (excluding local build/test artifacts).
|
||||||
for item in src pyproject.toml README.md LAUNCH.md LICENSE deploy; do
|
for item in src tests pyproject.toml README.md LAUNCH.md LICENSE deploy; do
|
||||||
if [[ -e "$SOURCE_DIR/$item" ]]; then
|
if [[ -e "$SOURCE_DIR/$item" ]]; then
|
||||||
cp -a "$SOURCE_DIR/$item" "$INSTALL_DIR/"
|
cp -a "$SOURCE_DIR/$item" "$INSTALL_DIR/"
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
log "Building the virtual environment"
|
log "Building the virtual environment"
|
||||||
# --system-site-packages is what makes the status LED work. gpiozero is pure
|
if ! python3 -m venv "$INSTALL_DIR/.venv"; then
|
||||||
# Python, but the pin backend it needs (lgpio on Bookworm, RPi.GPIO on older
|
|
||||||
# releases) is an apt package installed into the *system* Python. A plain venv
|
|
||||||
# cannot see it, so gpiozero finds no usable pin factory, raises BadPinFactory
|
|
||||||
# and the app falls back to a no-op LED: the reader works and the light stays
|
|
||||||
# dark, with nothing failing. Re-running this on an existing venv updates
|
|
||||||
# pyvenv.cfg in place, so an upgrade picks the flag up too.
|
|
||||||
if ! python3 -m venv --system-site-packages "$INSTALL_DIR/.venv"; then
|
|
||||||
warn "could not create the virtualenv."
|
warn "could not create the virtualenv."
|
||||||
warn "On Debian/Raspberry Pi OS this usually means python3-venv is missing:"
|
warn "On Debian/Raspberry Pi OS this usually means python3-venv is missing:"
|
||||||
warn " sudo apt-get install python3-venv"
|
warn " sudo apt-get install python3-venv"
|
||||||
@@ -268,17 +172,6 @@ fi
|
|||||||
usermod -aG dialout "$SERVICE_USER"
|
usermod -aG dialout "$SERVICE_USER"
|
||||||
echo " $SERVICE_USER is in group dialout"
|
echo " $SERVICE_USER is in group dialout"
|
||||||
|
|
||||||
# The status LED drives /dev/gpiochip*, which is root:gpio on modern Raspberry
|
|
||||||
# Pi OS (Bookworm onward, gpiozero via lgpio). Without this group the service
|
|
||||||
# cannot open the chip, gpiozero falls back to a no-op LED, and the reader
|
|
||||||
# works with no light - the failure that is easiest to miss. Older releases
|
|
||||||
# (Bullseye, RPi.GPIO) use /dev/gpiomem and need no extra group, hence the
|
|
||||||
# group check rather than an unconditional usermod.
|
|
||||||
if getent group gpio >/dev/null 2>&1; then
|
|
||||||
usermod -aG gpio "$SERVICE_USER"
|
|
||||||
echo " $SERVICE_USER is in group gpio (status LED)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 4. Stable device name
|
# 4. Stable device name
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -289,49 +182,15 @@ udevadm control --reload-rules
|
|||||||
udevadm trigger --subsystem-match=tty || true
|
udevadm trigger --subsystem-match=tty || true
|
||||||
sleep 1
|
sleep 1
|
||||||
|
|
||||||
if [[ "$SERIAL_DEVICE" == "/dev/rdm-reader" && ! -e /dev/rdm-reader ]]; then
|
if [[ -e /dev/rdm-reader ]]; then
|
||||||
|
echo " /dev/rdm-reader -> $(readlink -f /dev/rdm-reader)"
|
||||||
|
else
|
||||||
warn "/dev/rdm-reader does not exist yet."
|
warn "/dev/rdm-reader does not exist yet."
|
||||||
warn "The USB adapter may not be plugged in, or it uses a different chip."
|
warn "The reader may not be plugged in, or it uses a different chip."
|
||||||
warn "Run '$INSTALL_DIR/.venv/bin/rdm-card-reader --list-ports --verbose-ports'"
|
warn "Run '$INSTALL_DIR/.venv/bin/rdm-card-reader --list-ports --verbose-ports'"
|
||||||
warn "then edit /etc/udev/rules.d/99-rdm-reader.rules to match your adapter."
|
warn "then edit /etc/udev/rules.d/99-rdm-reader.rules to match your adapter."
|
||||||
warn "Reader wired to the Pi's UART pins instead? That is the default device:"
|
|
||||||
warn "re-run without --device (or with --device /dev/serial0)."
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Refuse to enable a service that can only exit 1. A unit that fails on every
|
|
||||||
# start burns its StartLimitBurst within seconds and then stays dead until
|
|
||||||
# someone runs 'systemctl reset-failed' by hand - a confusing failure to debug
|
|
||||||
# remotely, and one that silently returns "installed successfully".
|
|
||||||
if [[ ! -e "$SERIAL_DEVICE" ]]; then
|
|
||||||
warn "$SERIAL_DEVICE does not exist, so the service could never start."
|
|
||||||
warn "Nothing has been enabled. Fix the device, then re-run this installer."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Prove the port can actually be opened, not merely that it exists. Existence
|
|
||||||
# alone is not enough: a node with no hardware behind it still fails to open.
|
|
||||||
if ! "$INSTALL_DIR/.venv/bin/python" - "$SERIAL_DEVICE" <<'PY'
|
|
||||||
import sys
|
|
||||||
|
|
||||||
import serial
|
|
||||||
|
|
||||||
try:
|
|
||||||
serial.Serial(sys.argv[1], 9600, timeout=1).close()
|
|
||||||
except Exception as exc:
|
|
||||||
print(" x {}: {}".format(sys.argv[1], exc))
|
|
||||||
raise SystemExit(1)
|
|
||||||
PY
|
|
||||||
then
|
|
||||||
warn "could not open $SERIAL_DEVICE at 9600 baud."
|
|
||||||
warn "For the on-board UART check that it is enabled:"
|
|
||||||
warn " sudo raspi-config # Interface Options -> Serial Port"
|
|
||||||
warn " 'login shell over serial?' -> No"
|
|
||||||
warn " 'serial port hardware?' -> Yes"
|
|
||||||
warn "Nothing has been enabled. Fix the above, then re-run this installer."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo " $SERIAL_DEVICE opened at 9600 baud"
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 5. Service
|
# 5. Service
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -345,15 +204,10 @@ for origin in "${ORIGINS[@]:-}"; do
|
|||||||
[[ -n "$origin" ]] && ORIGIN_ARGS+=" --allow-origin $origin"
|
[[ -n "$origin" ]] && ORIGIN_ARGS+=" --allow-origin $origin"
|
||||||
done
|
done
|
||||||
|
|
||||||
python3 - "$UNIT" "$INSTALL_DIR" "$BRIDGE_PORT" "$ORIGIN_ARGS" "$SERVICE_USER" \
|
python3 - "$UNIT" "$INSTALL_DIR" "$BRIDGE_PORT" "$ORIGIN_ARGS" "$SERVICE_USER" <<'PY'
|
||||||
"$SERIAL_DEVICE" "$LED_PIN" "$LED_MODE" "$LED_ACTIVE_LOW" "$HEARTBEAT" \
|
|
||||||
"$ERROR_LED_PIN" "$ERROR_LED_ACTIVE_LOW" "$ERROR_PULSE" \
|
|
||||||
"$BRIDGE_HOST" "$ALLOW_REMOTE" "$BRIDGE_TOKEN" <<'PY'
|
|
||||||
import os, re, sys
|
import os, re, sys
|
||||||
|
|
||||||
(unit, install_dir, port, origin_args, user, device, led_pin, led_mode,
|
unit, install_dir, port, origin_args, user = sys.argv[1:6]
|
||||||
led_active_low, heartbeat, error_led_pin, error_led_active_low,
|
|
||||||
error_pulse, bridge_host, allow_remote, bridge_token) = sys.argv[1:17]
|
|
||||||
text = open(unit).read()
|
text = open(unit).read()
|
||||||
|
|
||||||
# The console script is created by the installer, but the .pth file is what
|
# The console script is created by the installer, but the .pth file is what
|
||||||
@@ -363,72 +217,13 @@ wrapper = f"{install_dir}/.venv/bin/rdm-card-reader"
|
|||||||
if not os.path.exists(wrapper):
|
if not os.path.exists(wrapper):
|
||||||
wrapper = f"{install_dir}/.venv/bin/python -m rdm_card_reader"
|
wrapper = f"{install_dir}/.venv/bin/python -m rdm_card_reader"
|
||||||
|
|
||||||
# The token goes into an environment file, not the unit: a value on the
|
|
||||||
# ExecStart line is readable by anything that can run 'systemctl cat', and
|
|
||||||
# shows up in the process list. The '-' prefix makes the file optional.
|
|
||||||
env_file = os.path.join(install_dir, "bridge.env")
|
|
||||||
text = re.sub(r"^EnvironmentFile=.*\n?", "", text, count=1, flags=re.M)
|
|
||||||
if bridge_token:
|
|
||||||
with open(env_file, "w") as handle:
|
|
||||||
handle.write(f"RDM_BRIDGE_TOKEN={bridge_token}\n")
|
|
||||||
os.chmod(env_file, 0o600)
|
|
||||||
text = re.sub(r"^ExecStart=", f"EnvironmentFile=-{env_file}\\nExecStart=",
|
|
||||||
text, count=1, flags=re.M)
|
|
||||||
print(f" token stored in {env_file} (mode 0600)")
|
|
||||||
|
|
||||||
led_active_args = " --led-active-low" if led_active_low == "1" else ""
|
|
||||||
# An empty pin means --no-error-led: the status LED blinks instead.
|
|
||||||
if error_led_pin:
|
|
||||||
error_args = f" --error-led-pin {error_led_pin}"
|
|
||||||
if error_led_active_low == "1":
|
|
||||||
error_args += " --error-led-active-low"
|
|
||||||
if error_pulse:
|
|
||||||
error_args += f" --error-pulse {error_pulse}"
|
|
||||||
else:
|
|
||||||
error_args = " --no-error-led"
|
|
||||||
|
|
||||||
# Exposing the bridge beyond loopback is explicit: --allow-remote is required,
|
|
||||||
# and the token protects the API once the caller is not on this machine.
|
|
||||||
host_args = ""
|
|
||||||
if bridge_host != "127.0.0.1":
|
|
||||||
host_args = f" --host {bridge_host}"
|
|
||||||
if allow_remote == "1":
|
|
||||||
host_args += " --allow-remote"
|
|
||||||
|
|
||||||
command = (
|
command = (
|
||||||
f"{wrapper} --serve "
|
f"{wrapper} --serve "
|
||||||
f"--port {device} --bridge-port {port} --mode login "
|
f"--port /dev/rdm-reader --bridge-port {port} --mode login{origin_args}"
|
||||||
f"--led-pin {led_pin} --led-mode {led_mode} --heartbeat {heartbeat}"
|
|
||||||
f"{led_active_args}{error_args}{host_args}{origin_args}"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def device_unit(path):
|
|
||||||
"""systemd device unit for *path*, e.g. /dev/rdm-reader -> dev-rdm\\x2dreader.device."""
|
|
||||||
name = path[len("/dev/"):] if path.startswith("/dev/") else path
|
|
||||||
name = name.replace("-", "\\x2d").replace("/", "-")
|
|
||||||
return f"dev-{name}.device"
|
|
||||||
|
|
||||||
|
|
||||||
# Replace the whole (possibly line-continued) ExecStart definition.
|
# Replace the whole (possibly line-continued) ExecStart definition.
|
||||||
text = re.sub(r"ExecStart=[\s\S]*?(?=\n\n|\nRestart=)", f"ExecStart={command}", text, count=1)
|
text = re.sub(r"ExecStart=[\s\S]*?(?=\n\n|\nRestart=)", f"ExecStart={command}", text, count=1)
|
||||||
text = re.sub(r"^User=.*$", f"User={user}", text, count=1, flags=re.M)
|
text = re.sub(r"^User=.*$", f"User={user}", text, count=1, flags=re.M)
|
||||||
# Order after the device we actually open. The shipped unit names a device
|
|
||||||
# that is not the one the service reads, so systemd never really waited for it.
|
|
||||||
text = re.sub(r"^After=.*$", f"After={device_unit(device)} network-online.target",
|
|
||||||
text, count=1, flags=re.M)
|
|
||||||
# DevicePolicy=closed means only the devices listed here are usable, so the
|
|
||||||
# list has to cover everything the service touches: the serial port, and the
|
|
||||||
# GPIO chip the status LED drives. Omitting the chip is a silent failure - the
|
|
||||||
# reader keeps working and only the LED stays dark - so it is easy to miss.
|
|
||||||
# gpiochip0 is the header on Pi 1-4; Pi 5 uses gpiochip4.
|
|
||||||
device_allow = "\n".join([
|
|
||||||
f"DeviceAllow={device} rw",
|
|
||||||
"DeviceAllow=/dev/gpiochip0 rw",
|
|
||||||
"DeviceAllow=/dev/gpiochip4 rw",
|
|
||||||
])
|
|
||||||
text = re.sub(r"^DeviceAllow=.*(?:\nDeviceAllow=.*)*", device_allow, text,
|
|
||||||
count=1, flags=re.M)
|
|
||||||
open(unit, "w").write(text)
|
open(unit, "w").write(text)
|
||||||
PY
|
PY
|
||||||
|
|
||||||
@@ -453,11 +248,7 @@ cat <<EOF
|
|||||||
|
|
||||||
$(log "Installed")
|
$(log "Installed")
|
||||||
|
|
||||||
Bridge : http://127.0.0.1:$BRIDGE_PORT$( [[ "$BRIDGE_HOST" != "127.0.0.1" ]] && echo " (also on http://$BRIDGE_HOST:$BRIDGE_PORT)" )
|
Bridge : http://127.0.0.1:$BRIDGE_PORT
|
||||||
Device : $SERIAL_DEVICE
|
|
||||||
LED : GPIO $LED_PIN, $LED_MODE mode$( [[ "$LED_ACTIVE_LOW" -eq 1 ]] && echo ", active-low" )
|
|
||||||
Error LED : $( [[ -n "$ERROR_LED_PIN" ]] && echo "GPIO $ERROR_LED_PIN, ${ERROR_PULSE}s on a bad read" || echo "none (status LED blinks)" )
|
|
||||||
Heartbeat : ${HEARTBEAT}s (presence/LED window)
|
|
||||||
Card state : curl http://127.0.0.1:$BRIDGE_PORT/api/card
|
Card state : curl http://127.0.0.1:$BRIDGE_PORT/api/card
|
||||||
Health : curl http://127.0.0.1:$BRIDGE_PORT/api/health
|
Health : curl http://127.0.0.1:$BRIDGE_PORT/api/health
|
||||||
Logs : journalctl -u rdm-card-reader -f
|
Logs : journalctl -u rdm-card-reader -f
|
||||||
|
|||||||
@@ -2,10 +2,8 @@
|
|||||||
Description=RDM card reader HTTP bridge (serves card taps for web login)
|
Description=RDM card reader HTTP bridge (serves card taps for web login)
|
||||||
Documentation=file:/opt/rdm-card-reader/README.md
|
Documentation=file:/opt/rdm-card-reader/README.md
|
||||||
# The serial device must exist before we start, otherwise the reader opens the
|
# The serial device must exist before we start, otherwise the reader opens the
|
||||||
# wrong tty or none at all. install.sh rewrites this line for the device given
|
# wrong tty or none at all.
|
||||||
# with --device, since only it knows whether the reader is on the on-board UART
|
After=dev-serial.device network-online.target
|
||||||
# (/dev/serial0, the default) or a USB adapter (/dev/rdm-reader).
|
|
||||||
After=dev-serial0.device network-online.target
|
|
||||||
Wants=network-online.target
|
Wants=network-online.target
|
||||||
|
|
||||||
# Do not restart forever if the reader is genuinely absent. These are [Unit]
|
# Do not restart forever if the reader is genuinely absent. These are [Unit]
|
||||||
@@ -19,29 +17,22 @@ User=rdmcard
|
|||||||
Group=dialout
|
Group=dialout
|
||||||
WorkingDirectory=/opt/rdm-card-reader
|
WorkingDirectory=/opt/rdm-card-reader
|
||||||
|
|
||||||
# --serve runs the HTTP bridge, which is what makes the reader available as an
|
# --serve exposes the card on http://127.0.0.1:8765/api/card
|
||||||
# API on http://127.0.0.1:8765. --port points at the device the reader is
|
# --port points at the udev alias, so the reader is the same device every boot.
|
||||||
# actually wired to; install.sh rewrites this line from --device, and only it
|
|
||||||
# knows whether that is a USB adapter (/dev/rdm-reader) or the on-board UART
|
|
||||||
# (/dev/serial0). --led-pin is rewritten from --led-pin for the same reason.
|
|
||||||
# Autodetect refuses to guess if more than one USB adapter is present, which is
|
# Autodetect refuses to guess if more than one USB adapter is present, which is
|
||||||
# the safe behaviour: a wrong guess means authenticating the wrong person.
|
# the safe behaviour: a wrong guess means authenticating the wrong person.
|
||||||
# The wheels for rdm6300 and pyserial are bundled inside the package, so no
|
# The wheels for rdm6300 and pyserial are bundled inside the package, so no
|
||||||
# --wheel-dir is needed here: the install works with no internet.
|
# --wheel-dir is needed here: the install works with no internet.
|
||||||
ExecStart=/opt/rdm-card-reader/.venv/bin/rdm-card-reader \
|
ExecStart=/opt/rdm-card-reader/.venv/bin/rdm-card-reader \
|
||||||
--serve \
|
--serve \
|
||||||
--port /dev/serial0 \
|
--port /dev/rdm-reader \
|
||||||
--mode login \
|
--mode login \
|
||||||
--led-pin 23 \
|
--allow-origin https://your.web.app
|
||||||
--led-mode presence \
|
|
||||||
--heartbeat 2.0
|
|
||||||
Restart=always
|
Restart=always
|
||||||
RestartSec=3
|
RestartSec=3
|
||||||
|
|
||||||
# The bridge reads the serial port, drives the status LED and listens on
|
# The bridge only needs to read the serial port and listen on loopback.
|
||||||
# loopback. DeviceAllow is rewritten by install.sh alongside --port: with
|
|
||||||
# DevicePolicy=closed a device missing from this list is denied outright, and
|
|
||||||
# omitting the GPIO chip leaves the LED dark while everything else works.
|
|
||||||
NoNewPrivileges=true
|
NoNewPrivileges=true
|
||||||
PrivateTmp=true
|
PrivateTmp=true
|
||||||
ProtectSystem=strict
|
ProtectSystem=strict
|
||||||
@@ -53,9 +44,7 @@ RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
|
|||||||
RestrictNamespaces=true
|
RestrictNamespaces=true
|
||||||
LockPersonality=true
|
LockPersonality=true
|
||||||
MemoryDenyWriteExecute=true
|
MemoryDenyWriteExecute=true
|
||||||
DeviceAllow=/dev/serial0 rw
|
DeviceAllow=/dev/rdm-reader rw
|
||||||
DeviceAllow=/dev/gpiochip0 rw
|
|
||||||
DeviceAllow=/dev/gpiochip4 rw
|
|
||||||
DevicePolicy=closed
|
DevicePolicy=closed
|
||||||
|
|
||||||
# stdout/stderr go here; watch with: journalctl -u rdm-card-reader -f
|
# stdout/stderr go here; watch with: journalctl -u rdm-card-reader -f
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 22 KiB |
+6
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "rdm-card-reader"
|
name = "rdm-card-reader"
|
||||||
version = "0.4.0"
|
version = "0.3.0"
|
||||||
description = "RDM6300 / EM4100 RFID card reader: prints card events, or serves them over HTTP so a web app can authenticate a card tap."
|
description = "RDM6300 / EM4100 RFID card reader: prints card events, or serves them over HTTP so a web app can authenticate a card tap."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.8"
|
requires-python = ">=3.8"
|
||||||
@@ -24,6 +24,7 @@ dependencies = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
|
dev = ["pytest>=7.0"]
|
||||||
# Status LED support. Installed automatically on first use by the app, or
|
# Status LED support. Installed automatically on first use by the app, or
|
||||||
# explicitly with: pip install '.[led]'
|
# explicitly with: pip install '.[led]'
|
||||||
led = ["gpiozero>=2.0", "colorzero>=2.0"]
|
led = ["gpiozero>=2.0", "colorzero>=2.0"]
|
||||||
@@ -36,3 +37,7 @@ where = ["src"]
|
|||||||
|
|
||||||
[tool.setuptools.package-data]
|
[tool.setuptools.package-data]
|
||||||
rdm_card_reader = ["py.typed", "wheels/*.whl", "wheels/SHA256SUMS"]
|
rdm_card_reader = ["py.typed", "wheels/*.whl", "wheels/SHA256SUMS"]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["tests"]
|
||||||
|
addopts = "-q"
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 2.7 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 232 KiB |
@@ -30,12 +30,7 @@ from .ports import (
|
|||||||
discover,
|
discover,
|
||||||
resolve,
|
resolve,
|
||||||
)
|
)
|
||||||
from .led import (
|
from .led import DEFAULT_LED_PIN, StatusLED, create as create_led
|
||||||
DEFAULT_LED_MODE,
|
|
||||||
DEFAULT_LED_PIN,
|
|
||||||
StatusLED,
|
|
||||||
create as create_led,
|
|
||||||
)
|
|
||||||
from .reader import available, list_ports, open_reader, reload_library
|
from .reader import available, list_ports, open_reader, reload_library
|
||||||
|
|
||||||
__version__ = "0.4.0"
|
__version__ = "0.4.0"
|
||||||
@@ -46,7 +41,6 @@ __all__ = [
|
|||||||
"CardData",
|
"CardData",
|
||||||
"CardEvent",
|
"CardEvent",
|
||||||
"AmbiguousPort",
|
"AmbiguousPort",
|
||||||
"DEFAULT_LED_MODE",
|
|
||||||
"DEFAULT_LED_PIN",
|
"DEFAULT_LED_PIN",
|
||||||
"EventHub",
|
"EventHub",
|
||||||
"PortError",
|
"PortError",
|
||||||
|
|||||||
@@ -46,6 +46,10 @@ LED_PACKAGES = (
|
|||||||
("colorzero", "colorzero>=2.0"),
|
("colorzero", "colorzero>=2.0"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
#: The LED extras are optional: refusing to start because a LED cannot be
|
||||||
|
#: driven would be absurd.
|
||||||
|
OPTIONAL_IMPORTS = frozenset({"gpiozero", "colorzero"})
|
||||||
|
|
||||||
#: Shown when an install fails, so the operator knows what to do by hand.
|
#: Shown when an install fails, so the operator knows what to do by hand.
|
||||||
MANUAL_HINT = (
|
MANUAL_HINT = (
|
||||||
"Install manually with: {python} -m pip install {requirements}"
|
"Install manually with: {python} -m pip install {requirements}"
|
||||||
@@ -217,6 +221,12 @@ def install(
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _is_optional(packages: Sequence[tuple]) -> bool:
|
||||||
|
"""True when every package in the group is an optional extra."""
|
||||||
|
names = {name for name, _ in packages}
|
||||||
|
return bool(names) and names <= OPTIONAL_IMPORTS
|
||||||
|
|
||||||
|
|
||||||
def ensure_dependencies(
|
def ensure_dependencies(
|
||||||
packages: Sequence[tuple] = REQUIRED_PACKAGES,
|
packages: Sequence[tuple] = REQUIRED_PACKAGES,
|
||||||
wheel_dir: Optional[str] = None,
|
wheel_dir: Optional[str] = None,
|
||||||
|
|||||||
+22
-186
@@ -55,10 +55,8 @@ import time
|
|||||||
from collections import deque
|
from collections import deque
|
||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
from typing import Any, Callable, Deque, Dict, List, Optional
|
from typing import Any, Callable, Deque, Dict, List, Optional
|
||||||
from urllib.parse import urlsplit
|
|
||||||
|
|
||||||
from .card import EVENT_INVALID, EVENT_INSERTED, EVENT_REMOVED, CardEvent
|
from .card import EVENT_INSERTED, EVENT_REMOVED, CardEvent
|
||||||
from .webui import render_page
|
|
||||||
|
|
||||||
#: Reader modes. ``login`` publishes inserts only (a tap = an authentication
|
#: Reader modes. ``login`` publishes inserts only (a tap = an authentication
|
||||||
#: attempt). ``monitor`` publishes inserts and removals both.
|
#: attempt). ``monitor`` publishes inserts and removals both.
|
||||||
@@ -77,12 +75,6 @@ DEFAULT_HISTORY = 50
|
|||||||
#: replaced.
|
#: replaced.
|
||||||
DEFAULT_TAP_TTL = 10.0
|
DEFAULT_TAP_TTL = 10.0
|
||||||
|
|
||||||
#: How long a *claimed* tap stays readable afterwards. The page that claimed it
|
|
||||||
#: needs a moment to confirm which card logged in, but an id that stays
|
|
||||||
#: readable forever is indistinguishable from a tap still waiting to be spent -
|
|
||||||
#: so it is cleared once this window passes. Set to 0 to keep it indefinitely.
|
|
||||||
DEFAULT_CLAIMED_TTL = 30.0
|
|
||||||
|
|
||||||
|
|
||||||
class EventHub:
|
class EventHub:
|
||||||
"""Tracks reader state and fans card events out to waiting subscribers.
|
"""Tracks reader state and fans card events out to waiting subscribers.
|
||||||
@@ -104,7 +96,6 @@ class EventHub:
|
|||||||
history: int = DEFAULT_HISTORY,
|
history: int = DEFAULT_HISTORY,
|
||||||
mode: str = MODE_LOGIN,
|
mode: str = MODE_LOGIN,
|
||||||
tap_ttl: float = DEFAULT_TAP_TTL,
|
tap_ttl: float = DEFAULT_TAP_TTL,
|
||||||
claimed_ttl: float = DEFAULT_CLAIMED_TTL,
|
|
||||||
):
|
):
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
self._subscribers: List["deque"] = []
|
self._subscribers: List["deque"] = []
|
||||||
@@ -113,10 +104,9 @@ class EventHub:
|
|||||||
self._last_at: Optional[float] = None
|
self._last_at: Optional[float] = None
|
||||||
self._mode = mode if mode in VALID_MODES else MODE_LOGIN
|
self._mode = mode if mode in VALID_MODES else MODE_LOGIN
|
||||||
self._started_at = time.time()
|
self._started_at = time.time()
|
||||||
self._counts = {EVENT_INSERTED: 0, EVENT_REMOVED: 0, EVENT_INVALID: 0}
|
self._counts = {EVENT_INSERTED: 0, EVENT_REMOVED: 0, "invalid_card": 0}
|
||||||
self._port: Optional[str] = None
|
self._port: Optional[str] = None
|
||||||
self._tap_ttl = float(tap_ttl if tap_ttl else 0.0)
|
self._tap_ttl = float(tap_ttl if tap_ttl else 0.0)
|
||||||
self._claimed_ttl = float(claimed_ttl if claimed_ttl else 0.0)
|
|
||||||
|
|
||||||
# The latched tap. It deliberately outlives the card leaving the
|
# The latched tap. It deliberately outlives the card leaving the
|
||||||
# reader, so a page that loads after the tap can still claim it.
|
# reader, so a page that loads after the tap can still claim it.
|
||||||
@@ -187,16 +177,8 @@ class EventHub:
|
|||||||
|
|
||||||
``present`` is whether the card is physically on the reader right now;
|
``present`` is whether the card is physically on the reader right now;
|
||||||
``claimable`` is whether this tap can still be spent on a login.
|
``claimable`` is whether this tap can still be spent on a login.
|
||||||
|
|
||||||
A claimed tap is reported for ``claimed_ttl`` seconds after it was
|
|
||||||
spent, so the page that claimed it can confirm which card logged in. An
|
|
||||||
expired unclaimed tap is cleared too: keeping it would leave an id that
|
|
||||||
can never be claimed again, which is indistinguishable from a tap that
|
|
||||||
is still waiting to be used.
|
|
||||||
"""
|
"""
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._drop_dead_tap()
|
|
||||||
|
|
||||||
if self._tap is None:
|
if self._tap is None:
|
||||||
return {
|
return {
|
||||||
"present": False,
|
"present": False,
|
||||||
@@ -218,32 +200,6 @@ class EventHub:
|
|||||||
"card": self._tap.to_dict(),
|
"card": self._tap.to_dict(),
|
||||||
}
|
}
|
||||||
|
|
||||||
def _drop_dead_tap(self) -> None:
|
|
||||||
"""Clear the latched tap once it can no longer be acted on.
|
|
||||||
|
|
||||||
Called with the lock held. Two independent deadlines: an unclaimed tap
|
|
||||||
is dead after ``tap_ttl``, a claimed one after ``claimed_ttl``. A tap of
|
|
||||||
zero TTL means "keep until replaced", which the claimed case uses to
|
|
||||||
keep the id around indefinitely.
|
|
||||||
|
|
||||||
Crucially this drops only the *tap*, never the presence flag: whether a
|
|
||||||
card is on the reader is a physical fact learnt from insert/remove
|
|
||||||
events, and a tap ageing out says nothing about it. Clearing presence
|
|
||||||
here reported "no card" while a card was sitting on the antenna.
|
|
||||||
"""
|
|
||||||
if self._tap is None:
|
|
||||||
return
|
|
||||||
now = time.time()
|
|
||||||
if self._consumed_at is not None:
|
|
||||||
if self._claimed_ttl and now - self._consumed_at > self._claimed_ttl:
|
|
||||||
self._tap = None
|
|
||||||
self._tap_at = None
|
|
||||||
self._consumed_at = None
|
|
||||||
return
|
|
||||||
if self._tap_ttl and now - (self._tap_at or 0.0) > self._tap_ttl:
|
|
||||||
self._tap = None
|
|
||||||
self._tap_at = None
|
|
||||||
|
|
||||||
def consume_card(self):
|
def consume_card(self):
|
||||||
"""Atomically claim the latched tap for one authentication.
|
"""Atomically claim the latched tap for one authentication.
|
||||||
|
|
||||||
@@ -251,7 +207,6 @@ class EventHub:
|
|||||||
this call succeeds, so two concurrent requests cannot both win.
|
this call succeeds, so two concurrent requests cannot both win.
|
||||||
"""
|
"""
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._drop_dead_tap()
|
|
||||||
if self._tap is None:
|
if self._tap is None:
|
||||||
return None, "no card read yet", 409
|
return None, "no card read yet", 409
|
||||||
|
|
||||||
@@ -290,7 +245,6 @@ class EventHub:
|
|||||||
|
|
||||||
def health(self) -> Dict[str, Any]:
|
def health(self) -> Dict[str, Any]:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._drop_dead_tap()
|
|
||||||
last_age = (
|
last_age = (
|
||||||
round(time.time() - self._last_at, 1) if self._last_at else None
|
round(time.time() - self._last_at, 1) if self._last_at else None
|
||||||
)
|
)
|
||||||
@@ -306,7 +260,6 @@ class EventHub:
|
|||||||
"card_present": self._on_reader,
|
"card_present": self._on_reader,
|
||||||
"card_consumed": self._consumed_at is not None,
|
"card_consumed": self._consumed_at is not None,
|
||||||
"tap_ttl_seconds": self._tap_ttl or None,
|
"tap_ttl_seconds": self._tap_ttl or None,
|
||||||
"claimed_ttl_seconds": self._claimed_ttl or None,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
def last(self) -> Optional[Dict[str, Any]]:
|
def last(self) -> Optional[Dict[str, Any]]:
|
||||||
@@ -327,31 +280,14 @@ class _Handler(BaseHTTPRequestHandler):
|
|||||||
|
|
||||||
# -- helpers ----------------------------------------------------------
|
# -- helpers ----------------------------------------------------------
|
||||||
|
|
||||||
def _origin_is_loopback(self, origin: str) -> bool:
|
|
||||||
"""True when *origin* is this server itself or a loopback address.
|
|
||||||
|
|
||||||
A browser sends ``Origin`` even for same-origin requests, so without
|
|
||||||
this the status page served from this very port could not call its own
|
|
||||||
API. A remote site cannot forge ``Origin: http://127.0.0.1``, so
|
|
||||||
trusting loopback here does not widen what an attacker can reach.
|
|
||||||
"""
|
|
||||||
host = self.headers.get("Host")
|
|
||||||
if host and origin in ("http://" + host, "https://" + host):
|
|
||||||
return True
|
|
||||||
try:
|
|
||||||
hostname = (urlsplit(origin).hostname or "").lower()
|
|
||||||
except ValueError:
|
|
||||||
return False
|
|
||||||
return hostname in ("127.0.0.1", "localhost", "::1", "0.0.0.0")
|
|
||||||
|
|
||||||
def _cors_ok(self) -> bool:
|
def _cors_ok(self) -> bool:
|
||||||
"""Refuse cross-origin reads unless the origin was allow-listed."""
|
"""Refuse cross-origin reads unless the origin was allow-listed."""
|
||||||
origin = self.headers.get("Origin")
|
origin = self.headers.get("Origin")
|
||||||
if not origin:
|
if not origin:
|
||||||
return True # same-origin fetch or a non-browser client
|
return True # same-origin fetch or a non-browser client
|
||||||
if self._origin_is_loopback(origin):
|
|
||||||
return True
|
|
||||||
allowed: List[str] = getattr(self.server, "allowed_origins", [])
|
allowed: List[str] = getattr(self.server, "allowed_origins", [])
|
||||||
|
if not allowed:
|
||||||
|
return False # browsers only; opt out entirely
|
||||||
return origin in allowed
|
return origin in allowed
|
||||||
|
|
||||||
def _token_ok(self) -> bool:
|
def _token_ok(self) -> bool:
|
||||||
@@ -370,52 +306,26 @@ class _Handler(BaseHTTPRequestHandler):
|
|||||||
supplied = pair.split("=", 1)[1]
|
supplied = pair.split("=", 1)[1]
|
||||||
return secrets.compare_digest(supplied, required)
|
return secrets.compare_digest(supplied, required)
|
||||||
|
|
||||||
def _allowed_origin(self) -> Optional[str]:
|
|
||||||
"""The Origin to echo in ``Access-Control-Allow-Origin``, or None.
|
|
||||||
|
|
||||||
Same-origin and loopback callers are echoed so the dashboard can read
|
|
||||||
its own API; any other origin must be on the allow-list.
|
|
||||||
"""
|
|
||||||
origin = self.headers.get("Origin")
|
|
||||||
if not origin:
|
|
||||||
return None
|
|
||||||
if self._origin_is_loopback(origin):
|
|
||||||
return origin
|
|
||||||
if origin in getattr(self.server, "allowed_origins", []):
|
|
||||||
return origin
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _send_json(self, payload: Any, status: int = 200) -> None:
|
def _send_json(self, payload: Any, status: int = 200) -> None:
|
||||||
body = json.dumps(payload).encode("utf-8")
|
body = json.dumps(payload).encode("utf-8")
|
||||||
self.send_response(status)
|
self.send_response(status)
|
||||||
self.send_header("Content-Type", "application/json")
|
self.send_header("Content-Type", "application/json")
|
||||||
self.send_header("Content-Length", str(len(body)))
|
self.send_header("Content-Length", str(len(body)))
|
||||||
self.send_header("Cache-Control", "no-store")
|
self.send_header("Cache-Control", "no-store")
|
||||||
allowed = self._allowed_origin()
|
origin = self.headers.get("Origin")
|
||||||
if allowed:
|
if origin and origin in getattr(self.server, "allowed_origins", []):
|
||||||
self.send_header("Access-Control-Allow-Origin", allowed)
|
self.send_header("Access-Control-Allow-Origin", origin)
|
||||||
self.send_header("Vary", "Origin")
|
self.send_header("Vary", "Origin")
|
||||||
self.end_headers()
|
self.end_headers()
|
||||||
self.wfile.write(body)
|
self.wfile.write(body)
|
||||||
|
|
||||||
def _send_bytes(self, body: bytes, content_type: str, status: int = 200) -> None:
|
|
||||||
self.send_response(status)
|
|
||||||
self.send_header("Content-Type", content_type)
|
|
||||||
self.send_header("Content-Length", str(len(body)))
|
|
||||||
self.send_header("Cache-Control", "no-store")
|
|
||||||
self.end_headers()
|
|
||||||
self.wfile.write(body)
|
|
||||||
|
|
||||||
def _send_page(self) -> None:
|
|
||||||
self._send_bytes(render_page(), "text/html; charset=utf-8")
|
|
||||||
|
|
||||||
def _send_no_content(self) -> None:
|
def _send_no_content(self) -> None:
|
||||||
self.send_response(204)
|
self.send_response(204)
|
||||||
self.send_header("Cache-Control", "no-store")
|
self.send_header("Cache-Control", "no-store")
|
||||||
self.send_header("Content-Length", "0")
|
self.send_header("Content-Length", "0")
|
||||||
allowed = self._allowed_origin()
|
origin = self.headers.get("Origin")
|
||||||
if allowed:
|
if origin and origin in getattr(self.server, "allowed_origins", []):
|
||||||
self.send_header("Access-Control-Allow-Origin", allowed)
|
self.send_header("Access-Control-Allow-Origin", origin)
|
||||||
self.send_header("Vary", "Origin")
|
self.send_header("Vary", "Origin")
|
||||||
self.end_headers()
|
self.end_headers()
|
||||||
|
|
||||||
@@ -433,16 +343,6 @@ class _Handler(BaseHTTPRequestHandler):
|
|||||||
def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API
|
def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API
|
||||||
path = self.path.split("?", 1)[0].rstrip("/") or "/"
|
path = self.path.split("?", 1)[0].rstrip("/") or "/"
|
||||||
|
|
||||||
# The status page is served before the CORS and token checks, on
|
|
||||||
# purpose: it is static, reveals nothing on its own, and has to be able
|
|
||||||
# to load so it can prompt for a token when the API demands one.
|
|
||||||
if path == "/":
|
|
||||||
if getattr(self.server, "serve_page", True):
|
|
||||||
self._send_page()
|
|
||||||
else:
|
|
||||||
self._send_json({"error": "not found", "path": "/"}, status=404)
|
|
||||||
return
|
|
||||||
|
|
||||||
if path == "/api/health":
|
if path == "/api/health":
|
||||||
self._send_json(self._hub.health())
|
self._send_json(self._hub.health())
|
||||||
return
|
return
|
||||||
@@ -466,15 +366,6 @@ class _Handler(BaseHTTPRequestHandler):
|
|||||||
self._send_json(self._hub.current_card())
|
self._send_json(self._hub.current_card())
|
||||||
return
|
return
|
||||||
|
|
||||||
if path == "/api/led":
|
|
||||||
controller = getattr(self.server, "led", None)
|
|
||||||
if controller is None:
|
|
||||||
self._send_json({"error": "LED control not available"},
|
|
||||||
status=503)
|
|
||||||
return
|
|
||||||
self._send_json(controller.snapshot())
|
|
||||||
return
|
|
||||||
|
|
||||||
if path == "/api/taps":
|
if path == "/api/taps":
|
||||||
limit = 20
|
limit = 20
|
||||||
if "?" in self.path:
|
if "?" in self.path:
|
||||||
@@ -496,58 +387,6 @@ class _Handler(BaseHTTPRequestHandler):
|
|||||||
def do_POST(self) -> None: # noqa: N802
|
def do_POST(self) -> None: # noqa: N802
|
||||||
path = self.path.split("?", 1)[0].rstrip("/") or "/"
|
path = self.path.split("?", 1)[0].rstrip("/") or "/"
|
||||||
|
|
||||||
if path == "/api/led":
|
|
||||||
if not self._cors_ok():
|
|
||||||
self._send_json({"error": "origin not allowed"}, status=403)
|
|
||||||
return
|
|
||||||
if not self._token_ok():
|
|
||||||
self._send_json({"error": "missing or invalid token"}, status=401)
|
|
||||||
return
|
|
||||||
controller = getattr(self.server, "led", None)
|
|
||||||
if controller is None:
|
|
||||||
self._send_json({"error": "LED control not available"},
|
|
||||||
status=503)
|
|
||||||
return
|
|
||||||
|
|
||||||
length = int(self.headers.get("Content-Length") or 0)
|
|
||||||
raw = self.rfile.read(length) if length else b"{}"
|
|
||||||
try:
|
|
||||||
payload = json.loads(raw.decode("utf-8") or "{}")
|
|
||||||
except ValueError:
|
|
||||||
self._send_json({"error": "invalid JSON body"}, status=400)
|
|
||||||
return
|
|
||||||
|
|
||||||
which = str(payload.get("led", ""))
|
|
||||||
if which == "release" or payload.get("release"):
|
|
||||||
controller.release()
|
|
||||||
self._send_json(controller.snapshot())
|
|
||||||
return
|
|
||||||
|
|
||||||
if "on" not in payload:
|
|
||||||
self._send_json(
|
|
||||||
{"error": "expected {\"led\": \"status|error\", "
|
|
||||||
"\"on\": true|false}, or {\"release\": true}"},
|
|
||||||
status=400,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
if which not in ("status", "error"):
|
|
||||||
self._send_json(
|
|
||||||
{"error": "unknown led", "valid": ["status", "error"]},
|
|
||||||
status=400,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
if not controller.set_led(which, bool(payload.get("on"))):
|
|
||||||
# The LED exists in configuration but has no usable pin, so
|
|
||||||
# there is nothing to switch. Say so rather than 200.
|
|
||||||
self._send_json(
|
|
||||||
{"error": "that LED is not available",
|
|
||||||
"state": controller.snapshot()},
|
|
||||||
status=409,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
self._send_json(controller.snapshot())
|
|
||||||
return
|
|
||||||
|
|
||||||
if path == "/api/card/claim":
|
if path == "/api/card/claim":
|
||||||
if not self._cors_ok():
|
if not self._cors_ok():
|
||||||
self._send_json({"error": "origin not allowed"}, status=403)
|
self._send_json({"error": "origin not allowed"}, status=403)
|
||||||
@@ -592,10 +431,10 @@ class _Handler(BaseHTTPRequestHandler):
|
|||||||
self._send_json({"mode": mode})
|
self._send_json({"mode": mode})
|
||||||
|
|
||||||
def do_OPTIONS(self) -> None: # noqa: N802
|
def do_OPTIONS(self) -> None: # noqa: N802
|
||||||
|
origin = self.headers.get("Origin")
|
||||||
self.send_response(204)
|
self.send_response(204)
|
||||||
allowed = self._allowed_origin()
|
if origin and origin in getattr(self.server, "allowed_origins", []):
|
||||||
if allowed:
|
self.send_header("Access-Control-Allow-Origin", origin)
|
||||||
self.send_header("Access-Control-Allow-Origin", allowed)
|
|
||||||
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||||
self.send_header("Access-Control-Allow-Headers", "Content-Type, X-Auth-Token")
|
self.send_header("Access-Control-Allow-Headers", "Content-Type, X-Auth-Token")
|
||||||
self.send_header("Vary", "Origin")
|
self.send_header("Vary", "Origin")
|
||||||
@@ -611,9 +450,9 @@ class _Handler(BaseHTTPRequestHandler):
|
|||||||
self.send_header("Content-Type", "text/event-stream")
|
self.send_header("Content-Type", "text/event-stream")
|
||||||
self.send_header("Cache-Control", "no-store")
|
self.send_header("Cache-Control", "no-store")
|
||||||
self.send_header("Connection", "keep-alive")
|
self.send_header("Connection", "keep-alive")
|
||||||
allowed = self._allowed_origin()
|
origin = self.headers.get("Origin")
|
||||||
if allowed:
|
if origin and origin in getattr(self.server, "allowed_origins", []):
|
||||||
self.send_header("Access-Control-Allow-Origin", allowed)
|
self.send_header("Access-Control-Allow-Origin", origin)
|
||||||
self.send_header("Vary", "Origin")
|
self.send_header("Vary", "Origin")
|
||||||
self.end_headers()
|
self.end_headers()
|
||||||
|
|
||||||
@@ -648,20 +487,17 @@ def create_server(
|
|||||||
allowed_origins: Optional[List[str]] = None,
|
allowed_origins: Optional[List[str]] = None,
|
||||||
token: Optional[str] = None,
|
token: Optional[str] = None,
|
||||||
logger: Optional[Callable[[str], None]] = None,
|
logger: Optional[Callable[[str], None]] = None,
|
||||||
serve_page: bool = True,
|
|
||||||
led: Any = None,
|
|
||||||
) -> ThreadingHTTPServer:
|
) -> ThreadingHTTPServer:
|
||||||
"""Build (but do not start) the HTTP server around *hub*.
|
"""Build (but do not start) the HTTP server around *hub*."""
|
||||||
|
|
||||||
*led* is the LED controller, exposed for the manual override at
|
|
||||||
``/api/led``. It is optional: without it the endpoint reports 503.
|
|
||||||
"""
|
|
||||||
server = ThreadingHTTPServer((host, port), _Handler)
|
server = ThreadingHTTPServer((host, port), _Handler)
|
||||||
server.daemon_threads = True
|
server.daemon_threads = True
|
||||||
server.hub = hub # type: ignore[attr-defined]
|
server.hub = hub # type: ignore[attr-defined]
|
||||||
server.led = led # type: ignore[attr-defined]
|
|
||||||
server.serve_page = serve_page # type: ignore[attr-defined]
|
|
||||||
server.allowed_origins = list(allowed_origins or []) # type: ignore[attr-defined]
|
server.allowed_origins = list(allowed_origins or []) # type: ignore[attr-defined]
|
||||||
server.token = token or os.environ.get("RDM_BRIDGE_TOKEN") or None # type: ignore[attr-defined]
|
server.token = token or os.environ.get("RDM_BRIDGE_TOKEN") or None # type: ignore[attr-defined]
|
||||||
server.logger = logger # type: ignore[attr-defined]
|
server.logger = logger # type: ignore[attr-defined]
|
||||||
return server
|
return server
|
||||||
|
|
||||||
|
|
||||||
|
def is_loopback(host: str) -> bool:
|
||||||
|
"""True for hosts that are not reachable off-box."""
|
||||||
|
return host in ("127.0.0.1", "::1", "localhost")
|
||||||
|
|||||||
@@ -18,13 +18,10 @@ except ImportError: # pragma: no cover - fallback so the model stays importable
|
|||||||
CardData = namedtuple("CardData", ["value", "checksum", "type", "is_valid"])
|
CardData = namedtuple("CardData", ["value", "checksum", "type", "is_valid"])
|
||||||
|
|
||||||
#: Serial devices tried in order. ``/dev/ttyS0`` first matches the Raspberry Pi
|
#: Serial devices tried in order. ``/dev/ttyS0`` first matches the Raspberry Pi
|
||||||
#: default, the others cover USB / GPIO UART dongles. ``/dev/serial0`` is the
|
#: default, the others cover USB / GPIO UART dongles.
|
||||||
#: canonical Pi alias for the on-board UART - it resolves to ttyAMA0 or ttyS0
|
|
||||||
#: depending on the model, and is the device the docs tell you to use.
|
|
||||||
CANDIDATE_PORTS = (
|
CANDIDATE_PORTS = (
|
||||||
"/dev/ttyS0",
|
"/dev/ttyS0",
|
||||||
"/dev/ttyAMA0",
|
"/dev/ttyAMA0",
|
||||||
"/dev/serial0",
|
|
||||||
"/dev/ttyUSB0",
|
"/dev/ttyUSB0",
|
||||||
"/dev/ttyACM0",
|
"/dev/ttyACM0",
|
||||||
)
|
)
|
||||||
@@ -35,15 +32,7 @@ CONFIG_CARD_VALUE = 12886709
|
|||||||
|
|
||||||
#: Seconds without a read before a card is considered removed. Set to ``None``
|
#: Seconds without a read before a card is considered removed. Set to ``None``
|
||||||
#: to disable removal detection entirely.
|
#: to disable removal detection entirely.
|
||||||
#:
|
DEFAULT_HEARTBEAT_INTERVAL = 0.5
|
||||||
#: This is also the presence window: the card counts as "present" until this
|
|
||||||
#: long after the last frame. RDM6300 modules typically report a card once per
|
|
||||||
#: placement rather than re-reading continuously, so a short value makes
|
|
||||||
#: ``present`` flicker - and the status LED with it - while the card sits still
|
|
||||||
#: on the antenna. Longer holds presence (and the light) on longer but makes
|
|
||||||
#: lifting the card take longer to notice; raise it if a card held in place is
|
|
||||||
#: reported as removed, lower it if removal feels sluggish.
|
|
||||||
DEFAULT_HEARTBEAT_INTERVAL = 2.0
|
|
||||||
|
|
||||||
BAR = "-" * 58
|
BAR = "-" * 58
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ from typing import Optional, Sequence
|
|||||||
from . import __version__
|
from . import __version__
|
||||||
from .bootstrap import LED_PACKAGES, ensure_dependencies
|
from .bootstrap import LED_PACKAGES, ensure_dependencies
|
||||||
from .bridge import (
|
from .bridge import (
|
||||||
DEFAULT_CLAIMED_TTL,
|
|
||||||
DEFAULT_HOST,
|
DEFAULT_HOST,
|
||||||
DEFAULT_PORT,
|
DEFAULT_PORT,
|
||||||
DEFAULT_TAP_TTL,
|
DEFAULT_TAP_TTL,
|
||||||
@@ -20,22 +19,15 @@ from .bridge import (
|
|||||||
create_server,
|
create_server,
|
||||||
)
|
)
|
||||||
from .card import (
|
from .card import (
|
||||||
|
BAR,
|
||||||
CANDIDATE_PORTS,
|
CANDIDATE_PORTS,
|
||||||
DEFAULT_HEARTBEAT_INTERVAL,
|
DEFAULT_HEARTBEAT_INTERVAL,
|
||||||
EVENT_INVALID,
|
EVENT_INVALID,
|
||||||
EVENT_REMOVED,
|
|
||||||
CardEvent,
|
CardEvent,
|
||||||
format_event,
|
format_event,
|
||||||
)
|
)
|
||||||
from .demo import run_demo
|
from .demo import run_demo
|
||||||
from .led import (
|
from .led import DEFAULT_LED_PIN, MODES as LED_MODES, create as create_led
|
||||||
DEFAULT_ERROR_LED_PIN,
|
|
||||||
DEFAULT_LED_MODE,
|
|
||||||
DEFAULT_LED_PIN,
|
|
||||||
ERROR_BLINK_SECONDS,
|
|
||||||
MODES as LED_MODES,
|
|
||||||
create as create_led,
|
|
||||||
)
|
|
||||||
from .ports import AmbiguousPort, PortNotFound, discover, resolve
|
from .ports import AmbiguousPort, PortNotFound, discover, resolve
|
||||||
from .reader import available, explain_error, list_ports, open_reader, reload_library
|
from .reader import available, explain_error, list_ports, open_reader, reload_library
|
||||||
|
|
||||||
@@ -145,11 +137,6 @@ def _build_led(args):
|
|||||||
if args.no_led:
|
if args.no_led:
|
||||||
return create_led(enabled=False)
|
return create_led(enabled=False)
|
||||||
|
|
||||||
# A dedicated error LED is the default. The old single-LED behaviour is
|
|
||||||
# still reachable: --no-error-led blinks the status LED instead, and
|
|
||||||
# --error-led-pin with the status pin's number does the same thing.
|
|
||||||
error_pin = None if args.no_error_led else args.error_led_pin
|
|
||||||
|
|
||||||
# Only pull in gpiozero when a LED was actually asked for, so a headless
|
# Only pull in gpiozero when a LED was actually asked for, so a headless
|
||||||
# install does not grow the dependency set.
|
# install does not grow the dependency set.
|
||||||
ensure_dependencies(
|
ensure_dependencies(
|
||||||
@@ -163,11 +150,7 @@ def _build_led(args):
|
|||||||
pin=args.led_pin,
|
pin=args.led_pin,
|
||||||
enabled=True,
|
enabled=True,
|
||||||
mode=args.led_mode,
|
mode=args.led_mode,
|
||||||
active_high=not args.led_active_low,
|
|
||||||
error_blink=not args.led_no_error_blink,
|
error_blink=not args.led_no_error_blink,
|
||||||
error_pin=error_pin,
|
|
||||||
error_active_high=not args.error_led_active_low,
|
|
||||||
error_pulse=args.error_pulse,
|
|
||||||
logger=lambda message: print(" {}".format(message)),
|
logger=lambda message: print(" {}".format(message)),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -186,8 +169,7 @@ def _run_bridge(args) -> int:
|
|||||||
|
|
||||||
device = _resolve_port(args)
|
device = _resolve_port(args)
|
||||||
|
|
||||||
hub = EventHub(mode=args.mode, tap_ttl=args.tap_ttl,
|
hub = EventHub(mode=args.mode, tap_ttl=args.tap_ttl)
|
||||||
claimed_ttl=args.claimed_ttl)
|
|
||||||
hub.set_port(device)
|
hub.set_port(device)
|
||||||
|
|
||||||
led = _build_led(args)
|
led = _build_led(args)
|
||||||
@@ -196,7 +178,7 @@ def _run_bridge(args) -> int:
|
|||||||
# Drive the LED first: it is queued, so it never delays the reader.
|
# Drive the LED first: it is queued, so it never delays the reader.
|
||||||
if event.event == EVENT_INVALID:
|
if event.event == EVENT_INVALID:
|
||||||
led.error()
|
led.error()
|
||||||
elif event.event == EVENT_REMOVED:
|
elif event.event == "card_removed":
|
||||||
led.remove()
|
led.remove()
|
||||||
else:
|
else:
|
||||||
led.insert()
|
led.insert()
|
||||||
@@ -223,8 +205,6 @@ def _run_bridge(args) -> int:
|
|||||||
allowed_origins=args.allow_origin,
|
allowed_origins=args.allow_origin,
|
||||||
token=args.token,
|
token=args.token,
|
||||||
logger=print if args.verbose else None,
|
logger=print if args.verbose else None,
|
||||||
serve_page=not args.no_page,
|
|
||||||
led=led,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
server_thread = threading.Thread(target=server.serve_forever, daemon=True,
|
server_thread = threading.Thread(target=server.serve_forever, daemon=True,
|
||||||
@@ -233,10 +213,7 @@ def _run_bridge(args) -> int:
|
|||||||
|
|
||||||
print(" OK reader on {} ; bridge on http://{}:{} (mode: {})".format(
|
print(" OK reader on {} ; bridge on http://{}:{} (mode: {})".format(
|
||||||
device, args.host, args.bridge_port, args.mode))
|
device, args.host, args.bridge_port, args.mode))
|
||||||
if not args.no_page:
|
|
||||||
print(" GET / <- status page in a browser")
|
|
||||||
print(" GET /api/card <- the card on the reader right now")
|
print(" GET /api/card <- the card on the reader right now")
|
||||||
print(" GET /api/led <- LED state; POST to switch one by hand")
|
|
||||||
print(" POST /api/card/claim (single use, for one login)")
|
print(" POST /api/card/claim (single use, for one login)")
|
||||||
print(" GET /api/events (SSE stream)")
|
print(" GET /api/events (SSE stream)")
|
||||||
print(" GET /api/last /api/health")
|
print(" GET /api/last /api/health")
|
||||||
@@ -308,10 +285,6 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
bridge.add_argument("--tap-ttl", type=float, default=DEFAULT_TAP_TTL,
|
bridge.add_argument("--tap-ttl", type=float, default=DEFAULT_TAP_TTL,
|
||||||
help="seconds an unclaimed tap stays claimable; 0 keeps it "
|
help="seconds an unclaimed tap stays claimable; 0 keeps it "
|
||||||
"claimable while the card is present (default: %(default)s)")
|
"claimable while the card is present (default: %(default)s)")
|
||||||
bridge.add_argument("--claimed-ttl", type=float, default=DEFAULT_CLAIMED_TTL,
|
|
||||||
help="seconds a claimed card stays readable so the claiming "
|
|
||||||
"page can confirm which card logged in; 0 keeps it "
|
|
||||||
"indefinitely (default: %(default)s)")
|
|
||||||
bridge.add_argument("--allow-origin", action="append", metavar="ORIGIN",
|
bridge.add_argument("--allow-origin", action="append", metavar="ORIGIN",
|
||||||
help="permit this browser origin to call the API; repeatable")
|
help="permit this browser origin to call the API; repeatable")
|
||||||
bridge.add_argument("--token",
|
bridge.add_argument("--token",
|
||||||
@@ -319,8 +292,6 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
"(or set RDM_BRIDGE_TOKEN)")
|
"(or set RDM_BRIDGE_TOKEN)")
|
||||||
bridge.add_argument("--allow-remote", action="store_true",
|
bridge.add_argument("--allow-remote", action="store_true",
|
||||||
help="permit binding a non-loopback address")
|
help="permit binding a non-loopback address")
|
||||||
bridge.add_argument("--no-page", action="store_true",
|
|
||||||
help="do not serve the status page at GET /")
|
|
||||||
bridge.add_argument("--verbose", action="store_true",
|
bridge.add_argument("--verbose", action="store_true",
|
||||||
help="also print each event and each HTTP request")
|
help="also print each event and each HTTP request")
|
||||||
|
|
||||||
@@ -330,27 +301,12 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
led.add_argument("--led-pin", type=int, default=DEFAULT_LED_PIN,
|
led.add_argument("--led-pin", type=int, default=DEFAULT_LED_PIN,
|
||||||
help="GPIO pin of the status LED (default: %(default)s, "
|
help="GPIO pin of the status LED (default: %(default)s, "
|
||||||
"matching the workstation app)")
|
"matching the workstation app)")
|
||||||
led.add_argument("--led-mode", choices=list(LED_MODES), default=DEFAULT_LED_MODE,
|
led.add_argument("--led-mode", choices=list(LED_MODES), default=LED_MODES[0],
|
||||||
help="'presence' (default): lit only while a card is on the "
|
help="'error' (default): lit whenever the reader is running, "
|
||||||
"reader; 'error': lit whenever the reader is running, "
|
"blinking means a bad read; 'presence': lit only while "
|
||||||
"blinking means a bad read")
|
"a card is on the reader")
|
||||||
led.add_argument("--led-no-error-blink", action="store_true",
|
led.add_argument("--led-no-error-blink", action="store_true",
|
||||||
help="do not blink the LED on a bad read")
|
help="do not blink the LED on a bad read")
|
||||||
led.add_argument("--led-active-low", action="store_true",
|
|
||||||
help="LED is wired active-low (cathode to the pin, anode "
|
|
||||||
"to 3.3 V); inverts the signal")
|
|
||||||
led.add_argument("--error-led-pin", type=int, default=DEFAULT_ERROR_LED_PIN,
|
|
||||||
metavar="N",
|
|
||||||
help="BCM GPIO of a dedicated error LED, lit briefly on a "
|
|
||||||
"bad read (default: %(default)s, matching the "
|
|
||||||
"workstation app)")
|
|
||||||
led.add_argument("--no-error-led", action="store_true",
|
|
||||||
help="no dedicated error LED; blink the status LED instead")
|
|
||||||
led.add_argument("--error-led-active-low", action="store_true",
|
|
||||||
help="the error LED is wired active-low")
|
|
||||||
led.add_argument("--error-pulse", type=float, default=ERROR_BLINK_SECONDS,
|
|
||||||
help="seconds to hold the error LED on a bad read "
|
|
||||||
"(default: %(default)s)")
|
|
||||||
return parser
|
return parser
|
||||||
|
|
||||||
|
|
||||||
@@ -381,7 +337,7 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
|
|||||||
# LED first: it is queued, so it never delays the reader loop.
|
# LED first: it is queued, so it never delays the reader loop.
|
||||||
if event.event == EVENT_INVALID:
|
if event.event == EVENT_INVALID:
|
||||||
led.error()
|
led.error()
|
||||||
elif event.event == EVENT_REMOVED:
|
elif event.event == "card_removed":
|
||||||
led.remove()
|
led.remove()
|
||||||
else:
|
else:
|
||||||
led.insert()
|
led.insert()
|
||||||
|
|||||||
@@ -7,9 +7,6 @@ import time
|
|||||||
|
|
||||||
from .card import (
|
from .card import (
|
||||||
DEFAULT_HEARTBEAT_INTERVAL,
|
DEFAULT_HEARTBEAT_INTERVAL,
|
||||||
EVENT_INVALID,
|
|
||||||
EVENT_INSERTED,
|
|
||||||
EVENT_REMOVED,
|
|
||||||
CardData,
|
CardData,
|
||||||
CardEvent,
|
CardEvent,
|
||||||
format_event,
|
format_event,
|
||||||
@@ -40,12 +37,12 @@ def run_demo(
|
|||||||
|
|
||||||
for _ in range(max(1, count)):
|
for _ in range(max(1, count)):
|
||||||
card = rng.choice(DEMO_CARDS)
|
card = rng.choice(DEMO_CARDS)
|
||||||
event_name = EVENT_INSERTED if card.is_valid else EVENT_INVALID
|
event_name = "card_inserted" if card.is_valid else "invalid_card"
|
||||||
_emit(CardEvent.from_card(event_name, card, "demo"), as_json)
|
_emit(CardEvent.from_card(event_name, card, "demo"), as_json)
|
||||||
|
|
||||||
time.sleep(max(0.1, heartbeat))
|
time.sleep(max(0.1, heartbeat))
|
||||||
if card.is_valid:
|
if card.is_valid:
|
||||||
_emit(CardEvent.from_card(EVENT_REMOVED, card, "demo"), as_json)
|
_emit(CardEvent.from_card("card_removed", card, "demo"), as_json)
|
||||||
time.sleep(0.3)
|
time.sleep(0.3)
|
||||||
|
|
||||||
print("\nDemo finished.", flush=True)
|
print("\nDemo finished.", flush=True)
|
||||||
|
|||||||
+55
-266
@@ -12,35 +12,21 @@ differences:
|
|||||||
* It is explicit. Whether an LED is actually attached is reported, so "no light
|
* It is explicit. Whether an LED is actually attached is reported, so "no light
|
||||||
came on" is distinguishable from "the card was not read".
|
came on" is distinguishable from "the card was not read".
|
||||||
|
|
||||||
Two LEDs, because one cannot say two things at once:
|
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
|
||||||
* the **status** LED (default GPIO 23) tracks the card: lit while one is on the
|
will fight.
|
||||||
reader, in ``presence`` mode.
|
|
||||||
* the **error** LED (default GPIO 24) is for a bad read: dark normally, lit
|
|
||||||
briefly on a checksum failure. Without it, ``presence`` mode - the default -
|
|
||||||
has no way at all to report an invalid card, because the status light is
|
|
||||||
already carrying the card's presence.
|
|
||||||
|
|
||||||
The default status pin (23) matches the workstation app. **Use a separate LED
|
|
||||||
from the workstation's if both run on one device**, since two processes driving
|
|
||||||
one pin will fight.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import queue
|
import queue
|
||||||
import threading
|
import threading
|
||||||
import time
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any, Callable, Optional, Tuple
|
from typing import Any, Callable, Optional
|
||||||
|
|
||||||
#: GPIO pin of the status LED. Chosen to match the workstation app.
|
#: GPIO pin of the status LED. Chosen to match the workstation app.
|
||||||
DEFAULT_LED_PIN = 23
|
DEFAULT_LED_PIN = 23
|
||||||
|
|
||||||
#: GPIO pin of the error LED. Separate from the status pin so the two can be
|
|
||||||
#: read independently: one says "a card is here", the other "the read failed".
|
|
||||||
DEFAULT_ERROR_LED_PIN = 24
|
|
||||||
|
|
||||||
#: How long to hold an error indication, in seconds.
|
#: How long to hold an error indication, in seconds.
|
||||||
ERROR_BLINK_SECONDS = 1.0
|
ERROR_BLINK_SECONDS = 1.0
|
||||||
|
|
||||||
@@ -48,20 +34,19 @@ OFF = "off"
|
|||||||
ON = "on"
|
ON = "on"
|
||||||
BLINK = "blink"
|
BLINK = "blink"
|
||||||
|
|
||||||
#: Lit only while a card is on the reader, and off otherwise. This is a card
|
|
||||||
#: indicator, for when the light should mean "a card is here". The default: a
|
|
||||||
#: light that tracks the card is unambiguous, whereas a light that is always on
|
|
||||||
#: says nothing about the card.
|
|
||||||
PRESENCE = "presence"
|
|
||||||
|
|
||||||
#: Lit the whole time the app runs, and blinks on a bad read. This is a health
|
#: Lit the whole time the app runs, and blinks on a bad read. This is a health
|
||||||
#: indicator: light on means the reader is alive and ready for a card.
|
#: indicator: light on means the reader is alive and ready for a card.
|
||||||
ERROR = "error"
|
ERROR = "error"
|
||||||
|
|
||||||
MODES = (PRESENCE, 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)
|
||||||
|
|
||||||
#: What the light means unless told otherwise.
|
|
||||||
DEFAULT_LED_MODE = PRESENCE
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class LEDStatus:
|
class LEDStatus:
|
||||||
@@ -73,8 +58,6 @@ class LEDStatus:
|
|||||||
mode: str = ERROR
|
mode: str = ERROR
|
||||||
state: bool = False
|
state: bool = False
|
||||||
reason: str = ""
|
reason: str = ""
|
||||||
#: False when the LED is wired active-low (cathode to the pin).
|
|
||||||
active_high: bool = True
|
|
||||||
|
|
||||||
def to_dict(self) -> dict:
|
def to_dict(self) -> dict:
|
||||||
return {
|
return {
|
||||||
@@ -84,7 +67,6 @@ class LEDStatus:
|
|||||||
"mode": self.mode,
|
"mode": self.mode,
|
||||||
"state": "on" if self.state else "off",
|
"state": "on" if self.state else "off",
|
||||||
"reason": self.reason,
|
"reason": self.reason,
|
||||||
"active_high": self.active_high,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -165,39 +147,6 @@ class _Blinker(threading.Thread):
|
|||||||
self._stop.set()
|
self._stop.set()
|
||||||
|
|
||||||
|
|
||||||
def _open_led(
|
|
||||||
pin: int, active_high: bool, log: Callable[[str], None], label: str
|
|
||||||
) -> Tuple[Any, bool, str]:
|
|
||||||
"""Build the pin object for *pin*.
|
|
||||||
|
|
||||||
Returns ``(led, usable, reason)``. *led* is a :class:`NullLED` when no pin
|
|
||||||
is usable, so callers never have to special-case it.
|
|
||||||
|
|
||||||
The library import is checked **separately** from the pin construction on
|
|
||||||
purpose: ``gpiozero`` raises ``BadPinFactory`` when no pin provider works,
|
|
||||||
and that is a subclass of ``ImportError``. Catching ``ImportError`` around
|
|
||||||
the pin construction would therefore report "gpiozero is not installed"
|
|
||||||
even when it is installed and simply has no usable pin library - on a Pi
|
|
||||||
missing ``lgpio`` or ``RPi.GPIO`` that message sends you to the wrong fix.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
import gpiozero # noqa: F401
|
|
||||||
except ImportError as exc:
|
|
||||||
reason = "gpiozero not installed: {}".format(exc)
|
|
||||||
log("{} unavailable: {}".format(label, reason))
|
|
||||||
return NullLED(pin, reason), False, reason
|
|
||||||
|
|
||||||
try:
|
|
||||||
built = GPIOLED(pin, active_high=active_high)
|
|
||||||
except Exception as exc:
|
|
||||||
# No usable pin factory (not a Pi), pin already in use, bad pin.
|
|
||||||
reason = "no usable GPIO on pin {}: {}".format(pin, exc)
|
|
||||||
log("{} unavailable: {}".format(label, reason))
|
|
||||||
return NullLED(pin, reason), False, reason
|
|
||||||
|
|
||||||
return built, True, "ready"
|
|
||||||
|
|
||||||
|
|
||||||
class StatusLED:
|
class StatusLED:
|
||||||
"""Non-blocking LED controller driven by card events.
|
"""Non-blocking LED controller driven by card events.
|
||||||
|
|
||||||
@@ -209,83 +158,56 @@ class StatusLED:
|
|||||||
self,
|
self,
|
||||||
pin: int = DEFAULT_LED_PIN,
|
pin: int = DEFAULT_LED_PIN,
|
||||||
enabled: bool = True,
|
enabled: bool = True,
|
||||||
mode: str = DEFAULT_LED_MODE,
|
mode: str = ERROR,
|
||||||
active_high: bool = True,
|
active_high: bool = True,
|
||||||
error_blink: bool = True,
|
error_blink: bool = True,
|
||||||
logger: Optional[Callable[[str], None]] = None,
|
logger: Optional[Callable[[str], None]] = None,
|
||||||
error_pin: Optional[int] = None,
|
|
||||||
error_active_high: bool = True,
|
|
||||||
error_pulse: float = ERROR_BLINK_SECONDS,
|
|
||||||
):
|
):
|
||||||
self._log = logger or (lambda message: None)
|
self._log = logger or (lambda message: None)
|
||||||
self._mode = mode if mode in MODES else DEFAULT_LED_MODE
|
self._mode = mode if mode in MODES else ERROR
|
||||||
self._error_blink = error_blink
|
self._error_blink = error_blink
|
||||||
self._enabled = enabled
|
self._enabled = enabled
|
||||||
self._blinker: Optional[_Blinker] = None
|
self._blinker: Optional[_Blinker] = None
|
||||||
self._error_pulse = max(0.0, float(error_pulse))
|
|
||||||
self._state = False
|
self._state = False
|
||||||
self._error_until: Optional[float] = None
|
|
||||||
self._error_state = False
|
|
||||||
#: Set while the status page is forcing a LED, so card events and the
|
|
||||||
#: error pulse leave it alone.
|
|
||||||
self._manual_status = False
|
|
||||||
self._manual_error = False
|
|
||||||
self._queue: "queue.Queue[Optional[str]]" = queue.Queue()
|
self._queue: "queue.Queue[Optional[str]]" = queue.Queue()
|
||||||
self._thread: Optional[threading.Thread] = None
|
self._thread: Optional[threading.Thread] = None
|
||||||
|
|
||||||
if not enabled:
|
if not enabled:
|
||||||
self.led: Any = NullLED(None, "LED disabled (--no-led)")
|
self.led: Any = NullLED(None, "LED disabled (--no-led)")
|
||||||
self.status = LEDStatus(False, None, "null", self._mode, False,
|
self.status = LEDStatus(False, None, "null", self._mode, False,
|
||||||
"disabled", active_high)
|
"disabled")
|
||||||
self.error_led: Any = NullLED(None, "LED disabled (--no-led)")
|
|
||||||
self.error_status = LEDStatus(False, None, "null", self._mode,
|
|
||||||
False, "disabled", error_active_high)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
self.led, usable, reason = _open_led(pin, active_high, self._log, "LED")
|
# Check the library separately from building the pin.
|
||||||
self.status = LEDStatus(usable, pin,
|
#
|
||||||
"gpiozero" if usable else "null", self._mode,
|
# gpiozero raises BadPinFactory when no pin provider works, and that is
|
||||||
False, reason, active_high)
|
# a subclass of ImportError, so catching ImportError around the pin
|
||||||
if usable:
|
# construction would report "gpiozero is not installed" even when it is
|
||||||
self._log("LED on GPIO {} ({} mode, {}, gpiozero)".format(
|
# installed and simply has no usable pin library. On a Pi missing lgpio
|
||||||
pin, self._mode,
|
# or RPi.GPIO that message sends you to the wrong fix.
|
||||||
"active-high" if active_high else "active-low"))
|
try:
|
||||||
|
import gpiozero # noqa: F401
|
||||||
# A dedicated error LED, when one is wired. It reports a bad read on its
|
except ImportError as exc:
|
||||||
# own pin, so the status light stays free to mean "a card is here".
|
reason = "gpiozero not installed: {}".format(exc)
|
||||||
if error_pin is not None and error_pin == pin:
|
self.led = NullLED(pin, reason)
|
||||||
# Two LEDs on one pin cannot be driven independently, and opening
|
self.status = LEDStatus(False, pin, "null", self._mode, False,
|
||||||
# the pin twice makes the second open fail anyway. Treat it as "no
|
reason)
|
||||||
# dedicated error LED" so the status LED blink is used instead.
|
self._log("LED unavailable: {}".format(reason))
|
||||||
self._log("Error LED pin {} is the status pin; using the status "
|
|
||||||
"LED to indicate bad reads".format(error_pin))
|
|
||||||
error_pin = None
|
|
||||||
|
|
||||||
if error_pin is None:
|
|
||||||
self.error_led = NullLED(None, "no error LED configured")
|
|
||||||
self.error_status = LEDStatus(False, None, "null", ERROR, False,
|
|
||||||
"not configured", error_active_high)
|
|
||||||
else:
|
else:
|
||||||
self.error_led, e_usable, e_reason = _open_led(
|
try:
|
||||||
error_pin, error_active_high, self._log, "Error LED")
|
self.led = GPIOLED(pin, active_high=active_high)
|
||||||
self.error_status = LEDStatus(e_usable, error_pin,
|
self.status = LEDStatus(True, pin, "gpiozero", self._mode,
|
||||||
"gpiozero" if e_usable else "null",
|
False, "ready")
|
||||||
ERROR, False, e_reason,
|
self._log("LED on GPIO {} ({} mode, gpiozero)".format(
|
||||||
error_active_high)
|
pin, self._mode))
|
||||||
if e_usable:
|
except Exception as exc:
|
||||||
self._log("Error LED on GPIO {} (lit for {}s on a bad read, "
|
# No usable pin factory (not a Pi), pin already in use, bad pin.
|
||||||
"{}, gpiozero)".format(
|
reason = ("no usable GPIO on pin {}: {}".format(pin, exc))
|
||||||
error_pin, self._error_pulse,
|
self.led = NullLED(pin, reason)
|
||||||
"active-high" if error_active_high
|
self.status = LEDStatus(False, pin, "null", self._mode, False,
|
||||||
else "active-low"))
|
reason)
|
||||||
|
self._log("LED unavailable: {}".format(reason))
|
||||||
self._thread = threading.Thread(target=self._worker, daemon=True,
|
|
||||||
name="led-worker")
|
|
||||||
# The worker only needs to wake on a timer when there is an error pulse
|
|
||||||
# to expire. Without one it blocks on the queue, as before.
|
|
||||||
self._poll = (0.05 if (self.error_status.enabled
|
|
||||||
and self._error_pulse > 0) else None)
|
|
||||||
self._thread.start()
|
|
||||||
|
|
||||||
if self.status.enabled and self._mode == ERROR:
|
if self.status.enabled and self._mode == ERROR:
|
||||||
# A health indicator shows 'alive' immediately. In presence mode the
|
# A health indicator shows 'alive' immediately. In presence mode the
|
||||||
@@ -294,108 +216,23 @@ class StatusLED:
|
|||||||
self._set_state(True)
|
self._set_state(True)
|
||||||
self.status.state = True
|
self.status.state = True
|
||||||
|
|
||||||
|
self._thread = threading.Thread(target=self._worker, daemon=True,
|
||||||
|
name="led-worker")
|
||||||
|
self._thread.start()
|
||||||
|
|
||||||
# -- event hooks ------------------------------------------------------
|
# -- event hooks ------------------------------------------------------
|
||||||
|
|
||||||
def insert(self) -> None:
|
def insert(self) -> None:
|
||||||
"""A valid card was read: off in 'error' mode, on in 'presence' mode."""
|
"""A valid card was read: off in 'error' mode, on in 'presence' mode."""
|
||||||
if self._manual_status:
|
|
||||||
return
|
|
||||||
self._send(OFF if self._mode == ERROR else ON)
|
self._send(OFF if self._mode == ERROR else ON)
|
||||||
|
|
||||||
def remove(self) -> None:
|
def remove(self) -> None:
|
||||||
"""The card left the reader: dark in 'presence' mode; 'error' mode stays on."""
|
"""The card left the reader: dark in 'presence' mode; 'error' mode stays on."""
|
||||||
if self._manual_status:
|
|
||||||
return
|
|
||||||
self._send(ON if self._mode == ERROR else OFF)
|
self._send(ON if self._mode == ERROR else OFF)
|
||||||
|
|
||||||
def error(self) -> None:
|
def error(self) -> None:
|
||||||
"""A bad read: blink the status LED, or pulse the error LED if wired.
|
"""A bad read: blink, if blinking is enabled."""
|
||||||
|
self._send(BLINK if self._error_blink else ON)
|
||||||
With a dedicated error LED the indication goes there and the status
|
|
||||||
light is left alone, so the two LEDs stay independent: one says "a card
|
|
||||||
is here", the other "the read failed". Without one, the status LED
|
|
||||||
blinks as before - but only in ``error`` mode, since a blink and "card
|
|
||||||
present" cannot both be shown on one light.
|
|
||||||
"""
|
|
||||||
if self._manual_status or self._manual_error:
|
|
||||||
return
|
|
||||||
if self.error_status.enabled and self._error_pulse > 0:
|
|
||||||
self._error_until = time.monotonic() + self._error_pulse
|
|
||||||
return
|
|
||||||
if self._error_blink:
|
|
||||||
self._send(BLINK)
|
|
||||||
|
|
||||||
def _pulse_done(self, now: float) -> bool:
|
|
||||||
return self._error_until is not None and now >= self._error_until
|
|
||||||
|
|
||||||
def _error_indicate(self, on: bool) -> None:
|
|
||||||
"""Set the error LED, remembering the state for :meth:`close`."""
|
|
||||||
if on:
|
|
||||||
self.error_led.on()
|
|
||||||
else:
|
|
||||||
self.error_led.off()
|
|
||||||
self._error_state = on
|
|
||||||
self.error_status.state = on
|
|
||||||
|
|
||||||
# -- manual control (diagnostics) -------------------------------------
|
|
||||||
|
|
||||||
def set_led(self, which: str, on: bool) -> bool:
|
|
||||||
"""Force one LED on or off by hand. Returns False for an unknown name.
|
|
||||||
|
|
||||||
This exists to answer "can the software drive the pin at all?", which
|
|
||||||
separates a wiring fault from a configuration one. It latches: the
|
|
||||||
forced state holds until :meth:`release` or the next card event, so a
|
|
||||||
light turned on from the status page does not go dark when the next
|
|
||||||
poll runs.
|
|
||||||
"""
|
|
||||||
if which == "status":
|
|
||||||
if not self.status.enabled:
|
|
||||||
return False
|
|
||||||
self._manual_status = True
|
|
||||||
self._send(ON if on else OFF)
|
|
||||||
# Record the requested state now: the worker applies it
|
|
||||||
# asynchronously, and a snapshot taken before it runs would
|
|
||||||
# otherwise contradict the response we just sent.
|
|
||||||
self._set_state(bool(on))
|
|
||||||
return True
|
|
||||||
if which == "error":
|
|
||||||
if not self.error_status.enabled:
|
|
||||||
return False
|
|
||||||
self._manual_error = True
|
|
||||||
# Cancel any pulse in flight so it cannot fight the manual state.
|
|
||||||
self._error_until = None
|
|
||||||
self._error_indicate(bool(on))
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
def release(self) -> None:
|
|
||||||
"""Drop any manual override and let card events drive the LEDs again."""
|
|
||||||
self._manual_status = False
|
|
||||||
self._manual_error = False
|
|
||||||
self._error_until = None
|
|
||||||
if self.status.enabled:
|
|
||||||
# Return to the resting state for the current mode, recorded now so
|
|
||||||
# a snapshot taken before the worker runs agrees with it.
|
|
||||||
resting = self._mode == ERROR
|
|
||||||
self._send(ON if resting else OFF)
|
|
||||||
self._set_state(resting)
|
|
||||||
if self.error_status.enabled:
|
|
||||||
self._error_indicate(False)
|
|
||||||
|
|
||||||
def snapshot(self) -> dict:
|
|
||||||
"""Both LEDs' state, for ``/api/led`` and the status page."""
|
|
||||||
return {
|
|
||||||
"manual": self._manual_status or self._manual_error,
|
|
||||||
"status": self._led_info(self.status, self._manual_status),
|
|
||||||
"error": self._led_info(self.error_status, self._manual_error),
|
|
||||||
}
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _led_info(status: "LEDStatus", manual: bool) -> dict:
|
|
||||||
info = status.to_dict()
|
|
||||||
info["manual"] = manual
|
|
||||||
info["on"] = bool(status.state)
|
|
||||||
return info
|
|
||||||
|
|
||||||
# -- control ----------------------------------------------------------
|
# -- control ----------------------------------------------------------
|
||||||
|
|
||||||
@@ -409,12 +246,7 @@ class StatusLED:
|
|||||||
|
|
||||||
def _worker(self) -> None:
|
def _worker(self) -> None:
|
||||||
while True:
|
while True:
|
||||||
try:
|
command = self._queue.get()
|
||||||
command = self._queue.get(timeout=self._poll)
|
|
||||||
except queue.Empty:
|
|
||||||
# No command pending: run the time-based error pulse, if any.
|
|
||||||
self._tick_error_pulse()
|
|
||||||
continue
|
|
||||||
if command is None:
|
if command is None:
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
@@ -425,20 +257,6 @@ class StatusLED:
|
|||||||
finally:
|
finally:
|
||||||
self._queue.task_done()
|
self._queue.task_done()
|
||||||
|
|
||||||
def _tick_error_pulse(self) -> None:
|
|
||||||
"""Light the error LED for its window, then darken it again."""
|
|
||||||
if self._error_until is None or self._manual_error:
|
|
||||||
return
|
|
||||||
now = time.monotonic()
|
|
||||||
if now < self._error_until:
|
|
||||||
if not self._error_state:
|
|
||||||
self._error_indicate(True)
|
|
||||||
elif self._error_state:
|
|
||||||
self._error_indicate(False)
|
|
||||||
else:
|
|
||||||
# Window passed and already dark: stop tracking it.
|
|
||||||
self._error_until = None
|
|
||||||
|
|
||||||
def _apply(self, command: str) -> None:
|
def _apply(self, command: str) -> None:
|
||||||
# Blinking is used only for a bad read, and only by 'error' mode.
|
# Blinking is used only for a bad read, and only by 'error' mode.
|
||||||
if command == BLINK and self._mode == ERROR:
|
if command == BLINK and self._mode == ERROR:
|
||||||
@@ -471,55 +289,26 @@ class StatusLED:
|
|||||||
|
|
||||||
def close(self) -> None:
|
def close(self) -> None:
|
||||||
self._stop_blink()
|
self._stop_blink()
|
||||||
self._manual_status = False
|
|
||||||
self._manual_error = False
|
|
||||||
if self._thread is not None:
|
if self._thread is not None:
|
||||||
try:
|
self._queue.put(None)
|
||||||
self._queue.put_nowait(None)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
try:
|
try:
|
||||||
self.led.off()
|
self.led.off()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
self._set_state(False)
|
self._set_state(False)
|
||||||
try:
|
|
||||||
self.error_led.off()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
self.error_status.state = False
|
|
||||||
try:
|
try:
|
||||||
self.led.close()
|
self.led.close()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
try:
|
|
||||||
self.error_led.close()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def create(
|
def create(
|
||||||
enabled: bool = True,
|
enabled: bool = True,
|
||||||
pin: int = DEFAULT_LED_PIN,
|
pin: int = DEFAULT_LED_PIN,
|
||||||
mode: str = DEFAULT_LED_MODE,
|
mode: str = ERROR,
|
||||||
active_high: bool = True,
|
|
||||||
error_blink: bool = True,
|
error_blink: bool = True,
|
||||||
logger: Optional[Callable[[str], None]] = None,
|
logger: Optional[Callable[[str], None]] = None,
|
||||||
error_pin: Optional[int] = None,
|
|
||||||
error_active_high: bool = True,
|
|
||||||
error_pulse: float = ERROR_BLINK_SECONDS,
|
|
||||||
) -> StatusLED:
|
) -> StatusLED:
|
||||||
"""Build the LED controller, degrading to a no-op when GPIO is unusable.
|
"""Build the LED controller, degrading to a no-op when GPIO is unusable."""
|
||||||
|
|
||||||
*active_high* is False for an LED wired active-low - cathode to the GPIO
|
|
||||||
pin, anode to 3.3 V, or a driver that inverts. Getting this wrong does not
|
|
||||||
fail: the light simply does the opposite of what is wanted.
|
|
||||||
|
|
||||||
*error_pin* enables a second, dedicated LED for bad reads. Pass the same
|
|
||||||
value as *pin* to fall back to the single-LED behaviour.
|
|
||||||
"""
|
|
||||||
return StatusLED(pin=pin, enabled=enabled, mode=mode,
|
return StatusLED(pin=pin, enabled=enabled, mode=mode,
|
||||||
active_high=active_high,
|
error_blink=error_blink, logger=logger)
|
||||||
error_blink=error_blink, logger=logger,
|
|
||||||
error_pin=error_pin, error_active_high=error_active_high,
|
|
||||||
error_pulse=error_pulse)
|
|
||||||
|
|||||||
@@ -10,14 +10,7 @@ import importlib
|
|||||||
import os
|
import os
|
||||||
from typing import Callable, Dict, List, Optional, Sequence
|
from typing import Callable, Dict, List, Optional, Sequence
|
||||||
|
|
||||||
from .card import (
|
from .card import CANDIDATE_PORTS, DEFAULT_HEARTBEAT_INTERVAL, CardEvent
|
||||||
CANDIDATE_PORTS,
|
|
||||||
DEFAULT_HEARTBEAT_INTERVAL,
|
|
||||||
EVENT_INVALID,
|
|
||||||
EVENT_INSERTED,
|
|
||||||
EVENT_REMOVED,
|
|
||||||
CardEvent,
|
|
||||||
)
|
|
||||||
|
|
||||||
try: # pragma: no cover - depends on the environment
|
try: # pragma: no cover - depends on the environment
|
||||||
import rdm6300
|
import rdm6300
|
||||||
@@ -65,15 +58,15 @@ def _make_reader_class(callback: EventCallback):
|
|||||||
"""Fires *callback* for each insert, removal and checksum failure."""
|
"""Fires *callback* for each insert, removal and checksum failure."""
|
||||||
|
|
||||||
def card_inserted(self, card) -> None:
|
def card_inserted(self, card) -> None:
|
||||||
callback(CardEvent.from_card(EVENT_INSERTED, card, self.port))
|
callback(CardEvent.from_card("card_inserted", card, self.port))
|
||||||
|
|
||||||
def card_removed(self, card) -> None:
|
def card_removed(self, card) -> None:
|
||||||
if card is None:
|
if card is None:
|
||||||
return
|
return
|
||||||
callback(CardEvent.from_card(EVENT_REMOVED, card, self.port))
|
callback(CardEvent.from_card("card_removed", card, self.port))
|
||||||
|
|
||||||
def invalid_card(self, card) -> None:
|
def invalid_card(self, card) -> None:
|
||||||
callback(CardEvent.from_card(EVENT_INVALID, card, self.port))
|
callback(CardEvent.from_card("invalid_card", card, self.port))
|
||||||
|
|
||||||
return CardReader
|
return CardReader
|
||||||
|
|
||||||
|
|||||||
@@ -1,438 +0,0 @@
|
|||||||
"""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")
|
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
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)
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
"""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)
|
||||||
@@ -0,0 +1,501 @@
|
|||||||
|
"""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
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
"""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
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
"""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
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
"""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)
|
||||||
|
)
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
"""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())
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
"""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())
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
"""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()
|
||||||
@@ -0,0 +1,306 @@
|
|||||||
|
"""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()
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
"""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
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
"""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)
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
"""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)
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
"""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