Files
rdm-card-reader/README.md
T
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

377 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.
---
## 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.
---
## Install
```bash
pip install .
```
On a Raspberry Pi OS install with an externally-managed Python, add
`--break-system-packages` or use a virtual environment:
```bash
python3 -m venv .venv && . .venv/bin/activate && pip install .
```
`rdm6300` and `pyserial` are pulled in automatically.
---
## 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` |
| `--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"}
```
### Exit codes
| Code | Meaning |
| --- | --- |
| `0` | Ran normally, or stopped with Ctrl+C |
| `1` | No serial port could be opened |
| `2` | The `rdm6300` library is not installed |
---
## 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.
---
## 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