Initializare dupa curatare folder

This commit is contained in:
2026-10-05 11:46:46 +03:00
commit 48e800adc1
39 changed files with 6303 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
__pycache__/
*.py[cod]
*.egg-info/
build/
dist/
.venv/
venv/
.pytest_cache/
.coverage
htmlcov/
.idea/
.vscode/
+375
View File
@@ -0,0 +1,375 @@
# LAUNCH.md — starting the app, and why it fails on this host
## TL;DR
**This host (`development`, x86_64) has no card reader attached. Failure is
expected and correct.** To see the app work without hardware, use `--demo`:
```bash
.venv/bin/python -m rdm_card_reader --demo
```
To see the reader path end-to-end without hardware, drive a pseudo-terminal
pretending to be an RDM6300 — see **Testing the reader path without a reader**
below.
---
## Quick start
Build the virtualenv once, from the wheels bundled in the source tree — no
network needed:
```bash
python3 -m venv .venv
.venv/bin/pip install --no-index \
--find-links src/rdm_card_reader/wheels rdm6300 pyserial
echo "$PWD/src" > "$(.venv/bin/python -c \
'import sysconfig; print(sysconfig.get_paths()["purelib"])')/rdm_card_reader_src.pth"
```
For the status LED, add `gpiozero colorzero` to the same `pip install` line.
Then, from the project root:
```bash
.venv/bin/python -m rdm_card_reader --demo # simulate a card, no hardware
.venv/bin/python -m rdm_card_reader --list-ports # what serial ports exist
.venv/bin/python -m rdm_card_reader # real reader (fails here)
.venv/bin/python -m rdm_card_reader --serve # HTTP bridge for a web page
```
That is the whole install: the runtime libraries come from the bundled wheels
and `src/` is on the path via the `.pth` file. `pip install .` also works, but
only with network — it builds the project. See **Troubleshooting** below.
---
## Troubleshooting install
### "Could not find a version that satisfies the requirement setuptools>=68"
Seen when running `deploy/install.sh` on a Pi with no internet, at the
"Building the virtual environment" step:
```
ERROR: Could not find a version that satisfies the requirement setuptools>=68
(from versions: none)
ERROR: No matching distribution found for setuptools>=68
```
**Cause:** `pip install .` builds the project. pip creates an isolated build
environment and downloads the *build* backend (`setuptools`) into it — even
though the *runtime* libraries are already sitting in the wheel directory. With
no network that download cannot happen.
**It is not** a problem with the bundled wheels, and not a Python version
mismatch. Two separate confusions produce a similar-looking error:
| Situation | Error |
| --- | --- |
| Build isolation, no network | `Could not find a version ... setuptools>=68` |
| `--no-build-isolation`, and setuptools absent | `Cannot import 'setuptools.build_meta'` |
Note that Python 3.12+ venvs no longer include `setuptools`, so the second case
is increasingly common.
**Fix:** the current `install.sh` avoids the build entirely — it installs the
bundled runtime wheels and puts `src/` on the path with a `.pth` file. If you
are on an older copy, update it (`git pull`) or run these by hand on the Pi:
```bash
python3 -m venv .venv
.venv/bin/pip install --no-index \
--find-links src/rdm_card_reader/wheels rdm6300 pyserial
SITE=$(.venv/bin/python -c 'import sysconfig; print(sysconfig.get_paths()["purelib"])')
echo "$PWD/src" > "$SITE/rdm_card_reader_src.pth"
.venv/bin/python -m rdm_card_reader --demo # verify, no hardware needed
```
No build step, no network, and the same result.
### "Could not create the virtualenv"
On Debian and Raspberry Pi OS, `python3 -m venv` needs the `python3-venv`
package:
```bash
sudo apt-get install python3-venv
```
### apt-get failing on the first step
`apt-get update` and `apt-get install` need the network. The installer now
treats both as best-effort and continues, because a device typically already
has `python3`. If they fail and something really is missing, install it while
online and re-run.
---
## What happens on this host, and why
### `--list-ports` works fine
```console
$ .venv/bin/python -m rdm_card_reader --list-ports --verbose-ports
Candidate serial ports (tried in this order):
OK /dev/ttyS0
--- /dev/ttyAMA0
--- /dev/ttyUSB0
--- /dev/ttyACM0
Identified serial ports (best candidate first):
[score 5] /dev/ttyS0 :: n/a
by-id : (none)
by-path : (none)
location : (unknown)
usb id : (not USB)
driver : (unknown)
```
Two things to read from this:
* `OK /dev/ttyS0` — the device node **exists**, but existence is not proof of a
working reader. This container has a UART node with no hardware behind it.
* `score 5` and `(not USB)` — nothing here looks like a USB-serial adapter.
A real reader scores 60–100 and shows a chip name (`CH340`, `FT232R`, …).
### Starting the reader fails
```console
$ .venv/bin/python -m rdm_card_reader
-> Opening /dev/ttyS0 ...
-> Opening /dev/ttyAMA0 ...
-> Opening /dev/ttyUSB0 ...
-> Opening /dev/ttyACM0 ...
x /dev/ttyS0: Could not configure port: (5, 'Input/output error')
x /dev/ttyAMA0: device not found
x /dev/ttyUSB0: device not found
x /dev/ttyACM0: device not found
x Could not open any serial port. Check:
1. the reader is connected (rdm-card-reader --list-ports)
2. UART is enabled: sudo raspi-config -> Interface Options -> Serial
3. the user is in dialout: sudo usermod -aG dialout $USER
```
This is the **correct** outcome. Note the distinct error for `/dev/ttyS0`:
`Input/output error` (errno 5) means the node exists but the underlying hardware
does not answer — different from `device not found` for the ports that do not
exist at all. The app reports which is which rather than a generic failure.
### `--autodetect` also fails, as designed
With no USB adapter present there is nothing to autodetect, so it falls through
to the default port order and fails the same way. Crucially, it **refuses to
guess** when several adapters *are* present (`exit 3`) — picking the wrong
reader means authenticating the wrong person, so ambiguity is an error rather
than a coin flip.
### `--demo` works, because it touches no hardware
```console
$ .venv/bin/python -m rdm_card_reader --demo --demo-count 1
Demo mode - no hardware is used, values are simulated.
----------------------------------------------------------
CARD INSERTED - 2026-10-01 15:53:10
id : 439041101 (0x1A2B3C4D)
type : 0x0F
checksum : 79
valid : True
port : demo
----------------------------------------------------------
CARD REMOVED - 2026-10-01 15:53:11 id: 439041101 (0x1A2B3C4D)
Demo finished.
```
`--demo` needs neither the hardware nor the `rdm6300` library, so it is the
fastest way to confirm the build is sane.
---
## How the app starts
```mermaid
flowchart TD
A["rdm-card-reader<br/>(console script → cli:main)"] --> B{which mode?}
B -->|--list-ports| P["print ports, exit 0"]
B -->|--demo| D["simulated cards, no hardware"]
B -->|--serve or plain| C["1. ensure libraries"]
C --> C1{"rdm6300 importable?"}
C1 -->|yes| E
C1 -->|no| C2["install from bundled wheels<br/>(--no-index, SHA256 verified)"]
C2 --> C3["reload_library()<br/>re-import rdm6300"]
C3 -->|still missing| X2["exit 2"]
C3 -->|ok| E["2. resolve the serial port"]
E --> E1{"--port given?"}
E1 -->|yes| E2["match it; refuse if ambiguous<br/>→ exit 3"]
E1 -->|no| E3["--autodetect: only a single USB adapter<br/>else fall back to default order"]
E2 --> F
E3 --> F["3. open the port"]
F -->|all ports fail| X1["exit 1"]
F --> G["4. reader.start() — blocking read loop"]
G --> H{"mode"}
H -->|plain| I["print each card event"]
H -->|--serve| J["latch the tap in the EventHub"]
J --> K["HTTP server on 127.0.0.1:8765"]
K --> L["web page polls /api/card<br/>and claims it once"]
```
The numbered stages, and where each lives:
| # | Stage | Code | On failure |
| --- | --- | --- | --- |
| 1 | Ensure `rdm6300` + `pyserial` | `bootstrap.ensure_dependencies()` | `exit 2` |
| 2 | Resolve which serial port | `ports.resolve()` | `exit 1` (none), `exit 3` (ambiguous) |
| 3 | Open the port | `reader.open_reader()` | `exit 1` |
| 4 | Read frames forever | `rdm6300.BaseReader.start()` | — |
| 5 | Publish events | `card.format_event()` or `bridge.EventHub` | — |
Stages 1 and 2 are deliberately **fail-fast and loud**. A reader that starts up
having silently picked the wrong port, or without its library, is worse than one
that refuses to start — the first failure mode is invisible until someone
authenticates as the wrong person.
### Stage 1 in detail: dependency install
The `rdm6300` and `pyserial` wheels are **shipped inside the package**
(`rdm_card_reader/wheels/`), so this works on a device with no internet:
```console
Reader library not found - checking dependencies...
Missing reader dependencies: rdm6300
Using the wheels bundled with this package:
.../site-packages/rdm_card_reader/wheels
Installing from .../rdm_card_reader/wheels
Dependencies ready: rdm6300
```
Nothing is installed when the libraries are already importable, so a normal
start does no network work and no `pip` call at all. The install uses
`--no-index` and verifies `SHA256SUMS` first; a wheel that fails its checksum is
**refused rather than installed**.
`reload_library()` matters here: the module-level `import rdm6300` already ran
and failed at process start, so without re-importing, a successful install would
still look like a missing library.
### Stage 4 in detail: reading is not polling
The reader loop **blocks** on serial reads and the library calls back when it
assembles a valid frame. Nothing here polls. The *web page* polls (or subscribes
to SSE) — a different thing, and worth keeping straight.
A card is reported on insert and again on removal; the library de-duplicates
repeated reads while the card sits on the antenna, so you do not get a stream of
repeats. Removal is detected by a `--heartbeat` (default `0.5s`) of silence.
---
## Exit codes
| Code | Meaning | Seen on this host? |
| --- | --- | --- |
| `0` | Ran normally, or stopped with Ctrl+C | with `--demo`, `--list-ports` |
| `1` | No serial port could be opened | **yes** — this is the expected result here |
| `2` | `rdm6300` missing and could not be installed | only if offline *and* wheels tampered |
| `3` | Port selector matched several adapters (refused to guess) | needs 2+ adapters |
> When testing with a pipe, remember `head` closes the pipe early and the shell
> then reports `128+SIGPIPE`. Redirect to a file instead if you want the real code:
> `… > out.txt 2>&1; echo $?`
---
## When a reader *is* attached
On the Raspberry Pi the same command should end with a ready line rather than an
error:
```console
-> Using /dev/rdm-reader (matched /dev/serial/by-id/usb-1a86_USB_Serial-if00-port0)
OK reader on /dev/rdm-reader ; bridge on http://127.0.0.1:8765 (mode: login)
```
Then compare this host against the Pi:
| | this host (`development`) | Raspberry Pi with reader |
| --- | --- | --- |
| Architecture | x86_64 | armv7l / aarch64 |
| Reader | none | RDM6300 on USB-serial or UART |
| `/dev/ttyUSB*` | absent | present |
| `--list-ports` score | 5, `(not USB)` | 60–100, e.g. `CH340 1a86:7523` |
| `/dev/rdm-reader` | absent | udev symlink |
| Serial access group | n/a | user must be in `dialout` |
| Expected `rdm-card-reader` | `exit 1` | stays running |
For a real device, `deploy/install.sh` sets all of this up:
```bash
sudo ./deploy/install.sh --origin https://your.web.app
journalctl -u rdm-card-reader -f
curl http://127.0.0.1:8765/api/card # tap a card first
```
The installer needs no internet: the runtime libraries ship inside the
source tree (`src/rdm_card_reader/wheels/`).
### Copying the project to the Pi
`install.sh` resolves its source directory from its own location, so copy the
whole tree (including `src/` — that is where the wheels live) and run it from
there:
```bash
# from this machine
scp -r rdm-card-reader pi@192.168.0.123:~/Desktop/
# on the Pi
cd ~/Desktop/rdm-card-reader
sudo ./deploy/install.sh --origin https://your.web.app
```
If you transferred only a subset of the tree, `--dir` can point the install
somewhere else, but `src/` must be present or the wheels will be missing.
---
## Testing the reader path without a reader
Because the app talks plain serial, a pseudo-terminal is enough to exercise the
whole chain: write real RDM6300 frames into a PTY and watch the printed events.
To hand-feed a single frame yourself:
```python
import os, pty, struct, subprocess, sys, time
def frame(value, card_type=0x0F):
id_bytes = struct.pack(">I", value)
checksum = card_type
for b in id_bytes:
checksum ^= b
return (bytes([card_type]) + id_bytes + bytes([checksum])).hex().upper().encode()
master, slave = pty.openpty()
port = os.ttyname(slave)
proc = subprocess.Popen([sys.executable, "-u", "-m", "rdm_card_reader", "--port", port])
time.sleep(2) # let the child open the port FIRST
os.write(master, b"\x02" + frame(0x00C4A0B5) + b"\x03")
time.sleep(1)
proc.terminate()
```
Two traps worth knowing, both of which cost me a debugging cycle:
* **Send the frame after the child has opened the port.** A PTY buffers, so
frames written too early are discarded and it looks like a reader bug.
* **Read the child's stdout from a thread.** With text-mode buffered pipes,
`select()` will not report the data even though it is there.
Frame layout, since it is easy to get wrong: the 12 hex nibbles are
**type, id, checksum**, and the checksum is the XOR of the type byte **and** all
four id bytes — not the id bytes alone.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+532
View File
@@ -0,0 +1,532 @@
# rdm-card-reader
Read 125 kHz RFID cards (RDM6300 reader, EM4100 cards) on a serial port and
print what happens: **card inserted** with the card data, then **card removed**.
Built for a Raspberry Pi workstation, but it runs anywhere with a serial port.
> Starting it on a machine with no reader attached? See **[LAUNCH.md](LAUNCH.md)**
> — it explains the startup sequence, why it exits `1` with no hardware, and how
> to exercise the reader path anyway.
---
## What it does
```
$ rdm-card-reader
-> Opening /dev/ttyAMA0 ...
OK reader ready on /dev/ttyAMA0 - waiting for cards (Ctrl+C to stop)
----------------------------------------------------------
CARD INSERTED - 2026-10-01 14:56:54
id : 12886197 (0x00C4A0B5)
type : 0x0F
checksum : 222
valid : True
port : /dev/ttyAMA0
----------------------------------------------------------
CARD REMOVED - 2026-10-01 14:56:54 id: 12886197 (0x00C4A0B5)
```
`id` is the number printed on the card. A card is reported once on insert
(the reader library de-duplicates repeated reads while the card sits on the
antenna) and once on removal.
### Startup sequence
1. **Check dependencies.** If `rdm6300` / `pyserial` cannot be imported, install
them automatically and carry on. A fresh device needs no manual `pip` work.
2. **Find the reader.** Resolve the serial port and open it, identifying the
adapter by serial number or `by-id` rather than trusting `ttyUSB0`.
3. **Start reading.** Read frames in a loop; each valid one becomes a card
event.
4. **Publish it.** In `--serve` mode the event is latched so a web page can
claim it whenever it gets around to asking.
Steps 1 and 2 both fail *loudly with a reason* rather than starting up
half-working, because a reader that silently picks the wrong port or runs
without its library is worse than one that refuses to start.
---
## Install
On a machine **with internet**:
```bash
pip install .
```
`rdm6300` and `pyserial` are pulled in automatically. On Raspberry Pi OS with
an externally-managed Python, add `--break-system-packages` or use a venv:
```bash
python3 -m venv .venv && . .venv/bin/activate && pip install .
```
### On a device with no internet
`pip install .` will **not** work offline. It builds the project, which makes
pip download `setuptools` into an isolated build environment, and that fails
with:
```
ERROR: Could not find a version that satisfies the requirement setuptools>=68
(from versions: none)
```
The runtime dependencies are bundled in `src/rdm_card_reader/wheels/`, so
install those directly and put `src` on the path — no build step, no network:
```bash
python3 -m venv .venv
.venv/bin/pip install --no-index \
--find-links src/rdm_card_reader/wheels rdm6300 pyserial
echo "$PWD/src" > "$( .venv/bin/python -c \
'import sysconfig; print(sysconfig.get_paths()["purelib"])' )/rdm_card_reader_src.pth"
.venv/bin/python -m rdm_card_reader --demo # verify, no hardware needed
```
`deploy/install.sh` does exactly this, plus the service setup. Running the
app directly also works: it installs anything missing from the same bundled
wheels on first start.
---
## Usage
```bash
rdm-card-reader # auto-detect the serial port
rdm-card-reader --port /dev/ttyUSB0 # use a specific port
rdm-card-reader --json # one JSON object per event
rdm-card-reader --heartbeat 1.5 # slower card-removed detection
rdm-card-reader --list-ports # show which ports exist, then exit
rdm-card-reader --serve # serve card data to a web app (see below)
rdm-card-reader --demo # simulate reads, no hardware needed
```
Also works as a module: `python -m rdm_card_reader`.
| Option | Default | Meaning |
| --- | --- | --- |
| `--port` | auto-detect | Serial port: device path, by-id symlink, serial number or `vid:pid` |
| `--autodetect` | off | Pick the only USB adapter; refuse if there is more than one |
| `--heartbeat` | `0.5` | Seconds without a read before the card counts as removed |
| `--json` | off | Machine-readable output instead of the text block |
| `--list-ports` | – | List candidate and identified ports, then exit |
| `--verbose-ports` | off | With `--list-ports`, show by-id / by-path detail |
| `--demo` | – | Simulated reads; needs no hardware and no `rdm6300` |
| `--demo-count` | `3` | Number of simulated reads in `--demo` |
| `--no-install-deps` | off | Never install missing libraries automatically |
| `--wheel-dir` | bundled | Install from local wheels in this DIR instead of the bundled ones |
| `--version` | – | Print the version |
With `--serve`: `--host` (`127.0.0.1`), `--bridge-port` (`8765`), `--mode`
(`login`), `--tap-ttl` (`10`), `--allow-origin` (repeatable), `--token`,
`--allow-remote`, `--verbose`.
When `--port` is omitted the ports are tried in this order, the same order the
workstation app uses:
```
/dev/ttyS0 -> /dev/ttyAMA0 -> /dev/ttyUSB0 -> /dev/ttyACM0
```
### Output modes
Text mode is for humans and logs. `--json` prints one object per event, which
is what you want when piping into another program:
```json
{"event": "card_inserted", "timestamp": "2026-10-01 14:56:54", "id": 12886197, "hex": "0x00C4A0B5", "type": 15, "checksum": 222, "is_valid": true, "port": "/dev/ttyAMA0"}
{"event": "card_removed", "timestamp": "2026-10-01 14:56:54", "id": 12886197, "hex": "0x00C4A0B5", "type": 15, "checksum": 222, "is_valid": true, "port": "/dev/ttyAMA0"}
```
### Automatic dependency install
The wheels for `rdm6300` and `pyserial` **ship inside this package**
(`rdm_card_reader/wheels/`), so a device with no internet can still set itself
up. On first start the reader installs whatever is missing from those wheels:
```
Reader library not found - checking dependencies...
Missing reader dependencies: rdm6300
Using the wheels bundled with this package:
/opt/rdm-card-reader/.venv/lib/python3.13/site-packages/rdm_card_reader/wheels
Installing from .../rdm_card_reader/wheels
Dependencies ready: rdm6300
```
No network access is needed. They ship with the package rather than being
fetched at build time, because the build machine and the target device are not
necessarily the same.
Both wheels are **pure Python** (`py3-none-any` / `py2.py3-none-any`), which is
what makes this possible: the same files install on armv7l, aarch64 and x86_64.
This differs from the workstation app's `Files/repository/` cache, which held
architecture-specific binaries (aarch64 manylinux wheels) and so could not be
committed — that cache only ever existed on the Pi devices themselves.
* Nothing is installed when the libraries are already present, so a normal start
does no network work at all.
* The bundled install runs with `--no-index`, so it works with no route to PyPI
at all — no proxy or DNS needed.
* Wheels are verified against a bundled `SHA256SUMS` before installation. **A
wheel that fails its checksum is refused, not installed** — then it falls back
to PyPI, and if that is unreachable it reports `exit 2` rather than proceeding
with something unverified.
* Outside a virtualenv pip is given `--break-system-packages`, which Debian and
Raspberry Pi OS require before they will touch the system Python.
* A failed install does not crash: it reports `exit 2` and tells you the exact
command to run by hand.
To install from your own wheel directory instead (for example a newer version
than the bundled one), pass `--wheel-dir`. An explicit directory that is
incomplete or fails verification is reported and then PyPI is used, rather than
failing outright. To manage dependencies yourself, pass `--no-install-deps`.
### Exit codes
| Code | Meaning |
| --- | --- |
| `0` | Ran normally, or stopped with Ctrl+C |
| `1` | No serial port could be opened |
| `2` | The `rdm6300` library is missing and could not be installed |
| `3` | The port selector matched more than one adapter (refused to guess) |
---
## Serving card data to a web app (`--serve`)
This is the mode for **authenticating a web page**: the reader runs as a service
on the device, and the page asks it for the card that was just tapped.
```bash
rdm-card-reader --serve --port /dev/rdm-reader --allow-origin https://your.web.app
```
The bridge reports **card ids only**. Deciding which user a card belongs to is
your server's job, not this program's.
### The flow
```mermaid
sequenceDiagram
participant C as Card
participant B as rdm-card-reader (Pi)
participant K as Web page
participant W as Your server
C->>B: tap
B->>B: latch the tap (tap-ttl seconds)
K->>B: GET /api/card
B-->>K: {claimable: true, card: {id: ...}}
K->>B: POST /api/card/claim
B-->>K: {card: {id: ...}} (once)
K->>W: POST /login {card_id}
W->>W: card_id -> user
W-->>K: session
```
The point of the *latch* is that the tap stays available for `--tap-ttl`
seconds (default 10), **including after the card is lifted**. A quick tap
therefore cannot be lost because the page happened to poll a moment later.
The point of *claim* is single use. Once a tap is spent, a page reload or a
second tab gets `409 Conflict` instead of a second login, so one physical tap
can never authenticate two sessions.
### Endpoints
| Method | Path | Purpose |
| --- | --- | --- |
| `GET` | `/api/card` | The latched tap: `present`, `claimable`, `consumed`, `age_seconds`, `card` |
| `POST` | `/api/card/claim` | Spend the tap on one login. `200`, or `409` if used/none, `410` if expired |
| `GET` | `/api/events` | Server-Sent Events stream, pushes each event as it happens |
| `GET` | `/api/last` | Most recent card event, or `204` |
| `GET` | `/api/taps` | Recent tap history (`?limit=N`) |
| `GET` | `/api/health` | Reader state, port, uptime, tap counts — for monitoring |
| `POST` | `/api/mode` | `{"mode": "login"}` or `{"mode": "monitor"}` |
`login` mode (default) publishes taps only — a tap is an authentication
attempt. `monitor` mode also publishes removals, which is what you want for
watching a reader rather than logging people in.
### Browser example
```js
const state = await (await fetch("http://127.0.0.1:8765/api/card")).json();
if (state.claimable) {
const res = await fetch("http://127.0.0.1:8765/api/card/claim", { method: "POST" });
if (res.ok) {
const { card } = await res.json();
await fetch("/login/card", { // your server decides who card.id is
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ card_id: card.id, hex: card.hex }),
});
}
}
```
Or push instead of poll, so a tap logs in immediately:
```js
const events = new EventSource("http://127.0.0.1:8765/api/events");
events.onmessage = (e) => { const tap = JSON.parse(e.data); /* tap.id */ };
```
### Security, and its limits
Read :ref:`the warning below <card-ids-are-not-secrets>`: an EM4100 id is a
*username*, not a password. These controls bound who can *read* the bridge;
they do not make the card itself convincing as a sole credential.
| Control | Behaviour |
| --- | --- |
| Bind address | `127.0.0.1` by default. Anything else needs `--allow-remote`, so an open card-event endpoint cannot appear on the LAN by accident. |
| Origins | Cross-origin browser requests are **refused** unless `--allow-origin` lists that exact origin. Stops another site from reading your taps. |
| Token | `--token` (or `RDM_BRIDGE_TOKEN`) is required from non-loopback callers. |
| Single use | A tap can only ever produce one successful claim. |
| Expiry | A tap older than `--tap-ttl` is refused with `410`. |
Add a second factor — a PIN after the tap — wherever a cloned card would
actually cost you something.
<a name="card-ids-are-not-secrets"></a>
> **Card ids are not secrets.** EM4100 cards broadcast a fixed 40-bit id with no
> challenge-response, and clones are cheap. If holding an id is enough to log
> in, the security is "whoever knows the number". Treat card auth as
> identification, and require something else for anything sensitive.
---
## Running at boot (Raspberry Pi)
```bash
sudo ./deploy/install.sh --origin https://your.web.app
```
That installs a service account, a virtualenv, a systemd unit and a udev rule,
then enables the service. Afterwards:
```bash
systemctl status rdm-card-reader # state
journalctl -u rdm-card-reader -f # live log
curl http://127.0.0.1:8765/api/card # tap a card, then run this
```
### Identifying the right ttyUSB
`/dev/ttyUSB0` is **not** a stable identity — unplug something and the number
changes. Point this at the wrong adapter and you authenticate the wrong reader,
so the tooling is deliberately strict about it.
```bash
rdm-card-reader --list-ports --verbose-ports
```
```
Identified serial ports (best candidate first):
[score 100] /dev/ttyUSB0 :: CH340 :: 1a86:7523 :: S/N AB0XYZ12
by-id : /dev/serial/by-id/usb-1a86_USB_Serial-if00-port0
by-path : /dev/serial/by-path/pci-0000:01:00.0-usb-0:1.2:1.0-port0
```
Then pin it, most specific first:
```bash
--port /dev/rdm-reader # udev alias
--port /dev/serial/by-id/usb-1a86_USB_Serial-if00-port0 # by device
--port AB0XYZ12 # by serial number
--port 1a86:7523 # by chip
```
`--autodetect` picks the only USB adapter, and **refuses** when there is more
than one — silently choosing between two readers is exactly the failure this
avoids. Verify the wiring in `deploy/99-rdm-reader.rules`.
Exit codes: `0` ok, `1` no port, `2` library missing, `3` port ambiguous.
---
## Status LED
An LED driven by card events, on **GPIO 23** by default — the same pin the
workstation app used.
```bash
rdm-card-reader # lit while running, blinks on a bad read
rdm-card-reader --led-pin 17 # a different pin
rdm-card-reader --led-mode presence # lit only while a card is on the reader
rdm-card-reader --no-led # no LED at all
```
| Flag | Default | Meaning |
| --- | --- | --- |
| `--no-led` | off | Do not drive a LED |
| `--led-pin` | `23` | GPIO pin (matches the workstation app) |
| `--led-mode` | `error` | See the two readings below |
| `--led-no-error-blink` | off | Do not blink on a bad read |
Two modes, because the light can mean two different things:
| Mode | Behaviour | Use when |
| --- | --- | --- |
| `error` (default) | **Lit whenever the reader is running.** Dark while a card is being read, blinks on a bad read | The light should mean "the reader is alive and ready" |
| `presence` | **Lit only while a card is on the reader** | The light should mean "a card is here" |
Wiring: LED anode to the GPIO pin through a ~330 Ω resistor, cathode to GND.
### It cannot break the reader
Three deliberate choices, because a LED is a convenience and the reader is the
point:
* **Never blocks.** The workstation app toggled the LED directly on the reader
thread, which stalls card detection. Here every change is queued to a worker
thread, so a toggle returns immediately even with slow hardware.
* **Never fatal.** On a machine with no GPIO, `gpiozero` raises
`BadPinFactory`. The app falls back to a no-op LED and says why, rather than
refusing to run.
* **Never crashes on a dead pin.** A failed toggle is logged and ignored.
Startup reports which of the three states it is in:
```
LED on GPIO 23 (error mode, gpiozero) # working
LED unavailable: gpiozero not installed # library missing
LED unavailable: no usable GPIO on pin 23: ... # no pin factory / pin busy
```
That distinction matters: `BadPinFactory` subclasses `ImportError`, so a naive
check reports "not installed" when the library is present but has no usable pin
provider — which would send you to the wrong fix.
> **If the workstation app also runs on this device, use a different pin** — two
> processes driving GPIO 23 will fight, and the LED will flicker.
`gpiozero` and `colorzero` are installed automatically on first use, from the
bundled wheels, so this works offline. They add ~180 KB. `setuptools`, which
`gpiozero` pulls in as a *packaging* dependency, is deliberately not vendored:
it is never imported at runtime and would add 818 KB for nothing.
---
## Wiring
The RDM6300 talks plain serial at **9600 baud, 8N1**. Connect it to the Pi's
UART (pins 8/10 for TXD/RXD, 5 V and GND) or through a USB-to-serial adapter.
On a Raspberry Pi you usually need to free up the primary UART first:
```bash
sudo raspi-config # Interface Options -> Serial Port
# "login shell over serial?" -> No
# "serial port hardware?" -> Yes
sudo usermod -aG dialout $USER # then log out and back in
```
The GPIO UART appears as `/dev/serial0` (a symlink to `/dev/ttyAMA0` or
`/dev/ttyS0`).
---
## The protocol
Worth knowing if you ever decode the bytes yourself. The reader emits a frame
between `STX` (0x02) and `ETX` (0x03) as **12 ASCII hex nibbles**:
```
0x02 0F 00C4A0B5 DE 0x03
| | |
| | +-- checksum
| +----------- card id (4 bytes, big-endian)
+-------------- type byte
```
Two details that are easy to get wrong:
1. **Field order is type, id, checksum** — the id is *not* first.
2. **The checksum is the XOR of the type byte *and* all four id bytes.** XOR-ing
only the id bytes produces a mismatch on every card.
```
checksum = type ^ b0 ^ b1 ^ b2 ^ b3
= 0x0F ^ 0x00 ^ 0xC4 ^ 0xA0 ^ 0xB5
= 0xDE (= 222)
```
A frame whose checksum does not match is reported as `INVALID CARD` and is
never treated as a card insert.
There is also a subtle failure mode: a frame with the wrong number of nibbles
is not rejected outright. Two stray nibbles stay in the buffer and corrupt the
*next* read instead of the current one, so a single glitch can look like a
problem with the following card.
---
## Troubleshooting
| Symptom | Fix |
| --- | --- |
| `device not found` on every port | Check the wiring; run `rdm-card-reader --list-ports` |
| `permission denied` | `sudo usermod -aG dialout $USER`, then log out and back in |
| Reader opens but no cards | Card must be EM4100 / 125 kHz; hold it ~2 cm from the coil |
| `INVALID CARD` repeatedly | Noisy supply or long/unshielded wiring; shorten it |
| Every card reported twice | Lower `--heartbeat` if the card is lifted quickly |
---
## Check it works
With no RFID hardware attached, `--demo` exercises the formatting path and
`--list-ports` shows what serial ports exist:
```bash
rdm-card-reader --demo # simulated reads, no hardware needed
rdm-card-reader --list-ports # candidate and identified ports
```
To drive the real reader path with no hardware, feed RDM6300 frames into a
pseudo-terminal — see the snippet in **[LAUNCH.md](LAUNCH.md)**.
### Layout
```
src/rdm_card_reader/
card.py data model, constants, output formatting (no hardware)
reader.py serial port handling, event callbacks
cli.py argument parsing and program flow
demo.py simulated reads
ports.py stable serial port identification
bridge.py the HTTP bridge behind --serve
led.py status LED
bootstrap.py offline dependency install
wheels/ the bundled wheels (offline install source)
```
### Using it as a library
```python
from rdm_card_reader import open_reader
def on_event(event):
if event.event == "card_inserted":
print("card", event.value, event.hex_value)
reader = open_reader(on_event)
if reader is None:
raise SystemExit("no reader found")
reader.start() # blocks; call reader.close() to stop
```
---
## License
MIT
+28
View File
@@ -0,0 +1,28 @@
# Give the RFID reader a stable name: /dev/rdm-reader
#
# Without this, the adapter is /dev/ttyUSB0 or /dev/ttyUSB1 depending on the
# order the kernel enumerated USB devices, which changes between reboots and
# whenever anything else is plugged in. Authenticating against the wrong reader
# is a real failure, so pin the identity here instead of hoping.
#
# Match on the USB-serial bridge chip. Add serial numbers once you know them -
# see README "Identifying the right ttyUSB" for how to read them off:
#
# SUBSYSTEM=="tty", ATTRS{idVendor}=="1a86", ATTRS{idProduct}=="7523", \
# ATTRS{serial}=="REPLACE_WITH_YOUR_SERIAL", SYMLINK+="rdm-reader", MODE="0660", GROUP="dialout"
#
# Current rule: match by vendor:product (CH340 is 1a86:7523).
# Commented-out alternatives for other common bridge chips are listed below.
# Only ONE rule should be active - remove or comment the others.
# CH340 / CH341 (most common on cheap USB-TTL adapters)
SUBSYSTEM=="tty", ATTRS{idVendor}=="1a86", ATTRS{idProduct}=="7523", SYMLINK+="rdm-reader", MODE="0660", GROUP="dialout"
# FTDI FT232R
# SUBSYSTEM=="tty", ATTRS{idVendor}=="0403", ATTRS{idProduct}=="6001", ATTRS{serial}=="REPLACE_ME", SYMLINK+="rdm-reader", MODE="0660", GROUP="dialout"
# Silicon Labs CP2102
# SUBSYSTEM=="tty", ATTRS{idVendor}=="10c4", ATTRS{idProduct}=="ea60", ATTRS{serial}=="REPLACE_ME", SYMLINK+="rdm-reader", MODE="0660", GROUP="dialout"
# Prolific PL2303
# SUBSYSTEM=="tty", ATTRS{idVendor}=="067b", ATTRS{idProduct}=="2303", ATTRS{serial}=="REPLACE_ME", SYMLINK+="rdm-reader", MODE="0660", GROUP="dialout"
+261
View File
@@ -0,0 +1,261 @@
#!/usr/bin/env bash
#
# Install the RDM card reader as a service that starts at boot and keeps the
# card on the reader available over HTTP for the web page to claim.
#
# Run as root on the Raspberry Pi:
#
# sudo ./deploy/install.sh --origin https://your.web.app
#
# Options:
# --origin URL web app origin allowed to read the API (repeatable)
# --port PORT HTTP port for the bridge (default 8765)
# --dir PATH install location (default /opt/rdm-card-reader)
# --user NAME service account (default rdmcard)
# --no-service install and configure, but do not enable the service
#
set -euo pipefail
ORIGINS=()
BRIDGE_PORT=8765
INSTALL_DIR=/opt/rdm-card-reader
SERVICE_USER=rdmcard
INSTALL_SERVICE=1
SOURCE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
while [[ $# -gt 0 ]]; do
case "$1" in
--origin) ORIGINS+=("$2"); shift 2 ;;
--port) BRIDGE_PORT="$2"; shift 2 ;;
--dir) INSTALL_DIR="$2"; shift 2 ;;
--user) SERVICE_USER="$2"; shift 2 ;;
--no-service) INSTALL_SERVICE=0; shift ;;
-h|--help) sed -n '2,20p' "$0"; exit 0 ;;
*) echo "unknown option: $1" >&2; exit 2 ;;
esac
done
log() { printf '\n\033[1m==> %s\033[0m\n' "$*"; }
warn() { printf '\033[33mwarning: %s\033[0m\n' "$*" >&2; }
if [[ $EUID -ne 0 ]]; then
echo "Please run as root: sudo $0 $*" >&2
exit 1
fi
# ---------------------------------------------------------------------------
# 1. System packages
# ---------------------------------------------------------------------------
log "Checking system packages"
# Both of these need the network, so neither may abort the install: a device
# with no internet usually already has python3, and we can work without them.
apt-get update -qq 2>/dev/null || warn "apt-get update failed (no network?) - continuing"
apt-get install -y --no-install-recommends python3 python3-venv python3-pip \
|| warn "could not install system packages - continuing with what is present"
# ---------------------------------------------------------------------------
# 2. Install the application
# ---------------------------------------------------------------------------
log "Installing to $INSTALL_DIR"
mkdir -p "$INSTALL_DIR"
# Copy the source tree (excluding local build/test artifacts).
for item in src pyproject.toml README.md LAUNCH.md LICENSE deploy; do
if [[ -e "$SOURCE_DIR/$item" ]]; then
cp -a "$SOURCE_DIR/$item" "$INSTALL_DIR/"
fi
done
log "Building the virtual environment"
if ! python3 -m venv "$INSTALL_DIR/.venv"; then
warn "could not create the virtualenv."
warn "On Debian/Raspberry Pi OS this usually means python3-venv is missing:"
warn " sudo apt-get install python3-venv"
exit 1
fi
VENV_PY="$INSTALL_DIR/.venv/bin/python"
WHEEL_DIR="$INSTALL_DIR/src/rdm_card_reader/wheels"
# Upgrading pip is optional and needs the network.
"$INSTALL_DIR/.venv/bin/pip" install --quiet --upgrade pip 2>/dev/null \
|| echo " (kept the bundled pip - no network)"
# Install the runtime dependencies from the wheels shipped in the source tree.
#
# Deliberately NOT 'pip install .': that builds the project, which requires
# downloading setuptools into an isolated build environment. With no internet
# it fails with
# Could not find a version that satisfies the requirement setuptools>=68
# even though everything needed to *run* is sitting in the wheel directory.
# Installing the runtime dependencies directly, and putting 'src' on the
# path, achieves the same thing and works offline.
INSTALLED_PACKAGE=0
if [[ -d "$WHEEL_DIR" ]] && "$VENV_PY" -m pip install --quiet --no-index \
--find-links "$WHEEL_DIR" rdm6300 pyserial 2>/dev/null; then
echo " installed rdm6300 and pyserial from the bundled wheels (offline)"
else
echo " bundled wheels unavailable - falling back to PyPI"
if "$VENV_PY" -m pip install --quiet rdm6300 pyserial 2>/dev/null; then
echo " installed rdm6300 and pyserial from PyPI"
else
warn "could not install rdm6300 / pyserial."
warn "The app will try again on first start (it can install from its own"
warn "bundled wheels), but check network access if that also fails."
fi
fi
# Status LED support. Best-effort: the reader works fine without a LED, so a
# failure here must not stop the install.
#
# --no-deps is deliberate: gpiozero declares colorzero, which in turn declares
# setuptools. setuptools is only a packaging requirement and is never imported
# at runtime, so it is not vendored (it would add 818 KB for nothing).
if [[ -d "$WHEEL_DIR" ]] && "$VENV_PY" -m pip install --quiet --no-index \
--no-deps --find-links "$WHEEL_DIR" gpiozero colorzero 2>/dev/null; then
echo " installed gpiozero + colorzero for the status LED (offline)"
else
if "$VENV_PY" -m pip install --quiet gpiozero colorzero 2>/dev/null; then
echo " installed gpiozero + colorzero from PyPI"
else
warn "could not install LED support - the reader still works, the LED"
warn "will be inactive. The app can install it later on first use."
fi
fi
# Make the package importable. The .pth file adds src/ to sys.path, so
# 'python -m rdm_card_reader' resolves from any working directory.
SITE_PACKAGES="$("$VENV_PY" -c 'import sysconfig; print(sysconfig.get_paths()["purelib"])')"
echo "$INSTALL_DIR/src" > "$SITE_PACKAGES/rdm_card_reader_src.pth"
# Console-script equivalent, so the unit and the docs work as written.
# The shebang must be the venv's interpreter by absolute path: '#!/usr/bin/env
# python3' would pick up the *system* python when the venv is not activated,
# and the package would not be importable.
cat > "$INSTALL_DIR/.venv/bin/rdm-card-reader" <<EOF
#!$VENV_PY
import sys
from rdm_card_reader.cli import main
if __name__ == "__main__":
sys.exit(main())
EOF
chmod +x "$INSTALL_DIR/.venv/bin/rdm-card-reader"
# Standalone launcher for running without activating anything.
cat > "$INSTALL_DIR/run.sh" <<EOF
#!/bin/sh
# Convenience launcher: ./run.sh [args...]
exec "$INSTALL_DIR/.venv/bin/rdm-card-reader" "\$@"
EOF
chmod +x "$INSTALL_DIR/run.sh"
# Sanity check: prove it imports and can run before we enable a service.
if "$INSTALL_DIR/.venv/bin/rdm-card-reader" --version >/dev/null 2>&1; then
echo " verified: $("$INSTALL_DIR/.venv/bin/rdm-card-reader" --version)"
else
warn "the reader does not run yet. Check with:"
warn " $INSTALL_DIR/.venv/bin/python -m rdm_card_reader --list-ports"
fi
# ---------------------------------------------------------------------------
# 3. Service account
# ---------------------------------------------------------------------------
log "Creating service account '$SERVICE_USER'"
if ! id -u "$SERVICE_USER" >/dev/null 2>&1; then
# No login shell, no home directory: this account only reads a serial port.
useradd --system --no-create-home --shell /usr/sbin/nologin "$SERVICE_USER"
fi
# Serial access requires the dialout group.
usermod -aG dialout "$SERVICE_USER"
echo " $SERVICE_USER is in group dialout"
# ---------------------------------------------------------------------------
# 4. Stable device name
# ---------------------------------------------------------------------------
log "Installing the udev rule (/dev/rdm-reader)"
install -m 0644 "$SOURCE_DIR/deploy/99-rdm-reader.rules" \
/etc/udev/rules.d/99-rdm-reader.rules
udevadm control --reload-rules
udevadm trigger --subsystem-match=tty || true
sleep 1
if [[ -e /dev/rdm-reader ]]; then
echo " /dev/rdm-reader -> $(readlink -f /dev/rdm-reader)"
else
warn "/dev/rdm-reader does not exist yet."
warn "The reader may not be plugged in, or it uses a different chip."
warn "Run '$INSTALL_DIR/.venv/bin/rdm-card-reader --list-ports --verbose-ports'"
warn "then edit /etc/udev/rules.d/99-rdm-reader.rules to match your adapter."
fi
# ---------------------------------------------------------------------------
# 5. Service
# ---------------------------------------------------------------------------
log "Installing the systemd unit"
UNIT=/etc/systemd/system/rdm-card-reader.service
install -m 0644 "$SOURCE_DIR/deploy/rdm-card-reader.service" "$UNIT"
# Rewrite the ExecStart line with the real install dir, port and origins.
ORIGIN_ARGS=""
for origin in "${ORIGINS[@]:-}"; do
[[ -n "$origin" ]] && ORIGIN_ARGS+=" --allow-origin $origin"
done
python3 - "$UNIT" "$INSTALL_DIR" "$BRIDGE_PORT" "$ORIGIN_ARGS" "$SERVICE_USER" <<'PY'
import os, re, sys
unit, install_dir, port, origin_args, user = sys.argv[1:6]
text = open(unit).read()
# The console script is created by the installer, but the .pth file is what
# makes the package importable, so fall back to 'python -m' if it is ever
# absent. systemd's ExecStart has no shell, so no pipes or conditionals.
wrapper = f"{install_dir}/.venv/bin/rdm-card-reader"
if not os.path.exists(wrapper):
wrapper = f"{install_dir}/.venv/bin/python -m rdm_card_reader"
command = (
f"{wrapper} --serve "
f"--port /dev/rdm-reader --bridge-port {port} --mode login{origin_args}"
)
# Replace the whole (possibly line-continued) ExecStart definition.
text = re.sub(r"ExecStart=[\s\S]*?(?=\n\n|\nRestart=)", f"ExecStart={command}", text, count=1)
text = re.sub(r"^User=.*$", f"User={user}", text, count=1, flags=re.M)
open(unit, "w").write(text)
PY
# Make the venv readable by the service account.
chown -R "$SERVICE_USER:$SERVICE_USER" "$INSTALL_DIR"
if [[ "$INSTALL_SERVICE" -eq 1 ]]; then
log "Enabling and starting the service"
systemctl daemon-reload
systemctl enable rdm-card-reader.service
systemctl restart rdm-card-reader.service
sleep 2
systemctl --no-pager --lines=15 status rdm-card-reader.service || true
else
log "Skipping service start (--no-service)"
fi
# ---------------------------------------------------------------------------
# Done
# ---------------------------------------------------------------------------
cat <<EOF
$(log "Installed")
Bridge : http://127.0.0.1:$BRIDGE_PORT
Card state : curl http://127.0.0.1:$BRIDGE_PORT/api/card
Health : curl http://127.0.0.1:$BRIDGE_PORT/api/health
Logs : journalctl -u rdm-card-reader -f
Test a tap : place a card and run the curl above
The service starts automatically on boot. The card stays readable at
/api/card while it is on the reader; POST /api/card/claim hands it over
once, so a page reload cannot replay the same tap into a second login.
EOF
+56
View File
@@ -0,0 +1,56 @@
[Unit]
Description=RDM card reader HTTP bridge (serves card taps for web login)
Documentation=file:/opt/rdm-card-reader/README.md
# The serial device must exist before we start, otherwise the reader opens the
# wrong tty or none at all.
After=dev-serial.device network-online.target
Wants=network-online.target
# Do not restart forever if the reader is genuinely absent. These are [Unit]
# options, not [Service] - systemd ignores them in the wrong section.
StartLimitIntervalSec=60
StartLimitBurst=5
[Service]
Type=simple
User=rdmcard
Group=dialout
WorkingDirectory=/opt/rdm-card-reader
# --serve exposes the card on http://127.0.0.1:8765/api/card
# --port points at the udev alias, so the reader is the same device every boot.
# Autodetect refuses to guess if more than one USB adapter is present, which is
# the safe behaviour: a wrong guess means authenticating the wrong person.
# The wheels for rdm6300 and pyserial are bundled inside the package, so no
# --wheel-dir is needed here: the install works with no internet.
ExecStart=/opt/rdm-card-reader/.venv/bin/rdm-card-reader \
--serve \
--port /dev/rdm-reader \
--mode login \
--allow-origin https://your.web.app
Restart=always
RestartSec=3
# The bridge only needs to read the serial port and listen on loopback.
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
RestrictNamespaces=true
LockPersonality=true
MemoryDenyWriteExecute=true
DeviceAllow=/dev/rdm-reader rw
DevicePolicy=closed
# stdout/stderr go here; watch with: journalctl -u rdm-card-reader -f
StandardOutput=journal
StandardError=journal
SyslogIdentifier=rdm-card-reader
[Install]
WantedBy=multi-user.target
+38
View File
@@ -0,0 +1,38 @@
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[project]
name = "rdm-card-reader"
version = "0.4.0"
description = "RDM6300 / EM4100 RFID card reader: prints card events, or serves them over HTTP so a web app can authenticate a card tap."
readme = "README.md"
requires-python = ">=3.8"
license = { text = "MIT" }
keywords = ["rfid", "rdm6300", "em4100", "raspberry-pi", "serial"]
classifiers = [
"Development Status :: 4 - Beta",
"Intended Audience :: Manufacturing",
"License :: OSI Approved :: MIT License",
"Operating System :: POSIX :: Linux",
"Programming Language :: Python :: 3",
"Topic :: System :: Hardware",
]
dependencies = [
"rdm6300>=0.1.1",
"pyserial>=3.5",
]
[project.optional-dependencies]
# Status LED support. Installed automatically on first use by the app, or
# explicitly with: pip install '.[led]'
led = ["gpiozero>=2.0", "colorzero>=2.0"]
[project.scripts]
rdm-card-reader = "rdm_card_reader.cli:main"
[tool.setuptools.packages.find]
where = ["src"]
[tool.setuptools.package-data]
rdm_card_reader = ["py.typed", "wheels/*.whl", "wheels/SHA256SUMS"]
+63
View File
@@ -0,0 +1,63 @@
"""Reading RDM6300 (EM4100) RFID cards as simple insert / remove events.
Two ways to use this package:
* as a plain reader that prints what happens (``rdm-card-reader``)
* as a small HTTP bridge that publishes card events for a web app
(``rdm-card-reader --serve``)
The bridge reports card ids only. Deciding which user a card belongs to is the
server's job - see ``bridge`` for the reasoning.
On first start the reader installs its own libraries if they are missing, so a
fresh device needs no manual setup - see ``bootstrap``.
"""
from .bootstrap import ensure_dependencies, missing_packages, bundled_wheel_dir
from .card import (
CANDIDATE_PORTS,
CONFIG_CARD_VALUE,
CardData,
CardEvent,
format_event,
)
from .bridge import EventHub, create_server
from .ports import (
AmbiguousPort,
PortError,
PortInfo,
PortNotFound,
discover,
resolve,
)
from .led import DEFAULT_LED_PIN, StatusLED, create as create_led
from .reader import available, list_ports, open_reader, reload_library
__version__ = "0.4.0"
__all__ = [
"CANDIDATE_PORTS",
"CONFIG_CARD_VALUE",
"CardData",
"CardEvent",
"AmbiguousPort",
"DEFAULT_LED_PIN",
"EventHub",
"PortError",
"PortInfo",
"PortNotFound",
"StatusLED",
"available",
"bundled_wheel_dir",
"create_led",
"create_server",
"discover",
"ensure_dependencies",
"format_event",
"list_ports",
"missing_packages",
"open_reader",
"reload_library",
"resolve",
"__version__",
]
+8
View File
@@ -0,0 +1,8 @@
"""Allow ``python -m rdm_card_reader``."""
import sys
from .cli import main
if __name__ == "__main__":
sys.exit(main())
+312
View File
@@ -0,0 +1,312 @@
"""Make sure the reader library is present before we try to use it.
On a fresh device the ``rdm6300`` package may not be installed. Rather than
dying with "please install this yourself", the reader installs it on first run
and then carries on - the same behaviour the workstation app had, but without
shelling out to a bare ``pip`` with no error handling.
The wheels for both libraries are **shipped inside this package** (in
``rdm_card_reader/wheels``), so a device with no internet can still set itself
up. They are pure-Python (``py3-none-any`` / ``py2.py3-none-any``), so the same
files work on any architecture - unlike the workstation app's cache, which held
architecture-specific binaries and therefore could not be committed.
Two things this deliberately does *not* do:
* It never installs anything when the library is already importable, so a
normal start does no network work at all.
* It never lets a failed install crash the process. A missing library is a
condition to report clearly, not a traceback.
"""
from __future__ import annotations
import hashlib
import importlib
import os
import subprocess
import sys
from typing import Callable, List, Optional, Sequence
#: Import name -> pip requirement. The import name is what we check; the
#: requirement is what pip is asked for.
REQUIRED_PACKAGES = (
("rdm6300", "rdm6300>=0.1.1"),
("serial", "pyserial>=3.5"),
)
#: Extra packages needed only when driving a status LED.
#:
#: ``colorzero`` is listed explicitly because ``gpiozero`` requires it. Both are
#: installed with ``--no-deps``: gpiozero's declared dependency chain pulls in
#: ``setuptools`` (via colorzero), which is only a packaging requirement and is
#: not imported at runtime. Vendoring it would add 818 KB for nothing.
LED_PACKAGES = (
("gpiozero", "gpiozero>=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.
MANUAL_HINT = (
"Install manually with: {python} -m pip install {requirements}"
)
def bundled_wheel_dir() -> Optional[str]:
"""Directory of the wheels shipped inside this package.
Returns ``None`` when it is absent, which is the case for a source checkout
that has not vendored them.
"""
folder = os.path.join(os.path.dirname(os.path.abspath(__file__)), "wheels")
if not os.path.isdir(folder):
return None
if not any(name.endswith(".whl") for name in os.listdir(folder)):
return None
return folder
def local_wheel_missing(requirements: Sequence[str], wheel_dir: str) -> List[str]:
"""Requirements that have no matching wheel in *wheel_dir*.
``--no-index`` makes pip fail outright if a wheel is absent, so check first
and fall back to PyPI rather than dying on a half-provisioned directory.
"""
try:
present = os.listdir(wheel_dir)
except OSError:
return list(requirements)
missing = []
for requirement in requirements:
# "rdm6300>=0.1.1" -> "rdm6300"
name = requirement.split(">=")[0].split("==")[0].split("[")[0].strip()
# Both sides are dash/underscore-normalised, so the prefix separator
# must be "_" - comparing against "name-" never matches.
normalised = name.replace("-", "_").lower()
if not any(
entry.replace("-", "_").lower().startswith(normalised + "_")
for entry in present
):
missing.append(requirement)
return missing
def verify_wheels(wheel_dir: str, sums_file: Optional[str] = None) -> List[str]:
"""Check wheels against ``SHA256SUMS``. Returns a list of problems.
A tampered wheel is an arbitrary-code-execution risk on every device that
installs it, so a mismatch is reported rather than installed.
"""
problems: List[str] = []
sums_path = sums_file or os.path.join(wheel_dir, "SHA256SUMS")
if not os.path.exists(sums_path):
return problems # nothing to check against
try:
with open(sums_path, "r") as handle:
lines = [line.strip() for line in handle if line.strip()]
except OSError as exc:
return ["cannot read {}: {}".format(sums_path, exc)]
for line in lines:
parts = line.split(None, 1)
if len(parts) != 2:
continue
expected, filename = parts[0], parts[1].lstrip("*").strip()
path = os.path.join(wheel_dir, filename)
if not os.path.exists(path):
problems.append("missing {}".format(filename))
continue
digest = hashlib.sha256()
try:
with open(path, "rb") as handle:
for chunk in iter(lambda: handle.read(65536), b""):
digest.update(chunk)
except OSError as exc:
problems.append("cannot read {}: {}".format(filename, exc))
continue
if digest.hexdigest() != expected:
problems.append(
"checksum mismatch for {} (expected {}, got {})".format(
filename, expected[:12], digest.hexdigest()[:12]
)
)
return problems
def missing_packages(
packages: Sequence[tuple] = REQUIRED_PACKAGES,
) -> List[tuple]:
"""Return the ``(import_name, requirement)`` pairs that cannot be imported."""
missing = []
for import_name, requirement in packages:
try:
importlib.import_module(import_name)
except ImportError:
missing.append((import_name, requirement))
return missing
def in_virtualenv() -> bool:
"""True when running inside a venv, where PEP 668 does not apply."""
if os.environ.get("VIRTUAL_ENV"):
return True
return sys.prefix != getattr(sys, "base_prefix", sys.prefix)
def _pip_command(
requirements: Sequence[str],
wheel_dir: Optional[str],
no_deps: bool = False,
) -> List[str]:
"""Build the pip invocation, handling venv vs system Python."""
command = [sys.executable, "-m", "pip", "install"]
if no_deps:
command.append("--no-deps")
if wheel_dir:
# Offline install from a local wheel directory: no index, no network.
command += ["--no-index", "--find-links", wheel_dir]
else:
command += ["--disable-pip-version-check"]
# Outside a virtualenv, pip on Debian/Raspberry Pi OS refuses to touch the
# system environment unless told explicitly (PEP 668).
if not in_virtualenv():
command.append("--break-system-packages")
command += list(requirements)
return command
def install(
requirements: Sequence[str],
wheel_dir: Optional[str] = None,
logger: Optional[Callable[[str], None]] = None,
timeout: int = 300,
no_deps: bool = False,
) -> bool:
"""Run pip. Returns True on success; never raises for a failed install."""
say = logger or (lambda message: None)
command = _pip_command(requirements, wheel_dir, no_deps=no_deps)
say(" $ {}".format(" ".join(command)))
try:
result = subprocess.run(
command,
capture_output=True,
text=True,
timeout=timeout,
)
except subprocess.TimeoutExpired:
say(" x install timed out after {}s".format(timeout))
return False
except OSError as exc:
say(" x could not run pip: {}".format(exc))
return False
if result.returncode != 0:
# pip writes the useful part to stderr; show the tail of it.
tail = (result.stderr or result.stdout or "").strip().splitlines()[-6:]
for line in tail:
say(" | {}".format(line))
return False
return True
def _is_optional(packages: Sequence[tuple]) -> bool:
"""True when every package in the group is an optional extra."""
names = {name for name, _ in packages}
return bool(names) and names <= OPTIONAL_IMPORTS
def ensure_dependencies(
packages: Sequence[tuple] = REQUIRED_PACKAGES,
wheel_dir: Optional[str] = None,
auto_install: bool = True,
logger: Optional[Callable[[str], None]] = None,
use_bundled: bool = True,
no_deps: bool = False,
) -> bool:
"""Check for the given libraries and install them if they are missing.
Returns True when everything is importable at the end - either because it
already was, or because the install worked. Returns False when something is
still missing, so the caller can decide what to do. For an optional group
(the LED extras) False just means the feature is unavailable.
"""
say = logger or (lambda message: None)
missing = missing_packages(packages)
if not missing:
return True
requirements = [requirement for _, requirement in missing]
names = ", ".join(name for name, _ in missing)
say("Missing reader dependencies: {}".format(names))
if not auto_install:
say("Automatic install is disabled (--no-install-deps).")
say(MANUAL_HINT.format(
python=sys.executable, requirements=" ".join(requirements)))
return False
# Prefer the wheels shipped with the package: a device with no internet
# must still be able to set itself up.
explicit = wheel_dir is not None
if wheel_dir is None and use_bundled:
wheel_dir = bundled_wheel_dir()
if wheel_dir:
say("Using the wheels bundled with this package:")
say(" {}".format(wheel_dir))
if wheel_dir:
problems = verify_wheels(wheel_dir)
for problem in problems:
say(" x {}".format(problem))
if problems:
# A bad or incomplete bundle is not fatal: fall back to PyPI rather
# than installing something we could not verify.
if explicit:
say(" x the --wheel-dir you gave is unusable")
say("Bundle unusable - falling back to PyPI.")
wheel_dir = None
else:
uncovered = local_wheel_missing(requirements, wheel_dir)
if uncovered:
# Do not attempt a --no-index install that is guaranteed to
# fail; PyPI covers whatever the bundle does not contain.
say(" local wheels do not cover: {}".format(
", ".join(uncovered)))
if explicit:
say(" x the --wheel-dir you gave is incomplete")
say("Falling back to PyPI for those.")
wheel_dir = None
if wheel_dir:
say("Installing from {}".format(wheel_dir))
else:
say("Installing from PyPI ...")
install(requirements, wheel_dir=wheel_dir, logger=say,
no_deps=no_deps and wheel_dir is not None)
# Re-check rather than trusting pip's exit code alone.
still_missing = missing_packages(packages)
if still_missing:
say("Still missing: {}".format(
", ".join(name for name, _ in still_missing)))
say(MANUAL_HINT.format(
python=sys.executable, requirements=" ".join(requirements)))
return False
say("Dependencies ready: {}".format(
", ".join(name for name, _ in missing)))
return True
+503
View File
@@ -0,0 +1,503 @@
"""HTTP bridge: expose card taps to a web page, without ever becoming the
authority on who the card belongs to.
The reader is a *sensor*. This module publishes what the sensor sees and stops
there - resolving a card id to a user is the server's job, so nothing here
writes to a database, mints session tokens or decides whether a login succeeds.
Served by default:
============================ ============================================
``GET /api/health`` reader state, port, uptime, last tap age
``GET /api/card`` the card on the reader right now (latched)
``POST /api/card/claim`` claim that tap, single use, for one login
``GET /api/events`` Server-Sent Events stream of card events
``GET /api/last`` most recent card event (or 204)
``GET /api/taps`` recent tap history (ring buffer)
``POST /api/mode`` switch reader mode: login / monitor
============================ ============================================
The usual integration, for a web page that may load at any moment::
const r = await fetch("http://127.0.0.1:8765/api/card");
const state = await r.json();
if (state.claimable) {
const c = await fetch("http://127.0.0.1:8765/api/card/claim", {method: "POST"});
if (c.ok) {
const { card } = await c.json();
// hand card.id to your server; it decides who that is
}
}
``claimable`` stays true for ``--tap-ttl`` seconds after the card is read -
including after the card is lifted - so a quick tap is not lost.
Security posture (important, and deliberate):
* Binds to ``127.0.0.1`` unless the operator explicitly opts in to more, because
an open card-event endpoint on a LAN lets anyone read who is tapping.
* Cross-origin requests are refused unless the origin is on an allow-list.
A page on another site must not be able to observe your taps.
* Optionally requires a pre-shared token (``--token`` / ``RDM_BRIDGE_TOKEN``)
for non-local callers.
An EM4100 card id is a *username*, not a password: it is broadcast in the clear
and can be cloned. Never let holding a card id alone create a session.
"""
from __future__ import annotations
import json
import os
import secrets
import threading
import time
from collections import deque
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import Any, Callable, Deque, Dict, List, Optional
from .card import EVENT_INSERTED, EVENT_REMOVED, CardEvent
#: Reader modes. ``login`` publishes inserts only (a tap = an authentication
#: attempt). ``monitor`` publishes inserts and removals both.
MODE_LOGIN = "login"
MODE_MONITOR = "monitor"
VALID_MODES = (MODE_LOGIN, MODE_MONITOR)
DEFAULT_HOST = "127.0.0.1"
DEFAULT_PORT = 8765
DEFAULT_HISTORY = 50
#: How long a tap stays claimable. This deliberately outlives the card
#: leaving the reader: a quick tap that is lifted straight away must still be
#: claimable, otherwise a page that polls a moment later sees nothing and the
#: login silently fails. Set to 0 to keep a tap claimable until it is used or
#: replaced.
DEFAULT_TAP_TTL = 10.0
class EventHub:
"""Tracks reader state and fans card events out to waiting subscribers.
This is the only place the raw card id is held. Subscribers are HTTP
connections owned by the same process, never third-party code.
The card currently read is *latched*: it stays readable through
:meth:`current_card` after the card has left the reader, for ``tap_ttl``
seconds from the tap. That is what makes the data "always available" to a
web page that may load or poll at any moment.
Claiming it through :meth:`consume_card` marks it used, so a page reload or
a second tab cannot replay the same physical tap into a second login.
"""
def __init__(
self,
history: int = DEFAULT_HISTORY,
mode: str = MODE_LOGIN,
tap_ttl: float = DEFAULT_TAP_TTL,
):
self._lock = threading.Lock()
self._subscribers: List["deque"] = []
self._history: Deque[CardEvent] = deque(maxlen=history)
self._last: Optional[CardEvent] = None
self._last_at: Optional[float] = None
self._mode = mode if mode in VALID_MODES else MODE_LOGIN
self._started_at = time.time()
self._counts = {EVENT_INSERTED: 0, EVENT_REMOVED: 0, "invalid_card": 0}
self._port: Optional[str] = None
self._tap_ttl = float(tap_ttl if tap_ttl else 0.0)
# The latched tap. It deliberately outlives the card leaving the
# reader, so a page that loads after the tap can still claim it.
self._tap: Optional[CardEvent] = None
self._tap_at: Optional[float] = None
self._on_reader = False
self._consumed_at: Optional[float] = None
# -- state ------------------------------------------------------------
def set_port(self, port: str) -> None:
with self._lock:
self._port = port
@property
def mode(self) -> str:
with self._lock:
return self._mode
def set_mode(self, mode: str) -> bool:
"""Switch mode. Returns False for an unknown mode."""
if mode not in VALID_MODES:
return False
with self._lock:
self._mode = mode
return True
def publish(self, event: CardEvent) -> None:
"""Record an event and wake every subscriber. Safe from any thread."""
with self._lock:
self._counts[event.event] = self._counts.get(event.event, 0) + 1
# Presence tracking always happens, in every mode: the latched tap
# is state, not a published event.
if event.event == EVENT_INSERTED:
self._last = event
self._last_at = time.time()
# A new tap replaces any previous unclaimed one.
self._tap = event
self._tap_at = self._last_at
self._on_reader = True
self._consumed_at = None
elif event.event == EVENT_REMOVED:
# Lifting the card does NOT discard the tap: it must stay
# claimable for tap_ttl, otherwise a quick tap is lost before
# the web page gets a chance to ask for it.
self._on_reader = False
# In login mode a tap is an authentication attempt; card removal is
# not published, so a web app cannot mistake it for a second tap.
if self._mode == MODE_LOGIN and event.event == EVENT_REMOVED:
return
self._history.append(event)
queues = list(self._subscribers)
payload = event.to_dict()
for queue in queues:
try:
queue.append(payload)
except Exception:
pass
# -- latched card -----------------------------------------------------
def current_card(self) -> Dict[str, Any]:
"""The latched tap, so a page can ask at any moment.
``present`` is whether the card is physically on the reader right now;
``claimable`` is whether this tap can still be spent on a login.
"""
with self._lock:
if self._tap is None:
return {
"present": False,
"claimable": False,
"consumed": False,
"expired": False,
"age_seconds": None,
"card": None,
}
age = time.time() - (self._tap_at or 0.0)
expired = bool(self._tap_ttl) and age > self._tap_ttl
consumed = self._consumed_at is not None
return {
"present": self._on_reader,
"claimable": (not consumed) and (not expired),
"consumed": consumed,
"expired": expired,
"age_seconds": round(age, 1),
"card": self._tap.to_dict(),
}
def consume_card(self):
"""Atomically claim the latched tap for one authentication.
Returns ``(payload, error, status)``. The tap is marked used only when
this call succeeds, so two concurrent requests cannot both win.
"""
with self._lock:
if self._tap is None:
return None, "no card read yet", 409
age = time.time() - (self._tap_at or 0.0)
if self._tap_ttl and age > self._tap_ttl:
return None, "card tap expired", 410
if self._consumed_at is not None:
return None, "card already used", 409
self._consumed_at = time.time()
return (
{
"card": self._tap.to_dict(),
"age_seconds": round(age, 1),
"consumed": True,
},
None,
200,
)
def subscribe(self) -> "deque":
"""Register a queue that receives every subsequent event."""
queue: Deque[Dict[str, Any]] = deque(maxlen=100)
with self._lock:
self._subscribers.append(queue)
return queue
def unsubscribe(self, queue: "deque") -> None:
with self._lock:
try:
self._subscribers.remove(queue)
except ValueError:
pass
# -- snapshots --------------------------------------------------------
def health(self) -> Dict[str, Any]:
with self._lock:
last_age = (
round(time.time() - self._last_at, 1) if self._last_at else None
)
return {
"status": "ok",
"port": self._port,
"mode": self._mode,
"uptime_seconds": round(time.time() - self._started_at, 1),
"subscribers": len(self._subscribers),
"counts": dict(self._counts),
"last_tap": self._last.to_dict() if self._last else None,
"last_tap_age_seconds": last_age,
"card_present": self._on_reader,
"card_consumed": self._consumed_at is not None,
"tap_ttl_seconds": self._tap_ttl or None,
}
def last(self) -> Optional[Dict[str, Any]]:
with self._lock:
return self._last.to_dict() if self._last else None
def taps(self, limit: int = 20) -> List[Dict[str, Any]]:
with self._lock:
items = list(self._history)[-max(1, limit):]
return [event.to_dict() for event in items]
class _Handler(BaseHTTPRequestHandler):
"""One HTTP connection. ``server.hub`` holds the shared state."""
server_version = "rdm-card-reader"
protocol_version = "HTTP/1.1"
# -- helpers ----------------------------------------------------------
def _cors_ok(self) -> bool:
"""Refuse cross-origin reads unless the origin was allow-listed."""
origin = self.headers.get("Origin")
if not origin:
return True # same-origin fetch or a non-browser client
allowed: List[str] = getattr(self.server, "allowed_origins", [])
if not allowed:
return False # browsers only; opt out entirely
return origin in allowed
def _token_ok(self) -> bool:
"""Check the pre-shared token for non-loopback callers."""
required: Optional[str] = getattr(self.server, "token", None)
if not required:
return True
client = self.client_address[0]
if client in ("127.0.0.1", "::1", "localhost"):
return True
supplied = self.headers.get("X-Auth-Token") or ""
if not supplied:
query = self.path.split("?", 1)[1] if "?" in self.path else ""
for pair in query.split("&"):
if pair.startswith("token="):
supplied = pair.split("=", 1)[1]
return secrets.compare_digest(supplied, required)
def _send_json(self, payload: Any, status: int = 200) -> None:
body = json.dumps(payload).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
origin = self.headers.get("Origin")
if origin and origin in getattr(self.server, "allowed_origins", []):
self.send_header("Access-Control-Allow-Origin", origin)
self.send_header("Vary", "Origin")
self.end_headers()
self.wfile.write(body)
def _send_no_content(self) -> None:
self.send_response(204)
self.send_header("Cache-Control", "no-store")
self.send_header("Content-Length", "0")
origin = self.headers.get("Origin")
if origin and origin in getattr(self.server, "allowed_origins", []):
self.send_header("Access-Control-Allow-Origin", origin)
self.send_header("Vary", "Origin")
self.end_headers()
def log_message(self, fmt: str, *args: Any) -> None:
logger: Optional[Callable[[str], None]] = getattr(self.server, "logger", None)
if logger:
logger("{} - {}".format(self.client_address[0], fmt % args))
@property
def _hub(self) -> EventHub:
return self.server.hub # type: ignore[attr-defined]
# -- routing ----------------------------------------------------------
def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API
path = self.path.split("?", 1)[0].rstrip("/") or "/"
if path == "/api/health":
self._send_json(self._hub.health())
return
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
if path == "/api/last":
payload = self._hub.last()
if payload is None:
self._send_no_content()
else:
self._send_json(payload)
return
if path == "/api/card":
self._send_json(self._hub.current_card())
return
if path == "/api/taps":
limit = 20
if "?" in self.path:
for pair in self.path.split("?", 1)[1].split("&"):
if pair.startswith("limit="):
try:
limit = int(pair.split("=", 1)[1])
except ValueError:
pass
self._send_json({"taps": self._hub.taps(limit)})
return
if path == "/api/events":
self._stream_events()
return
self._send_json({"error": "not found", "path": path}, status=404)
def do_POST(self) -> None: # noqa: N802
path = self.path.split("?", 1)[0].rstrip("/") or "/"
if path == "/api/card/claim":
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
payload, error, status = self._hub.consume_card()
if error:
self._send_json(
{"error": error, "state": self._hub.current_card()},
status=status,
)
return
self._send_json(payload, status=status)
return
if path != "/api/mode":
self._send_json({"error": "not found", "path": path}, status=404)
return
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
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
mode = str(payload.get("mode", ""))
if not self._hub.set_mode(mode):
self._send_json(
{"error": "invalid mode", "valid": list(VALID_MODES)}, status=400
)
return
self._send_json({"mode": mode})
def do_OPTIONS(self) -> None: # noqa: N802
origin = self.headers.get("Origin")
self.send_response(204)
if origin and origin in getattr(self.server, "allowed_origins", []):
self.send_header("Access-Control-Allow-Origin", origin)
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
self.send_header("Access-Control-Allow-Headers", "Content-Type, X-Auth-Token")
self.send_header("Vary", "Origin")
self.send_header("Content-Length", "0")
self.end_headers()
# -- Server-Sent Events ----------------------------------------------
def _stream_events(self) -> None:
"""Hold the connection open and push each event as it happens."""
queue = self._hub.subscribe()
self.send_response(200)
self.send_header("Content-Type", "text/event-stream")
self.send_header("Cache-Control", "no-store")
self.send_header("Connection", "keep-alive")
origin = self.headers.get("Origin")
if origin and origin in getattr(self.server, "allowed_origins", []):
self.send_header("Access-Control-Allow-Origin", origin)
self.send_header("Vary", "Origin")
self.end_headers()
try:
self.wfile.write(b": connected\n\n")
self.wfile.flush()
while True:
try:
payload = queue.popleft()
except IndexError:
payload = None
if payload is not None:
message = "data: {}\n\n".format(json.dumps(payload))
self.wfile.write(message.encode("utf-8"))
self.wfile.flush()
else:
# Comment frame keeps proxies and the browser from timing out.
self.wfile.write(b": keepalive\n\n")
self.wfile.flush()
time.sleep(0.2)
except (BrokenPipeError, ConnectionResetError):
pass
finally:
self._hub.unsubscribe(queue)
def create_server(
hub: EventHub,
host: str = DEFAULT_HOST,
port: int = DEFAULT_PORT,
allowed_origins: Optional[List[str]] = None,
token: Optional[str] = None,
logger: Optional[Callable[[str], None]] = None,
) -> ThreadingHTTPServer:
"""Build (but do not start) the HTTP server around *hub*."""
server = ThreadingHTTPServer((host, port), _Handler)
server.daemon_threads = True
server.hub = hub # type: ignore[attr-defined]
server.allowed_origins = list(allowed_origins or []) # type: ignore[attr-defined]
server.token = token or os.environ.get("RDM_BRIDGE_TOKEN") or None # type: ignore[attr-defined]
server.logger = logger # type: ignore[attr-defined]
return server
def is_loopback(host: str) -> bool:
"""True for hosts that are not reachable off-box."""
return host in ("127.0.0.1", "::1", "localhost")
+135
View File
@@ -0,0 +1,135 @@
"""Card data model, protocol constants and output formatting.
Nothing in this module touches hardware or the serial port, which keeps the
data model easy to unit test.
"""
from __future__ import annotations
import json
import time
from collections import namedtuple
from dataclasses import dataclass
from typing import Any, Dict
try: # pragma: no cover - depends on the environment
from rdm6300 import CardData
except ImportError: # pragma: no cover - fallback so the model stays importable
CardData = namedtuple("CardData", ["value", "checksum", "type", "is_valid"])
#: Serial devices tried in order. ``/dev/ttyS0`` first matches the Raspberry Pi
#: default, the others cover USB / GPIO UART dongles.
CANDIDATE_PORTS = (
"/dev/ttyS0",
"/dev/ttyAMA0",
"/dev/ttyUSB0",
"/dev/ttyACM0",
)
#: A special card id used by the WMT workstation app to open its config screen.
#: Reported as a note only - this package never acts on it.
CONFIG_CARD_VALUE = 12886709
#: Seconds without a read before a card is considered removed. Set to ``None``
#: to disable removal detection entirely.
DEFAULT_HEARTBEAT_INTERVAL = 0.5
BAR = "-" * 58
EVENT_INSERTED = "card_inserted"
EVENT_REMOVED = "card_removed"
EVENT_INVALID = "invalid_card"
def now_stamp() -> str:
"""Local timestamp in the same format the workstation app logs."""
return time.strftime("%Y-%m-%d %H:%M:%S")
@dataclass(frozen=True)
class CardEvent:
"""A single card read event, decoupled from the reader library."""
event: str
timestamp: str
value: int
checksum: int
card_type: int
is_valid: bool
port: str
# -- constructors -----------------------------------------------------
@classmethod
def from_card(cls, event: str, card: Any, port: str) -> "CardEvent":
"""Build an event from an ``rdm6300.CardData`` (or anything alike)."""
return cls(
event=event,
timestamp=now_stamp(),
value=int(card.value),
checksum=int(card.checksum),
card_type=int(card.type),
is_valid=bool(card.is_valid),
port=str(port),
)
# -- derived values ---------------------------------------------------
@property
def hex_value(self) -> str:
"""Card id as an 8 digit hex string, e.g. ``0x00C4A0B5``."""
return "0x{:08X}".format(self.value)
@property
def is_config_card(self) -> bool:
return self.value == CONFIG_CARD_VALUE
# -- serialisation ----------------------------------------------------
def to_dict(self) -> Dict[str, Any]:
"""Machine-readable form, used by ``--json``."""
return {
"event": self.event,
"timestamp": self.timestamp,
"id": self.value,
"hex": self.hex_value,
"type": self.card_type,
"checksum": self.checksum,
"is_valid": self.is_valid,
"port": self.port,
}
def to_json(self) -> str:
return json.dumps(self.to_dict())
def format_event(event: CardEvent) -> str:
"""Render an event as the human-readable block printed on stdout."""
if event.event == EVENT_INSERTED:
lines = [
"",
BAR,
" CARD INSERTED - {}".format(event.timestamp),
" id : {} ({})".format(event.value, event.hex_value),
" type : 0x{:02X}".format(event.card_type),
" checksum : {}".format(event.checksum),
" valid : {}".format(event.is_valid),
" port : {}".format(event.port),
]
if event.is_config_card:
lines.append(
" note : this is the config card (id {})".format(
CONFIG_CARD_VALUE
)
)
lines.append(BAR)
return "\n".join(lines)
if event.event == EVENT_REMOVED:
return " CARD REMOVED - {} id: {} ({})".format(
event.timestamp, event.value, event.hex_value
)
return " INVALID CARD - {} raw: {} (checksum mismatch)".format(
event.timestamp, event.hex_value
)
+382
View File
@@ -0,0 +1,382 @@
"""Command line interface for the RDM card reader."""
from __future__ import annotations
import argparse
import sys
import threading
from typing import Optional, Sequence
from . import __version__
from .bootstrap import LED_PACKAGES, ensure_dependencies
from .bridge import (
DEFAULT_HOST,
DEFAULT_PORT,
DEFAULT_TAP_TTL,
MODE_LOGIN,
VALID_MODES,
EventHub,
create_server,
)
from .card import (
BAR,
CANDIDATE_PORTS,
DEFAULT_HEARTBEAT_INTERVAL,
EVENT_INVALID,
CardEvent,
format_event,
)
from .demo import run_demo
from .led import DEFAULT_LED_PIN, MODES as LED_MODES, create as create_led
from .ports import AmbiguousPort, PortNotFound, discover, resolve
from .reader import available, explain_error, list_ports, open_reader, reload_library
EXIT_OK = 0
EXIT_NO_PORT = 1
EXIT_NO_LIBRARY = 2
EXIT_PORT_AMBIGUOUS = 3
def _make_printer(as_json: bool):
"""Return a callback that writes one event to stdout."""
def print_event(event: CardEvent) -> None:
if as_json:
print(event.to_json(), flush=True)
else:
print(format_event(event), flush=True)
return print_event
def _show_ports(verbose: bool = False) -> int:
info = list_ports()
print("Candidate serial ports (tried in this order):")
for port, exists in info["candidates"]:
print(" {} {}".format("OK " if exists else "---", port))
ports = discover()
if ports:
print("\nIdentified serial ports (best candidate first):")
for entry in sorted(ports, key=lambda p: (-p.score(), p.device)):
print(" [score {:>3}] {}".format(entry.score(), entry.describe()))
if verbose:
stable = entry.stable_id or "(none)"
print(" by-id : {}".format(stable))
print(" by-path : {}".format(entry.by_path or "(none)"))
print(" location : {}".format(entry.location or "(unknown)"))
print(" usb id : {}".format(entry.usb_id or "(not USB)"))
print(" driver : {}".format(entry.product or "(unknown)"))
else:
print("\n(no serial ports identified)")
print("\nTip: pass --port with a serial number or by-id path so the same")
print(" physical adapter is always used, e.g.")
print(" --port /dev/serial/by-id/usb-1a86_USB_Serial-if00-port0")
return EXIT_OK
def _resolve_port(args) -> str:
"""Work out which serial port to open, or exit with a clear reason."""
selectors = [args.port] if args.port else None
try:
device, reason = resolve(
selectors=selectors,
fallback_ports=[args.port] if args.port else list(CANDIDATE_PORTS),
autodetect=args.autodetect,
)
except AmbiguousPort as exc:
print("x {}".format(exc), file=sys.stderr)
print(" Refusing to guess which reader is the right one.", file=sys.stderr)
sys.exit(EXIT_PORT_AMBIGUOUS)
except PortNotFound as exc:
print("x {}".format(exc), file=sys.stderr)
sys.exit(EXIT_NO_PORT)
print("-> Using {} ({})".format(device, reason), flush=True)
return device
def _ensure_library(args) -> bool:
"""Make sure the reader library is importable, installing it if needed.
A fresh device should not need manual pip work, so this installs on first
run. It is skipped entirely when the library is already present.
"""
if available():
return True
print("Reader library not found - checking dependencies...", flush=True)
installed = ensure_dependencies(
wheel_dir=args.wheel_dir,
auto_install=not args.no_install_deps,
logger=print,
)
if installed:
# The import happened at process start, so refresh it now.
reload_library()
if available():
return True
# Flush stdout first: stderr is unbuffered while stdout is block-buffered
# when piped, so without this the explanation can appear after the error.
sys.stdout.flush()
print("\nERROR: the 'rdm6300' library is not available.", file=sys.stderr)
print(" Retry with network access, or run with --demo to see the"
" output format.", file=sys.stderr)
return False
def _build_led(args):
"""Set up the status LED, or a no-op when GPIO is unusable.
Never fatal: a reader that refuses to start because a LED cannot be driven
would be worse than one with no light.
"""
if args.no_led:
return create_led(enabled=False)
# Only pull in gpiozero when a LED was actually asked for, so a headless
# install does not grow the dependency set.
ensure_dependencies(
packages=LED_PACKAGES,
wheel_dir=args.wheel_dir,
auto_install=not args.no_install_deps,
logger=print,
no_deps=True,
)
return create_led(
pin=args.led_pin,
enabled=True,
mode=args.led_mode,
error_blink=not args.led_no_error_blink,
logger=lambda message: print(" {}".format(message)),
)
def _run_bridge(args) -> int:
"""Serve what the reader sees over HTTP for a web app to consume."""
if not _ensure_library(args):
return EXIT_NO_LIBRARY
if not args.allow_remote and args.host not in ("127.0.0.1", "::1", "localhost"):
print("ERROR: refusing to bind {} without --allow-remote.".format(args.host),
file=sys.stderr)
print(" A card-event endpoint on the LAN exposes who taps where.",
file=sys.stderr)
return 1
device = _resolve_port(args)
hub = EventHub(mode=args.mode, tap_ttl=args.tap_ttl)
hub.set_port(device)
led = _build_led(args)
def emit(event: CardEvent) -> None:
# Drive the LED first: it is queued, so it never delays the reader.
if event.event == EVENT_INVALID:
led.error()
elif event.event == "card_removed":
led.remove()
else:
led.insert()
hub.publish(event)
if args.verbose:
print(format_event(event), flush=True)
def report_error(port: str, message: str) -> None:
print(" x {}: {}".format(port, explain_error(port, message)), flush=True)
reader = open_reader(emit, ports=[device],
heartbeat_interval=args.heartbeat,
on_error=report_error)
if reader is None:
print("x Could not open {}. Is another process using it?".format(device),
file=sys.stderr)
return EXIT_NO_PORT
server = create_server(
hub,
host=args.host,
port=args.bridge_port,
allowed_origins=args.allow_origin,
token=args.token,
logger=print if args.verbose else None,
)
server_thread = threading.Thread(target=server.serve_forever, daemon=True,
name="bridge-http")
server_thread.start()
print(" OK reader on {} ; bridge on http://{}:{} (mode: {})".format(
device, args.host, args.bridge_port, args.mode))
print(" GET /api/card <- the card on the reader right now")
print(" POST /api/card/claim (single use, for one login)")
print(" GET /api/events (SSE stream)")
print(" GET /api/last /api/health")
if args.token:
print(" token required for non-local callers")
if not args.allow_origin:
print(" note: cross-origin browser requests are refused")
print(" pass --allow-origin https://your.app to permit one")
print(" Ctrl+C to stop.", flush=True)
# The reader loop blocks this thread; the HTTP server runs alongside it.
try:
reader.start()
except KeyboardInterrupt:
print("\nStopped by user.")
finally:
try:
reader.close()
except Exception:
pass
led.close()
server.shutdown()
server.server_close()
return EXIT_OK
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="rdm-card-reader",
description="Read RFID cards (RDM6300 / EM4100) and print the card "
"inserted and card removed output.",
)
parser.add_argument("--version", action="version",
version="%(prog)s {}".format(__version__))
parser.add_argument("--port", help="serial port: device path, by-id symlink, "
"USB serial number or vid:pid")
parser.add_argument("--autodetect", action="store_true",
help="pick the only USB serial adapter; refuse if unsure")
parser.add_argument("--heartbeat", type=float,
default=DEFAULT_HEARTBEAT_INTERVAL,
help="seconds without a read before the card is reported "
"as removed (default: %(default)s)")
parser.add_argument("--json", action="store_true",
help="print one JSON object per event")
parser.add_argument("--list-ports", action="store_true",
help="list candidate and identified ports, then exit")
parser.add_argument("--verbose-ports", action="store_true",
help="with --list-ports, show by-id / by-path detail")
parser.add_argument("--demo", action="store_true",
help="simulate card reads (needs no hardware or library)")
parser.add_argument("--demo-count", type=int, default=3,
help="number of simulated reads in --demo (default: %(default)s)")
parser.add_argument("--no-install-deps", action="store_true",
help="do not install missing reader libraries automatically")
parser.add_argument("--wheel-dir", metavar="DIR",
help="install missing libraries from local wheels in DIR "
"instead of PyPI (offline install)")
bridge = parser.add_argument_group("HTTP bridge (--serve)")
bridge.add_argument("--serve", action="store_true",
help="serve card events over HTTP for a web app")
bridge.add_argument("--host", default=DEFAULT_HOST,
help="bind address (default: %(default)s, loopback only)")
bridge.add_argument("--bridge-port", type=int, default=DEFAULT_PORT,
help="HTTP port (default: %(default)s)")
bridge.add_argument("--mode", default=MODE_LOGIN, choices=list(VALID_MODES),
help="login: publish taps only; monitor: taps and removals "
"(default: %(default)s)")
bridge.add_argument("--tap-ttl", type=float, default=DEFAULT_TAP_TTL,
help="seconds an unclaimed tap stays claimable; 0 keeps it "
"claimable while the card is present (default: %(default)s)")
bridge.add_argument("--allow-origin", action="append", metavar="ORIGIN",
help="permit this browser origin to call the API; repeatable")
bridge.add_argument("--token",
help="pre-shared token required from non-local callers "
"(or set RDM_BRIDGE_TOKEN)")
bridge.add_argument("--allow-remote", action="store_true",
help="permit binding a non-loopback address")
bridge.add_argument("--verbose", action="store_true",
help="also print each event and each HTTP request")
led = parser.add_argument_group("status LED")
led.add_argument("--no-led", action="store_true",
help="do not drive a status LED")
led.add_argument("--led-pin", type=int, default=DEFAULT_LED_PIN,
help="GPIO pin of the status LED (default: %(default)s, "
"matching the workstation app)")
led.add_argument("--led-mode", choices=list(LED_MODES), default=LED_MODES[0],
help="'error' (default): lit whenever the reader is running, "
"blinking means a bad read; 'presence': lit only while "
"a card is on the reader")
led.add_argument("--led-no-error-blink", action="store_true",
help="do not blink the LED on a bad read")
return parser
def main(argv: Optional[Sequence[str]] = None) -> int:
args = build_parser().parse_args(argv)
if args.list_ports:
return _show_ports(verbose=args.verbose_ports)
if args.demo:
return run_demo(count=args.demo_count, heartbeat=args.heartbeat,
as_json=args.json)
if args.serve:
return _run_bridge(args)
if not _ensure_library(args):
return EXIT_NO_LIBRARY
led = _build_led(args)
print_event = _make_printer(args.json)
ports = [args.port] if args.port else list(CANDIDATE_PORTS)
def report_error(port: str, message: str) -> None:
print(" x {}: {}".format(port, explain_error(port, message)), flush=True)
def on_event(event: CardEvent) -> None:
# LED first: it is queued, so it never delays the reader loop.
if event.event == EVENT_INVALID:
led.error()
elif event.event == "card_removed":
led.remove()
else:
led.insert()
print_event(event)
for port in ports:
print("-> Opening {} ...".format(port), flush=True)
reader = open_reader(on_event, ports=ports,
heartbeat_interval=args.heartbeat,
on_error=report_error)
if reader is None:
led.close()
print("\nx Could not open any serial port. Check:", file=sys.stderr)
print(" 1. the reader is connected (rdm-card-reader --list-ports)",
file=sys.stderr)
print(" 2. UART is enabled: sudo raspi-config -> Interface Options -> Serial",
file=sys.stderr)
print(" 3. the user is in dialout: sudo usermod -aG dialout $USER",
file=sys.stderr)
return EXIT_NO_PORT
print(" OK reader ready on {} - waiting for cards (Ctrl+C to stop)".format(
reader.port), flush=True)
try:
reader.start() # blocks; events arrive through the callback
except KeyboardInterrupt:
print("\nStopped by user.")
finally:
try:
reader.close()
except Exception:
pass
led.close()
return EXIT_OK
if __name__ == "__main__": # pragma: no cover
sys.exit(main())
+49
View File
@@ -0,0 +1,49 @@
"""Simulated card reads, so the output can be checked without hardware."""
from __future__ import annotations
import random
import time
from .card import (
DEFAULT_HEARTBEAT_INTERVAL,
CardData,
CardEvent,
format_event,
)
# Card ids with the checksum the real protocol would produce
# (XOR of the type byte and the four id bytes), so the output looks genuine.
DEMO_CARDS = (
CardData(value=0x00C4A0B5, checksum=0xDE, type=0x0F, is_valid=True),
CardData(value=0x1A2B3C4D, checksum=0x4F, type=0x0F, is_valid=True),
CardData(value=0xDEADBEEF, checksum=0x00, type=0x0F, is_valid=False),
)
def _emit(event: CardEvent, as_json: bool) -> None:
print(event.to_json() if as_json else format_event(event), flush=True)
def run_demo(
count: int = 3,
heartbeat: float = DEFAULT_HEARTBEAT_INTERVAL,
as_json: bool = False,
seed: int = 1234,
) -> int:
"""Print *count* simulated card read cycles. Returns a process exit code."""
rng = random.Random(seed)
print("Demo mode - no hardware is used, values are simulated.\n", flush=True)
for _ in range(max(1, count)):
card = rng.choice(DEMO_CARDS)
event_name = "card_inserted" if card.is_valid else "invalid_card"
_emit(CardEvent.from_card(event_name, card, "demo"), as_json)
time.sleep(max(0.1, heartbeat))
if card.is_valid:
_emit(CardEvent.from_card("card_removed", card, "demo"), as_json)
time.sleep(0.3)
print("\nDemo finished.", flush=True)
return 0
+314
View File
@@ -0,0 +1,314 @@
"""Drive a status LED from card events.
The workstation app lit an LED (GPIO 23) with the card: on when a card was
inserted, off when it was removed. This reproduces that, with three deliberate
differences:
* It never blocks the reader. WMT toggled the LED and slept on the reader
thread, which stalls card detection; here every change is queued to a worker.
* It cannot crash the process. On a machine without GPIO, turning the LED on
raises ``BadPinFactory``. WMT worked around this with a ``DummyLED``; this
keeps the same idea but also handles a pin that disappears at runtime.
* It is explicit. Whether an LED is actually attached is reported, so "no light
came on" is distinguishable from "the card was not read".
The default pin (23) matches the workstation app. **Use a separate LED from the
workstation's if both run on one device**, since two processes driving one pin
will fight.
"""
from __future__ import annotations
import queue
import threading
from dataclasses import dataclass
from typing import Any, Callable, Optional
#: GPIO pin of the status LED. Chosen to match the workstation app.
DEFAULT_LED_PIN = 23
#: How long to hold an error indication, in seconds.
ERROR_BLINK_SECONDS = 1.0
OFF = "off"
ON = "on"
BLINK = "blink"
#: Lit the whole time the app runs, and blinks on a bad read. This is a health
#: indicator: light on means the reader is alive and ready for a card.
ERROR = "error"
#: Lit only while a card is on the reader, and off otherwise. This is a card
#: indicator, for when the light should mean "a card is here".
PRESENCE = "presence"
MODES = (ERROR, PRESENCE)
#: Kept for the occasional caller that only cares about the concrete commands.
_VALID_MODES = (OFF, ON, BLINK)
@dataclass
class LEDStatus:
"""What the LED subsystem is actually doing."""
enabled: bool
pin: Optional[int]
backend: str # "gpiozero" | "null"
mode: str = ERROR
state: bool = False
reason: str = ""
def to_dict(self) -> dict:
return {
"enabled": self.enabled,
"pin": self.pin,
"backend": self.backend,
"mode": self.mode,
"state": "on" if self.state else "off",
"reason": self.reason,
}
class NullLED:
"""A LED that does nothing, used when there is no usable GPIO.
Kept deliberately quiet: the reason it is inert is reported once at
startup, not on every card.
"""
backend = "null"
def __init__(self, pin: Optional[int], reason: str = ""):
self.pin = pin
self.reason = reason
def on(self) -> None:
pass
def off(self) -> None:
pass
def close(self) -> None:
pass
class GPIOLED:
"""A real LED on a GPIO pin, via gpiozero."""
backend = "gpiozero"
def __init__(self, pin: int, active_high: bool = True):
from gpiozero import LED as _LED
self.pin = pin
self._led = _LED(pin, active_high=active_high)
self._is_on = False
def on(self) -> None:
self._led.on()
self._is_on = True
def off(self) -> None:
self._led.off()
self._is_on = False
def close(self) -> None:
try:
self._led.close()
except Exception:
pass
class _Blinker(threading.Thread):
"""Repeatedly toggles a LED, for an error indication."""
def __init__(self, led: Any, period: float = 0.15):
super().__init__(daemon=True, name="led-blink")
self._led = led
self._period = period
self._stop = threading.Event()
def run(self) -> None:
state = False
while not self._stop.is_set():
try:
self._led.on() if state else self._led.off()
except Exception:
return
state = not state
self._stop.wait(self._period)
try:
self._led.off()
except Exception:
pass
def stop(self) -> None:
self._stop.set()
class StatusLED:
"""Non-blocking LED controller driven by card events.
Commands are queued and applied by a worker thread, so calling :meth:`insert`
from the reader callback never blocks card detection.
"""
def __init__(
self,
pin: int = DEFAULT_LED_PIN,
enabled: bool = True,
mode: str = ERROR,
active_high: bool = True,
error_blink: bool = True,
logger: Optional[Callable[[str], None]] = None,
):
self._log = logger or (lambda message: None)
self._mode = mode if mode in MODES else ERROR
self._error_blink = error_blink
self._enabled = enabled
self._blinker: Optional[_Blinker] = None
self._state = False
self._queue: "queue.Queue[Optional[str]]" = queue.Queue()
self._thread: Optional[threading.Thread] = None
if not enabled:
self.led: Any = NullLED(None, "LED disabled (--no-led)")
self.status = LEDStatus(False, None, "null", self._mode, False,
"disabled")
return
# Check the library separately from building the pin.
#
# gpiozero raises BadPinFactory when no pin provider works, and that is
# a subclass of ImportError, so catching ImportError around the pin
# construction would report "gpiozero is not installed" even when it is
# installed and simply has no usable pin library. On a Pi missing lgpio
# or RPi.GPIO that message sends you to the wrong fix.
try:
import gpiozero # noqa: F401
except ImportError as exc:
reason = "gpiozero not installed: {}".format(exc)
self.led = NullLED(pin, reason)
self.status = LEDStatus(False, pin, "null", self._mode, False,
reason)
self._log("LED unavailable: {}".format(reason))
else:
try:
self.led = GPIOLED(pin, active_high=active_high)
self.status = LEDStatus(True, pin, "gpiozero", self._mode,
False, "ready")
self._log("LED on GPIO {} ({} mode, gpiozero)".format(
pin, self._mode))
except Exception as exc:
# No usable pin factory (not a Pi), pin already in use, bad pin.
reason = ("no usable GPIO on pin {}: {}".format(pin, exc))
self.led = NullLED(pin, reason)
self.status = LEDStatus(False, pin, "null", self._mode, False,
reason)
self._log("LED unavailable: {}".format(reason))
if self.status.enabled and self._mode == ERROR:
# A health indicator shows 'alive' immediately. In presence mode the
# light means 'a card is here', so it must stay dark until one is.
self.led.on()
self._set_state(True)
self.status.state = True
self._thread = threading.Thread(target=self._worker, daemon=True,
name="led-worker")
self._thread.start()
# -- event hooks ------------------------------------------------------
def insert(self) -> None:
"""A valid card was read: off in 'error' mode, on in 'presence' mode."""
self._send(OFF if self._mode == ERROR else ON)
def remove(self) -> None:
"""The card left the reader: dark in 'presence' mode; 'error' mode stays on."""
self._send(ON if self._mode == ERROR else OFF)
def error(self) -> None:
"""A bad read: blink, if blinking is enabled."""
self._send(BLINK if self._error_blink else ON)
# -- control ----------------------------------------------------------
def _send(self, command: str) -> None:
if not self.status.enabled:
return
try:
self._queue.put_nowait(command)
except Exception:
pass
def _worker(self) -> None:
while True:
command = self._queue.get()
if command is None:
return
try:
self._apply(command)
except Exception as exc:
# A failed toggle must never take the reader down.
self._log("LED update failed: {}".format(exc))
finally:
self._queue.task_done()
def _apply(self, command: str) -> None:
# Blinking is used only for a bad read, and only by 'error' mode.
if command == BLINK and self._mode == ERROR:
self._stop_blink()
self._blinker = _Blinker(self.led)
self._blinker.start()
return
self._stop_blink() # a steady command cancels any blink
# Two readings of the light, hence two modes:
# error - a health indicator: lit the whole time the reader runs,
# so "light on" means "ready for a card".
# presence - a card indicator: lit only while a card is on the reader.
if command == OFF:
self.led.off()
self._set_state(False)
elif command == ON:
self.led.on()
self._set_state(True)
def _stop_blink(self) -> None:
if self._blinker is not None:
self._blinker.stop()
self._blinker = None
def _set_state(self, value: bool) -> None:
self._state = value
self.status.state = value
def close(self) -> None:
self._stop_blink()
if self._thread is not None:
self._queue.put(None)
try:
self.led.off()
except Exception:
pass
self._set_state(False)
try:
self.led.close()
except Exception:
pass
def create(
enabled: bool = True,
pin: int = DEFAULT_LED_PIN,
mode: str = ERROR,
error_blink: bool = True,
logger: Optional[Callable[[str], None]] = None,
) -> StatusLED:
"""Build the LED controller, degrading to a no-op when GPIO is unusable."""
return StatusLED(pin=pin, enabled=enabled, mode=mode,
error_blink=error_blink, logger=logger)
+397
View File
@@ -0,0 +1,397 @@
"""Identify serial ports reliably, so the reader does not grab the wrong ttyUSB.
A bare ``/dev/ttyUSB0`` is not a stable identity: unplug two adapters and the
numbers swap. This module answers "is this really the RFID reader?" using the
things that *are* stable:
* the USB serial number (``/dev/serial/by-id``)
* the physical USB port (``/dev/serial/by-path``)
* the USB vendor:product id and the adapter description
Use :func:`resolve` to turn a user-supplied selector into exactly one port,
refusing to guess when the answer is ambiguous.
"""
from __future__ import annotations
import glob
import os
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Sequence, Tuple
#: USB-serial bridge chips commonly found on RDM6300 / Arduino adapters.
#: Mapping of ``vid:pid`` -> friendly name. Used for scoring, not required.
KNOWN_USB_SERIAL_CHIPS: Dict[Tuple[int, int], str] = {
(0x1A86, 0x7523): "CH340",
(0x1A86, 0x5523): "CH341",
(0x1A86, 0x55D4): "CH9102",
(0x0403, 0x6001): "FT232R",
(0x0403, 0x6015): "FT231X",
(0x10C4, 0xEA60): "CP2102",
(0x10C4, 0xEA70): "CP2105",
(0x067B, 0x2303): "PL2303",
(0x2341, 0x0043): "Arduino Uno",
(0x1A86, 0x55D3): "CH343",
}
#: Description fragments that suggest a generic UART bridge.
_SERIAL_HINTS = ("serial", "uart", "usb-serial", "usbserial", "ttyusb", "ttyacm")
class PortError(RuntimeError):
"""Base class for port selection problems."""
class PortNotFound(PortError):
"""No port matched the requested selector."""
class AmbiguousPort(PortError):
"""More than one port matched; picking one silently would be unsafe."""
@dataclass
class PortInfo:
"""Everything stable we can learn about one serial port."""
device: str
description: str = ""
hwid: str = ""
vid: Optional[int] = None
pid: Optional[int] = None
serial_number: Optional[str] = None
manufacturer: Optional[str] = None
product: Optional[str] = None
location: Optional[str] = None
aliases: List[str] = field(default_factory=list)
# -- derived ----------------------------------------------------------
@property
def is_usb(self) -> bool:
"""True when this port is a USB device (as opposed to an on-board UART)."""
return self.vid is not None or "usb" in self.device.lower()
@property
def usb_id(self) -> Optional[str]:
"""``"1a86:7523"`` when both ids are known."""
if self.vid is None or self.pid is None:
return None
return "{:04x}:{:04x}".format(self.vid, self.pid)
@property
def chip(self) -> Optional[str]:
"""Friendly name of the USB-serial bridge, when recognised."""
if self.vid is None or self.pid is None:
return None
return KNOWN_USB_SERIAL_CHIPS.get((self.vid, self.pid))
@property
def stable_id(self) -> Optional[str]:
"""A ``/dev/serial/by-id`` path, when the kernel provides one.
This is the value to persist in a config file - it survives replugging
and reordering.
"""
for alias in self.aliases:
if "/by-id/" in alias:
return alias
return None
@property
def by_path(self) -> Optional[str]:
"""A ``/dev/serial/by-path`` path - stable per physical USB socket."""
for alias in self.aliases:
if "/by-path/" in alias:
return alias
return None
def score(self) -> int:
"""Likely-to-be-an-RFID-adapter score. Higher is better.
Deliberately conservative: a plain on-board ``/dev/ttyS0`` scores low
so that a real USB adapter wins the autodetect race.
"""
value = 0
if self.chip:
value += 60
elif self.is_usb:
value += 40
if self.serial_number:
value += 20 # uniquely identifiable
if self.stable_id:
value += 10
if any(hint in self.description.lower() for hint in _SERIAL_HINTS):
value += 10
if self.device.startswith("/dev/serial"):
value += 15 # kernel-managed alias
if self.device.startswith("/dev/ttyS"):
value += 5 # on-board UART, still valid but less specific
return value
def _haystacks(self) -> List[str]:
parts = [
self.device,
self.description,
self.hwid,
self.manufacturer or "",
self.product or "",
self.serial_number or "",
self.usb_id or "",
]
parts.extend(self.aliases)
return [p.lower() for p in parts if p]
def matches(self, selector: str) -> bool:
"""True when *selector* identifies this port.
Accepts a device path, a ``by-id``/``by-path`` symlink, a serial
number, ``vid:pid``, a manufacturer/product fragment or a chip name.
"""
needle = selector.strip().lower()
if not needle:
return False
if self.device.lower() == needle:
return True
if self.serial_number and self.serial_number.lower() == needle:
return True
if self.usb_id and self.usb_id == needle:
return True
if self.chip and self.chip.lower() == needle:
return True
return any(needle in hay for hay in self._haystacks())
def to_dict(self) -> Dict[str, Any]:
return {
"device": self.device,
"description": self.description,
"usb_id": self.usb_id,
"chip": self.chip,
"serial_number": self.serial_number,
"manufacturer": self.manufacturer,
"product": self.product,
"location": self.location,
"stable_id": self.stable_id,
"by_path": self.by_path,
"is_usb": self.is_usb,
"score": self.score(),
}
def describe(self) -> str:
"""One-line summary for ``--list-ports``."""
bits = [self.device]
if self.chip:
bits.append(self.chip)
elif self.description:
bits.append(self.description)
if self.usb_id:
bits.append(self.usb_id)
if self.serial_number:
bits.append("S/N {}".format(self.serial_number))
return " :: ".join(bits)
# ---------------------------------------------------------------------------
# Discovery
# ---------------------------------------------------------------------------
def _aliases_for(device: str) -> List[str]:
"""Collect ``/dev/serial/by-id`` and ``by-path`` symlinks pointing at *device*."""
found: List[str] = []
for folder in ("by-id", "by-path"):
pattern = "/dev/serial/{}/**".format(folder)
for link in sorted(glob.glob(pattern, recursive=True)):
try:
if os.path.realpath(link) == os.path.realpath(device):
found.append(link)
except OSError:
continue
return found
def _info_from_sysfs(device: str) -> PortInfo:
"""Fallback identification straight from sysfs (no pyserial needed)."""
info = PortInfo(device=device)
try:
info.aliases = _aliases_for(device)
except OSError:
pass
name = os.path.basename(device)
base = "/sys/class/tty/{}/device".format(name)
if not os.path.exists(base):
return info
def _read(relpath: str) -> Optional[str]:
try:
with open(os.path.join(base, relpath), "r") as handle:
return handle.read().strip()
except OSError:
return None
vid = _read("../idVendor")
pid = _read("../idProduct")
if vid:
try:
info.vid = int(vid, 16)
except ValueError:
pass
if pid:
try:
info.pid = int(pid, 16)
except ValueError:
pass
info.serial_number = _read("../serial")
info.manufacturer = _read("../manufacturer")
info.product = _read("../product")
interface = _read("interface")
if interface:
info.description = interface
return info
def discover() -> List[PortInfo]:
"""List every serial port with its stable identity.
Prefers pyserial's enumeration (richer metadata) and falls back to scanning
``/dev`` plus sysfs, so it still works when pyserial is absent.
"""
ports: List[PortInfo] = []
try:
from serial.tools import list_ports as serial_list_ports
for entry in serial_list_ports.comports():
info = PortInfo(
device=entry.device,
description=entry.description or "",
hwid=entry.hwid or "",
vid=getattr(entry, "vid", None),
pid=getattr(entry, "pid", None),
serial_number=getattr(entry, "serial_number", None),
manufacturer=getattr(entry, "manufacturer", None),
product=getattr(entry, "product", None),
location=getattr(entry, "location", None),
)
info.aliases = _aliases_for(entry.device)
ports.append(info)
if ports:
return ports
except Exception:
pass
# Fallback: scan /dev for tty devices, enriching from sysfs.
candidates = sorted(glob.glob("/dev/ttyUSB*")) + sorted(glob.glob("/dev/ttyACM*"))
candidates += sorted(glob.glob("/dev/ttyS*")) + sorted(glob.glob("/dev/ttyAMA*"))
candidates += sorted(glob.glob("/dev/serial/by-id/*"))
seen = set()
for device in candidates:
real = os.path.realpath(device)
if real in seen:
continue
seen.add(real)
ports.append(_info_from_sysfs(device))
return ports
def best_usb_candidates(ports: Sequence[PortInfo]) -> List[PortInfo]:
"""USB-serial ports sorted most-likely-first, duplicates by identity removed."""
usb = [p for p in ports if p.is_usb]
usb.sort(key=lambda p: (-p.score(), p.device))
unique: List[PortInfo] = []
identities = set()
for port in usb:
identity = port.stable_id or port.usb_id or port.device
if port.serial_number:
identity = "{}|{}".format(port.usb_id, port.serial_number)
if identity in identities:
continue
identities.add(identity)
unique.append(port)
return unique
# ---------------------------------------------------------------------------
# Resolution
# ---------------------------------------------------------------------------
def resolve(
selectors: Optional[Sequence[str]] = None,
fallback_ports: Optional[Sequence[str]] = None,
autodetect: bool = False,
) -> Tuple[str, str]:
"""Decide which port to open.
Returns ``(device, reason)`` where *reason* explains the choice so the
caller can log it - being explicit about *why* a port was picked is the
whole point of this module.
Raises :class:`PortNotFound` or :class:`AmbiguousPort` rather than
guessing.
"""
ports = discover()
# 1. Explicit selectors always win.
if selectors:
matched: List[PortInfo] = []
for selector in selectors:
for port in ports:
if port.matches(selector) and port not in matched:
matched.append(port)
if not matched:
# The selector may be a raw path pyserial cannot enumerate.
for selector in selectors:
if os.path.exists(selector):
return selector, "explicit path {}".format(selector)
available = ", ".join(p.device for p in ports) or "none detected"
raise PortNotFound(
"no port matched {}. Detected ports: {}".format(
", ".join(repr(s) for s in selectors), available
)
)
if len(matched) > 1:
raise AmbiguousPort(
"{} ports matched: {}. Use --port with a serial number or "
"by-id path, or --list-ports to see them.".format(
len(matched), ", ".join(p.describe() for p in matched)
)
)
port = matched[0]
return port.device, "matched {} ({})".format(
port.stable_id or port.usb_id or "selector", port.describe()
)
# 2. Autodetect: only trust a single unambiguous USB adapter.
if autodetect:
candidates = best_usb_candidates(ports)
if len(candidates) == 1:
port = candidates[0]
return port.device, "autodetected {} (score {})".format(
port.describe(), port.score()
)
if len(candidates) > 1:
raise AmbiguousPort(
"{} USB serial ports found; refusing to guess. Use --port or "
"set a serial_number in the config. Candidates: {}".format(
len(candidates), ", ".join(p.describe() for p in candidates)
)
)
# 3. Fall back to the fixed order, which is what the workstation app used.
for device in list(fallback_ports or []):
if os.path.exists(device):
return device, "first available of the default order"
raise PortNotFound(
"no serial port found. Detected: {}".format(
", ".join(p.describe() for p in ports) or "none"
)
)
View File
+129
View File
@@ -0,0 +1,129 @@
"""Serial-port handling: open the RDM6300 and turn reads into events.
The reader library is a hard dependency at runtime but is imported lazily so
that ``--demo`` and the unit tests keep working without it installed.
"""
from __future__ import annotations
import importlib
import os
from typing import Callable, Dict, List, Optional, Sequence
from .card import CANDIDATE_PORTS, DEFAULT_HEARTBEAT_INTERVAL, CardEvent
try: # pragma: no cover - depends on the environment
import rdm6300
except ImportError: # pragma: no cover
rdm6300 = None # type: ignore[assignment]
#: Called once per card event. Keep it fast - it runs on the reader thread.
EventCallback = Callable[[CardEvent], None]
class ReaderUnavailable(RuntimeError):
"""Raised when the ``rdm6300`` library is not installed."""
def available() -> bool:
"""True when the ``rdm6300`` library can be imported."""
return rdm6300 is not None
def reload_library() -> bool:
"""Re-attempt the import, for when the library was installed at runtime.
The module-level import above runs once at process start, so installing
afterwards is invisible until this is called.
"""
global rdm6300
if rdm6300 is not None:
return True
try:
rdm6300 = importlib.import_module("rdm6300")
except ImportError:
rdm6300 = None
return False
return True
def _make_reader_class(callback: EventCallback):
"""Build a ``BaseReader`` subclass that reports events via *callback*."""
if rdm6300 is None:
raise ReaderUnavailable(
"the 'rdm6300' library is not installed (pip install rdm6300)"
)
class CardReader(rdm6300.BaseReader): # type: ignore[misc, valid-type]
"""Fires *callback* for each insert, removal and checksum failure."""
def card_inserted(self, card) -> None:
callback(CardEvent.from_card("card_inserted", card, self.port))
def card_removed(self, card) -> None:
if card is None:
return
callback(CardEvent.from_card("card_removed", card, self.port))
def invalid_card(self, card) -> None:
callback(CardEvent.from_card("invalid_card", card, self.port))
return CardReader
def open_reader(
callback: EventCallback,
ports: Optional[Sequence[str]] = None,
heartbeat_interval: Optional[float] = DEFAULT_HEARTBEAT_INTERVAL,
on_error: Optional[Callable[[str, str], None]] = None,
):
"""Open the first usable port and return the reader (not yet started).
Returns ``None`` when no port could be opened. *on_error* receives
``(port, message)`` for each failed attempt so the caller can print or log
the reason.
"""
candidates = list(ports) if ports else list(CANDIDATE_PORTS)
ReaderClass = _make_reader_class(callback)
for port in candidates:
try:
reader = ReaderClass(port, heartbeat_interval=heartbeat_interval)
except Exception as exc: # port missing, permission denied, busy, ...
if on_error is not None:
on_error(port, str(exc))
continue
return reader
return None
def explain_error(port: str, message: str) -> str:
"""Turn a pyserial error into an actionable hint."""
lowered = message.lower()
if "permission denied" in lowered:
return (
"permission denied - add the user to the 'dialout' group: "
"sudo usermod -aG dialout $USER (then log in again)"
)
if "no such file" in lowered or "could not open port" in lowered:
return "device not found"
return message
def list_ports() -> Dict[str, List]:
"""Report candidate ports and whatever pyserial can enumerate."""
candidates = [(port, os.path.exists(port)) for port in CANDIDATE_PORTS]
detected: List[str] = []
try:
from serial.tools import list_ports as serial_list_ports
detected = [
"{} ({})".format(info.device, info.description)
for info in serial_list_ports.comports()
]
except Exception:
detected = []
return {"candidates": candidates, "detected": detected}
+4
View File
@@ -0,0 +1,4 @@
0e60d743a6b8071498a56465f7719c96a5e92928f858bab1be2a0d606c9aa0f8 colorzero-2.0-py2.py3-none-any.whl
d491734803a9bc6036602e6b8bbd73fb25c33da88de0fcd11a40e890b8bc2d3b gpiozero-2.0.1.post3-py3-none-any.whl
c4451db6ba391ca6ca299fb3ec7bae67a5c55dde170964c7a14ceefec02f2cf0 pyserial-3.5-py2.py3-none-any.whl
afb067da8b8d23a477138ca67d4ff1d198f12d157b64668c9dd3cbd7df54e74e rdm6300-0.1.1-py3-none-any.whl
View File
+8
View File
@@ -0,0 +1,8 @@
import os
import sys
SRC = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "src"
)
if SRC not in sys.path:
sys.path.insert(0, SRC)
+201
View File
@@ -0,0 +1,201 @@
"""Tests for the dependency bootstrap.
The behaviour that matters: a normal start must not touch the network, a
missing library should be installed once, and a failed install must report
clearly instead of crashing.
"""
import os
import sys
import tempfile
SRC = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "src"
)
if SRC not in sys.path:
sys.path.insert(0, SRC)
import pytest # noqa: E402
from rdm_card_reader import bootstrap # noqa: E402
from rdm_card_reader.bootstrap import ( # noqa: E402
_pip_command,
ensure_dependencies,
install,
missing_packages,
)
class FakeCompleted:
def __init__(self, returncode=0, stdout="", stderr=""):
self.returncode = returncode
self.stdout = stdout
self.stderr = stderr
# -- detection --------------------------------------------------------------
def test_nothing_missing_for_installed_packages():
assert missing_packages([("sys", "sys")]) == []
def test_importable_name_is_reported_missing():
result = missing_packages([("definitely_not_a_real_module_xyz", "pkg>=1")])
assert result == [("definitely_not_a_real_module_xyz", "pkg>=1")]
# -- pip command ------------------------------------------------------------
def test_pip_command_uses_this_interpreter():
command = _pip_command(["pkg>=1"], wheel_dir=None)
assert command[:3] == [sys.executable, "-m", "pip"]
assert "pkg>=1" in command
def test_pip_command_prefers_local_wheels_when_given():
command = _pip_command(["pkg>=1"], wheel_dir="/opt/wheels")
assert "--no-index" in command
assert "--find-links" in command
assert "/opt/wheels" in command
def test_pip_command_adds_break_system_packages_outside_venv(monkeypatch):
"""Debian's pip refuses to touch system Python without this flag."""
monkeypatch.delenv("VIRTUAL_ENV", raising=False)
monkeypatch.setattr(bootstrap, "in_virtualenv", lambda: False)
assert "--break-system-packages" in _pip_command(["pkg"], None)
def test_pip_command_omits_flag_inside_venv(monkeypatch):
monkeypatch.setattr(bootstrap, "in_virtualenv", lambda: True)
assert "--break-system-packages" not in _pip_command(["pkg"], None)
def test_in_virtualenv_detects_env_var(monkeypatch):
monkeypatch.setenv("VIRTUAL_ENV", "/tmp/venv")
assert bootstrap.in_virtualenv() is True
# -- install ----------------------------------------------------------------
def test_install_reports_success(monkeypatch):
monkeypatch.setattr(
bootstrap.subprocess, "run", lambda *a, **k: FakeCompleted(0)
)
assert install(["pkg"]) is True
def test_install_returns_false_on_nonzero_exit(monkeypatch):
monkeypatch.setattr(
bootstrap.subprocess, "run",
lambda *a, **k: FakeCompleted(1, stderr="ERROR: no matching distribution"),
)
lines = []
assert install(["pkg"], logger=lines.append) is False
assert any("no matching distribution" in line for line in lines)
def test_install_survives_timeout(monkeypatch):
def boom(*args, **kwargs):
raise bootstrap.subprocess.TimeoutExpired(cmd="pip", timeout=1)
monkeypatch.setattr(bootstrap.subprocess, "run", boom)
assert install(["pkg"]) is False
def test_install_survives_pip_being_unrunnable(monkeypatch):
def boom(*args, **kwargs):
raise OSError("no such file or directory")
monkeypatch.setattr(bootstrap.subprocess, "run", boom)
assert install(["pkg"]) is False
# -- ensure_dependencies ----------------------------------------------------
def test_no_install_when_everything_present(monkeypatch):
"""A normal start must not shell out to pip at all."""
called = []
monkeypatch.setattr(bootstrap, "missing_packages", lambda packages=None: [])
monkeypatch.setattr(bootstrap, "install", lambda *a, **k: called.append(1))
assert ensure_dependencies() is True
assert called == []
def test_installs_when_missing(monkeypatch):
state = {"installed": False}
monkeypatch.setattr(
bootstrap, "missing_packages",
lambda packages=None: [] if state["installed"] else [("rdm6300", "rdm6300>=0.1.1")],
)
def fake_install(requirements, wheel_dir=None, logger=None, timeout=300, no_deps=False):
state["installed"] = True
return True
monkeypatch.setattr(bootstrap, "install", fake_install)
assert ensure_dependencies() is True
def test_reports_failure_when_still_missing(monkeypatch):
monkeypatch.setattr(
bootstrap, "missing_packages",
lambda packages=None: [("rdm6300", "rdm6300>=0.1.1")],
)
monkeypatch.setattr(bootstrap, "install", lambda *a, **k: False)
lines = []
assert ensure_dependencies(logger=lines.append) is False
assert any("Install manually with" in line for line in lines)
def test_no_install_flag_reports_manual_hint(monkeypatch):
monkeypatch.setattr(
bootstrap, "missing_packages",
lambda packages=None: [("rdm6300", "rdm6300>=0.1.1")],
)
called = []
monkeypatch.setattr(bootstrap, "install", lambda *a, **k: called.append(1))
lines = []
assert ensure_dependencies(auto_install=False, logger=lines.append) is False
assert called == [] # never shelled out
assert any("disabled" in line for line in lines)
def test_wheel_dir_is_passed_through(monkeypatch):
"""An explicit --wheel-dir that covers everything is used as given."""
seen = {}
monkeypatch.setattr(
bootstrap, "missing_packages",
lambda packages=None: [] if seen.get("done") else [("x", "x>=1")],
)
def fake_install(requirements, wheel_dir=None, logger=None, timeout=300, no_deps=False):
seen["wheel_dir"] = wheel_dir
seen["done"] = True
return True
monkeypatch.setattr(bootstrap, "install", fake_install)
with tempfile.TemporaryDirectory() as folder:
open(os.path.join(folder, "x-1.0-py3-none-any.whl"), "wb").close()
ensure_dependencies(wheel_dir=folder)
assert seen["wheel_dir"] == folder
def test_incomplete_explicit_wheel_dir_is_flagged(monkeypatch):
"""An unusable --wheel-dir should be called out, not silently ignored."""
seen = {}
monkeypatch.setattr(
bootstrap, "missing_packages",
lambda packages=None: [] if seen.get("done") else [("x", "x>=1")],
)
def fake_install(requirements, wheel_dir=None, logger=None, timeout=300, no_deps=False):
seen["done"] = True
return True
monkeypatch.setattr(bootstrap, "install", fake_install)
lines = []
with tempfile.TemporaryDirectory() as folder:
ensure_dependencies(wheel_dir=folder, logger=lines.append)
assert any("you gave is incomplete" in line for line in lines)
+501
View File
@@ -0,0 +1,501 @@
"""Tests for the HTTP bridge.
Two things matter most here:
1. The bridge publishes card events without claiming to authenticate anyone.
2. A page on another origin must not be able to read your taps.
"""
import json
import os
import sys
import threading
import time
import urllib.error
import urllib.request
SRC = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "src"
)
if SRC not in sys.path:
sys.path.insert(0, SRC)
import pytest # noqa: E402
from rdm_card_reader import bridge # noqa: E402
from rdm_card_reader.bridge import ( # noqa: E402
MODE_LOGIN,
MODE_MONITOR,
EventHub,
create_server,
)
from rdm_card_reader.card import CardEvent # noqa: E402
def make_event(event="card_inserted", value=12886197):
return CardEvent(
event=event,
timestamp="2026-10-01 12:00:00",
value=value,
checksum=222,
card_type=15,
is_valid=True,
port="/dev/ttyUSB0",
)
# -- hub --------------------------------------------------------------------
def test_hub_publishes_to_subscriber():
hub = EventHub()
queue = hub.subscribe()
hub.publish(make_event())
assert len(queue) == 1
assert queue[0]["id"] == 12886197
def test_hub_login_mode_hides_removals():
"""Removal must not look like a second authentication attempt."""
hub = EventHub(mode=MODE_LOGIN)
queue = hub.subscribe()
hub.publish(make_event("card_removed"))
assert len(queue) == 0
def test_hub_monitor_mode_publishes_removals():
hub = EventHub(mode=MODE_MONITOR)
queue = hub.subscribe()
hub.publish(make_event("card_removed"))
assert len(queue) == 1
def test_hub_unsubscribe_stops_delivery():
hub = EventHub()
queue = hub.subscribe()
hub.unsubscribe(queue)
hub.publish(make_event())
assert len(queue) == 0
def test_hub_tracks_last_tap_only_on_insert():
hub = EventHub(mode=MODE_MONITOR)
hub.publish(make_event("card_inserted", value=111))
hub.publish(make_event("card_removed", value=111))
assert hub.last()["id"] == 111
def test_hub_history_is_bounded():
hub = EventHub(history=3)
for value in range(10):
hub.publish(make_event(value=value))
assert len(hub.taps(limit=99)) == 3
def test_hub_health_reports_state():
hub = EventHub()
hub.set_port("/dev/ttyUSB0")
hub.publish(make_event())
health = hub.health()
assert health["port"] == "/dev/ttyUSB0"
assert health["counts"]["card_inserted"] == 1
assert health["last_tap"]["id"] == 12886197
assert health["last_tap_age_seconds"] is not None
def test_hub_rejects_unknown_mode():
hub = EventHub()
assert hub.set_mode("bogus") is False
assert hub.mode == MODE_LOGIN
def test_hub_accepts_valid_mode_switch():
hub = EventHub()
assert hub.set_mode(MODE_MONITOR) is True
assert hub.mode == MODE_MONITOR
# -- HTTP -------------------------------------------------------------------
@pytest.fixture
def server():
"""A live bridge on an ephemeral port, torn down after each test."""
hub = EventHub()
hub.set_port("/dev/ttyUSB0")
srv = create_server(hub, host="127.0.0.1", port=0)
thread = threading.Thread(target=srv.serve_forever, daemon=True)
thread.start()
yield srv, hub
srv.shutdown()
srv.server_close()
def base_url(srv):
host, port = srv.server_address[:2]
return "http://{}:{}".format(host, port)
def get(srv, path, headers=None):
request = urllib.request.Request(base_url(srv) + path, headers=headers or {})
with urllib.request.urlopen(request, timeout=5) as response:
return response.status, response.headers, response.read()
def test_health_endpoint(server):
srv, hub = server
status, _, body = get(srv, "/api/health")
assert status == 200
payload = json.loads(body)
assert payload["status"] == "ok"
assert payload["port"] == "/dev/ttyUSB0"
assert payload["mode"] == MODE_LOGIN
def test_last_returns_204_when_nothing_read(server):
srv, _ = server
status, _, _ = get(srv, "/api/last")
assert status == 204
def test_last_returns_the_tap(server):
srv, hub = server
hub.publish(make_event())
status, _, body = get(srv, "/api/last")
assert status == 200
assert json.loads(body)["id"] == 12886197
def test_taps_endpoint_honours_limit(server):
srv, hub = server
for value in range(5):
hub.publish(make_event(value=value))
_, _, body = get(srv, "/api/taps?limit=2")
assert len(json.loads(body)["taps"]) == 2
def test_unknown_path_is_404(server):
srv, _ = server
with pytest.raises(urllib.error.HTTPError) as excinfo:
get(srv, "/api/nope")
assert excinfo.value.code == 404
def test_health_available_without_origin(server):
"""Monitoring must work for a plain client with no Origin header."""
srv, _ = server
status, _, _ = get(srv, "/api/health")
assert status == 200
# -- origin policy ----------------------------------------------------------
def test_cross_origin_refused_when_no_allowlist(server):
"""A third-party page must not be able to read card ids."""
srv, _ = server
with pytest.raises(urllib.error.HTTPError) as excinfo:
get(srv, "/api/last", headers={"Origin": "https://evil.example"})
assert excinfo.value.code == 403
def test_allowlisted_origin_permitted_and_echoed():
hub = EventHub()
srv = create_server(hub, host="127.0.0.1", port=0,
allowed_origins=["https://kiosk.example"])
thread = threading.Thread(target=srv.serve_forever, daemon=True)
thread.start()
try:
hub.publish(make_event())
status, headers, body = get(
srv, "/api/last", headers={"Origin": "https://kiosk.example"}
)
assert status == 200
assert headers.get("Access-Control-Allow-Origin") == "https://kiosk.example"
assert json.loads(body)["id"] == 12886197
finally:
srv.shutdown()
srv.server_close()
def test_unlisted_origin_still_refused_when_allowlist_set():
hub = EventHub()
srv = create_server(hub, host="127.0.0.1", port=0,
allowed_origins=["https://kiosk.example"])
thread = threading.Thread(target=srv.serve_forever, daemon=True)
thread.start()
try:
with pytest.raises(urllib.error.HTTPError) as excinfo:
get(srv, "/api/last", headers={"Origin": "https://other.example"})
assert excinfo.value.code == 403
finally:
srv.shutdown()
srv.server_close()
def test_token_not_required_from_loopback():
hub = EventHub()
srv = create_server(hub, host="127.0.0.1", port=0, token="sekrit")
thread = threading.Thread(target=srv.serve_forever, daemon=True)
thread.start()
try:
status, _, _ = get(srv, "/api/health")
assert status == 200
finally:
srv.shutdown()
srv.server_close()
# -- mode switching ---------------------------------------------------------
def post(srv, path, payload, headers=None):
data = json.dumps(payload).encode()
request = urllib.request.Request(
base_url(srv) + path, data=data, method="POST",
headers={"Content-Type": "application/json", **(headers or {})},
)
with urllib.request.urlopen(request, timeout=5) as response:
return response.status, response.read()
def test_mode_can_be_switched(server):
srv, hub = server
status, body = post(srv, "/api/mode", {"mode": MODE_MONITOR})
assert status == 200
assert json.loads(body)["mode"] == MODE_MONITOR
assert hub.mode == MODE_MONITOR
def test_invalid_mode_rejected(server):
srv, _ = server
with pytest.raises(urllib.error.HTTPError) as excinfo:
post(srv, "/api/mode", {"mode": "nonsense"})
assert excinfo.value.code == 400
def test_malformed_json_rejected(server):
srv, _ = server
request = urllib.request.Request(
base_url(srv) + "/api/mode", data=b"{not json",
method="POST", headers={"Content-Type": "application/json"},
)
with pytest.raises(urllib.error.HTTPError) as excinfo:
urllib.request.urlopen(request, timeout=5)
assert excinfo.value.code == 400
# -- SSE --------------------------------------------------------------------
def test_sse_stream_delivers_a_tap(server):
"""The stream used by the web page must push a tap as it happens."""
srv, hub = server
received = []
def listen():
with urllib.request.urlopen(base_url(srv) + "/api/events", timeout=10) as resp:
for raw in resp:
line = raw.decode("utf-8").strip()
if line.startswith("data: "):
received.append(json.loads(line[6:]))
return
thread = threading.Thread(target=listen, daemon=True)
thread.start()
time.sleep(0.4) # let the subscription register
hub.publish(make_event())
thread.join(timeout=6)
assert received and received[0]["id"] == 12886197
def test_loopback_helper():
assert bridge.is_loopback("127.0.0.1") is True
assert bridge.is_loopback("0.0.0.0") is False
# -- latched card (the "always available" behaviour) ------------------------
def test_card_not_present_before_any_tap():
hub = EventHub()
state = hub.current_card()
assert state["present"] is False
assert state["card"] is None
def test_card_stays_available_after_the_page_loads():
"""The tap must outlive the page that reads it."""
hub = EventHub()
hub.publish(make_event(value=555))
for _ in range(3): # simulated later page loads
state = hub.current_card()
assert state["present"] is True
assert state["card"]["id"] == 555
def test_card_cleared_when_removed():
"""Lifting the card must NOT lose the tap - only mark it as gone."""
hub = EventHub(tap_ttl=30)
hub.publish(make_event())
hub.publish(make_event("card_removed"))
state = hub.current_card()
assert state["present"] is False # no longer physically on the reader
assert state["claimable"] is True # but still spendable
assert state["card"]["id"] == 12886197
def test_quick_tap_is_not_lost():
"""The real-world case: card lifted before the page polls."""
hub = EventHub(tap_ttl=10)
hub.publish(make_event(value=31337))
hub.publish(make_event("card_removed")) # lifted immediately
payload, error, _ = hub.consume_card()
assert error is None
assert payload["card"]["id"] == 31337
def test_claim_returns_the_card_once():
hub = EventHub()
hub.publish(make_event(value=777))
payload, error, status = hub.consume_card()
assert error is None and status == 200
assert payload["card"]["id"] == 777
assert payload["consumed"] is True
def test_claim_is_single_use():
"""A page reload must not turn one physical tap into two logins."""
hub = EventHub()
hub.publish(make_event())
assert hub.consume_card()[1] is None
payload, error, status = hub.consume_card()
assert payload is None
assert error == "card already used"
assert status == 409
def test_claim_without_a_card_is_conflict():
hub = EventHub()
payload, error, status = hub.consume_card()
assert payload is None
assert error == "no card read yet"
assert status == 409
def test_removing_and_replacing_the_card_allows_a_new_claim():
"""Lifting the card and tapping it again is a fresh authentication."""
hub = EventHub(tap_ttl=30)
hub.publish(make_event(value=111))
assert hub.consume_card()[1] is None
hub.publish(make_event("card_removed"))
hub.publish(make_event(value=111))
payload, error, _ = hub.consume_card()
assert error is None
assert payload["card"]["id"] == 111
def test_card_endpoint_reports_claimable_after_lift(server):
srv, hub = server
hub.publish(make_event(value=8080))
hub.publish(make_event("card_removed"))
_, _, body = get(srv, "/api/card")
payload = json.loads(body)
assert payload["present"] is False
assert payload["claimable"] is True
assert payload["card"]["id"] == 8080
def test_claim_expires_after_ttl():
hub = EventHub(tap_ttl=0.05)
hub.publish(make_event())
time.sleep(0.12)
payload, error, status = hub.consume_card()
assert payload is None
assert error == "card tap expired"
assert status == 410
def test_ttl_zero_never_expires_while_present():
hub = EventHub(tap_ttl=0)
hub.publish(make_event())
state = hub.current_card()
assert state["expired"] is False
assert state["claimable"] is True
def test_tap_becomes_unclaimable_after_ttl():
hub = EventHub(tap_ttl=0.05)
hub.publish(make_event())
time.sleep(0.12)
assert hub.current_card()["claimable"] is False
def test_a_new_tap_replaces_an_unclaimed_one():
hub = EventHub(tap_ttl=30)
hub.publish(make_event(value=111))
hub.publish(make_event(value=222))
payload, error, _ = hub.consume_card()
assert error is None
assert payload["card"]["id"] == 222
def test_health_shows_card_presence():
hub = EventHub()
hub.publish(make_event())
health = hub.health()
assert health["card_present"] is True
assert health["card_consumed"] is False
# -- latched card over HTTP -------------------------------------------------
def test_card_endpoint_reports_presence(server):
srv, hub = server
_, _, body = get(srv, "/api/card")
assert json.loads(body)["present"] is False
hub.publish(make_event(value=4242))
_, _, body = get(srv, "/api/card")
payload = json.loads(body)
assert payload["present"] is True
assert payload["card"]["id"] == 4242
def test_claim_endpoint_works_once(server):
srv, hub = server
hub.publish(make_event(value=9999))
status, body = post(srv, "/api/card/claim", {})
assert status == 200
assert json.loads(body)["card"]["id"] == 9999
with pytest.raises(urllib.error.HTTPError) as excinfo:
post(srv, "/api/card/claim", {})
assert excinfo.value.code == 409
detail = json.loads(excinfo.value.read())
assert detail["error"] == "card already used"
def test_claim_endpoint_without_card_conflicts(server):
srv, _ = server
with pytest.raises(urllib.error.HTTPError) as excinfo:
post(srv, "/api/card/claim", {})
assert excinfo.value.code == 409
def test_claim_refused_from_other_origin(server):
"""Another site must not be able to claim the card either."""
srv, hub = server
hub.publish(make_event())
with pytest.raises(urllib.error.HTTPError) as excinfo:
post(srv, "/api/card/claim", {},
headers={"Origin": "https://evil.example"})
assert excinfo.value.code == 403
# the tap must still be claimable by the real caller
assert hub.current_card()["consumed"] is False
def test_claim_does_not_consume_on_refused_origin(server):
srv, hub = server
hub.publish(make_event(value=1234))
with pytest.raises(urllib.error.HTTPError):
post(srv, "/api/card/claim", {},
headers={"Origin": "https://evil.example"})
payload, error, _ = hub.consume_card()
assert error is None
assert payload["card"]["id"] == 1234
+111
View File
@@ -0,0 +1,111 @@
"""The event model and its output formatting."""
import json
import pytest
from rdm_card_reader.card import (
CONFIG_CARD_VALUE,
CardEvent,
format_event,
)
SAMPLE = CardEvent(
event="card_inserted",
timestamp="2026-10-01 12:00:00",
value=0x00C4A0B5,
checksum=0xDE,
card_type=0x0F,
is_valid=True,
port="/dev/ttyS0",
)
def test_hex_value_is_zero_padded_to_eight_digits():
assert SAMPLE.hex_value == "0x00C4A0B5"
def test_from_card_accepts_any_object_with_the_right_fields():
class FakeCard:
value = 1234
checksum = 5
type = 15
is_valid = True
event = CardEvent.from_card("card_inserted", FakeCard(), "/dev/ttyUSB0")
assert event.value == 1234
assert event.hex_value == "0x000004D2"
assert event.port == "/dev/ttyUSB0"
def test_to_dict_has_the_documented_keys():
assert set(SAMPLE.to_dict()) == {
"event", "timestamp", "id", "hex", "type", "checksum", "is_valid", "port",
}
def test_to_json_is_parseable():
assert json.loads(SAMPLE.to_json())["hex"] == "0x00C4A0B5"
def test_inserted_block_lists_the_card_data():
text = format_event(SAMPLE)
assert "CARD INSERTED" in text
assert "12886197" in text
assert "0x00C4A0B5" in text
assert "0x0F" in text
assert "222" in text
assert "True" in text
def test_config_card_is_flagged_in_the_note():
event = CardEvent(
event="card_inserted",
timestamp="2026-10-01 12:00:00",
value=CONFIG_CARD_VALUE,
checksum=0,
card_type=0x0F,
is_valid=True,
port="/dev/ttyS0",
)
text = format_event(event)
assert "config card" in text
assert str(CONFIG_CARD_VALUE) in text
def test_ordinary_card_has_no_config_note():
assert "config card" not in format_event(SAMPLE)
def test_removed_line_is_single_line():
event = CardEvent(
event="card_removed",
timestamp="2026-10-01 12:00:00",
value=0x00C4A0B5,
checksum=0xDE,
card_type=0x0F,
is_valid=True,
port="/dev/ttyS0",
)
text = format_event(event)
assert text.count("\n") == 0
assert "CARD REMOVED" in text
def test_invalid_line_mentions_checksum():
event = CardEvent(
event="invalid_card",
timestamp="2026-10-01 12:00:00",
value=0xDEADBEEF,
checksum=0,
card_type=0x0F,
is_valid=False,
port="/dev/ttyS0",
)
assert "checksum mismatch" in format_event(event)
@pytest.mark.parametrize("value,expected", [(0, "0x00000000"), (0xFFFFFFFF, "0xFFFFFFFF")])
def test_hex_value_boundaries(value, expected):
event = CardEvent("card_inserted", "-", value, 0, 0x0F, True, "p")
assert event.hex_value == expected
+93
View File
@@ -0,0 +1,93 @@
"""CLI behaviour: exit codes, output modes and argument handling."""
import json
import pytest
from rdm_card_reader import cli
def test_list_ports_exits_zero(capsys):
assert cli.main(["--list-ports"]) == cli.EXIT_OK
out = capsys.readouterr().out
assert "/dev/ttyS0" in out
def test_demo_exits_zero(capsys):
assert cli.main(["--demo", "--demo-count", "1"]) == cli.EXIT_OK
assert "CARD INSERTED" in capsys.readouterr().out
def test_demo_json_emits_one_object_per_event(capsys):
cli.main(["--demo", "--demo-count", "1", "--json"])
lines = [line for line in capsys.readouterr().out.splitlines() if line.strip()]
events = [json.loads(line) for line in lines if line.startswith("{")]
assert events
assert {"event", "timestamp", "id", "hex"} <= set(events[0])
def test_demo_count_is_respected(capsys):
cli.main(["--demo", "--demo-count", "4"])
assert capsys.readouterr().out.count("CARD INSERTED") >= 1
def test_missing_library_exits_two(monkeypatch, capsys):
monkeypatch.setattr(cli, "available", lambda: False)
assert cli.main([]) == cli.EXIT_NO_LIBRARY
assert "rdm6300" in capsys.readouterr().err
def test_no_port_available_exits_one(monkeypatch, capsys):
monkeypatch.setattr(cli, "available", lambda: True)
monkeypatch.setattr(cli, "open_reader", lambda *a, **kw: None)
assert cli.main(["--port", "/dev/nope"]) == cli.EXIT_NO_PORT
assert "Could not open any serial port" in capsys.readouterr().err
def test_explicit_port_is_the_only_one_tried(monkeypatch):
monkeypatch.setattr(cli, "available", lambda: True)
tried = []
def fake_open(callback, ports=None, **kwargs):
tried.extend(ports or [])
return None
monkeypatch.setattr(cli, "open_reader", fake_open)
cli.main(["--port", "/dev/ttyUSB0"])
assert tried == ["/dev/ttyUSB0"]
def test_keyboard_interrupt_is_handled(monkeypatch, capsys):
monkeypatch.setattr(cli, "available", lambda: True)
class Reader:
port = "/dev/ttyS0"
def start(self):
raise KeyboardInterrupt
def close(self):
self.closed = True
reader = Reader()
monkeypatch.setattr(cli, "open_reader", lambda *a, **kw: reader)
assert cli.main([]) == cli.EXIT_OK
assert reader.closed is True
assert "Stopped by user" in capsys.readouterr().out
def test_version_flag_exits_zero(capsys):
with pytest.raises(SystemExit) as exc:
cli.main(["--version"])
assert exc.value.code == 0
assert "rdm-card-reader" in capsys.readouterr().out
def test_json_mode_prints_json_from_the_callback(capsys):
printer = cli._make_printer(as_json=True)
from rdm_card_reader.card import CardEvent
printer(CardEvent("card_inserted", "-", 1, 2, 15, True, "p"))
payload = json.loads(capsys.readouterr().out.strip())
assert payload["id"] == 1
+199
View File
@@ -0,0 +1,199 @@
"""Tests for the deploy artifacts.
These are the files that only ever run on the target device, so a mistake in
them is found late and painfully. The checks here are the ones that would have
caught real bugs: the install must not depend on the network, and the service
must not reference something the installer does not create.
"""
import os
import re
import stat
HERE = os.path.dirname(os.path.abspath(__file__))
PROJECT = os.path.dirname(HERE)
DEPLOY = os.path.join(PROJECT, "deploy")
INSTALL_SH = os.path.join(DEPLOY, "install.sh")
UNIT = os.path.join(DEPLOY, "rdm-card-reader.service")
RULES = os.path.join(DEPLOY, "99-rdm-reader.rules")
def read(path):
with open(path, "r") as handle:
return handle.read()
# -- install.sh -------------------------------------------------------------
def test_installer_exists_and_is_executable():
assert os.path.exists(INSTALL_SH)
mode = os.stat(INSTALL_SH).st_mode
assert mode & stat.S_IXUSR, "install.sh should be executable"
def test_installer_does_not_pip_install_the_project():
"""`pip install .` needs setuptools from the network, so it breaks offline.
This is the bug that made installation fail on an air-gapped Pi with
"Could not find a version that satisfies the requirement setuptools>=68".
"""
text = read(INSTALL_SH)
code = "\n".join(
line for line in text.splitlines() if not line.strip().startswith("#")
)
assert not re.search(r'pip"?\s+install[^\n]*"\$INSTALL_DIR"', code), (
"install.sh must not 'pip install' the project directory: it requires "
"downloading setuptools and fails with no network"
)
assert not re.search(r"pip\s+install\s+\.\s*$", code, re.M)
def test_installer_installs_runtime_deps_from_bundled_wheels():
text = read(INSTALL_SH)
assert "--no-index" in text
assert "--find-links" in text
assert "wheels" in text
def test_installer_adds_src_to_path():
"""Without this the package is not importable at all."""
text = read(INSTALL_SH)
assert ".pth" in text
def test_installer_network_steps_are_not_fatal():
"""A device with no internet must still get a working install."""
text = read(INSTALL_SH)
for line in text.splitlines():
stripped = line.strip()
if stripped.startswith("apt-get"):
assert "|| " in line or line.rstrip().endswith("\\"), (
"network-dependent step should not abort the install: " + stripped
)
# The pip self-upgrade is best-effort too. The fallback may sit on the
# following line (line continuation), so join continuations first.
joined = text.replace("\\\n", " ")
assert re.search(r"upgrade pip[^\n]*\|\|", joined), (
"'pip install --upgrade pip' needs the network, so it must not abort "
"the install; add '|| ...' to it"
)
def test_installer_copies_the_wheels_with_the_source():
"""The bundled wheels live under src/, so src/ must be copied."""
text = read(INSTALL_SH)
match = re.search(r"for item in ([^\n;]+);", text)
assert match, "could not find the copy list in install.sh"
assert "src" in match.group(1)
def test_installer_checks_venv_creation_can_fail_clearly():
text = read(INSTALL_SH)
assert "python3-venv" in text, "should mention the usual cause of venv failure"
def test_installer_verifies_the_install_before_enabling_service():
text = read(INSTALL_SH)
assert "--version" in text, "installer should sanity-check the install"
def test_installer_copies_launch_doc():
text = read(INSTALL_SH)
assert "LAUNCH.md" in text
# -- service unit -----------------------------------------------------------
def test_unit_exists():
assert os.path.exists(UNIT)
def test_unit_restarts_on_failure():
text = read(UNIT)
assert "Restart=always" in text
assert "RestartSec=" in text
def test_unit_runs_as_a_non_root_user_in_dialout():
text = read(UNIT)
assert re.search(r"^User=", text, re.M)
assert re.search(r"^Group=dialout", text, re.M)
def _sections(text):
"""Split an INI-style unit into {section: body}.
Splitting on the literal string "[Service]" is wrong: the file mentions it
in a comment above the real section header.
"""
sections = {}
current = None
for line in text.splitlines():
header = re.match(r"^\[([^\]]+)\]\s*$", line)
if header:
current = header.group(1)
sections[current] = []
elif current is not None:
sections[current].append(line)
return {name: "\n".join(body) for name, body in sections.items()}
def test_unit_start_limits_are_in_the_unit_section():
"""systemd silently ignores these in [Service]."""
sections = _sections(read(UNIT))
assert "Unit" in sections and "Service" in sections
for key in ("StartLimitIntervalSec", "StartLimitBurst"):
assert key not in sections["Service"], (
"{} belongs in [Unit]; systemd ignores it in [Service]".format(key)
)
assert "StartLimitBurst" in sections["Unit"]
def test_unit_has_the_expected_sections():
sections = _sections(read(UNIT))
assert set(sections) >= {"Unit", "Service", "Install"}
def test_unit_uses_loopback_by_default():
text = read(UNIT)
# No --host flag means the CLI default (127.0.0.1) applies.
assert "--host" not in text
def test_unit_serves_in_login_mode():
text = read(UNIT)
assert "--serve" in text
assert "--mode login" in text
def test_unit_points_at_the_stable_device_alias():
text = read(UNIT)
assert "/dev/rdm-reader" in text
# -- udev rule --------------------------------------------------------------
def test_udev_rule_creates_the_alias_the_unit_expects():
text = read(RULES)
assert 'SYMLINK+="rdm-reader"' in text
unit = read(UNIT)
assert "/dev/rdm-reader" in unit, "unit and rule must agree on the alias name"
def test_udev_rule_sets_group_and_mode():
text = read(RULES)
assert 'GROUP="dialout"' in text
assert 'MODE="0660"' in text
def test_udev_rule_only_has_one_active_match():
"""Multiple active rules would fight over the same alias."""
active = [
line
for line in read(RULES).splitlines()
if line.strip().startswith('SUBSYSTEM=="tty"')
]
assert len(active) == 1, "exactly one rule should be active, found {}".format(
len(active)
)
+177
View File
@@ -0,0 +1,177 @@
"""End-to-end test: a real tap must be claimable by a web page at any time.
Simulates the RDM6300 on a pseudo-terminal, starts the actual bridge the way
systemd would, then walks the exact flow a web page performs:
card placed -> page loads -> polls /api/card -> claims it -> hand over once
Also asserts the two things that matter for security: the tap survives until
claimed, and it cannot be claimed twice.
"""
import json
import os
import pty
import signal
import struct
import subprocess
import sys
import threading
import time
import urllib.error
import urllib.request
HERE = os.path.dirname(os.path.abspath(__file__))
PROJECT = os.path.dirname(HERE)
VENV_PY = os.path.join(PROJECT, ".venv", "bin", "python")
PYTHON = VENV_PY if os.path.exists(VENV_PY) else sys.executable
CARD_VALUE = 0x00C4A0B5
EXPECTED_ID = 12886197
def em4100_frame(value, card_type=0x0F):
"""12 hex nibbles: type + 4 id bytes + XOR checksum of those 5 bytes."""
id_bytes = struct.pack(">I", value)
checksum = card_type
for b in id_bytes:
checksum ^= b
frame = (bytes([card_type]) + id_bytes + bytes([checksum])).hex().upper()
assert len(frame) == 12, frame
return frame.encode("ascii")
def main():
master, slave = pty.openpty()
serial_port = os.ttyname(slave)
# Port 0 would be cleaner but the CLI needs a real number; pick a free one.
import socket
probe = socket.socket()
probe.bind(("127.0.0.1", 0))
http_port = probe.getsockname()[1]
probe.close()
proc = subprocess.Popen(
[PYTHON, "-m", "rdm_card_reader", "--serve",
"--port", serial_port, "--bridge-port", str(http_port),
"--mode", "login", "--tap-ttl", "15",
"--allow-origin", "https://kiosk.example"],
cwd=PROJECT, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
text=True, bufsize=1, start_new_session=True,
)
output = []
lock = threading.Lock()
def pump():
for line in proc.stdout:
with lock:
output.append(line.rstrip())
threading.Thread(target=pump, daemon=True).start()
base = "http://127.0.0.1:{}".format(http_port)
results = {}
def api(path, method="GET", headers=None):
request = urllib.request.Request(
base + path, method=method,
headers=headers or {},
data=b"" if method == "POST" else None,
)
with urllib.request.urlopen(request, timeout=5) as response:
body = response.read()
return response.status, (json.loads(body) if body else None)
try:
# Wait for the HTTP server to come up.
deadline = time.time() + 15
while time.time() < deadline:
try:
api("/api/health")
break
except Exception:
time.sleep(0.3)
results["bridge started"] = True
# The child needs a moment to open the serial port; frames written
# before that are discarded, which would look like a reader bug.
time.sleep(2.0)
# --- no card yet -------------------------------------------------
_, state = api("/api/card")
results["no card -> nothing claimable"] = state["claimable"] is False
# --- a single tap, as a person would -------------------------------
frame = em4100_frame(CARD_VALUE)
os.write(master, b"\x02" + frame + b"\x03")
time.sleep(0.3)
# --- a page loads later and polls; the card must still be there ----
time.sleep(1.0)
_, state = api("/api/card")
with lock:
print("state after tap: {}".format(json.dumps(state)), flush=True)
for line in output[-12:]:
print(" " + line)
results["card still available later"] = bool(
state["claimable"] and state["card"]["id"] == EXPECTED_ID
)
results["card not consumed yet"] = state["consumed"] is False
# --- claim it, as the web page does -------------------------------
status, claimed = api(
"/api/card/claim", method="POST",
headers={"Origin": "https://kiosk.example"},
)
results["claim returns 200"] = status == 200
results["claim gives the right card"] = claimed["card"]["id"] == EXPECTED_ID
# --- single use ---------------------------------------------------
try:
api("/api/card/claim", method="POST",
headers={"Origin": "https://kiosk.example"})
results["second claim refused"] = False
except urllib.error.HTTPError as exc:
results["second claim refused"] = exc.code == 409
# --- a foreign origin cannot grab it either ------------------------
_, state = api("/api/card")
results["card marked consumed"] = state["consumed"] is True
finally:
try:
os.killpg(os.getpgid(proc.pid), signal.SIGINT)
except Exception:
pass
try:
proc.wait(timeout=8)
except Exception:
proc.kill()
try:
os.close(master)
os.close(slave)
except Exception:
pass
with lock:
lines = list(output)
print("======== bridge output ========")
for line in lines[:14]:
print(line)
print("===============================")
print()
ok = True
for name, passed in results.items():
print(" [{}] {}".format("PASS" if passed else "FAIL", name))
ok = ok and bool(passed)
return 0 if ok else 1
if __name__ == "__main__":
sys.exit(main())
+209
View File
@@ -0,0 +1,209 @@
"""End-to-end: a real card read must drive the LED on and off.
Uses a pseudo-terminal for the reader and a fake pin for the LED, so the whole
chain runs — serial frame -> card event -> LED worker -> pin state — without a
Pi and without GPIO hardware.
"""
import os
import pty
import struct
import subprocess
import sys
import threading
import time
HERE = os.path.dirname(os.path.abspath(__file__))
PROJECT = os.path.dirname(HERE)
VENV_PY = os.path.join(PROJECT, ".venv", "bin", "python")
PYTHON = VENV_PY if os.path.exists(VENV_PY) else sys.executable
CARD_VALUE = 0x00C4A0B5
# Runs the app's own CLI with gpiozero's LED replaced by a recorder, so we can
# observe what the app would have done to the pin.
DRIVER = r'''
import os, sys, threading, time, json
sys.path.insert(0, os.environ["PROJECT_SRC"])
from rdm_card_reader import led as led_module
TRANSITIONS = []
_LOCK = threading.Lock()
class RecordingLED:
def __init__(self, pin=None, active_high=True):
self.pin = pin
self._state = False
def _record(self, value):
self._state = value
with _LOCK:
TRANSITIONS.append(value)
# Write through immediately: the test terminates this process, so
# anything buffered for the end would be lost.
sys.stderr.write("TRANSITION=" + value + "\n")
sys.stderr.flush()
def on(self):
self._record("on")
def off(self):
self._record("off")
def close(self):
pass
class FakeGPIOLED(led_module.GPIOLED):
def __init__(self, pin, active_high=True):
self.pin = pin
self._led = RecordingLED(pin, active_high)
self._is_on = False
def on(self):
self._led.on()
self._is_on = True
def off(self):
self._led.off()
self._is_on = False
def close(self):
self._led.close()
led_module.GPIOLED = FakeGPIOLED
from rdm_card_reader.cli import main
code = main(["--port", os.environ["SERIAL_PORT"], "--heartbeat", "0.3",
"--led-mode", os.environ.get("LED_MODE", "error"), "--json"])
sys.exit(code)
'''
def em4100_frame(value, card_type=0x0F):
id_bytes = struct.pack(">I", value)
checksum = card_type
for b in id_bytes:
checksum ^= b
frame = (bytes([card_type]) + id_bytes + bytes([checksum])).hex().upper()
assert len(frame) == 12, frame
return frame.encode("ascii")
def pump(stream, sink):
"""Read a stream on its own thread.
Text-mode pipes buffer, so select() would not report this data.
"""
for line in stream:
sink.append(line.rstrip())
def run_case(mode):
"""Drive a card through the app and return (events, led transitions)."""
master, slave = pty.openpty()
serial_port = os.ttyname(slave)
env = dict(os.environ)
env["SERIAL_PORT"] = serial_port
env["PROJECT_SRC"] = os.path.join(PROJECT, "src")
env["LED_MODE"] = mode
env.pop("PYTHONPATH", None)
proc = subprocess.Popen(
[PYTHON, "-u", "-c", DRIVER],
cwd=PROJECT, env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
text=True, bufsize=1,
)
stdout, stderr = [], []
t1 = threading.Thread(target=pump, args=(proc.stdout, stdout), daemon=True)
t2 = threading.Thread(target=pump, args=(proc.stderr, stderr), daemon=True)
t1.start()
t2.start()
frame = em4100_frame(CARD_VALUE)
try:
time.sleep(2.0) # let the reader open the port
for _ in range(6): # card present
os.write(master, b"\x02" + frame + b"\x03")
time.sleep(0.1)
time.sleep(1.2) # stop writing -> card removed
finally:
try:
proc.terminate()
except Exception:
pass
try:
proc.wait(timeout=5)
except Exception:
proc.kill()
for t in (t1, t2):
t.join(timeout=3)
try:
os.close(master)
os.close(slave)
except Exception:
pass
transitions = [
line.split("=", 1)[1]
for line in stderr
if line.startswith("TRANSITION=")
]
return stdout, stderr, transitions
def main():
failures = 0
# --- error mode: lit while running, dark while a card is read ----------
stdout, stderr, transitions = run_case("error")
print("======== error mode ========")
for line in stdout[:2]:
print(" " + line)
print(" events: {}".format(
[l.split('"event": "')[1].split('"')[0]
for l in stdout if '"event"' in l]))
print(" transitions: {}".format(transitions))
checks = {
"card was read": any("card_inserted" in l for l in stdout),
"card removal seen": any("card_removed" in l for l in stdout),
"lit at startup": transitions[:1] == ["on"],
"dark while reading": "off" in transitions,
"lit again after": transitions[-1:] == ["on"],
}
# --- presence mode: dark until a card arrives -------------------------
stdout2, stderr2, transitions2 = run_case("presence")
print("\n======== presence mode ========")
print(" transitions: {}".format(transitions2))
checks.update({
"presence: read still works":
any("card_inserted" in l for l in stdout2),
"presence: dark at startup": transitions2[:1] == ["on"] or not transitions2,
"presence: lit on the card": "on" in transitions2,
"presence: dark after": transitions2[-1:] == ["off"],
})
print()
ok = True
for name, passed in checks.items():
print(" [{}] {}".format("PASS" if passed else "FAIL", name))
ok = ok and bool(passed)
if not ok:
failures = 1
print("\n--- driver stderr (error mode) ---")
print("\n".join(stderr)[:1200])
return failures
if __name__ == "__main__":
sys.exit(main())
+165
View File
@@ -0,0 +1,165 @@
"""End-to-end test over a pseudo-terminal, with no RFID hardware.
The test owns the master side of a PTY, points the CLI at the slave side and
writes real RDM6300 frames. This exercises the same code path a physical
reader would: serial open -> byte stream -> parse -> callback -> stdout.
"""
import json
import os
import pty
import signal
import subprocess
import sys
import threading
import time
import pytest
from rdm_card_reader.card import CONFIG_CARD_VALUE
from .test_protocol import encode_stream, encode_frame # noqa: F401
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
SRC = os.path.join(ROOT, "src")
TIMEOUT = 15.0
class ReaderProcess:
"""Run the CLI against a PTY and collect its stdout in the background."""
def __init__(self, extra_args=()):
self.master, slave = pty.openpty()
self.port = os.ttyname(slave)
env = dict(os.environ)
env["PYTHONPATH"] = SRC + os.pathsep + env.get("PYTHONPATH", "")
self.proc = subprocess.Popen(
[sys.executable, "-u", "-m", "rdm_card_reader",
"--port", self.port, "--heartbeat", "0.3", *extra_args],
cwd=ROOT, env=env, stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, text=True, bufsize=1,
start_new_session=True,
)
self.lines = []
self._lock = threading.Lock()
self._thread = threading.Thread(target=self._pump, daemon=True)
self._thread.start()
def _pump(self):
for line in self.proc.stdout:
with self._lock:
self.lines.append(line.rstrip())
def output(self):
with self._lock:
return "\n".join(self.lines)
def send(self, frame):
os.write(self.master, b"\x02" + frame + b"\x03")
def wait_for(self, needle, timeout=TIMEOUT):
deadline = time.time() + timeout
while time.time() < deadline:
if needle in self.output():
return True
time.sleep(0.05)
return False
def __enter__(self):
self.wait_for("waiting for cards")
return self
def __exit__(self, *exc):
try:
os.killpg(os.getpgid(self.proc.pid), signal.SIGINT)
except Exception:
pass
try:
self.proc.wait(timeout=5)
except Exception:
self.proc.kill()
self._thread.join(timeout=3)
for fd in (self.master, self.proc.stdout):
try:
fd.close()
except Exception:
pass
return False
@pytest.mark.skipif(sys.platform != "linux", reason="PTY test is Linux only")
def test_card_insert_and_remove_are_reported():
card_id = 0x00C4A0B5
frame = encode_frame(card_id)
with ReaderProcess() as rp:
deadline = time.time() + TIMEOUT
while time.time() < deadline and not rp.wait_for("CARD INSERTED", 0.2):
rp.send(frame)
assert rp.wait_for("CARD INSERTED"), rp.output()
assert rp.wait_for("CARD REMOVED")
text = rp.output()
assert rp.port in text
assert str(card_id) in text
assert "0x00C4A0B5" in text
assert "222" in text # checksum = 0xDE
assert "0x0F" in text # type
assert "True" in text # valid
@pytest.mark.skipif(sys.platform != "linux", reason="PTY test is Linux only")
def test_corrupt_checksum_is_reported_as_invalid():
good = bytearray(encode_frame(0x1A2B3C4D))
good[-1] = ord("0") # break the checksum
bad = bytes(good)
with ReaderProcess() as rp:
deadline = time.time() + TIMEOUT
while time.time() < deadline and not rp.wait_for("INVALID CARD", 0.2):
rp.send(bad)
assert "INVALID CARD" in rp.output()
assert "checksum mismatch" in rp.output()
@pytest.mark.skipif(sys.platform != "linux", reason="PTY test is Linux only")
def test_json_mode_emits_machine_readable_events():
with ReaderProcess(extra_args=("--json",)) as rp:
deadline = time.time() + TIMEOUT
while time.time() < deadline and not rp.wait_for('"event"', 0.2):
rp.send(encode_frame(0x00C4A0B5))
payloads = [
json.loads(line)
for line in rp.output().splitlines()
if line.strip().startswith("{")
]
assert payloads, rp.output()
assert any(p["event"] == "card_inserted" for p in payloads)
assert any(p["id"] == 0x00C4A0B5 for p in payloads)
assert all(p["port"] == rp.port for p in payloads)
@pytest.mark.skipif(sys.platform != "linux", reason="PTY test is Linux only")
def test_config_card_is_annotated():
with ReaderProcess() as rp:
deadline = time.time() + TIMEOUT
while time.time() < deadline and not rp.wait_for("config card", 0.2):
rp.send(encode_frame(CONFIG_CARD_VALUE))
assert "config card" in rp.output()
@pytest.mark.skipif(sys.platform != "linux", reason="PTY test is Linux only")
def test_removal_after_heartbeat_expires():
with ReaderProcess() as rp:
deadline = time.time() + TIMEOUT
while time.time() < deadline and not rp.wait_for("CARD INSERTED", 0.2):
rp.send(encode_frame(0x00C4A0B5))
# stop sending and let the 0.3 s heartbeat lapse
assert rp.wait_for("CARD REMOVED", timeout=5)
assert "CARD REMOVED" in rp.output()
+306
View File
@@ -0,0 +1,306 @@
"""Tests for the status LED.
Two things matter: the LED must never be able to break the reader, and it must
behave the way the workstation app did (on with the card, off without). Real
GPIO is unavailable here, so a fake pin is injected to test the logic.
"""
import os
import sys
import threading
import time
SRC = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "src"
)
if SRC not in sys.path:
sys.path.insert(0, SRC)
import pytest # noqa: E402
from rdm_card_reader import led as led_module # noqa: E402
from rdm_card_reader.led import ( # noqa: E402
BLINK,
DEFAULT_LED_PIN,
ERROR,
OFF,
ON,
PRESENCE,
NullLED,
StatusLED,
create,
)
class FakeLED:
"""Stands in for gpiozero.LED, recording what it was told to do."""
def __init__(self, pin, active_high=True):
self.pin = pin
self.active_high = active_high
self.state = False
self.transitions = []
self.closed = False
def on(self):
self.state = True
self.transitions.append("on")
def off(self):
self.state = False
self.transitions.append("off")
def close(self):
self.closed = True
@pytest.fixture
def fake_gpio(monkeypatch):
"""Make StatusLED build a FakeLED instead of touching GPIO."""
created = {}
class FakeGPIOLED(led_module.GPIOLED):
def __init__(self, pin, active_high=True):
self.pin = pin
self._led = FakeLED(pin, active_high)
self._is_on = False
created["led"] = self._led
def on(self):
self._led.on()
self._is_on = True
def off(self):
self._led.off()
self._is_on = False
def close(self):
self._led.close()
monkeypatch.setattr(led_module, "GPIOLED", FakeGPIOLED)
return created
def wait_for_state(led_obj, expected, timeout=3.0):
"""Wait for the worker thread to apply a command."""
deadline = time.time() + timeout
while time.time() < deadline:
if led_obj.state is expected:
return True
time.sleep(0.01)
return False
# -- defaults ---------------------------------------------------------------
def test_default_pin_matches_the_workstation_app():
assert DEFAULT_LED_PIN == 23
# -- graceful degradation ---------------------------------------------------
def test_no_gpio_gives_a_working_no_op(monkeypatch):
"""On a machine without GPIO the LED must be inert, not fatal."""
def boom(pin, active_high=True):
raise RuntimeError("Unable to load any default pin factory!")
monkeypatch.setattr(led_module, "GPIOLED", boom)
status = StatusLED(pin=23)
assert status.status.enabled is False
assert status.status.backend == "null"
# Every hook must still be safe to call.
status.insert()
status.remove()
status.error()
status.close()
def test_missing_gpiozero_is_reported_not_raised(monkeypatch):
"""gpiozero absent: say so, and do not raise."""
import builtins
real_import = builtins.__import__
def blocked(name, *args, **kwargs):
if name == "gpiozero":
raise ImportError("No module named 'gpiozero'")
return real_import(name, *args, **kwargs)
monkeypatch.setattr(builtins, "__import__", blocked)
status = StatusLED(pin=23)
assert status.status.enabled is False
assert "not installed" in status.status.reason
def test_no_pin_factory_is_not_reported_as_missing_library(monkeypatch):
"""BadPinFactory subclasses ImportError, so the two must be distinguished.
Reporting 'gpiozero is not installed' when it is installed but has no
usable pin library would send you to the wrong fix.
"""
class FakeBadPinFactory(ImportError):
pass
def boom(pin, active_high=True):
raise FakeBadPinFactory(
"Unable to load any default pin factory!"
)
monkeypatch.setattr(led_module, "GPIOLED", boom)
status = StatusLED(pin=23)
assert status.status.enabled is False
assert "not installed" not in status.status.reason
assert "pin factory" in status.status.reason
def test_disabled_led_is_inert(fake_gpio):
status = StatusLED(pin=23, enabled=False)
assert status.status.enabled is False
assert isinstance(status.led, NullLED)
status.insert()
time.sleep(0.05)
assert "led" not in fake_gpio
def test_null_led_methods_are_safe():
null = NullLED(23, "no gpio")
null.on()
null.off()
null.close()
# -- behaviour --------------------------------------------------------------
def test_error_mode_lights_up_at_startup(fake_gpio):
"""A health indicator should show 'alive' without waiting for a card."""
status = StatusLED(pin=23, mode=ERROR)
assert fake_gpio["led"].state is True
status.close()
def test_error_mode_blinks_on_a_bad_read(fake_gpio):
status = StatusLED(pin=23, mode=ERROR, error_blink=True)
status.error()
time.sleep(0.25)
assert len(fake_gpio["led"].transitions) >= 2
status.close()
def test_error_mode_settles_back_after_a_blink(fake_gpio):
status = StatusLED(pin=23, mode=ERROR, error_blink=True)
status.error()
time.sleep(0.25)
status.remove() # any steady command cancels blinking
assert wait_for_state(fake_gpio["led"], True)
status.close()
def test_error_mode_without_blink_stays_lit(fake_gpio):
status = StatusLED(pin=23, mode=ERROR, error_blink=False)
status.error()
assert wait_for_state(fake_gpio["led"], True)
status.close()
def test_presence_mode_is_dark_until_a_card_arrives(fake_gpio):
status = StatusLED(pin=23, mode=PRESENCE)
assert fake_gpio["led"].state is False
status.insert()
assert wait_for_state(fake_gpio["led"], True)
status.remove()
assert wait_for_state(fake_gpio["led"], False)
status.close()
def test_close_turns_the_led_off(fake_gpio):
status = StatusLED(pin=23, mode=ERROR)
assert fake_gpio["led"].state is True
status.close()
assert fake_gpio["led"].closed is True
assert fake_gpio["led"].state is False
def test_pin_and_active_high_are_passed_through(fake_gpio):
status = StatusLED(pin=17, active_high=False)
assert fake_gpio["led"].pin == 17
assert fake_gpio["led"].active_high is False
status.close()
# -- never blocks or crashes ------------------------------------------------
def test_insert_does_not_block(monkeypatch):
"""The reader thread calls this, so it must return immediately."""
class SlowLED(led_module.GPIOLED):
def __init__(self, pin, active_high=True):
self.pin = pin
self._is_on = False
def on(self):
time.sleep(0.5) # deliberately slow hardware
def off(self):
pass
def close(self):
pass
monkeypatch.setattr(led_module, "GPIOLED", SlowLED)
# mode=presence so the startup light-up does not pay the slow 'on' cost
status = StatusLED(pin=23, mode=PRESENCE)
start = time.time()
for _ in range(20):
status.insert()
elapsed = time.time() - start
assert elapsed < 0.2, "insert() blocked for {:.2f}s".format(elapsed)
status.close()
def test_failing_toggle_does_not_raise(fake_gpio, monkeypatch):
"""A dead pin must not take the reader down."""
status = StatusLED(pin=23)
assert fake_gpio["led"] is not None
def boom():
raise OSError("pin went away")
monkeypatch.setattr(status.led, "on", boom)
status.insert()
time.sleep(0.15) # worker swallows the error
status.close() # and close() still works
def test_hardware_error_is_reported_to_the_logger(monkeypatch):
lines = []
def boom(pin, active_high=True):
raise RuntimeError("no pin factory")
monkeypatch.setattr(led_module, "GPIOLED", boom)
StatusLED(pin=23, logger=lines.append)
assert any("LED unavailable" in line for line in lines)
# -- factory ----------------------------------------------------------------
def test_create_defaults_to_error_mode_on_the_workstation_pin(fake_gpio):
status = create()
assert status.status.enabled is True
assert status.status.pin == DEFAULT_LED_PIN
assert status.status.mode == ERROR
status.close()
def test_create_disabled_returns_a_no_op():
status = create(enabled=False)
assert status.status.enabled is False
status.close()
def test_status_dict_is_json_serialisable(fake_gpio):
import json
status = create()
payload = json.dumps(status.status.to_dict())
assert "gpiozero" in payload
status.close()
+228
View File
@@ -0,0 +1,228 @@
"""Tests for reliable serial-port identification.
The point of these tests is that picking the wrong ttyUSB silently is the
failure mode that matters, so ambiguity must raise rather than guess.
"""
import os
import sys
SRC = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "src"
)
if SRC not in sys.path:
sys.path.insert(0, SRC)
import pytest # noqa: E402
from rdm_card_reader import ports # noqa: E402
from rdm_card_reader.ports import ( # noqa: E402
AmbiguousPort,
PortInfo,
PortNotFound,
best_usb_candidates,
resolve,
)
def make_port(device, vid=None, pid=None, serial=None, description="",
aliases=None, manufacturer=None, product=None):
return PortInfo(
device=device,
vid=vid,
pid=pid,
serial_number=serial,
description=description,
aliases=list(aliases or []),
manufacturer=manufacturer,
product=product,
)
@pytest.fixture
def two_adapters(monkeypatch):
"""Two identical CH340 adapters plus an on-board UART."""
a = make_port("/dev/ttyUSB0", 0x1A86, 0x7523, "CH340A",
"USB Serial", ["/dev/serial/by-id/usb-1a86_A-if00-port0"])
b = make_port("/dev/ttyUSB1", 0x1A86, 0x7523, "CH340B",
"USB Serial", ["/dev/serial/by-id/usb-1a86_B-if00-port0"])
onboard = make_port("/dev/ttyS0", description="ttyS0")
monkeypatch.setattr(ports, "discover", lambda: [a, b, onboard])
return a, b, onboard
# -- identity ---------------------------------------------------------------
def test_usb_id_and_chip_name():
port = make_port("/dev/ttyUSB0", 0x1A86, 0x7523)
assert port.usb_id == "1a86:7523"
assert port.chip == "CH340"
assert port.is_usb is True
def test_unknown_chip_still_identified_as_usb():
port = make_port("/dev/ttyUSB0", 0xFFFF, 0x0001)
assert port.chip is None
assert port.is_usb is True
def test_stable_id_prefers_by_id_symlink():
port = make_port(
"/dev/ttyUSB0",
aliases=["/dev/serial/by-path/pci-1-usb-0:1.2:1.0-port0",
"/dev/serial/by-id/usb-1a86_USB-if00-port0"],
)
assert port.stable_id == "/dev/serial/by-id/usb-1a86_USB-if00-port0"
assert port.by_path.endswith("port0")
def test_onboard_uart_is_not_usb():
assert make_port("/dev/ttyS0").is_usb is False
# -- scoring ----------------------------------------------------------------
def test_usb_adapter_scores_above_onboard_uart():
adapter = make_port("/dev/ttyUSB0", 0x1A86, 0x7523, "SN1")
onboard = make_port("/dev/ttyS0")
assert adapter.score() > onboard.score()
def test_serial_number_raises_score():
with_sn = make_port("/dev/ttyUSB0", 0x1A86, 0x7523, "SN1")
without = make_port("/dev/ttyUSB0", 0x1A86, 0x7523, None)
assert with_sn.score() > without.score()
# -- matching ---------------------------------------------------------------
def test_match_by_device_path():
assert make_port("/dev/ttyUSB0").matches("/dev/ttyUSB0")
def test_match_by_serial_number():
port = make_port("/dev/ttyUSB0", 0x1A86, 0x7523, "CH340A")
assert port.matches("CH340A")
def test_match_by_vid_pid():
port = make_port("/dev/ttyUSB0", 0x1A86, 0x7523)
assert port.matches("1a86:7523")
def test_match_by_chip_name():
assert make_port("/dev/ttyUSB0", 0x1A86, 0x7523).matches("ch340")
def test_match_by_id_symlink():
port = make_port("/dev/ttyUSB0",
aliases=["/dev/serial/by-id/usb-1a86_A-if00-port0"])
assert port.matches("/dev/serial/by-id/usb-1a86_A-if00-port0")
def test_empty_selector_never_matches():
assert not make_port("/dev/ttyUSB0").matches("")
# -- resolution -------------------------------------------------------------
def test_resolve_by_serial_number_picks_right_adapter(two_adapters):
device, reason = resolve(selectors=["CH340B"])
assert device == "/dev/ttyUSB1"
assert "matched" in reason
def test_resolve_by_by_id_path(two_adapters):
device, _ = resolve(selectors=["/dev/serial/by-id/usb-1a86_B-if00-port0"])
assert device == "/dev/ttyUSB1"
def test_resolve_refuses_when_selector_matches_both(two_adapters):
"""A vid:pid shared by two adapters must not silently pick one."""
with pytest.raises(AmbiguousPort):
resolve(selectors=["1a86:7523"])
def test_resolve_unknown_selector_raises_not_found(two_adapters):
with pytest.raises(PortNotFound):
resolve(selectors=["NOSUCHDEVICE"])
def test_resolve_falls_back_to_raw_path_when_not_enumerable(monkeypatch, tmp_path):
fake = tmp_path / "myport"
fake.write_text("")
monkeypatch.setattr(ports, "discover", lambda: [])
device, reason = resolve(selectors=[str(fake)])
assert device == str(fake)
assert "explicit path" in reason
def test_autodetect_requires_exactly_one(monkeypatch):
single = make_port("/dev/ttyUSB3", 0x1A86, 0x7523, "ONLY")
monkeypatch.setattr(ports, "discover", lambda: [single, make_port("/dev/ttyS0")])
device, reason = resolve(autodetect=True)
assert device == "/dev/ttyUSB3"
assert "autodetected" in reason
def test_autodetect_refuses_with_two_adapters(two_adapters):
with pytest.raises(AmbiguousPort):
resolve(autodetect=True)
def test_autodetect_ignores_onboard_uart(monkeypatch):
"""An on-board UART must not make a single USB adapter look ambiguous."""
adapter = make_port("/dev/ttyUSB0", 0x1A86, 0x7523, "ONLY")
monkeypatch.setattr(
ports, "discover",
lambda: [adapter, make_port("/dev/ttyS0"), make_port("/dev/ttyAMA0")],
)
device, _ = resolve(autodetect=True)
assert device == "/dev/ttyUSB0"
def test_fallback_order_used_when_no_usb(monkeypatch):
"""Must not depend on the host actually having a serial port."""
monkeypatch.setattr(ports, "discover", lambda: [])
monkeypatch.setattr(ports.os.path, "exists", lambda path: path == "/dev/ttyS0")
device, reason = resolve(fallback_ports=["/dev/ttyS0", "/dev/ttyUSB0"])
assert device == "/dev/ttyS0"
assert "default order" in reason
def test_fallback_order_skips_missing_ports(monkeypatch):
monkeypatch.setattr(ports, "discover", lambda: [])
monkeypatch.setattr(
ports.os.path, "exists", lambda path: path == "/dev/ttyUSB0"
)
device, _ = resolve(fallback_ports=["/dev/ttyS0", "/dev/ttyUSB0"])
assert device == "/dev/ttyUSB0"
def test_no_port_at_all_raises(monkeypatch):
monkeypatch.setattr(ports, "discover", lambda: [])
with pytest.raises(PortNotFound):
resolve(fallback_ports=[])
# -- helper ----------------------------------------------------------------
def test_best_usb_candidates_dedupes_same_identity():
a = make_port("/dev/ttyUSB0", 0x1A86, 0x7523, "SAME")
dup = make_port("/dev/ttyUSB1", 0x1A86, 0x7523, "SAME")
result = best_usb_candidates([a, dup])
assert len(result) == 1
def test_best_usb_candidates_keeps_distinct_serial_numbers():
a = make_port("/dev/ttyUSB0", 0x1A86, 0x7523, "A")
b = make_port("/dev/ttyUSB1", 0x1A86, 0x7523, "B")
assert len(best_usb_candidates([a, b])) == 2
def test_describe_is_human_readable():
port = make_port("/dev/ttyUSB0", 0x1A86, 0x7523, "ABC", "USB Serial")
text = port.describe()
assert "/dev/ttyUSB0" in text
assert "CH340" in text
assert "ABC" in text
+108
View File
@@ -0,0 +1,108 @@
"""RDM6300 wire format: type + id + checksum, 12 hex nibbles per frame.
The checksum is the XOR of the type byte *and* the four id bytes. Getting this
wrong (e.g. XOR-ing only the id) makes every card read fail its checksum, so
the exact bytes are pinned here.
"""
import struct
import pytest
from rdm_card_reader.card import CardData
START = 0x02
END = 0x03
def encode_frame(value, card_type=0x0F):
"""Encode a card as the 12 nibbles the RDM6300 emits between STX and ETX."""
id_bytes = struct.pack(">I", value)
checksum = card_type
for byte in id_bytes:
checksum ^= byte
frame = bytes([card_type]) + id_bytes + bytes([checksum])
encoded = frame.hex().upper().encode("ascii")
assert len(encoded) == 12
return encoded
def encode_stream(value, card_type=0x0F):
return bytes([START]) + encode_frame(value, card_type) + bytes([END])
def decode_frame(frame):
"""Reference decoder mirroring rdm6300.BaseReader._parse_fragment."""
nibbles = [int(ch, 16) for ch in frame.decode("ascii")]
def to_int(chunk):
out = 0
for item in chunk:
out = (out << 4) | item
return out
calculated = 0
for i in range(0, 10, 2):
calculated ^= (nibbles[i] << 4) | nibbles[i + 1]
received = to_int(nibbles[10:12])
return CardData(
value=to_int(nibbles[2:10]),
checksum=received,
is_valid=received == calculated,
type=to_int(nibbles[0:2]),
)
@pytest.mark.parametrize(
"value,expected",
[
(0x00C4A0B5, b"0F00C4A0B5DE"),
(0x1A2B3C4D, b"0F1A2B3C4D4F"),
],
)
def test_encoded_frame_bytes_are_stable(value, expected):
assert encode_frame(value) == expected
def test_frame_is_twelve_nibbles():
assert len(encode_frame(0x00C4A0B5)) == 12
def test_checksum_covers_type_byte_not_only_id():
"""Same id with a different type byte must change the checksum."""
assert encode_frame(0x00C4A0B5, 0x0F) != encode_frame(0x00C4A0B5, 0x10)
def test_round_trip_yields_valid_card():
card = decode_frame(encode_frame(0x00C4A0B5))
assert card.value == 0x00C4A0B5
assert card.type == 0x0F
assert card.checksum == 0xDE
assert card.is_valid is True
def test_round_trip_of_second_sample():
card = decode_frame(encode_frame(0x1A2B3C4D))
assert card.value == 0x1A2B3C4D
assert card.is_valid is True
def test_corrupted_checksum_is_invalid():
frame = bytearray(encode_frame(0x00C4A0B5))
frame[-1] = ord("0")
assert decode_frame(bytes(frame)).is_valid is False
def test_frame_with_wrong_nibble_count_is_rejected():
"""A 14 nibble frame leaves 2 nibbles buffered in the real reader."""
assert len(encode_frame(0x00C4A0B5)) == 12
fourteen = b"0F00C4A0B5DE00"
assert len(fourteen) == 14
def test_stream_wrapper_uses_stx_and_etx():
stream = encode_stream(0x00C4A0B5)
assert stream[0] == START
assert stream[-1] == END
assert stream[1:-1] == encode_frame(0x00C4A0B5)
+149
View File
@@ -0,0 +1,149 @@
"""Port selection and error explanation, using a fake reader library."""
import types
import pytest
import rdm_card_reader.reader as reader_module
from rdm_card_reader.card import CANDIDATE_PORTS
from rdm_card_reader.reader import explain_error, list_ports, open_reader
class FakeBaseReader:
"""Stands in for rdm6300.BaseReader; records the port it was built with."""
fail_on = ()
def __init__(self, port, heartbeat_interval=0.5):
if port in type(self).fail_on:
raise OSError("could not open port {}".format(port))
self.port = port
self.heartbeat_interval = heartbeat_interval
self.started = False
self.closed = False
def start(self):
self.started = True
def close(self):
self.closed = True
@pytest.fixture
def fake_library(monkeypatch):
module = types.SimpleNamespace(BaseReader=FakeBaseReader)
monkeypatch.setattr(reader_module, "rdm6300", module)
return module
@pytest.fixture(autouse=True)
def restore_library():
original = reader_module.rdm6300
yield
reader_module.rdm6300 = original
def test_default_port_order_matches_the_workstation_app():
assert CANDIDATE_PORTS == (
"/dev/ttyS0", "/dev/ttyAMA0", "/dev/ttyUSB0", "/dev/ttyACM0",
)
def test_first_working_port_wins(fake_library):
FakeBaseReader.fail_on = ("/dev/ttyS0", "/dev/ttyAMA0")
reader = open_reader(lambda event: None,
ports=["/dev/ttyS0", "/dev/ttyAMA0", "/dev/ttyUSB0"])
assert reader is not None
assert reader.port == "/dev/ttyUSB0"
def test_returns_none_when_no_port_works(fake_library):
FakeBaseReader.fail_on = ("/dev/ttyS0", "/dev/ttyAMA0")
assert open_reader(lambda event: None,
ports=["/dev/ttyS0", "/dev/ttyAMA0"]) is None
def test_failures_are_reported_with_the_port(fake_library):
FakeBaseReader.fail_on = ("/dev/ttyS0",)
seen = []
open_reader(lambda event: None, ports=["/dev/ttyS0"],
on_error=lambda port, message: seen.append((port, message)))
assert seen and seen[0][0] == "/dev/ttyS0"
def test_heartbeat_interval_is_passed_through(fake_library):
FakeBaseReader.fail_on = ()
reader = open_reader(lambda event: None, ports=["/dev/ttyS0"],
heartbeat_interval=1.5)
assert reader.heartbeat_interval == 1.5
def test_heartbeat_none_disables_removal_detection(fake_library):
FakeBaseReader.fail_on = ()
reader = open_reader(lambda event: None, ports=["/dev/ttyS0"],
heartbeat_interval=None)
assert reader.heartbeat_interval is None
def test_events_are_forwarded_to_the_callback(fake_library):
FakeBaseReader.fail_on = ()
received = []
reader = open_reader(received.append, ports=["/dev/ttyS0"])
class Card:
value = 7
checksum = 1
type = 15
is_valid = True
reader.card_inserted(Card())
assert len(received) == 1
assert received[0].value == 7
assert received[0].event == "card_inserted"
def test_card_removed_with_none_is_ignored(fake_library):
FakeBaseReader.fail_on = ()
received = []
reader = open_reader(received.append, ports=["/dev/ttyS0"])
reader.card_removed(None)
assert received == []
def test_invalid_card_is_forwarded(fake_library):
FakeBaseReader.fail_on = ()
received = []
reader = open_reader(received.append, ports=["/dev/ttyS0"])
class Card:
value = 1
checksum = 2
type = 15
is_valid = False
reader.invalid_card(Card())
assert received[0].event == "invalid_card"
def test_missing_library_raises(monkeypatch):
monkeypatch.setattr(reader_module, "rdm6300", None)
with pytest.raises(reader_module.ReaderUnavailable):
open_reader(lambda event: None)
@pytest.mark.parametrize(
"message,expected_fragment",
[
("[Errno 13] Permission denied: '/dev/ttyS0'", "dialout"),
("could not open port /dev/nope", "device not found"),
("something else", "something else"),
],
)
def test_explain_error(message, expected_fragment):
assert expected_fragment in explain_error("/dev/ttyS0", message)
def test_list_ports_shape():
info = list_ports()
assert set(info) == {"candidates", "detected"}
assert [port for port, _ in info["candidates"]] == list(CANDIDATE_PORTS)
+229
View File
@@ -0,0 +1,229 @@
"""Tests for the vendored wheels and their verification.
The bundled wheels let a device with no internet set itself up, so the things
that matter are: they are actually present and pure-Python (portable), the
checksums match, and a tampered wheel is refused rather than installed.
"""
import os
import shutil
import sys
SRC = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "src"
)
if SRC not in sys.path:
sys.path.insert(0, SRC)
import pytest # noqa: E402
from rdm_card_reader import bootstrap # noqa: E402
from rdm_card_reader.bootstrap import ( # noqa: E402
bundled_wheel_dir,
ensure_dependencies,
local_wheel_missing,
verify_wheels,
)
# -- the wheels that ship with the package ---------------------------------
def test_bundled_wheel_dir_exists():
folder = bundled_wheel_dir()
assert folder is not None
assert os.path.isdir(folder)
def test_both_required_wheels_are_present():
folder = bundled_wheel_dir()
names = os.listdir(folder)
assert any(n.startswith("rdm6300-") and n.endswith(".whl") for n in names)
assert any(n.startswith("pyserial-") and n.endswith(".whl") for n in names)
def test_bundled_wheels_are_pure_python():
"""A compiled wheel would only work on one architecture; these must not."""
folder = bundled_wheel_dir()
wheels = [n for n in os.listdir(folder) if n.endswith(".whl")]
assert wheels
for name in wheels:
assert name.endswith("-none-any.whl"), (
"{} is architecture-specific and cannot be shipped".format(name)
)
def test_bundled_wheels_verify_against_their_checksums():
folder = bundled_wheel_dir()
assert verify_wheels(folder) == []
def test_checksums_file_is_shipped():
folder = bundled_wheel_dir()
assert os.path.exists(os.path.join(folder, "SHA256SUMS"))
def test_total_vendored_size_is_reasonable():
"""Guard against someone dropping a huge binary into the repo."""
folder = bundled_wheel_dir()
total = sum(
os.path.getsize(os.path.join(folder, name))
for name in os.listdir(folder)
)
assert total < 2 * 1024 * 1024
# -- verification -----------------------------------------------------------
def test_tampered_wheel_is_detected(tmp_path):
folder = tmp_path / "wheels"
folder.mkdir()
wheel = folder / "rdm6300-0.1.1-py3-none-any.whl"
wheel.write_bytes(b"not the real wheel")
(folder / "SHA256SUMS").write_text(
"afb067da8b8d23a477138ca67d4ff1d198f12d157b64668c9dd3cbd7df54e74e"
" rdm6300-0.1.1-py3-none-any.whl\n"
)
problems = verify_wheels(str(folder))
assert problems
assert any("checksum mismatch" in p for p in problems)
def test_missing_wheel_is_reported(tmp_path):
folder = tmp_path / "wheels"
folder.mkdir()
(folder / "SHA256SUMS").write_text(
"abc123 rdm6300-0.1.1-py3-none-any.whl\n"
)
problems = verify_wheels(str(folder))
assert any("missing" in p for p in problems)
def test_no_sums_file_means_nothing_to_check(tmp_path):
folder = tmp_path / "wheels"
folder.mkdir()
(folder / "x-1.0-py3-none-any.whl").write_bytes(b"x")
assert verify_wheels(str(folder)) == []
def test_verification_passes_for_untouched_copy(tmp_path):
source = bundled_wheel_dir()
dest = tmp_path / "wheels"
shutil.copytree(source, dest)
assert verify_wheels(str(dest)) == []
# -- coverage check ---------------------------------------------------------
def test_local_wheel_missing_detects_absent_requirement(tmp_path):
folder = tmp_path / "wheels"
folder.mkdir()
assert local_wheel_missing(["rdm6300>=0.1.1"], str(folder)) == ["rdm6300>=0.1.1"]
def test_local_wheel_missing_accepts_present_requirement():
folder = bundled_wheel_dir()
assert local_wheel_missing(["rdm6300>=0.1.1", "pyserial>=3.5"], folder) == []
def test_local_wheel_missing_normalises_underscores(tmp_path):
folder = tmp_path / "wheels"
folder.mkdir()
(folder / "charset_normalizer-3.4.1-py3-none-any.whl").write_bytes(b"x")
assert local_wheel_missing(["charset-normalizer>=3"], str(folder)) == []
# -- integration into ensure_dependencies -----------------------------------
def test_bundled_wheels_used_automatically(monkeypatch):
"""No --wheel-dir given, so the bundled ones must be picked up."""
seen = {}
monkeypatch.setattr(
bootstrap, "missing_packages",
lambda packages=None: [] if seen.get("done") else [("rdm6300", "rdm6300>=0.1.1")],
)
def fake_install(requirements, wheel_dir=None, logger=None, timeout=300, no_deps=False):
seen["wheel_dir"] = wheel_dir
seen["done"] = True
return True
monkeypatch.setattr(bootstrap, "install", fake_install)
lines = []
assert ensure_dependencies(logger=lines.append) is True
assert seen["wheel_dir"] == bundled_wheel_dir()
assert any("bundled" in line for line in lines)
def test_tampered_bundle_falls_back_to_pypi(monkeypatch, tmp_path):
"""A bad checksum must not block installation - but must be reported."""
folder = tmp_path / "wheels"
folder.mkdir()
(folder / "rdm6300-0.1.1-py3-none-any.whl").write_bytes(b"tampered")
(folder / "SHA256SUMS").write_text("deadbeef rdm6300-0.1.1-py3-none-any.whl\n")
seen = {}
monkeypatch.setattr(bootstrap, "bundled_wheel_dir", lambda: str(folder))
monkeypatch.setattr(
bootstrap, "missing_packages",
lambda packages=None: [] if seen.get("done") else [("rdm6300", "rdm6300>=0.1.1")],
)
def fake_install(requirements, wheel_dir=None, logger=None, timeout=300, no_deps=False):
seen["wheel_dir"] = wheel_dir
seen["done"] = True
return True
monkeypatch.setattr(bootstrap, "install", fake_install)
lines = []
assert ensure_dependencies(logger=lines.append) is True
assert seen["wheel_dir"] is None # fell back to PyPI
assert any("checksum mismatch" in line for line in lines)
def test_explicit_wheel_dir_overrides_bundled(monkeypatch, tmp_path):
folder = tmp_path / "wheels"
folder.mkdir()
(folder / "rdm6300-0.1.1-py3-none-any.whl").write_bytes(b"x")
(folder / "pyserial-3.5-py2.py3-none-any.whl").write_bytes(b"x")
seen = {}
monkeypatch.setattr(
bootstrap, "missing_packages",
lambda packages=None: [] if seen.get("done") else [("rdm6300", "rdm6300>=0.1.1")],
)
def fake_install(requirements, wheel_dir=None, logger=None, timeout=300, no_deps=False):
seen["wheel_dir"] = wheel_dir
seen["done"] = True
return True
monkeypatch.setattr(bootstrap, "install", fake_install)
ensure_dependencies(wheel_dir=str(folder))
assert seen["wheel_dir"] == str(folder)
def test_bundle_not_covering_a_requirement_falls_back(monkeypatch, tmp_path):
"""A bundle missing one package must not cause a guaranteed pip failure."""
folder = tmp_path / "wheels"
folder.mkdir()
(folder / "rdm6300-0.1.1-py3-none-any.whl").write_bytes(b"x")
seen = {}
monkeypatch.setattr(bootstrap, "bundled_wheel_dir", lambda: str(folder))
monkeypatch.setattr(
bootstrap, "missing_packages",
lambda packages=None: [] if seen.get("done")
else [("rdm6300", "rdm6300>=0.1.1"), ("serial", "pyserial>=3.5")],
)
def fake_install(requirements, wheel_dir=None, logger=None, timeout=300, no_deps=False):
seen["wheel_dir"] = wheel_dir
seen["wheel_dir_at_call"] = wheel_dir
seen["done"] = True
return True
monkeypatch.setattr(bootstrap, "install", fake_install)
lines = []
ensure_dependencies(logger=lines.append)
assert seen["wheel_dir"] is None
assert any("do not cover" in line for line in lines)