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