2ded37e190
install.sh ran 'pip install .', which builds the project: pip downloads
setuptools into an isolated build environment. With no internet it fails at
the 'Building the virtual environment' step with
Could not find a version that satisfies the requirement setuptools>=68
(from versions: none)
even though the runtime libraries are already in the wheel directory. The
build backend is only needed to package, never to run.
- Install the runtime deps straight from the bundled wheels with --no-index,
and put src/ on the path via a .pth file, so no build step happens at all.
- Generate the console script with the venv's interpreter as an absolute
shebang. '#\!/usr/bin/env python3' resolves to the *system* python when the
venv is not activated, so the package was not importable from the unit.
- apt-get update/install and 'pip install --upgrade pip' are now best-effort:
all three need the network and none should abort the install.
- Detect a failed 'python3 -m venv' and name python3-venv as the usual cause.
- Sanity-check the install with --version before enabling the service.
- The unit falls back to 'python -m rdm_card_reader' if the console script is
ever absent (no shell in ExecStart, so the installer picks the path).
Reproduced the failure offline, then verified the fix offline as well: a venv
created on a copy of the tree self-provisions from the bundled wheels and runs
from cwd=/ with no PYTHONPATH and no venv activation.
Added tests/test_deploy.py (20 tests) covering the installer and the deploy
artifacts, including a regression test for 'pip install .' that was confirmed
to fail when the bug is reintroduced.
469 lines
17 KiB
Markdown
469 lines
17 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.
|
||
|
||
---
|
||
|
||
## 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
|