# 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 `: 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. > **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