Compare commits

..

6 Commits

Author SHA1 Message Date
ske087 031cf0f25a Add a status LED driven by card events
The workstation app lit an LED on GPIO 23 with the card. This brings that
across, with three deliberate differences: it never blocks the reader, it
cannot be fatal, and its state is reported.

WMT toggled the LED directly inside card_inserted, on the reader thread, where
a slow write stalls card detection. Here every change is queued to a worker, so
insert() returns immediately (tested with a deliberately slow pin).

Two modes, because the light can mean two things:
  error    (default) lit while the reader runs, dark while reading, blinks on a
                     bad read - a health indicator
  presence           lit only while a card is on the reader

gpiozero raises BadPinFactory on a machine with no GPIO, and that subclasses
ImportError - so a single except ImportError reports 'gpiozero is not
installed' when the library is present but has no usable pin provider. The
library check and the pin construction are therefore separate.

Vendors gpiozero + colorzero (~180 KB) so the LED installs offline too, and
deliberately not setuptools: gpiozero declares it via colorzero, but it is only
a packaging requirement and is never imported at runtime. Installing to the
bundled wheels therefore uses --no-deps, saving 818 KB.

Verified end to end over a pseudo-terminal with a fake pin: a real RDM6300
frame drives the LED through the app's own CLI in both modes.
2026-10-01 16:43:29 +03:00
ske087 2ded37e190 Fix install.sh failing on offline Raspberry Pi at the venv step
install.sh ran 'pip install .', which builds the project: pip downloads
setuptools into an isolated build environment. With no internet it fails at
the 'Building the virtual environment' step with

    Could not find a version that satisfies the requirement setuptools>=68
    (from versions: none)

even though the runtime libraries are already in the wheel directory. The
build backend is only needed to package, never to run.

- Install the runtime deps straight from the bundled wheels with --no-index,
  and put src/ on the path via a .pth file, so no build step happens at all.
- Generate the console script with the venv's interpreter as an absolute
  shebang. '#\!/usr/bin/env python3' resolves to the *system* python when the
  venv is not activated, so the package was not importable from the unit.
- apt-get update/install and 'pip install --upgrade pip' are now best-effort:
  all three need the network and none should abort the install.
- Detect a failed 'python3 -m venv' and name python3-venv as the usual cause.
- Sanity-check the install with --version before enabling the service.
- The unit falls back to 'python -m rdm_card_reader' if the console script is
  ever absent (no shell in ExecStart, so the installer picks the path).

Reproduced the failure offline, then verified the fix offline as well: a venv
created on a copy of the tree self-provisions from the bundled wheels and runs
from cwd=/ with no PYTHONPATH and no venv activation.

Added tests/test_deploy.py (20 tests) covering the installer and the deploy
artifacts, including a regression test for 'pip install .' that was confirmed
to fail when the bug is reintroduced.
2026-10-01 16:19:33 +03:00
ske087 674d91a5ca Add LAUNCH.md: startup sequence and why it fails without a reader
development is x86_64 with no RFID reader attached, so the reader path exits 1
by design. That looks like breakage when it is not, so document it.

- The expected output on this host, including why /dev/ttyS0 gives
  'Input/output error' (node exists, hardware does not answer) rather than
  'device not found' - the app distinguishes the two.
- The 5-stage startup as a diagram, with the code and exit code for each stage,
  and why stages 1-2 fail fast on purpose.
- How to exercise the reader path with no hardware: the PTY tests, plus a
  standalone snippet that writes a real RDM6300 frame. Both the snippet and the
  documented outputs were run verbatim, not written from memory.
- The pipe trap: head closes the pipe early and the shell then reports
  128+SIGPIPE, so redirect to a file when checking the real exit code.
2026-10-01 15:55:16 +03:00
ske087 23cd8f2df5 Ship the reader wheels inside the package for offline install
A device with no internet could not set itself up: the auto-install needed
PyPI. The rdm6300 and pyserial wheels now ship inside the package, so first
start works with no network at all.

Both wheels are pure Python (py3-none-any / py2.py3-none-any), which is what
makes this possible - the same files work on armv7l, aarch64 and x86_64. This
is the difference from the workstation app's Files/repository cache, which
held aarch64 manylinux binaries and so could not be committed; that cache only
ever existed on the Pi devices.

- bundled_wheel_dir() locates them; ensure_dependencies() prefers them
  automatically, so the service needs no --wheel-dir.
- verify_wheels() checks SHA256SUMS before installing. A wheel that fails its
  checksum is refused rather than installed, then PyPI is tried, and if that is
  unreachable it reports exit 2 instead of proceeding unverified.
- local_wheel_missing() falls back to PyPI when the bundle does not cover a
  requirement, instead of attempting a --no-index install that must fail.
- An explicit --wheel-dir that is incomplete or unverified is called out, not
  silently swapped for PyPI.

Verified end to end: built wheel and sdist both contain the wheels; a venv
with no rdm6300 self-provisions with a dead proxy and no PIP_NO_INDEX; and a
tampered wheel is refused with nothing installed.
2026-10-01 15:48:34 +03:00
ske087 eaf3f394fc Install missing reader libraries on first start
A freshly flashed device needed manual pip work before the reader would
start. It now checks its dependencies, installs whatever is missing, and
carries on.

- bootstrap.py: import check, pip invocation, and a re-check that does not
  trust pip's exit code alone. Installs nothing when the libraries are
  already present, so a normal start does no network work.
- --wheel-dir installs from local wheels with --no-index, for devices with
  no internet. --no-install-deps opts out entirely.
- Outside a venv pip gets --break-system-packages, which Debian and
  Raspberry Pi OS require before touching the system Python.
- reload_library() re-imports rdm6300 when it was installed at runtime, since
  the module-level import already ran and failed.
- A failed install reports exit 2 and the exact manual command rather than
  raising; stdout is flushed so the explanation is not printed after the
  error when piped.
2026-10-01 15:40:56 +03:00
ske087 576f22268f RDM card reader: print card events, select the right port, serve taps to a web app
Card reading (rdm6300 + pyserial) printing card inserted/removed with the
full card data, plus a --serve mode that publishes taps over HTTP so a web
page can authenticate a tap.

The bridge reports card ids only - mapping a card to a user stays on the
server. The tap is latched for --tap-ttl seconds, including after the card is
lifted, and is single use, so a page reload cannot replay one tap into two
logins.

Port selection identifies the reader by serial number or by-id path instead of
/dev/ttyUSBn, and refuses to guess when a selector matches several adapters.

