031cf0f25a
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.
532 lines
19 KiB
Markdown
532 lines
19 KiB
Markdown
# 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 |
|
||
|
||
---
|
||
|
||
## Development
|
||
|
||
```bash
|
||
pip install -e ".[dev]"
|
||
pytest
|
||
```
|
||
|
||
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
|
||
|
||
```
|
||
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
|
||
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
|
||
|
||
```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
|