diff --git a/README.md b/README.md index 8ed3192..afb7e8e 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,13 @@ # rdm-card-reader -Read 125 kHz RFID cards (RDM6300 reader, EM4100 cards) on a serial port and -print what happens: **card inserted** with the card data, then **card removed**. +Read 125 kHz RFID cards (RDM6300 reader, EM4100 cards) on a serial port. Two +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)** > — 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 ``` @@ -99,7 +118,8 @@ wheels on first start. ```bash 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 --heartbeat 1.5 # slower card-removed detection 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` | | `--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 | | `--list-ports` | – | List candidate and identified ports, then exit | | `--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 | | `--version` | – | Print the version | -With `--serve`: `--host` (`127.0.0.1`), `--bridge-port` (`8765`), `--mode` -(`login`), `--tap-ttl` (`10`), `--allow-origin` (repeatable), `--token`, -`--allow-remote`, `--verbose`. +With `--serve`: -When `--port` is omitted the ports are tried in this order, the same order the -workstation app uses: +| Option | Default | Meaning | +| --- | --- | --- | +| `--host` | `127.0.0.1` | Bind address; anything else needs `--allow-remote` | +| `--bridge-port` | `8765` | HTTP port | +| `--mode` | `login` | `login` publishes taps only; `monitor` also publishes removals | +| `--tap-ttl` | `10` | Seconds an unclaimed tap stays claimable; `0` keeps it until replaced. Keep it ≥ `--heartbeat` | +| `--claimed-ttl` | `30` | Seconds a claimed card stays readable; `0` keeps it indefinitely | +| `--allow-origin` | – | Permit this browser origin (repeatable) | +| `--token` | – | Pre-shared token for non-local callers (or `RDM_BRIDGE_TOKEN`) | +| `--allow-remote` | off | Permit binding a non-loopback address | +| `--verbose` | off | Also print each event and each HTTP request | + +When `--port` is omitted the ports are tried in this order: ``` -/dev/ttyS0 -> /dev/ttyAMA0 -> /dev/ttyUSB0 -> /dev/ttyACM0 +/dev/ttyS0 -> /dev/ttyAMA0 -> /dev/serial0 -> /dev/ttyUSB0 -> /dev/ttyACM0 ``` ### 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. ```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 @@ -222,9 +251,9 @@ sequenceDiagram C->>B: tap B->>B: latch the tap (tap-ttl seconds) 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 - B-->>K: {card: {id: ...}} (once) + B-->>K: {card: {id: ...}, consumed: true} (once, then 409) K->>W: POST /login {card_id} W->>W: card_id -> user 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 therefore cannot be lost because the page happened to poll a moment later. +`present` and `card` answer different questions, which is the part that +surprises people: + +| Field | Meaning | +| --- | --- | +| `present` | the card is physically on the reader **right now** — goes `false` the moment it is lifted | +| `card` | the **latched tap** — survives the card being lifted, so you still know which card it was | + +So a card that was tapped and lifted a second ago reports `present: false` with +`card` still populated. That is correct, not a bug: if the id vanished with the +card, any poll arriving after the lift could not authenticate anything. + The point of *claim* is single use. Once a tap is spent, a page reload or a second tab gets `409 Conflict` instead of a second login, so one physical tap can never authenticate two sessions. +Two independent windows govern how long the card stays readable: + +| Window | Flag | Default | Governs | +| --- | --- | --- | --- | +| Unclaimed | `--tap-ttl` | 10 s | how long a tap can still be spent on a login | +| Claimed | `--claimed-ttl` | 30 s | how long a claimed card stays readable, so the page that claimed it can confirm which card logged in | + +Put together, `/api/card` moves through these states: + +| Moment | `present` | `card` | `claimable` | `consumed` | +| --- | --- | --- | --- | --- | +| Card held on the reader | `true` | id | `true` | `false` | +| Lifted, within `--tap-ttl` | `false` | id | `true` | `false` | +| Claimed, within `--claimed-ttl` | `false` | id | `false` | `true` | +| After the claimed window | `false` | `null` | `false` | `false` | +| Unclaimed and past `--tap-ttl` | `false` | `null` | `false` | `false` | + +The last two rows are the point: an id that can no longer be acted on is +cleared, so it cannot be mistaken for a tap still waiting to be used. +`/api/last` and `/api/taps` keep the history either way. Setting either value to +`0` disables that expiry. + +> **Set `--tap-ttl` at least as long as `--heartbeat`, or longer.** +> `--tap-ttl` applies to the tap, `--heartbeat` to presence, and they are +> independent. If the tap expires *before* the heartbeat declares the card +> removed, a card still sitting on the reader has its id cleared and reads as +> `"card": null` until the next re-read — which on a reader that reports once +> per placement may be a long time. Matching them (`--heartbeat 10 --tap-ttl 10`) +> avoids the gap for a card held in place. + ### Endpoints | 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/last` | Most recent card event, or `204` | | `GET` | `/api/taps` | Recent tap history (`?limit=N`) | -| `GET` | `/api/health` | Reader state, port, uptime, tap counts — for monitoring | +| `GET` | `/api/health` | Reader state, port, uptime, tap counts, both TTLs — for monitoring | | `POST` | `/api/mode` | `{"mode": "login"}` or `{"mode": "monitor"}` | `login` mode (default) publishes taps only — a tap is an authentication attempt. `monitor` mode also publishes removals, which is what you want for watching a reader rather than logging people in. +`/api/health` is the liveness check: `status: "ok"` with a rising +`uptime_seconds` means the service is serving. It is answered **before** the +origin and token checks, so it stays readable by any client that can reach the +port — which is the intent on loopback, but means binding with `--allow-remote` +exposes the port, mode, uptime and tap counts to the network. + ### Browser example ```js @@ -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: ```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. | | Token | `--token` (or `RDM_BRIDGE_TOKEN`) is required from non-loopback callers. | | Single use | A tap can only ever produce one successful claim. | -| Expiry | A tap older than `--tap-ttl` is refused with `410`. | +| Expiry | An unclaimed tap older than `--tap-ttl` is refused with `410`. | +| Bounded retention | A claimed tap stays readable for `--claimed-ttl` (30 s), then the id is cleared — an id that can no longer be acted on is never left in the response. | Add a second factor — a PIN after the tap — wherever a cloned card would actually cost you something. @@ -305,19 +392,127 @@ actually cost you something. ## 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 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, -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 --bridge-port 8765 --mode login \ + --led-pin --led-mode --heartbeat +``` + +`WorkingDirectory=/opt/rdm-card-reader`, so this is equivalent to running +`.venv/bin/python -m rdm_card_reader --port ` 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 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 -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 `/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: ```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 AB0XYZ12 # by serial number --port 1a86:7523 # by chip ``` +Note the two are wired differently and the udev alias only exists for one of +them — see **UART or USB?** below. + `--autodetect` picks the only USB adapter, and **refuses** when there is more than one — silently choosing between two readers is exactly the failure this avoids. Verify the wiring in `deploy/99-rdm-reader.rules`. @@ -354,29 +553,41 @@ Exit codes: `0` ok, `1` no port, `2` library missing, `3` port ambiguous. ## Status LED -An LED driven by card events, on **GPIO 23** by default — the same pin the -workstation app used. +An LED driven by card events, on **GPIO 23** (physical pin **16**) by default — +the same pin the workstation app used. Optional: everything works without it. + +By default the light **tracks the card**: lit while a card is on the reader, +dark otherwise. ```bash -rdm-card-reader # lit 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-mode presence # lit only while a card is on the reader rdm-card-reader --no-led # no LED at all ``` | Flag | Default | Meaning | | --- | --- | --- | | `--no-led` | off | Do not drive a LED | -| `--led-pin` | `23` | GPIO pin (matches the workstation app) | -| `--led-mode` | `error` | See the two readings below | +| `--led-pin` | `23` | **BCM** GPIO number — not the physical header pin | +| `--led-mode` | `presence` | See the two readings below | | `--led-no-error-blink` | off | Do not blink on a bad read | +`--led-pin` takes the **BCM GPIO number**. The two are easy to confuse, and it +is the second most common reason a LED "does not work" — the default `23` is +**physical pin 16**, not pin 23. The full mapping is under **Wiring** below. + Two modes, because the light can mean two different things: | Mode | Behaviour | Use when | | --- | --- | --- | -| `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` | **Lit only while a card is on the reader** | The light should mean "a card is here" | +| `presence` (default) | **Lit only while a card is on the reader**, dark otherwise | The light should mean "a card is here" — the common choice, and unambiguous | +| `error` | **Lit whenever the reader is running.** Dark while a card is being read, blinks on a bad read | The light should mean "the reader is alive and ready" | + +> In `presence` mode the light is **dark at startup** and lights on the first +> valid card read, then goes dark when the card is lifted. A dark LED on a +> freshly started service is therefore correct, not a fault. Check that a card +> read lights it before concluding the LED is broken. Wiring: LED anode to the GPIO pin through a ~330 Ω resistor, cathode to GND. @@ -393,10 +604,11 @@ point: refusing to run. * **Never crashes on a dead pin.** A failed toggle is logged and ignored. -Startup reports which of the three states it is in: +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: 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 provider — which would send you to the wrong fix. +### The reader works but the light stays dark + +Because a LED failure is never fatal, a misconfigured LED looks exactly like a +working reader with no light. Check the reason the app printed: + +```bash +journalctl -u rdm-card-reader -n 40 --no-pager | grep -i led +``` + +| Message | Cause | Fix | +| --- | --- | --- | +| `LED unavailable: no usable GPIO on pin 23: ...` | gpiozero cannot reach a pin backend, or systemd/group permission is missing | See the three checks below | +| `LED unavailable: gpiozero not installed: ...` | LED extras missing from the venv | Install from the bundled wheels (below) | +| `LED on GPIO 23 (presence mode, gpiozero)` | LED is initialised — suspect wiring, or simply no card read yet | See the wiring note below | + +**Check 1 — the venv must see the system pin backend.** `gpiozero` is pure +Python, but the library it uses to talk to the pins (`lgpio` on Bookworm, +`RPi.GPIO` on older releases) is an **apt package in the system Python**. A +plain venv cannot see it, so gpiozero raises `BadPinFactory` and the app +quietly falls back to a no-op LED. This is why `install.sh` creates the venv +with `--system-site-packages`; if you built the venv by hand, that is the most +likely cause: + +```bash +grep system-site-packages /opt/rdm-card-reader/.venv/pyvenv.cfg # want: true +/opt/rdm-card-reader/.venv/bin/python -c 'import lgpio; print("lgpio OK")' +``` + +If the first line is missing or the import fails, install the backend and +rebuild the venv: + +```bash +sudo apt-get install -y python3-lgpio # Bookworm; python3-rpi.gpio on older +sudo ./deploy/install.sh --device /dev/serial0 # recreates the venv with the flag +``` + +**Check 2 — systemd device policy.** The unit sets `DevicePolicy=closed`, so +only devices named in `DeviceAllow=` are reachable. It must list the GPIO chip +as well as the serial port: + +``` +DeviceAllow=/dev/serial0 rw +DeviceAllow=/dev/gpiochip0 rw +DeviceAllow=/dev/gpiochip4 rw +``` + +`gpiochip0` is the header on Pi 1–4, `gpiochip4` on Pi 5. A missing entry is +denied by systemd before any group permission is even consulted. Confirm what +the service actually runs with +`systemctl cat rdm-card-reader | grep DeviceAllow`. + +**Check 3 — the `gpio` group.** On modern Raspberry Pi OS (Bookworm onward) +`gpiozero` drives GPIO through `lgpio`, which opens `/dev/gpiochip0` — owned +`root:gpio`, mode `0660`. The service account must be in `gpio`: + +```bash +sudo usermod -aG gpio rdmcard +``` + +Older releases (Bullseye, `RPi.GPIO`) use `/dev/gpiomem` and need no extra +group. + +After any of these changes: `sudo systemctl daemon-reload` (systemd caches unit +files) and `sudo systemctl restart rdm-card-reader`. + +**If the LED extras are missing**, install them offline from the bundled wheels +— a Pi with no internet cannot reach PyPI: + +```bash +/opt/rdm-card-reader/.venv/bin/pip install --no-index \ + --find-links /opt/rdm-card-reader/src/rdm_card_reader/wheels \ + --no-deps gpiozero colorzero +``` + +**If the journal says the LED is on GPIO 23**, the software side is fine. +Tap a card: in the default `presence` mode the light comes on while the card is +held and goes dark when it is lifted. If it stays dark with a card on the +reader, the problem is electrical — see the pin numbering note under +**Wiring**. GPIO 23 is physical pin **16**, and the anode needs a series +resistor to the pin with the cathode to GND. + > **If the workstation app also runs on this device, use a different pin** — two > processes driving GPIO 23 will fight, and the LED will flicker. @@ -417,8 +710,29 @@ it is never imported at runtime and would add 818 KB for nothing. ## Wiring -The RDM6300 talks plain serial at **9600 baud, 8N1**. Connect it to the Pi's -UART (pins 8/10 for TXD/RXD, 5 V and GND) or through a USB-to-serial adapter. +The RDM6300 talks plain serial at **9600 baud, 8N1**. + +A typical connection to the Pi's 40-pin header, using the on-board UART: + +| RDM6300 | Pi pin | Function | +| --- | --- | --- | +| VCC (5 V) | 4 | power | +| GND | 6 | ground (must be common) | +| TX | **10** (GPIO 15, RXD) | card data → Pi | +| RX | 8 (GPIO 14, TXD) | not used — the reader only transmits | + +The reader is **transmit-only**: it emits frames continuously and never reads, +so the Pi's TX line (pin 8) has nothing to send. Only pin 10 carries data. + +> **Level warning.** The RDM6300's TX output is **5 V TTL**, while GPIO 15 is a +> **3.3 V** input. Feeding 5 V straight in often appears to work but stresses +> the pin. Use a divider (e.g. 1 kΩ in series, 2 kΩ to GND) or a level shifter +> on the reader-TX → pin-10 line. This is not a cause of "no cards read" if you +> already see data, but it is worth fixing before relying on the device. + +For the status LED: **anode** → GPIO pin through a ~330 Ω resistor, +**cathode** → GND. See the pin numbering table below, and note the LED is +optional — the reader works without it. On a Raspberry Pi you usually need to free up the primary UART first: @@ -429,9 +743,66 @@ sudo raspi-config # Interface Options -> Serial Port 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 `/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 @@ -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 | | `INVALID CARD` repeatedly | Noisy supply or long/unshielded wiring; shorten it | | Every card reported twice | Lower `--heartbeat` if the card is lifted quickly | +| Card reported as **removed while still on the reader** | Raise `--heartbeat` — the reader reports a card once per placement, so a short window expires while the card sits still | +| LED blinks on and off instead of staying lit | Same cause: the LED follows `present`. Raise `--heartbeat` | +| Service `exit 1`, `no port matched '/dev/rdm-reader'` | Reader is on the UART — re-install with `--device /dev/serial0` | +| Service `exit 1`, `Could not open /dev/serial0` | UART held by the serial console, or the user is not in `dialout` | +| Service stuck in `failed`, restarts refused | `sudo systemctl reset-failed rdm-card-reader` | +| `curl` returns nothing, `Expecting value: line 1 column 1` | Nothing is listening — the service is not running; check `ss -ltnp \| grep 8765` | +| Reader works but the LED is dark | See **The reader works but the light stays dark** | --- diff --git a/deploy/install.sh b/deploy/install.sh index fc4c68b..73ffc96 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -10,10 +10,19 @@ # Options: # --origin URL web app origin allowed to read the API (repeatable) # --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) # --user NAME service account (default rdmcard) # --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 ORIGINS=() @@ -21,6 +30,10 @@ BRIDGE_PORT=8765 INSTALL_DIR=/opt/rdm-card-reader SERVICE_USER=rdmcard INSTALL_SERVICE=1 +SERIAL_DEVICE=/dev/serial0 +LED_PIN=23 +LED_MODE=presence +HEARTBEAT=2.0 SOURCE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" while [[ $# -gt 0 ]]; do @@ -29,8 +42,12 @@ while [[ $# -gt 0 ]]; do --port) BRIDGE_PORT="$2"; shift 2 ;; --dir) INSTALL_DIR="$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 ;; - -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 ;; esac done @@ -67,7 +84,14 @@ for item in src pyproject.toml README.md LAUNCH.md LICENSE deploy; do done 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 "On Debian/Raspberry Pi OS this usually means python3-venv is missing:" warn " sudo apt-get install python3-venv" @@ -172,6 +196,17 @@ fi usermod -aG dialout "$SERVICE_USER" 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 # --------------------------------------------------------------------------- @@ -182,15 +217,49 @@ udevadm control --reload-rules udevadm trigger --subsystem-match=tty || true sleep 1 -if [[ -e /dev/rdm-reader ]]; then - echo " /dev/rdm-reader -> $(readlink -f /dev/rdm-reader)" -else +if [[ "$SERIAL_DEVICE" == "/dev/rdm-reader" && ! -e /dev/rdm-reader ]]; then 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 "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 +# 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 # --------------------------------------------------------------------------- @@ -204,10 +273,12 @@ for origin in "${ORIGINS[@]:-}"; do [[ -n "$origin" ]] && ORIGIN_ARGS+=" --allow-origin $origin" 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 -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() # 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 = ( 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. 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) +# 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) PY @@ -249,6 +347,9 @@ cat < 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): """Atomically claim the latched tap for one authentication. @@ -207,6 +249,7 @@ class EventHub: this call succeeds, so two concurrent requests cannot both win. """ with self._lock: + self._drop_dead_tap() if self._tap is None: return None, "no card read yet", 409 @@ -245,6 +288,7 @@ class EventHub: def health(self) -> Dict[str, Any]: with self._lock: + self._drop_dead_tap() last_age = ( 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_consumed": self._consumed_at is not None, "tap_ttl_seconds": self._tap_ttl or None, + "claimed_ttl_seconds": self._claimed_ttl or None, } def last(self) -> Optional[Dict[str, Any]]: diff --git a/src/rdm_card_reader/card.py b/src/rdm_card_reader/card.py index 437777f..562d1fe 100644 --- a/src/rdm_card_reader/card.py +++ b/src/rdm_card_reader/card.py @@ -18,10 +18,13 @@ except ImportError: # pragma: no cover - fallback so the model stays importable CardData = namedtuple("CardData", ["value", "checksum", "type", "is_valid"]) #: 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 = ( "/dev/ttyS0", "/dev/ttyAMA0", + "/dev/serial0", "/dev/ttyUSB0", "/dev/ttyACM0", ) @@ -32,7 +35,15 @@ CONFIG_CARD_VALUE = 12886709 #: Seconds without a read before a card is considered removed. Set to ``None`` #: 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 diff --git a/src/rdm_card_reader/cli.py b/src/rdm_card_reader/cli.py index 4cec201..d2b8763 100644 --- a/src/rdm_card_reader/cli.py +++ b/src/rdm_card_reader/cli.py @@ -10,6 +10,7 @@ from typing import Optional, Sequence from . import __version__ from .bootstrap import LED_PACKAGES, ensure_dependencies from .bridge import ( + DEFAULT_CLAIMED_TTL, DEFAULT_HOST, DEFAULT_PORT, DEFAULT_TAP_TTL, @@ -27,7 +28,12 @@ from .card import ( format_event, ) 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 .reader import available, explain_error, list_ports, open_reader, reload_library @@ -169,7 +175,8 @@ def _run_bridge(args) -> int: 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) led = _build_led(args) @@ -285,6 +292,10 @@ def build_parser() -> argparse.ArgumentParser: bridge.add_argument("--tap-ttl", type=float, default=DEFAULT_TAP_TTL, help="seconds an unclaimed tap stays claimable; 0 keeps it " "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", help="permit this browser origin to call the API; repeatable") bridge.add_argument("--token", @@ -301,10 +312,10 @@ def build_parser() -> argparse.ArgumentParser: led.add_argument("--led-pin", type=int, default=DEFAULT_LED_PIN, help="GPIO pin of the status LED (default: %(default)s, " "matching the workstation app)") - led.add_argument("--led-mode", choices=list(LED_MODES), default=LED_MODES[0], - help="'error' (default): lit whenever the reader is running, " - "blinking means a bad read; 'presence': lit only while " - "a card is on the reader") + led.add_argument("--led-mode", choices=list(LED_MODES), default=DEFAULT_LED_MODE, + help="'presence' (default): lit only while a card is on the " + "reader; 'error': lit whenever the reader is running, " + "blinking means a bad read") led.add_argument("--led-no-error-blink", action="store_true", help="do not blink the LED on a bad read") return parser diff --git a/src/rdm_card_reader/keyboard.py b/src/rdm_card_reader/keyboard.py new file mode 100644 index 0000000..38403c5 --- /dev/null +++ b/src/rdm_card_reader/keyboard.py @@ -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) diff --git a/src/rdm_card_reader/led.py b/src/rdm_card_reader/led.py index 94fa84a..9eba6aa 100644 --- a/src/rdm_card_reader/led.py +++ b/src/rdm_card_reader/led.py @@ -34,15 +34,20 @@ OFF = "off" ON = "on" 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 #: indicator: light on means the reader is alive and ready for a card. ERROR = "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" +MODES = (PRESENCE, ERROR) -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. _VALID_MODES = (OFF, ON, BLINK) @@ -59,8 +64,7 @@ class LEDStatus: state: bool = False reason: str = "" - def to_dict(self) -> dict: - return { + def to_dict(self) -> dict: return { "enabled": self.enabled, "pin": self.pin, "backend": self.backend, @@ -158,13 +162,13 @@ class StatusLED: self, pin: int = DEFAULT_LED_PIN, enabled: bool = True, - mode: str = ERROR, + mode: str = DEFAULT_LED_MODE, active_high: bool = True, error_blink: bool = True, logger: Optional[Callable[[str], None]] = 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._enabled = enabled self._blinker: Optional[_Blinker] = None @@ -305,7 +309,7 @@ class StatusLED: def create( enabled: bool = True, pin: int = DEFAULT_LED_PIN, - mode: str = ERROR, + mode: str = DEFAULT_LED_MODE, error_blink: bool = True, logger: Optional[Callable[[str], None]] = None, ) -> StatusLED: