04f0ab9efa
--led-pin is the BCM GPIO number, not the physical header pin, and getting it wrong produces a dark LED with no error at all: the app still reports "LED on GPIO 23" and looks healthy. The three-row table did not cover most of the header, so the fix for a wire on any other pin was left as guesswork. Lists all 22 usable pins both ways round, gives the concrete "physical pin 23 needs --led-pin 11" case, names the pins that must not be used (the reader's own UART on 8/10, reserved ID_SD/ID_SC on 27/28, and the power pins), and adds a blink-each-candidate script that finds the pin by experiment. Verified the table against the standard 40-pin pinout. Also drops a duplicated sentence about /dev/serial0 in Wiring. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1062 lines
41 KiB
Markdown
1062 lines
41 KiB
Markdown
# 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.
|
||
|
||
### 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"}` |
|
||
|
||
`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) |
|
||
| `--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 |
|
||
| `--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.
|
||
|
||
### 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
|
||
|
||
An LED driven by card events, on **GPIO 23** (physical pin **16**) by default —
|
||
the same pin the workstation app used. Optional: everything works without it.
|
||
|
||
By default the light **tracks the card**: lit while a card is on the reader,
|
||
dark otherwise.
|
||
|
||
```bash
|
||
rdm-card-reader # lit only while a card is on the reader
|
||
rdm-card-reader --led-mode error # lit whenever running, blinks on a bad read
|
||
rdm-card-reader --led-pin 17 # a different pin
|
||
rdm-card-reader --no-led # no LED at all
|
||
```
|
||
|
||
| Flag | Default | Meaning |
|
||
| --- | --- | --- |
|
||
| `--no-led` | off | Do not drive a LED |
|
||
| `--led-pin` | `23` | **BCM** GPIO number — not the physical header pin |
|
||
| `--led-mode` | `presence` | See the two readings below |
|
||
| `--led-no-error-blink` | off | 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.
|
||
|
||
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.
|
||
|
||
### 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 which of the three states it is in (the mode shown is
|
||
`presence` by default):
|
||
|
||
```
|
||
LED on GPIO 23 (presence mode, gpiozero) # 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, gpiozero)` | LED is initialised — suspect wiring, or simply no card read yet | See the wiring note below |
|
||
|
||
**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`; 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 the first line is missing or the import fails, install the backend and
|
||
rebuild the venv:
|
||
|
||
```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 the status LED: **anode** → GPIO pin through a ~330 Ω resistor,
|
||
**cathode** → GND. See the pin numbering table below, and note the LED is
|
||
optional — the reader works without it.
|
||
|
||
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`** (the default) | | 33 | `13` |
|
||
| 18 | `24` | | 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**.
|
||
|
||
```bash
|
||
sudo ./deploy/install.sh --device /dev/serial0 --led-pin 11 # wire on physical pin 23
|
||
```
|
||
|
||
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
|