deploy/ has the systemd unit, a udev rule for a stable /dev/rdm-reader, and
install.sh. Covered by unit tests plus PTY end-to-end tests.
2026-10-01 15:31:04 +03:00
11 changed files with 99 additions and 1009 deletions
+13 -21
View File
@@ -9,28 +9,18 @@ expected and correct.** To see the app work without hardware, use `--demo`:
.venv/bin/python -m rdm_card_reader --demo
```
To see the reader path end-to-end without hardware, drive a pseudo-terminal
pretending to be an RDM6300 — see **Testing the reader path without a reader**
below.
To see the reader path run end-to-end without hardware, the test suite drives a
pseudo-terminal pretending to be an RDM6300:
```bash
.venv/bin/python -m pytest tests/ -q # 152 tests, includes e2e PTY tests
```
---
## 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:
All commands run from the project root. The virtualenv is already built.
```bash
.venv/bin/python -m rdm_card_reader --demo # simulate a card, no hardware
@@ -39,9 +29,9 @@ Then, from the project root:
.venv/bin/python -m rdm_card_reader --serve # HTTP bridge for a web page
```
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.
After `pip install .` the same thing is available as `rdm-card-reader …`.
`pip install .` needs the network — see **Troubleshooting** below for the
offline route.
---
@@ -340,7 +330,9 @@ somewhere else, but `src/` must be present or the wheels will be missing.
## Testing the reader path without a reader
Because the app talks plain serial, a pseudo-terminal is enough to exercise the
whole chain: write real RDM6300 frames into a PTY and watch the printed events.
whole chain. `tests/test_e2e_pty.py` writes real RDM6300 frames into a PTY and
asserts on the printed events; `tests/test_e2e_bridge.py` does the same and then
claims the tap over HTTP exactly as a web page would.
To hand-feed a single frame yourself:
+43 -422
View File
@@ -1,13 +1,9 @@
# rdm-card-reader
Read 125 kHz RFID cards (RDM6300 reader, EM4100 cards) on a serial port. Two
ways to use it:
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**.
* **print events** — card inserted with the card data, then card removed
* **serve an API** (`--serve`) — expose the card on the reader over HTTP so a
web app can authenticate a card tap
Built for a Raspberry Pi, but it runs anywhere with a serial port.
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
@@ -15,21 +11,6 @@ Built for a Raspberry Pi, but it runs anywhere with a serial port.
---
## Contents
- [What it does](#what-it-does)
- [Install](#install)
- [Usage](#usage)
- [Serving card data to a web app (`--serve`)](#serving-card-data-to-a-web-app---serve)
- [Running at boot (Raspberry Pi)](#running-at-boot-raspberry-pi)
- [Status LED](#status-led)
- [Wiring](#wiring)
- [The protocol](#the-protocol)
- [Troubleshooting](#troubleshooting)
- [Check it works](#check-it-works)
---
## What it does
```
@@ -118,8 +99,7 @@ wheels on first start.
```bash
rdm-card-reader # auto-detect the serial port
rdm-card-reader --port /dev/serial0 # the Pi's on-board UART
rdm-card-reader --port /dev/ttyUSB0 # a USB adapter
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
@@ -133,7 +113,7 @@ Also works as a module: `python -m rdm_card_reader`.
| --- | --- | --- |
| `--port` | auto-detect | Serial port: device path, by-id symlink, serial number or `vid:pid` |
| `--autodetect` | off | Pick the only USB adapter; refuse if there is more than one |
| `--heartbeat` | `2.0` | Seconds without a read before the card counts as removed. Also the presence/LED window |
| `--heartbeat` | `0.5` | Seconds without a read before the card counts as removed |
| `--json` | off | Machine-readable output instead of the text block |
| `--list-ports` | – | List candidate and identified ports, then exit |
| `--verbose-ports` | off | With `--list-ports`, show by-id / by-path detail |
@@ -143,24 +123,15 @@ Also works as a module: `python -m rdm_card_reader`.
| `--wheel-dir` | bundled | Install from local wheels in this DIR instead of the bundled ones |
| `--version` | – | Print the version |
With `--serve`:
With `--serve`: `--host` (`127.0.0.1`), `--bridge-port` (`8765`), `--mode`
(`login`), `--tap-ttl` (`10`), `--allow-origin` (repeatable), `--token`,
`--allow-remote`, `--verbose`.
| Option | Default | Meaning |
| --- | --- | --- |
| `--host` | `127.0.0.1` | Bind address; anything else needs `--allow-remote` |
| `--bridge-port` | `8765` | HTTP port |
| `--mode` | `login` | `login` publishes taps only; `monitor` also publishes removals |
| `--tap-ttl` | `10` | Seconds an unclaimed tap stays claimable; `0` keeps it until replaced. Keep it ≥ `--heartbeat` |
| `--claimed-ttl` | `30` | Seconds a claimed card stays readable; `0` keeps it indefinitely |
| `--allow-origin` | – | Permit this browser origin (repeatable) |
| `--token` | – | Pre-shared token for non-local callers (or `RDM_BRIDGE_TOKEN`) |
| `--allow-remote` | off | Permit binding a non-loopback address |
| `--verbose` | off | Also print each event and each HTTP request |
When `--port` is omitted the ports are tried in this order:
When `--port` is omitted the ports are tried in this order, the same order the
workstation app uses:
```
/dev/ttyS0 -> /dev/ttyAMA0 -> /dev/serial0 -> /dev/ttyUSB0 -> /dev/ttyACM0
/dev/ttyS0 -> /dev/ttyAMA0 -> /dev/ttyUSB0 -> /dev/ttyACM0
```
### Output modes
@@ -233,7 +204,7 @@ This is the mode for **authenticating a web page**: the reader runs as a service
on the device, and the page asks it for the card that was just tapped.
```bash
rdm-card-reader --serve --port /dev/serial0 --allow-origin https://your.web.app
rdm-card-reader --serve --port /dev/rdm-reader --allow-origin https://your.web.app
```
The bridge reports **card ids only**. Deciding which user a card belongs to is
@@ -251,9 +222,9 @@ sequenceDiagram
C->>B: tap
B->>B: latch the tap (tap-ttl seconds)
K->>B: GET /api/card
B-->>K: {present: true, claimable: true, card: {id: ...}}
B-->>K: {claimable: true, card: {id: ...}}
K->>B: POST /api/card/claim
B-->>K: {card: {id: ...}, consumed: true} (once, then 409)
B-->>K: {card: {id: ...}} (once)
K->>W: POST /login {card_id}
W->>W: card_id -> user
W-->>K: session
@@ -263,52 +234,10 @@ The point of the *latch* is that the tap stays available for `--tap-ttl`
seconds (default 10), **including after the card is lifted**. A quick tap
therefore cannot be lost because the page happened to poll a moment later.
`present` and `card` answer different questions, which is the part that
surprises people:
| Field | Meaning |
| --- | --- |
| `present` | the card is physically on the reader **right now** — goes `false` the moment it is lifted |
| `card` | the **latched tap** — survives the card being lifted, so you still know which card it was |
So a card that was tapped and lifted a second ago reports `present: false` with
`card` still populated. That is correct, not a bug: if the id vanished with the
card, any poll arriving after the lift could not authenticate anything.
The point of *claim* is single use. Once a tap is spent, a page reload or a
second tab gets `409 Conflict` instead of a second login, so one physical tap
can never authenticate two sessions.
Two independent windows govern how long the card stays readable:
| Window | Flag | Default | Governs |
| --- | --- | --- | --- |
| Unclaimed | `--tap-ttl` | 10 s | how long a tap can still be spent on a login |
| Claimed | `--claimed-ttl` | 30 s | how long a claimed card stays readable, so the page that claimed it can confirm which card logged in |
Put together, `/api/card` moves through these states:
| Moment | `present` | `card` | `claimable` | `consumed` |
| --- | --- | --- | --- | --- |
| Card held on the reader | `true` | id | `true` | `false` |
| Lifted, within `--tap-ttl` | `false` | id | `true` | `false` |
| Claimed, within `--claimed-ttl` | `false` | id | `false` | `true` |
| After the claimed window | `false` | `null` | `false` | `false` |
| Unclaimed and past `--tap-ttl` | `false` | `null` | `false` | `false` |
The last two rows are the point: an id that can no longer be acted on is
cleared, so it cannot be mistaken for a tap still waiting to be used.
`/api/last` and `/api/taps` keep the history either way. Setting either value to
`0` disables that expiry.
> **Set `--tap-ttl` at least as long as `--heartbeat`, or longer.**
> `--tap-ttl` applies to the tap, `--heartbeat` to presence, and they are
> independent. If the tap expires *before* the heartbeat declares the card
> removed, a card still sitting on the reader has its id cleared and reads as
> `"card": null` until the next re-read — which on a reader that reports once
> per placement may be a long time. Matching them (`--heartbeat 10 --tap-ttl 10`)
> avoids the gap for a card held in place.
### Endpoints
| Method | Path | Purpose |
@@ -318,19 +247,13 @@ cleared, so it cannot be mistaken for a tap still waiting to be used.
| `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, both TTLs — for monitoring |
| `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.
`/api/health` is the liveness check: `status: "ok"` with a rising
`uptime_seconds` means the service is serving. It is answered **before** the
origin and token checks, so it stays readable by any client that can reach the
port — which is the intent on loopback, but means binding with `--allow-remote`
exposes the port, mode, uptime and tap counts to the network.
### Browser example
```js
@@ -348,15 +271,6 @@ if (state.claimable) {
}
```
Already claimed, and you just want to know *which* card it was? The id stays
readable for `--claimed-ttl` (default 30 s) after the claim, with
`consumed: true`:
```js
const { card, consumed } = await (await fetch("http://127.0.0.1:8765/api/card")).json();
if (consumed && card) console.log("that tap was card", card.id);
```
Or push instead of poll, so a tap logs in immediately:
```js
@@ -376,8 +290,7 @@ they do not make the card itself convincing as a sole credential.
| Origins | Cross-origin browser requests are **refused** unless `--allow-origin` lists that exact origin. Stops another site from reading your taps. |
| Token | `--token` (or `RDM_BRIDGE_TOKEN`) is required from non-loopback callers. |
| Single use | A tap can only ever produce one successful claim. |
| Expiry | An unclaimed tap older than `--tap-ttl` is refused with `410`. |
| Bounded retention | A claimed tap stays readable for `--claimed-ttl` (30 s), then the id is cleared — an id that can no longer be acted on is never left in the response. |
| 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.
@@ -392,127 +305,19 @@ actually cost you something.
## Running at boot (Raspberry Pi)
The defaults already match the common wiring — **`/dev/serial0`** for the
reader on the Pi's UART and **GPIO 23** for the LED — so the LED pin and device
usually need no arguments at all:
```bash
sudo ./deploy/install.sh --origin https://your.web.app
```
To be explicit (and safe if the defaults ever change):
```bash
sudo ./deploy/install.sh --origin https://your.web.app \
--device /dev/serial0 \
--led-pin 23 \
--led-mode presence
```
| Argument | Why |
| --- | --- |
| `--device /dev/serial0` | The reader is wired to the UART pins (8 / 10). This is the default. For a USB-to-serial adapter pass `--device /dev/rdm-reader` instead — that alias is created by udev for USB adapters only, so it never exists on a UART setup |
| `--led-pin 23` | BCM GPIO 23, which is **physical pin 16**. The default |
| `--led-mode presence` | Lit while a card is on the reader, dark otherwise. The default |
> **`--led-pin` is the BCM GPIO number, not the header pin.** If your LED wire
> is on physical pin 16, pass `--led-pin 23` (the default). Passing
> `--led-pin 16` would drive **GPIO 16**, which is physical pin 36 — a different
> pin entirely. The LED is optional: with no LED, pass `--led-mode presence`
> regardless and ignore the light, or add `--no-led` to the unit if you want
> the app to stop trying.
### Standalone install
`deploy/install.sh` is the only installer — there is no `deploy.sh`.
That installs a service account, a virtualenv, a systemd unit and a udev rule,
then enables the service. The device defaults to `/dev/rdm-reader`; if the
reader is on the Pi's UART instead, add `--device /dev/serial0` — see
**UART or USB?** above. The installer refuses to enable the service unless the
device exists and opens, so a misconfigured port fails during install rather
than as a crash-looping unit.
Useful installer options:
| Option | Default | Meaning |
| --- | --- | --- |
| `--origin URL` | – | Web app origin allowed to call the API (repeatable) |
| `--device PATH` | `/dev/serial0` | Serial device the service opens (UART; use `/dev/rdm-reader` for USB) |
| `--led-pin N` | `23` | BCM GPIO number of the status LED (physical pin 16) |
| `--led-mode M` | `presence` | `presence`: lit while a card is on the reader; `error`: lit whenever running |
| `--heartbeat S` | `2.0` | Seconds before a silent reader counts as card removed — also the presence/LED window |
| `--port PORT` | `8765` | HTTP port for the bridge |
| `--dir PATH` | `/opt/rdm-card-reader` | Install location |
| `--user NAME` | `rdmcard` | Service account |
| `--no-service` | off | Install and configure, but do not enable the service |
The unit runs the app as:
```
/opt/rdm-card-reader/.venv/bin/rdm-card-reader \
--serve --port <device> --bridge-port 8765 --mode login \
--led-pin <pin> --led-mode <mode> --heartbeat <seconds>
```
`WorkingDirectory=/opt/rdm-card-reader`, so this is equivalent to running
`.venv/bin/python -m rdm_card_reader --port <device>` there with `--serve`
added. `--serve` is what makes the API available; without it the app only
prints events, which a service sends to the journal.
Afterwards:
then enables the service. Afterwards:
```bash
systemctl status rdm-card-reader # state
systemctl is-enabled rdm-card-reader # should be 'enabled' (starts at boot)
journalctl -u rdm-card-reader -f # live log
ss -ltnp | grep 8765 # confirms something is listening
curl -sS http://127.0.0.1:8765/api/card # tap a card, then run this
curl http://127.0.0.1:8765/api/card # tap a card, then run this
```
A healthy startup logs one line:
```
OK reader on /dev/serial0 ; bridge on http://127.0.0.1:8765 (mode: login)
```
### Checking the reader from the command line
```bash
# Is the reader alive? (status, port, uptime)
curl -sS http://127.0.0.1:8765/api/health | python3 -m json.tool
# Is a card on the reader right now?
curl -sS http://127.0.0.1:8765/api/card | python3 -m json.tool
```
Use `curl -sS` rather than plain `-s`: the extra `S` prints errors, so a
`Connection refused` is visible instead of an empty response that `json.tool`
then reports as `Expecting value: line 1 column 1 (char 0)`. That message means
*nothing answered*, which almost always means the service is not running —
check `ss -ltnp | grep 8765` first.
### The service gave up after a few failures
`StartLimitBurst=5` over `StartLimitIntervalSec=60` means systemd stops trying
after five failed starts — about 15 seconds. That is deliberate (an absent
reader should not restart forever), but it means fixing the wiring is not
enough on its own; the failed state has to be cleared:
```bash
sudo systemctl reset-failed rdm-card-reader
sudo systemctl restart rdm-card-reader
```
If a unit edit has no effect, you forgot `sudo systemctl daemon-reload` —
systemd caches unit files. Confirm what the service actually runs with:
```bash
systemctl cat rdm-card-reader | grep -E '^(ExecStart|DeviceAllow|After)'
```
That output, not the `.service` file on disk, is the truth the service runs.
### Identifying the right ttyUSB
`/dev/ttyUSB0` is **not** a stable identity — unplug something and the number
@@ -533,16 +338,12 @@ Identified serial ports (best candidate first):
Then pin it, most specific first:
```bash
--port /dev/rdm-reader # udev alias (USB adapters)
--port /dev/serial0 # on-board UART
--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
```
Note the two are wired differently and the udev alias only exists for one of
them — see **UART or USB?** below.
`--autodetect` picks the only USB adapter, and **refuses** when there is more
than one — silently choosing between two readers is exactly the failure this
avoids. Verify the wiring in `deploy/99-rdm-reader.rules`.
@@ -553,41 +354,29 @@ Exit codes: `0` ok, `1` no port, `2` library missing, `3` port ambiguous.
## Status LED
An LED driven by card events, on **GPIO 23** (physical pin **16**) by default —
the same pin the workstation app used. Optional: everything works without it.
By default the light **tracks the card**: lit while a card is on the reader,
dark otherwise.
An LED driven by card events, on **GPIO 23** by default — the same pin the
workstation app used.
```bash
rdm-card-reader # lit only while a card is on the reader
rdm-card-reader --led-mode error # lit whenever running, blinks on a bad read
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` | **BCM** GPIO number — not the physical header pin |
| `--led-mode` | `presence` | See the two readings below |
| `--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 |
`--led-pin` takes the **BCM GPIO number**. The two are easy to confuse, and it
is the second most common reason a LED "does not work" — the default `23` is
**physical pin 16**, not pin 23. The full mapping is under **Wiring** below.
Two modes, because the light can mean two different things:
| Mode | Behaviour | Use when |
| --- | --- | --- |
| `presence` (default) | **Lit only while a card is on the reader**, dark otherwise | The light should mean "a card is here" — the common choice, and unambiguous |
| `error` | **Lit whenever the reader is running.** Dark while a card is being read, blinks on a bad read | The light should mean "the reader is alive and ready" |
> In `presence` mode the light is **dark at startup** and lights on the first
> valid card read, then goes dark when the card is lifted. A dark LED on a
> freshly started service is therefore correct, not a fault. Check that a card
> read lights it before concluding the LED is broken.
| `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.
@@ -604,11 +393,10 @@ point:
refusing to run.
* **Never crashes on a dead pin.** A failed toggle is logged and ignored.
Startup reports which of the three states it is in (the mode shown is
`presence` by default):
Startup reports which of the three states it is in:
```
LED on GPIO 23 (presence mode, gpiozero) # working
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
```
@@ -617,87 +405,6 @@ That distinction matters: `BadPinFactory` subclasses `ImportError`, so a naive
check reports "not installed" when the library is present but has no usable pin
provider — which would send you to the wrong fix.
### The reader works but the light stays dark
Because a LED failure is never fatal, a misconfigured LED looks exactly like a
working reader with no light. Check the reason the app printed:
```bash
journalctl -u rdm-card-reader -n 40 --no-pager | grep -i led
```
| Message | Cause | Fix |
| --- | --- | --- |
| `LED unavailable: no usable GPIO on pin 23: ...` | gpiozero cannot reach a pin backend, or systemd/group permission is missing | See the three checks below |
| `LED unavailable: gpiozero not installed: ...` | LED extras missing from the venv | Install from the bundled wheels (below) |
| `LED on GPIO 23 (presence mode, gpiozero)` | LED is initialised — suspect wiring, or simply no card read yet | See the wiring note below |
**Check 1 — the venv must see the system pin backend.** `gpiozero` is pure
Python, but the library it uses to talk to the pins (`lgpio` on Bookworm,
`RPi.GPIO` on older releases) is an **apt package in the system Python**. A
plain venv cannot see it, so gpiozero raises `BadPinFactory` and the app
quietly falls back to a no-op LED. This is why `install.sh` creates the venv
with `--system-site-packages`; if you built the venv by hand, that is the most
likely cause:
```bash
grep system-site-packages /opt/rdm-card-reader/.venv/pyvenv.cfg # want: true
/opt/rdm-card-reader/.venv/bin/python -c 'import lgpio; print("lgpio OK")'
```
If the first line is missing or the import fails, install the backend and
rebuild the venv:
```bash
sudo apt-get install -y python3-lgpio # Bookworm; python3-rpi.gpio on older
sudo ./deploy/install.sh --device /dev/serial0 # recreates the venv with the flag
```
**Check 2 — systemd device policy.** The unit sets `DevicePolicy=closed`, so
only devices named in `DeviceAllow=` are reachable. It must list the GPIO chip
as well as the serial port:
```
DeviceAllow=/dev/serial0 rw
DeviceAllow=/dev/gpiochip0 rw
DeviceAllow=/dev/gpiochip4 rw
```
`gpiochip0` is the header on Pi 1–4, `gpiochip4` on Pi 5. A missing entry is
denied by systemd before any group permission is even consulted. Confirm what
the service actually runs with
`systemctl cat rdm-card-reader | grep DeviceAllow`.
**Check 3 — the `gpio` group.** On modern Raspberry Pi OS (Bookworm onward)
`gpiozero` drives GPIO through `lgpio`, which opens `/dev/gpiochip0` — owned
`root:gpio`, mode `0660`. The service account must be in `gpio`:
```bash
sudo usermod -aG gpio rdmcard
```
Older releases (Bullseye, `RPi.GPIO`) use `/dev/gpiomem` and need no extra
group.
After any of these changes: `sudo systemctl daemon-reload` (systemd caches unit
files) and `sudo systemctl restart rdm-card-reader`.
**If the LED extras are missing**, install them offline from the bundled wheels
— a Pi with no internet cannot reach PyPI:
```bash
/opt/rdm-card-reader/.venv/bin/pip install --no-index \
--find-links /opt/rdm-card-reader/src/rdm_card_reader/wheels \
--no-deps gpiozero colorzero
```
**If the journal says the LED is on GPIO 23**, the software side is fine.
Tap a card: in the default `presence` mode the light comes on while the card is
held and goes dark when it is lifted. If it stays dark with a card on the
reader, the problem is electrical — see the pin numbering note under
**Wiring**. GPIO 23 is physical pin **16**, and the anode needs a series
resistor to the pin with the cathode to GND.
> **If the workstation app also runs on this device, use a different pin** — two
> processes driving GPIO 23 will fight, and the LED will flicker.
@@ -710,29 +417,8 @@ it is never imported at runtime and would add 818 KB for nothing.
## Wiring
The RDM6300 talks plain serial at **9600 baud, 8N1**.
A typical connection to the Pi's 40-pin header, using the on-board UART:
| RDM6300 | Pi pin | Function |
| --- | --- | --- |
| VCC (5 V) | 4 | power |
| GND | 6 | ground (must be common) |
| TX | **10** (GPIO 15, RXD) | card data → Pi |
| RX | 8 (GPIO 14, TXD) | not used — the reader only transmits |
The reader is **transmit-only**: it emits frames continuously and never reads,
so the Pi's TX line (pin 8) has nothing to send. Only pin 10 carries data.
> **Level warning.** The RDM6300's TX output is **5 V TTL**, while GPIO 15 is a
> **3.3 V** input. Feeding 5 V straight in often appears to work but stresses
> the pin. Use a divider (e.g. 1 kΩ in series, 2 kΩ to GND) or a level shifter
> on the reader-TX → pin-10 line. This is not a cause of "no cards read" if you
> already see data, but it is worth fixing before relying on the device.
For the status LED: **anode** → GPIO pin through a ~330 Ω resistor,
**cathode** → GND. See the pin numbering table below, and note the LED is
optional — the reader works without it.
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:
@@ -743,66 +429,9 @@ sudo raspi-config # Interface Options -> Serial Port
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`). If the serial console still owns the port the service will fail
to open it — check with:
```bash
sudo systemctl is-active serial-getty@ttyAMA0.service # must not be active
grep -o 'console=[^ ]*' /boot/firmware/cmdline.txt # must not say serial0
```
The GPIO UART appears as `/dev/serial0` (a symlink to `/dev/ttyAMA0` or
`/dev/ttyS0`).
### UART or USB? The service must be told which
The two are not interchangeable, and getting it wrong is the most common reason
the service fails with `exit 1`:
| Wiring | Device to pass | udev alias |
| --- | --- | --- |
| Pi UART pins **8 / 10** (or 10/8) | `/dev/serial0` | **never created** |
| USB-to-serial adapter | `/dev/rdm-reader` | created by the udev rule |
`deploy/99-rdm-reader.rules` matches a USB bridge chip, so it **cannot** fire
for the on-board UART — there is no USB device to match. A service pointing at
`/dev/rdm-reader` with the reader on the UART therefore finds nothing and exits
`1` on every start.
Tell the installer which one you have:
```bash
sudo ./deploy/install.sh --origin https://your.web.app --device /dev/serial0
```
It verifies the device both exists *and* opens at 9600 baud before enabling the
service, so this class of failure is caught at install time rather than as a
crash-looping unit:
```
/dev/serial0 opened at 9600 baud
```
If the LED is not on GPIO 23, say so at the same time (`--led-pin` takes the
**BCM GPIO number**, not the physical header pin):
```bash
sudo ./deploy/install.sh --device /dev/serial0 --led-pin 18 # LED on GPIO 18
```
### LED pin numbering
| `--led-pin` | BCM GPIO | Physical header pin |
| --- | --- | --- |
| `23` (the default) | GPIO 23 | **16** |
| `18` | GPIO 18 | 12 |
| `17` | GPIO 17 | 11 |
`--led-pin` is the **BCM** number, which is what the app uses and what GPIO
libraries use. Putting the LED on physical pin 23 would drive **GPIO 11**, not
GPIO 23 — the wire must go to physical pin **16** for the default to be right.
---
## The protocol
@@ -849,28 +478,19 @@ problem with the following card.
| Reader opens but no cards | Card must be EM4100 / 125 kHz; hold it ~2 cm from the coil |
| `INVALID CARD` repeatedly | Noisy supply or long/unshielded wiring; shorten it |
| Every card reported twice | Lower `--heartbeat` if the card is lifted quickly |
| Card reported as **removed while still on the reader** | Raise `--heartbeat` — the reader reports a card once per placement, so a short window expires while the card sits still |
| LED blinks on and off instead of staying lit | Same cause: the LED follows `present`. Raise `--heartbeat` |
| Service `exit 1`, `no port matched '/dev/rdm-reader'` | Reader is on the UART — re-install with `--device /dev/serial0` |
| Service `exit 1`, `Could not open /dev/serial0` | UART held by the serial console, or the user is not in `dialout` |
| Service stuck in `failed`, restarts refused | `sudo systemctl reset-failed rdm-card-reader` |
| `curl` returns nothing, `Expecting value: line 1 column 1` | Nothing is listening — the service is not running; check `ss -ltnp \| grep 8765` |
| Reader works but the LED is dark | See **The reader works but the light stays dark** |
---
## Check it works
With no RFID hardware attached, `--demo` exercises the formatting path and
`--list-ports` shows what serial ports exist:
## Development
```bash
rdm-card-reader --demo # simulated reads, no hardware needed
rdm-card-reader --list-ports # candidate and identified ports
pip install -e ".[dev]"
pytest
```
To drive the real reader path with no hardware, feed RDM6300 frames into a
pseudo-terminal — see the snippet in **[LAUNCH.md](LAUNCH.md)**.
The suite needs no RFID hardware: the end-to-end tests open a pseudo-terminal,
write real RDM6300 frames into it and assert on the CLI output. `--demo` also
works with no hardware at all.
### Layout
@@ -880,11 +500,12 @@ src/rdm_card_reader/
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)
tests/
test_protocol.py RDM6300 wire format, byte for byte
test_card.py event model and formatting
test_reader.py port selection, using a fake reader library
test_cli.py exit codes and output modes
test_e2e_pty.py full stack over a pseudo-terminal
```
### Using it as a library
+10 -111
View File
@@ -10,19 +10,10 @@
# Options:
# --origin URL web app origin allowed to read the API (repeatable)
# --port PORT HTTP port for the bridge (default 8765)
# --device PATH serial device the service opens (default /dev/serial0)
# --led-pin N BCM GPIO number of the status LED (default 23)
# --led-mode M 'presence' (default) or 'error'
# --heartbeat S seconds before a silent reader counts as card removed
# (default 2.0; this is also the presence/LED window)
# --dir PATH install location (default /opt/rdm-card-reader)
# --user NAME service account (default rdmcard)
# --no-service install and configure, but do not enable the service
#
# The default device is /dev/serial0, the Raspberry Pi's on-board UART, because
# that is how the reader is normally wired (header pins 8/10). For a
# USB-to-serial adapter pass --device /dev/rdm-reader, the udev alias.
#
set -euo pipefail
ORIGINS=()
@@ -30,10 +21,6 @@ BRIDGE_PORT=8765
INSTALL_DIR=/opt/rdm-card-reader
SERVICE_USER=rdmcard
INSTALL_SERVICE=1
SERIAL_DEVICE=/dev/serial0
LED_PIN=23
LED_MODE=presence
HEARTBEAT=2.0
SOURCE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
while [[ $# -gt 0 ]]; do
@@ -42,12 +29,8 @@ while [[ $# -gt 0 ]]; do
--port) BRIDGE_PORT="$2"; shift 2 ;;
--dir) INSTALL_DIR="$2"; shift 2 ;;
--user) SERVICE_USER="$2"; shift 2 ;;
--device) SERIAL_DEVICE="$2"; shift 2 ;;
--led-pin) LED_PIN="$2"; shift 2 ;;
--led-mode) LED_MODE="$2"; shift 2 ;;
--heartbeat) HEARTBEAT="$2"; shift 2 ;;
--no-service) INSTALL_SERVICE=0; shift ;;
-h|--help) sed -n '2,33p' "$0"; exit 0 ;;
-h|--help) sed -n '2,20p' "$0"; exit 0 ;;
*) echo "unknown option: $1" >&2; exit 2 ;;
esac
done
@@ -77,21 +60,14 @@ 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
for item in src tests 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"
# --system-site-packages is what makes the status LED work. gpiozero is pure
# Python, but the pin backend it needs (lgpio on Bookworm, RPi.GPIO on older
# releases) is an apt package installed into the *system* Python. A plain venv
# cannot see it, so gpiozero finds no usable pin factory, raises BadPinFactory
# and the app falls back to a no-op LED: the reader works and the light stays
# dark, with nothing failing. Re-running this on an existing venv updates
# pyvenv.cfg in place, so an upgrade picks the flag up too.
if ! python3 -m venv --system-site-packages "$INSTALL_DIR/.venv"; then
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"
@@ -196,17 +172,6 @@ fi
usermod -aG dialout "$SERVICE_USER"
echo " $SERVICE_USER is in group dialout"
# The status LED drives /dev/gpiochip*, which is root:gpio on modern Raspberry
# Pi OS (Bookworm onward, gpiozero via lgpio). Without this group the service
# cannot open the chip, gpiozero falls back to a no-op LED, and the reader
# works with no light - the failure that is easiest to miss. Older releases
# (Bullseye, RPi.GPIO) use /dev/gpiomem and need no extra group, hence the
# group check rather than an unconditional usermod.
if getent group gpio >/dev/null 2>&1; then
usermod -aG gpio "$SERVICE_USER"
echo " $SERVICE_USER is in group gpio (status LED)"
fi
# ---------------------------------------------------------------------------
# 4. Stable device name
# ---------------------------------------------------------------------------
@@ -217,49 +182,15 @@ udevadm control --reload-rules
udevadm trigger --subsystem-match=tty || true
sleep 1
if [[ "$SERIAL_DEVICE" == "/dev/rdm-reader" && ! -e /dev/rdm-reader ]]; then
if [[ -e /dev/rdm-reader ]]; then
echo " /dev/rdm-reader -> $(readlink -f /dev/rdm-reader)"
else
warn "/dev/rdm-reader does not exist yet."
warn "The USB adapter may not be plugged in, or it uses a different chip."
warn "The reader may not be plugged in, or it uses a different chip."
warn "Run '$INSTALL_DIR/.venv/bin/rdm-card-reader --list-ports --verbose-ports'"
warn "then edit /etc/udev/rules.d/99-rdm-reader.rules to match your adapter."
warn "Reader wired to the Pi's UART pins instead? That is the default device:"
warn "re-run without --device (or with --device /dev/serial0)."
fi
# Refuse to enable a service that can only exit 1. A unit that fails on every
# start burns its StartLimitBurst within seconds and then stays dead until
# someone runs 'systemctl reset-failed' by hand - a confusing failure to debug
# remotely, and one that silently returns "installed successfully".
if [[ ! -e "$SERIAL_DEVICE" ]]; then
warn "$SERIAL_DEVICE does not exist, so the service could never start."
warn "Nothing has been enabled. Fix the device, then re-run this installer."
exit 1
fi
# Prove the port can actually be opened, not merely that it exists. Existence
# alone is not enough: a node with no hardware behind it still fails to open.
if ! "$INSTALL_DIR/.venv/bin/python" - "$SERIAL_DEVICE" <<'PY'
import sys
import serial
try:
serial.Serial(sys.argv[1], 9600, timeout=1).close()
except Exception as exc:
print(" x {}: {}".format(sys.argv[1], exc))
raise SystemExit(1)
PY
then
warn "could not open $SERIAL_DEVICE at 9600 baud."
warn "For the on-board UART check that it is enabled:"
warn " sudo raspi-config # Interface Options -> Serial Port"
warn " 'login shell over serial?' -> No"
warn " 'serial port hardware?' -> Yes"
warn "Nothing has been enabled. Fix the above, then re-run this installer."
exit 1
fi
echo " $SERIAL_DEVICE opened at 9600 baud"
# ---------------------------------------------------------------------------
# 5. Service
# ---------------------------------------------------------------------------
@@ -273,12 +204,10 @@ for origin in "${ORIGINS[@]:-}"; do
[[ -n "$origin" ]] && ORIGIN_ARGS+=" --allow-origin $origin"
done
python3 - "$UNIT" "$INSTALL_DIR" "$BRIDGE_PORT" "$ORIGIN_ARGS" "$SERVICE_USER" \
"$SERIAL_DEVICE" "$LED_PIN" "$LED_MODE" "$HEARTBEAT" <<'PY'
python3 - "$UNIT" "$INSTALL_DIR" "$BRIDGE_PORT" "$ORIGIN_ARGS" "$SERVICE_USER" <<'PY'
import os, re, sys
(unit, install_dir, port, origin_args, user, device, led_pin, led_mode,
heartbeat) = sys.argv[1:10]
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
@@ -290,38 +219,11 @@ if not os.path.exists(wrapper):
command = (
f"{wrapper} --serve "
f"--port {device} --bridge-port {port} --mode login "
f"--led-pin {led_pin} --led-mode {led_mode} --heartbeat {heartbeat}"
f"{origin_args}"
f"--port /dev/rdm-reader --bridge-port {port} --mode login{origin_args}"
)
def device_unit(path):
"""systemd device unit for *path*, e.g. /dev/rdm-reader -> dev-rdm\\x2dreader.device."""
name = path[len("/dev/"):] if path.startswith("/dev/") else path
name = name.replace("-", "\\x2d").replace("/", "-")
return f"dev-{name}.device"
# Replace the whole (possibly line-continued) ExecStart definition.
text = re.sub(r"ExecStart=[\s\S]*?(?=\n\n|\nRestart=)", f"ExecStart={command}", text, count=1)
text = re.sub(r"^User=.*$", f"User={user}", text, count=1, flags=re.M)
# Order after the device we actually open. The shipped unit names a device
# that is not the one the service reads, so systemd never really waited for it.
text = re.sub(r"^After=.*$", f"After={device_unit(device)} network-online.target",
text, count=1, flags=re.M)
# DevicePolicy=closed means only the devices listed here are usable, so the
# list has to cover everything the service touches: the serial port, and the
# GPIO chip the status LED drives. Omitting the chip is a silent failure - the
# reader keeps working and only the LED stays dark - so it is easy to miss.
# gpiochip0 is the header on Pi 1-4; Pi 5 uses gpiochip4.
device_allow = "\n".join([
f"DeviceAllow={device} rw",
"DeviceAllow=/dev/gpiochip0 rw",
"DeviceAllow=/dev/gpiochip4 rw",
])
text = re.sub(r"^DeviceAllow=.*(?:\nDeviceAllow=.*)*", device_allow, text,
count=1, flags=re.M)
open(unit, "w").write(text)
PY
@@ -347,9 +249,6 @@ cat <<EOF
$(log "Installed")
Bridge : http://127.0.0.1:$BRIDGE_PORT
Device : $SERIAL_DEVICE
LED : GPIO $LED_PIN, $LED_MODE mode
Heartbeat : ${HEARTBEAT}s (presence/LED window)
Card state : curl http://127.0.0.1:$BRIDGE_PORT/api/card
Health : curl http://127.0.0.1:$BRIDGE_PORT/api/health
Logs : journalctl -u rdm-card-reader -f
+9 -20
View File
@@ -2,10 +2,8 @@
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. install.sh rewrites this line for the device given
# with --device, since only it knows whether the reader is on the on-board UART
# (/dev/serial0, the default) or a USB adapter (/dev/rdm-reader).
After=dev-serial0.device network-online.target
# 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]
@@ -19,29 +17,22 @@ User=rdmcard
Group=dialout
WorkingDirectory=/opt/rdm-card-reader
# --serve runs the HTTP bridge, which is what makes the reader available as an
# API on http://127.0.0.1:8765. --port points at the device the reader is
# actually wired to; install.sh rewrites this line from --device, and only it
# knows whether that is a USB adapter (/dev/rdm-reader) or the on-board UART
# (/dev/serial0). --led-pin is rewritten from --led-pin for the same reason.
# --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/serial0 \
--port /dev/rdm-reader \
--mode login \
--led-pin 23 \
--led-mode presence \
--heartbeat 2.0
--allow-origin https://your.web.app
Restart=always
RestartSec=3
# The bridge reads the serial port, drives the status LED and listens on
# loopback. DeviceAllow is rewritten by install.sh alongside --port: with
# DevicePolicy=closed a device missing from this list is denied outright, and
# omitting the GPIO chip leaves the LED dark while everything else works.
# The bridge only needs to read the serial port and listen on loopback.
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
@@ -53,9 +44,7 @@ RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
RestrictNamespaces=true
LockPersonality=true
MemoryDenyWriteExecute=true
DeviceAllow=/dev/serial0 rw
DeviceAllow=/dev/gpiochip0 rw
DeviceAllow=/dev/gpiochip4 rw
DeviceAllow=/dev/rdm-reader rw
DevicePolicy=closed
# stdout/stderr go here; watch with: journalctl -u rdm-card-reader -f
+6 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "rdm-card-reader"
version = "0.4.0"
version = "0.3.0"
description = "RDM6300 / EM4100 RFID card reader: prints card events, or serves them over HTTP so a web app can authenticate a card tap."
readme = "README.md"
requires-python = ">=3.8"
@@ -24,6 +24,7 @@ dependencies = [
]
[project.optional-dependencies]
dev = ["pytest>=7.0"]
# Status LED support. Installed automatically on first use by the app, or
# explicitly with: pip install '.[led]'
led = ["gpiozero>=2.0", "colorzero>=2.0"]
@@ -36,3 +37,7 @@ where = ["src"]
[tool.setuptools.package-data]
rdm_card_reader = ["py.typed", "wheels/*.whl", "wheels/SHA256SUMS"]
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-q"
+1 -7
View File
@@ -30,12 +30,7 @@ from .ports import (
discover,
resolve,
)
from .led import (
DEFAULT_LED_MODE,
DEFAULT_LED_PIN,
StatusLED,
create as create_led,
)
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"
@@ -46,7 +41,6 @@ __all__ = [
"CardData",
"CardEvent",
"AmbiguousPort",
"DEFAULT_LED_MODE",
"DEFAULT_LED_PIN",
"EventHub",
"PortError",
-45
View File
@@ -75,12 +75,6 @@ DEFAULT_HISTORY = 50
#: replaced.
DEFAULT_TAP_TTL = 10.0
#: How long a *claimed* tap stays readable afterwards. The page that claimed it
#: needs a moment to confirm which card logged in, but an id that stays
#: readable forever is indistinguishable from a tap still waiting to be spent -
#: so it is cleared once this window passes. Set to 0 to keep it indefinitely.
DEFAULT_CLAIMED_TTL = 30.0
class EventHub:
"""Tracks reader state and fans card events out to waiting subscribers.
@@ -102,7 +96,6 @@ class EventHub:
history: int = DEFAULT_HISTORY,
mode: str = MODE_LOGIN,
tap_ttl: float = DEFAULT_TAP_TTL,
claimed_ttl: float = DEFAULT_CLAIMED_TTL,
):
self._lock = threading.Lock()
self._subscribers: List["deque"] = []
@@ -114,7 +107,6 @@ class EventHub:
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)
self._claimed_ttl = float(claimed_ttl if claimed_ttl else 0.0)
# The latched tap. It deliberately outlives the card leaving the
# reader, so a page that loads after the tap can still claim it.
@@ -185,16 +177,8 @@ class EventHub:
``present`` is whether the card is physically on the reader right now;
``claimable`` is whether this tap can still be spent on a login.
A claimed tap is reported for ``claimed_ttl`` seconds after it was
spent, so the page that claimed it can confirm which card logged in. An
expired unclaimed tap is cleared too: keeping it would leave an id that
can never be claimed again, which is indistinguishable from a tap that
is still waiting to be used.
"""
with self._lock:
self._drop_dead_tap()
if self._tap is None:
return {
"present": False,
@@ -216,32 +200,6 @@ class EventHub:
"card": self._tap.to_dict(),
}
def _drop_dead_tap(self) -> None:
"""Clear the latched tap once it can no longer be acted on.
Called with the lock held. Two independent deadlines: an unclaimed tap
is dead after ``tap_ttl``, a claimed one after ``claimed_ttl``. A tap of
zero TTL means "keep until replaced", which the claimed case uses to
keep the id around indefinitely.
Crucially this drops only the *tap*, never the presence flag: whether a
card is on the reader is a physical fact learnt from insert/remove
events, and a tap ageing out says nothing about it. Clearing presence
here reported "no card" while a card was sitting on the antenna.
"""
if self._tap is None:
return
now = time.time()
if self._consumed_at is not None:
if self._claimed_ttl and now - self._consumed_at > self._claimed_ttl:
self._tap = None
self._tap_at = None
self._consumed_at = None
return
if self._tap_ttl and now - (self._tap_at or 0.0) > self._tap_ttl:
self._tap = None
self._tap_at = None
def consume_card(self):
"""Atomically claim the latched tap for one authentication.
@@ -249,7 +207,6 @@ class EventHub:
this call succeeds, so two concurrent requests cannot both win.
"""
with self._lock:
self._drop_dead_tap()
if self._tap is None:
return None, "no card read yet", 409
@@ -288,7 +245,6 @@ class EventHub:
def health(self) -> Dict[str, Any]:
with self._lock:
self._drop_dead_tap()
last_age = (
round(time.time() - self._last_at, 1) if self._last_at else None
)
@@ -304,7 +260,6 @@ class EventHub:
"card_present": self._on_reader,
"card_consumed": self._consumed_at is not None,
"tap_ttl_seconds": self._tap_ttl or None,
"claimed_ttl_seconds": self._claimed_ttl or None,
}
def last(self) -> Optional[Dict[str, Any]]:
+2 -13
View File
@@ -18,13 +18,10 @@ 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. ``/dev/serial0`` is the
#: canonical Pi alias for the on-board UART - it resolves to ttyAMA0 or ttyS0
#: depending on the model, and is the device the docs tell you to use.
#: default, the others cover USB / GPIO UART dongles.
CANDIDATE_PORTS = (
"/dev/ttyS0",
"/dev/ttyAMA0",
"/dev/serial0",
"/dev/ttyUSB0",
"/dev/ttyACM0",
)
@@ -35,15 +32,7 @@ CONFIG_CARD_VALUE = 12886709
#: Seconds without a read before a card is considered removed. Set to ``None``
#: to disable removal detection entirely.
#:
#: This is also the presence window: the card counts as "present" until this
#: long after the last frame. RDM6300 modules typically report a card once per
#: placement rather than re-reading continuously, so a short value makes
#: ``present`` flicker - and the status LED with it - while the card sits still
#: on the antenna. Longer holds presence (and the light) on longer but makes
#: lifting the card take longer to notice; raise it if a card held in place is
#: reported as removed, lower it if removal feels sluggish.
DEFAULT_HEARTBEAT_INTERVAL = 2.0
DEFAULT_HEARTBEAT_INTERVAL = 0.5
BAR = "-" * 58
+6 -17
View File
@@ -10,7 +10,6 @@ from typing import Optional, Sequence
from . import __version__
from .bootstrap import LED_PACKAGES, ensure_dependencies
from .bridge import (
DEFAULT_CLAIMED_TTL,
DEFAULT_HOST,
DEFAULT_PORT,
DEFAULT_TAP_TTL,
@@ -28,12 +27,7 @@ from .card import (
format_event,
)
from .demo import run_demo
from .led import (
DEFAULT_LED_MODE,
DEFAULT_LED_PIN,
MODES as LED_MODES,
create as create_led,
)
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
@@ -175,8 +169,7 @@ def _run_bridge(args) -> int:
device = _resolve_port(args)
hub = EventHub(mode=args.mode, tap_ttl=args.tap_ttl,
claimed_ttl=args.claimed_ttl)
hub = EventHub(mode=args.mode, tap_ttl=args.tap_ttl)
hub.set_port(device)
led = _build_led(args)
@@ -292,10 +285,6 @@ def build_parser() -> argparse.ArgumentParser:
bridge.add_argument("--tap-ttl", type=float, default=DEFAULT_TAP_TTL,
help="seconds an unclaimed tap stays claimable; 0 keeps it "
"claimable while the card is present (default: %(default)s)")
bridge.add_argument("--claimed-ttl", type=float, default=DEFAULT_CLAIMED_TTL,
help="seconds a claimed card stays readable so the claiming "
"page can confirm which card logged in; 0 keeps it "
"indefinitely (default: %(default)s)")
bridge.add_argument("--allow-origin", action="append", metavar="ORIGIN",
help="permit this browser origin to call the API; repeatable")
bridge.add_argument("--token",
@@ -312,10 +301,10 @@ def build_parser() -> argparse.ArgumentParser:
led.add_argument("--led-pin", type=int, default=DEFAULT_LED_PIN,
help="GPIO pin of the status LED (default: %(default)s, "
"matching the workstation app)")
led.add_argument("--led-mode", choices=list(LED_MODES), default=DEFAULT_LED_MODE,
help="'presence' (default): lit only while a card is on the "
"reader; 'error': lit whenever the reader is running, "
"blinking means a bad read")
led.add_argument("--led-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
-339
View File
@@ -1,339 +0,0 @@
"""Type a card id into whatever window has focus, like a keyboard-wedge reader.
A commercial USB RFID reader presents itself to the machine as a keyboard: the
card code appears in the focused window as if it had been typed. This does the
same in software, by creating a virtual keyboard through the kernel's
``/dev/uinput`` interface.
Two consequences of doing it that way, both deliberate:
* **No external tool and no extra dependency.** ``python-evdev`` would be the
usual choice, but it is a C extension, so its wheels are tied to one
interpreter and one CPU architecture. This project ships pure-Python wheels
precisely so the same files work on 32- and 64-bit ARM; pulling in a compiled
dependency would break that. ``ctypes`` and ``struct`` are enough.
* **It works from the reader's system service.** uinput registers the device
with the kernel, not with a login session, so the desktop picks it up exactly
as it would a real keyboard. No ``DISPLAY``, no session bus, no
``systemctl --user`` unit is involved - unlike X11-only tools such as
``xdotool``, which also fail on Wayland.
The known limit, which a real wedge reader shares: the text goes to whatever
holds focus, so a tap while a password prompt is focused types the card id
there. That is why the feature is **opt-in** (``--type``) rather than default.
Like the LED, this never blocks the reader thread, never crashes the process,
and never pretends to work when it cannot: whether a virtual keyboard was
actually created is reported at startup.
"""
from __future__ import annotations
import ctypes
import fcntl
import os
import queue
import threading
import time
from dataclasses import dataclass
from typing import Any, Callable, Dict, List, Optional
#: Where the kernel exposes the uinput control device.
UINPUT_PATH = "/dev/uinput"
#: Suppress repeated identical text, e.g. the same card held on the antenna.
DEFAULT_KEY_DELAY = 0.002
#: Output formats for the card id.
DECIMAL = "decimal"
HEX = "hex"
FORMATS = (DECIMAL, HEX)
#: What to send after the id, so the target field is submitted.
ENTER = "enter"
TAB = "tab"
NONE = "none"
SUFFIXES = (ENTER, TAB, NONE)
# -- kernel constants (linux/input-event-codes.h and linux/uinput.h) ---------
EV_SYN = 0x00
EV_KEY = 0x01
SYN_REPORT = 0x00
_KEY_PRESS = 1
_KEY_RELEASE = 0
_UI_DEV_CREATE = 0x5501
_UI_DEV_DESTROY = 0x5502
_UI_SET_EVBIT = 0x40045564
_UI_SET_KEYBIT = 0x40045565
_UINPUT_MAX_NAME_SIZE = 80
#: Character -> key code. Digits cover the default output; a-f cover ``--hex``
#: (lower case, since upper case would need a shift modifier for no real gain).
_KEYCODES: Dict[str, int] = {
"1": 2, "2": 3, "3": 4, "4": 5, "5": 6,
"6": 7, "7": 8, "8": 9, "9": 10, "0": 11,
"a": 30, "b": 48, "c": 46, "d": 32, "e": 18, "f": 33,
"x": 45,
}
_SPECIAL: Dict[str, int] = {
ENTER: 28,
TAB: 15,
}
class _InputEvent(ctypes.Structure):
"""``struct input_event``.
``tv_sec``/``tv_usec`` are ``c_long`` so the layout is right on both 32- and
64-bit kernels - the struct is 16 bytes on one and 24 on the other, and
hard-coding either would corrupt the event.
"""
_fields_ = [
("tv_sec", ctypes.c_long),
("tv_usec", ctypes.c_long),
("type", ctypes.c_uint16),
("code", ctypes.c_uint16),
("value", ctypes.c_int32),
]
class _InputId(ctypes.Structure):
_fields_ = [
("bustype", ctypes.c_uint16),
("vendor", ctypes.c_uint16),
("product", ctypes.c_uint16),
("version", ctypes.c_uint16),
]
class _UInputUserDev(ctypes.Structure):
"""``struct uinput_user_dev``, used to name and describe the device."""
_fields_ = [
("name", ctypes.c_char * _UINPUT_MAX_NAME_SIZE),
("id", _InputId),
("ff_effects_max", ctypes.c_uint32),
("absmax", ctypes.c_int32 * 64),
("absmin", ctypes.c_int32 * 64),
("absfuzz", ctypes.c_int32 * 64),
("absflat", ctypes.c_int32 * 64),
]
def _as_bytes(struct: ctypes.Structure) -> bytes:
return ctypes.string_at(ctypes.byref(struct), ctypes.sizeof(struct))
@dataclass
class WedgeStatus:
"""What the wedge subsystem is actually doing."""
enabled: bool
backend: str # "uinput" | "null"
format: str = DECIMAL
suffix: str = ENTER
reason: str = ""
def to_dict(self) -> dict:
return {
"enabled": self.enabled,
"backend": self.backend,
"format": self.format,
"suffix": self.suffix,
"reason": self.reason,
}
class NullWedge:
"""A wedge that does nothing, used when uinput is unavailable."""
backend = "null"
def __init__(self, reason: str = ""):
self.reason = reason
def type_text(self, text: str) -> None:
pass
def close(self) -> None:
pass
class UInputWedge:
"""A virtual keyboard created through ``/dev/uinput``."""
backend = "uinput"
def __init__(self, name: str = "rdm-card-reader", key_delay: float = DEFAULT_KEY_DELAY):
self.key_delay = key_delay
self._fd = os.open(UINPUT_PATH, os.O_WRONLY | os.O_NONBLOCK)
# Declare the only kinds of event this device will ever send.
fcntl.ioctl(self._fd, _UI_SET_EVBIT, EV_KEY)
for code in sorted(set(_KEYCODES.values()) | set(_SPECIAL.values())):
fcntl.ioctl(self._fd, _UI_SET_KEYBIT, code)
dev = _UInputUserDev()
dev.name = name.encode("utf-8")[:_UINPUT_MAX_NAME_SIZE - 1]
# BUS_USB keeps the device looking like an ordinary keyboard to udev.
dev.id = _InputId(bustype=0x03, vendor=0x1, product=0x1, version=1)
os.write(self._fd, _as_bytes(dev))
fcntl.ioctl(self._fd, _UI_DEV_CREATE)
# udev needs a moment to notice a brand-new input device; typing before
# it does means the first characters go nowhere.
time.sleep(0.2)
def _send(self, event_type: int, code: int, value: int) -> None:
os.write(self._fd, _as_bytes(_InputEvent(0, 0, event_type, code, value)))
def _tap(self, code: int) -> None:
self._send(EV_KEY, code, _KEY_PRESS)
self._send(EV_SYN, SYN_REPORT, 0)
self._send(EV_KEY, code, _KEY_RELEASE)
self._send(EV_SYN, SYN_REPORT, 0)
if self.key_delay:
time.sleep(self.key_delay)
def type_text(self, text: str) -> None:
for character in text:
code = _KEYCODES.get(character.lower())
if code is not None:
self._tap(code)
def press(self, name: str) -> None:
code = _SPECIAL.get(name)
if code is not None:
self._tap(code)
def close(self) -> None:
try:
fcntl.ioctl(self._fd, _UI_DEV_DESTROY)
except Exception:
pass
try:
os.close(self._fd)
except Exception:
pass
def format_card(event: Any, fmt: str = DECIMAL) -> str:
"""Render a card event as the text a wedge reader would have typed."""
if fmt == HEX:
return event.hex_value
return str(event.value)
class KeyboardWedge:
"""Non-blocking wedge controller driven by card events.
Text is queued and typed by a worker thread, so a card read never waits on
the desktop - the same arrangement the LED uses, and for the same reason.
"""
def __init__(
self,
enabled: bool = True,
fmt: str = DECIMAL,
suffix: str = ENTER,
key_delay: float = DEFAULT_KEY_DELAY,
logger: Optional[Callable[[str], None]] = None,
):
self._log = logger or (lambda message: None)
self._fmt = fmt if fmt in FORMATS else DECIMAL
self._suffix = suffix if suffix in SUFFIXES else ENTER
self._queue: "queue.Queue[Optional[str]]" = queue.Queue()
self._thread: Optional[threading.Thread] = None
if not enabled:
self.wedge: Any = NullWedge("typing disabled (--no-type)")
self.status = WedgeStatus(False, "null", self._fmt, self._suffix,
"disabled")
return
try:
self.wedge = UInputWedge(key_delay=key_delay)
self.status = WedgeStatus(True, "uinput", self._fmt, self._suffix,
"ready")
self._log("Keyboard wedge ready (uinput, {} text, {} suffix)".format(
self._fmt, self._suffix))
except PermissionError as exc:
reason = (
"cannot write {}: {} - add the user to the 'input' group "
"and re-run the installer".format(UINPUT_PATH, exc)
)
self.wedge = NullWedge(reason)
self.status = WedgeStatus(False, "null", self._fmt, self._suffix,
reason)
self._log("Keyboard wedge unavailable: {}".format(reason))
except Exception as exc:
reason = "no usable uinput: {}".format(exc)
self.wedge = NullWedge(reason)
self.status = WedgeStatus(False, "null", self._fmt, self._suffix,
reason)
self._log("Keyboard wedge unavailable: {}".format(reason))
self._thread = threading.Thread(target=self._worker, daemon=True,
name="wedge-worker")
self._thread.start()
# -- event hooks ------------------------------------------------------
def insert(self, event: Any) -> None:
"""A card was read: queue its id, then the configured suffix."""
self._send(format_card(event, self._fmt))
@property
def suffix(self) -> str:
return self._suffix
# -- control ----------------------------------------------------------
def _send(self, text: str) -> None:
if not self.status.enabled:
return
try:
self._queue.put_nowait(text)
except Exception:
pass
def _worker(self) -> None:
while True:
text = self._queue.get()
if text is None:
return
try:
self.wedge.type_text(text)
if self._suffix != NONE:
self.wedge.press(self._suffix)
except Exception as exc:
# A failed keystroke must never take the reader down.
self._log("Keyboard wedge failed: {}".format(exc))
finally:
self._queue.task_done()
def close(self) -> None:
if self._thread is not None:
try:
self._queue.put_nowait(None)
except Exception:
pass
self.wedge.close()
def create(
enabled: bool = True,
fmt: str = DECIMAL,
suffix: str = ENTER,
key_delay: float = DEFAULT_KEY_DELAY,
logger: Optional[Callable[[str], None]] = None,
) -> KeyboardWedge:
"""Build the wedge controller, falling back to a no-op when uinput fails."""
return KeyboardWedge(enabled=enabled, fmt=fmt, suffix=suffix,
key_delay=key_delay, logger=logger)
+9 -13
View File
@@ -34,20 +34,15 @@ OFF = "off"
ON = "on"
BLINK = "blink"
#: Lit only while a card is on the reader, and off otherwise. This is a card
#: indicator, for when the light should mean "a card is here". The default: a
#: light that tracks the card is unambiguous, whereas a light that is always on
#: says nothing about the card.
PRESENCE = "presence"
#: Lit the whole time the app runs, and blinks on a bad read. This is a health
#: indicator: light on means the reader is alive and ready for a card.
ERROR = "error"
MODES = (PRESENCE, ERROR)
#: Lit only while a card is on the reader, and off otherwise. This is a card
#: indicator, for when the light should mean "a card is here".
PRESENCE = "presence"
#: What the light means unless told otherwise.
DEFAULT_LED_MODE = PRESENCE
MODES = (ERROR, PRESENCE)
#: Kept for the occasional caller that only cares about the concrete commands.
_VALID_MODES = (OFF, ON, BLINK)
@@ -64,7 +59,8 @@ class LEDStatus:
state: bool = False
reason: str = ""
def to_dict(self) -> dict: return {
def to_dict(self) -> dict:
return {
"enabled": self.enabled,
"pin": self.pin,
"backend": self.backend,
@@ -162,13 +158,13 @@ class StatusLED:
self,
pin: int = DEFAULT_LED_PIN,
enabled: bool = True,
mode: str = DEFAULT_LED_MODE,
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 DEFAULT_LED_MODE
self._mode = mode if mode in MODES else ERROR
self._error_blink = error_blink
self._enabled = enabled
self._blinker: Optional[_Blinker] = None
@@ -309,7 +305,7 @@ class StatusLED:
def create(
enabled: bool = True,
pin: int = DEFAULT_LED_PIN,
mode: str = DEFAULT_LED_MODE,
mode: str = ERROR,
error_blink: bool = True,
logger: Optional[Callable[[str], None]] = None,
) -> StatusLED: