Add keyboard-wedge typing and LED event support

- keyboard.py: emulate a commercial USB RFID reader by typing card ids
  into the focused window via a /dev/uinput virtual keyboard
- led.py / bridge.py: drive the status LED from card events
- cli.py, card.py: wire up the new options and event flow
- deploy: harden install.sh and the systemd unit
- README: document keyboard mode, LED support, and deployment

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-10-05 21:14:58 +03:00
parent 48e800adc1
commit c0a354b98e
9 changed files with 973 additions and 67 deletions
+409 -31
View File
@@ -1,9 +1,13 @@
# rdm-card-reader # rdm-card-reader
Read 125 kHz RFID cards (RDM6300 reader, EM4100 cards) on a serial port and Read 125 kHz RFID cards (RDM6300 reader, EM4100 cards) on a serial port. Two
print what happens: **card inserted** with the card data, then **card removed**. ways to use it:
Built for a Raspberry Pi workstation, but it runs anywhere with a serial port. * **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)** > 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 > — it explains the startup sequence, why it exits `1` with no hardware, and how
@@ -11,6 +15,21 @@ Built for a Raspberry Pi workstation, but it runs anywhere with a serial port.
--- ---
## 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)
- [Running at boot (Raspberry Pi)](#running-at-boot-raspberry-pi)
- [Status LED](#status-led)
- [Wiring](#wiring)
- [The protocol](#the-protocol)
- [Troubleshooting](#troubleshooting)
- [Check it works](#check-it-works)
---
## What it does ## What it does
``` ```
@@ -99,7 +118,8 @@ wheels on first start.
```bash ```bash
rdm-card-reader # auto-detect the serial port rdm-card-reader # auto-detect the serial port
rdm-card-reader --port /dev/ttyUSB0 # use a specific 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 --json # one JSON object per event
rdm-card-reader --heartbeat 1.5 # slower card-removed detection rdm-card-reader --heartbeat 1.5 # slower card-removed detection
rdm-card-reader --list-ports # show which ports exist, then exit rdm-card-reader --list-ports # show which ports exist, then exit
@@ -113,7 +133,7 @@ Also works as a module: `python -m rdm_card_reader`.
| --- | --- | --- | | --- | --- | --- |
| `--port` | auto-detect | Serial port: device path, by-id symlink, serial number or `vid:pid` | | `--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 | | `--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 | | `--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 | | `--json` | off | Machine-readable output instead of the text block |
| `--list-ports` | – | List candidate and identified ports, then exit | | `--list-ports` | – | List candidate and identified ports, then exit |
| `--verbose-ports` | off | With `--list-ports`, show by-id / by-path detail | | `--verbose-ports` | off | With `--list-ports`, show by-id / by-path detail |
@@ -123,15 +143,24 @@ Also works as a module: `python -m rdm_card_reader`.
| `--wheel-dir` | bundled | Install from local wheels in this DIR instead of the bundled ones | | `--wheel-dir` | bundled | Install from local wheels in this DIR instead of the bundled ones |
| `--version` | – | Print the version | | `--version` | – | Print the version |
With `--serve`: `--host` (`127.0.0.1`), `--bridge-port` (`8765`), `--mode` With `--serve`:
(`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 | Option | Default | Meaning |
workstation app uses: | --- | --- | --- |
| `--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/ttyUSB0 -> /dev/ttyACM0 /dev/ttyS0 -> /dev/ttyAMA0 -> /dev/serial0 -> /dev/ttyUSB0 -> /dev/ttyACM0
``` ```
### Output modes ### Output modes
@@ -204,7 +233,7 @@ 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. on the device, and the page asks it for the card that was just tapped.
```bash ```bash
rdm-card-reader --serve --port /dev/rdm-reader --allow-origin https://your.web.app 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 The bridge reports **card ids only**. Deciding which user a card belongs to is
@@ -222,9 +251,9 @@ sequenceDiagram
C->>B: tap C->>B: tap
B->>B: latch the tap (tap-ttl seconds) B->>B: latch the tap (tap-ttl seconds)
K->>B: GET /api/card K->>B: GET /api/card
B-->>K: {claimable: true, card: {id: ...}} B-->>K: {present: true, claimable: true, card: {id: ...}}
K->>B: POST /api/card/claim K->>B: POST /api/card/claim
B-->>K: {card: {id: ...}} (once) B-->>K: {card: {id: ...}, consumed: true} (once, then 409)
K->>W: POST /login {card_id} K->>W: POST /login {card_id}
W->>W: card_id -> user W->>W: card_id -> user
W-->>K: session W-->>K: session
@@ -234,10 +263,52 @@ 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 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. 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 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 second tab gets `409 Conflict` instead of a second login, so one physical tap
can never authenticate two sessions. 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.
### Endpoints ### Endpoints
| Method | Path | Purpose | | Method | Path | Purpose |
@@ -247,13 +318,19 @@ can never authenticate two sessions.
| `GET` | `/api/events` | Server-Sent Events stream, pushes each event as it happens | | `GET` | `/api/events` | Server-Sent Events stream, pushes each event as it happens |
| `GET` | `/api/last` | Most recent card event, or `204` | | `GET` | `/api/last` | Most recent card event, or `204` |
| `GET` | `/api/taps` | Recent tap history (`?limit=N`) | | `GET` | `/api/taps` | Recent tap history (`?limit=N`) |
| `GET` | `/api/health` | Reader state, port, uptime, tap counts — for monitoring | | `GET` | `/api/health` | Reader state, port, uptime, tap counts, both TTLs — for monitoring |
| `POST` | `/api/mode` | `{"mode": "login"}` or `{"mode": "monitor"}` | | `POST` | `/api/mode` | `{"mode": "login"}` or `{"mode": "monitor"}` |
`login` mode (default) publishes taps only — a tap is an authentication `login` mode (default) publishes taps only — a tap is an authentication
attempt. `monitor` mode also publishes removals, which is what you want for attempt. `monitor` mode also publishes removals, which is what you want for
watching a reader rather than logging people in. 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 ### Browser example
```js ```js
@@ -271,6 +348,15 @@ if (state.claimable) {
} }
``` ```
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: Or push instead of poll, so a tap logs in immediately:
```js ```js
@@ -290,7 +376,8 @@ they do not make the card itself convincing as a sole credential.
| Origins | Cross-origin browser requests are **refused** unless `--allow-origin` lists that exact origin. Stops another site from reading your taps. | | 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. | | Token | `--token` (or `RDM_BRIDGE_TOKEN`) is required from non-loopback callers. |
| Single use | A tap can only ever produce one successful claim. | | Single use | A tap can only ever produce one successful claim. |
| Expiry | A tap older than `--tap-ttl` is refused with `410`. | | 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 Add a second factor — a PIN after the tap — wherever a cloned card would
actually cost you something. actually cost you something.
@@ -305,19 +392,127 @@ actually cost you something.
## Running at boot (Raspberry Pi) ## Running at boot (Raspberry Pi)
The defaults already match the common wiring — **`/dev/serial0`** for the
reader on the Pi's UART and **GPIO 23** for the LED — so the LED pin and device
usually need no arguments at all:
```bash ```bash
sudo ./deploy/install.sh --origin https://your.web.app sudo ./deploy/install.sh --origin https://your.web.app
``` ```
To be explicit (and safe if the defaults ever change):
```bash
sudo ./deploy/install.sh --origin https://your.web.app \
--device /dev/serial0 \
--led-pin 23 \
--led-mode presence
```
| Argument | Why |
| --- | --- |
| `--device /dev/serial0` | The reader is wired to the UART pins (8 / 10). This is the default. For a USB-to-serial adapter pass `--device /dev/rdm-reader` instead — that alias is created by udev for USB adapters only, so it never exists on a UART setup |
| `--led-pin 23` | BCM GPIO 23, which is **physical pin 16**. The default |
| `--led-mode presence` | Lit while a card is on the reader, dark otherwise. The default |
> **`--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, pass `--led-mode presence`
> regardless and ignore the light, or add `--no-led` to the unit if you want
> the app to stop trying.
### Standalone install
`deploy/install.sh` is the only installer — there is no `deploy.sh`.
That installs a service account, a virtualenv, a systemd unit and a udev rule, That installs a service account, a virtualenv, a systemd unit and a udev rule,
then enables the service. Afterwards: then enables the service. The device defaults to `/dev/rdm-reader`; if the
reader is on the Pi's UART instead, add `--device /dev/serial0` — see
**UART or USB?** above. The installer refuses to enable the service unless the
device exists and opens, so a misconfigured port fails during install rather
than as a crash-looping unit.
Useful 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 |
The unit runs the app as:
```
/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.
Afterwards:
```bash ```bash
systemctl status rdm-card-reader # state 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 journalctl -u rdm-card-reader -f # live log
curl http://127.0.0.1:8765/api/card # tap a card, then run this ss -ltnp | grep 8765 # confirms something is listening
curl -sS http://127.0.0.1:8765/api/card # tap a card, then run this
``` ```
A healthy startup logs one line:
```
OK reader on /dev/serial0 ; bridge on http://127.0.0.1:8765 (mode: login)
```
### Checking the reader from the command line
```bash
# 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 ### Identifying the right ttyUSB
`/dev/ttyUSB0` is **not** a stable identity — unplug something and the number `/dev/ttyUSB0` is **not** a stable identity — unplug something and the number
@@ -338,12 +533,16 @@ Identified serial ports (best candidate first):
Then pin it, most specific first: Then pin it, most specific first:
```bash ```bash
--port /dev/rdm-reader # udev alias --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 /dev/serial/by-id/usb-1a86_USB_Serial-if00-port0 # by device
--port AB0XYZ12 # by serial number --port AB0XYZ12 # by serial number
--port 1a86:7523 # by chip --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 `--autodetect` picks the only USB adapter, and **refuses** when there is more
than one — silently choosing between two readers is exactly the failure this than one — silently choosing between two readers is exactly the failure this
avoids. Verify the wiring in `deploy/99-rdm-reader.rules`. avoids. Verify the wiring in `deploy/99-rdm-reader.rules`.
@@ -354,29 +553,41 @@ Exit codes: `0` ok, `1` no port, `2` library missing, `3` port ambiguous.
## Status LED ## Status LED
An LED driven by card events, on **GPIO 23** by default — the same pin the An LED driven by card events, on **GPIO 23** (physical pin **16**) by default —
workstation app used. 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 ```bash
rdm-card-reader # lit while running, blinks on a bad read 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 --led-pin 17 # a different pin
rdm-card-reader --led-mode presence # lit only while a card is on the reader
rdm-card-reader --no-led # no LED at all rdm-card-reader --no-led # no LED at all
``` ```
| Flag | Default | Meaning | | Flag | Default | Meaning |
| --- | --- | --- | | --- | --- | --- |
| `--no-led` | off | Do not drive a LED | | `--no-led` | off | Do not drive a LED |
| `--led-pin` | `23` | GPIO pin (matches the workstation app) | | `--led-pin` | `23` | **BCM** GPIO number — not the physical header pin |
| `--led-mode` | `error` | See the two readings below | | `--led-mode` | `presence` | See the two readings below |
| `--led-no-error-blink` | off | Do not blink on a bad read | | `--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: Two modes, because the light can mean two different things:
| Mode | Behaviour | Use when | | Mode | Behaviour | Use when |
| --- | --- | --- | | --- | --- | --- |
| `error` (default) | **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" | | `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 |
| `presence` | **Lit only while a card is on the reader** | The light should mean "a card is here" | | `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. Wiring: LED anode to the GPIO pin through a ~330 Ω resistor, cathode to GND.
@@ -393,10 +604,11 @@ point:
refusing to run. refusing to run.
* **Never crashes on a dead pin.** A failed toggle is logged and ignored. * **Never crashes on a dead pin.** A failed toggle is logged and ignored.
Startup reports which of the three states it is in: Startup reports which of the three states it is in (the mode shown is
`presence` by default):
``` ```
LED on GPIO 23 (error mode, gpiozero) # working LED on GPIO 23 (presence mode, gpiozero) # working
LED unavailable: gpiozero not installed # library missing LED unavailable: gpiozero not installed # library missing
LED unavailable: no usable GPIO on pin 23: ... # no pin factory / pin busy LED unavailable: no usable GPIO on pin 23: ... # no pin factory / pin busy
``` ```
@@ -405,6 +617,87 @@ That distinction matters: `BadPinFactory` subclasses `ImportError`, so a naive
check reports "not installed" when the library is present but has no usable pin check reports "not installed" when the library is present but has no usable pin
provider — which would send you to the wrong fix. 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 > **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. > processes driving GPIO 23 will fight, and the LED will flicker.
@@ -417,8 +710,29 @@ it is never imported at runtime and would add 818 KB for nothing.
## Wiring ## Wiring
The RDM6300 talks plain serial at **9600 baud, 8N1**. Connect it to the Pi's The RDM6300 talks plain serial at **9600 baud, 8N1**.
UART (pins 8/10 for TXD/RXD, 5 V and GND) or through a USB-to-serial adapter.
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: On a Raspberry Pi you usually need to free up the primary UART first:
@@ -429,9 +743,66 @@ sudo raspi-config # Interface Options -> Serial Port
sudo usermod -aG dialout $USER # then log out and back in 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
```
The GPIO UART appears as `/dev/serial0` (a symlink to `/dev/ttyAMA0` or The GPIO UART appears as `/dev/serial0` (a symlink to `/dev/ttyAMA0` or
`/dev/ttyS0`). `/dev/ttyS0`).
### 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` | BCM GPIO | Physical header pin |
| --- | --- | --- |
| `23` (the default) | GPIO 23 | **16** |
| `18` | GPIO 18 | 12 |
| `17` | GPIO 17 | 11 |
`--led-pin` is the **BCM** number, which is what the app uses and what GPIO
libraries use. Putting the LED on physical pin 23 would drive **GPIO 11**, not
GPIO 23 — the wire must go to physical pin **16** for the default to be right.
--- ---
## The protocol ## The protocol
@@ -478,6 +849,13 @@ problem with the following card.
| Reader opens but no cards | Card must be EM4100 / 125 kHz; hold it ~2 cm from the coil | | 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 | | `INVALID CARD` repeatedly | Noisy supply or long/unshielded wiring; shorten it |
| Every card reported twice | Lower `--heartbeat` if the card is lifted quickly | | 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** |
--- ---
+110 -9
View File
@@ -10,10 +10,19 @@
# Options: # Options:
# --origin URL web app origin allowed to read the API (repeatable) # --origin URL web app origin allowed to read the API (repeatable)
# --port PORT HTTP port for the bridge (default 8765) # --port PORT HTTP port for the bridge (default 8765)
# --device PATH serial device the service opens (default /dev/serial0)
# --led-pin N BCM GPIO number of the status LED (default 23)
# --led-mode M 'presence' (default) or 'error'
# --heartbeat S seconds before a silent reader counts as card removed
# (default 2.0; this is also the presence/LED window)
# --dir PATH install location (default /opt/rdm-card-reader) # --dir PATH install location (default /opt/rdm-card-reader)
# --user NAME service account (default rdmcard) # --user NAME service account (default rdmcard)
# --no-service install and configure, but do not enable the service # --no-service install and configure, but do not enable the service
# #
# The default device is /dev/serial0, the Raspberry Pi's on-board UART, because
# that is how the reader is normally wired (header pins 8/10). For a
# USB-to-serial adapter pass --device /dev/rdm-reader, the udev alias.
#
set -euo pipefail set -euo pipefail
ORIGINS=() ORIGINS=()
@@ -21,6 +30,10 @@ BRIDGE_PORT=8765
INSTALL_DIR=/opt/rdm-card-reader INSTALL_DIR=/opt/rdm-card-reader
SERVICE_USER=rdmcard SERVICE_USER=rdmcard
INSTALL_SERVICE=1 INSTALL_SERVICE=1
SERIAL_DEVICE=/dev/serial0
LED_PIN=23
LED_MODE=presence
HEARTBEAT=2.0
SOURCE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" SOURCE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
@@ -29,8 +42,12 @@ while [[ $# -gt 0 ]]; do
--port) BRIDGE_PORT="$2"; shift 2 ;; --port) BRIDGE_PORT="$2"; shift 2 ;;
--dir) INSTALL_DIR="$2"; shift 2 ;; --dir) INSTALL_DIR="$2"; shift 2 ;;
--user) SERVICE_USER="$2"; shift 2 ;; --user) SERVICE_USER="$2"; shift 2 ;;
--device) SERIAL_DEVICE="$2"; shift 2 ;;
--led-pin) LED_PIN="$2"; shift 2 ;;
--led-mode) LED_MODE="$2"; shift 2 ;;
--heartbeat) HEARTBEAT="$2"; shift 2 ;;
--no-service) INSTALL_SERVICE=0; shift ;; --no-service) INSTALL_SERVICE=0; shift ;;
-h|--help) sed -n '2,20p' "$0"; exit 0 ;; -h|--help) sed -n '2,33p' "$0"; exit 0 ;;
*) echo "unknown option: $1" >&2; exit 2 ;; *) echo "unknown option: $1" >&2; exit 2 ;;
esac esac
done done
@@ -67,7 +84,14 @@ for item in src pyproject.toml README.md LAUNCH.md LICENSE deploy; do
done done
log "Building the virtual environment" log "Building the virtual environment"
if ! python3 -m venv "$INSTALL_DIR/.venv"; then # --system-site-packages is what makes the status LED work. gpiozero is pure
# Python, but the pin backend it needs (lgpio on Bookworm, RPi.GPIO on older
# releases) is an apt package installed into the *system* Python. A plain venv
# cannot see it, so gpiozero finds no usable pin factory, raises BadPinFactory
# and the app falls back to a no-op LED: the reader works and the light stays
# dark, with nothing failing. Re-running this on an existing venv updates
# pyvenv.cfg in place, so an upgrade picks the flag up too.
if ! python3 -m venv --system-site-packages "$INSTALL_DIR/.venv"; then
warn "could not create the virtualenv." warn "could not create the virtualenv."
warn "On Debian/Raspberry Pi OS this usually means python3-venv is missing:" warn "On Debian/Raspberry Pi OS this usually means python3-venv is missing:"
warn " sudo apt-get install python3-venv" warn " sudo apt-get install python3-venv"
@@ -172,6 +196,17 @@ fi
usermod -aG dialout "$SERVICE_USER" usermod -aG dialout "$SERVICE_USER"
echo " $SERVICE_USER is in group dialout" echo " $SERVICE_USER is in group dialout"
# The status LED drives /dev/gpiochip*, which is root:gpio on modern Raspberry
# Pi OS (Bookworm onward, gpiozero via lgpio). Without this group the service
# cannot open the chip, gpiozero falls back to a no-op LED, and the reader
# works with no light - the failure that is easiest to miss. Older releases
# (Bullseye, RPi.GPIO) use /dev/gpiomem and need no extra group, hence the
# group check rather than an unconditional usermod.
if getent group gpio >/dev/null 2>&1; then
usermod -aG gpio "$SERVICE_USER"
echo " $SERVICE_USER is in group gpio (status LED)"
fi
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 4. Stable device name # 4. Stable device name
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -182,15 +217,49 @@ udevadm control --reload-rules
udevadm trigger --subsystem-match=tty || true udevadm trigger --subsystem-match=tty || true
sleep 1 sleep 1
if [[ -e /dev/rdm-reader ]]; then if [[ "$SERIAL_DEVICE" == "/dev/rdm-reader" && ! -e /dev/rdm-reader ]]; then
echo " /dev/rdm-reader -> $(readlink -f /dev/rdm-reader)"
else
warn "/dev/rdm-reader does not exist yet." warn "/dev/rdm-reader does not exist yet."
warn "The reader may not be plugged in, or it uses a different chip." warn "The USB adapter may not be plugged in, or it uses a different chip."
warn "Run '$INSTALL_DIR/.venv/bin/rdm-card-reader --list-ports --verbose-ports'" warn "Run '$INSTALL_DIR/.venv/bin/rdm-card-reader --list-ports --verbose-ports'"
warn "then edit /etc/udev/rules.d/99-rdm-reader.rules to match your adapter." warn "then edit /etc/udev/rules.d/99-rdm-reader.rules to match your adapter."
warn "Reader wired to the Pi's UART pins instead? That is the default device:"
warn "re-run without --device (or with --device /dev/serial0)."
fi fi
# Refuse to enable a service that can only exit 1. A unit that fails on every
# start burns its StartLimitBurst within seconds and then stays dead until
# someone runs 'systemctl reset-failed' by hand - a confusing failure to debug
# remotely, and one that silently returns "installed successfully".
if [[ ! -e "$SERIAL_DEVICE" ]]; then
warn "$SERIAL_DEVICE does not exist, so the service could never start."
warn "Nothing has been enabled. Fix the device, then re-run this installer."
exit 1
fi
# Prove the port can actually be opened, not merely that it exists. Existence
# alone is not enough: a node with no hardware behind it still fails to open.
if ! "$INSTALL_DIR/.venv/bin/python" - "$SERIAL_DEVICE" <<'PY'
import sys
import serial
try:
serial.Serial(sys.argv[1], 9600, timeout=1).close()
except Exception as exc:
print(" x {}: {}".format(sys.argv[1], exc))
raise SystemExit(1)
PY
then
warn "could not open $SERIAL_DEVICE at 9600 baud."
warn "For the on-board UART check that it is enabled:"
warn " sudo raspi-config # Interface Options -> Serial Port"
warn " 'login shell over serial?' -> No"
warn " 'serial port hardware?' -> Yes"
warn "Nothing has been enabled. Fix the above, then re-run this installer."
exit 1
fi
echo " $SERIAL_DEVICE opened at 9600 baud"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 5. Service # 5. Service
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -204,10 +273,12 @@ for origin in "${ORIGINS[@]:-}"; do
[[ -n "$origin" ]] && ORIGIN_ARGS+=" --allow-origin $origin" [[ -n "$origin" ]] && ORIGIN_ARGS+=" --allow-origin $origin"
done done
python3 - "$UNIT" "$INSTALL_DIR" "$BRIDGE_PORT" "$ORIGIN_ARGS" "$SERVICE_USER" <<'PY' python3 - "$UNIT" "$INSTALL_DIR" "$BRIDGE_PORT" "$ORIGIN_ARGS" "$SERVICE_USER" \
"$SERIAL_DEVICE" "$LED_PIN" "$LED_MODE" "$HEARTBEAT" <<'PY'
import os, re, sys import os, re, sys
unit, install_dir, port, origin_args, user = sys.argv[1:6] (unit, install_dir, port, origin_args, user, device, led_pin, led_mode,
heartbeat) = sys.argv[1:10]
text = open(unit).read() text = open(unit).read()
# The console script is created by the installer, but the .pth file is what # The console script is created by the installer, but the .pth file is what
@@ -219,11 +290,38 @@ if not os.path.exists(wrapper):
command = ( command = (
f"{wrapper} --serve " f"{wrapper} --serve "
f"--port /dev/rdm-reader --bridge-port {port} --mode login{origin_args}" f"--port {device} --bridge-port {port} --mode login "
f"--led-pin {led_pin} --led-mode {led_mode} --heartbeat {heartbeat}"
f"{origin_args}"
) )
def device_unit(path):
"""systemd device unit for *path*, e.g. /dev/rdm-reader -> dev-rdm\\x2dreader.device."""
name = path[len("/dev/"):] if path.startswith("/dev/") else path
name = name.replace("-", "\\x2d").replace("/", "-")
return f"dev-{name}.device"
# Replace the whole (possibly line-continued) ExecStart definition. # Replace the whole (possibly line-continued) ExecStart definition.
text = re.sub(r"ExecStart=[\s\S]*?(?=\n\n|\nRestart=)", f"ExecStart={command}", text, count=1) text = re.sub(r"ExecStart=[\s\S]*?(?=\n\n|\nRestart=)", f"ExecStart={command}", text, count=1)
text = re.sub(r"^User=.*$", f"User={user}", text, count=1, flags=re.M) text = re.sub(r"^User=.*$", f"User={user}", text, count=1, flags=re.M)
# Order after the device we actually open. The shipped unit names a device
# that is not the one the service reads, so systemd never really waited for it.
text = re.sub(r"^After=.*$", f"After={device_unit(device)} network-online.target",
text, count=1, flags=re.M)
# DevicePolicy=closed means only the devices listed here are usable, so the
# list has to cover everything the service touches: the serial port, and the
# GPIO chip the status LED drives. Omitting the chip is a silent failure - the
# reader keeps working and only the LED stays dark - so it is easy to miss.
# gpiochip0 is the header on Pi 1-4; Pi 5 uses gpiochip4.
device_allow = "\n".join([
f"DeviceAllow={device} rw",
"DeviceAllow=/dev/gpiochip0 rw",
"DeviceAllow=/dev/gpiochip4 rw",
])
text = re.sub(r"^DeviceAllow=.*(?:\nDeviceAllow=.*)*", device_allow, text,
count=1, flags=re.M)
open(unit, "w").write(text) open(unit, "w").write(text)
PY PY
@@ -249,6 +347,9 @@ cat <<EOF
$(log "Installed") $(log "Installed")
Bridge : http://127.0.0.1:$BRIDGE_PORT Bridge : http://127.0.0.1:$BRIDGE_PORT
Device : $SERIAL_DEVICE
LED : GPIO $LED_PIN, $LED_MODE mode
Heartbeat : ${HEARTBEAT}s (presence/LED window)
Card state : curl http://127.0.0.1:$BRIDGE_PORT/api/card Card state : curl http://127.0.0.1:$BRIDGE_PORT/api/card
Health : curl http://127.0.0.1:$BRIDGE_PORT/api/health Health : curl http://127.0.0.1:$BRIDGE_PORT/api/health
Logs : journalctl -u rdm-card-reader -f Logs : journalctl -u rdm-card-reader -f
+20 -9
View File
@@ -2,8 +2,10 @@
Description=RDM card reader HTTP bridge (serves card taps for web login) Description=RDM card reader HTTP bridge (serves card taps for web login)
Documentation=file:/opt/rdm-card-reader/README.md Documentation=file:/opt/rdm-card-reader/README.md
# The serial device must exist before we start, otherwise the reader opens the # The serial device must exist before we start, otherwise the reader opens the
# wrong tty or none at all. # wrong tty or none at all. install.sh rewrites this line for the device given
After=dev-serial.device network-online.target # with --device, since only it knows whether the reader is on the on-board UART
# (/dev/serial0, the default) or a USB adapter (/dev/rdm-reader).
After=dev-serial0.device network-online.target
Wants=network-online.target Wants=network-online.target
# Do not restart forever if the reader is genuinely absent. These are [Unit] # Do not restart forever if the reader is genuinely absent. These are [Unit]
@@ -17,22 +19,29 @@ User=rdmcard
Group=dialout Group=dialout
WorkingDirectory=/opt/rdm-card-reader WorkingDirectory=/opt/rdm-card-reader
# --serve exposes the card on http://127.0.0.1:8765/api/card # --serve runs the HTTP bridge, which is what makes the reader available as an
# --port points at the udev alias, so the reader is the same device every boot. # API on http://127.0.0.1:8765. --port points at the device the reader is
# actually wired to; install.sh rewrites this line from --device, and only it
# knows whether that is a USB adapter (/dev/rdm-reader) or the on-board UART
# (/dev/serial0). --led-pin is rewritten from --led-pin for the same reason.
# Autodetect refuses to guess if more than one USB adapter is present, which is # Autodetect refuses to guess if more than one USB adapter is present, which is
# the safe behaviour: a wrong guess means authenticating the wrong person. # the safe behaviour: a wrong guess means authenticating the wrong person.
# The wheels for rdm6300 and pyserial are bundled inside the package, so no # The wheels for rdm6300 and pyserial are bundled inside the package, so no
# --wheel-dir is needed here: the install works with no internet. # --wheel-dir is needed here: the install works with no internet.
ExecStart=/opt/rdm-card-reader/.venv/bin/rdm-card-reader \ ExecStart=/opt/rdm-card-reader/.venv/bin/rdm-card-reader \
--serve \ --serve \
--port /dev/rdm-reader \ --port /dev/serial0 \
--mode login \ --mode login \
--allow-origin https://your.web.app --led-pin 23 \
--led-mode presence \
--heartbeat 2.0
Restart=always Restart=always
RestartSec=3 RestartSec=3
# The bridge only needs to read the serial port and listen on loopback. # The bridge reads the serial port, drives the status LED and listens on
# loopback. DeviceAllow is rewritten by install.sh alongside --port: with
# DevicePolicy=closed a device missing from this list is denied outright, and
# omitting the GPIO chip leaves the LED dark while everything else works.
NoNewPrivileges=true NoNewPrivileges=true
PrivateTmp=true PrivateTmp=true
ProtectSystem=strict ProtectSystem=strict
@@ -44,7 +53,9 @@ RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
RestrictNamespaces=true RestrictNamespaces=true
LockPersonality=true LockPersonality=true
MemoryDenyWriteExecute=true MemoryDenyWriteExecute=true
DeviceAllow=/dev/rdm-reader rw DeviceAllow=/dev/serial0 rw
DeviceAllow=/dev/gpiochip0 rw
DeviceAllow=/dev/gpiochip4 rw
DevicePolicy=closed DevicePolicy=closed
# stdout/stderr go here; watch with: journalctl -u rdm-card-reader -f # stdout/stderr go here; watch with: journalctl -u rdm-card-reader -f
+7 -1
View File
@@ -30,7 +30,12 @@ from .ports import (
discover, discover,
resolve, resolve,
) )
from .led import DEFAULT_LED_PIN, StatusLED, create as create_led from .led import (
DEFAULT_LED_MODE,
DEFAULT_LED_PIN,
StatusLED,
create as create_led,
)
from .reader import available, list_ports, open_reader, reload_library from .reader import available, list_ports, open_reader, reload_library
__version__ = "0.4.0" __version__ = "0.4.0"
@@ -41,6 +46,7 @@ __all__ = [
"CardData", "CardData",
"CardEvent", "CardEvent",
"AmbiguousPort", "AmbiguousPort",
"DEFAULT_LED_MODE",
"DEFAULT_LED_PIN", "DEFAULT_LED_PIN",
"EventHub", "EventHub",
"PortError", "PortError",
+45
View File
@@ -75,6 +75,12 @@ DEFAULT_HISTORY = 50
#: replaced. #: replaced.
DEFAULT_TAP_TTL = 10.0 DEFAULT_TAP_TTL = 10.0
#: How long a *claimed* tap stays readable afterwards. The page that claimed it
#: needs a moment to confirm which card logged in, but an id that stays
#: readable forever is indistinguishable from a tap still waiting to be spent -
#: so it is cleared once this window passes. Set to 0 to keep it indefinitely.
DEFAULT_CLAIMED_TTL = 30.0
class EventHub: class EventHub:
"""Tracks reader state and fans card events out to waiting subscribers. """Tracks reader state and fans card events out to waiting subscribers.
@@ -96,6 +102,7 @@ class EventHub:
history: int = DEFAULT_HISTORY, history: int = DEFAULT_HISTORY,
mode: str = MODE_LOGIN, mode: str = MODE_LOGIN,
tap_ttl: float = DEFAULT_TAP_TTL, tap_ttl: float = DEFAULT_TAP_TTL,
claimed_ttl: float = DEFAULT_CLAIMED_TTL,
): ):
self._lock = threading.Lock() self._lock = threading.Lock()
self._subscribers: List["deque"] = [] self._subscribers: List["deque"] = []
@@ -107,6 +114,7 @@ class EventHub:
self._counts = {EVENT_INSERTED: 0, EVENT_REMOVED: 0, "invalid_card": 0} self._counts = {EVENT_INSERTED: 0, EVENT_REMOVED: 0, "invalid_card": 0}
self._port: Optional[str] = None self._port: Optional[str] = None
self._tap_ttl = float(tap_ttl if tap_ttl else 0.0) self._tap_ttl = float(tap_ttl if tap_ttl else 0.0)
self._claimed_ttl = float(claimed_ttl if claimed_ttl else 0.0)
# The latched tap. It deliberately outlives the card leaving the # The latched tap. It deliberately outlives the card leaving the
# reader, so a page that loads after the tap can still claim it. # reader, so a page that loads after the tap can still claim it.
@@ -177,8 +185,16 @@ class EventHub:
``present`` is whether the card is physically on the reader right now; ``present`` is whether the card is physically on the reader right now;
``claimable`` is whether this tap can still be spent on a login. ``claimable`` is whether this tap can still be spent on a login.
A claimed tap is reported for ``claimed_ttl`` seconds after it was
spent, so the page that claimed it can confirm which card logged in. An
expired unclaimed tap is cleared too: keeping it would leave an id that
can never be claimed again, which is indistinguishable from a tap that
is still waiting to be used.
""" """
with self._lock: with self._lock:
self._drop_dead_tap()
if self._tap is None: if self._tap is None:
return { return {
"present": False, "present": False,
@@ -200,6 +216,32 @@ class EventHub:
"card": self._tap.to_dict(), "card": self._tap.to_dict(),
} }
def _drop_dead_tap(self) -> None:
"""Clear the latched tap once it can no longer be acted on.
Called with the lock held. Two independent deadlines: an unclaimed tap
is dead after ``tap_ttl``, a claimed one after ``claimed_ttl``. A tap of
zero TTL means "keep until replaced", which the claimed case uses to
keep the id around indefinitely.
Crucially this drops only the *tap*, never the presence flag: whether a
card is on the reader is a physical fact learnt from insert/remove
events, and a tap ageing out says nothing about it. Clearing presence
here reported "no card" while a card was sitting on the antenna.
"""
if self._tap is None:
return
now = time.time()
if self._consumed_at is not None:
if self._claimed_ttl and now - self._consumed_at > self._claimed_ttl:
self._tap = None
self._tap_at = None
self._consumed_at = None
return
if self._tap_ttl and now - (self._tap_at or 0.0) > self._tap_ttl:
self._tap = None
self._tap_at = None
def consume_card(self): def consume_card(self):
"""Atomically claim the latched tap for one authentication. """Atomically claim the latched tap for one authentication.
@@ -207,6 +249,7 @@ class EventHub:
this call succeeds, so two concurrent requests cannot both win. this call succeeds, so two concurrent requests cannot both win.
""" """
with self._lock: with self._lock:
self._drop_dead_tap()
if self._tap is None: if self._tap is None:
return None, "no card read yet", 409 return None, "no card read yet", 409
@@ -245,6 +288,7 @@ class EventHub:
def health(self) -> Dict[str, Any]: def health(self) -> Dict[str, Any]:
with self._lock: with self._lock:
self._drop_dead_tap()
last_age = ( last_age = (
round(time.time() - self._last_at, 1) if self._last_at else None round(time.time() - self._last_at, 1) if self._last_at else None
) )
@@ -260,6 +304,7 @@ class EventHub:
"card_present": self._on_reader, "card_present": self._on_reader,
"card_consumed": self._consumed_at is not None, "card_consumed": self._consumed_at is not None,
"tap_ttl_seconds": self._tap_ttl or None, "tap_ttl_seconds": self._tap_ttl or None,
"claimed_ttl_seconds": self._claimed_ttl or None,
} }
def last(self) -> Optional[Dict[str, Any]]: def last(self) -> Optional[Dict[str, Any]]:
+13 -2
View File
@@ -18,10 +18,13 @@ except ImportError: # pragma: no cover - fallback so the model stays importable
CardData = namedtuple("CardData", ["value", "checksum", "type", "is_valid"]) CardData = namedtuple("CardData", ["value", "checksum", "type", "is_valid"])
#: Serial devices tried in order. ``/dev/ttyS0`` first matches the Raspberry Pi #: Serial devices tried in order. ``/dev/ttyS0`` first matches the Raspberry Pi
#: default, the others cover USB / GPIO UART dongles. #: default, the others cover USB / GPIO UART dongles. ``/dev/serial0`` is the
#: canonical Pi alias for the on-board UART - it resolves to ttyAMA0 or ttyS0
#: depending on the model, and is the device the docs tell you to use.
CANDIDATE_PORTS = ( CANDIDATE_PORTS = (
"/dev/ttyS0", "/dev/ttyS0",
"/dev/ttyAMA0", "/dev/ttyAMA0",
"/dev/serial0",
"/dev/ttyUSB0", "/dev/ttyUSB0",
"/dev/ttyACM0", "/dev/ttyACM0",
) )
@@ -32,7 +35,15 @@ CONFIG_CARD_VALUE = 12886709
#: Seconds without a read before a card is considered removed. Set to ``None`` #: Seconds without a read before a card is considered removed. Set to ``None``
#: to disable removal detection entirely. #: to disable removal detection entirely.
DEFAULT_HEARTBEAT_INTERVAL = 0.5 #:
#: This is also the presence window: the card counts as "present" until this
#: long after the last frame. RDM6300 modules typically report a card once per
#: placement rather than re-reading continuously, so a short value makes
#: ``present`` flicker - and the status LED with it - while the card sits still
#: on the antenna. Longer holds presence (and the light) on longer but makes
#: lifting the card take longer to notice; raise it if a card held in place is
#: reported as removed, lower it if removal feels sluggish.
DEFAULT_HEARTBEAT_INTERVAL = 2.0
BAR = "-" * 58 BAR = "-" * 58
+17 -6
View File
@@ -10,6 +10,7 @@ from typing import Optional, Sequence
from . import __version__ from . import __version__
from .bootstrap import LED_PACKAGES, ensure_dependencies from .bootstrap import LED_PACKAGES, ensure_dependencies
from .bridge import ( from .bridge import (
DEFAULT_CLAIMED_TTL,
DEFAULT_HOST, DEFAULT_HOST,
DEFAULT_PORT, DEFAULT_PORT,
DEFAULT_TAP_TTL, DEFAULT_TAP_TTL,
@@ -27,7 +28,12 @@ from .card import (
format_event, format_event,
) )
from .demo import run_demo from .demo import run_demo
from .led import DEFAULT_LED_PIN, MODES as LED_MODES, create as create_led from .led import (
DEFAULT_LED_MODE,
DEFAULT_LED_PIN,
MODES as LED_MODES,
create as create_led,
)
from .ports import AmbiguousPort, PortNotFound, discover, resolve from .ports import AmbiguousPort, PortNotFound, discover, resolve
from .reader import available, explain_error, list_ports, open_reader, reload_library from .reader import available, explain_error, list_ports, open_reader, reload_library
@@ -169,7 +175,8 @@ def _run_bridge(args) -> int:
device = _resolve_port(args) device = _resolve_port(args)
hub = EventHub(mode=args.mode, tap_ttl=args.tap_ttl) hub = EventHub(mode=args.mode, tap_ttl=args.tap_ttl,
claimed_ttl=args.claimed_ttl)
hub.set_port(device) hub.set_port(device)
led = _build_led(args) led = _build_led(args)
@@ -285,6 +292,10 @@ def build_parser() -> argparse.ArgumentParser:
bridge.add_argument("--tap-ttl", type=float, default=DEFAULT_TAP_TTL, bridge.add_argument("--tap-ttl", type=float, default=DEFAULT_TAP_TTL,
help="seconds an unclaimed tap stays claimable; 0 keeps it " help="seconds an unclaimed tap stays claimable; 0 keeps it "
"claimable while the card is present (default: %(default)s)") "claimable while the card is present (default: %(default)s)")
bridge.add_argument("--claimed-ttl", type=float, default=DEFAULT_CLAIMED_TTL,
help="seconds a claimed card stays readable so the claiming "
"page can confirm which card logged in; 0 keeps it "
"indefinitely (default: %(default)s)")
bridge.add_argument("--allow-origin", action="append", metavar="ORIGIN", bridge.add_argument("--allow-origin", action="append", metavar="ORIGIN",
help="permit this browser origin to call the API; repeatable") help="permit this browser origin to call the API; repeatable")
bridge.add_argument("--token", bridge.add_argument("--token",
@@ -301,10 +312,10 @@ def build_parser() -> argparse.ArgumentParser:
led.add_argument("--led-pin", type=int, default=DEFAULT_LED_PIN, led.add_argument("--led-pin", type=int, default=DEFAULT_LED_PIN,
help="GPIO pin of the status LED (default: %(default)s, " help="GPIO pin of the status LED (default: %(default)s, "
"matching the workstation app)") "matching the workstation app)")
led.add_argument("--led-mode", choices=list(LED_MODES), default=LED_MODES[0], led.add_argument("--led-mode", choices=list(LED_MODES), default=DEFAULT_LED_MODE,
help="'error' (default): lit whenever the reader is running, " help="'presence' (default): lit only while a card is on the "
"blinking means a bad read; 'presence': lit only while " "reader; 'error': lit whenever the reader is running, "
"a card is on the reader") "blinking means a bad read")
led.add_argument("--led-no-error-blink", action="store_true", led.add_argument("--led-no-error-blink", action="store_true",
help="do not blink the LED on a bad read") help="do not blink the LED on a bad read")
return parser return parser
+339
View File
@@ -0,0 +1,339 @@
"""Type a card id into whatever window has focus, like a keyboard-wedge reader.
A commercial USB RFID reader presents itself to the machine as a keyboard: the
card code appears in the focused window as if it had been typed. This does the
same in software, by creating a virtual keyboard through the kernel's
``/dev/uinput`` interface.
Two consequences of doing it that way, both deliberate:
* **No external tool and no extra dependency.** ``python-evdev`` would be the
usual choice, but it is a C extension, so its wheels are tied to one
interpreter and one CPU architecture. This project ships pure-Python wheels
precisely so the same files work on 32- and 64-bit ARM; pulling in a compiled
dependency would break that. ``ctypes`` and ``struct`` are enough.
* **It works from the reader's system service.** uinput registers the device
with the kernel, not with a login session, so the desktop picks it up exactly
as it would a real keyboard. No ``DISPLAY``, no session bus, no
``systemctl --user`` unit is involved - unlike X11-only tools such as
``xdotool``, which also fail on Wayland.
The known limit, which a real wedge reader shares: the text goes to whatever
holds focus, so a tap while a password prompt is focused types the card id
there. That is why the feature is **opt-in** (``--type``) rather than default.
Like the LED, this never blocks the reader thread, never crashes the process,
and never pretends to work when it cannot: whether a virtual keyboard was
actually created is reported at startup.
"""
from __future__ import annotations
import ctypes
import fcntl
import os
import queue
import threading
import time
from dataclasses import dataclass
from typing import Any, Callable, Dict, List, Optional
#: Where the kernel exposes the uinput control device.
UINPUT_PATH = "/dev/uinput"
#: Suppress repeated identical text, e.g. the same card held on the antenna.
DEFAULT_KEY_DELAY = 0.002
#: Output formats for the card id.
DECIMAL = "decimal"
HEX = "hex"
FORMATS = (DECIMAL, HEX)
#: What to send after the id, so the target field is submitted.
ENTER = "enter"
TAB = "tab"
NONE = "none"
SUFFIXES = (ENTER, TAB, NONE)
# -- kernel constants (linux/input-event-codes.h and linux/uinput.h) ---------
EV_SYN = 0x00
EV_KEY = 0x01
SYN_REPORT = 0x00
_KEY_PRESS = 1
_KEY_RELEASE = 0
_UI_DEV_CREATE = 0x5501
_UI_DEV_DESTROY = 0x5502
_UI_SET_EVBIT = 0x40045564
_UI_SET_KEYBIT = 0x40045565
_UINPUT_MAX_NAME_SIZE = 80
#: Character -> key code. Digits cover the default output; a-f cover ``--hex``
#: (lower case, since upper case would need a shift modifier for no real gain).
_KEYCODES: Dict[str, int] = {
"1": 2, "2": 3, "3": 4, "4": 5, "5": 6,
"6": 7, "7": 8, "8": 9, "9": 10, "0": 11,
"a": 30, "b": 48, "c": 46, "d": 32, "e": 18, "f": 33,
"x": 45,
}
_SPECIAL: Dict[str, int] = {
ENTER: 28,
TAB: 15,
}
class _InputEvent(ctypes.Structure):
"""``struct input_event``.
``tv_sec``/``tv_usec`` are ``c_long`` so the layout is right on both 32- and
64-bit kernels - the struct is 16 bytes on one and 24 on the other, and
hard-coding either would corrupt the event.
"""
_fields_ = [
("tv_sec", ctypes.c_long),
("tv_usec", ctypes.c_long),
("type", ctypes.c_uint16),
("code", ctypes.c_uint16),
("value", ctypes.c_int32),
]
class _InputId(ctypes.Structure):
_fields_ = [
("bustype", ctypes.c_uint16),
("vendor", ctypes.c_uint16),
("product", ctypes.c_uint16),
("version", ctypes.c_uint16),
]
class _UInputUserDev(ctypes.Structure):
"""``struct uinput_user_dev``, used to name and describe the device."""
_fields_ = [
("name", ctypes.c_char * _UINPUT_MAX_NAME_SIZE),
("id", _InputId),
("ff_effects_max", ctypes.c_uint32),
("absmax", ctypes.c_int32 * 64),
("absmin", ctypes.c_int32 * 64),
("absfuzz", ctypes.c_int32 * 64),
("absflat", ctypes.c_int32 * 64),
]
def _as_bytes(struct: ctypes.Structure) -> bytes:
return ctypes.string_at(ctypes.byref(struct), ctypes.sizeof(struct))
@dataclass
class WedgeStatus:
"""What the wedge subsystem is actually doing."""
enabled: bool
backend: str # "uinput" | "null"
format: str = DECIMAL
suffix: str = ENTER
reason: str = ""
def to_dict(self) -> dict:
return {
"enabled": self.enabled,
"backend": self.backend,
"format": self.format,
"suffix": self.suffix,
"reason": self.reason,
}
class NullWedge:
"""A wedge that does nothing, used when uinput is unavailable."""
backend = "null"
def __init__(self, reason: str = ""):
self.reason = reason
def type_text(self, text: str) -> None:
pass
def close(self) -> None:
pass
class UInputWedge:
"""A virtual keyboard created through ``/dev/uinput``."""
backend = "uinput"
def __init__(self, name: str = "rdm-card-reader", key_delay: float = DEFAULT_KEY_DELAY):
self.key_delay = key_delay
self._fd = os.open(UINPUT_PATH, os.O_WRONLY | os.O_NONBLOCK)
# Declare the only kinds of event this device will ever send.
fcntl.ioctl(self._fd, _UI_SET_EVBIT, EV_KEY)
for code in sorted(set(_KEYCODES.values()) | set(_SPECIAL.values())):
fcntl.ioctl(self._fd, _UI_SET_KEYBIT, code)
dev = _UInputUserDev()
dev.name = name.encode("utf-8")[:_UINPUT_MAX_NAME_SIZE - 1]
# BUS_USB keeps the device looking like an ordinary keyboard to udev.
dev.id = _InputId(bustype=0x03, vendor=0x1, product=0x1, version=1)
os.write(self._fd, _as_bytes(dev))
fcntl.ioctl(self._fd, _UI_DEV_CREATE)
# udev needs a moment to notice a brand-new input device; typing before
# it does means the first characters go nowhere.
time.sleep(0.2)
def _send(self, event_type: int, code: int, value: int) -> None:
os.write(self._fd, _as_bytes(_InputEvent(0, 0, event_type, code, value)))
def _tap(self, code: int) -> None:
self._send(EV_KEY, code, _KEY_PRESS)
self._send(EV_SYN, SYN_REPORT, 0)
self._send(EV_KEY, code, _KEY_RELEASE)
self._send(EV_SYN, SYN_REPORT, 0)
if self.key_delay:
time.sleep(self.key_delay)
def type_text(self, text: str) -> None:
for character in text:
code = _KEYCODES.get(character.lower())
if code is not None:
self._tap(code)
def press(self, name: str) -> None:
code = _SPECIAL.get(name)
if code is not None:
self._tap(code)
def close(self) -> None:
try:
fcntl.ioctl(self._fd, _UI_DEV_DESTROY)
except Exception:
pass
try:
os.close(self._fd)
except Exception:
pass
def format_card(event: Any, fmt: str = DECIMAL) -> str:
"""Render a card event as the text a wedge reader would have typed."""
if fmt == HEX:
return event.hex_value
return str(event.value)
class KeyboardWedge:
"""Non-blocking wedge controller driven by card events.
Text is queued and typed by a worker thread, so a card read never waits on
the desktop - the same arrangement the LED uses, and for the same reason.
"""
def __init__(
self,
enabled: bool = True,
fmt: str = DECIMAL,
suffix: str = ENTER,
key_delay: float = DEFAULT_KEY_DELAY,
logger: Optional[Callable[[str], None]] = None,
):
self._log = logger or (lambda message: None)
self._fmt = fmt if fmt in FORMATS else DECIMAL
self._suffix = suffix if suffix in SUFFIXES else ENTER
self._queue: "queue.Queue[Optional[str]]" = queue.Queue()
self._thread: Optional[threading.Thread] = None
if not enabled:
self.wedge: Any = NullWedge("typing disabled (--no-type)")
self.status = WedgeStatus(False, "null", self._fmt, self._suffix,
"disabled")
return
try:
self.wedge = UInputWedge(key_delay=key_delay)
self.status = WedgeStatus(True, "uinput", self._fmt, self._suffix,
"ready")
self._log("Keyboard wedge ready (uinput, {} text, {} suffix)".format(
self._fmt, self._suffix))
except PermissionError as exc:
reason = (
"cannot write {}: {} - add the user to the 'input' group "
"and re-run the installer".format(UINPUT_PATH, exc)
)
self.wedge = NullWedge(reason)
self.status = WedgeStatus(False, "null", self._fmt, self._suffix,
reason)
self._log("Keyboard wedge unavailable: {}".format(reason))
except Exception as exc:
reason = "no usable uinput: {}".format(exc)
self.wedge = NullWedge(reason)
self.status = WedgeStatus(False, "null", self._fmt, self._suffix,
reason)
self._log("Keyboard wedge unavailable: {}".format(reason))
self._thread = threading.Thread(target=self._worker, daemon=True,
name="wedge-worker")
self._thread.start()
# -- event hooks ------------------------------------------------------
def insert(self, event: Any) -> None:
"""A card was read: queue its id, then the configured suffix."""
self._send(format_card(event, self._fmt))
@property
def suffix(self) -> str:
return self._suffix
# -- control ----------------------------------------------------------
def _send(self, text: str) -> None:
if not self.status.enabled:
return
try:
self._queue.put_nowait(text)
except Exception:
pass
def _worker(self) -> None:
while True:
text = self._queue.get()
if text is None:
return
try:
self.wedge.type_text(text)
if self._suffix != NONE:
self.wedge.press(self._suffix)
except Exception as exc:
# A failed keystroke must never take the reader down.
self._log("Keyboard wedge failed: {}".format(exc))
finally:
self._queue.task_done()
def close(self) -> None:
if self._thread is not None:
try:
self._queue.put_nowait(None)
except Exception:
pass
self.wedge.close()
def create(
enabled: bool = True,
fmt: str = DECIMAL,
suffix: str = ENTER,
key_delay: float = DEFAULT_KEY_DELAY,
logger: Optional[Callable[[str], None]] = None,
) -> KeyboardWedge:
"""Build the wedge controller, falling back to a no-op when uinput fails."""
return KeyboardWedge(enabled=enabled, fmt=fmt, suffix=suffix,
key_delay=key_delay, logger=logger)
+13 -9
View File
@@ -34,15 +34,20 @@ OFF = "off"
ON = "on" ON = "on"
BLINK = "blink" BLINK = "blink"
#: Lit only while a card is on the reader, and off otherwise. This is a card
#: indicator, for when the light should mean "a card is here". The default: a
#: light that tracks the card is unambiguous, whereas a light that is always on
#: says nothing about the card.
PRESENCE = "presence"
#: Lit the whole time the app runs, and blinks on a bad read. This is a health #: Lit the whole time the app runs, and blinks on a bad read. This is a health
#: indicator: light on means the reader is alive and ready for a card. #: indicator: light on means the reader is alive and ready for a card.
ERROR = "error" ERROR = "error"
#: Lit only while a card is on the reader, and off otherwise. This is a card MODES = (PRESENCE, ERROR)
#: indicator, for when the light should mean "a card is here".
PRESENCE = "presence"
MODES = (ERROR, PRESENCE) #: What the light means unless told otherwise.
DEFAULT_LED_MODE = PRESENCE
#: Kept for the occasional caller that only cares about the concrete commands. #: Kept for the occasional caller that only cares about the concrete commands.
_VALID_MODES = (OFF, ON, BLINK) _VALID_MODES = (OFF, ON, BLINK)
@@ -59,8 +64,7 @@ class LEDStatus:
state: bool = False state: bool = False
reason: str = "" reason: str = ""
def to_dict(self) -> dict: def to_dict(self) -> dict: return {
return {
"enabled": self.enabled, "enabled": self.enabled,
"pin": self.pin, "pin": self.pin,
"backend": self.backend, "backend": self.backend,
@@ -158,13 +162,13 @@ class StatusLED:
self, self,
pin: int = DEFAULT_LED_PIN, pin: int = DEFAULT_LED_PIN,
enabled: bool = True, enabled: bool = True,
mode: str = ERROR, mode: str = DEFAULT_LED_MODE,
active_high: bool = True, active_high: bool = True,
error_blink: bool = True, error_blink: bool = True,
logger: Optional[Callable[[str], None]] = None, logger: Optional[Callable[[str], None]] = None,
): ):
self._log = logger or (lambda message: None) self._log = logger or (lambda message: None)
self._mode = mode if mode in MODES else ERROR self._mode = mode if mode in MODES else DEFAULT_LED_MODE
self._error_blink = error_blink self._error_blink = error_blink
self._enabled = enabled self._enabled = enabled
self._blinker: Optional[_Blinker] = None self._blinker: Optional[_Blinker] = None
@@ -305,7 +309,7 @@ class StatusLED:
def create( def create(
enabled: bool = True, enabled: bool = True,
pin: int = DEFAULT_LED_PIN, pin: int = DEFAULT_LED_PIN,
mode: str = ERROR, mode: str = DEFAULT_LED_MODE,
error_blink: bool = True, error_blink: bool = True,
logger: Optional[Callable[[str], None]] = None, logger: Optional[Callable[[str], None]] = None,
) -> StatusLED: ) -> StatusLED: