Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 031cf0f25a | |||
| 2ded37e190 | |||
| 674d91a5ca | |||
| 23cd8f2df5 | |||
| eaf3f394fc | |||
| 576f22268f |
@@ -9,28 +9,18 @@ expected and correct.** To see the app work without hardware, use `--demo`:
|
|||||||
.venv/bin/python -m rdm_card_reader --demo
|
.venv/bin/python -m rdm_card_reader --demo
|
||||||
```
|
```
|
||||||
|
|
||||||
To see the reader path end-to-end without hardware, drive a pseudo-terminal
|
To see the reader path run end-to-end without hardware, the test suite drives a
|
||||||
pretending to be an RDM6300 — see **Testing the reader path without a reader**
|
pseudo-terminal pretending to be an RDM6300:
|
||||||
below.
|
|
||||||
|
```bash
|
||||||
|
.venv/bin/python -m pytest tests/ -q # 152 tests, includes e2e PTY tests
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Quick start
|
## Quick start
|
||||||
|
|
||||||
Build the virtualenv once, from the wheels bundled in the source tree — no
|
All commands run from the project root. The virtualenv is already built.
|
||||||
network needed:
|
|
||||||
|
|
||||||
```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"
|
|
||||||
```
|
|
||||||
|
|
||||||
For the status LED, add `gpiozero colorzero` to the same `pip install` line.
|
|
||||||
|
|
||||||
Then, from the project root:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
.venv/bin/python -m rdm_card_reader --demo # simulate a card, no hardware
|
.venv/bin/python -m rdm_card_reader --demo # simulate a card, no hardware
|
||||||
@@ -39,9 +29,9 @@ Then, from the project root:
|
|||||||
.venv/bin/python -m rdm_card_reader --serve # HTTP bridge for a web page
|
.venv/bin/python -m rdm_card_reader --serve # HTTP bridge for a web page
|
||||||
```
|
```
|
||||||
|
|
||||||
That is the whole install: the runtime libraries come from the bundled wheels
|
After `pip install .` the same thing is available as `rdm-card-reader …`.
|
||||||
and `src/` is on the path via the `.pth` file. `pip install .` also works, but
|
`pip install .` needs the network — see **Troubleshooting** below for the
|
||||||
only with network — it builds the project. See **Troubleshooting** below.
|
offline route.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -340,7 +330,9 @@ somewhere else, but `src/` must be present or the wheels will be missing.
|
|||||||
## Testing the reader path without a reader
|
## Testing the reader path without a reader
|
||||||
|
|
||||||
Because the app talks plain serial, a pseudo-terminal is enough to exercise the
|
Because the app talks plain serial, a pseudo-terminal is enough to exercise the
|
||||||
whole chain: write real RDM6300 frames into a PTY and watch the printed events.
|
whole chain. `tests/test_e2e_pty.py` writes real RDM6300 frames into a PTY and
|
||||||
|
asserts on the printed events; `tests/test_e2e_bridge.py` does the same and then
|
||||||
|
claims the tap over HTTP exactly as a web page would.
|
||||||
|
|
||||||
To hand-feed a single frame yourself:
|
To hand-feed a single frame yourself:
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,9 @@
|
|||||||
# rdm-card-reader
|
# rdm-card-reader
|
||||||
|
|
||||||
Read 125 kHz RFID cards (RDM6300 reader, EM4100 cards) on a serial port. Two
|
Read 125 kHz RFID cards (RDM6300 reader, EM4100 cards) on a serial port and
|
||||||
ways to use it:
|
print what happens: **card inserted** with the card data, then **card removed**.
|
||||||
|
|
||||||
* **print events** — card inserted with the card data, then card removed
|
Built for a Raspberry Pi workstation, but it runs anywhere with a serial port.
|
||||||
* **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
|
||||||
@@ -15,21 +11,6 @@ Built for a Raspberry Pi, 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
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -118,8 +99,7 @@ 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/serial0 # the Pi's on-board UART
|
rdm-card-reader --port /dev/ttyUSB0 # use a specific port
|
||||||
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
|
||||||
@@ -133,7 +113,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` | `2.0` | Seconds without a read before the card counts as removed. Also the presence/LED window |
|
| `--heartbeat` | `0.5` | Seconds without a read before the card counts as removed |
|
||||||
| `--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 |
|
||||||
@@ -143,24 +123,15 @@ 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`:
|
With `--serve`: `--host` (`127.0.0.1`), `--bridge-port` (`8765`), `--mode`
|
||||||
|
(`login`), `--tap-ttl` (`10`), `--allow-origin` (repeatable), `--token`,
|
||||||
|
`--allow-remote`, `--verbose`.
|
||||||
|
|
||||||
| Option | Default | Meaning |
|
When `--port` is omitted the ports are tried in this order, the same order the
|
||||||
| --- | --- | --- |
|
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/serial0 -> /dev/ttyUSB0 -> /dev/ttyACM0
|
/dev/ttyS0 -> /dev/ttyAMA0 -> /dev/ttyUSB0 -> /dev/ttyACM0
|
||||||
```
|
```
|
||||||
|
|
||||||
### Output modes
|
### Output modes
|
||||||
@@ -233,7 +204,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/serial0 --allow-origin https://your.web.app
|
rdm-card-reader --serve --port /dev/rdm-reader --allow-origin https://your.web.app
|
||||||
```
|
```
|
||||||
|
|
||||||
The bridge reports **card ids only**. Deciding which user a card belongs to is
|
The bridge reports **card ids only**. Deciding which user a card belongs to is
|
||||||
@@ -251,9 +222,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: {present: true, claimable: true, card: {id: ...}}
|
B-->>K: {claimable: true, card: {id: ...}}
|
||||||
K->>B: POST /api/card/claim
|
K->>B: POST /api/card/claim
|
||||||
B-->>K: {card: {id: ...}, consumed: true} (once, then 409)
|
B-->>K: {card: {id: ...}} (once)
|
||||||
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
|
||||||
@@ -263,52 +234,10 @@ 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 |
|
||||||
@@ -318,19 +247,13 @@ cleared, so it cannot be mistaken for a tap still waiting to be used.
|
|||||||
| `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, both TTLs — for monitoring |
|
| `GET` | `/api/health` | Reader state, port, uptime, tap counts — 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
|
||||||
@@ -348,15 +271,6 @@ 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
|
||||||
@@ -376,8 +290,7 @@ 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 | An unclaimed tap older than `--tap-ttl` is refused with `410`. |
|
| Expiry | A 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.
|
||||||
@@ -392,127 +305,19 @@ 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. The device defaults to `/dev/rdm-reader`; if the
|
then enables the service. Afterwards:
|
||||||
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
|
||||||
ss -ltnp | grep 8765 # confirms something is listening
|
curl http://127.0.0.1:8765/api/card # tap a card, then run this
|
||||||
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
|
||||||
@@ -533,16 +338,12 @@ 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 (USB adapters)
|
--port /dev/rdm-reader # udev alias
|
||||||
--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`.
|
||||||
@@ -553,41 +354,29 @@ 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** (physical pin **16**) by default —
|
An LED driven by card events, on **GPIO 23** by default — the same pin the
|
||||||
the same pin the workstation app used. Optional: everything works without it.
|
workstation app used.
|
||||||
|
|
||||||
By default the light **tracks the card**: lit while a card is on the reader,
|
|
||||||
dark otherwise.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
rdm-card-reader # lit only while a card is on the reader
|
rdm-card-reader # lit while running, blinks on a bad read
|
||||||
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` | **BCM** GPIO number — not the physical header pin |
|
| `--led-pin` | `23` | GPIO pin (matches the workstation app) |
|
||||||
| `--led-mode` | `presence` | See the two readings below |
|
| `--led-mode` | `error` | 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 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `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` (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" |
|
||||||
| `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" |
|
| `presence` | **Lit only while a card is on the reader** | The light should mean "a card is here" |
|
||||||
|
|
||||||
> 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.
|
||||||
|
|
||||||
@@ -604,11 +393,10 @@ 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 (the mode shown is
|
Startup reports which of the three states it is in:
|
||||||
`presence` by default):
|
|
||||||
|
|
||||||
```
|
```
|
||||||
LED on GPIO 23 (presence mode, gpiozero) # working
|
LED on GPIO 23 (error 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
|
||||||
```
|
```
|
||||||
@@ -617,87 +405,6 @@ 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.
|
||||||
|
|
||||||
@@ -710,29 +417,8 @@ it is never imported at runtime and would add 818 KB for nothing.
|
|||||||
|
|
||||||
## Wiring
|
## Wiring
|
||||||
|
|
||||||
The RDM6300 talks plain serial at **9600 baud, 8N1**.
|
The RDM6300 talks plain serial at **9600 baud, 8N1**. Connect it to the Pi's
|
||||||
|
UART (pins 8/10 for TXD/RXD, 5 V and GND) or through a USB-to-serial adapter.
|
||||||
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:
|
||||||
|
|
||||||
@@ -743,66 +429,9 @@ 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
|
||||||
@@ -849,28 +478,19 @@ 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** |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Check it works
|
## Development
|
||||||
|
|
||||||
With no RFID hardware attached, `--demo` exercises the formatting path and
|
|
||||||
`--list-ports` shows what serial ports exist:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
rdm-card-reader --demo # simulated reads, no hardware needed
|
pip install -e ".[dev]"
|
||||||
rdm-card-reader --list-ports # candidate and identified ports
|
pytest
|
||||||
```
|
```
|
||||||
|
|
||||||
To drive the real reader path with no hardware, feed RDM6300 frames into a
|
The suite needs no RFID hardware: the end-to-end tests open a pseudo-terminal,
|
||||||
pseudo-terminal — see the snippet in **[LAUNCH.md](LAUNCH.md)**.
|
write real RDM6300 frames into it and assert on the CLI output. `--demo` also
|
||||||
|
works with no hardware at all.
|
||||||
|
|
||||||
### Layout
|
### Layout
|
||||||
|
|
||||||
@@ -880,11 +500,12 @@ src/rdm_card_reader/
|
|||||||
reader.py serial port handling, event callbacks
|
reader.py serial port handling, event callbacks
|
||||||
cli.py argument parsing and program flow
|
cli.py argument parsing and program flow
|
||||||
demo.py simulated reads
|
demo.py simulated reads
|
||||||
ports.py stable serial port identification
|
tests/
|
||||||
bridge.py the HTTP bridge behind --serve
|
test_protocol.py RDM6300 wire format, byte for byte
|
||||||
led.py status LED
|
test_card.py event model and formatting
|
||||||
bootstrap.py offline dependency install
|
test_reader.py port selection, using a fake reader library
|
||||||
wheels/ the bundled wheels (offline install source)
|
test_cli.py exit codes and output modes
|
||||||
|
test_e2e_pty.py full stack over a pseudo-terminal
|
||||||
```
|
```
|
||||||
|
|
||||||
### Using it as a library
|
### Using it as a library
|
||||||
|
|||||||
+10
-111
@@ -10,19 +10,10 @@
|
|||||||
# 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=()
|
||||||
@@ -30,10 +21,6 @@ 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
|
||||||
@@ -42,12 +29,8 @@ 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,33p' "$0"; exit 0 ;;
|
-h|--help) sed -n '2,20p' "$0"; exit 0 ;;
|
||||||
*) echo "unknown option: $1" >&2; exit 2 ;;
|
*) echo "unknown option: $1" >&2; exit 2 ;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
@@ -77,21 +60,14 @@ log "Installing to $INSTALL_DIR"
|
|||||||
mkdir -p "$INSTALL_DIR"
|
mkdir -p "$INSTALL_DIR"
|
||||||
|
|
||||||
# Copy the source tree (excluding local build/test artifacts).
|
# Copy the source tree (excluding local build/test artifacts).
|
||||||
for item in src pyproject.toml README.md LAUNCH.md LICENSE deploy; do
|
for item in src tests pyproject.toml README.md LAUNCH.md LICENSE deploy; do
|
||||||
if [[ -e "$SOURCE_DIR/$item" ]]; then
|
if [[ -e "$SOURCE_DIR/$item" ]]; then
|
||||||
cp -a "$SOURCE_DIR/$item" "$INSTALL_DIR/"
|
cp -a "$SOURCE_DIR/$item" "$INSTALL_DIR/"
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
log "Building the virtual environment"
|
log "Building the virtual environment"
|
||||||
# --system-site-packages is what makes the status LED work. gpiozero is pure
|
if ! python3 -m venv "$INSTALL_DIR/.venv"; then
|
||||||
# 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"
|
||||||
@@ -196,17 +172,6 @@ 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
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -217,49 +182,15 @@ udevadm control --reload-rules
|
|||||||
udevadm trigger --subsystem-match=tty || true
|
udevadm trigger --subsystem-match=tty || true
|
||||||
sleep 1
|
sleep 1
|
||||||
|
|
||||||
if [[ "$SERIAL_DEVICE" == "/dev/rdm-reader" && ! -e /dev/rdm-reader ]]; then
|
if [[ -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 USB adapter may not be plugged in, or it uses a different chip."
|
warn "The reader 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
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -273,12 +204,10 @@ 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" \
|
python3 - "$UNIT" "$INSTALL_DIR" "$BRIDGE_PORT" "$ORIGIN_ARGS" "$SERVICE_USER" <<'PY'
|
||||||
"$SERIAL_DEVICE" "$LED_PIN" "$LED_MODE" "$HEARTBEAT" <<'PY'
|
|
||||||
import os, re, sys
|
import os, re, sys
|
||||||
|
|
||||||
(unit, install_dir, port, origin_args, user, device, led_pin, led_mode,
|
unit, install_dir, port, origin_args, user = sys.argv[1:6]
|
||||||
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
|
||||||
@@ -290,38 +219,11 @@ if not os.path.exists(wrapper):
|
|||||||
|
|
||||||
command = (
|
command = (
|
||||||
f"{wrapper} --serve "
|
f"{wrapper} --serve "
|
||||||
f"--port {device} --bridge-port {port} --mode login "
|
f"--port /dev/rdm-reader --bridge-port {port} --mode login{origin_args}"
|
||||||
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
|
||||||
|
|
||||||
@@ -347,9 +249,6 @@ 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
|
||||||
|
|||||||
@@ -2,10 +2,8 @@
|
|||||||
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. install.sh rewrites this line for the device given
|
# wrong tty or none at all.
|
||||||
# with --device, since only it knows whether the reader is on the on-board UART
|
After=dev-serial.device network-online.target
|
||||||
# (/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]
|
||||||
@@ -19,29 +17,22 @@ User=rdmcard
|
|||||||
Group=dialout
|
Group=dialout
|
||||||
WorkingDirectory=/opt/rdm-card-reader
|
WorkingDirectory=/opt/rdm-card-reader
|
||||||
|
|
||||||
# --serve runs the HTTP bridge, which is what makes the reader available as an
|
# --serve exposes the card on http://127.0.0.1:8765/api/card
|
||||||
# API on http://127.0.0.1:8765. --port points at the device the reader is
|
# --port points at the udev alias, so the reader is the same device every boot.
|
||||||
# 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/serial0 \
|
--port /dev/rdm-reader \
|
||||||
--mode login \
|
--mode login \
|
||||||
--led-pin 23 \
|
--allow-origin https://your.web.app
|
||||||
--led-mode presence \
|
|
||||||
--heartbeat 2.0
|
|
||||||
Restart=always
|
Restart=always
|
||||||
RestartSec=3
|
RestartSec=3
|
||||||
|
|
||||||
# The bridge reads the serial port, drives the status LED and listens on
|
# The bridge only needs to read the serial port and listen on loopback.
|
||||||
# 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
|
||||||
@@ -53,9 +44,7 @@ RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
|
|||||||
RestrictNamespaces=true
|
RestrictNamespaces=true
|
||||||
LockPersonality=true
|
LockPersonality=true
|
||||||
MemoryDenyWriteExecute=true
|
MemoryDenyWriteExecute=true
|
||||||
DeviceAllow=/dev/serial0 rw
|
DeviceAllow=/dev/rdm-reader 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
|
||||||
|
|||||||
+6
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "rdm-card-reader"
|
name = "rdm-card-reader"
|
||||||
version = "0.4.0"
|
version = "0.3.0"
|
||||||
description = "RDM6300 / EM4100 RFID card reader: prints card events, or serves them over HTTP so a web app can authenticate a card tap."
|
description = "RDM6300 / EM4100 RFID card reader: prints card events, or serves them over HTTP so a web app can authenticate a card tap."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.8"
|
requires-python = ">=3.8"
|
||||||
@@ -24,6 +24,7 @@ dependencies = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
|
dev = ["pytest>=7.0"]
|
||||||
# Status LED support. Installed automatically on first use by the app, or
|
# Status LED support. Installed automatically on first use by the app, or
|
||||||
# explicitly with: pip install '.[led]'
|
# explicitly with: pip install '.[led]'
|
||||||
led = ["gpiozero>=2.0", "colorzero>=2.0"]
|
led = ["gpiozero>=2.0", "colorzero>=2.0"]
|
||||||
@@ -36,3 +37,7 @@ where = ["src"]
|
|||||||
|
|
||||||
[tool.setuptools.package-data]
|
[tool.setuptools.package-data]
|
||||||
rdm_card_reader = ["py.typed", "wheels/*.whl", "wheels/SHA256SUMS"]
|
rdm_card_reader = ["py.typed", "wheels/*.whl", "wheels/SHA256SUMS"]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["tests"]
|
||||||
|
addopts = "-q"
|
||||||
|
|||||||
@@ -30,12 +30,7 @@ from .ports import (
|
|||||||
discover,
|
discover,
|
||||||
resolve,
|
resolve,
|
||||||
)
|
)
|
||||||
from .led import (
|
from .led import DEFAULT_LED_PIN, StatusLED, create as create_led
|
||||||
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"
|
||||||
@@ -46,7 +41,6 @@ __all__ = [
|
|||||||
"CardData",
|
"CardData",
|
||||||
"CardEvent",
|
"CardEvent",
|
||||||
"AmbiguousPort",
|
"AmbiguousPort",
|
||||||
"DEFAULT_LED_MODE",
|
|
||||||
"DEFAULT_LED_PIN",
|
"DEFAULT_LED_PIN",
|
||||||
"EventHub",
|
"EventHub",
|
||||||
"PortError",
|
"PortError",
|
||||||
|
|||||||
@@ -75,12 +75,6 @@ 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.
|
||||||
@@ -102,7 +96,6 @@ 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"] = []
|
||||||
@@ -114,7 +107,6 @@ 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.
|
||||||
@@ -185,16 +177,8 @@ 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,
|
||||||
@@ -216,32 +200,6 @@ 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.
|
||||||
|
|
||||||
@@ -249,7 +207,6 @@ 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
|
||||||
|
|
||||||
@@ -288,7 +245,6 @@ 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
|
||||||
)
|
)
|
||||||
@@ -304,7 +260,6 @@ 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]]:
|
||||||
|
|||||||
@@ -18,13 +18,10 @@ 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. ``/dev/serial0`` is the
|
#: default, the others cover USB / GPIO UART dongles.
|
||||||
#: 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",
|
||||||
)
|
)
|
||||||
@@ -35,15 +32,7 @@ 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
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ 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,
|
||||||
@@ -28,12 +27,7 @@ from .card import (
|
|||||||
format_event,
|
format_event,
|
||||||
)
|
)
|
||||||
from .demo import run_demo
|
from .demo import run_demo
|
||||||
from .led import (
|
from .led import DEFAULT_LED_PIN, MODES as LED_MODES, create as create_led
|
||||||
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
|
||||||
|
|
||||||
@@ -175,8 +169,7 @@ 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)
|
||||||
@@ -292,10 +285,6 @@ 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",
|
||||||
@@ -312,10 +301,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=DEFAULT_LED_MODE,
|
led.add_argument("--led-mode", choices=list(LED_MODES), default=LED_MODES[0],
|
||||||
help="'presence' (default): lit only while a card is on the "
|
help="'error' (default): lit whenever the reader is running, "
|
||||||
"reader; 'error': lit whenever the reader is running, "
|
"blinking means a bad read; 'presence': lit only while "
|
||||||
"blinking means a bad read")
|
"a card is on the reader")
|
||||||
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
|
||||||
|
|||||||
@@ -1,339 +0,0 @@
|
|||||||
"""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)
|
|
||||||
@@ -34,20 +34,15 @@ 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"
|
||||||
|
|
||||||
MODES = (PRESENCE, ERROR)
|
#: 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".
|
||||||
|
PRESENCE = "presence"
|
||||||
|
|
||||||
#: What the light means unless told otherwise.
|
MODES = (ERROR, PRESENCE)
|
||||||
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)
|
||||||
@@ -64,7 +59,8 @@ class LEDStatus:
|
|||||||
state: bool = False
|
state: bool = False
|
||||||
reason: str = ""
|
reason: str = ""
|
||||||
|
|
||||||
def to_dict(self) -> dict: return {
|
def to_dict(self) -> dict:
|
||||||
|
return {
|
||||||
"enabled": self.enabled,
|
"enabled": self.enabled,
|
||||||
"pin": self.pin,
|
"pin": self.pin,
|
||||||
"backend": self.backend,
|
"backend": self.backend,
|
||||||
@@ -162,13 +158,13 @@ class StatusLED:
|
|||||||
self,
|
self,
|
||||||
pin: int = DEFAULT_LED_PIN,
|
pin: int = DEFAULT_LED_PIN,
|
||||||
enabled: bool = True,
|
enabled: bool = True,
|
||||||
mode: str = DEFAULT_LED_MODE,
|
mode: str = ERROR,
|
||||||
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 DEFAULT_LED_MODE
|
self._mode = mode if mode in MODES else ERROR
|
||||||
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
|
||||||
@@ -309,7 +305,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 = DEFAULT_LED_MODE,
|
mode: str = ERROR,
|
||||||
error_blink: bool = True,
|
error_blink: bool = True,
|
||||||
logger: Optional[Callable[[str], None]] = None,
|
logger: Optional[Callable[[str], None]] = None,
|
||||||
) -> StatusLED:
|
) -> StatusLED:
|
||||||
|
|||||||
Reference in New Issue
Block a user