Compare commits
8 Commits
c720f4ca67
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 84569ec9ae | |||
| 74c9e0f8fa | |||
| c2b3eda649 | |||
| a399542a6e | |||
| b357b5755a | |||
| 04f0ab9efa | |||
| bbd2861897 | |||
| 882b89d6da |
@@ -7,3 +7,7 @@ dist/
|
|||||||
venv/
|
venv/
|
||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
|
|
||||||
|
# Release build outputs: generated from the source tree, not source
|
||||||
|
rdm-card-reader-release/
|
||||||
|
rdm-card-reader-release-*.zip
|
||||||
|
|||||||
Binary file not shown.
@@ -21,7 +21,7 @@ Built for a Raspberry Pi, but it runs anywhere with a serial port.
|
|||||||
- [Install](#install)
|
- [Install](#install)
|
||||||
- [Usage](#usage)
|
- [Usage](#usage)
|
||||||
- [Serving card data to a web app (`--serve`)](#serving-card-data-to-a-web-app---serve)
|
- [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)
|
- [Quick start on a Raspberry Pi](#quick-start-on-a-raspberry-pi)
|
||||||
- [Status LED](#status-led)
|
- [Status LED](#status-led)
|
||||||
- [Wiring](#wiring)
|
- [Wiring](#wiring)
|
||||||
- [The protocol](#the-protocol)
|
- [The protocol](#the-protocol)
|
||||||
@@ -309,10 +309,45 @@ cleared, so it cannot be mistaken for a tap still waiting to be used.
|
|||||||
> per placement may be a long time. Matching them (`--heartbeat 10 --tap-ttl 10`)
|
> per placement may be a long time. Matching them (`--heartbeat 10 --tap-ttl 10`)
|
||||||
> avoids the gap for a card held in place.
|
> avoids the gap for a card held in place.
|
||||||
|
|
||||||
|
### Status page
|
||||||
|
|
||||||
|
Serving also puts a **status page** at `GET /` on the same port, so you can see
|
||||||
|
what the reader is doing from a browser:
|
||||||
|
|
||||||
|
```
|
||||||
|
http://127.0.0.1:8765/
|
||||||
|
```
|
||||||
|
|
||||||
|
It shows the reader's mode, port, uptime, whether a card is present and
|
||||||
|
claimable, the current card, the event counters, and a live event log. The log
|
||||||
|
is seeded with recent history from `/api/taps` and then updated live over the
|
||||||
|
`/api/events` SSE stream, so it keeps filling in without a refresh.
|
||||||
|
|
||||||
|
The page is **read-only**: it deliberately has no "claim" button, because
|
||||||
|
`POST /api/card/claim` spends the tap, and a page that claimed would silently
|
||||||
|
consume a real login just for being open. Claim from your own app instead.
|
||||||
|
|
||||||
|
Disable it with `--no-page` if you want the port to serve the API only.
|
||||||
|
|
||||||
|
The page includes an **LED panel** with buttons to switch either LED on or off
|
||||||
|
by hand, for the case where the light does not come on and you need to know
|
||||||
|
whether the software can drive the pin at all. A forced state is held until you
|
||||||
|
press *hand back to card events*, so card events do not immediately undo it.
|
||||||
|
The same is reachable without a browser:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sS http://127.0.0.1:8765/api/led # read the state
|
||||||
|
curl -sS -X POST -H 'Content-Type: application/json' \
|
||||||
|
-d '{"led":"status","on":true}' http://127.0.0.1:8765/api/led
|
||||||
|
curl -sS -X POST -H 'Content-Type: application/json' \
|
||||||
|
-d '{"release":true}' http://127.0.0.1:8765/api/led
|
||||||
|
```
|
||||||
|
|
||||||
### Endpoints
|
### Endpoints
|
||||||
|
|
||||||
| Method | Path | Purpose |
|
| Method | Path | Purpose |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
|
| `GET` | `/` | Status page (unless `--no-page`) |
|
||||||
| `GET` | `/api/card` | The latched tap: `present`, `claimable`, `consumed`, `age_seconds`, `card` |
|
| `GET` | `/api/card` | The latched tap: `present`, `claimable`, `consumed`, `age_seconds`, `card` |
|
||||||
| `POST` | `/api/card/claim` | Spend the tap on one login. `200`, or `409` if used/none, `410` if expired |
|
| `POST` | `/api/card/claim` | Spend the tap on one login. `200`, or `409` if used/none, `410` if expired |
|
||||||
| `GET` | `/api/events` | Server-Sent Events stream, pushes each event as it happens |
|
| `GET` | `/api/events` | Server-Sent Events stream, pushes each event as it happens |
|
||||||
@@ -320,6 +355,8 @@ cleared, so it cannot be mistaken for a tap still waiting to be used.
|
|||||||
| `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, both TTLs — for monitoring |
|
||||||
| `POST` | `/api/mode` | `{"mode": "login"}` or `{"mode": "monitor"}` |
|
| `POST` | `/api/mode` | `{"mode": "login"}` or `{"mode": "monitor"}` |
|
||||||
|
| `GET` | `/api/led` | Both LEDs: pin, polarity, on/off, whether held manually |
|
||||||
|
| `POST` | `/api/led` | `{"led": "status"\|"error", "on": true\|false}`, or `{"release": true}` |
|
||||||
|
|
||||||
`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
|
||||||
@@ -390,64 +427,175 @@ actually cost you something.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Running at boot (Raspberry Pi)
|
## Quick start on a Raspberry Pi
|
||||||
|
|
||||||
The defaults already match the common wiring — **`/dev/serial0`** for the
|
End-to-end: enable the serial port, confirm the reader is visible, install the
|
||||||
reader on the Pi's UART and **GPIO 23** for the LED — so the LED pin and device
|
service, verify it runs. Steps 1–2 change nothing on disk and are safe to run
|
||||||
usually need no arguments at all:
|
first.
|
||||||
|
|
||||||
|
### 1. Enable the serial port
|
||||||
|
|
||||||
|
`/dev/serial0` is the Pi's on-board UART, and it does not exist until the UART
|
||||||
|
is switched on:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo raspi-config
|
||||||
|
# Interface Options -> Serial Port
|
||||||
|
# "Would you like a login shell to be accessible over serial?" -> No
|
||||||
|
# "Would you like the serial port hardware to be enabled?" -> Yes
|
||||||
|
sudo reboot
|
||||||
|
```
|
||||||
|
|
||||||
|
Menu numbering shifts between `raspi-config` releases, so navigate by name:
|
||||||
|
**Interface Options**, then **Serial Port**.
|
||||||
|
|
||||||
|
**Both answers matter.** Answering *Yes* to the login shell leaves a console on
|
||||||
|
the port, so the reader gets nothing: the app either reports `device not found`
|
||||||
|
or opens the port and never sees a card.
|
||||||
|
|
||||||
|
### 2. Confirm the device appears
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ls -l /dev/serial0 /dev/rdm-reader 2>&1
|
||||||
|
```
|
||||||
|
|
||||||
|
| What you see | Wiring | Device to pass |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `/dev/serial0 -> ttyAMA0` (or `ttyS0`) | UART, header pins **8 / 10** | `--device /dev/serial0` |
|
||||||
|
| `/dev/rdm-reader` | USB-to-serial adapter | `--device /dev/rdm-reader` |
|
||||||
|
| `No such file or directory` for both | nothing openable yet | see below |
|
||||||
|
|
||||||
|
`/dev/rdm-reader` is created by the udev rule and matches a **USB** bridge chip,
|
||||||
|
so it can never exist on a UART setup. The two are mutually exclusive — a
|
||||||
|
service pointing at the wrong one exits `1` on every start.
|
||||||
|
|
||||||
|
If neither exists, ask what the system does see:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ls /dev/ttyUSB* /dev/ttyACM* /dev/ttyS* 2>/dev/null
|
||||||
|
dmesg | grep -iE "ttyUSB|ttyAMA|ch34|ftdi|cp210|pl2303" | tail
|
||||||
|
```
|
||||||
|
|
||||||
|
No `ttyUSB`/`ttyACM` line in `dmesg` means the adapter is not enumerating at
|
||||||
|
all — a cable, power or hardware problem that no `--device` value can fix.
|
||||||
|
|
||||||
|
If a `/dev/ttyUSB0` appears but `/dev/rdm-reader` does not, the adapter's chip
|
||||||
|
is not the CH340 the rule matches by default. See **Identifying the right
|
||||||
|
ttyUSB** below.
|
||||||
|
|
||||||
|
### 3. Install the service
|
||||||
|
|
||||||
|
The defaults already match the common wiring — `/dev/serial0` for the reader on
|
||||||
|
the UART, GPIO 23 for the LED:
|
||||||
|
|
||||||
```bash
|
```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):
|
`deploy/install.sh` is the only installer — there is no `deploy.sh`. It sets up
|
||||||
|
a service account, a virtualenv, a udev rule and a systemd unit, then enables
|
||||||
|
the service.
|
||||||
|
|
||||||
|
Tell it which device you have, since the two are not interchangeable:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo ./deploy/install.sh --origin https://your.web.app \
|
sudo ./deploy/install.sh --device /dev/serial0 # UART, pins 8 / 10
|
||||||
--device /dev/serial0 \
|
sudo ./deploy/install.sh --device /dev/rdm-reader # USB adapter
|
||||||
--led-pin 23 \
|
|
||||||
--led-mode presence
|
|
||||||
```
|
```
|
||||||
|
|
||||||
| Argument | Why |
|
It refuses to enable the service unless the device exists **and** opens at
|
||||||
|
9600 baud, so a wrong port fails here instead of becoming a crash-looping unit
|
||||||
|
that burns its restart limit within seconds and then stays dead. Success looks
|
||||||
|
like:
|
||||||
|
|
||||||
|
```
|
||||||
|
/dev/serial0 opened at 9600 baud
|
||||||
|
```
|
||||||
|
|
||||||
|
If it stops instead, the message is one of exactly two:
|
||||||
|
|
||||||
|
| Message | Cause |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `--device /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 |
|
| `<device> does not exist, so the service could never start` | step 1 or 2 not done, or the wrong of the two devices |
|
||||||
| `--led-pin 23` | BCM GPIO 23, which is **physical pin 16**. The default |
|
| `could not open <device> at 9600 baud` | device exists but is not usable — UART held by the serial console, or the user is not in `dialout` |
|
||||||
| `--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
|
Nothing is enabled in either case, and re-running is safe: the account, udev
|
||||||
> is on physical pin 16, pass `--led-pin 23` (the default). Passing
|
rule and virtualenv are all idempotent.
|
||||||
> `--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
|
### 4. Verify it runs
|
||||||
|
|
||||||
`deploy/install.sh` is the only installer — there is no `deploy.sh`.
|
```bash
|
||||||
|
systemctl status rdm-card-reader # state
|
||||||
|
systemctl is-enabled rdm-card-reader # should be 'enabled' (starts at boot)
|
||||||
|
journalctl -u rdm-card-reader -f # live log
|
||||||
|
ss -ltnp | grep 8765 # confirms something is listening
|
||||||
|
```
|
||||||
|
|
||||||
That installs a service account, a virtualenv, a systemd unit and a udev rule,
|
A healthy startup logs one line:
|
||||||
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:
|
```
|
||||||
|
OK reader on /dev/serial0 ; bridge on http://127.0.0.1:8765 (mode: login)
|
||||||
|
```
|
||||||
|
|
||||||
|
Then tap a card and check — or just open **<http://127.0.0.1:8765/>** for the
|
||||||
|
status page, which shows the reader state and a live event log:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sS http://127.0.0.1:8765/api/card | python3 -m json.tool
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. If cards are not detected
|
||||||
|
|
||||||
|
Run the reader in the foreground, using the venv the installer built, to see
|
||||||
|
the failure directly instead of through the journal:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo /opt/rdm-card-reader/.venv/bin/rdm-card-reader --port /dev/serial0
|
||||||
|
```
|
||||||
|
|
||||||
|
`--list-ports --verbose-ports` answers "is the port even there?" — it prints
|
||||||
|
candidates with `OK`/`---` (exists?) and then every identified port with its
|
||||||
|
`by-id` path, USB id and serial number:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo /opt/rdm-card-reader/.venv/bin/rdm-card-reader --list-ports --verbose-ports
|
||||||
|
```
|
||||||
|
|
||||||
|
A service that cannot see cards while the foreground run works usually means
|
||||||
|
the unit is pointed at the other device — compare its `ExecStart` (below) with
|
||||||
|
what `--list-ports` reports.
|
||||||
|
|
||||||
|
### Installer options
|
||||||
|
|
||||||
| Option | Default | Meaning |
|
| Option | Default | Meaning |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `--origin URL` | – | Web app origin allowed to call the API (repeatable) |
|
| `--origin URL` | – | Web app origin allowed to call the API (repeatable) |
|
||||||
|
| `--host ADDR` | `127.0.0.1` | Bind address. Loopback only by default |
|
||||||
|
| `--allow-remote` | off | Permit a non-loopback `--host` |
|
||||||
|
| `--token SECRET` | – | Require this token from non-loopback callers (see below) |
|
||||||
| `--device PATH` | `/dev/serial0` | Serial device the service opens (UART; use `/dev/rdm-reader` for USB) |
|
| `--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-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 |
|
| `--led-mode M` | `presence` | `presence`: lit while a card is on the reader; `error`: lit whenever running |
|
||||||
|
| `--led-active-low` | off | LED is wired active-low (cathode to the pin, anode to 3.3 V); inverts the signal |
|
||||||
|
| `--error-led-pin N` | `24` | BCM GPIO of a second LED, lit briefly on a bad read |
|
||||||
|
| `--no-error-led` | off | No error LED; blink the status LED instead |
|
||||||
|
| `--error-pulse S` | `1.0` | Seconds to hold the error LED on a bad read |
|
||||||
| `--heartbeat S` | `2.0` | Seconds before a silent reader counts as card removed — also the presence/LED window |
|
| `--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 |
|
| `--port PORT` | `8765` | HTTP port for the bridge |
|
||||||
| `--dir PATH` | `/opt/rdm-card-reader` | Install location |
|
| `--dir PATH` | `/opt/rdm-card-reader` | Install location |
|
||||||
| `--user NAME` | `rdmcard` | Service account |
|
| `--user NAME` | `rdmcard` | Service account |
|
||||||
| `--no-service` | off | Install and configure, but do not enable the service |
|
| `--no-service` | off | Install and configure, but do not enable the service |
|
||||||
|
|
||||||
The unit runs the app as:
|
> **`--no-service` does not skip the device checks.** They run before it, so an
|
||||||
|
> install with no reader attached still exits `1`.
|
||||||
|
|
||||||
|
> **`--led-pin` is the BCM GPIO number, not the header pin.** If your LED wire
|
||||||
|
> is on physical pin 16, pass `--led-pin 23` (the default). Passing
|
||||||
|
> `--led-pin 16` would drive **GPIO 16**, which is physical pin 36 — a different
|
||||||
|
> pin entirely. The LED is optional: with no LED, leave the default and ignore
|
||||||
|
> the light, or add `--no-led` to the unit to stop the app trying.
|
||||||
|
|
||||||
|
### What the unit runs
|
||||||
|
|
||||||
```
|
```
|
||||||
/opt/rdm-card-reader/.venv/bin/rdm-card-reader \
|
/opt/rdm-card-reader/.venv/bin/rdm-card-reader \
|
||||||
@@ -460,25 +608,58 @@ The unit runs the app as:
|
|||||||
added. `--serve` is what makes the API available; without it the app only
|
added. `--serve` is what makes the API available; without it the app only
|
||||||
prints events, which a service sends to the journal.
|
prints events, which a service sends to the journal.
|
||||||
|
|
||||||
Afterwards:
|
### Reaching the page from another machine
|
||||||
|
|
||||||
|
The bridge binds **loopback only** by default, so `http://192.168.x.x:8765/`
|
||||||
|
from your laptop gets a *connection refused* — nothing is listening on that
|
||||||
|
address. Opening the port to the network is opt-in, and needs a token:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
systemctl status rdm-card-reader # state
|
sudo ./deploy/install.sh --device /dev/serial0 \
|
||||||
systemctl is-enabled rdm-card-reader # should be 'enabled' (starts at boot)
|
--host 0.0.0.0 --allow-remote --token 'pick-a-long-secret'
|
||||||
journalctl -u rdm-card-reader -f # live log
|
|
||||||
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:
|
The installer refuses a non-loopback `--host` without `--allow-remote`, and
|
||||||
|
warns if you skip the token. The app enforces the same rule, so this cannot be
|
||||||
|
switched on by accident.
|
||||||
|
|
||||||
|
> **What you are exposing.** Off loopback, anyone who can reach the port can
|
||||||
|
> read card taps *and* switch the status LEDs. A tap is not a password, but it
|
||||||
|
> is a username, and the endpoint reports who has touched the reader. Keep it on
|
||||||
|
> a trusted network, and set a token.
|
||||||
|
|
||||||
|
Loopback callers (the Pi itself) never need the token, so local tools and
|
||||||
|
`curl` keep working unchanged.
|
||||||
|
|
||||||
|
The token is written to `/opt/rdm-card-reader/bridge.env` (mode `0600`) and
|
||||||
|
referenced by the unit as `EnvironmentFile=`, rather than being placed on the
|
||||||
|
`ExecStart` line — a value there is readable by anything that can run
|
||||||
|
`systemctl cat` and appears in the process list.
|
||||||
|
|
||||||
|
Once exposed, open the page and paste the token into the box at the top right;
|
||||||
|
it is kept in `localStorage` and sent as `X-Auth-Token`. Until a token is
|
||||||
|
supplied the panels stay empty and the LED buttons do nothing, because the API
|
||||||
|
answers `401`. For the LED buttons specifically:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# from your laptop, if you invoked install.sh with --host 0.0.0.0 --token SECRET
|
||||||
|
curl -sS -H "X-Auth-Token: SECRET" http://192.168.0.228:8765/api/led
|
||||||
```
|
```
|
||||||
OK reader on /dev/serial0 ; bridge on http://127.0.0.1:8765 (mode: login)
|
|
||||||
|
If you would rather not expose the port at all, an SSH tunnel gives you the
|
||||||
|
same page without opening anything:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh -N -L 8765:127.0.0.1:8765 pi@192.168.0.228
|
||||||
|
# then browse http://127.0.0.1:8765/ on your laptop
|
||||||
```
|
```
|
||||||
|
|
||||||
### Checking the reader from the command line
|
### Checking the reader from the command line
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# Everything at once, in a browser: the status page
|
||||||
|
xdg-open http://127.0.0.1:8765/ # or open it on another machine
|
||||||
|
|
||||||
# Is the reader alive? (status, port, uptime)
|
# Is the reader alive? (status, port, uptime)
|
||||||
curl -sS http://127.0.0.1:8765/api/health | python3 -m json.tool
|
curl -sS http://127.0.0.1:8765/api/health | python3 -m json.tool
|
||||||
|
|
||||||
@@ -553,30 +734,48 @@ 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 —
|
Two LEDs, each saying one thing. Both optional: everything works without
|
||||||
the same pin the workstation app used. Optional: everything works without it.
|
either.
|
||||||
|
|
||||||
By default the light **tracks the card**: lit while a card is on the reader,
|
| LED | Default pin | Means |
|
||||||
dark otherwise.
|
| --- | --- | --- |
|
||||||
|
| **Status** | GPIO 23 (physical pin **16**) | Lit while a card is on the reader (`presence` mode) |
|
||||||
|
| **Error** | GPIO 24 (physical pin **18**) | Lit briefly when a read fails (checksum mismatch) |
|
||||||
|
|
||||||
|
The status pin matches the workstation app. The error LED exists because one
|
||||||
|
light cannot say two things at once: in the default `presence` mode the status
|
||||||
|
LED is already carrying "a card is here", so **without a separate error LED a
|
||||||
|
bad read is reported not at all**. A dedicated red LED on its own pin is the
|
||||||
|
clearer arrangement.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
rdm-card-reader # lit only while a card is on the reader
|
rdm-card-reader # status LED tracks the card; error LED pulses on a bad read
|
||||||
rdm-card-reader --led-mode error # lit whenever running, blinks on a bad read
|
rdm-card-reader --led-mode error # status LED lit whenever running, blinks on a bad read
|
||||||
rdm-card-reader --led-pin 17 # a different pin
|
rdm-card-reader --error-led-pin 25 # error LED on a different pin
|
||||||
rdm-card-reader --no-led # no LED at all
|
rdm-card-reader --no-error-led # no error LED; blink the status LED instead
|
||||||
|
rdm-card-reader --no-led # no LEDs at all
|
||||||
```
|
```
|
||||||
|
|
||||||
| Flag | Default | Meaning |
|
| Flag | Default | Meaning |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `--no-led` | off | Do not drive a LED |
|
| `--no-led` | off | Do not drive any LED |
|
||||||
| `--led-pin` | `23` | **BCM** GPIO number — not the physical header pin |
|
| `--led-pin` | `23` | **BCM** GPIO number of the status LED — not the physical header pin |
|
||||||
| `--led-mode` | `presence` | See the two readings below |
|
| `--led-mode` | `presence` | See the two readings below |
|
||||||
| `--led-no-error-blink` | off | Do not blink on a bad read |
|
| `--led-active-low` | off | Status LED wired active-low (cathode to the pin) |
|
||||||
|
| `--error-led-pin` | `24` | BCM GPIO of the error LED |
|
||||||
|
| `--error-led-active-low` | off | Error LED wired active-low |
|
||||||
|
| `--error-pulse` | `1.0` | Seconds to hold the error LED on a bad read |
|
||||||
|
| `--no-error-led` | off | No error LED; blink the status LED instead |
|
||||||
|
| `--led-no-error-blink` | off | With `--no-error-led`, do not blink on a bad read |
|
||||||
|
|
||||||
`--led-pin` takes the **BCM GPIO number**. The two are easy to confuse, and it
|
`--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
|
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.
|
**physical pin 16**, not pin 23. The full mapping is under **Wiring** below.
|
||||||
|
|
||||||
|
Giving the error LED the same pin as the status LED is treated as "no error
|
||||||
|
LED": two lights on one pin cannot be driven independently, so the app says so
|
||||||
|
and falls back to the status blink rather than failing.
|
||||||
|
|
||||||
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 |
|
||||||
@@ -590,6 +789,10 @@ Two modes, because the light can mean two different things:
|
|||||||
> read lights it before concluding the LED is broken.
|
> 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.
|
||||||
|
For the opposite arrangement — cathode to the pin, anode to 3.3 V, or a
|
||||||
|
transistor/MOSFET driver that inverts — pass **`--led-active-low`** (or
|
||||||
|
`--error-led-active-low` for the error LED), or the light will do the opposite
|
||||||
|
of what is wanted.
|
||||||
|
|
||||||
### It cannot break the reader
|
### It cannot break the reader
|
||||||
|
|
||||||
@@ -604,13 +807,14 @@ 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 the state of each LED (the mode shown is `presence` by
|
||||||
`presence` by default):
|
default):
|
||||||
|
|
||||||
```
|
```
|
||||||
LED on GPIO 23 (presence mode, gpiozero) # working
|
LED on GPIO 23 (presence mode, active-high, gpiozero) # status LED working
|
||||||
LED unavailable: gpiozero not installed # library missing
|
Error LED on GPIO 24 (lit for 1.0s on a bad read, ...) # error LED working
|
||||||
LED unavailable: no usable GPIO on pin 23: ... # no pin factory / pin busy
|
LED unavailable: gpiozero not installed # library missing
|
||||||
|
LED unavailable: no usable GPIO on pin 23: ... # no pin factory / pin busy
|
||||||
```
|
```
|
||||||
|
|
||||||
That distinction matters: `BadPinFactory` subclasses `ImportError`, so a naive
|
That distinction matters: `BadPinFactory` subclasses `ImportError`, so a naive
|
||||||
@@ -630,23 +834,78 @@ journalctl -u rdm-card-reader -n 40 --no-pager | grep -i led
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `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: 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 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 |
|
| `LED on GPIO 23 (presence mode, active-high, gpiozero)` | LED is initialised — suspect wiring, pin number, or simply no card read yet | See the wiring note below |
|
||||||
|
| No error indication on a bad read | The error LED pin is wrong or the LED is unwired, and the status LED only blinks in `error` mode | Check `Error LED on GPIO …` in the journal; see below |
|
||||||
|
|
||||||
|
**Check 0 — is the light doing the opposite?** A dark LED and an *inverted* LED
|
||||||
|
are different faults. If the light is **on with no card and goes dark when a
|
||||||
|
card is placed**, the pin number is right and the polarity is wrong: the LED is
|
||||||
|
wired active-low. Pass `--led-active-low` (installer, or the app directly), and
|
||||||
|
confirm with the journal, which now names the polarity:
|
||||||
|
|
||||||
|
```
|
||||||
|
LED on GPIO 23 (presence mode, active-low, gpiozero)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Check 0b — can the software drive the pin at all?** Before suspecting the
|
||||||
|
wiring, prove the pin works. Open the status page and use the **LED panel** to
|
||||||
|
switch each light on and off by hand, which takes card events out of the
|
||||||
|
picture entirely:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sS -X POST -H 'Content-Type: application/json' \
|
||||||
|
-d '{"led":"status","on":true}' http://127.0.0.1:8765/api/led
|
||||||
|
```
|
||||||
|
|
||||||
|
- **The light comes on** → the pin, backend and permissions are all fine, so
|
||||||
|
the problem is card events, not hardware. Check the journal for
|
||||||
|
`CARD INSERTED` when you tap.
|
||||||
|
- **Nothing happens, HTTP 200** → the pin is being driven but no light is
|
||||||
|
connected to it. Wiring, or the wrong pin.
|
||||||
|
- **HTTP 409 `that LED is not available`** → the LED has no usable pin; look at
|
||||||
|
`reason` in the response, which is the same text as the journal line.
|
||||||
|
|
||||||
|
**HTTP 401 `missing or invalid token`** → you are reaching the bridge from
|
||||||
|
another machine and it was installed with a token. Paste the token into the box
|
||||||
|
at the top of the status page, or send `X-Auth-Token`. See **Reaching the page
|
||||||
|
from another machine** above.
|
||||||
|
|
||||||
|
Press *hand back to card events* (or POST `{"release": true}`) when finished, or
|
||||||
|
the LEDs stay held where you left them.
|
||||||
|
|
||||||
|
To isolate polarity from everything else, run the service in `error` mode,
|
||||||
|
where the light is lit the whole time the app runs:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo ./deploy/install.sh --device /dev/serial0 --led-mode error
|
||||||
|
```
|
||||||
|
|
||||||
|
Lit continuously means the hardware path is good. Dark means wiring, pin
|
||||||
|
number or backend. Lit-while-idle *and* dark-when-a-card-arrives in `presence`
|
||||||
|
mode means polarity.
|
||||||
|
|
||||||
**Check 1 — the venv must see the system pin backend.** `gpiozero` is pure
|
**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,
|
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
|
`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
|
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
|
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
|
with `--system-site-packages` and installs a backend. If you built the venv by
|
||||||
likely cause:
|
hand, that is the most likely cause:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
grep system-site-packages /opt/rdm-card-reader/.venv/pyvenv.cfg # want: true
|
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")'
|
/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
|
If either line is wrong, let the installer set both up at once:
|
||||||
rebuild the venv:
|
|
||||||
|
```bash
|
||||||
|
sudo ./deploy/install.sh --device /dev/serial0
|
||||||
|
```
|
||||||
|
|
||||||
|
It installs `python3-lgpio` (Bookworm and later) or `python3-rpi.gpio` (older
|
||||||
|
releases) when neither backend is importable, and recreates the venv with the
|
||||||
|
flag. To do it yourself instead:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo apt-get install -y python3-lgpio # Bookworm; python3-rpi.gpio on older
|
sudo apt-get install -y python3-lgpio # Bookworm; python3-rpi.gpio on older
|
||||||
@@ -730,9 +989,10 @@ so the Pi's TX line (pin 8) has nothing to send. Only pin 10 carries data.
|
|||||||
> on the reader-TX → pin-10 line. This is not a cause of "no cards read" if you
|
> 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.
|
> already see data, but it is worth fixing before relying on the device.
|
||||||
|
|
||||||
For the status LED: **anode** → GPIO pin through a ~330 Ω resistor,
|
For each LED: **anode** → GPIO pin through a ~330 Ω resistor,
|
||||||
**cathode** → GND. See the pin numbering table below, and note the LED is
|
**cathode** → GND. Status LED on GPIO 23 (physical pin 16) and error LED on
|
||||||
optional — the reader works without it.
|
GPIO 24 (physical pin 18) by default. See the pin numbering table below, and
|
||||||
|
note both LEDs are optional — the reader works without them.
|
||||||
|
|
||||||
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:
|
||||||
|
|
||||||
@@ -752,9 +1012,6 @@ sudo systemctl is-active serial-getty@ttyAMA0.service # must not be active
|
|||||||
grep -o 'console=[^ ]*' /boot/firmware/cmdline.txt # must not say serial0
|
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
|
### UART or USB? The service must be told which
|
||||||
|
|
||||||
The two are not interchangeable, and getting it wrong is the most common reason
|
The two are not interchangeable, and getting it wrong is the most common reason
|
||||||
@@ -793,15 +1050,58 @@ sudo ./deploy/install.sh --device /dev/serial0 --led-pin 18 # LED on GPIO 18
|
|||||||
|
|
||||||
### LED pin numbering
|
### LED pin numbering
|
||||||
|
|
||||||
| `--led-pin` | BCM GPIO | Physical header pin |
|
`--led-pin` is the **BCM GPIO number** — what the app uses and what GPIO
|
||||||
| --- | --- | --- |
|
libraries use — **not** the physical header pin number. This is the single most
|
||||||
| `23` (the default) | GPIO 23 | **16** |
|
common way to get a dark LED with no error: the app reports
|
||||||
| `18` | GPIO 18 | 12 |
|
`LED on GPIO <n>` and looks healthy, while the wire is on a different pin.
|
||||||
| `17` | GPIO 17 | 11 |
|
|
||||||
|
|
||||||
`--led-pin` is the **BCM** number, which is what the app uses and what GPIO
|
Look up the pin your wire is actually on, and pass the BCM number:
|
||||||
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.
|
| Physical pin | `--led-pin` (BCM) | | Physical pin | `--led-pin` (BCM) |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| 7 | `4` | | 24 | `8` |
|
||||||
|
| 11 | `17` | | 26 | `7` |
|
||||||
|
| 12 | `18` | | 29 | `5` |
|
||||||
|
| 13 | `27` | | 31 | `6` |
|
||||||
|
| 15 | `22` | | 32 | `12` |
|
||||||
|
| **16** | **`23`** (status LED default) | | 33 | `13` |
|
||||||
|
| **18** | **`24`** (error LED default) | | 35 | `19` |
|
||||||
|
| 19 | `10` | | 36 | `16` |
|
||||||
|
| 21 | `9` | | 37 | `26` |
|
||||||
|
| 22 | `25` | | 38 | `20` |
|
||||||
|
| **23** | **`11`** | | 40 | `21` |
|
||||||
|
|
||||||
|
So an LED wired to **physical pin 23** needs `--led-pin 11`, and the default
|
||||||
|
`--led-pin 23` requires the wire on **physical pin 16**. The error LED defaults
|
||||||
|
to GPIO 24, which is **physical pin 18**.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo ./deploy/install.sh --device /dev/serial0 --led-pin 11 # status wire on physical pin 23
|
||||||
|
sudo ./deploy/install.sh --device /dev/serial0 --error-led-pin 25 # error wire on physical pin 22
|
||||||
|
```
|
||||||
|
|
||||||
|
Do **not** use physical pin 8 or 10 (GPIO 14 / 15 — the reader's own UART),
|
||||||
|
physical pin 27 or 28 (ID_SD / ID_SC, reserved), or pins 1 / 17 (3.3 V) and
|
||||||
|
2 / 4 (5 V), which are power rather than GPIO.
|
||||||
|
|
||||||
|
If you would rather find the pin by experiment, stop the service first — two
|
||||||
|
processes driving one pin fight each other — then blink each candidate and note
|
||||||
|
which one lights:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl stop rdm-card-reader
|
||||||
|
sudo /opt/rdm-card-reader/.venv/bin/python - <<'PY'
|
||||||
|
from gpiozero import LED
|
||||||
|
import time
|
||||||
|
# (physical pin, BCM GPIO)
|
||||||
|
for physical, bcm in [(23, 11), (16, 23), (12, 18), (11, 17), (22, 25)]:
|
||||||
|
print(f"physical pin {physical} (GPIO {bcm})", flush=True)
|
||||||
|
led = LED(bcm)
|
||||||
|
led.on(); time.sleep(1.0); led.off(); led.close()
|
||||||
|
time.sleep(0.5)
|
||||||
|
PY
|
||||||
|
sudo systemctl start rdm-card-reader
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -882,6 +1182,7 @@ src/rdm_card_reader/
|
|||||||
demo.py simulated reads
|
demo.py simulated reads
|
||||||
ports.py stable serial port identification
|
ports.py stable serial port identification
|
||||||
bridge.py the HTTP bridge behind --serve
|
bridge.py the HTTP bridge behind --serve
|
||||||
|
webui.py the status page served at GET / by the bridge
|
||||||
led.py status LED
|
led.py status LED
|
||||||
bootstrap.py offline dependency install
|
bootstrap.py offline dependency install
|
||||||
wheels/ the bundled wheels (offline install source)
|
wheels/ the bundled wheels (offline install source)
|
||||||
|
|||||||
+113
-5
@@ -10,9 +10,21 @@
|
|||||||
# 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)
|
||||||
|
# --host ADDR bind address (default 127.0.0.1, loopback only)
|
||||||
|
# --allow-remote permit binding a non-loopback address. Anyone who can
|
||||||
|
# reach the port can then read taps and switch the LEDs;
|
||||||
|
# pair it with --token
|
||||||
|
# --token SECRET pre-shared token required from non-loopback callers
|
||||||
|
# (or set RDM_BRIDGE_TOKEN in the environment)
|
||||||
# --device PATH serial device the service opens (default /dev/serial0)
|
# --device PATH serial device the service opens (default /dev/serial0)
|
||||||
# --led-pin N BCM GPIO number of the status LED (default 23)
|
# --led-pin N BCM GPIO number of the status LED (default 23)
|
||||||
# --led-mode M 'presence' (default) or 'error'
|
# --led-mode M 'presence' (default) or 'error'
|
||||||
|
# --led-active-low LED wired active-low (cathode to the pin, anode to
|
||||||
|
# 3.3 V, or an inverting driver); or the light will be on
|
||||||
|
# with no card and off with one
|
||||||
|
# --error-led-pin N BCM GPIO of a second LED for bad reads (default 24)
|
||||||
|
# --no-error-led do not use an error LED; blink the status LED instead
|
||||||
|
# --error-pulse S seconds to hold the error LED on a bad read (default 1)
|
||||||
# --heartbeat S seconds before a silent reader counts as card removed
|
# --heartbeat S seconds before a silent reader counts as card removed
|
||||||
# (default 2.0; this is also the presence/LED window)
|
# (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)
|
||||||
@@ -33,18 +45,36 @@ INSTALL_SERVICE=1
|
|||||||
SERIAL_DEVICE=/dev/serial0
|
SERIAL_DEVICE=/dev/serial0
|
||||||
LED_PIN=23
|
LED_PIN=23
|
||||||
LED_MODE=presence
|
LED_MODE=presence
|
||||||
|
LED_ACTIVE_LOW=0
|
||||||
|
ERROR_LED_PIN=24
|
||||||
|
ERROR_LED_ACTIVE_LOW=0
|
||||||
|
ERROR_PULSE=1.0
|
||||||
HEARTBEAT=2.0
|
HEARTBEAT=2.0
|
||||||
|
# Loopback by default: an open card endpoint on a LAN lets anyone read who is
|
||||||
|
# tapping, and now also toggle the LEDs. Reaching it from another machine needs
|
||||||
|
# --allow-remote, and a token once it is off loopback.
|
||||||
|
BRIDGE_HOST=127.0.0.1
|
||||||
|
ALLOW_REMOTE=0
|
||||||
|
BRIDGE_TOKEN=""
|
||||||
SOURCE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
SOURCE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
--origin) ORIGINS+=("$2"); shift 2 ;;
|
--origin) ORIGINS+=("$2"); shift 2 ;;
|
||||||
--port) BRIDGE_PORT="$2"; shift 2 ;;
|
--port) BRIDGE_PORT="$2"; shift 2 ;;
|
||||||
|
--host) BRIDGE_HOST="$2"; shift 2 ;;
|
||||||
|
--allow-remote) ALLOW_REMOTE=1; shift ;;
|
||||||
|
--token) BRIDGE_TOKEN="$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 ;;
|
--device) SERIAL_DEVICE="$2"; shift 2 ;;
|
||||||
--led-pin) LED_PIN="$2"; shift 2 ;;
|
--led-pin) LED_PIN="$2"; shift 2 ;;
|
||||||
--led-mode) LED_MODE="$2"; shift 2 ;;
|
--led-mode) LED_MODE="$2"; shift 2 ;;
|
||||||
|
--led-active-low) LED_ACTIVE_LOW=1; shift ;;
|
||||||
|
--error-led-pin) ERROR_LED_PIN="$2"; shift 2 ;;
|
||||||
|
--error-led-active-low) ERROR_LED_ACTIVE_LOW=1; shift ;;
|
||||||
|
--error-pulse) ERROR_PULSE="$2"; shift 2 ;;
|
||||||
|
--no-error-led) ERROR_LED_PIN=""; shift ;;
|
||||||
--heartbeat) HEARTBEAT="$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,33p' "$0"; exit 0 ;;
|
||||||
@@ -54,12 +84,31 @@ done
|
|||||||
|
|
||||||
log() { printf '\n\033[1m==> %s\033[0m\n' "$*"; }
|
log() { printf '\n\033[1m==> %s\033[0m\n' "$*"; }
|
||||||
warn() { printf '\033[33mwarning: %s\033[0m\n' "$*" >&2; }
|
warn() { printf '\033[33mwarning: %s\033[0m\n' "$*" >&2; }
|
||||||
|
note() { printf ' %s\n' "$*"; }
|
||||||
|
|
||||||
if [[ $EUID -ne 0 ]]; then
|
if [[ $EUID -ne 0 ]]; then
|
||||||
echo "Please run as root: sudo $0 $*" >&2
|
echo "Please run as root: sudo $0 $*" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# A non-loopback bind is opt-in, and the app refuses it without --allow-remote
|
||||||
|
# too. Mirror that here so the failure happens at install time with an
|
||||||
|
# explanation, rather than as a crash-looping unit.
|
||||||
|
if [[ "$BRIDGE_HOST" != "127.0.0.1" && "$BRIDGE_HOST" != "::1" \
|
||||||
|
&& "$BRIDGE_HOST" != "localhost" ]]; then
|
||||||
|
if [[ "$ALLOW_REMOTE" -eq 0 ]]; then
|
||||||
|
warn "--host $BRIDGE_HOST binds beyond loopback, which is not the default."
|
||||||
|
warn "Re-run with --allow-remote to confirm, e.g."
|
||||||
|
warn " sudo $0 --host $BRIDGE_HOST --allow-remote --token SECRET"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [[ -z "$BRIDGE_TOKEN" ]]; then
|
||||||
|
warn "--host $BRIDGE_HOST with no --token: anyone who can reach port"
|
||||||
|
warn "$BRIDGE_PORT can read card taps AND switch the status LEDs."
|
||||||
|
warn "Add --token SECRET, and open the page with that token."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 1. System packages
|
# 1. System packages
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -70,6 +119,29 @@ apt-get update -qq 2>/dev/null || warn "apt-get update failed (no network?) - co
|
|||||||
apt-get install -y --no-install-recommends python3 python3-venv python3-pip \
|
apt-get install -y --no-install-recommends python3 python3-venv python3-pip \
|
||||||
|| warn "could not install system packages - continuing with what is present"
|
|| warn "could not install system packages - continuing with what is present"
|
||||||
|
|
||||||
|
# The pin backend gpiozero needs to drive the LED is an apt package in the
|
||||||
|
# *system* Python (lgpio on Bookworm and later, RPi.GPIO on older releases).
|
||||||
|
# gpiozero itself comes from the bundled wheels, but without a backend it
|
||||||
|
# raises BadPinFactory, the app falls back to a no-op LED, and the reader works
|
||||||
|
# with the light dark and nothing failing - the failure that is easiest to
|
||||||
|
# miss. Best-effort, like the packages above: a full Raspberry Pi OS image
|
||||||
|
# ships python3-gpiozero, which already pulls the right backend in, so only
|
||||||
|
# install one when neither is importable.
|
||||||
|
if ! python3 -c 'import lgpio' 2>/dev/null \
|
||||||
|
&& ! python3 -c 'import RPi.GPIO' 2>/dev/null; then
|
||||||
|
echo " no GPIO backend found - installing one for the status LED"
|
||||||
|
if apt-get install -y --no-install-recommends python3-lgpio 2>/dev/null; then
|
||||||
|
echo " installed python3-lgpio (Bookworm and later)"
|
||||||
|
elif apt-get install -y --no-install-recommends python3-rpi.gpio 2>/dev/null; then
|
||||||
|
echo " installed python3-rpi.gpio (Bullseye and older)"
|
||||||
|
else
|
||||||
|
warn "could not install a GPIO pin backend (python3-lgpio or"
|
||||||
|
warn "python3-rpi.gpio). The reader will work, but the status LED will"
|
||||||
|
warn "stay dark. Install it on the Pi with:"
|
||||||
|
warn " sudo apt-get install -y python3-lgpio # or python3-rpi.gpio"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 2. Install the application
|
# 2. Install the application
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -274,11 +346,14 @@ for origin in "${ORIGINS[@]:-}"; do
|
|||||||
done
|
done
|
||||||
|
|
||||||
python3 - "$UNIT" "$INSTALL_DIR" "$BRIDGE_PORT" "$ORIGIN_ARGS" "$SERVICE_USER" \
|
python3 - "$UNIT" "$INSTALL_DIR" "$BRIDGE_PORT" "$ORIGIN_ARGS" "$SERVICE_USER" \
|
||||||
"$SERIAL_DEVICE" "$LED_PIN" "$LED_MODE" "$HEARTBEAT" <<'PY'
|
"$SERIAL_DEVICE" "$LED_PIN" "$LED_MODE" "$LED_ACTIVE_LOW" "$HEARTBEAT" \
|
||||||
|
"$ERROR_LED_PIN" "$ERROR_LED_ACTIVE_LOW" "$ERROR_PULSE" \
|
||||||
|
"$BRIDGE_HOST" "$ALLOW_REMOTE" "$BRIDGE_TOKEN" <<'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, device, led_pin, led_mode,
|
||||||
heartbeat) = sys.argv[1:10]
|
led_active_low, heartbeat, error_led_pin, error_led_active_low,
|
||||||
|
error_pulse, bridge_host, allow_remote, bridge_token) = sys.argv[1:17]
|
||||||
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
|
||||||
@@ -288,11 +363,43 @@ wrapper = f"{install_dir}/.venv/bin/rdm-card-reader"
|
|||||||
if not os.path.exists(wrapper):
|
if not os.path.exists(wrapper):
|
||||||
wrapper = f"{install_dir}/.venv/bin/python -m rdm_card_reader"
|
wrapper = f"{install_dir}/.venv/bin/python -m rdm_card_reader"
|
||||||
|
|
||||||
|
# The token goes into an environment file, not the unit: a value on the
|
||||||
|
# ExecStart line is readable by anything that can run 'systemctl cat', and
|
||||||
|
# shows up in the process list. The '-' prefix makes the file optional.
|
||||||
|
env_file = os.path.join(install_dir, "bridge.env")
|
||||||
|
text = re.sub(r"^EnvironmentFile=.*\n?", "", text, count=1, flags=re.M)
|
||||||
|
if bridge_token:
|
||||||
|
with open(env_file, "w") as handle:
|
||||||
|
handle.write(f"RDM_BRIDGE_TOKEN={bridge_token}\n")
|
||||||
|
os.chmod(env_file, 0o600)
|
||||||
|
text = re.sub(r"^ExecStart=", f"EnvironmentFile=-{env_file}\\nExecStart=",
|
||||||
|
text, count=1, flags=re.M)
|
||||||
|
print(f" token stored in {env_file} (mode 0600)")
|
||||||
|
|
||||||
|
led_active_args = " --led-active-low" if led_active_low == "1" else ""
|
||||||
|
# An empty pin means --no-error-led: the status LED blinks instead.
|
||||||
|
if error_led_pin:
|
||||||
|
error_args = f" --error-led-pin {error_led_pin}"
|
||||||
|
if error_led_active_low == "1":
|
||||||
|
error_args += " --error-led-active-low"
|
||||||
|
if error_pulse:
|
||||||
|
error_args += f" --error-pulse {error_pulse}"
|
||||||
|
else:
|
||||||
|
error_args = " --no-error-led"
|
||||||
|
|
||||||
|
# Exposing the bridge beyond loopback is explicit: --allow-remote is required,
|
||||||
|
# and the token protects the API once the caller is not on this machine.
|
||||||
|
host_args = ""
|
||||||
|
if bridge_host != "127.0.0.1":
|
||||||
|
host_args = f" --host {bridge_host}"
|
||||||
|
if allow_remote == "1":
|
||||||
|
host_args += " --allow-remote"
|
||||||
|
|
||||||
command = (
|
command = (
|
||||||
f"{wrapper} --serve "
|
f"{wrapper} --serve "
|
||||||
f"--port {device} --bridge-port {port} --mode login "
|
f"--port {device} --bridge-port {port} --mode login "
|
||||||
f"--led-pin {led_pin} --led-mode {led_mode} --heartbeat {heartbeat}"
|
f"--led-pin {led_pin} --led-mode {led_mode} --heartbeat {heartbeat}"
|
||||||
f"{origin_args}"
|
f"{led_active_args}{error_args}{host_args}{origin_args}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -346,9 +453,10 @@ cat <<EOF
|
|||||||
|
|
||||||
$(log "Installed")
|
$(log "Installed")
|
||||||
|
|
||||||
Bridge : http://127.0.0.1:$BRIDGE_PORT
|
Bridge : http://127.0.0.1:$BRIDGE_PORT$( [[ "$BRIDGE_HOST" != "127.0.0.1" ]] && echo " (also on http://$BRIDGE_HOST:$BRIDGE_PORT)" )
|
||||||
Device : $SERIAL_DEVICE
|
Device : $SERIAL_DEVICE
|
||||||
LED : GPIO $LED_PIN, $LED_MODE mode
|
LED : GPIO $LED_PIN, $LED_MODE mode$( [[ "$LED_ACTIVE_LOW" -eq 1 ]] && echo ", active-low" )
|
||||||
|
Error LED : $( [[ -n "$ERROR_LED_PIN" ]] && echo "GPIO $ERROR_LED_PIN, ${ERROR_PULSE}s on a bad read" || echo "none (status LED blinks)" )
|
||||||
Heartbeat : ${HEARTBEAT}s (presence/LED window)
|
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
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.7 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 232 KiB |
+139
-15
@@ -55,8 +55,10 @@ import time
|
|||||||
from collections import deque
|
from collections import deque
|
||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
from typing import Any, Callable, Deque, Dict, List, Optional
|
from typing import Any, Callable, Deque, Dict, List, Optional
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
from .card import EVENT_INVALID, EVENT_INSERTED, EVENT_REMOVED, CardEvent
|
from .card import EVENT_INVALID, EVENT_INSERTED, EVENT_REMOVED, CardEvent
|
||||||
|
from .webui import render_page
|
||||||
|
|
||||||
#: Reader modes. ``login`` publishes inserts only (a tap = an authentication
|
#: Reader modes. ``login`` publishes inserts only (a tap = an authentication
|
||||||
#: attempt). ``monitor`` publishes inserts and removals both.
|
#: attempt). ``monitor`` publishes inserts and removals both.
|
||||||
@@ -325,14 +327,31 @@ class _Handler(BaseHTTPRequestHandler):
|
|||||||
|
|
||||||
# -- helpers ----------------------------------------------------------
|
# -- helpers ----------------------------------------------------------
|
||||||
|
|
||||||
|
def _origin_is_loopback(self, origin: str) -> bool:
|
||||||
|
"""True when *origin* is this server itself or a loopback address.
|
||||||
|
|
||||||
|
A browser sends ``Origin`` even for same-origin requests, so without
|
||||||
|
this the status page served from this very port could not call its own
|
||||||
|
API. A remote site cannot forge ``Origin: http://127.0.0.1``, so
|
||||||
|
trusting loopback here does not widen what an attacker can reach.
|
||||||
|
"""
|
||||||
|
host = self.headers.get("Host")
|
||||||
|
if host and origin in ("http://" + host, "https://" + host):
|
||||||
|
return True
|
||||||
|
try:
|
||||||
|
hostname = (urlsplit(origin).hostname or "").lower()
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
return hostname in ("127.0.0.1", "localhost", "::1", "0.0.0.0")
|
||||||
|
|
||||||
def _cors_ok(self) -> bool:
|
def _cors_ok(self) -> bool:
|
||||||
"""Refuse cross-origin reads unless the origin was allow-listed."""
|
"""Refuse cross-origin reads unless the origin was allow-listed."""
|
||||||
origin = self.headers.get("Origin")
|
origin = self.headers.get("Origin")
|
||||||
if not origin:
|
if not origin:
|
||||||
return True # same-origin fetch or a non-browser client
|
return True # same-origin fetch or a non-browser client
|
||||||
|
if self._origin_is_loopback(origin):
|
||||||
|
return True
|
||||||
allowed: List[str] = getattr(self.server, "allowed_origins", [])
|
allowed: List[str] = getattr(self.server, "allowed_origins", [])
|
||||||
if not allowed:
|
|
||||||
return False # browsers only; opt out entirely
|
|
||||||
return origin in allowed
|
return origin in allowed
|
||||||
|
|
||||||
def _token_ok(self) -> bool:
|
def _token_ok(self) -> bool:
|
||||||
@@ -351,26 +370,52 @@ class _Handler(BaseHTTPRequestHandler):
|
|||||||
supplied = pair.split("=", 1)[1]
|
supplied = pair.split("=", 1)[1]
|
||||||
return secrets.compare_digest(supplied, required)
|
return secrets.compare_digest(supplied, required)
|
||||||
|
|
||||||
|
def _allowed_origin(self) -> Optional[str]:
|
||||||
|
"""The Origin to echo in ``Access-Control-Allow-Origin``, or None.
|
||||||
|
|
||||||
|
Same-origin and loopback callers are echoed so the dashboard can read
|
||||||
|
its own API; any other origin must be on the allow-list.
|
||||||
|
"""
|
||||||
|
origin = self.headers.get("Origin")
|
||||||
|
if not origin:
|
||||||
|
return None
|
||||||
|
if self._origin_is_loopback(origin):
|
||||||
|
return origin
|
||||||
|
if origin in getattr(self.server, "allowed_origins", []):
|
||||||
|
return origin
|
||||||
|
return None
|
||||||
|
|
||||||
def _send_json(self, payload: Any, status: int = 200) -> None:
|
def _send_json(self, payload: Any, status: int = 200) -> None:
|
||||||
body = json.dumps(payload).encode("utf-8")
|
body = json.dumps(payload).encode("utf-8")
|
||||||
self.send_response(status)
|
self.send_response(status)
|
||||||
self.send_header("Content-Type", "application/json")
|
self.send_header("Content-Type", "application/json")
|
||||||
self.send_header("Content-Length", str(len(body)))
|
self.send_header("Content-Length", str(len(body)))
|
||||||
self.send_header("Cache-Control", "no-store")
|
self.send_header("Cache-Control", "no-store")
|
||||||
origin = self.headers.get("Origin")
|
allowed = self._allowed_origin()
|
||||||
if origin and origin in getattr(self.server, "allowed_origins", []):
|
if allowed:
|
||||||
self.send_header("Access-Control-Allow-Origin", origin)
|
self.send_header("Access-Control-Allow-Origin", allowed)
|
||||||
self.send_header("Vary", "Origin")
|
self.send_header("Vary", "Origin")
|
||||||
self.end_headers()
|
self.end_headers()
|
||||||
self.wfile.write(body)
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
def _send_bytes(self, body: bytes, content_type: str, status: int = 200) -> None:
|
||||||
|
self.send_response(status)
|
||||||
|
self.send_header("Content-Type", content_type)
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.send_header("Cache-Control", "no-store")
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
def _send_page(self) -> None:
|
||||||
|
self._send_bytes(render_page(), "text/html; charset=utf-8")
|
||||||
|
|
||||||
def _send_no_content(self) -> None:
|
def _send_no_content(self) -> None:
|
||||||
self.send_response(204)
|
self.send_response(204)
|
||||||
self.send_header("Cache-Control", "no-store")
|
self.send_header("Cache-Control", "no-store")
|
||||||
self.send_header("Content-Length", "0")
|
self.send_header("Content-Length", "0")
|
||||||
origin = self.headers.get("Origin")
|
allowed = self._allowed_origin()
|
||||||
if origin and origin in getattr(self.server, "allowed_origins", []):
|
if allowed:
|
||||||
self.send_header("Access-Control-Allow-Origin", origin)
|
self.send_header("Access-Control-Allow-Origin", allowed)
|
||||||
self.send_header("Vary", "Origin")
|
self.send_header("Vary", "Origin")
|
||||||
self.end_headers()
|
self.end_headers()
|
||||||
|
|
||||||
@@ -388,6 +433,16 @@ class _Handler(BaseHTTPRequestHandler):
|
|||||||
def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API
|
def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API
|
||||||
path = self.path.split("?", 1)[0].rstrip("/") or "/"
|
path = self.path.split("?", 1)[0].rstrip("/") or "/"
|
||||||
|
|
||||||
|
# The status page is served before the CORS and token checks, on
|
||||||
|
# purpose: it is static, reveals nothing on its own, and has to be able
|
||||||
|
# to load so it can prompt for a token when the API demands one.
|
||||||
|
if path == "/":
|
||||||
|
if getattr(self.server, "serve_page", True):
|
||||||
|
self._send_page()
|
||||||
|
else:
|
||||||
|
self._send_json({"error": "not found", "path": "/"}, status=404)
|
||||||
|
return
|
||||||
|
|
||||||
if path == "/api/health":
|
if path == "/api/health":
|
||||||
self._send_json(self._hub.health())
|
self._send_json(self._hub.health())
|
||||||
return
|
return
|
||||||
@@ -411,6 +466,15 @@ class _Handler(BaseHTTPRequestHandler):
|
|||||||
self._send_json(self._hub.current_card())
|
self._send_json(self._hub.current_card())
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if path == "/api/led":
|
||||||
|
controller = getattr(self.server, "led", None)
|
||||||
|
if controller is None:
|
||||||
|
self._send_json({"error": "LED control not available"},
|
||||||
|
status=503)
|
||||||
|
return
|
||||||
|
self._send_json(controller.snapshot())
|
||||||
|
return
|
||||||
|
|
||||||
if path == "/api/taps":
|
if path == "/api/taps":
|
||||||
limit = 20
|
limit = 20
|
||||||
if "?" in self.path:
|
if "?" in self.path:
|
||||||
@@ -432,6 +496,58 @@ class _Handler(BaseHTTPRequestHandler):
|
|||||||
def do_POST(self) -> None: # noqa: N802
|
def do_POST(self) -> None: # noqa: N802
|
||||||
path = self.path.split("?", 1)[0].rstrip("/") or "/"
|
path = self.path.split("?", 1)[0].rstrip("/") or "/"
|
||||||
|
|
||||||
|
if path == "/api/led":
|
||||||
|
if not self._cors_ok():
|
||||||
|
self._send_json({"error": "origin not allowed"}, status=403)
|
||||||
|
return
|
||||||
|
if not self._token_ok():
|
||||||
|
self._send_json({"error": "missing or invalid token"}, status=401)
|
||||||
|
return
|
||||||
|
controller = getattr(self.server, "led", None)
|
||||||
|
if controller is None:
|
||||||
|
self._send_json({"error": "LED control not available"},
|
||||||
|
status=503)
|
||||||
|
return
|
||||||
|
|
||||||
|
length = int(self.headers.get("Content-Length") or 0)
|
||||||
|
raw = self.rfile.read(length) if length else b"{}"
|
||||||
|
try:
|
||||||
|
payload = json.loads(raw.decode("utf-8") or "{}")
|
||||||
|
except ValueError:
|
||||||
|
self._send_json({"error": "invalid JSON body"}, status=400)
|
||||||
|
return
|
||||||
|
|
||||||
|
which = str(payload.get("led", ""))
|
||||||
|
if which == "release" or payload.get("release"):
|
||||||
|
controller.release()
|
||||||
|
self._send_json(controller.snapshot())
|
||||||
|
return
|
||||||
|
|
||||||
|
if "on" not in payload:
|
||||||
|
self._send_json(
|
||||||
|
{"error": "expected {\"led\": \"status|error\", "
|
||||||
|
"\"on\": true|false}, or {\"release\": true}"},
|
||||||
|
status=400,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if which not in ("status", "error"):
|
||||||
|
self._send_json(
|
||||||
|
{"error": "unknown led", "valid": ["status", "error"]},
|
||||||
|
status=400,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if not controller.set_led(which, bool(payload.get("on"))):
|
||||||
|
# The LED exists in configuration but has no usable pin, so
|
||||||
|
# there is nothing to switch. Say so rather than 200.
|
||||||
|
self._send_json(
|
||||||
|
{"error": "that LED is not available",
|
||||||
|
"state": controller.snapshot()},
|
||||||
|
status=409,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
self._send_json(controller.snapshot())
|
||||||
|
return
|
||||||
|
|
||||||
if path == "/api/card/claim":
|
if path == "/api/card/claim":
|
||||||
if not self._cors_ok():
|
if not self._cors_ok():
|
||||||
self._send_json({"error": "origin not allowed"}, status=403)
|
self._send_json({"error": "origin not allowed"}, status=403)
|
||||||
@@ -476,10 +592,10 @@ class _Handler(BaseHTTPRequestHandler):
|
|||||||
self._send_json({"mode": mode})
|
self._send_json({"mode": mode})
|
||||||
|
|
||||||
def do_OPTIONS(self) -> None: # noqa: N802
|
def do_OPTIONS(self) -> None: # noqa: N802
|
||||||
origin = self.headers.get("Origin")
|
|
||||||
self.send_response(204)
|
self.send_response(204)
|
||||||
if origin and origin in getattr(self.server, "allowed_origins", []):
|
allowed = self._allowed_origin()
|
||||||
self.send_header("Access-Control-Allow-Origin", origin)
|
if allowed:
|
||||||
|
self.send_header("Access-Control-Allow-Origin", allowed)
|
||||||
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||||
self.send_header("Access-Control-Allow-Headers", "Content-Type, X-Auth-Token")
|
self.send_header("Access-Control-Allow-Headers", "Content-Type, X-Auth-Token")
|
||||||
self.send_header("Vary", "Origin")
|
self.send_header("Vary", "Origin")
|
||||||
@@ -495,9 +611,9 @@ class _Handler(BaseHTTPRequestHandler):
|
|||||||
self.send_header("Content-Type", "text/event-stream")
|
self.send_header("Content-Type", "text/event-stream")
|
||||||
self.send_header("Cache-Control", "no-store")
|
self.send_header("Cache-Control", "no-store")
|
||||||
self.send_header("Connection", "keep-alive")
|
self.send_header("Connection", "keep-alive")
|
||||||
origin = self.headers.get("Origin")
|
allowed = self._allowed_origin()
|
||||||
if origin and origin in getattr(self.server, "allowed_origins", []):
|
if allowed:
|
||||||
self.send_header("Access-Control-Allow-Origin", origin)
|
self.send_header("Access-Control-Allow-Origin", allowed)
|
||||||
self.send_header("Vary", "Origin")
|
self.send_header("Vary", "Origin")
|
||||||
self.end_headers()
|
self.end_headers()
|
||||||
|
|
||||||
@@ -532,11 +648,19 @@ def create_server(
|
|||||||
allowed_origins: Optional[List[str]] = None,
|
allowed_origins: Optional[List[str]] = None,
|
||||||
token: Optional[str] = None,
|
token: Optional[str] = None,
|
||||||
logger: Optional[Callable[[str], None]] = None,
|
logger: Optional[Callable[[str], None]] = None,
|
||||||
|
serve_page: bool = True,
|
||||||
|
led: Any = None,
|
||||||
) -> ThreadingHTTPServer:
|
) -> ThreadingHTTPServer:
|
||||||
"""Build (but do not start) the HTTP server around *hub*."""
|
"""Build (but do not start) the HTTP server around *hub*.
|
||||||
|
|
||||||
|
*led* is the LED controller, exposed for the manual override at
|
||||||
|
``/api/led``. It is optional: without it the endpoint reports 503.
|
||||||
|
"""
|
||||||
server = ThreadingHTTPServer((host, port), _Handler)
|
server = ThreadingHTTPServer((host, port), _Handler)
|
||||||
server.daemon_threads = True
|
server.daemon_threads = True
|
||||||
server.hub = hub # type: ignore[attr-defined]
|
server.hub = hub # type: ignore[attr-defined]
|
||||||
|
server.led = led # type: ignore[attr-defined]
|
||||||
|
server.serve_page = serve_page # type: ignore[attr-defined]
|
||||||
server.allowed_origins = list(allowed_origins or []) # type: ignore[attr-defined]
|
server.allowed_origins = list(allowed_origins or []) # type: ignore[attr-defined]
|
||||||
server.token = token or os.environ.get("RDM_BRIDGE_TOKEN") or None # type: ignore[attr-defined]
|
server.token = token or os.environ.get("RDM_BRIDGE_TOKEN") or None # type: ignore[attr-defined]
|
||||||
server.logger = logger # type: ignore[attr-defined]
|
server.logger = logger # type: ignore[attr-defined]
|
||||||
|
|||||||
@@ -29,8 +29,10 @@ from .card import (
|
|||||||
)
|
)
|
||||||
from .demo import run_demo
|
from .demo import run_demo
|
||||||
from .led import (
|
from .led import (
|
||||||
|
DEFAULT_ERROR_LED_PIN,
|
||||||
DEFAULT_LED_MODE,
|
DEFAULT_LED_MODE,
|
||||||
DEFAULT_LED_PIN,
|
DEFAULT_LED_PIN,
|
||||||
|
ERROR_BLINK_SECONDS,
|
||||||
MODES as LED_MODES,
|
MODES as LED_MODES,
|
||||||
create as create_led,
|
create as create_led,
|
||||||
)
|
)
|
||||||
@@ -143,6 +145,11 @@ def _build_led(args):
|
|||||||
if args.no_led:
|
if args.no_led:
|
||||||
return create_led(enabled=False)
|
return create_led(enabled=False)
|
||||||
|
|
||||||
|
# A dedicated error LED is the default. The old single-LED behaviour is
|
||||||
|
# still reachable: --no-error-led blinks the status LED instead, and
|
||||||
|
# --error-led-pin with the status pin's number does the same thing.
|
||||||
|
error_pin = None if args.no_error_led else args.error_led_pin
|
||||||
|
|
||||||
# Only pull in gpiozero when a LED was actually asked for, so a headless
|
# Only pull in gpiozero when a LED was actually asked for, so a headless
|
||||||
# install does not grow the dependency set.
|
# install does not grow the dependency set.
|
||||||
ensure_dependencies(
|
ensure_dependencies(
|
||||||
@@ -156,7 +163,11 @@ def _build_led(args):
|
|||||||
pin=args.led_pin,
|
pin=args.led_pin,
|
||||||
enabled=True,
|
enabled=True,
|
||||||
mode=args.led_mode,
|
mode=args.led_mode,
|
||||||
|
active_high=not args.led_active_low,
|
||||||
error_blink=not args.led_no_error_blink,
|
error_blink=not args.led_no_error_blink,
|
||||||
|
error_pin=error_pin,
|
||||||
|
error_active_high=not args.error_led_active_low,
|
||||||
|
error_pulse=args.error_pulse,
|
||||||
logger=lambda message: print(" {}".format(message)),
|
logger=lambda message: print(" {}".format(message)),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -212,6 +223,8 @@ def _run_bridge(args) -> int:
|
|||||||
allowed_origins=args.allow_origin,
|
allowed_origins=args.allow_origin,
|
||||||
token=args.token,
|
token=args.token,
|
||||||
logger=print if args.verbose else None,
|
logger=print if args.verbose else None,
|
||||||
|
serve_page=not args.no_page,
|
||||||
|
led=led,
|
||||||
)
|
)
|
||||||
|
|
||||||
server_thread = threading.Thread(target=server.serve_forever, daemon=True,
|
server_thread = threading.Thread(target=server.serve_forever, daemon=True,
|
||||||
@@ -220,7 +233,10 @@ def _run_bridge(args) -> int:
|
|||||||
|
|
||||||
print(" OK reader on {} ; bridge on http://{}:{} (mode: {})".format(
|
print(" OK reader on {} ; bridge on http://{}:{} (mode: {})".format(
|
||||||
device, args.host, args.bridge_port, args.mode))
|
device, args.host, args.bridge_port, args.mode))
|
||||||
|
if not args.no_page:
|
||||||
|
print(" GET / <- status page in a browser")
|
||||||
print(" GET /api/card <- the card on the reader right now")
|
print(" GET /api/card <- the card on the reader right now")
|
||||||
|
print(" GET /api/led <- LED state; POST to switch one by hand")
|
||||||
print(" POST /api/card/claim (single use, for one login)")
|
print(" POST /api/card/claim (single use, for one login)")
|
||||||
print(" GET /api/events (SSE stream)")
|
print(" GET /api/events (SSE stream)")
|
||||||
print(" GET /api/last /api/health")
|
print(" GET /api/last /api/health")
|
||||||
@@ -303,6 +319,8 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
"(or set RDM_BRIDGE_TOKEN)")
|
"(or set RDM_BRIDGE_TOKEN)")
|
||||||
bridge.add_argument("--allow-remote", action="store_true",
|
bridge.add_argument("--allow-remote", action="store_true",
|
||||||
help="permit binding a non-loopback address")
|
help="permit binding a non-loopback address")
|
||||||
|
bridge.add_argument("--no-page", action="store_true",
|
||||||
|
help="do not serve the status page at GET /")
|
||||||
bridge.add_argument("--verbose", action="store_true",
|
bridge.add_argument("--verbose", action="store_true",
|
||||||
help="also print each event and each HTTP request")
|
help="also print each event and each HTTP request")
|
||||||
|
|
||||||
@@ -318,6 +336,21 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
"blinking means a bad read")
|
"blinking means a bad read")
|
||||||
led.add_argument("--led-no-error-blink", action="store_true",
|
led.add_argument("--led-no-error-blink", action="store_true",
|
||||||
help="do not blink the LED on a bad read")
|
help="do not blink the LED on a bad read")
|
||||||
|
led.add_argument("--led-active-low", action="store_true",
|
||||||
|
help="LED is wired active-low (cathode to the pin, anode "
|
||||||
|
"to 3.3 V); inverts the signal")
|
||||||
|
led.add_argument("--error-led-pin", type=int, default=DEFAULT_ERROR_LED_PIN,
|
||||||
|
metavar="N",
|
||||||
|
help="BCM GPIO of a dedicated error LED, lit briefly on a "
|
||||||
|
"bad read (default: %(default)s, matching the "
|
||||||
|
"workstation app)")
|
||||||
|
led.add_argument("--no-error-led", action="store_true",
|
||||||
|
help="no dedicated error LED; blink the status LED instead")
|
||||||
|
led.add_argument("--error-led-active-low", action="store_true",
|
||||||
|
help="the error LED is wired active-low")
|
||||||
|
led.add_argument("--error-pulse", type=float, default=ERROR_BLINK_SECONDS,
|
||||||
|
help="seconds to hold the error LED on a bad read "
|
||||||
|
"(default: %(default)s)")
|
||||||
return parser
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+256
-45
@@ -12,21 +12,35 @@ differences:
|
|||||||
* It is explicit. Whether an LED is actually attached is reported, so "no light
|
* It is explicit. Whether an LED is actually attached is reported, so "no light
|
||||||
came on" is distinguishable from "the card was not read".
|
came on" is distinguishable from "the card was not read".
|
||||||
|
|
||||||
The default pin (23) matches the workstation app. **Use a separate LED from the
|
Two LEDs, because one cannot say two things at once:
|
||||||
workstation's if both run on one device**, since two processes driving one pin
|
|
||||||
will fight.
|
* the **status** LED (default GPIO 23) tracks the card: lit while one is on the
|
||||||
|
reader, in ``presence`` mode.
|
||||||
|
* the **error** LED (default GPIO 24) is for a bad read: dark normally, lit
|
||||||
|
briefly on a checksum failure. Without it, ``presence`` mode - the default -
|
||||||
|
has no way at all to report an invalid card, because the status light is
|
||||||
|
already carrying the card's presence.
|
||||||
|
|
||||||
|
The default status pin (23) matches the workstation app. **Use a separate LED
|
||||||
|
from the workstation's if both run on one device**, since two processes driving
|
||||||
|
one pin will fight.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import queue
|
import queue
|
||||||
import threading
|
import threading
|
||||||
|
import time
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any, Callable, Optional
|
from typing import Any, Callable, Optional, Tuple
|
||||||
|
|
||||||
#: GPIO pin of the status LED. Chosen to match the workstation app.
|
#: GPIO pin of the status LED. Chosen to match the workstation app.
|
||||||
DEFAULT_LED_PIN = 23
|
DEFAULT_LED_PIN = 23
|
||||||
|
|
||||||
|
#: GPIO pin of the error LED. Separate from the status pin so the two can be
|
||||||
|
#: read independently: one says "a card is here", the other "the read failed".
|
||||||
|
DEFAULT_ERROR_LED_PIN = 24
|
||||||
|
|
||||||
#: How long to hold an error indication, in seconds.
|
#: How long to hold an error indication, in seconds.
|
||||||
ERROR_BLINK_SECONDS = 1.0
|
ERROR_BLINK_SECONDS = 1.0
|
||||||
|
|
||||||
@@ -59,14 +73,18 @@ class LEDStatus:
|
|||||||
mode: str = ERROR
|
mode: str = ERROR
|
||||||
state: bool = False
|
state: bool = False
|
||||||
reason: str = ""
|
reason: str = ""
|
||||||
|
#: False when the LED is wired active-low (cathode to the pin).
|
||||||
|
active_high: bool = True
|
||||||
|
|
||||||
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,
|
||||||
"mode": self.mode,
|
"mode": self.mode,
|
||||||
"state": "on" if self.state else "off",
|
"state": "on" if self.state else "off",
|
||||||
"reason": self.reason,
|
"reason": self.reason,
|
||||||
|
"active_high": self.active_high,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -147,6 +165,39 @@ class _Blinker(threading.Thread):
|
|||||||
self._stop.set()
|
self._stop.set()
|
||||||
|
|
||||||
|
|
||||||
|
def _open_led(
|
||||||
|
pin: int, active_high: bool, log: Callable[[str], None], label: str
|
||||||
|
) -> Tuple[Any, bool, str]:
|
||||||
|
"""Build the pin object for *pin*.
|
||||||
|
|
||||||
|
Returns ``(led, usable, reason)``. *led* is a :class:`NullLED` when no pin
|
||||||
|
is usable, so callers never have to special-case it.
|
||||||
|
|
||||||
|
The library import is checked **separately** from the pin construction on
|
||||||
|
purpose: ``gpiozero`` raises ``BadPinFactory`` when no pin provider works,
|
||||||
|
and that is a subclass of ``ImportError``. Catching ``ImportError`` around
|
||||||
|
the pin construction would therefore report "gpiozero is not installed"
|
||||||
|
even when it is installed and simply has no usable pin library - on a Pi
|
||||||
|
missing ``lgpio`` or ``RPi.GPIO`` that message sends you to the wrong fix.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
import gpiozero # noqa: F401
|
||||||
|
except ImportError as exc:
|
||||||
|
reason = "gpiozero not installed: {}".format(exc)
|
||||||
|
log("{} unavailable: {}".format(label, reason))
|
||||||
|
return NullLED(pin, reason), False, reason
|
||||||
|
|
||||||
|
try:
|
||||||
|
built = GPIOLED(pin, active_high=active_high)
|
||||||
|
except Exception as exc:
|
||||||
|
# No usable pin factory (not a Pi), pin already in use, bad pin.
|
||||||
|
reason = "no usable GPIO on pin {}: {}".format(pin, exc)
|
||||||
|
log("{} unavailable: {}".format(label, reason))
|
||||||
|
return NullLED(pin, reason), False, reason
|
||||||
|
|
||||||
|
return built, True, "ready"
|
||||||
|
|
||||||
|
|
||||||
class StatusLED:
|
class StatusLED:
|
||||||
"""Non-blocking LED controller driven by card events.
|
"""Non-blocking LED controller driven by card events.
|
||||||
|
|
||||||
@@ -162,52 +213,79 @@ class StatusLED:
|
|||||||
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,
|
||||||
|
error_pin: Optional[int] = None,
|
||||||
|
error_active_high: bool = True,
|
||||||
|
error_pulse: float = ERROR_BLINK_SECONDS,
|
||||||
):
|
):
|
||||||
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 DEFAULT_LED_MODE
|
||||||
self._error_blink = error_blink
|
self._error_blink = error_blink
|
||||||
self._enabled = enabled
|
self._enabled = enabled
|
||||||
self._blinker: Optional[_Blinker] = None
|
self._blinker: Optional[_Blinker] = None
|
||||||
|
self._error_pulse = max(0.0, float(error_pulse))
|
||||||
self._state = False
|
self._state = False
|
||||||
|
self._error_until: Optional[float] = None
|
||||||
|
self._error_state = False
|
||||||
|
#: Set while the status page is forcing a LED, so card events and the
|
||||||
|
#: error pulse leave it alone.
|
||||||
|
self._manual_status = False
|
||||||
|
self._manual_error = False
|
||||||
self._queue: "queue.Queue[Optional[str]]" = queue.Queue()
|
self._queue: "queue.Queue[Optional[str]]" = queue.Queue()
|
||||||
self._thread: Optional[threading.Thread] = None
|
self._thread: Optional[threading.Thread] = None
|
||||||
|
|
||||||
if not enabled:
|
if not enabled:
|
||||||
self.led: Any = NullLED(None, "LED disabled (--no-led)")
|
self.led: Any = NullLED(None, "LED disabled (--no-led)")
|
||||||
self.status = LEDStatus(False, None, "null", self._mode, False,
|
self.status = LEDStatus(False, None, "null", self._mode, False,
|
||||||
"disabled")
|
"disabled", active_high)
|
||||||
|
self.error_led: Any = NullLED(None, "LED disabled (--no-led)")
|
||||||
|
self.error_status = LEDStatus(False, None, "null", self._mode,
|
||||||
|
False, "disabled", error_active_high)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Check the library separately from building the pin.
|
self.led, usable, reason = _open_led(pin, active_high, self._log, "LED")
|
||||||
#
|
self.status = LEDStatus(usable, pin,
|
||||||
# gpiozero raises BadPinFactory when no pin provider works, and that is
|
"gpiozero" if usable else "null", self._mode,
|
||||||
# a subclass of ImportError, so catching ImportError around the pin
|
False, reason, active_high)
|
||||||
# construction would report "gpiozero is not installed" even when it is
|
if usable:
|
||||||
# installed and simply has no usable pin library. On a Pi missing lgpio
|
self._log("LED on GPIO {} ({} mode, {}, gpiozero)".format(
|
||||||
# or RPi.GPIO that message sends you to the wrong fix.
|
pin, self._mode,
|
||||||
try:
|
"active-high" if active_high else "active-low"))
|
||||||
import gpiozero # noqa: F401
|
|
||||||
except ImportError as exc:
|
# A dedicated error LED, when one is wired. It reports a bad read on its
|
||||||
reason = "gpiozero not installed: {}".format(exc)
|
# own pin, so the status light stays free to mean "a card is here".
|
||||||
self.led = NullLED(pin, reason)
|
if error_pin is not None and error_pin == pin:
|
||||||
self.status = LEDStatus(False, pin, "null", self._mode, False,
|
# Two LEDs on one pin cannot be driven independently, and opening
|
||||||
reason)
|
# the pin twice makes the second open fail anyway. Treat it as "no
|
||||||
self._log("LED unavailable: {}".format(reason))
|
# dedicated error LED" so the status LED blink is used instead.
|
||||||
|
self._log("Error LED pin {} is the status pin; using the status "
|
||||||
|
"LED to indicate bad reads".format(error_pin))
|
||||||
|
error_pin = None
|
||||||
|
|
||||||
|
if error_pin is None:
|
||||||
|
self.error_led = NullLED(None, "no error LED configured")
|
||||||
|
self.error_status = LEDStatus(False, None, "null", ERROR, False,
|
||||||
|
"not configured", error_active_high)
|
||||||
else:
|
else:
|
||||||
try:
|
self.error_led, e_usable, e_reason = _open_led(
|
||||||
self.led = GPIOLED(pin, active_high=active_high)
|
error_pin, error_active_high, self._log, "Error LED")
|
||||||
self.status = LEDStatus(True, pin, "gpiozero", self._mode,
|
self.error_status = LEDStatus(e_usable, error_pin,
|
||||||
False, "ready")
|
"gpiozero" if e_usable else "null",
|
||||||
self._log("LED on GPIO {} ({} mode, gpiozero)".format(
|
ERROR, False, e_reason,
|
||||||
pin, self._mode))
|
error_active_high)
|
||||||
except Exception as exc:
|
if e_usable:
|
||||||
# No usable pin factory (not a Pi), pin already in use, bad pin.
|
self._log("Error LED on GPIO {} (lit for {}s on a bad read, "
|
||||||
reason = ("no usable GPIO on pin {}: {}".format(pin, exc))
|
"{}, gpiozero)".format(
|
||||||
self.led = NullLED(pin, reason)
|
error_pin, self._error_pulse,
|
||||||
self.status = LEDStatus(False, pin, "null", self._mode, False,
|
"active-high" if error_active_high
|
||||||
reason)
|
else "active-low"))
|
||||||
self._log("LED unavailable: {}".format(reason))
|
|
||||||
|
self._thread = threading.Thread(target=self._worker, daemon=True,
|
||||||
|
name="led-worker")
|
||||||
|
# The worker only needs to wake on a timer when there is an error pulse
|
||||||
|
# to expire. Without one it blocks on the queue, as before.
|
||||||
|
self._poll = (0.05 if (self.error_status.enabled
|
||||||
|
and self._error_pulse > 0) else None)
|
||||||
|
self._thread.start()
|
||||||
|
|
||||||
if self.status.enabled and self._mode == ERROR:
|
if self.status.enabled and self._mode == ERROR:
|
||||||
# A health indicator shows 'alive' immediately. In presence mode the
|
# A health indicator shows 'alive' immediately. In presence mode the
|
||||||
@@ -216,23 +294,108 @@ class StatusLED:
|
|||||||
self._set_state(True)
|
self._set_state(True)
|
||||||
self.status.state = True
|
self.status.state = True
|
||||||
|
|
||||||
self._thread = threading.Thread(target=self._worker, daemon=True,
|
|
||||||
name="led-worker")
|
|
||||||
self._thread.start()
|
|
||||||
|
|
||||||
# -- event hooks ------------------------------------------------------
|
# -- event hooks ------------------------------------------------------
|
||||||
|
|
||||||
def insert(self) -> None:
|
def insert(self) -> None:
|
||||||
"""A valid card was read: off in 'error' mode, on in 'presence' mode."""
|
"""A valid card was read: off in 'error' mode, on in 'presence' mode."""
|
||||||
|
if self._manual_status:
|
||||||
|
return
|
||||||
self._send(OFF if self._mode == ERROR else ON)
|
self._send(OFF if self._mode == ERROR else ON)
|
||||||
|
|
||||||
def remove(self) -> None:
|
def remove(self) -> None:
|
||||||
"""The card left the reader: dark in 'presence' mode; 'error' mode stays on."""
|
"""The card left the reader: dark in 'presence' mode; 'error' mode stays on."""
|
||||||
|
if self._manual_status:
|
||||||
|
return
|
||||||
self._send(ON if self._mode == ERROR else OFF)
|
self._send(ON if self._mode == ERROR else OFF)
|
||||||
|
|
||||||
def error(self) -> None:
|
def error(self) -> None:
|
||||||
"""A bad read: blink, if blinking is enabled."""
|
"""A bad read: blink the status LED, or pulse the error LED if wired.
|
||||||
self._send(BLINK if self._error_blink else ON)
|
|
||||||
|
With a dedicated error LED the indication goes there and the status
|
||||||
|
light is left alone, so the two LEDs stay independent: one says "a card
|
||||||
|
is here", the other "the read failed". Without one, the status LED
|
||||||
|
blinks as before - but only in ``error`` mode, since a blink and "card
|
||||||
|
present" cannot both be shown on one light.
|
||||||
|
"""
|
||||||
|
if self._manual_status or self._manual_error:
|
||||||
|
return
|
||||||
|
if self.error_status.enabled and self._error_pulse > 0:
|
||||||
|
self._error_until = time.monotonic() + self._error_pulse
|
||||||
|
return
|
||||||
|
if self._error_blink:
|
||||||
|
self._send(BLINK)
|
||||||
|
|
||||||
|
def _pulse_done(self, now: float) -> bool:
|
||||||
|
return self._error_until is not None and now >= self._error_until
|
||||||
|
|
||||||
|
def _error_indicate(self, on: bool) -> None:
|
||||||
|
"""Set the error LED, remembering the state for :meth:`close`."""
|
||||||
|
if on:
|
||||||
|
self.error_led.on()
|
||||||
|
else:
|
||||||
|
self.error_led.off()
|
||||||
|
self._error_state = on
|
||||||
|
self.error_status.state = on
|
||||||
|
|
||||||
|
# -- manual control (diagnostics) -------------------------------------
|
||||||
|
|
||||||
|
def set_led(self, which: str, on: bool) -> bool:
|
||||||
|
"""Force one LED on or off by hand. Returns False for an unknown name.
|
||||||
|
|
||||||
|
This exists to answer "can the software drive the pin at all?", which
|
||||||
|
separates a wiring fault from a configuration one. It latches: the
|
||||||
|
forced state holds until :meth:`release` or the next card event, so a
|
||||||
|
light turned on from the status page does not go dark when the next
|
||||||
|
poll runs.
|
||||||
|
"""
|
||||||
|
if which == "status":
|
||||||
|
if not self.status.enabled:
|
||||||
|
return False
|
||||||
|
self._manual_status = True
|
||||||
|
self._send(ON if on else OFF)
|
||||||
|
# Record the requested state now: the worker applies it
|
||||||
|
# asynchronously, and a snapshot taken before it runs would
|
||||||
|
# otherwise contradict the response we just sent.
|
||||||
|
self._set_state(bool(on))
|
||||||
|
return True
|
||||||
|
if which == "error":
|
||||||
|
if not self.error_status.enabled:
|
||||||
|
return False
|
||||||
|
self._manual_error = True
|
||||||
|
# Cancel any pulse in flight so it cannot fight the manual state.
|
||||||
|
self._error_until = None
|
||||||
|
self._error_indicate(bool(on))
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def release(self) -> None:
|
||||||
|
"""Drop any manual override and let card events drive the LEDs again."""
|
||||||
|
self._manual_status = False
|
||||||
|
self._manual_error = False
|
||||||
|
self._error_until = None
|
||||||
|
if self.status.enabled:
|
||||||
|
# Return to the resting state for the current mode, recorded now so
|
||||||
|
# a snapshot taken before the worker runs agrees with it.
|
||||||
|
resting = self._mode == ERROR
|
||||||
|
self._send(ON if resting else OFF)
|
||||||
|
self._set_state(resting)
|
||||||
|
if self.error_status.enabled:
|
||||||
|
self._error_indicate(False)
|
||||||
|
|
||||||
|
def snapshot(self) -> dict:
|
||||||
|
"""Both LEDs' state, for ``/api/led`` and the status page."""
|
||||||
|
return {
|
||||||
|
"manual": self._manual_status or self._manual_error,
|
||||||
|
"status": self._led_info(self.status, self._manual_status),
|
||||||
|
"error": self._led_info(self.error_status, self._manual_error),
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _led_info(status: "LEDStatus", manual: bool) -> dict:
|
||||||
|
info = status.to_dict()
|
||||||
|
info["manual"] = manual
|
||||||
|
info["on"] = bool(status.state)
|
||||||
|
return info
|
||||||
|
|
||||||
# -- control ----------------------------------------------------------
|
# -- control ----------------------------------------------------------
|
||||||
|
|
||||||
@@ -246,7 +409,12 @@ class StatusLED:
|
|||||||
|
|
||||||
def _worker(self) -> None:
|
def _worker(self) -> None:
|
||||||
while True:
|
while True:
|
||||||
command = self._queue.get()
|
try:
|
||||||
|
command = self._queue.get(timeout=self._poll)
|
||||||
|
except queue.Empty:
|
||||||
|
# No command pending: run the time-based error pulse, if any.
|
||||||
|
self._tick_error_pulse()
|
||||||
|
continue
|
||||||
if command is None:
|
if command is None:
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
@@ -257,6 +425,20 @@ class StatusLED:
|
|||||||
finally:
|
finally:
|
||||||
self._queue.task_done()
|
self._queue.task_done()
|
||||||
|
|
||||||
|
def _tick_error_pulse(self) -> None:
|
||||||
|
"""Light the error LED for its window, then darken it again."""
|
||||||
|
if self._error_until is None or self._manual_error:
|
||||||
|
return
|
||||||
|
now = time.monotonic()
|
||||||
|
if now < self._error_until:
|
||||||
|
if not self._error_state:
|
||||||
|
self._error_indicate(True)
|
||||||
|
elif self._error_state:
|
||||||
|
self._error_indicate(False)
|
||||||
|
else:
|
||||||
|
# Window passed and already dark: stop tracking it.
|
||||||
|
self._error_until = None
|
||||||
|
|
||||||
def _apply(self, command: str) -> None:
|
def _apply(self, command: str) -> None:
|
||||||
# Blinking is used only for a bad read, and only by 'error' mode.
|
# Blinking is used only for a bad read, and only by 'error' mode.
|
||||||
if command == BLINK and self._mode == ERROR:
|
if command == BLINK and self._mode == ERROR:
|
||||||
@@ -289,26 +471,55 @@ class StatusLED:
|
|||||||
|
|
||||||
def close(self) -> None:
|
def close(self) -> None:
|
||||||
self._stop_blink()
|
self._stop_blink()
|
||||||
|
self._manual_status = False
|
||||||
|
self._manual_error = False
|
||||||
if self._thread is not None:
|
if self._thread is not None:
|
||||||
self._queue.put(None)
|
try:
|
||||||
|
self._queue.put_nowait(None)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
try:
|
try:
|
||||||
self.led.off()
|
self.led.off()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
self._set_state(False)
|
self._set_state(False)
|
||||||
|
try:
|
||||||
|
self.error_led.off()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self.error_status.state = False
|
||||||
try:
|
try:
|
||||||
self.led.close()
|
self.led.close()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
try:
|
||||||
|
self.error_led.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
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 = DEFAULT_LED_MODE,
|
||||||
|
active_high: bool = True,
|
||||||
error_blink: bool = True,
|
error_blink: bool = True,
|
||||||
logger: Optional[Callable[[str], None]] = None,
|
logger: Optional[Callable[[str], None]] = None,
|
||||||
|
error_pin: Optional[int] = None,
|
||||||
|
error_active_high: bool = True,
|
||||||
|
error_pulse: float = ERROR_BLINK_SECONDS,
|
||||||
) -> StatusLED:
|
) -> StatusLED:
|
||||||
"""Build the LED controller, degrading to a no-op when GPIO is unusable."""
|
"""Build the LED controller, degrading to a no-op when GPIO is unusable.
|
||||||
|
|
||||||
|
*active_high* is False for an LED wired active-low - cathode to the GPIO
|
||||||
|
pin, anode to 3.3 V, or a driver that inverts. Getting this wrong does not
|
||||||
|
fail: the light simply does the opposite of what is wanted.
|
||||||
|
|
||||||
|
*error_pin* enables a second, dedicated LED for bad reads. Pass the same
|
||||||
|
value as *pin* to fall back to the single-LED behaviour.
|
||||||
|
"""
|
||||||
return StatusLED(pin=pin, enabled=enabled, mode=mode,
|
return StatusLED(pin=pin, enabled=enabled, mode=mode,
|
||||||
error_blink=error_blink, logger=logger)
|
active_high=active_high,
|
||||||
|
error_blink=error_blink, logger=logger,
|
||||||
|
error_pin=error_pin, error_active_high=error_active_high,
|
||||||
|
error_pulse=error_pulse)
|
||||||
|
|||||||
@@ -0,0 +1,438 @@
|
|||||||
|
"""The status page served at ``GET /`` by the HTTP bridge.
|
||||||
|
|
||||||
|
Kept as a single string in a Python module rather than a packaged ``.html``
|
||||||
|
file on purpose: this way the page is present however the package is
|
||||||
|
installed - source checkout, editable install, or the venv the installer
|
||||||
|
builds - with no ``package-data`` entry to forget.
|
||||||
|
|
||||||
|
The page is read-only by design. It deliberately has no "claim" button:
|
||||||
|
``POST /api/card/claim`` spends the latched tap, so a status page that
|
||||||
|
claimed would silently consume a real login just for being open.
|
||||||
|
|
||||||
|
Everything lives in one file with no external assets, because the device it
|
||||||
|
runs on is usually a Raspberry Pi that may have no internet access at all.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
#: Poll interval for the status panels, in milliseconds. The event log is
|
||||||
|
#: pushed over SSE and does not wait for this.
|
||||||
|
REFRESH_MS = 2000
|
||||||
|
|
||||||
|
#: How many log rows the page keeps before dropping the oldest.
|
||||||
|
MAX_LOG_ROWS = 200
|
||||||
|
|
||||||
|
PAGE_HTML = """<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>RDM card reader</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #14161a; --panel: #1c1f26; --line: #2b3038;
|
||||||
|
--fg: #e6e9ef; --dim: #8b93a1; --accent: #6ea8fe;
|
||||||
|
--ok: #4ade80; --warn: #fbbf24; --bad: #f87171;
|
||||||
|
--mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0; padding: 1rem; background: var(--bg); color: var(--fg);
|
||||||
|
font: 15px/1.45 var(--mono); -webkit-text-size-adjust: 100%;
|
||||||
|
}
|
||||||
|
header {
|
||||||
|
display: flex; align-items: center; gap: .75rem; flex-wrap: wrap;
|
||||||
|
padding-bottom: .75rem; border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
h1 { font-size: 1.05rem; margin: 0; font-weight: 600; letter-spacing: .02em; }
|
||||||
|
h2 { font-size: .8rem; margin: 0 0 .5rem; color: var(--dim);
|
||||||
|
font-weight: 600; text-transform: uppercase; letter-spacing: .08em; }
|
||||||
|
.pill {
|
||||||
|
font-size: .75rem; padding: .2rem .55rem; border-radius: 999px;
|
||||||
|
border: 1px solid var(--line); color: var(--dim); white-space: nowrap;
|
||||||
|
}
|
||||||
|
.pill.up { color: var(--ok); border-color: color-mix(in srgb, var(--ok) 40%, transparent); }
|
||||||
|
.pill.down { color: var(--bad); border-color: color-mix(in srgb, var(--bad) 40%, transparent); }
|
||||||
|
#token {
|
||||||
|
margin-left: auto; background: var(--panel); color: var(--fg);
|
||||||
|
border: 1px solid var(--line); border-radius: 6px;
|
||||||
|
padding: .25rem .5rem; font: inherit; font-size: .78rem; min-width: 14rem;
|
||||||
|
}
|
||||||
|
#token:focus { outline: 1px solid var(--accent); }
|
||||||
|
main { display: grid; gap: 1rem; grid-template-columns: repeat(auto-fit, minmax(19rem, 1fr)); margin: 1rem 0; }
|
||||||
|
.panel { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: .9rem 1rem; }
|
||||||
|
dl { display: grid; grid-template-columns: auto 1fr; gap: .3rem .9rem; margin: 0; }
|
||||||
|
dt { color: var(--dim); font-size: .82rem; }
|
||||||
|
dd { margin: 0; text-align: right; font-variant-numeric: tabular-nums; word-break: break-all; }
|
||||||
|
dd.yes { color: var(--ok); } dd.no { color: var(--dim); }
|
||||||
|
.empty { color: var(--dim); font-style: italic; font-size: .84rem; }
|
||||||
|
.logwrap { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: .9rem 1rem; }
|
||||||
|
#log { list-style: none; margin: 0; padding: 0; max-height: 24rem; overflow-y: auto; }
|
||||||
|
#log li {
|
||||||
|
display: grid; grid-template-columns: 7.5rem 6.5rem 1fr; gap: .6rem;
|
||||||
|
padding: .28rem 0; border-bottom: 1px solid color-mix(in srgb, var(--line) 55%, transparent);
|
||||||
|
font-size: .84rem; align-items: baseline;
|
||||||
|
}
|
||||||
|
#log li:last-child { border-bottom: 0; }
|
||||||
|
.ts { color: var(--dim); }
|
||||||
|
.ev { font-weight: 600; }
|
||||||
|
.inserted .ev { color: var(--ok); }
|
||||||
|
.removed .ev { color: var(--accent); }
|
||||||
|
.invalid .ev { color: var(--bad); }
|
||||||
|
.detail { color: var(--dim); word-break: break-all; }
|
||||||
|
.detail b { color: var(--fg); font-weight: 600; }
|
||||||
|
.ledbtns { display: flex; flex-wrap: wrap; gap: .4rem; margin-top: .7rem; }
|
||||||
|
.ledbtns button {
|
||||||
|
font: inherit; font-size: .8rem; padding: .3rem .6rem; cursor: pointer;
|
||||||
|
color: var(--fg); background: #23272f; border: 1px solid var(--line);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.ledbtns button:hover:not(:disabled) { border-color: var(--accent); }
|
||||||
|
.ledbtns button:disabled { opacity: .4; cursor: not-allowed; }
|
||||||
|
.ledmsg { margin: .6rem 0 0; font-size: .78rem; color: var(--dim); min-height: 1em; }
|
||||||
|
.ledmsg.err { color: var(--bad); }
|
||||||
|
footer { margin-top: 1rem; color: var(--dim); font-size: .78rem; }
|
||||||
|
footer code { color: var(--fg); background: var(--panel); padding: .1rem .3rem; border-radius: 4px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<h1>RDM card reader</h1>
|
||||||
|
<span id="conn" class="pill">connecting…</span>
|
||||||
|
<input id="token" placeholder="token (only if the bridge asks for one)"
|
||||||
|
autocomplete="off" spellcheck="false">
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Reader</h2>
|
||||||
|
<dl>
|
||||||
|
<dt>mode</dt> <dd id="mode">—</dd>
|
||||||
|
<dt>port</dt> <dd id="port">—</dd>
|
||||||
|
<dt>uptime</dt> <dd id="uptime">—</dd>
|
||||||
|
<dt>card present</dt><dd id="present">—</dd>
|
||||||
|
<dt>claimable</dt> <dd id="claimable">—</dd>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Current card</h2>
|
||||||
|
<dl>
|
||||||
|
<dt>id</dt> <dd id="c-id">—</dd>
|
||||||
|
<dt>hex</dt> <dd id="c-hex">—</dd>
|
||||||
|
<dt>type</dt> <dd id="c-type">—</dd>
|
||||||
|
<dt>valid</dt> <dd id="c-valid">—</dd>
|
||||||
|
<dt>age</dt> <dd id="c-age">—</dd>
|
||||||
|
</dl>
|
||||||
|
<p id="c-none" class="empty">No card has been read yet.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Counters</h2>
|
||||||
|
<dl>
|
||||||
|
<dt>inserted</dt> <dd id="n-inserted">—</dd>
|
||||||
|
<dt>removed</dt> <dd id="n-removed">—</dd>
|
||||||
|
<dt>invalid</dt> <dd id="n-invalid">—</dd>
|
||||||
|
<dt>subscribers</dt> <dd id="n-subs">—</dd>
|
||||||
|
<dt>tap / claimed TTL</dt><dd id="n-ttl">—</dd>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h2>LEDs</h2>
|
||||||
|
<dl>
|
||||||
|
<dt>green (status)</dt> <dd id="led-st-state">—</dd>
|
||||||
|
<dt>red (error)</dt> <dd id="led-er-state">—</dd>
|
||||||
|
<dt>status pin</dt> <dd id="led-st-pin">—</dd>
|
||||||
|
<dt>error pin</dt> <dd id="led-er-pin">—</dd>
|
||||||
|
<dt>driven by</dt> <dd id="led-auto">—</dd>
|
||||||
|
</dl>
|
||||||
|
<div class="ledbtns">
|
||||||
|
<button type="button" data-led="status" data-on="1">Green on</button>
|
||||||
|
<button type="button" data-led="status" data-on="0">Green off</button>
|
||||||
|
<button type="button" data-led="error" data-on="1">Red on</button>
|
||||||
|
<button type="button" data-led="error" data-on="0">Red off</button>
|
||||||
|
<button type="button" id="led-release">Hand back to card events</button>
|
||||||
|
</div>
|
||||||
|
<p class="ledmsg" id="led-msg">Switching here holds until you hand it back.</p>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<section class="logwrap">
|
||||||
|
<h2>Event log</h2>
|
||||||
|
<ol id="log"></ol>
|
||||||
|
<p id="log-none" class="empty">Waiting for card events…</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
Recent history from <code>GET /api/taps</code>, then live via
|
||||||
|
<code>GET /api/events</code> (SSE); status polled every 2s.
|
||||||
|
Read-only: this page never claims a tap. The API is documented in the README.
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
"use strict";
|
||||||
|
var REFRESH_MS = __REFRESH_MS__;
|
||||||
|
var MAX_ROWS = __MAX_ROWS__;
|
||||||
|
|
||||||
|
var el = function (id) { return document.getElementById(id); };
|
||||||
|
var tokenInput = el("token");
|
||||||
|
tokenInput.value = localStorage.getItem("rdm-token") || "";
|
||||||
|
|
||||||
|
function token() { return tokenInput.value.trim(); }
|
||||||
|
|
||||||
|
function authHeaders() {
|
||||||
|
var t = token();
|
||||||
|
return t ? { "X-Auth-Token": t } : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function setConn(up) {
|
||||||
|
var c = el("conn");
|
||||||
|
c.className = "pill " + (up ? "up" : "down");
|
||||||
|
c.textContent = up ? "live" : "disconnected";
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDuration(seconds) {
|
||||||
|
if (seconds === null || seconds === undefined) return "\\u2014";
|
||||||
|
var s = Math.max(0, Math.round(seconds));
|
||||||
|
var h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), r = s % 60;
|
||||||
|
if (h) return h + "h " + m + "m";
|
||||||
|
if (m) return m + "m " + r + "s";
|
||||||
|
return r + "s";
|
||||||
|
}
|
||||||
|
|
||||||
|
// card_type is an integer (0x0F arrives as 15), so it must be re-encoded
|
||||||
|
// as hex rather than printed as-is.
|
||||||
|
function hex2(value) {
|
||||||
|
return "0x" + Number(value).toString(16).toUpperCase().padStart(2, "0");
|
||||||
|
}
|
||||||
|
|
||||||
|
function setYesNo(id, value) {
|
||||||
|
var node = el(id);
|
||||||
|
node.textContent = value ? "yes" : "no";
|
||||||
|
node.className = value ? "yes" : "no";
|
||||||
|
}
|
||||||
|
|
||||||
|
function setText(id, value) {
|
||||||
|
el(id).textContent = (value === null || value === undefined || value === "")
|
||||||
|
? "\\u2014" : String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- LEDs -----------------------------------------------------------
|
||||||
|
|
||||||
|
function ledMsg(text, isError) {
|
||||||
|
var node = el("led-msg");
|
||||||
|
node.textContent = text;
|
||||||
|
node.className = "ledmsg" + (isError ? " err" : "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderLEDs(data) {
|
||||||
|
var st = data.status || {}, er = data.error || {};
|
||||||
|
var stState = !st.enabled ? "off (no pin)"
|
||||||
|
: (st.on ? "on" : "off") + (st.manual ? " (manual)" : "");
|
||||||
|
var erState = !er.enabled ? "off (no pin)"
|
||||||
|
: (er.on ? "on" : "off") + (er.manual ? " (manual)" : "");
|
||||||
|
setText("led-st-state", stState);
|
||||||
|
setText("led-er-state", erState);
|
||||||
|
setText("led-st-pin", st.pin === null || st.pin === undefined
|
||||||
|
? "\\u2014" : "GPIO " + st.pin + (st.active_high === false ? ", active-low" : ""));
|
||||||
|
setText("led-er-pin", er.pin === null || er.pin === undefined
|
||||||
|
? "\\u2014" : "GPIO " + er.pin + (er.active_high === false ? ", active-low" : ""));
|
||||||
|
setText("led-auto", data.manual ? "held (manual)" : "card events");
|
||||||
|
|
||||||
|
// Disable a button when its LED has no usable pin, so a dead control is
|
||||||
|
// visibly dead rather than silently doing nothing.
|
||||||
|
var buttons = document.querySelectorAll(".ledbtns button[data-led]");
|
||||||
|
Array.prototype.forEach.call(buttons, function (b) {
|
||||||
|
var name = b.getAttribute("data-led");
|
||||||
|
if (!name) { b.disabled = false; return; }
|
||||||
|
b.disabled = name === "status" ? !st.enabled : !er.enabled;
|
||||||
|
});
|
||||||
|
el("led-release").disabled = !data.manual;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ledCommand(body, message) {
|
||||||
|
fetch("/api/led", {
|
||||||
|
method: "POST",
|
||||||
|
headers: Object.assign({ "Content-Type": "application/json" },
|
||||||
|
authHeaders()),
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
}).then(function (r) {
|
||||||
|
return r.json().then(function (data) { return { ok: r.ok, status: r.status, data: data }; });
|
||||||
|
}).then(function (res) {
|
||||||
|
if (!res.ok) {
|
||||||
|
ledMsg(res.data && res.data.error ? res.data.error : "failed (" + res.status + ")", true);
|
||||||
|
if (res.data && res.data.state) renderLEDs(res.data.state);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
renderLEDs(res.data);
|
||||||
|
ledMsg(message);
|
||||||
|
}).catch(function () { ledMsg("request failed", true); });
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll(".ledbtns button[data-led]").forEach(function (button) {
|
||||||
|
button.addEventListener("click", function () {
|
||||||
|
var name = button.getAttribute("data-led");
|
||||||
|
var on = button.getAttribute("data-on") === "1";
|
||||||
|
ledCommand({ led: name, on: on },
|
||||||
|
name + " LED forced " + (on ? "on" : "off") +
|
||||||
|
" - press \\"hand back\\" to restore");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
el("led-release").addEventListener("click", function () {
|
||||||
|
ledCommand({ release: true }, "back under card-event control");
|
||||||
|
});
|
||||||
|
|
||||||
|
function pollLEDs() {
|
||||||
|
fetch("/api/led", { headers: authHeaders() })
|
||||||
|
.then(function (r) { return r.ok ? r.json() : null; })
|
||||||
|
.then(function (data) { if (data) renderLEDs(data); })
|
||||||
|
.catch(function () { /* the reader panel already reports failures */ });
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderStatus(health, card) {
|
||||||
|
setText("mode", health.mode);
|
||||||
|
setText("port", health.port || "not open");
|
||||||
|
setText("uptime", fmtDuration(health.uptime_seconds));
|
||||||
|
setYesNo("present", !!health.card_present);
|
||||||
|
setYesNo("claimable", !!(card && card.claimable));
|
||||||
|
|
||||||
|
var counts = health.counts || {};
|
||||||
|
setText("n-inserted", counts.card_inserted || 0);
|
||||||
|
setText("n-removed", counts.card_removed || 0);
|
||||||
|
setText("n-invalid", counts.invalid_card || 0);
|
||||||
|
setText("n-subs", health.subscribers);
|
||||||
|
setText("n-ttl", (health.tap_ttl_seconds || "\\u221e") + " / " +
|
||||||
|
(health.claimed_ttl_seconds || "\\u221e"));
|
||||||
|
|
||||||
|
var shown = (card && card.card) || health.last_tap;
|
||||||
|
el("c-none").hidden = !!shown;
|
||||||
|
if (shown) {
|
||||||
|
setText("c-id", shown.id);
|
||||||
|
setText("c-hex", shown.hex);
|
||||||
|
setText("c-type", hex2(shown.type));
|
||||||
|
setText("c-valid", shown.is_valid ? "yes" : "no");
|
||||||
|
setText("c-age", fmtDuration(card ? card.age_seconds : health.last_tap_age_seconds));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var KIND = { card_inserted: "inserted", card_removed: "removed", invalid_card: "invalid" };
|
||||||
|
|
||||||
|
// Guards against showing the same event twice: recent history is seeded from
|
||||||
|
// /api/taps on load, and the first SSE frames can overlap with it.
|
||||||
|
var seen = Object.create(null);
|
||||||
|
|
||||||
|
function pushLog(event) {
|
||||||
|
var key = (event.timestamp || "") + "|" + (event.event || "") + "|" + event.id;
|
||||||
|
if (seen[key]) return;
|
||||||
|
seen[key] = true;
|
||||||
|
|
||||||
|
var list = el("log");
|
||||||
|
var li = document.createElement("li");
|
||||||
|
li.className = KIND[event.event] || "";
|
||||||
|
|
||||||
|
var ts = document.createElement("span");
|
||||||
|
ts.className = "ts";
|
||||||
|
ts.textContent = event.timestamp || "";
|
||||||
|
|
||||||
|
var name = document.createElement("span");
|
||||||
|
name.className = "ev";
|
||||||
|
name.textContent = (event.event || "").replace(/_/g, " ");
|
||||||
|
|
||||||
|
var detail = document.createElement("span");
|
||||||
|
detail.className = "detail";
|
||||||
|
if (event.event === "card_removed") {
|
||||||
|
detail.innerHTML = "id " + event.id;
|
||||||
|
} else {
|
||||||
|
detail.innerHTML = "id <b>" + event.id + "</b> · " + event.hex +
|
||||||
|
" · " + hex2(event.type) +
|
||||||
|
(event.is_valid ? "" : " · checksum mismatch");
|
||||||
|
}
|
||||||
|
|
||||||
|
li.appendChild(ts); li.appendChild(name); li.appendChild(detail);
|
||||||
|
list.insertBefore(li, list.firstChild);
|
||||||
|
while (list.childElementCount > MAX_ROWS) list.removeChild(list.lastChild);
|
||||||
|
el("log-none").hidden = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function seedLog() {
|
||||||
|
fetch("/api/taps?limit=" + MAX_ROWS, { headers: authHeaders() })
|
||||||
|
.then(function (r) { return r.ok ? r.json() : null; })
|
||||||
|
.then(function (data) {
|
||||||
|
if (!data || !data.taps) return;
|
||||||
|
// taps() is chronological, oldest first; prepending leaves newest on top.
|
||||||
|
data.taps.forEach(pushLog);
|
||||||
|
})
|
||||||
|
.catch(function () { /* history is a convenience, not required */ });
|
||||||
|
}
|
||||||
|
|
||||||
|
function onError() {
|
||||||
|
setConn(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function poll() {
|
||||||
|
Promise.all([
|
||||||
|
fetch("/api/health", { headers: authHeaders() }),
|
||||||
|
fetch("/api/card", { headers: authHeaders() })
|
||||||
|
]).then(function (responses) {
|
||||||
|
if (responses[0].status === 401 || responses[1].status === 401) {
|
||||||
|
// The bridge wants a token for this caller. Say so instead of
|
||||||
|
// reporting the reader as disconnected.
|
||||||
|
el("conn").className = "pill down";
|
||||||
|
el("conn").textContent = "token required";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!responses[0].ok) throw new Error("health " + responses[0].status);
|
||||||
|
return Promise.all([responses[0].json(), responses[1].json()]);
|
||||||
|
}).then(function (data) {
|
||||||
|
if (!data) return;
|
||||||
|
renderStatus(data[0], data[1]);
|
||||||
|
if (es && es.readyState === 1) setConn(true);
|
||||||
|
}).catch(onError);
|
||||||
|
}
|
||||||
|
|
||||||
|
var es = null;
|
||||||
|
function connectEvents() { if (es) es.close();
|
||||||
|
var t = token();
|
||||||
|
// EventSource cannot set headers, so the token goes in the query string -
|
||||||
|
// the same mechanism the API already accepts for non-local callers.
|
||||||
|
var url = "/api/events" + (t ? "?token=" + encodeURIComponent(t) : "");
|
||||||
|
es = new EventSource(url);
|
||||||
|
es.onopen = function () { setConn(true); };
|
||||||
|
es.onmessage = function (message) {
|
||||||
|
try { pushLog(JSON.parse(message.data)); } catch (e) { /* ignore */ }
|
||||||
|
};
|
||||||
|
es.onerror = onError; // EventSource reconnects on its own
|
||||||
|
}
|
||||||
|
|
||||||
|
tokenInput.addEventListener("change", function () {
|
||||||
|
localStorage.setItem("rdm-token", token());
|
||||||
|
connectEvents();
|
||||||
|
seedLog();
|
||||||
|
poll();
|
||||||
|
pollLEDs();
|
||||||
|
});
|
||||||
|
|
||||||
|
connectEvents();
|
||||||
|
seedLog();
|
||||||
|
poll();
|
||||||
|
pollLEDs();
|
||||||
|
setInterval(poll, REFRESH_MS);
|
||||||
|
setInterval(pollLEDs, REFRESH_MS);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def render_page() -> bytes:
|
||||||
|
"""The dashboard as UTF-8 bytes, ready to write to the socket."""
|
||||||
|
page = (
|
||||||
|
PAGE_HTML
|
||||||
|
.replace("__REFRESH_MS__", str(REFRESH_MS))
|
||||||
|
.replace("__MAX_ROWS__", str(MAX_LOG_ROWS))
|
||||||
|
)
|
||||||
|
return page.encode("utf-8")
|
||||||
Reference in New Issue
Block a user