Files
rdm-card-reader/README.md
T
ske087 84569ec9ae Install the GPIO pin backend, and add the Word guide
install.sh created the virtualenv with --system-site-packages so it could
see the pin backend, but never installed one. On a Pi OS image whose
python3-gpiozero does not pull it in, gpiozero then raised BadPinFactory,
the app fell back to a no-op LED, and the reader ran with the light dark
and nothing failing - the failure that is easiest to miss. Install
python3-lgpio (Bookworm and later) or python3-rpi.gpio (older releases)
when neither backend is importable, best-effort like the packages above.

Adds the installation and API guide (DOCX, 36 pages, 4 figures, 25
tables) covering enabling the serial port, wiring, mounting, installing on
Raspberry Pi OS and the complete HTTP API, plus the header diagrams it
references.

.gitignore now excludes the release build outputs: they are generated from
the source tree, and committing them would duplicate the code.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-06 16:39:37 +03:00

1212 lines
48 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. Two
ways to use it:
* **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.
> 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.
---
## 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)
- [Quick start on a Raspberry Pi](#quick-start-on-a-raspberry-pi)
- [Status LED](#status-led)
- [Wiring](#wiring)
- [The protocol](#the-protocol)
- [Troubleshooting](#troubleshooting)
- [Check it works](#check-it-works)
---
## 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/serial0 # the Pi's on-board UART
rdm-card-reader --port /dev/ttyUSB0 # a USB adapter
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` | `2.0` | Seconds without a read before the card counts as removed. Also the presence/LED window |
| `--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`:
| 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:
```
/dev/ttyS0 -> /dev/ttyAMA0 -> /dev/serial0 -> /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/serial0 --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: {present: true, claimable: true, card: {id: ...}}
K->>B: POST /api/card/claim
B-->>K: {card: {id: ...}, consumed: true} (once, then 409)
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.
`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.
### Status page
Serving also puts a **status page** at `GET /` on the same port, so you can see
what the reader is doing from a browser:
```
http://127.0.0.1:8765/
```
It shows the reader's mode, port, uptime, whether a card is present and
claimable, the current card, the event counters, and a live event log. The log
is seeded with recent history from `/api/taps` and then updated live over the
`/api/events` SSE stream, so it keeps filling in without a refresh.
The page is **read-only**: it deliberately has no "claim" button, because
`POST /api/card/claim` spends the tap, and a page that claimed would silently
consume a real login just for being open. Claim from your own app instead.
Disable it with `--no-page` if you want the port to serve the API only.
The page includes an **LED panel** with buttons to switch either LED on or off
by hand, for the case where the light does not come on and you need to know
whether the software can drive the pin at all. A forced state is held until you
press *hand back to card events*, so card events do not immediately undo it.
The same is reachable without a browser:
```bash
curl -sS http://127.0.0.1:8765/api/led # read the state
curl -sS -X POST -H 'Content-Type: application/json' \
-d '{"led":"status","on":true}' http://127.0.0.1:8765/api/led
curl -sS -X POST -H 'Content-Type: application/json' \
-d '{"release":true}' http://127.0.0.1:8765/api/led
```
### Endpoints
| Method | Path | Purpose |
| --- | --- | --- |
| `GET` | `/` | Status page (unless `--no-page`) |
| `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, both TTLs — for monitoring |
| `POST` | `/api/mode` | `{"mode": "login"}` or `{"mode": "monitor"}` |
| `GET` | `/api/led` | Both LEDs: pin, polarity, on/off, whether held manually |
| `POST` | `/api/led` | `{"led": "status"\|"error", "on": true\|false}`, or `{"release": true}` |
`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
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 }),
});
}
}
```
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
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 | 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. |
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.
---
## Quick start on a Raspberry Pi
End-to-end: enable the serial port, confirm the reader is visible, install the
service, verify it runs. Steps 1–2 change nothing on disk and are safe to run
first.
### 1. Enable the serial port
`/dev/serial0` is the Pi's on-board UART, and it does not exist until the UART
is switched on:
```bash
sudo raspi-config
# Interface Options -> Serial Port
# "Would you like a login shell to be accessible over serial?" -> No
# "Would you like the serial port hardware to be enabled?" -> Yes
sudo reboot
```
Menu numbering shifts between `raspi-config` releases, so navigate by name:
**Interface Options**, then **Serial Port**.
**Both answers matter.** Answering *Yes* to the login shell leaves a console on
the port, so the reader gets nothing: the app either reports `device not found`
or opens the port and never sees a card.
### 2. Confirm the device appears
```bash
ls -l /dev/serial0 /dev/rdm-reader 2>&1
```
| What you see | Wiring | Device to pass |
| --- | --- | --- |
| `/dev/serial0 -> ttyAMA0` (or `ttyS0`) | UART, header pins **8 / 10** | `--device /dev/serial0` |
| `/dev/rdm-reader` | USB-to-serial adapter | `--device /dev/rdm-reader` |
| `No such file or directory` for both | nothing openable yet | see below |
`/dev/rdm-reader` is created by the udev rule and matches a **USB** bridge chip,
so it can never exist on a UART setup. The two are mutually exclusive — a
service pointing at the wrong one exits `1` on every start.
If neither exists, ask what the system does see:
```bash
ls /dev/ttyUSB* /dev/ttyACM* /dev/ttyS* 2>/dev/null
dmesg | grep -iE "ttyUSB|ttyAMA|ch34|ftdi|cp210|pl2303" | tail
```
No `ttyUSB`/`ttyACM` line in `dmesg` means the adapter is not enumerating at
all — a cable, power or hardware problem that no `--device` value can fix.
If a `/dev/ttyUSB0` appears but `/dev/rdm-reader` does not, the adapter's chip
is not the CH340 the rule matches by default. See **Identifying the right
ttyUSB** below.
### 3. Install the service
The defaults already match the common wiring — `/dev/serial0` for the reader on
the UART, GPIO 23 for the LED:
```bash
sudo ./deploy/install.sh --origin https://your.web.app
```
`deploy/install.sh` is the only installer — there is no `deploy.sh`. It sets up
a service account, a virtualenv, a udev rule and a systemd unit, then enables
the service.
Tell it which device you have, since the two are not interchangeable:
```bash
sudo ./deploy/install.sh --device /dev/serial0 # UART, pins 8 / 10
sudo ./deploy/install.sh --device /dev/rdm-reader # USB adapter
```
It refuses to enable the service unless the device exists **and** opens at
9600 baud, so a wrong port fails here instead of becoming a crash-looping unit
that burns its restart limit within seconds and then stays dead. Success looks
like:
```
/dev/serial0 opened at 9600 baud
```
If it stops instead, the message is one of exactly two:
| Message | Cause |
| --- | --- |
| `<device> does not exist, so the service could never start` | step 1 or 2 not done, or the wrong of the two devices |
| `could not open <device> at 9600 baud` | device exists but is not usable — UART held by the serial console, or the user is not in `dialout` |
Nothing is enabled in either case, and re-running is safe: the account, udev
rule and virtualenv are all idempotent.
### 4. Verify it runs
```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
```
A healthy startup logs one line:
```
OK reader on /dev/serial0 ; bridge on http://127.0.0.1:8765 (mode: login)
```
Then tap a card and check — or just open **<http://127.0.0.1:8765/>** for the
status page, which shows the reader state and a live event log:
```bash
curl -sS http://127.0.0.1:8765/api/card | python3 -m json.tool
```
### 5. If cards are not detected
Run the reader in the foreground, using the venv the installer built, to see
the failure directly instead of through the journal:
```bash
sudo /opt/rdm-card-reader/.venv/bin/rdm-card-reader --port /dev/serial0
```
`--list-ports --verbose-ports` answers "is the port even there?" — it prints
candidates with `OK`/`---` (exists?) and then every identified port with its
`by-id` path, USB id and serial number:
```bash
sudo /opt/rdm-card-reader/.venv/bin/rdm-card-reader --list-ports --verbose-ports
```
A service that cannot see cards while the foreground run works usually means
the unit is pointed at the other device — compare its `ExecStart` (below) with
what `--list-ports` reports.
### Installer options
| Option | Default | Meaning |
| --- | --- | --- |
| `--origin URL` | – | Web app origin allowed to call the API (repeatable) |
| `--host ADDR` | `127.0.0.1` | Bind address. Loopback only by default |
| `--allow-remote` | off | Permit a non-loopback `--host` |
| `--token SECRET` | – | Require this token from non-loopback callers (see below) |
| `--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 |
| `--led-active-low` | off | LED is wired active-low (cathode to the pin, anode to 3.3 V); inverts the signal |
| `--error-led-pin N` | `24` | BCM GPIO of a second LED, lit briefly on a bad read |
| `--no-error-led` | off | No error LED; blink the status LED instead |
| `--error-pulse S` | `1.0` | Seconds to hold the error LED on a bad read |
| `--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 |
> **`--no-service` does not skip the device checks.** They run before it, so an
> install with no reader attached still exits `1`.
> **`--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, leave the default and ignore
> the light, or add `--no-led` to the unit to stop the app trying.
### What the unit runs
```
/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.
### Reaching the page from another machine
The bridge binds **loopback only** by default, so `http://192.168.x.x:8765/`
from your laptop gets a *connection refused* — nothing is listening on that
address. Opening the port to the network is opt-in, and needs a token:
```bash
sudo ./deploy/install.sh --device /dev/serial0 \
--host 0.0.0.0 --allow-remote --token 'pick-a-long-secret'
```
The installer refuses a non-loopback `--host` without `--allow-remote`, and
warns if you skip the token. The app enforces the same rule, so this cannot be
switched on by accident.
> **What you are exposing.** Off loopback, anyone who can reach the port can
> read card taps *and* switch the status LEDs. A tap is not a password, but it
> is a username, and the endpoint reports who has touched the reader. Keep it on
> a trusted network, and set a token.
Loopback callers (the Pi itself) never need the token, so local tools and
`curl` keep working unchanged.
The token is written to `/opt/rdm-card-reader/bridge.env` (mode `0600`) and
referenced by the unit as `EnvironmentFile=`, rather than being placed on the
`ExecStart` line — a value there is readable by anything that can run
`systemctl cat` and appears in the process list.
Once exposed, open the page and paste the token into the box at the top right;
it is kept in `localStorage` and sent as `X-Auth-Token`. Until a token is
supplied the panels stay empty and the LED buttons do nothing, because the API
answers `401`. For the LED buttons specifically:
```bash
# from your laptop, if you invoked install.sh with --host 0.0.0.0 --token SECRET
curl -sS -H "X-Auth-Token: SECRET" http://192.168.0.228:8765/api/led
```
If you would rather not expose the port at all, an SSH tunnel gives you the
same page without opening anything:
```bash
ssh -N -L 8765:127.0.0.1:8765 pi@192.168.0.228
# then browse http://127.0.0.1:8765/ on your laptop
```
### Checking the reader from the command line
```bash
# Everything at once, in a browser: the status page
xdg-open http://127.0.0.1:8765/ # or open it on another machine
# 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
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 (USB adapters)
--port /dev/serial0 # on-board UART
--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`.
Exit codes: `0` ok, `1` no port, `2` library missing, `3` port ambiguous.
---
## Status LED
Two LEDs, each saying one thing. Both optional: everything works without
either.
| LED | Default pin | Means |
| --- | --- | --- |
| **Status** | GPIO 23 (physical pin **16**) | Lit while a card is on the reader (`presence` mode) |
| **Error** | GPIO 24 (physical pin **18**) | Lit briefly when a read fails (checksum mismatch) |
The status pin matches the workstation app. The error LED exists because one
light cannot say two things at once: in the default `presence` mode the status
LED is already carrying "a card is here", so **without a separate error LED a
bad read is reported not at all**. A dedicated red LED on its own pin is the
clearer arrangement.
```bash
rdm-card-reader # status LED tracks the card; error LED pulses on a bad read
rdm-card-reader --led-mode error # status LED lit whenever running, blinks on a bad read
rdm-card-reader --error-led-pin 25 # error LED on a different pin
rdm-card-reader --no-error-led # no error LED; blink the status LED instead
rdm-card-reader --no-led # no LEDs at all
```
| Flag | Default | Meaning |
| --- | --- | --- |
| `--no-led` | off | Do not drive any LED |
| `--led-pin` | `23` | **BCM** GPIO number of the status LED — not the physical header pin |
| `--led-mode` | `presence` | See the two readings below |
| `--led-active-low` | off | Status LED wired active-low (cathode to the pin) |
| `--error-led-pin` | `24` | BCM GPIO of the error LED |
| `--error-led-active-low` | off | Error LED wired active-low |
| `--error-pulse` | `1.0` | Seconds to hold the error LED on a bad read |
| `--no-error-led` | off | No error LED; blink the status LED instead |
| `--led-no-error-blink` | off | With `--no-error-led`, 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.
Giving the error LED the same pin as the status LED is treated as "no error
LED": two lights on one pin cannot be driven independently, so the app says so
and falls back to the status blink rather than failing.
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.
Wiring: LED anode to the GPIO pin through a ~330 Ω resistor, cathode to GND.
For the opposite arrangement — cathode to the pin, anode to 3.3 V, or a
transistor/MOSFET driver that inverts — pass **`--led-active-low`** (or
`--error-led-active-low` for the error LED), or the light will do the opposite
of what is wanted.
### 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 the state of each LED (the mode shown is `presence` by
default):
```
LED on GPIO 23 (presence mode, active-high, gpiozero) # status LED working
Error LED on GPIO 24 (lit for 1.0s on a bad read, ...) # error LED 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.
### 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, active-high, gpiozero)` | LED is initialised — suspect wiring, pin number, or simply no card read yet | See the wiring note below |
| No error indication on a bad read | The error LED pin is wrong or the LED is unwired, and the status LED only blinks in `error` mode | Check `Error LED on GPIO …` in the journal; see below |
**Check 0 — is the light doing the opposite?** A dark LED and an *inverted* LED
are different faults. If the light is **on with no card and goes dark when a
card is placed**, the pin number is right and the polarity is wrong: the LED is
wired active-low. Pass `--led-active-low` (installer, or the app directly), and
confirm with the journal, which now names the polarity:
```
LED on GPIO 23 (presence mode, active-low, gpiozero)
```
**Check 0b — can the software drive the pin at all?** Before suspecting the
wiring, prove the pin works. Open the status page and use the **LED panel** to
switch each light on and off by hand, which takes card events out of the
picture entirely:
```bash
curl -sS -X POST -H 'Content-Type: application/json' \
-d '{"led":"status","on":true}' http://127.0.0.1:8765/api/led
```
- **The light comes on** → the pin, backend and permissions are all fine, so
the problem is card events, not hardware. Check the journal for
`CARD INSERTED` when you tap.
- **Nothing happens, HTTP 200** → the pin is being driven but no light is
connected to it. Wiring, or the wrong pin.
- **HTTP 409 `that LED is not available`** → the LED has no usable pin; look at
`reason` in the response, which is the same text as the journal line.
**HTTP 401 `missing or invalid token`** → you are reaching the bridge from
another machine and it was installed with a token. Paste the token into the box
at the top of the status page, or send `X-Auth-Token`. See **Reaching the page
from another machine** above.
Press *hand back to card events* (or POST `{"release": true}`) when finished, or
the LEDs stay held where you left them.
To isolate polarity from everything else, run the service in `error` mode,
where the light is lit the whole time the app runs:
```bash
sudo ./deploy/install.sh --device /dev/serial0 --led-mode error
```
Lit continuously means the hardware path is good. Dark means wiring, pin
number or backend. Lit-while-idle *and* dark-when-a-card-arrives in `presence`
mode means polarity.
**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` and installs a backend. 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 either line is wrong, let the installer set both up at once:
```bash
sudo ./deploy/install.sh --device /dev/serial0
```
It installs `python3-lgpio` (Bookworm and later) or `python3-rpi.gpio` (older
releases) when neither backend is importable, and recreates the venv with the
flag. To do it yourself instead:
```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.
`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**.
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 each LED: **anode** → GPIO pin through a ~330 Ω resistor,
**cathode** → GND. Status LED on GPIO 23 (physical pin 16) and error LED on
GPIO 24 (physical pin 18) by default. See the pin numbering table below, and
note both LEDs are optional — the reader works without them.
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`). 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
```
### 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` is the **BCM GPIO number** — what the app uses and what GPIO
libraries use — **not** the physical header pin number. This is the single most
common way to get a dark LED with no error: the app reports
`LED on GPIO <n>` and looks healthy, while the wire is on a different pin.
Look up the pin your wire is actually on, and pass the BCM number:
| Physical pin | `--led-pin` (BCM) | | Physical pin | `--led-pin` (BCM) |
| --- | --- | --- | --- | --- |
| 7 | `4` | | 24 | `8` |
| 11 | `17` | | 26 | `7` |
| 12 | `18` | | 29 | `5` |
| 13 | `27` | | 31 | `6` |
| 15 | `22` | | 32 | `12` |
| **16** | **`23`** (status LED default) | | 33 | `13` |
| **18** | **`24`** (error LED default) | | 35 | `19` |
| 19 | `10` | | 36 | `16` |
| 21 | `9` | | 37 | `26` |
| 22 | `25` | | 38 | `20` |
| **23** | **`11`** | | 40 | `21` |
So an LED wired to **physical pin 23** needs `--led-pin 11`, and the default
`--led-pin 23` requires the wire on **physical pin 16**. The error LED defaults
to GPIO 24, which is **physical pin 18**.
```bash
sudo ./deploy/install.sh --device /dev/serial0 --led-pin 11 # status wire on physical pin 23
sudo ./deploy/install.sh --device /dev/serial0 --error-led-pin 25 # error wire on physical pin 22
```
Do **not** use physical pin 8 or 10 (GPIO 14 / 15 — the reader's own UART),
physical pin 27 or 28 (ID_SD / ID_SC, reserved), or pins 1 / 17 (3.3 V) and
2 / 4 (5 V), which are power rather than GPIO.
If you would rather find the pin by experiment, stop the service first — two
processes driving one pin fight each other — then blink each candidate and note
which one lights:
```bash
sudo systemctl stop rdm-card-reader
sudo /opt/rdm-card-reader/.venv/bin/python - <<'PY'
from gpiozero import LED
import time
# (physical pin, BCM GPIO)
for physical, bcm in [(23, 11), (16, 23), (12, 18), (11, 17), (22, 25)]:
print(f"physical pin {physical} (GPIO {bcm})", flush=True)
led = LED(bcm)
led.on(); time.sleep(1.0); led.off(); led.close()
time.sleep(0.5)
PY
sudo systemctl start rdm-card-reader
```
---
## 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 |
| 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:
```bash
rdm-card-reader --demo # simulated reads, no hardware needed
rdm-card-reader --list-ports # candidate and identified ports
```
To drive the real reader path with no hardware, feed RDM6300 frames into a
pseudo-terminal — see the snippet in **[LAUNCH.md](LAUNCH.md)**.
### 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
ports.py stable serial port identification
bridge.py the HTTP bridge behind --serve
webui.py the status page served at GET / by the bridge
led.py status LED
bootstrap.py offline dependency install
wheels/ the bundled wheels (offline install source)
```
### 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