Initializare dupa curatare folder
This commit is contained in:
+12
@@ -0,0 +1,12 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.egg-info/
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
.pytest_cache/
|
||||||
|
.coverage
|
||||||
|
htmlcov/
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
Executable
+261
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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"]
|
||||||
@@ -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__",
|
||||||
|
]
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
"""Allow ``python -m rdm_card_reader``."""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from .cli import main
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -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
|
||||||
@@ -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")
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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())
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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"
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -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}
|
||||||
@@ -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
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,8 @@
|
|||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
SRC = os.path.join(
|
||||||
|
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "src"
|
||||||
|
)
|
||||||
|
if SRC not in sys.path:
|
||||||
|
sys.path.insert(0, SRC)
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
"""Tests for the dependency bootstrap.
|
||||||
|
|
||||||
|
The behaviour that matters: a normal start must not touch the network, a
|
||||||
|
missing library should be installed once, and a failed install must report
|
||||||
|
clearly instead of crashing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
SRC = os.path.join(
|
||||||
|
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "src"
|
||||||
|
)
|
||||||
|
if SRC not in sys.path:
|
||||||
|
sys.path.insert(0, SRC)
|
||||||
|
|
||||||
|
import pytest # noqa: E402
|
||||||
|
|
||||||
|
from rdm_card_reader import bootstrap # noqa: E402
|
||||||
|
from rdm_card_reader.bootstrap import ( # noqa: E402
|
||||||
|
_pip_command,
|
||||||
|
ensure_dependencies,
|
||||||
|
install,
|
||||||
|
missing_packages,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeCompleted:
|
||||||
|
def __init__(self, returncode=0, stdout="", stderr=""):
|
||||||
|
self.returncode = returncode
|
||||||
|
self.stdout = stdout
|
||||||
|
self.stderr = stderr
|
||||||
|
|
||||||
|
|
||||||
|
# -- detection --------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_nothing_missing_for_installed_packages():
|
||||||
|
assert missing_packages([("sys", "sys")]) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_importable_name_is_reported_missing():
|
||||||
|
result = missing_packages([("definitely_not_a_real_module_xyz", "pkg>=1")])
|
||||||
|
assert result == [("definitely_not_a_real_module_xyz", "pkg>=1")]
|
||||||
|
|
||||||
|
|
||||||
|
# -- pip command ------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_pip_command_uses_this_interpreter():
|
||||||
|
command = _pip_command(["pkg>=1"], wheel_dir=None)
|
||||||
|
assert command[:3] == [sys.executable, "-m", "pip"]
|
||||||
|
assert "pkg>=1" in command
|
||||||
|
|
||||||
|
|
||||||
|
def test_pip_command_prefers_local_wheels_when_given():
|
||||||
|
command = _pip_command(["pkg>=1"], wheel_dir="/opt/wheels")
|
||||||
|
assert "--no-index" in command
|
||||||
|
assert "--find-links" in command
|
||||||
|
assert "/opt/wheels" in command
|
||||||
|
|
||||||
|
|
||||||
|
def test_pip_command_adds_break_system_packages_outside_venv(monkeypatch):
|
||||||
|
"""Debian's pip refuses to touch system Python without this flag."""
|
||||||
|
monkeypatch.delenv("VIRTUAL_ENV", raising=False)
|
||||||
|
monkeypatch.setattr(bootstrap, "in_virtualenv", lambda: False)
|
||||||
|
assert "--break-system-packages" in _pip_command(["pkg"], None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_pip_command_omits_flag_inside_venv(monkeypatch):
|
||||||
|
monkeypatch.setattr(bootstrap, "in_virtualenv", lambda: True)
|
||||||
|
assert "--break-system-packages" not in _pip_command(["pkg"], None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_in_virtualenv_detects_env_var(monkeypatch):
|
||||||
|
monkeypatch.setenv("VIRTUAL_ENV", "/tmp/venv")
|
||||||
|
assert bootstrap.in_virtualenv() is True
|
||||||
|
|
||||||
|
|
||||||
|
# -- install ----------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_install_reports_success(monkeypatch):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
bootstrap.subprocess, "run", lambda *a, **k: FakeCompleted(0)
|
||||||
|
)
|
||||||
|
assert install(["pkg"]) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_install_returns_false_on_nonzero_exit(monkeypatch):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
bootstrap.subprocess, "run",
|
||||||
|
lambda *a, **k: FakeCompleted(1, stderr="ERROR: no matching distribution"),
|
||||||
|
)
|
||||||
|
lines = []
|
||||||
|
assert install(["pkg"], logger=lines.append) is False
|
||||||
|
assert any("no matching distribution" in line for line in lines)
|
||||||
|
|
||||||
|
|
||||||
|
def test_install_survives_timeout(monkeypatch):
|
||||||
|
def boom(*args, **kwargs):
|
||||||
|
raise bootstrap.subprocess.TimeoutExpired(cmd="pip", timeout=1)
|
||||||
|
|
||||||
|
monkeypatch.setattr(bootstrap.subprocess, "run", boom)
|
||||||
|
assert install(["pkg"]) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_install_survives_pip_being_unrunnable(monkeypatch):
|
||||||
|
def boom(*args, **kwargs):
|
||||||
|
raise OSError("no such file or directory")
|
||||||
|
|
||||||
|
monkeypatch.setattr(bootstrap.subprocess, "run", boom)
|
||||||
|
assert install(["pkg"]) is False
|
||||||
|
|
||||||
|
|
||||||
|
# -- ensure_dependencies ----------------------------------------------------
|
||||||
|
|
||||||
|
def test_no_install_when_everything_present(monkeypatch):
|
||||||
|
"""A normal start must not shell out to pip at all."""
|
||||||
|
called = []
|
||||||
|
monkeypatch.setattr(bootstrap, "missing_packages", lambda packages=None: [])
|
||||||
|
monkeypatch.setattr(bootstrap, "install", lambda *a, **k: called.append(1))
|
||||||
|
assert ensure_dependencies() is True
|
||||||
|
assert called == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_installs_when_missing(monkeypatch):
|
||||||
|
state = {"installed": False}
|
||||||
|
monkeypatch.setattr(
|
||||||
|
bootstrap, "missing_packages",
|
||||||
|
lambda packages=None: [] if state["installed"] else [("rdm6300", "rdm6300>=0.1.1")],
|
||||||
|
)
|
||||||
|
|
||||||
|
def fake_install(requirements, wheel_dir=None, logger=None, timeout=300, no_deps=False):
|
||||||
|
state["installed"] = True
|
||||||
|
return True
|
||||||
|
|
||||||
|
monkeypatch.setattr(bootstrap, "install", fake_install)
|
||||||
|
assert ensure_dependencies() is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_reports_failure_when_still_missing(monkeypatch):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
bootstrap, "missing_packages",
|
||||||
|
lambda packages=None: [("rdm6300", "rdm6300>=0.1.1")],
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(bootstrap, "install", lambda *a, **k: False)
|
||||||
|
lines = []
|
||||||
|
assert ensure_dependencies(logger=lines.append) is False
|
||||||
|
assert any("Install manually with" in line for line in lines)
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_install_flag_reports_manual_hint(monkeypatch):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
bootstrap, "missing_packages",
|
||||||
|
lambda packages=None: [("rdm6300", "rdm6300>=0.1.1")],
|
||||||
|
)
|
||||||
|
called = []
|
||||||
|
monkeypatch.setattr(bootstrap, "install", lambda *a, **k: called.append(1))
|
||||||
|
lines = []
|
||||||
|
assert ensure_dependencies(auto_install=False, logger=lines.append) is False
|
||||||
|
assert called == [] # never shelled out
|
||||||
|
assert any("disabled" in line for line in lines)
|
||||||
|
|
||||||
|
|
||||||
|
def test_wheel_dir_is_passed_through(monkeypatch):
|
||||||
|
"""An explicit --wheel-dir that covers everything is used as given."""
|
||||||
|
seen = {}
|
||||||
|
monkeypatch.setattr(
|
||||||
|
bootstrap, "missing_packages",
|
||||||
|
lambda packages=None: [] if seen.get("done") else [("x", "x>=1")],
|
||||||
|
)
|
||||||
|
|
||||||
|
def fake_install(requirements, wheel_dir=None, logger=None, timeout=300, no_deps=False):
|
||||||
|
seen["wheel_dir"] = wheel_dir
|
||||||
|
seen["done"] = True
|
||||||
|
return True
|
||||||
|
|
||||||
|
monkeypatch.setattr(bootstrap, "install", fake_install)
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as folder:
|
||||||
|
open(os.path.join(folder, "x-1.0-py3-none-any.whl"), "wb").close()
|
||||||
|
ensure_dependencies(wheel_dir=folder)
|
||||||
|
assert seen["wheel_dir"] == folder
|
||||||
|
|
||||||
|
|
||||||
|
def test_incomplete_explicit_wheel_dir_is_flagged(monkeypatch):
|
||||||
|
"""An unusable --wheel-dir should be called out, not silently ignored."""
|
||||||
|
seen = {}
|
||||||
|
monkeypatch.setattr(
|
||||||
|
bootstrap, "missing_packages",
|
||||||
|
lambda packages=None: [] if seen.get("done") else [("x", "x>=1")],
|
||||||
|
)
|
||||||
|
|
||||||
|
def fake_install(requirements, wheel_dir=None, logger=None, timeout=300, no_deps=False):
|
||||||
|
seen["done"] = True
|
||||||
|
return True
|
||||||
|
|
||||||
|
monkeypatch.setattr(bootstrap, "install", fake_install)
|
||||||
|
lines = []
|
||||||
|
with tempfile.TemporaryDirectory() as folder:
|
||||||
|
ensure_dependencies(wheel_dir=folder, logger=lines.append)
|
||||||
|
assert any("you gave is incomplete" in line for line in lines)
|
||||||
@@ -0,0 +1,501 @@
|
|||||||
|
"""Tests for the HTTP bridge.
|
||||||
|
|
||||||
|
Two things matter most here:
|
||||||
|
|
||||||
|
1. The bridge publishes card events without claiming to authenticate anyone.
|
||||||
|
2. A page on another origin must not be able to read your taps.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
SRC = os.path.join(
|
||||||
|
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "src"
|
||||||
|
)
|
||||||
|
if SRC not in sys.path:
|
||||||
|
sys.path.insert(0, SRC)
|
||||||
|
|
||||||
|
import pytest # noqa: E402
|
||||||
|
|
||||||
|
from rdm_card_reader import bridge # noqa: E402
|
||||||
|
from rdm_card_reader.bridge import ( # noqa: E402
|
||||||
|
MODE_LOGIN,
|
||||||
|
MODE_MONITOR,
|
||||||
|
EventHub,
|
||||||
|
create_server,
|
||||||
|
)
|
||||||
|
from rdm_card_reader.card import CardEvent # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def make_event(event="card_inserted", value=12886197):
|
||||||
|
return CardEvent(
|
||||||
|
event=event,
|
||||||
|
timestamp="2026-10-01 12:00:00",
|
||||||
|
value=value,
|
||||||
|
checksum=222,
|
||||||
|
card_type=15,
|
||||||
|
is_valid=True,
|
||||||
|
port="/dev/ttyUSB0",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# -- hub --------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_hub_publishes_to_subscriber():
|
||||||
|
hub = EventHub()
|
||||||
|
queue = hub.subscribe()
|
||||||
|
hub.publish(make_event())
|
||||||
|
assert len(queue) == 1
|
||||||
|
assert queue[0]["id"] == 12886197
|
||||||
|
|
||||||
|
|
||||||
|
def test_hub_login_mode_hides_removals():
|
||||||
|
"""Removal must not look like a second authentication attempt."""
|
||||||
|
hub = EventHub(mode=MODE_LOGIN)
|
||||||
|
queue = hub.subscribe()
|
||||||
|
hub.publish(make_event("card_removed"))
|
||||||
|
assert len(queue) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_hub_monitor_mode_publishes_removals():
|
||||||
|
hub = EventHub(mode=MODE_MONITOR)
|
||||||
|
queue = hub.subscribe()
|
||||||
|
hub.publish(make_event("card_removed"))
|
||||||
|
assert len(queue) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_hub_unsubscribe_stops_delivery():
|
||||||
|
hub = EventHub()
|
||||||
|
queue = hub.subscribe()
|
||||||
|
hub.unsubscribe(queue)
|
||||||
|
hub.publish(make_event())
|
||||||
|
assert len(queue) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_hub_tracks_last_tap_only_on_insert():
|
||||||
|
hub = EventHub(mode=MODE_MONITOR)
|
||||||
|
hub.publish(make_event("card_inserted", value=111))
|
||||||
|
hub.publish(make_event("card_removed", value=111))
|
||||||
|
assert hub.last()["id"] == 111
|
||||||
|
|
||||||
|
|
||||||
|
def test_hub_history_is_bounded():
|
||||||
|
hub = EventHub(history=3)
|
||||||
|
for value in range(10):
|
||||||
|
hub.publish(make_event(value=value))
|
||||||
|
assert len(hub.taps(limit=99)) == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_hub_health_reports_state():
|
||||||
|
hub = EventHub()
|
||||||
|
hub.set_port("/dev/ttyUSB0")
|
||||||
|
hub.publish(make_event())
|
||||||
|
health = hub.health()
|
||||||
|
assert health["port"] == "/dev/ttyUSB0"
|
||||||
|
assert health["counts"]["card_inserted"] == 1
|
||||||
|
assert health["last_tap"]["id"] == 12886197
|
||||||
|
assert health["last_tap_age_seconds"] is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_hub_rejects_unknown_mode():
|
||||||
|
hub = EventHub()
|
||||||
|
assert hub.set_mode("bogus") is False
|
||||||
|
assert hub.mode == MODE_LOGIN
|
||||||
|
|
||||||
|
|
||||||
|
def test_hub_accepts_valid_mode_switch():
|
||||||
|
hub = EventHub()
|
||||||
|
assert hub.set_mode(MODE_MONITOR) is True
|
||||||
|
assert hub.mode == MODE_MONITOR
|
||||||
|
|
||||||
|
|
||||||
|
# -- HTTP -------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def server():
|
||||||
|
"""A live bridge on an ephemeral port, torn down after each test."""
|
||||||
|
hub = EventHub()
|
||||||
|
hub.set_port("/dev/ttyUSB0")
|
||||||
|
srv = create_server(hub, host="127.0.0.1", port=0)
|
||||||
|
thread = threading.Thread(target=srv.serve_forever, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
yield srv, hub
|
||||||
|
srv.shutdown()
|
||||||
|
srv.server_close()
|
||||||
|
|
||||||
|
|
||||||
|
def base_url(srv):
|
||||||
|
host, port = srv.server_address[:2]
|
||||||
|
return "http://{}:{}".format(host, port)
|
||||||
|
|
||||||
|
|
||||||
|
def get(srv, path, headers=None):
|
||||||
|
request = urllib.request.Request(base_url(srv) + path, headers=headers or {})
|
||||||
|
with urllib.request.urlopen(request, timeout=5) as response:
|
||||||
|
return response.status, response.headers, response.read()
|
||||||
|
|
||||||
|
|
||||||
|
def test_health_endpoint(server):
|
||||||
|
srv, hub = server
|
||||||
|
status, _, body = get(srv, "/api/health")
|
||||||
|
assert status == 200
|
||||||
|
payload = json.loads(body)
|
||||||
|
assert payload["status"] == "ok"
|
||||||
|
assert payload["port"] == "/dev/ttyUSB0"
|
||||||
|
assert payload["mode"] == MODE_LOGIN
|
||||||
|
|
||||||
|
|
||||||
|
def test_last_returns_204_when_nothing_read(server):
|
||||||
|
srv, _ = server
|
||||||
|
status, _, _ = get(srv, "/api/last")
|
||||||
|
assert status == 204
|
||||||
|
|
||||||
|
|
||||||
|
def test_last_returns_the_tap(server):
|
||||||
|
srv, hub = server
|
||||||
|
hub.publish(make_event())
|
||||||
|
status, _, body = get(srv, "/api/last")
|
||||||
|
assert status == 200
|
||||||
|
assert json.loads(body)["id"] == 12886197
|
||||||
|
|
||||||
|
|
||||||
|
def test_taps_endpoint_honours_limit(server):
|
||||||
|
srv, hub = server
|
||||||
|
for value in range(5):
|
||||||
|
hub.publish(make_event(value=value))
|
||||||
|
_, _, body = get(srv, "/api/taps?limit=2")
|
||||||
|
assert len(json.loads(body)["taps"]) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_path_is_404(server):
|
||||||
|
srv, _ = server
|
||||||
|
with pytest.raises(urllib.error.HTTPError) as excinfo:
|
||||||
|
get(srv, "/api/nope")
|
||||||
|
assert excinfo.value.code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_health_available_without_origin(server):
|
||||||
|
"""Monitoring must work for a plain client with no Origin header."""
|
||||||
|
srv, _ = server
|
||||||
|
status, _, _ = get(srv, "/api/health")
|
||||||
|
assert status == 200
|
||||||
|
|
||||||
|
|
||||||
|
# -- origin policy ----------------------------------------------------------
|
||||||
|
|
||||||
|
def test_cross_origin_refused_when_no_allowlist(server):
|
||||||
|
"""A third-party page must not be able to read card ids."""
|
||||||
|
srv, _ = server
|
||||||
|
with pytest.raises(urllib.error.HTTPError) as excinfo:
|
||||||
|
get(srv, "/api/last", headers={"Origin": "https://evil.example"})
|
||||||
|
assert excinfo.value.code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_allowlisted_origin_permitted_and_echoed():
|
||||||
|
hub = EventHub()
|
||||||
|
srv = create_server(hub, host="127.0.0.1", port=0,
|
||||||
|
allowed_origins=["https://kiosk.example"])
|
||||||
|
thread = threading.Thread(target=srv.serve_forever, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
try:
|
||||||
|
hub.publish(make_event())
|
||||||
|
status, headers, body = get(
|
||||||
|
srv, "/api/last", headers={"Origin": "https://kiosk.example"}
|
||||||
|
)
|
||||||
|
assert status == 200
|
||||||
|
assert headers.get("Access-Control-Allow-Origin") == "https://kiosk.example"
|
||||||
|
assert json.loads(body)["id"] == 12886197
|
||||||
|
finally:
|
||||||
|
srv.shutdown()
|
||||||
|
srv.server_close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_unlisted_origin_still_refused_when_allowlist_set():
|
||||||
|
hub = EventHub()
|
||||||
|
srv = create_server(hub, host="127.0.0.1", port=0,
|
||||||
|
allowed_origins=["https://kiosk.example"])
|
||||||
|
thread = threading.Thread(target=srv.serve_forever, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
try:
|
||||||
|
with pytest.raises(urllib.error.HTTPError) as excinfo:
|
||||||
|
get(srv, "/api/last", headers={"Origin": "https://other.example"})
|
||||||
|
assert excinfo.value.code == 403
|
||||||
|
finally:
|
||||||
|
srv.shutdown()
|
||||||
|
srv.server_close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_token_not_required_from_loopback():
|
||||||
|
hub = EventHub()
|
||||||
|
srv = create_server(hub, host="127.0.0.1", port=0, token="sekrit")
|
||||||
|
thread = threading.Thread(target=srv.serve_forever, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
try:
|
||||||
|
status, _, _ = get(srv, "/api/health")
|
||||||
|
assert status == 200
|
||||||
|
finally:
|
||||||
|
srv.shutdown()
|
||||||
|
srv.server_close()
|
||||||
|
|
||||||
|
|
||||||
|
# -- mode switching ---------------------------------------------------------
|
||||||
|
|
||||||
|
def post(srv, path, payload, headers=None):
|
||||||
|
data = json.dumps(payload).encode()
|
||||||
|
request = urllib.request.Request(
|
||||||
|
base_url(srv) + path, data=data, method="POST",
|
||||||
|
headers={"Content-Type": "application/json", **(headers or {})},
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(request, timeout=5) as response:
|
||||||
|
return response.status, response.read()
|
||||||
|
|
||||||
|
|
||||||
|
def test_mode_can_be_switched(server):
|
||||||
|
srv, hub = server
|
||||||
|
status, body = post(srv, "/api/mode", {"mode": MODE_MONITOR})
|
||||||
|
assert status == 200
|
||||||
|
assert json.loads(body)["mode"] == MODE_MONITOR
|
||||||
|
assert hub.mode == MODE_MONITOR
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_mode_rejected(server):
|
||||||
|
srv, _ = server
|
||||||
|
with pytest.raises(urllib.error.HTTPError) as excinfo:
|
||||||
|
post(srv, "/api/mode", {"mode": "nonsense"})
|
||||||
|
assert excinfo.value.code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_malformed_json_rejected(server):
|
||||||
|
srv, _ = server
|
||||||
|
request = urllib.request.Request(
|
||||||
|
base_url(srv) + "/api/mode", data=b"{not json",
|
||||||
|
method="POST", headers={"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
with pytest.raises(urllib.error.HTTPError) as excinfo:
|
||||||
|
urllib.request.urlopen(request, timeout=5)
|
||||||
|
assert excinfo.value.code == 400
|
||||||
|
|
||||||
|
|
||||||
|
# -- SSE --------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_sse_stream_delivers_a_tap(server):
|
||||||
|
"""The stream used by the web page must push a tap as it happens."""
|
||||||
|
srv, hub = server
|
||||||
|
received = []
|
||||||
|
|
||||||
|
def listen():
|
||||||
|
with urllib.request.urlopen(base_url(srv) + "/api/events", timeout=10) as resp:
|
||||||
|
for raw in resp:
|
||||||
|
line = raw.decode("utf-8").strip()
|
||||||
|
if line.startswith("data: "):
|
||||||
|
received.append(json.loads(line[6:]))
|
||||||
|
return
|
||||||
|
|
||||||
|
thread = threading.Thread(target=listen, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
time.sleep(0.4) # let the subscription register
|
||||||
|
hub.publish(make_event())
|
||||||
|
thread.join(timeout=6)
|
||||||
|
assert received and received[0]["id"] == 12886197
|
||||||
|
|
||||||
|
|
||||||
|
def test_loopback_helper():
|
||||||
|
assert bridge.is_loopback("127.0.0.1") is True
|
||||||
|
assert bridge.is_loopback("0.0.0.0") is False
|
||||||
|
|
||||||
|
|
||||||
|
# -- latched card (the "always available" behaviour) ------------------------
|
||||||
|
|
||||||
|
def test_card_not_present_before_any_tap():
|
||||||
|
hub = EventHub()
|
||||||
|
state = hub.current_card()
|
||||||
|
assert state["present"] is False
|
||||||
|
assert state["card"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_card_stays_available_after_the_page_loads():
|
||||||
|
"""The tap must outlive the page that reads it."""
|
||||||
|
hub = EventHub()
|
||||||
|
hub.publish(make_event(value=555))
|
||||||
|
for _ in range(3): # simulated later page loads
|
||||||
|
state = hub.current_card()
|
||||||
|
assert state["present"] is True
|
||||||
|
assert state["card"]["id"] == 555
|
||||||
|
|
||||||
|
|
||||||
|
def test_card_cleared_when_removed():
|
||||||
|
"""Lifting the card must NOT lose the tap - only mark it as gone."""
|
||||||
|
hub = EventHub(tap_ttl=30)
|
||||||
|
hub.publish(make_event())
|
||||||
|
hub.publish(make_event("card_removed"))
|
||||||
|
state = hub.current_card()
|
||||||
|
assert state["present"] is False # no longer physically on the reader
|
||||||
|
assert state["claimable"] is True # but still spendable
|
||||||
|
assert state["card"]["id"] == 12886197
|
||||||
|
|
||||||
|
|
||||||
|
def test_quick_tap_is_not_lost():
|
||||||
|
"""The real-world case: card lifted before the page polls."""
|
||||||
|
hub = EventHub(tap_ttl=10)
|
||||||
|
hub.publish(make_event(value=31337))
|
||||||
|
hub.publish(make_event("card_removed")) # lifted immediately
|
||||||
|
payload, error, _ = hub.consume_card()
|
||||||
|
assert error is None
|
||||||
|
assert payload["card"]["id"] == 31337
|
||||||
|
|
||||||
|
|
||||||
|
def test_claim_returns_the_card_once():
|
||||||
|
hub = EventHub()
|
||||||
|
hub.publish(make_event(value=777))
|
||||||
|
payload, error, status = hub.consume_card()
|
||||||
|
assert error is None and status == 200
|
||||||
|
assert payload["card"]["id"] == 777
|
||||||
|
assert payload["consumed"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_claim_is_single_use():
|
||||||
|
"""A page reload must not turn one physical tap into two logins."""
|
||||||
|
hub = EventHub()
|
||||||
|
hub.publish(make_event())
|
||||||
|
assert hub.consume_card()[1] is None
|
||||||
|
payload, error, status = hub.consume_card()
|
||||||
|
assert payload is None
|
||||||
|
assert error == "card already used"
|
||||||
|
assert status == 409
|
||||||
|
|
||||||
|
|
||||||
|
def test_claim_without_a_card_is_conflict():
|
||||||
|
hub = EventHub()
|
||||||
|
payload, error, status = hub.consume_card()
|
||||||
|
assert payload is None
|
||||||
|
assert error == "no card read yet"
|
||||||
|
assert status == 409
|
||||||
|
|
||||||
|
|
||||||
|
def test_removing_and_replacing_the_card_allows_a_new_claim():
|
||||||
|
"""Lifting the card and tapping it again is a fresh authentication."""
|
||||||
|
hub = EventHub(tap_ttl=30)
|
||||||
|
hub.publish(make_event(value=111))
|
||||||
|
assert hub.consume_card()[1] is None
|
||||||
|
hub.publish(make_event("card_removed"))
|
||||||
|
hub.publish(make_event(value=111))
|
||||||
|
payload, error, _ = hub.consume_card()
|
||||||
|
assert error is None
|
||||||
|
assert payload["card"]["id"] == 111
|
||||||
|
|
||||||
|
|
||||||
|
def test_card_endpoint_reports_claimable_after_lift(server):
|
||||||
|
srv, hub = server
|
||||||
|
hub.publish(make_event(value=8080))
|
||||||
|
hub.publish(make_event("card_removed"))
|
||||||
|
_, _, body = get(srv, "/api/card")
|
||||||
|
payload = json.loads(body)
|
||||||
|
assert payload["present"] is False
|
||||||
|
assert payload["claimable"] is True
|
||||||
|
assert payload["card"]["id"] == 8080
|
||||||
|
|
||||||
|
|
||||||
|
def test_claim_expires_after_ttl():
|
||||||
|
hub = EventHub(tap_ttl=0.05)
|
||||||
|
hub.publish(make_event())
|
||||||
|
time.sleep(0.12)
|
||||||
|
payload, error, status = hub.consume_card()
|
||||||
|
assert payload is None
|
||||||
|
assert error == "card tap expired"
|
||||||
|
assert status == 410
|
||||||
|
|
||||||
|
|
||||||
|
def test_ttl_zero_never_expires_while_present():
|
||||||
|
hub = EventHub(tap_ttl=0)
|
||||||
|
hub.publish(make_event())
|
||||||
|
state = hub.current_card()
|
||||||
|
assert state["expired"] is False
|
||||||
|
assert state["claimable"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_tap_becomes_unclaimable_after_ttl():
|
||||||
|
hub = EventHub(tap_ttl=0.05)
|
||||||
|
hub.publish(make_event())
|
||||||
|
time.sleep(0.12)
|
||||||
|
assert hub.current_card()["claimable"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_new_tap_replaces_an_unclaimed_one():
|
||||||
|
hub = EventHub(tap_ttl=30)
|
||||||
|
hub.publish(make_event(value=111))
|
||||||
|
hub.publish(make_event(value=222))
|
||||||
|
payload, error, _ = hub.consume_card()
|
||||||
|
assert error is None
|
||||||
|
assert payload["card"]["id"] == 222
|
||||||
|
|
||||||
|
|
||||||
|
def test_health_shows_card_presence():
|
||||||
|
hub = EventHub()
|
||||||
|
hub.publish(make_event())
|
||||||
|
health = hub.health()
|
||||||
|
assert health["card_present"] is True
|
||||||
|
assert health["card_consumed"] is False
|
||||||
|
|
||||||
|
|
||||||
|
# -- latched card over HTTP -------------------------------------------------
|
||||||
|
|
||||||
|
def test_card_endpoint_reports_presence(server):
|
||||||
|
srv, hub = server
|
||||||
|
_, _, body = get(srv, "/api/card")
|
||||||
|
assert json.loads(body)["present"] is False
|
||||||
|
|
||||||
|
hub.publish(make_event(value=4242))
|
||||||
|
_, _, body = get(srv, "/api/card")
|
||||||
|
payload = json.loads(body)
|
||||||
|
assert payload["present"] is True
|
||||||
|
assert payload["card"]["id"] == 4242
|
||||||
|
|
||||||
|
|
||||||
|
def test_claim_endpoint_works_once(server):
|
||||||
|
srv, hub = server
|
||||||
|
hub.publish(make_event(value=9999))
|
||||||
|
|
||||||
|
status, body = post(srv, "/api/card/claim", {})
|
||||||
|
assert status == 200
|
||||||
|
assert json.loads(body)["card"]["id"] == 9999
|
||||||
|
|
||||||
|
with pytest.raises(urllib.error.HTTPError) as excinfo:
|
||||||
|
post(srv, "/api/card/claim", {})
|
||||||
|
assert excinfo.value.code == 409
|
||||||
|
detail = json.loads(excinfo.value.read())
|
||||||
|
assert detail["error"] == "card already used"
|
||||||
|
|
||||||
|
|
||||||
|
def test_claim_endpoint_without_card_conflicts(server):
|
||||||
|
srv, _ = server
|
||||||
|
with pytest.raises(urllib.error.HTTPError) as excinfo:
|
||||||
|
post(srv, "/api/card/claim", {})
|
||||||
|
assert excinfo.value.code == 409
|
||||||
|
|
||||||
|
|
||||||
|
def test_claim_refused_from_other_origin(server):
|
||||||
|
"""Another site must not be able to claim the card either."""
|
||||||
|
srv, hub = server
|
||||||
|
hub.publish(make_event())
|
||||||
|
with pytest.raises(urllib.error.HTTPError) as excinfo:
|
||||||
|
post(srv, "/api/card/claim", {},
|
||||||
|
headers={"Origin": "https://evil.example"})
|
||||||
|
assert excinfo.value.code == 403
|
||||||
|
# the tap must still be claimable by the real caller
|
||||||
|
assert hub.current_card()["consumed"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_claim_does_not_consume_on_refused_origin(server):
|
||||||
|
srv, hub = server
|
||||||
|
hub.publish(make_event(value=1234))
|
||||||
|
with pytest.raises(urllib.error.HTTPError):
|
||||||
|
post(srv, "/api/card/claim", {},
|
||||||
|
headers={"Origin": "https://evil.example"})
|
||||||
|
payload, error, _ = hub.consume_card()
|
||||||
|
assert error is None
|
||||||
|
assert payload["card"]["id"] == 1234
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
"""The event model and its output formatting."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from rdm_card_reader.card import (
|
||||||
|
CONFIG_CARD_VALUE,
|
||||||
|
CardEvent,
|
||||||
|
format_event,
|
||||||
|
)
|
||||||
|
|
||||||
|
SAMPLE = CardEvent(
|
||||||
|
event="card_inserted",
|
||||||
|
timestamp="2026-10-01 12:00:00",
|
||||||
|
value=0x00C4A0B5,
|
||||||
|
checksum=0xDE,
|
||||||
|
card_type=0x0F,
|
||||||
|
is_valid=True,
|
||||||
|
port="/dev/ttyS0",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_hex_value_is_zero_padded_to_eight_digits():
|
||||||
|
assert SAMPLE.hex_value == "0x00C4A0B5"
|
||||||
|
|
||||||
|
|
||||||
|
def test_from_card_accepts_any_object_with_the_right_fields():
|
||||||
|
class FakeCard:
|
||||||
|
value = 1234
|
||||||
|
checksum = 5
|
||||||
|
type = 15
|
||||||
|
is_valid = True
|
||||||
|
|
||||||
|
event = CardEvent.from_card("card_inserted", FakeCard(), "/dev/ttyUSB0")
|
||||||
|
assert event.value == 1234
|
||||||
|
assert event.hex_value == "0x000004D2"
|
||||||
|
assert event.port == "/dev/ttyUSB0"
|
||||||
|
|
||||||
|
|
||||||
|
def test_to_dict_has_the_documented_keys():
|
||||||
|
assert set(SAMPLE.to_dict()) == {
|
||||||
|
"event", "timestamp", "id", "hex", "type", "checksum", "is_valid", "port",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_to_json_is_parseable():
|
||||||
|
assert json.loads(SAMPLE.to_json())["hex"] == "0x00C4A0B5"
|
||||||
|
|
||||||
|
|
||||||
|
def test_inserted_block_lists_the_card_data():
|
||||||
|
text = format_event(SAMPLE)
|
||||||
|
assert "CARD INSERTED" in text
|
||||||
|
assert "12886197" in text
|
||||||
|
assert "0x00C4A0B5" in text
|
||||||
|
assert "0x0F" in text
|
||||||
|
assert "222" in text
|
||||||
|
assert "True" in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_card_is_flagged_in_the_note():
|
||||||
|
event = CardEvent(
|
||||||
|
event="card_inserted",
|
||||||
|
timestamp="2026-10-01 12:00:00",
|
||||||
|
value=CONFIG_CARD_VALUE,
|
||||||
|
checksum=0,
|
||||||
|
card_type=0x0F,
|
||||||
|
is_valid=True,
|
||||||
|
port="/dev/ttyS0",
|
||||||
|
)
|
||||||
|
text = format_event(event)
|
||||||
|
assert "config card" in text
|
||||||
|
assert str(CONFIG_CARD_VALUE) in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_ordinary_card_has_no_config_note():
|
||||||
|
assert "config card" not in format_event(SAMPLE)
|
||||||
|
|
||||||
|
|
||||||
|
def test_removed_line_is_single_line():
|
||||||
|
event = CardEvent(
|
||||||
|
event="card_removed",
|
||||||
|
timestamp="2026-10-01 12:00:00",
|
||||||
|
value=0x00C4A0B5,
|
||||||
|
checksum=0xDE,
|
||||||
|
card_type=0x0F,
|
||||||
|
is_valid=True,
|
||||||
|
port="/dev/ttyS0",
|
||||||
|
)
|
||||||
|
text = format_event(event)
|
||||||
|
assert text.count("\n") == 0
|
||||||
|
assert "CARD REMOVED" in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_line_mentions_checksum():
|
||||||
|
event = CardEvent(
|
||||||
|
event="invalid_card",
|
||||||
|
timestamp="2026-10-01 12:00:00",
|
||||||
|
value=0xDEADBEEF,
|
||||||
|
checksum=0,
|
||||||
|
card_type=0x0F,
|
||||||
|
is_valid=False,
|
||||||
|
port="/dev/ttyS0",
|
||||||
|
)
|
||||||
|
assert "checksum mismatch" in format_event(event)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("value,expected", [(0, "0x00000000"), (0xFFFFFFFF, "0xFFFFFFFF")])
|
||||||
|
def test_hex_value_boundaries(value, expected):
|
||||||
|
event = CardEvent("card_inserted", "-", value, 0, 0x0F, True, "p")
|
||||||
|
assert event.hex_value == expected
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
"""CLI behaviour: exit codes, output modes and argument handling."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from rdm_card_reader import cli
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_ports_exits_zero(capsys):
|
||||||
|
assert cli.main(["--list-ports"]) == cli.EXIT_OK
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "/dev/ttyS0" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_demo_exits_zero(capsys):
|
||||||
|
assert cli.main(["--demo", "--demo-count", "1"]) == cli.EXIT_OK
|
||||||
|
assert "CARD INSERTED" in capsys.readouterr().out
|
||||||
|
|
||||||
|
|
||||||
|
def test_demo_json_emits_one_object_per_event(capsys):
|
||||||
|
cli.main(["--demo", "--demo-count", "1", "--json"])
|
||||||
|
lines = [line for line in capsys.readouterr().out.splitlines() if line.strip()]
|
||||||
|
events = [json.loads(line) for line in lines if line.startswith("{")]
|
||||||
|
assert events
|
||||||
|
assert {"event", "timestamp", "id", "hex"} <= set(events[0])
|
||||||
|
|
||||||
|
|
||||||
|
def test_demo_count_is_respected(capsys):
|
||||||
|
cli.main(["--demo", "--demo-count", "4"])
|
||||||
|
assert capsys.readouterr().out.count("CARD INSERTED") >= 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_library_exits_two(monkeypatch, capsys):
|
||||||
|
monkeypatch.setattr(cli, "available", lambda: False)
|
||||||
|
assert cli.main([]) == cli.EXIT_NO_LIBRARY
|
||||||
|
assert "rdm6300" in capsys.readouterr().err
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_port_available_exits_one(monkeypatch, capsys):
|
||||||
|
monkeypatch.setattr(cli, "available", lambda: True)
|
||||||
|
monkeypatch.setattr(cli, "open_reader", lambda *a, **kw: None)
|
||||||
|
assert cli.main(["--port", "/dev/nope"]) == cli.EXIT_NO_PORT
|
||||||
|
assert "Could not open any serial port" in capsys.readouterr().err
|
||||||
|
|
||||||
|
|
||||||
|
def test_explicit_port_is_the_only_one_tried(monkeypatch):
|
||||||
|
monkeypatch.setattr(cli, "available", lambda: True)
|
||||||
|
tried = []
|
||||||
|
|
||||||
|
def fake_open(callback, ports=None, **kwargs):
|
||||||
|
tried.extend(ports or [])
|
||||||
|
return None
|
||||||
|
|
||||||
|
monkeypatch.setattr(cli, "open_reader", fake_open)
|
||||||
|
cli.main(["--port", "/dev/ttyUSB0"])
|
||||||
|
assert tried == ["/dev/ttyUSB0"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_keyboard_interrupt_is_handled(monkeypatch, capsys):
|
||||||
|
monkeypatch.setattr(cli, "available", lambda: True)
|
||||||
|
|
||||||
|
class Reader:
|
||||||
|
port = "/dev/ttyS0"
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
raise KeyboardInterrupt
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
self.closed = True
|
||||||
|
|
||||||
|
reader = Reader()
|
||||||
|
monkeypatch.setattr(cli, "open_reader", lambda *a, **kw: reader)
|
||||||
|
|
||||||
|
assert cli.main([]) == cli.EXIT_OK
|
||||||
|
assert reader.closed is True
|
||||||
|
assert "Stopped by user" in capsys.readouterr().out
|
||||||
|
|
||||||
|
|
||||||
|
def test_version_flag_exits_zero(capsys):
|
||||||
|
with pytest.raises(SystemExit) as exc:
|
||||||
|
cli.main(["--version"])
|
||||||
|
assert exc.value.code == 0
|
||||||
|
assert "rdm-card-reader" in capsys.readouterr().out
|
||||||
|
|
||||||
|
|
||||||
|
def test_json_mode_prints_json_from_the_callback(capsys):
|
||||||
|
printer = cli._make_printer(as_json=True)
|
||||||
|
from rdm_card_reader.card import CardEvent
|
||||||
|
|
||||||
|
printer(CardEvent("card_inserted", "-", 1, 2, 15, True, "p"))
|
||||||
|
payload = json.loads(capsys.readouterr().out.strip())
|
||||||
|
assert payload["id"] == 1
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
"""Tests for the deploy artifacts.
|
||||||
|
|
||||||
|
These are the files that only ever run on the target device, so a mistake in
|
||||||
|
them is found late and painfully. The checks here are the ones that would have
|
||||||
|
caught real bugs: the install must not depend on the network, and the service
|
||||||
|
must not reference something the installer does not create.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import stat
|
||||||
|
|
||||||
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
PROJECT = os.path.dirname(HERE)
|
||||||
|
DEPLOY = os.path.join(PROJECT, "deploy")
|
||||||
|
|
||||||
|
INSTALL_SH = os.path.join(DEPLOY, "install.sh")
|
||||||
|
UNIT = os.path.join(DEPLOY, "rdm-card-reader.service")
|
||||||
|
RULES = os.path.join(DEPLOY, "99-rdm-reader.rules")
|
||||||
|
|
||||||
|
|
||||||
|
def read(path):
|
||||||
|
with open(path, "r") as handle:
|
||||||
|
return handle.read()
|
||||||
|
|
||||||
|
|
||||||
|
# -- install.sh -------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_installer_exists_and_is_executable():
|
||||||
|
assert os.path.exists(INSTALL_SH)
|
||||||
|
mode = os.stat(INSTALL_SH).st_mode
|
||||||
|
assert mode & stat.S_IXUSR, "install.sh should be executable"
|
||||||
|
|
||||||
|
|
||||||
|
def test_installer_does_not_pip_install_the_project():
|
||||||
|
"""`pip install .` needs setuptools from the network, so it breaks offline.
|
||||||
|
|
||||||
|
This is the bug that made installation fail on an air-gapped Pi with
|
||||||
|
"Could not find a version that satisfies the requirement setuptools>=68".
|
||||||
|
"""
|
||||||
|
text = read(INSTALL_SH)
|
||||||
|
code = "\n".join(
|
||||||
|
line for line in text.splitlines() if not line.strip().startswith("#")
|
||||||
|
)
|
||||||
|
assert not re.search(r'pip"?\s+install[^\n]*"\$INSTALL_DIR"', code), (
|
||||||
|
"install.sh must not 'pip install' the project directory: it requires "
|
||||||
|
"downloading setuptools and fails with no network"
|
||||||
|
)
|
||||||
|
assert not re.search(r"pip\s+install\s+\.\s*$", code, re.M)
|
||||||
|
|
||||||
|
|
||||||
|
def test_installer_installs_runtime_deps_from_bundled_wheels():
|
||||||
|
text = read(INSTALL_SH)
|
||||||
|
assert "--no-index" in text
|
||||||
|
assert "--find-links" in text
|
||||||
|
assert "wheels" in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_installer_adds_src_to_path():
|
||||||
|
"""Without this the package is not importable at all."""
|
||||||
|
text = read(INSTALL_SH)
|
||||||
|
assert ".pth" in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_installer_network_steps_are_not_fatal():
|
||||||
|
"""A device with no internet must still get a working install."""
|
||||||
|
text = read(INSTALL_SH)
|
||||||
|
for line in text.splitlines():
|
||||||
|
stripped = line.strip()
|
||||||
|
if stripped.startswith("apt-get"):
|
||||||
|
assert "|| " in line or line.rstrip().endswith("\\"), (
|
||||||
|
"network-dependent step should not abort the install: " + stripped
|
||||||
|
)
|
||||||
|
# The pip self-upgrade is best-effort too. The fallback may sit on the
|
||||||
|
# following line (line continuation), so join continuations first.
|
||||||
|
joined = text.replace("\\\n", " ")
|
||||||
|
assert re.search(r"upgrade pip[^\n]*\|\|", joined), (
|
||||||
|
"'pip install --upgrade pip' needs the network, so it must not abort "
|
||||||
|
"the install; add '|| ...' to it"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_installer_copies_the_wheels_with_the_source():
|
||||||
|
"""The bundled wheels live under src/, so src/ must be copied."""
|
||||||
|
text = read(INSTALL_SH)
|
||||||
|
match = re.search(r"for item in ([^\n;]+);", text)
|
||||||
|
assert match, "could not find the copy list in install.sh"
|
||||||
|
assert "src" in match.group(1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_installer_checks_venv_creation_can_fail_clearly():
|
||||||
|
text = read(INSTALL_SH)
|
||||||
|
assert "python3-venv" in text, "should mention the usual cause of venv failure"
|
||||||
|
|
||||||
|
|
||||||
|
def test_installer_verifies_the_install_before_enabling_service():
|
||||||
|
text = read(INSTALL_SH)
|
||||||
|
assert "--version" in text, "installer should sanity-check the install"
|
||||||
|
|
||||||
|
|
||||||
|
def test_installer_copies_launch_doc():
|
||||||
|
text = read(INSTALL_SH)
|
||||||
|
assert "LAUNCH.md" in text
|
||||||
|
|
||||||
|
|
||||||
|
# -- service unit -----------------------------------------------------------
|
||||||
|
|
||||||
|
def test_unit_exists():
|
||||||
|
assert os.path.exists(UNIT)
|
||||||
|
|
||||||
|
|
||||||
|
def test_unit_restarts_on_failure():
|
||||||
|
text = read(UNIT)
|
||||||
|
assert "Restart=always" in text
|
||||||
|
assert "RestartSec=" in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_unit_runs_as_a_non_root_user_in_dialout():
|
||||||
|
text = read(UNIT)
|
||||||
|
assert re.search(r"^User=", text, re.M)
|
||||||
|
assert re.search(r"^Group=dialout", text, re.M)
|
||||||
|
|
||||||
|
|
||||||
|
def _sections(text):
|
||||||
|
"""Split an INI-style unit into {section: body}.
|
||||||
|
|
||||||
|
Splitting on the literal string "[Service]" is wrong: the file mentions it
|
||||||
|
in a comment above the real section header.
|
||||||
|
"""
|
||||||
|
sections = {}
|
||||||
|
current = None
|
||||||
|
for line in text.splitlines():
|
||||||
|
header = re.match(r"^\[([^\]]+)\]\s*$", line)
|
||||||
|
if header:
|
||||||
|
current = header.group(1)
|
||||||
|
sections[current] = []
|
||||||
|
elif current is not None:
|
||||||
|
sections[current].append(line)
|
||||||
|
return {name: "\n".join(body) for name, body in sections.items()}
|
||||||
|
|
||||||
|
|
||||||
|
def test_unit_start_limits_are_in_the_unit_section():
|
||||||
|
"""systemd silently ignores these in [Service]."""
|
||||||
|
sections = _sections(read(UNIT))
|
||||||
|
assert "Unit" in sections and "Service" in sections
|
||||||
|
for key in ("StartLimitIntervalSec", "StartLimitBurst"):
|
||||||
|
assert key not in sections["Service"], (
|
||||||
|
"{} belongs in [Unit]; systemd ignores it in [Service]".format(key)
|
||||||
|
)
|
||||||
|
assert "StartLimitBurst" in sections["Unit"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_unit_has_the_expected_sections():
|
||||||
|
sections = _sections(read(UNIT))
|
||||||
|
assert set(sections) >= {"Unit", "Service", "Install"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_unit_uses_loopback_by_default():
|
||||||
|
text = read(UNIT)
|
||||||
|
# No --host flag means the CLI default (127.0.0.1) applies.
|
||||||
|
assert "--host" not in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_unit_serves_in_login_mode():
|
||||||
|
text = read(UNIT)
|
||||||
|
assert "--serve" in text
|
||||||
|
assert "--mode login" in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_unit_points_at_the_stable_device_alias():
|
||||||
|
text = read(UNIT)
|
||||||
|
assert "/dev/rdm-reader" in text
|
||||||
|
|
||||||
|
|
||||||
|
# -- udev rule --------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_udev_rule_creates_the_alias_the_unit_expects():
|
||||||
|
text = read(RULES)
|
||||||
|
assert 'SYMLINK+="rdm-reader"' in text
|
||||||
|
unit = read(UNIT)
|
||||||
|
assert "/dev/rdm-reader" in unit, "unit and rule must agree on the alias name"
|
||||||
|
|
||||||
|
|
||||||
|
def test_udev_rule_sets_group_and_mode():
|
||||||
|
text = read(RULES)
|
||||||
|
assert 'GROUP="dialout"' in text
|
||||||
|
assert 'MODE="0660"' in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_udev_rule_only_has_one_active_match():
|
||||||
|
"""Multiple active rules would fight over the same alias."""
|
||||||
|
active = [
|
||||||
|
line
|
||||||
|
for line in read(RULES).splitlines()
|
||||||
|
if line.strip().startswith('SUBSYSTEM=="tty"')
|
||||||
|
]
|
||||||
|
assert len(active) == 1, "exactly one rule should be active, found {}".format(
|
||||||
|
len(active)
|
||||||
|
)
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
"""End-to-end test: a real tap must be claimable by a web page at any time.
|
||||||
|
|
||||||
|
Simulates the RDM6300 on a pseudo-terminal, starts the actual bridge the way
|
||||||
|
systemd would, then walks the exact flow a web page performs:
|
||||||
|
|
||||||
|
card placed -> page loads -> polls /api/card -> claims it -> hand over once
|
||||||
|
|
||||||
|
Also asserts the two things that matter for security: the tap survives until
|
||||||
|
claimed, and it cannot be claimed twice.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import pty
|
||||||
|
import signal
|
||||||
|
import struct
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
PROJECT = os.path.dirname(HERE)
|
||||||
|
VENV_PY = os.path.join(PROJECT, ".venv", "bin", "python")
|
||||||
|
PYTHON = VENV_PY if os.path.exists(VENV_PY) else sys.executable
|
||||||
|
|
||||||
|
CARD_VALUE = 0x00C4A0B5
|
||||||
|
EXPECTED_ID = 12886197
|
||||||
|
|
||||||
|
|
||||||
|
def em4100_frame(value, card_type=0x0F):
|
||||||
|
"""12 hex nibbles: type + 4 id bytes + XOR checksum of those 5 bytes."""
|
||||||
|
id_bytes = struct.pack(">I", value)
|
||||||
|
checksum = card_type
|
||||||
|
for b in id_bytes:
|
||||||
|
checksum ^= b
|
||||||
|
frame = (bytes([card_type]) + id_bytes + bytes([checksum])).hex().upper()
|
||||||
|
assert len(frame) == 12, frame
|
||||||
|
return frame.encode("ascii")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
master, slave = pty.openpty()
|
||||||
|
serial_port = os.ttyname(slave)
|
||||||
|
|
||||||
|
# Port 0 would be cleaner but the CLI needs a real number; pick a free one.
|
||||||
|
import socket
|
||||||
|
probe = socket.socket()
|
||||||
|
probe.bind(("127.0.0.1", 0))
|
||||||
|
http_port = probe.getsockname()[1]
|
||||||
|
probe.close()
|
||||||
|
|
||||||
|
proc = subprocess.Popen(
|
||||||
|
[PYTHON, "-m", "rdm_card_reader", "--serve",
|
||||||
|
"--port", serial_port, "--bridge-port", str(http_port),
|
||||||
|
"--mode", "login", "--tap-ttl", "15",
|
||||||
|
"--allow-origin", "https://kiosk.example"],
|
||||||
|
cwd=PROJECT, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||||||
|
text=True, bufsize=1, start_new_session=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
output = []
|
||||||
|
lock = threading.Lock()
|
||||||
|
|
||||||
|
def pump():
|
||||||
|
for line in proc.stdout:
|
||||||
|
with lock:
|
||||||
|
output.append(line.rstrip())
|
||||||
|
|
||||||
|
threading.Thread(target=pump, daemon=True).start()
|
||||||
|
|
||||||
|
base = "http://127.0.0.1:{}".format(http_port)
|
||||||
|
results = {}
|
||||||
|
|
||||||
|
def api(path, method="GET", headers=None):
|
||||||
|
request = urllib.request.Request(
|
||||||
|
base + path, method=method,
|
||||||
|
headers=headers or {},
|
||||||
|
data=b"" if method == "POST" else None,
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(request, timeout=5) as response:
|
||||||
|
body = response.read()
|
||||||
|
return response.status, (json.loads(body) if body else None)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Wait for the HTTP server to come up.
|
||||||
|
deadline = time.time() + 15
|
||||||
|
while time.time() < deadline:
|
||||||
|
try:
|
||||||
|
api("/api/health")
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
time.sleep(0.3)
|
||||||
|
|
||||||
|
results["bridge started"] = True
|
||||||
|
|
||||||
|
# The child needs a moment to open the serial port; frames written
|
||||||
|
# before that are discarded, which would look like a reader bug.
|
||||||
|
time.sleep(2.0)
|
||||||
|
|
||||||
|
# --- no card yet -------------------------------------------------
|
||||||
|
_, state = api("/api/card")
|
||||||
|
results["no card -> nothing claimable"] = state["claimable"] is False
|
||||||
|
|
||||||
|
# --- a single tap, as a person would -------------------------------
|
||||||
|
frame = em4100_frame(CARD_VALUE)
|
||||||
|
os.write(master, b"\x02" + frame + b"\x03")
|
||||||
|
time.sleep(0.3)
|
||||||
|
|
||||||
|
# --- a page loads later and polls; the card must still be there ----
|
||||||
|
time.sleep(1.0)
|
||||||
|
_, state = api("/api/card")
|
||||||
|
with lock:
|
||||||
|
print("state after tap: {}".format(json.dumps(state)), flush=True)
|
||||||
|
for line in output[-12:]:
|
||||||
|
print(" " + line)
|
||||||
|
results["card still available later"] = bool(
|
||||||
|
state["claimable"] and state["card"]["id"] == EXPECTED_ID
|
||||||
|
)
|
||||||
|
results["card not consumed yet"] = state["consumed"] is False
|
||||||
|
|
||||||
|
# --- claim it, as the web page does -------------------------------
|
||||||
|
status, claimed = api(
|
||||||
|
"/api/card/claim", method="POST",
|
||||||
|
headers={"Origin": "https://kiosk.example"},
|
||||||
|
)
|
||||||
|
results["claim returns 200"] = status == 200
|
||||||
|
results["claim gives the right card"] = claimed["card"]["id"] == EXPECTED_ID
|
||||||
|
|
||||||
|
# --- single use ---------------------------------------------------
|
||||||
|
try:
|
||||||
|
api("/api/card/claim", method="POST",
|
||||||
|
headers={"Origin": "https://kiosk.example"})
|
||||||
|
results["second claim refused"] = False
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
results["second claim refused"] = exc.code == 409
|
||||||
|
|
||||||
|
# --- a foreign origin cannot grab it either ------------------------
|
||||||
|
_, state = api("/api/card")
|
||||||
|
results["card marked consumed"] = state["consumed"] is True
|
||||||
|
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
os.killpg(os.getpgid(proc.pid), signal.SIGINT)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
proc.wait(timeout=8)
|
||||||
|
except Exception:
|
||||||
|
proc.kill()
|
||||||
|
try:
|
||||||
|
os.close(master)
|
||||||
|
os.close(slave)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
with lock:
|
||||||
|
lines = list(output)
|
||||||
|
|
||||||
|
print("======== bridge output ========")
|
||||||
|
for line in lines[:14]:
|
||||||
|
print(line)
|
||||||
|
print("===============================")
|
||||||
|
print()
|
||||||
|
|
||||||
|
ok = True
|
||||||
|
for name, passed in results.items():
|
||||||
|
print(" [{}] {}".format("PASS" if passed else "FAIL", name))
|
||||||
|
ok = ok and bool(passed)
|
||||||
|
|
||||||
|
return 0 if ok else 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
"""End-to-end: a real card read must drive the LED on and off.
|
||||||
|
|
||||||
|
Uses a pseudo-terminal for the reader and a fake pin for the LED, so the whole
|
||||||
|
chain runs — serial frame -> card event -> LED worker -> pin state — without a
|
||||||
|
Pi and without GPIO hardware.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import pty
|
||||||
|
import struct
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
PROJECT = os.path.dirname(HERE)
|
||||||
|
VENV_PY = os.path.join(PROJECT, ".venv", "bin", "python")
|
||||||
|
PYTHON = VENV_PY if os.path.exists(VENV_PY) else sys.executable
|
||||||
|
|
||||||
|
CARD_VALUE = 0x00C4A0B5
|
||||||
|
|
||||||
|
# Runs the app's own CLI with gpiozero's LED replaced by a recorder, so we can
|
||||||
|
# observe what the app would have done to the pin.
|
||||||
|
DRIVER = r'''
|
||||||
|
import os, sys, threading, time, json
|
||||||
|
|
||||||
|
sys.path.insert(0, os.environ["PROJECT_SRC"])
|
||||||
|
|
||||||
|
from rdm_card_reader import led as led_module
|
||||||
|
|
||||||
|
TRANSITIONS = []
|
||||||
|
_LOCK = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
class RecordingLED:
|
||||||
|
def __init__(self, pin=None, active_high=True):
|
||||||
|
self.pin = pin
|
||||||
|
self._state = False
|
||||||
|
|
||||||
|
def _record(self, value):
|
||||||
|
self._state = value
|
||||||
|
with _LOCK:
|
||||||
|
TRANSITIONS.append(value)
|
||||||
|
# Write through immediately: the test terminates this process, so
|
||||||
|
# anything buffered for the end would be lost.
|
||||||
|
sys.stderr.write("TRANSITION=" + value + "\n")
|
||||||
|
sys.stderr.flush()
|
||||||
|
|
||||||
|
def on(self):
|
||||||
|
self._record("on")
|
||||||
|
|
||||||
|
def off(self):
|
||||||
|
self._record("off")
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class FakeGPIOLED(led_module.GPIOLED):
|
||||||
|
def __init__(self, pin, active_high=True):
|
||||||
|
self.pin = pin
|
||||||
|
self._led = RecordingLED(pin, active_high)
|
||||||
|
self._is_on = False
|
||||||
|
|
||||||
|
def on(self):
|
||||||
|
self._led.on()
|
||||||
|
self._is_on = True
|
||||||
|
|
||||||
|
def off(self):
|
||||||
|
self._led.off()
|
||||||
|
self._is_on = False
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
self._led.close()
|
||||||
|
|
||||||
|
|
||||||
|
led_module.GPIOLED = FakeGPIOLED
|
||||||
|
|
||||||
|
from rdm_card_reader.cli import main
|
||||||
|
|
||||||
|
code = main(["--port", os.environ["SERIAL_PORT"], "--heartbeat", "0.3",
|
||||||
|
"--led-mode", os.environ.get("LED_MODE", "error"), "--json"])
|
||||||
|
sys.exit(code)
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
def em4100_frame(value, card_type=0x0F):
|
||||||
|
id_bytes = struct.pack(">I", value)
|
||||||
|
checksum = card_type
|
||||||
|
for b in id_bytes:
|
||||||
|
checksum ^= b
|
||||||
|
frame = (bytes([card_type]) + id_bytes + bytes([checksum])).hex().upper()
|
||||||
|
assert len(frame) == 12, frame
|
||||||
|
return frame.encode("ascii")
|
||||||
|
|
||||||
|
|
||||||
|
def pump(stream, sink):
|
||||||
|
"""Read a stream on its own thread.
|
||||||
|
|
||||||
|
Text-mode pipes buffer, so select() would not report this data.
|
||||||
|
"""
|
||||||
|
for line in stream:
|
||||||
|
sink.append(line.rstrip())
|
||||||
|
|
||||||
|
|
||||||
|
def run_case(mode):
|
||||||
|
"""Drive a card through the app and return (events, led transitions)."""
|
||||||
|
master, slave = pty.openpty()
|
||||||
|
serial_port = os.ttyname(slave)
|
||||||
|
|
||||||
|
env = dict(os.environ)
|
||||||
|
env["SERIAL_PORT"] = serial_port
|
||||||
|
env["PROJECT_SRC"] = os.path.join(PROJECT, "src")
|
||||||
|
env["LED_MODE"] = mode
|
||||||
|
env.pop("PYTHONPATH", None)
|
||||||
|
|
||||||
|
proc = subprocess.Popen(
|
||||||
|
[PYTHON, "-u", "-c", DRIVER],
|
||||||
|
cwd=PROJECT, env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||||
|
text=True, bufsize=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
stdout, stderr = [], []
|
||||||
|
t1 = threading.Thread(target=pump, args=(proc.stdout, stdout), daemon=True)
|
||||||
|
t2 = threading.Thread(target=pump, args=(proc.stderr, stderr), daemon=True)
|
||||||
|
t1.start()
|
||||||
|
t2.start()
|
||||||
|
|
||||||
|
frame = em4100_frame(CARD_VALUE)
|
||||||
|
try:
|
||||||
|
time.sleep(2.0) # let the reader open the port
|
||||||
|
for _ in range(6): # card present
|
||||||
|
os.write(master, b"\x02" + frame + b"\x03")
|
||||||
|
time.sleep(0.1)
|
||||||
|
time.sleep(1.2) # stop writing -> card removed
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
proc.terminate()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
proc.wait(timeout=5)
|
||||||
|
except Exception:
|
||||||
|
proc.kill()
|
||||||
|
for t in (t1, t2):
|
||||||
|
t.join(timeout=3)
|
||||||
|
try:
|
||||||
|
os.close(master)
|
||||||
|
os.close(slave)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
transitions = [
|
||||||
|
line.split("=", 1)[1]
|
||||||
|
for line in stderr
|
||||||
|
if line.startswith("TRANSITION=")
|
||||||
|
]
|
||||||
|
return stdout, stderr, transitions
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
failures = 0
|
||||||
|
|
||||||
|
# --- error mode: lit while running, dark while a card is read ----------
|
||||||
|
stdout, stderr, transitions = run_case("error")
|
||||||
|
print("======== error mode ========")
|
||||||
|
for line in stdout[:2]:
|
||||||
|
print(" " + line)
|
||||||
|
print(" events: {}".format(
|
||||||
|
[l.split('"event": "')[1].split('"')[0]
|
||||||
|
for l in stdout if '"event"' in l]))
|
||||||
|
print(" transitions: {}".format(transitions))
|
||||||
|
|
||||||
|
checks = {
|
||||||
|
"card was read": any("card_inserted" in l for l in stdout),
|
||||||
|
"card removal seen": any("card_removed" in l for l in stdout),
|
||||||
|
"lit at startup": transitions[:1] == ["on"],
|
||||||
|
"dark while reading": "off" in transitions,
|
||||||
|
"lit again after": transitions[-1:] == ["on"],
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- presence mode: dark until a card arrives -------------------------
|
||||||
|
stdout2, stderr2, transitions2 = run_case("presence")
|
||||||
|
print("\n======== presence mode ========")
|
||||||
|
print(" transitions: {}".format(transitions2))
|
||||||
|
checks.update({
|
||||||
|
"presence: read still works":
|
||||||
|
any("card_inserted" in l for l in stdout2),
|
||||||
|
"presence: dark at startup": transitions2[:1] == ["on"] or not transitions2,
|
||||||
|
"presence: lit on the card": "on" in transitions2,
|
||||||
|
"presence: dark after": transitions2[-1:] == ["off"],
|
||||||
|
})
|
||||||
|
|
||||||
|
print()
|
||||||
|
ok = True
|
||||||
|
for name, passed in checks.items():
|
||||||
|
print(" [{}] {}".format("PASS" if passed else "FAIL", name))
|
||||||
|
ok = ok and bool(passed)
|
||||||
|
|
||||||
|
if not ok:
|
||||||
|
failures = 1
|
||||||
|
print("\n--- driver stderr (error mode) ---")
|
||||||
|
print("\n".join(stderr)[:1200])
|
||||||
|
return failures
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
"""End-to-end test over a pseudo-terminal, with no RFID hardware.
|
||||||
|
|
||||||
|
The test owns the master side of a PTY, points the CLI at the slave side and
|
||||||
|
writes real RDM6300 frames. This exercises the same code path a physical
|
||||||
|
reader would: serial open -> byte stream -> parse -> callback -> stdout.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import pty
|
||||||
|
import signal
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from rdm_card_reader.card import CONFIG_CARD_VALUE
|
||||||
|
|
||||||
|
from .test_protocol import encode_stream, encode_frame # noqa: F401
|
||||||
|
|
||||||
|
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
SRC = os.path.join(ROOT, "src")
|
||||||
|
|
||||||
|
TIMEOUT = 15.0
|
||||||
|
|
||||||
|
|
||||||
|
class ReaderProcess:
|
||||||
|
"""Run the CLI against a PTY and collect its stdout in the background."""
|
||||||
|
|
||||||
|
def __init__(self, extra_args=()):
|
||||||
|
self.master, slave = pty.openpty()
|
||||||
|
self.port = os.ttyname(slave)
|
||||||
|
env = dict(os.environ)
|
||||||
|
env["PYTHONPATH"] = SRC + os.pathsep + env.get("PYTHONPATH", "")
|
||||||
|
self.proc = subprocess.Popen(
|
||||||
|
[sys.executable, "-u", "-m", "rdm_card_reader",
|
||||||
|
"--port", self.port, "--heartbeat", "0.3", *extra_args],
|
||||||
|
cwd=ROOT, env=env, stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.STDOUT, text=True, bufsize=1,
|
||||||
|
start_new_session=True,
|
||||||
|
)
|
||||||
|
self.lines = []
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._thread = threading.Thread(target=self._pump, daemon=True)
|
||||||
|
self._thread.start()
|
||||||
|
|
||||||
|
def _pump(self):
|
||||||
|
for line in self.proc.stdout:
|
||||||
|
with self._lock:
|
||||||
|
self.lines.append(line.rstrip())
|
||||||
|
|
||||||
|
def output(self):
|
||||||
|
with self._lock:
|
||||||
|
return "\n".join(self.lines)
|
||||||
|
|
||||||
|
def send(self, frame):
|
||||||
|
os.write(self.master, b"\x02" + frame + b"\x03")
|
||||||
|
|
||||||
|
def wait_for(self, needle, timeout=TIMEOUT):
|
||||||
|
deadline = time.time() + timeout
|
||||||
|
while time.time() < deadline:
|
||||||
|
if needle in self.output():
|
||||||
|
return True
|
||||||
|
time.sleep(0.05)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
self.wait_for("waiting for cards")
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *exc):
|
||||||
|
try:
|
||||||
|
os.killpg(os.getpgid(self.proc.pid), signal.SIGINT)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
self.proc.wait(timeout=5)
|
||||||
|
except Exception:
|
||||||
|
self.proc.kill()
|
||||||
|
self._thread.join(timeout=3)
|
||||||
|
for fd in (self.master, self.proc.stdout):
|
||||||
|
try:
|
||||||
|
fd.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(sys.platform != "linux", reason="PTY test is Linux only")
|
||||||
|
def test_card_insert_and_remove_are_reported():
|
||||||
|
card_id = 0x00C4A0B5
|
||||||
|
frame = encode_frame(card_id)
|
||||||
|
|
||||||
|
with ReaderProcess() as rp:
|
||||||
|
deadline = time.time() + TIMEOUT
|
||||||
|
while time.time() < deadline and not rp.wait_for("CARD INSERTED", 0.2):
|
||||||
|
rp.send(frame)
|
||||||
|
|
||||||
|
assert rp.wait_for("CARD INSERTED"), rp.output()
|
||||||
|
assert rp.wait_for("CARD REMOVED")
|
||||||
|
|
||||||
|
text = rp.output()
|
||||||
|
assert rp.port in text
|
||||||
|
assert str(card_id) in text
|
||||||
|
assert "0x00C4A0B5" in text
|
||||||
|
assert "222" in text # checksum = 0xDE
|
||||||
|
assert "0x0F" in text # type
|
||||||
|
assert "True" in text # valid
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(sys.platform != "linux", reason="PTY test is Linux only")
|
||||||
|
def test_corrupt_checksum_is_reported_as_invalid():
|
||||||
|
good = bytearray(encode_frame(0x1A2B3C4D))
|
||||||
|
good[-1] = ord("0") # break the checksum
|
||||||
|
bad = bytes(good)
|
||||||
|
|
||||||
|
with ReaderProcess() as rp:
|
||||||
|
deadline = time.time() + TIMEOUT
|
||||||
|
while time.time() < deadline and not rp.wait_for("INVALID CARD", 0.2):
|
||||||
|
rp.send(bad)
|
||||||
|
|
||||||
|
assert "INVALID CARD" in rp.output()
|
||||||
|
assert "checksum mismatch" in rp.output()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(sys.platform != "linux", reason="PTY test is Linux only")
|
||||||
|
def test_json_mode_emits_machine_readable_events():
|
||||||
|
with ReaderProcess(extra_args=("--json",)) as rp:
|
||||||
|
deadline = time.time() + TIMEOUT
|
||||||
|
while time.time() < deadline and not rp.wait_for('"event"', 0.2):
|
||||||
|
rp.send(encode_frame(0x00C4A0B5))
|
||||||
|
|
||||||
|
payloads = [
|
||||||
|
json.loads(line)
|
||||||
|
for line in rp.output().splitlines()
|
||||||
|
if line.strip().startswith("{")
|
||||||
|
]
|
||||||
|
assert payloads, rp.output()
|
||||||
|
assert any(p["event"] == "card_inserted" for p in payloads)
|
||||||
|
assert any(p["id"] == 0x00C4A0B5 for p in payloads)
|
||||||
|
assert all(p["port"] == rp.port for p in payloads)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(sys.platform != "linux", reason="PTY test is Linux only")
|
||||||
|
def test_config_card_is_annotated():
|
||||||
|
with ReaderProcess() as rp:
|
||||||
|
deadline = time.time() + TIMEOUT
|
||||||
|
while time.time() < deadline and not rp.wait_for("config card", 0.2):
|
||||||
|
rp.send(encode_frame(CONFIG_CARD_VALUE))
|
||||||
|
|
||||||
|
assert "config card" in rp.output()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(sys.platform != "linux", reason="PTY test is Linux only")
|
||||||
|
def test_removal_after_heartbeat_expires():
|
||||||
|
with ReaderProcess() as rp:
|
||||||
|
deadline = time.time() + TIMEOUT
|
||||||
|
while time.time() < deadline and not rp.wait_for("CARD INSERTED", 0.2):
|
||||||
|
rp.send(encode_frame(0x00C4A0B5))
|
||||||
|
# stop sending and let the 0.3 s heartbeat lapse
|
||||||
|
assert rp.wait_for("CARD REMOVED", timeout=5)
|
||||||
|
|
||||||
|
assert "CARD REMOVED" in rp.output()
|
||||||
@@ -0,0 +1,306 @@
|
|||||||
|
"""Tests for the status LED.
|
||||||
|
|
||||||
|
Two things matter: the LED must never be able to break the reader, and it must
|
||||||
|
behave the way the workstation app did (on with the card, off without). Real
|
||||||
|
GPIO is unavailable here, so a fake pin is injected to test the logic.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
SRC = os.path.join(
|
||||||
|
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "src"
|
||||||
|
)
|
||||||
|
if SRC not in sys.path:
|
||||||
|
sys.path.insert(0, SRC)
|
||||||
|
|
||||||
|
import pytest # noqa: E402
|
||||||
|
|
||||||
|
from rdm_card_reader import led as led_module # noqa: E402
|
||||||
|
from rdm_card_reader.led import ( # noqa: E402
|
||||||
|
BLINK,
|
||||||
|
DEFAULT_LED_PIN,
|
||||||
|
ERROR,
|
||||||
|
OFF,
|
||||||
|
ON,
|
||||||
|
PRESENCE,
|
||||||
|
NullLED,
|
||||||
|
StatusLED,
|
||||||
|
create,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeLED:
|
||||||
|
"""Stands in for gpiozero.LED, recording what it was told to do."""
|
||||||
|
|
||||||
|
def __init__(self, pin, active_high=True):
|
||||||
|
self.pin = pin
|
||||||
|
self.active_high = active_high
|
||||||
|
self.state = False
|
||||||
|
self.transitions = []
|
||||||
|
self.closed = False
|
||||||
|
|
||||||
|
def on(self):
|
||||||
|
self.state = True
|
||||||
|
self.transitions.append("on")
|
||||||
|
|
||||||
|
def off(self):
|
||||||
|
self.state = False
|
||||||
|
self.transitions.append("off")
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
self.closed = True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def fake_gpio(monkeypatch):
|
||||||
|
"""Make StatusLED build a FakeLED instead of touching GPIO."""
|
||||||
|
created = {}
|
||||||
|
|
||||||
|
class FakeGPIOLED(led_module.GPIOLED):
|
||||||
|
def __init__(self, pin, active_high=True):
|
||||||
|
self.pin = pin
|
||||||
|
self._led = FakeLED(pin, active_high)
|
||||||
|
self._is_on = False
|
||||||
|
created["led"] = self._led
|
||||||
|
|
||||||
|
def on(self):
|
||||||
|
self._led.on()
|
||||||
|
self._is_on = True
|
||||||
|
|
||||||
|
def off(self):
|
||||||
|
self._led.off()
|
||||||
|
self._is_on = False
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
self._led.close()
|
||||||
|
|
||||||
|
monkeypatch.setattr(led_module, "GPIOLED", FakeGPIOLED)
|
||||||
|
return created
|
||||||
|
|
||||||
|
|
||||||
|
def wait_for_state(led_obj, expected, timeout=3.0):
|
||||||
|
"""Wait for the worker thread to apply a command."""
|
||||||
|
deadline = time.time() + timeout
|
||||||
|
while time.time() < deadline:
|
||||||
|
if led_obj.state is expected:
|
||||||
|
return True
|
||||||
|
time.sleep(0.01)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# -- defaults ---------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_default_pin_matches_the_workstation_app():
|
||||||
|
assert DEFAULT_LED_PIN == 23
|
||||||
|
|
||||||
|
|
||||||
|
# -- graceful degradation ---------------------------------------------------
|
||||||
|
|
||||||
|
def test_no_gpio_gives_a_working_no_op(monkeypatch):
|
||||||
|
"""On a machine without GPIO the LED must be inert, not fatal."""
|
||||||
|
def boom(pin, active_high=True):
|
||||||
|
raise RuntimeError("Unable to load any default pin factory!")
|
||||||
|
|
||||||
|
monkeypatch.setattr(led_module, "GPIOLED", boom)
|
||||||
|
status = StatusLED(pin=23)
|
||||||
|
assert status.status.enabled is False
|
||||||
|
assert status.status.backend == "null"
|
||||||
|
# Every hook must still be safe to call.
|
||||||
|
status.insert()
|
||||||
|
status.remove()
|
||||||
|
status.error()
|
||||||
|
status.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_gpiozero_is_reported_not_raised(monkeypatch):
|
||||||
|
"""gpiozero absent: say so, and do not raise."""
|
||||||
|
import builtins
|
||||||
|
|
||||||
|
real_import = builtins.__import__
|
||||||
|
|
||||||
|
def blocked(name, *args, **kwargs):
|
||||||
|
if name == "gpiozero":
|
||||||
|
raise ImportError("No module named 'gpiozero'")
|
||||||
|
return real_import(name, *args, **kwargs)
|
||||||
|
|
||||||
|
monkeypatch.setattr(builtins, "__import__", blocked)
|
||||||
|
status = StatusLED(pin=23)
|
||||||
|
assert status.status.enabled is False
|
||||||
|
assert "not installed" in status.status.reason
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_pin_factory_is_not_reported_as_missing_library(monkeypatch):
|
||||||
|
"""BadPinFactory subclasses ImportError, so the two must be distinguished.
|
||||||
|
|
||||||
|
Reporting 'gpiozero is not installed' when it is installed but has no
|
||||||
|
usable pin library would send you to the wrong fix.
|
||||||
|
"""
|
||||||
|
class FakeBadPinFactory(ImportError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def boom(pin, active_high=True):
|
||||||
|
raise FakeBadPinFactory(
|
||||||
|
"Unable to load any default pin factory!"
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(led_module, "GPIOLED", boom)
|
||||||
|
status = StatusLED(pin=23)
|
||||||
|
assert status.status.enabled is False
|
||||||
|
assert "not installed" not in status.status.reason
|
||||||
|
assert "pin factory" in status.status.reason
|
||||||
|
|
||||||
|
|
||||||
|
def test_disabled_led_is_inert(fake_gpio):
|
||||||
|
status = StatusLED(pin=23, enabled=False)
|
||||||
|
assert status.status.enabled is False
|
||||||
|
assert isinstance(status.led, NullLED)
|
||||||
|
status.insert()
|
||||||
|
time.sleep(0.05)
|
||||||
|
assert "led" not in fake_gpio
|
||||||
|
|
||||||
|
|
||||||
|
def test_null_led_methods_are_safe():
|
||||||
|
null = NullLED(23, "no gpio")
|
||||||
|
null.on()
|
||||||
|
null.off()
|
||||||
|
null.close()
|
||||||
|
|
||||||
|
|
||||||
|
# -- behaviour --------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_error_mode_lights_up_at_startup(fake_gpio):
|
||||||
|
"""A health indicator should show 'alive' without waiting for a card."""
|
||||||
|
status = StatusLED(pin=23, mode=ERROR)
|
||||||
|
assert fake_gpio["led"].state is True
|
||||||
|
status.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_error_mode_blinks_on_a_bad_read(fake_gpio):
|
||||||
|
status = StatusLED(pin=23, mode=ERROR, error_blink=True)
|
||||||
|
status.error()
|
||||||
|
time.sleep(0.25)
|
||||||
|
assert len(fake_gpio["led"].transitions) >= 2
|
||||||
|
status.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_error_mode_settles_back_after_a_blink(fake_gpio):
|
||||||
|
status = StatusLED(pin=23, mode=ERROR, error_blink=True)
|
||||||
|
status.error()
|
||||||
|
time.sleep(0.25)
|
||||||
|
status.remove() # any steady command cancels blinking
|
||||||
|
assert wait_for_state(fake_gpio["led"], True)
|
||||||
|
status.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_error_mode_without_blink_stays_lit(fake_gpio):
|
||||||
|
status = StatusLED(pin=23, mode=ERROR, error_blink=False)
|
||||||
|
status.error()
|
||||||
|
assert wait_for_state(fake_gpio["led"], True)
|
||||||
|
status.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_presence_mode_is_dark_until_a_card_arrives(fake_gpio):
|
||||||
|
status = StatusLED(pin=23, mode=PRESENCE)
|
||||||
|
assert fake_gpio["led"].state is False
|
||||||
|
status.insert()
|
||||||
|
assert wait_for_state(fake_gpio["led"], True)
|
||||||
|
status.remove()
|
||||||
|
assert wait_for_state(fake_gpio["led"], False)
|
||||||
|
status.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_close_turns_the_led_off(fake_gpio):
|
||||||
|
status = StatusLED(pin=23, mode=ERROR)
|
||||||
|
assert fake_gpio["led"].state is True
|
||||||
|
status.close()
|
||||||
|
assert fake_gpio["led"].closed is True
|
||||||
|
assert fake_gpio["led"].state is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_pin_and_active_high_are_passed_through(fake_gpio):
|
||||||
|
status = StatusLED(pin=17, active_high=False)
|
||||||
|
assert fake_gpio["led"].pin == 17
|
||||||
|
assert fake_gpio["led"].active_high is False
|
||||||
|
status.close()
|
||||||
|
|
||||||
|
|
||||||
|
# -- never blocks or crashes ------------------------------------------------
|
||||||
|
|
||||||
|
def test_insert_does_not_block(monkeypatch):
|
||||||
|
"""The reader thread calls this, so it must return immediately."""
|
||||||
|
class SlowLED(led_module.GPIOLED):
|
||||||
|
def __init__(self, pin, active_high=True):
|
||||||
|
self.pin = pin
|
||||||
|
self._is_on = False
|
||||||
|
|
||||||
|
def on(self):
|
||||||
|
time.sleep(0.5) # deliberately slow hardware
|
||||||
|
|
||||||
|
def off(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
monkeypatch.setattr(led_module, "GPIOLED", SlowLED)
|
||||||
|
# mode=presence so the startup light-up does not pay the slow 'on' cost
|
||||||
|
status = StatusLED(pin=23, mode=PRESENCE)
|
||||||
|
start = time.time()
|
||||||
|
for _ in range(20):
|
||||||
|
status.insert()
|
||||||
|
elapsed = time.time() - start
|
||||||
|
assert elapsed < 0.2, "insert() blocked for {:.2f}s".format(elapsed)
|
||||||
|
status.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_failing_toggle_does_not_raise(fake_gpio, monkeypatch):
|
||||||
|
"""A dead pin must not take the reader down."""
|
||||||
|
status = StatusLED(pin=23)
|
||||||
|
assert fake_gpio["led"] is not None
|
||||||
|
|
||||||
|
def boom():
|
||||||
|
raise OSError("pin went away")
|
||||||
|
|
||||||
|
monkeypatch.setattr(status.led, "on", boom)
|
||||||
|
status.insert()
|
||||||
|
time.sleep(0.15) # worker swallows the error
|
||||||
|
status.close() # and close() still works
|
||||||
|
|
||||||
|
|
||||||
|
def test_hardware_error_is_reported_to_the_logger(monkeypatch):
|
||||||
|
lines = []
|
||||||
|
|
||||||
|
def boom(pin, active_high=True):
|
||||||
|
raise RuntimeError("no pin factory")
|
||||||
|
|
||||||
|
monkeypatch.setattr(led_module, "GPIOLED", boom)
|
||||||
|
StatusLED(pin=23, logger=lines.append)
|
||||||
|
assert any("LED unavailable" in line for line in lines)
|
||||||
|
|
||||||
|
|
||||||
|
# -- factory ----------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_create_defaults_to_error_mode_on_the_workstation_pin(fake_gpio):
|
||||||
|
status = create()
|
||||||
|
assert status.status.enabled is True
|
||||||
|
assert status.status.pin == DEFAULT_LED_PIN
|
||||||
|
assert status.status.mode == ERROR
|
||||||
|
status.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_disabled_returns_a_no_op():
|
||||||
|
status = create(enabled=False)
|
||||||
|
assert status.status.enabled is False
|
||||||
|
status.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_status_dict_is_json_serialisable(fake_gpio):
|
||||||
|
import json
|
||||||
|
|
||||||
|
status = create()
|
||||||
|
payload = json.dumps(status.status.to_dict())
|
||||||
|
assert "gpiozero" in payload
|
||||||
|
status.close()
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
"""Tests for reliable serial-port identification.
|
||||||
|
|
||||||
|
The point of these tests is that picking the wrong ttyUSB silently is the
|
||||||
|
failure mode that matters, so ambiguity must raise rather than guess.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
SRC = os.path.join(
|
||||||
|
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "src"
|
||||||
|
)
|
||||||
|
if SRC not in sys.path:
|
||||||
|
sys.path.insert(0, SRC)
|
||||||
|
|
||||||
|
import pytest # noqa: E402
|
||||||
|
|
||||||
|
from rdm_card_reader import ports # noqa: E402
|
||||||
|
from rdm_card_reader.ports import ( # noqa: E402
|
||||||
|
AmbiguousPort,
|
||||||
|
PortInfo,
|
||||||
|
PortNotFound,
|
||||||
|
best_usb_candidates,
|
||||||
|
resolve,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def make_port(device, vid=None, pid=None, serial=None, description="",
|
||||||
|
aliases=None, manufacturer=None, product=None):
|
||||||
|
return PortInfo(
|
||||||
|
device=device,
|
||||||
|
vid=vid,
|
||||||
|
pid=pid,
|
||||||
|
serial_number=serial,
|
||||||
|
description=description,
|
||||||
|
aliases=list(aliases or []),
|
||||||
|
manufacturer=manufacturer,
|
||||||
|
product=product,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def two_adapters(monkeypatch):
|
||||||
|
"""Two identical CH340 adapters plus an on-board UART."""
|
||||||
|
a = make_port("/dev/ttyUSB0", 0x1A86, 0x7523, "CH340A",
|
||||||
|
"USB Serial", ["/dev/serial/by-id/usb-1a86_A-if00-port0"])
|
||||||
|
b = make_port("/dev/ttyUSB1", 0x1A86, 0x7523, "CH340B",
|
||||||
|
"USB Serial", ["/dev/serial/by-id/usb-1a86_B-if00-port0"])
|
||||||
|
onboard = make_port("/dev/ttyS0", description="ttyS0")
|
||||||
|
monkeypatch.setattr(ports, "discover", lambda: [a, b, onboard])
|
||||||
|
return a, b, onboard
|
||||||
|
|
||||||
|
|
||||||
|
# -- identity ---------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_usb_id_and_chip_name():
|
||||||
|
port = make_port("/dev/ttyUSB0", 0x1A86, 0x7523)
|
||||||
|
assert port.usb_id == "1a86:7523"
|
||||||
|
assert port.chip == "CH340"
|
||||||
|
assert port.is_usb is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_chip_still_identified_as_usb():
|
||||||
|
port = make_port("/dev/ttyUSB0", 0xFFFF, 0x0001)
|
||||||
|
assert port.chip is None
|
||||||
|
assert port.is_usb is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_stable_id_prefers_by_id_symlink():
|
||||||
|
port = make_port(
|
||||||
|
"/dev/ttyUSB0",
|
||||||
|
aliases=["/dev/serial/by-path/pci-1-usb-0:1.2:1.0-port0",
|
||||||
|
"/dev/serial/by-id/usb-1a86_USB-if00-port0"],
|
||||||
|
)
|
||||||
|
assert port.stable_id == "/dev/serial/by-id/usb-1a86_USB-if00-port0"
|
||||||
|
assert port.by_path.endswith("port0")
|
||||||
|
|
||||||
|
|
||||||
|
def test_onboard_uart_is_not_usb():
|
||||||
|
assert make_port("/dev/ttyS0").is_usb is False
|
||||||
|
|
||||||
|
|
||||||
|
# -- scoring ----------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_usb_adapter_scores_above_onboard_uart():
|
||||||
|
adapter = make_port("/dev/ttyUSB0", 0x1A86, 0x7523, "SN1")
|
||||||
|
onboard = make_port("/dev/ttyS0")
|
||||||
|
assert adapter.score() > onboard.score()
|
||||||
|
|
||||||
|
|
||||||
|
def test_serial_number_raises_score():
|
||||||
|
with_sn = make_port("/dev/ttyUSB0", 0x1A86, 0x7523, "SN1")
|
||||||
|
without = make_port("/dev/ttyUSB0", 0x1A86, 0x7523, None)
|
||||||
|
assert with_sn.score() > without.score()
|
||||||
|
|
||||||
|
|
||||||
|
# -- matching ---------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_match_by_device_path():
|
||||||
|
assert make_port("/dev/ttyUSB0").matches("/dev/ttyUSB0")
|
||||||
|
|
||||||
|
|
||||||
|
def test_match_by_serial_number():
|
||||||
|
port = make_port("/dev/ttyUSB0", 0x1A86, 0x7523, "CH340A")
|
||||||
|
assert port.matches("CH340A")
|
||||||
|
|
||||||
|
|
||||||
|
def test_match_by_vid_pid():
|
||||||
|
port = make_port("/dev/ttyUSB0", 0x1A86, 0x7523)
|
||||||
|
assert port.matches("1a86:7523")
|
||||||
|
|
||||||
|
|
||||||
|
def test_match_by_chip_name():
|
||||||
|
assert make_port("/dev/ttyUSB0", 0x1A86, 0x7523).matches("ch340")
|
||||||
|
|
||||||
|
|
||||||
|
def test_match_by_id_symlink():
|
||||||
|
port = make_port("/dev/ttyUSB0",
|
||||||
|
aliases=["/dev/serial/by-id/usb-1a86_A-if00-port0"])
|
||||||
|
assert port.matches("/dev/serial/by-id/usb-1a86_A-if00-port0")
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_selector_never_matches():
|
||||||
|
assert not make_port("/dev/ttyUSB0").matches("")
|
||||||
|
|
||||||
|
|
||||||
|
# -- resolution -------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_resolve_by_serial_number_picks_right_adapter(two_adapters):
|
||||||
|
device, reason = resolve(selectors=["CH340B"])
|
||||||
|
assert device == "/dev/ttyUSB1"
|
||||||
|
assert "matched" in reason
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_by_by_id_path(two_adapters):
|
||||||
|
device, _ = resolve(selectors=["/dev/serial/by-id/usb-1a86_B-if00-port0"])
|
||||||
|
assert device == "/dev/ttyUSB1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_refuses_when_selector_matches_both(two_adapters):
|
||||||
|
"""A vid:pid shared by two adapters must not silently pick one."""
|
||||||
|
with pytest.raises(AmbiguousPort):
|
||||||
|
resolve(selectors=["1a86:7523"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_unknown_selector_raises_not_found(two_adapters):
|
||||||
|
with pytest.raises(PortNotFound):
|
||||||
|
resolve(selectors=["NOSUCHDEVICE"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_falls_back_to_raw_path_when_not_enumerable(monkeypatch, tmp_path):
|
||||||
|
fake = tmp_path / "myport"
|
||||||
|
fake.write_text("")
|
||||||
|
monkeypatch.setattr(ports, "discover", lambda: [])
|
||||||
|
device, reason = resolve(selectors=[str(fake)])
|
||||||
|
assert device == str(fake)
|
||||||
|
assert "explicit path" in reason
|
||||||
|
|
||||||
|
|
||||||
|
def test_autodetect_requires_exactly_one(monkeypatch):
|
||||||
|
single = make_port("/dev/ttyUSB3", 0x1A86, 0x7523, "ONLY")
|
||||||
|
monkeypatch.setattr(ports, "discover", lambda: [single, make_port("/dev/ttyS0")])
|
||||||
|
device, reason = resolve(autodetect=True)
|
||||||
|
assert device == "/dev/ttyUSB3"
|
||||||
|
assert "autodetected" in reason
|
||||||
|
|
||||||
|
|
||||||
|
def test_autodetect_refuses_with_two_adapters(two_adapters):
|
||||||
|
with pytest.raises(AmbiguousPort):
|
||||||
|
resolve(autodetect=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_autodetect_ignores_onboard_uart(monkeypatch):
|
||||||
|
"""An on-board UART must not make a single USB adapter look ambiguous."""
|
||||||
|
adapter = make_port("/dev/ttyUSB0", 0x1A86, 0x7523, "ONLY")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
ports, "discover",
|
||||||
|
lambda: [adapter, make_port("/dev/ttyS0"), make_port("/dev/ttyAMA0")],
|
||||||
|
)
|
||||||
|
device, _ = resolve(autodetect=True)
|
||||||
|
assert device == "/dev/ttyUSB0"
|
||||||
|
|
||||||
|
|
||||||
|
def test_fallback_order_used_when_no_usb(monkeypatch):
|
||||||
|
"""Must not depend on the host actually having a serial port."""
|
||||||
|
monkeypatch.setattr(ports, "discover", lambda: [])
|
||||||
|
monkeypatch.setattr(ports.os.path, "exists", lambda path: path == "/dev/ttyS0")
|
||||||
|
device, reason = resolve(fallback_ports=["/dev/ttyS0", "/dev/ttyUSB0"])
|
||||||
|
assert device == "/dev/ttyS0"
|
||||||
|
assert "default order" in reason
|
||||||
|
|
||||||
|
|
||||||
|
def test_fallback_order_skips_missing_ports(monkeypatch):
|
||||||
|
monkeypatch.setattr(ports, "discover", lambda: [])
|
||||||
|
monkeypatch.setattr(
|
||||||
|
ports.os.path, "exists", lambda path: path == "/dev/ttyUSB0"
|
||||||
|
)
|
||||||
|
device, _ = resolve(fallback_ports=["/dev/ttyS0", "/dev/ttyUSB0"])
|
||||||
|
assert device == "/dev/ttyUSB0"
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_port_at_all_raises(monkeypatch):
|
||||||
|
monkeypatch.setattr(ports, "discover", lambda: [])
|
||||||
|
with pytest.raises(PortNotFound):
|
||||||
|
resolve(fallback_ports=[])
|
||||||
|
|
||||||
|
|
||||||
|
# -- helper ----------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_best_usb_candidates_dedupes_same_identity():
|
||||||
|
a = make_port("/dev/ttyUSB0", 0x1A86, 0x7523, "SAME")
|
||||||
|
dup = make_port("/dev/ttyUSB1", 0x1A86, 0x7523, "SAME")
|
||||||
|
result = best_usb_candidates([a, dup])
|
||||||
|
assert len(result) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_best_usb_candidates_keeps_distinct_serial_numbers():
|
||||||
|
a = make_port("/dev/ttyUSB0", 0x1A86, 0x7523, "A")
|
||||||
|
b = make_port("/dev/ttyUSB1", 0x1A86, 0x7523, "B")
|
||||||
|
assert len(best_usb_candidates([a, b])) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_describe_is_human_readable():
|
||||||
|
port = make_port("/dev/ttyUSB0", 0x1A86, 0x7523, "ABC", "USB Serial")
|
||||||
|
text = port.describe()
|
||||||
|
assert "/dev/ttyUSB0" in text
|
||||||
|
assert "CH340" in text
|
||||||
|
assert "ABC" in text
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
"""RDM6300 wire format: type + id + checksum, 12 hex nibbles per frame.
|
||||||
|
|
||||||
|
The checksum is the XOR of the type byte *and* the four id bytes. Getting this
|
||||||
|
wrong (e.g. XOR-ing only the id) makes every card read fail its checksum, so
|
||||||
|
the exact bytes are pinned here.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import struct
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from rdm_card_reader.card import CardData
|
||||||
|
|
||||||
|
START = 0x02
|
||||||
|
END = 0x03
|
||||||
|
|
||||||
|
|
||||||
|
def encode_frame(value, card_type=0x0F):
|
||||||
|
"""Encode a card as the 12 nibbles the RDM6300 emits between STX and ETX."""
|
||||||
|
id_bytes = struct.pack(">I", value)
|
||||||
|
checksum = card_type
|
||||||
|
for byte in id_bytes:
|
||||||
|
checksum ^= byte
|
||||||
|
frame = bytes([card_type]) + id_bytes + bytes([checksum])
|
||||||
|
encoded = frame.hex().upper().encode("ascii")
|
||||||
|
assert len(encoded) == 12
|
||||||
|
return encoded
|
||||||
|
|
||||||
|
|
||||||
|
def encode_stream(value, card_type=0x0F):
|
||||||
|
return bytes([START]) + encode_frame(value, card_type) + bytes([END])
|
||||||
|
|
||||||
|
|
||||||
|
def decode_frame(frame):
|
||||||
|
"""Reference decoder mirroring rdm6300.BaseReader._parse_fragment."""
|
||||||
|
nibbles = [int(ch, 16) for ch in frame.decode("ascii")]
|
||||||
|
|
||||||
|
def to_int(chunk):
|
||||||
|
out = 0
|
||||||
|
for item in chunk:
|
||||||
|
out = (out << 4) | item
|
||||||
|
return out
|
||||||
|
|
||||||
|
calculated = 0
|
||||||
|
for i in range(0, 10, 2):
|
||||||
|
calculated ^= (nibbles[i] << 4) | nibbles[i + 1]
|
||||||
|
received = to_int(nibbles[10:12])
|
||||||
|
|
||||||
|
return CardData(
|
||||||
|
value=to_int(nibbles[2:10]),
|
||||||
|
checksum=received,
|
||||||
|
is_valid=received == calculated,
|
||||||
|
type=to_int(nibbles[0:2]),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"value,expected",
|
||||||
|
[
|
||||||
|
(0x00C4A0B5, b"0F00C4A0B5DE"),
|
||||||
|
(0x1A2B3C4D, b"0F1A2B3C4D4F"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_encoded_frame_bytes_are_stable(value, expected):
|
||||||
|
assert encode_frame(value) == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_frame_is_twelve_nibbles():
|
||||||
|
assert len(encode_frame(0x00C4A0B5)) == 12
|
||||||
|
|
||||||
|
|
||||||
|
def test_checksum_covers_type_byte_not_only_id():
|
||||||
|
"""Same id with a different type byte must change the checksum."""
|
||||||
|
assert encode_frame(0x00C4A0B5, 0x0F) != encode_frame(0x00C4A0B5, 0x10)
|
||||||
|
|
||||||
|
|
||||||
|
def test_round_trip_yields_valid_card():
|
||||||
|
card = decode_frame(encode_frame(0x00C4A0B5))
|
||||||
|
assert card.value == 0x00C4A0B5
|
||||||
|
assert card.type == 0x0F
|
||||||
|
assert card.checksum == 0xDE
|
||||||
|
assert card.is_valid is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_round_trip_of_second_sample():
|
||||||
|
card = decode_frame(encode_frame(0x1A2B3C4D))
|
||||||
|
assert card.value == 0x1A2B3C4D
|
||||||
|
assert card.is_valid is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_corrupted_checksum_is_invalid():
|
||||||
|
frame = bytearray(encode_frame(0x00C4A0B5))
|
||||||
|
frame[-1] = ord("0")
|
||||||
|
assert decode_frame(bytes(frame)).is_valid is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_frame_with_wrong_nibble_count_is_rejected():
|
||||||
|
"""A 14 nibble frame leaves 2 nibbles buffered in the real reader."""
|
||||||
|
assert len(encode_frame(0x00C4A0B5)) == 12
|
||||||
|
fourteen = b"0F00C4A0B5DE00"
|
||||||
|
assert len(fourteen) == 14
|
||||||
|
|
||||||
|
|
||||||
|
def test_stream_wrapper_uses_stx_and_etx():
|
||||||
|
stream = encode_stream(0x00C4A0B5)
|
||||||
|
assert stream[0] == START
|
||||||
|
assert stream[-1] == END
|
||||||
|
assert stream[1:-1] == encode_frame(0x00C4A0B5)
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
"""Port selection and error explanation, using a fake reader library."""
|
||||||
|
|
||||||
|
import types
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import rdm_card_reader.reader as reader_module
|
||||||
|
from rdm_card_reader.card import CANDIDATE_PORTS
|
||||||
|
from rdm_card_reader.reader import explain_error, list_ports, open_reader
|
||||||
|
|
||||||
|
|
||||||
|
class FakeBaseReader:
|
||||||
|
"""Stands in for rdm6300.BaseReader; records the port it was built with."""
|
||||||
|
|
||||||
|
fail_on = ()
|
||||||
|
|
||||||
|
def __init__(self, port, heartbeat_interval=0.5):
|
||||||
|
if port in type(self).fail_on:
|
||||||
|
raise OSError("could not open port {}".format(port))
|
||||||
|
self.port = port
|
||||||
|
self.heartbeat_interval = heartbeat_interval
|
||||||
|
self.started = False
|
||||||
|
self.closed = False
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
self.started = True
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
self.closed = True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def fake_library(monkeypatch):
|
||||||
|
module = types.SimpleNamespace(BaseReader=FakeBaseReader)
|
||||||
|
monkeypatch.setattr(reader_module, "rdm6300", module)
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def restore_library():
|
||||||
|
original = reader_module.rdm6300
|
||||||
|
yield
|
||||||
|
reader_module.rdm6300 = original
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_port_order_matches_the_workstation_app():
|
||||||
|
assert CANDIDATE_PORTS == (
|
||||||
|
"/dev/ttyS0", "/dev/ttyAMA0", "/dev/ttyUSB0", "/dev/ttyACM0",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_first_working_port_wins(fake_library):
|
||||||
|
FakeBaseReader.fail_on = ("/dev/ttyS0", "/dev/ttyAMA0")
|
||||||
|
reader = open_reader(lambda event: None,
|
||||||
|
ports=["/dev/ttyS0", "/dev/ttyAMA0", "/dev/ttyUSB0"])
|
||||||
|
assert reader is not None
|
||||||
|
assert reader.port == "/dev/ttyUSB0"
|
||||||
|
|
||||||
|
|
||||||
|
def test_returns_none_when_no_port_works(fake_library):
|
||||||
|
FakeBaseReader.fail_on = ("/dev/ttyS0", "/dev/ttyAMA0")
|
||||||
|
assert open_reader(lambda event: None,
|
||||||
|
ports=["/dev/ttyS0", "/dev/ttyAMA0"]) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_failures_are_reported_with_the_port(fake_library):
|
||||||
|
FakeBaseReader.fail_on = ("/dev/ttyS0",)
|
||||||
|
seen = []
|
||||||
|
open_reader(lambda event: None, ports=["/dev/ttyS0"],
|
||||||
|
on_error=lambda port, message: seen.append((port, message)))
|
||||||
|
assert seen and seen[0][0] == "/dev/ttyS0"
|
||||||
|
|
||||||
|
|
||||||
|
def test_heartbeat_interval_is_passed_through(fake_library):
|
||||||
|
FakeBaseReader.fail_on = ()
|
||||||
|
reader = open_reader(lambda event: None, ports=["/dev/ttyS0"],
|
||||||
|
heartbeat_interval=1.5)
|
||||||
|
assert reader.heartbeat_interval == 1.5
|
||||||
|
|
||||||
|
|
||||||
|
def test_heartbeat_none_disables_removal_detection(fake_library):
|
||||||
|
FakeBaseReader.fail_on = ()
|
||||||
|
reader = open_reader(lambda event: None, ports=["/dev/ttyS0"],
|
||||||
|
heartbeat_interval=None)
|
||||||
|
assert reader.heartbeat_interval is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_events_are_forwarded_to_the_callback(fake_library):
|
||||||
|
FakeBaseReader.fail_on = ()
|
||||||
|
received = []
|
||||||
|
reader = open_reader(received.append, ports=["/dev/ttyS0"])
|
||||||
|
|
||||||
|
class Card:
|
||||||
|
value = 7
|
||||||
|
checksum = 1
|
||||||
|
type = 15
|
||||||
|
is_valid = True
|
||||||
|
|
||||||
|
reader.card_inserted(Card())
|
||||||
|
assert len(received) == 1
|
||||||
|
assert received[0].value == 7
|
||||||
|
assert received[0].event == "card_inserted"
|
||||||
|
|
||||||
|
|
||||||
|
def test_card_removed_with_none_is_ignored(fake_library):
|
||||||
|
FakeBaseReader.fail_on = ()
|
||||||
|
received = []
|
||||||
|
reader = open_reader(received.append, ports=["/dev/ttyS0"])
|
||||||
|
reader.card_removed(None)
|
||||||
|
assert received == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_card_is_forwarded(fake_library):
|
||||||
|
FakeBaseReader.fail_on = ()
|
||||||
|
received = []
|
||||||
|
reader = open_reader(received.append, ports=["/dev/ttyS0"])
|
||||||
|
|
||||||
|
class Card:
|
||||||
|
value = 1
|
||||||
|
checksum = 2
|
||||||
|
type = 15
|
||||||
|
is_valid = False
|
||||||
|
|
||||||
|
reader.invalid_card(Card())
|
||||||
|
assert received[0].event == "invalid_card"
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_library_raises(monkeypatch):
|
||||||
|
monkeypatch.setattr(reader_module, "rdm6300", None)
|
||||||
|
with pytest.raises(reader_module.ReaderUnavailable):
|
||||||
|
open_reader(lambda event: None)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"message,expected_fragment",
|
||||||
|
[
|
||||||
|
("[Errno 13] Permission denied: '/dev/ttyS0'", "dialout"),
|
||||||
|
("could not open port /dev/nope", "device not found"),
|
||||||
|
("something else", "something else"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_explain_error(message, expected_fragment):
|
||||||
|
assert expected_fragment in explain_error("/dev/ttyS0", message)
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_ports_shape():
|
||||||
|
info = list_ports()
|
||||||
|
assert set(info) == {"candidates", "detected"}
|
||||||
|
assert [port for port, _ in info["candidates"]] == list(CANDIDATE_PORTS)
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
"""Tests for the vendored wheels and their verification.
|
||||||
|
|
||||||
|
The bundled wheels let a device with no internet set itself up, so the things
|
||||||
|
that matter are: they are actually present and pure-Python (portable), the
|
||||||
|
checksums match, and a tampered wheel is refused rather than installed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
|
||||||
|
SRC = os.path.join(
|
||||||
|
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "src"
|
||||||
|
)
|
||||||
|
if SRC not in sys.path:
|
||||||
|
sys.path.insert(0, SRC)
|
||||||
|
|
||||||
|
import pytest # noqa: E402
|
||||||
|
|
||||||
|
from rdm_card_reader import bootstrap # noqa: E402
|
||||||
|
from rdm_card_reader.bootstrap import ( # noqa: E402
|
||||||
|
bundled_wheel_dir,
|
||||||
|
ensure_dependencies,
|
||||||
|
local_wheel_missing,
|
||||||
|
verify_wheels,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# -- the wheels that ship with the package ---------------------------------
|
||||||
|
|
||||||
|
def test_bundled_wheel_dir_exists():
|
||||||
|
folder = bundled_wheel_dir()
|
||||||
|
assert folder is not None
|
||||||
|
assert os.path.isdir(folder)
|
||||||
|
|
||||||
|
|
||||||
|
def test_both_required_wheels_are_present():
|
||||||
|
folder = bundled_wheel_dir()
|
||||||
|
names = os.listdir(folder)
|
||||||
|
assert any(n.startswith("rdm6300-") and n.endswith(".whl") for n in names)
|
||||||
|
assert any(n.startswith("pyserial-") and n.endswith(".whl") for n in names)
|
||||||
|
|
||||||
|
|
||||||
|
def test_bundled_wheels_are_pure_python():
|
||||||
|
"""A compiled wheel would only work on one architecture; these must not."""
|
||||||
|
folder = bundled_wheel_dir()
|
||||||
|
wheels = [n for n in os.listdir(folder) if n.endswith(".whl")]
|
||||||
|
assert wheels
|
||||||
|
for name in wheels:
|
||||||
|
assert name.endswith("-none-any.whl"), (
|
||||||
|
"{} is architecture-specific and cannot be shipped".format(name)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_bundled_wheels_verify_against_their_checksums():
|
||||||
|
folder = bundled_wheel_dir()
|
||||||
|
assert verify_wheels(folder) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_checksums_file_is_shipped():
|
||||||
|
folder = bundled_wheel_dir()
|
||||||
|
assert os.path.exists(os.path.join(folder, "SHA256SUMS"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_total_vendored_size_is_reasonable():
|
||||||
|
"""Guard against someone dropping a huge binary into the repo."""
|
||||||
|
folder = bundled_wheel_dir()
|
||||||
|
total = sum(
|
||||||
|
os.path.getsize(os.path.join(folder, name))
|
||||||
|
for name in os.listdir(folder)
|
||||||
|
)
|
||||||
|
assert total < 2 * 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
# -- verification -----------------------------------------------------------
|
||||||
|
|
||||||
|
def test_tampered_wheel_is_detected(tmp_path):
|
||||||
|
folder = tmp_path / "wheels"
|
||||||
|
folder.mkdir()
|
||||||
|
wheel = folder / "rdm6300-0.1.1-py3-none-any.whl"
|
||||||
|
wheel.write_bytes(b"not the real wheel")
|
||||||
|
(folder / "SHA256SUMS").write_text(
|
||||||
|
"afb067da8b8d23a477138ca67d4ff1d198f12d157b64668c9dd3cbd7df54e74e"
|
||||||
|
" rdm6300-0.1.1-py3-none-any.whl\n"
|
||||||
|
)
|
||||||
|
problems = verify_wheels(str(folder))
|
||||||
|
assert problems
|
||||||
|
assert any("checksum mismatch" in p for p in problems)
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_wheel_is_reported(tmp_path):
|
||||||
|
folder = tmp_path / "wheels"
|
||||||
|
folder.mkdir()
|
||||||
|
(folder / "SHA256SUMS").write_text(
|
||||||
|
"abc123 rdm6300-0.1.1-py3-none-any.whl\n"
|
||||||
|
)
|
||||||
|
problems = verify_wheels(str(folder))
|
||||||
|
assert any("missing" in p for p in problems)
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_sums_file_means_nothing_to_check(tmp_path):
|
||||||
|
folder = tmp_path / "wheels"
|
||||||
|
folder.mkdir()
|
||||||
|
(folder / "x-1.0-py3-none-any.whl").write_bytes(b"x")
|
||||||
|
assert verify_wheels(str(folder)) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_verification_passes_for_untouched_copy(tmp_path):
|
||||||
|
source = bundled_wheel_dir()
|
||||||
|
dest = tmp_path / "wheels"
|
||||||
|
shutil.copytree(source, dest)
|
||||||
|
assert verify_wheels(str(dest)) == []
|
||||||
|
|
||||||
|
|
||||||
|
# -- coverage check ---------------------------------------------------------
|
||||||
|
|
||||||
|
def test_local_wheel_missing_detects_absent_requirement(tmp_path):
|
||||||
|
folder = tmp_path / "wheels"
|
||||||
|
folder.mkdir()
|
||||||
|
assert local_wheel_missing(["rdm6300>=0.1.1"], str(folder)) == ["rdm6300>=0.1.1"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_local_wheel_missing_accepts_present_requirement():
|
||||||
|
folder = bundled_wheel_dir()
|
||||||
|
assert local_wheel_missing(["rdm6300>=0.1.1", "pyserial>=3.5"], folder) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_local_wheel_missing_normalises_underscores(tmp_path):
|
||||||
|
folder = tmp_path / "wheels"
|
||||||
|
folder.mkdir()
|
||||||
|
(folder / "charset_normalizer-3.4.1-py3-none-any.whl").write_bytes(b"x")
|
||||||
|
assert local_wheel_missing(["charset-normalizer>=3"], str(folder)) == []
|
||||||
|
|
||||||
|
|
||||||
|
# -- integration into ensure_dependencies -----------------------------------
|
||||||
|
|
||||||
|
def test_bundled_wheels_used_automatically(monkeypatch):
|
||||||
|
"""No --wheel-dir given, so the bundled ones must be picked up."""
|
||||||
|
seen = {}
|
||||||
|
monkeypatch.setattr(
|
||||||
|
bootstrap, "missing_packages",
|
||||||
|
lambda packages=None: [] if seen.get("done") else [("rdm6300", "rdm6300>=0.1.1")],
|
||||||
|
)
|
||||||
|
|
||||||
|
def fake_install(requirements, wheel_dir=None, logger=None, timeout=300, no_deps=False):
|
||||||
|
seen["wheel_dir"] = wheel_dir
|
||||||
|
seen["done"] = True
|
||||||
|
return True
|
||||||
|
|
||||||
|
monkeypatch.setattr(bootstrap, "install", fake_install)
|
||||||
|
lines = []
|
||||||
|
assert ensure_dependencies(logger=lines.append) is True
|
||||||
|
assert seen["wheel_dir"] == bundled_wheel_dir()
|
||||||
|
assert any("bundled" in line for line in lines)
|
||||||
|
|
||||||
|
|
||||||
|
def test_tampered_bundle_falls_back_to_pypi(monkeypatch, tmp_path):
|
||||||
|
"""A bad checksum must not block installation - but must be reported."""
|
||||||
|
folder = tmp_path / "wheels"
|
||||||
|
folder.mkdir()
|
||||||
|
(folder / "rdm6300-0.1.1-py3-none-any.whl").write_bytes(b"tampered")
|
||||||
|
(folder / "SHA256SUMS").write_text("deadbeef rdm6300-0.1.1-py3-none-any.whl\n")
|
||||||
|
|
||||||
|
seen = {}
|
||||||
|
monkeypatch.setattr(bootstrap, "bundled_wheel_dir", lambda: str(folder))
|
||||||
|
monkeypatch.setattr(
|
||||||
|
bootstrap, "missing_packages",
|
||||||
|
lambda packages=None: [] if seen.get("done") else [("rdm6300", "rdm6300>=0.1.1")],
|
||||||
|
)
|
||||||
|
|
||||||
|
def fake_install(requirements, wheel_dir=None, logger=None, timeout=300, no_deps=False):
|
||||||
|
seen["wheel_dir"] = wheel_dir
|
||||||
|
seen["done"] = True
|
||||||
|
return True
|
||||||
|
|
||||||
|
monkeypatch.setattr(bootstrap, "install", fake_install)
|
||||||
|
lines = []
|
||||||
|
assert ensure_dependencies(logger=lines.append) is True
|
||||||
|
assert seen["wheel_dir"] is None # fell back to PyPI
|
||||||
|
assert any("checksum mismatch" in line for line in lines)
|
||||||
|
|
||||||
|
|
||||||
|
def test_explicit_wheel_dir_overrides_bundled(monkeypatch, tmp_path):
|
||||||
|
folder = tmp_path / "wheels"
|
||||||
|
folder.mkdir()
|
||||||
|
(folder / "rdm6300-0.1.1-py3-none-any.whl").write_bytes(b"x")
|
||||||
|
(folder / "pyserial-3.5-py2.py3-none-any.whl").write_bytes(b"x")
|
||||||
|
|
||||||
|
seen = {}
|
||||||
|
monkeypatch.setattr(
|
||||||
|
bootstrap, "missing_packages",
|
||||||
|
lambda packages=None: [] if seen.get("done") else [("rdm6300", "rdm6300>=0.1.1")],
|
||||||
|
)
|
||||||
|
|
||||||
|
def fake_install(requirements, wheel_dir=None, logger=None, timeout=300, no_deps=False):
|
||||||
|
seen["wheel_dir"] = wheel_dir
|
||||||
|
seen["done"] = True
|
||||||
|
return True
|
||||||
|
|
||||||
|
monkeypatch.setattr(bootstrap, "install", fake_install)
|
||||||
|
ensure_dependencies(wheel_dir=str(folder))
|
||||||
|
assert seen["wheel_dir"] == str(folder)
|
||||||
|
|
||||||
|
|
||||||
|
def test_bundle_not_covering_a_requirement_falls_back(monkeypatch, tmp_path):
|
||||||
|
"""A bundle missing one package must not cause a guaranteed pip failure."""
|
||||||
|
folder = tmp_path / "wheels"
|
||||||
|
folder.mkdir()
|
||||||
|
(folder / "rdm6300-0.1.1-py3-none-any.whl").write_bytes(b"x")
|
||||||
|
|
||||||
|
seen = {}
|
||||||
|
monkeypatch.setattr(bootstrap, "bundled_wheel_dir", lambda: str(folder))
|
||||||
|
monkeypatch.setattr(
|
||||||
|
bootstrap, "missing_packages",
|
||||||
|
lambda packages=None: [] if seen.get("done")
|
||||||
|
else [("rdm6300", "rdm6300>=0.1.1"), ("serial", "pyserial>=3.5")],
|
||||||
|
)
|
||||||
|
|
||||||
|
def fake_install(requirements, wheel_dir=None, logger=None, timeout=300, no_deps=False):
|
||||||
|
seen["wheel_dir"] = wheel_dir
|
||||||
|
seen["wheel_dir_at_call"] = wheel_dir
|
||||||
|
seen["done"] = True
|
||||||
|
return True
|
||||||
|
|
||||||
|
monkeypatch.setattr(bootstrap, "install", fake_install)
|
||||||
|
lines = []
|
||||||
|
ensure_dependencies(logger=lines.append)
|
||||||
|
assert seen["wheel_dir"] is None
|
||||||
|
assert any("do not cover" in line for line in lines)
|
||||||
Reference in New Issue
Block a user