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.
This commit is contained in:
@@ -0,0 +1,376 @@
|
||||
# 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
|
||||
Reference in New Issue
Block a user