Compare commits
8 Commits
c720f4ca67
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 84569ec9ae | |||
| 74c9e0f8fa | |||
| c2b3eda649 | |||
| a399542a6e | |||
| b357b5755a | |||
| 04f0ab9efa | |||
| bbd2861897 | |||
| 882b89d6da |
@@ -7,3 +7,7 @@ dist/
|
||||
venv/
|
||||
.idea/
|
||||
.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)
|
||||
- [Usage](#usage)
|
||||
- [Serving card data to a web app (`--serve`)](#serving-card-data-to-a-web-app---serve)
|
||||
- [Running at boot (Raspberry Pi)](#running-at-boot-raspberry-pi)
|
||||
- [Quick start on a Raspberry Pi](#quick-start-on-a-raspberry-pi)
|
||||
- [Status LED](#status-led)
|
||||
- [Wiring](#wiring)
|
||||
- [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`)
|
||||
> 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
|
||||
|
||||
| Method | Path | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `GET` | `/` | Status page (unless `--no-page`) |
|
||||
| `GET` | `/api/card` | The latched tap: `present`, `claimable`, `consumed`, `age_seconds`, `card` |
|
||||
| `POST` | `/api/card/claim` | Spend the tap on one login. `200`, or `409` if used/none, `410` if expired |
|
||||
| `GET` | `/api/events` | Server-Sent Events stream, pushes each event as it happens |
|
||||
@@ -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/health` | Reader state, port, uptime, tap counts, both TTLs — for monitoring |
|
||||
| `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
|
||||
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
|
||||
reader on the Pi's UART and **GPIO 23** for the LED — so the LED pin and device
|
||||
usually need no arguments at all:
|
||||
End-to-end: enable the serial port, confirm the reader is visible, install the
|
||||
service, verify it runs. Steps 1–2 change nothing on disk and are safe to run
|
||||
first.
|
||||
|
||||
### 1. Enable the serial port
|
||||
|
||||
`/dev/serial0` is the Pi's on-board UART, and it does not exist until the UART
|
||||
is switched on:
|
||||
|
||||
```bash
|
||||
sudo raspi-config
|
||||
# Interface Options -> Serial Port
|
||||
# "Would you like a login shell to be accessible over serial?" -> No
|
||||
# "Would you like the serial port hardware to be enabled?" -> Yes
|
||||
sudo reboot
|
||||
```
|
||||
|
||||
Menu numbering shifts between `raspi-config` releases, so navigate by name:
|
||||
**Interface Options**, then **Serial Port**.
|
||||
|
||||
**Both answers matter.** Answering *Yes* to the login shell leaves a console on
|
||||
the port, so the reader gets nothing: the app either reports `device not found`
|
||||
or opens the port and never sees a card.
|
||||
|
||||
### 2. Confirm the device appears
|
||||
|
||||
```bash
|
||||
ls -l /dev/serial0 /dev/rdm-reader 2>&1
|
||||
```
|
||||
|
||||
| What you see | Wiring | Device to pass |
|
||||
| --- | --- | --- |
|
||||
| `/dev/serial0 -> ttyAMA0` (or `ttyS0`) | UART, header pins **8 / 10** | `--device /dev/serial0` |
|
||||
| `/dev/rdm-reader` | USB-to-serial adapter | `--device /dev/rdm-reader` |
|
||||
| `No such file or directory` for both | nothing openable yet | see below |
|
||||
|
||||
`/dev/rdm-reader` is created by the udev rule and matches a **USB** bridge chip,
|
||||
so it can never exist on a UART setup. The two are mutually exclusive — a
|
||||
service pointing at the wrong one exits `1` on every start.
|
||||
|
||||
If neither exists, ask what the system does see:
|
||||
|
||||
```bash
|
||||
ls /dev/ttyUSB* /dev/ttyACM* /dev/ttyS* 2>/dev/null
|
||||
dmesg | grep -iE "ttyUSB|ttyAMA|ch34|ftdi|cp210|pl2303" | tail
|
||||
```
|
||||
|
||||
No `ttyUSB`/`ttyACM` line in `dmesg` means the adapter is not enumerating at
|
||||
all — a cable, power or hardware problem that no `--device` value can fix.
|
||||
|
||||
If a `/dev/ttyUSB0` appears but `/dev/rdm-reader` does not, the adapter's chip
|
||||
is not the CH340 the rule matches by default. See **Identifying the right
|
||||
ttyUSB** below.
|
||||
|
||||
### 3. Install the service
|
||||
|
||||
The defaults already match the common wiring — `/dev/serial0` for the reader on
|
||||
the UART, GPIO 23 for the LED:
|
||||
|
||||
```bash
|
||||
sudo ./deploy/install.sh --origin https://your.web.app
|
||||
```
|
||||
|
||||
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
|
||||
sudo ./deploy/install.sh --origin https://your.web.app \
|
||||
--device /dev/serial0 \
|
||||
--led-pin 23 \
|
||||
--led-mode presence
|
||||
sudo ./deploy/install.sh --device /dev/serial0 # UART, pins 8 / 10
|
||||
sudo ./deploy/install.sh --device /dev/rdm-reader # USB adapter
|
||||
```
|
||||
|
||||
| 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 |
|
||||
| `--led-pin 23` | BCM GPIO 23, which is **physical pin 16**. The default |
|
||||
| `--led-mode presence` | Lit while a card is on the reader, dark otherwise. The default |
|
||||
| `<device> does not exist, so the service could never start` | step 1 or 2 not done, or the wrong of the two devices |
|
||||
| `could not open <device> at 9600 baud` | device exists but is not usable — UART held by the serial console, or the user is not in `dialout` |
|
||||
|
||||
> **`--led-pin` is the BCM GPIO number, not the header pin.** If your LED wire
|
||||
> is on physical pin 16, pass `--led-pin 23` (the default). Passing
|
||||
> `--led-pin 16` would drive **GPIO 16**, which is physical pin 36 — a different
|
||||
> pin entirely. The LED is optional: with no LED, pass `--led-mode presence`
|
||||
> regardless and ignore the light, or add `--no-led` to the unit if you want
|
||||
> the app to stop trying.
|
||||
Nothing is enabled in either case, and re-running is safe: the account, udev
|
||||
rule and virtualenv are all idempotent.
|
||||
|
||||
### 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,
|
||||
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.
|
||||
A healthy startup logs one line:
|
||||
|
||||
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 |
|
||||
| --- | --- | --- |
|
||||
| `--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) |
|
||||
| `--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-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 |
|
||||
| `--port PORT` | `8765` | HTTP port for the bridge |
|
||||
| `--dir PATH` | `/opt/rdm-card-reader` | Install location |
|
||||
| `--user NAME` | `rdmcard` | Service account |
|
||||
| `--no-service` | off | Install and configure, but do not enable the service |
|
||||
|
||||
The unit runs the app as:
|
||||
> **`--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 \
|
||||
@@ -460,25 +608,58 @@ The unit runs the app as:
|
||||
added. `--serve` is what makes the API available; without it the app only
|
||||
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
|
||||
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
|
||||
curl -sS http://127.0.0.1:8765/api/card # tap a card, then run this
|
||||
sudo ./deploy/install.sh --device /dev/serial0 \
|
||||
--host 0.0.0.0 --allow-remote --token 'pick-a-long-secret'
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
```bash
|
||||
# Everything at once, in a browser: the status page
|
||||
xdg-open http://127.0.0.1:8765/ # or open it on another machine
|
||||
|
||||
# Is the reader alive? (status, port, uptime)
|
||||
curl -sS http://127.0.0.1:8765/api/health | python3 -m json.tool
|
||||
|
||||
@@ -553,30 +734,48 @@ Exit codes: `0` ok, `1` no port, `2` library missing, `3` port ambiguous.
|
||||
|
||||
## Status LED
|
||||
|
||||
An LED driven by card events, on **GPIO 23** (physical pin **16**) by default —
|
||||
the same pin the workstation app used. Optional: everything works without it.
|
||||
Two LEDs, each saying one thing. Both optional: everything works without
|
||||
either.
|
||||
|
||||
By default the light **tracks the card**: lit while a card is on the reader,
|
||||
dark otherwise.
|
||||
| LED | Default pin | Means |
|
||||
| --- | --- | --- |
|
||||
| **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
|
||||
rdm-card-reader # lit only while a card is on the reader
|
||||
rdm-card-reader --led-mode error # lit whenever running, blinks on a bad read
|
||||
rdm-card-reader --led-pin 17 # a different pin
|
||||
rdm-card-reader --no-led # no LED at all
|
||||
rdm-card-reader # status LED tracks the card; error LED pulses on a bad read
|
||||
rdm-card-reader --led-mode error # status LED lit whenever running, blinks on a bad read
|
||||
rdm-card-reader --error-led-pin 25 # error LED on a different pin
|
||||
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 |
|
||||
| --- | --- | --- |
|
||||
| `--no-led` | off | Do not drive a LED |
|
||||
| `--led-pin` | `23` | **BCM** GPIO number — not the physical header pin |
|
||||
| `--no-led` | off | Do not drive any LED |
|
||||
| `--led-pin` | `23` | **BCM** GPIO number of the status LED — not the physical header pin |
|
||||
| `--led-mode` | `presence` | See the two readings below |
|
||||
| `--led-no-error-blink` | off | Do not blink on a bad read |
|
||||
| `--led-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
|
||||
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.
|
||||
|
||||
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:
|
||||
|
||||
| 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.
|
||||
|
||||
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
|
||||
|
||||
@@ -604,11 +807,12 @@ point:
|
||||
refusing to run.
|
||||
* **Never crashes on a dead pin.** A failed toggle is logged and ignored.
|
||||
|
||||
Startup reports which of the three states it is in (the mode shown is
|
||||
`presence` by default):
|
||||
Startup reports the state of each LED (the mode shown is `presence` by
|
||||
default):
|
||||
|
||||
```
|
||||
LED on GPIO 23 (presence mode, gpiozero) # working
|
||||
LED on GPIO 23 (presence mode, active-high, gpiozero) # status LED working
|
||||
Error LED on GPIO 24 (lit for 1.0s on a bad read, ...) # error LED working
|
||||
LED unavailable: gpiozero not installed # library missing
|
||||
LED unavailable: no usable GPIO on pin 23: ... # no pin factory / pin busy
|
||||
```
|
||||
@@ -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: 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
|
||||
Python, but the library it uses to talk to the pins (`lgpio` on Bookworm,
|
||||
`RPi.GPIO` on older releases) is an **apt package in the system Python**. A
|
||||
plain venv cannot see it, so gpiozero raises `BadPinFactory` and the app
|
||||
quietly falls back to a no-op LED. This is why `install.sh` creates the venv
|
||||
with `--system-site-packages`; if you built the venv by hand, that is the most
|
||||
likely cause:
|
||||
with `--system-site-packages` and installs a backend. If you built the venv by
|
||||
hand, that is the most likely cause:
|
||||
|
||||
```bash
|
||||
grep system-site-packages /opt/rdm-card-reader/.venv/pyvenv.cfg # want: true
|
||||
/opt/rdm-card-reader/.venv/bin/python -c 'import lgpio; print("lgpio OK")'
|
||||
```
|
||||
|
||||
If the first line is missing or the import fails, install the backend and
|
||||
rebuild the venv:
|
||||
If either line is wrong, let the installer set both up at once:
|
||||
|
||||
```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
|
||||
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
|
||||
> already see data, but it is worth fixing before relying on the device.
|
||||
|
||||
For the status LED: **anode** → GPIO pin through a ~330 Ω resistor,
|
||||
**cathode** → GND. See the pin numbering table below, and note the LED is
|
||||
optional — the reader works without it.
|
||||
For each LED: **anode** → GPIO pin through a ~330 Ω resistor,
|
||||
**cathode** → GND. Status LED on GPIO 23 (physical pin 16) and error LED on
|
||||
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:
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
The GPIO UART appears as `/dev/serial0` (a symlink to `/dev/ttyAMA0` or
|
||||
`/dev/ttyS0`).
|
||||
|
||||
### UART or USB? The service must be told which
|
||||
|
||||
The two are not interchangeable, and getting it wrong is the most common reason
|
||||
@@ -793,15 +1050,58 @@ sudo ./deploy/install.sh --device /dev/serial0 --led-pin 18 # LED on GPIO 18
|
||||
|
||||
### LED pin numbering
|
||||
|
||||
| `--led-pin` | BCM GPIO | Physical header pin |
|
||||
| --- | --- | --- |
|
||||
| `23` (the default) | GPIO 23 | **16** |
|
||||
| `18` | GPIO 18 | 12 |
|
||||
| `17` | GPIO 17 | 11 |
|
||||
`--led-pin` is the **BCM GPIO number** — what the app uses and what GPIO
|
||||
libraries use — **not** the physical header pin number. This is the single most
|
||||
common way to get a dark LED with no error: the app reports
|
||||
`LED on GPIO <n>` and looks healthy, while the wire is on a different pin.
|
||||
|
||||
`--led-pin` is the **BCM** number, which is what the app uses and what GPIO
|
||||
libraries use. Putting the LED on physical pin 23 would drive **GPIO 11**, not
|
||||
GPIO 23 — the wire must go to physical pin **16** for the default to be right.
|
||||
Look up the pin your wire is actually on, and pass the BCM number:
|
||||
|
||||
| Physical pin | `--led-pin` (BCM) | | Physical pin | `--led-pin` (BCM) |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 7 | `4` | | 24 | `8` |
|
||||
| 11 | `17` | | 26 | `7` |
|
||||
| 12 | `18` | | 29 | `5` |
|
||||
| 13 | `27` | | 31 | `6` |
|
||||
| 15 | `22` | | 32 | `12` |
|
||||
| **16** | **`23`** (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
|
||||
ports.py stable serial port identification
|
||||
bridge.py the HTTP bridge behind --serve
|
||||
webui.py the status page served at GET / by the bridge
|
||||
led.py status LED
|
||||
bootstrap.py offline dependency install
|
||||
wheels/ the bundled wheels (offline install source)
|
||||
|
||||
+113
-5
@@ -10,9 +10,21 @@
|
||||
# Options:
|
||||
# --origin URL web app origin allowed to read the API (repeatable)
|
||||
# --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)
|
||||
# --led-pin N BCM GPIO number of the status LED (default 23)
|
||||
# --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
|
||||
# (default 2.0; this is also the presence/LED window)
|
||||
# --dir PATH install location (default /opt/rdm-card-reader)
|
||||
@@ -33,18 +45,36 @@ INSTALL_SERVICE=1
|
||||
SERIAL_DEVICE=/dev/serial0
|
||||
LED_PIN=23
|
||||
LED_MODE=presence
|
||||
LED_ACTIVE_LOW=0
|
||||
ERROR_LED_PIN=24
|
||||
ERROR_LED_ACTIVE_LOW=0
|
||||
ERROR_PULSE=1.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)"
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--origin) ORIGINS+=("$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 ;;
|
||||
--user) SERVICE_USER="$2"; shift 2 ;;
|
||||
--device) SERIAL_DEVICE="$2"; shift 2 ;;
|
||||
--led-pin) LED_PIN="$2"; shift 2 ;;
|
||||
--led-mode) LED_MODE="$2"; shift 2 ;;
|
||||
--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 ;;
|
||||
--no-service) INSTALL_SERVICE=0; shift ;;
|
||||
-h|--help) sed -n '2,33p' "$0"; exit 0 ;;
|
||||
@@ -54,12 +84,31 @@ done
|
||||
|
||||
log() { printf '\n\033[1m==> %s\033[0m\n' "$*"; }
|
||||
warn() { printf '\033[33mwarning: %s\033[0m\n' "$*" >&2; }
|
||||
note() { printf ' %s\n' "$*"; }
|
||||
|
||||
if [[ $EUID -ne 0 ]]; then
|
||||
echo "Please run as root: sudo $0 $*" >&2
|
||||
exit 1
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -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 \
|
||||
|| 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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -274,11 +346,14 @@ for origin in "${ORIGINS[@]:-}"; do
|
||||
done
|
||||
|
||||
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
|
||||
|
||||
(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()
|
||||
|
||||
# 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):
|
||||
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 = (
|
||||
f"{wrapper} --serve "
|
||||
f"--port {device} --bridge-port {port} --mode login "
|
||||
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")
|
||||
|
||||
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
|
||||
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)
|
||||
Card state : curl http://127.0.0.1:$BRIDGE_PORT/api/card
|
||||
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 http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
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 .webui import render_page
|
||||
|
||||
#: Reader modes. ``login`` publishes inserts only (a tap = an authentication
|
||||
#: attempt). ``monitor`` publishes inserts and removals both.
|
||||
@@ -325,14 +327,31 @@ class _Handler(BaseHTTPRequestHandler):
|
||||
|
||||
# -- 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:
|
||||
"""Refuse cross-origin reads unless the origin was allow-listed."""
|
||||
origin = self.headers.get("Origin")
|
||||
if not origin:
|
||||
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", [])
|
||||
if not allowed:
|
||||
return False # browsers only; opt out entirely
|
||||
return origin in allowed
|
||||
|
||||
def _token_ok(self) -> bool:
|
||||
@@ -351,26 +370,52 @@ class _Handler(BaseHTTPRequestHandler):
|
||||
supplied = pair.split("=", 1)[1]
|
||||
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:
|
||||
body = json.dumps(payload).encode("utf-8")
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
origin = self.headers.get("Origin")
|
||||
if origin and origin in getattr(self.server, "allowed_origins", []):
|
||||
self.send_header("Access-Control-Allow-Origin", origin)
|
||||
allowed = self._allowed_origin()
|
||||
if allowed:
|
||||
self.send_header("Access-Control-Allow-Origin", allowed)
|
||||
self.send_header("Vary", "Origin")
|
||||
self.end_headers()
|
||||
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:
|
||||
self.send_response(204)
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.send_header("Content-Length", "0")
|
||||
origin = self.headers.get("Origin")
|
||||
if origin and origin in getattr(self.server, "allowed_origins", []):
|
||||
self.send_header("Access-Control-Allow-Origin", origin)
|
||||
allowed = self._allowed_origin()
|
||||
if allowed:
|
||||
self.send_header("Access-Control-Allow-Origin", allowed)
|
||||
self.send_header("Vary", "Origin")
|
||||
self.end_headers()
|
||||
|
||||
@@ -388,6 +433,16 @@ class _Handler(BaseHTTPRequestHandler):
|
||||
def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API
|
||||
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":
|
||||
self._send_json(self._hub.health())
|
||||
return
|
||||
@@ -411,6 +466,15 @@ class _Handler(BaseHTTPRequestHandler):
|
||||
self._send_json(self._hub.current_card())
|
||||
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":
|
||||
limit = 20
|
||||
if "?" in self.path:
|
||||
@@ -432,6 +496,58 @@ class _Handler(BaseHTTPRequestHandler):
|
||||
def do_POST(self) -> None: # noqa: N802
|
||||
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 not self._cors_ok():
|
||||
self._send_json({"error": "origin not allowed"}, status=403)
|
||||
@@ -476,10 +592,10 @@ class _Handler(BaseHTTPRequestHandler):
|
||||
self._send_json({"mode": mode})
|
||||
|
||||
def do_OPTIONS(self) -> None: # noqa: N802
|
||||
origin = self.headers.get("Origin")
|
||||
self.send_response(204)
|
||||
if origin and origin in getattr(self.server, "allowed_origins", []):
|
||||
self.send_header("Access-Control-Allow-Origin", origin)
|
||||
allowed = self._allowed_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-Headers", "Content-Type, X-Auth-Token")
|
||||
self.send_header("Vary", "Origin")
|
||||
@@ -495,9 +611,9 @@ class _Handler(BaseHTTPRequestHandler):
|
||||
self.send_header("Content-Type", "text/event-stream")
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.send_header("Connection", "keep-alive")
|
||||
origin = self.headers.get("Origin")
|
||||
if origin and origin in getattr(self.server, "allowed_origins", []):
|
||||
self.send_header("Access-Control-Allow-Origin", origin)
|
||||
allowed = self._allowed_origin()
|
||||
if allowed:
|
||||
self.send_header("Access-Control-Allow-Origin", allowed)
|
||||
self.send_header("Vary", "Origin")
|
||||
self.end_headers()
|
||||
|
||||
@@ -532,11 +648,19 @@ def create_server(
|
||||
allowed_origins: Optional[List[str]] = None,
|
||||
token: Optional[str] = None,
|
||||
logger: Optional[Callable[[str], None]] = None,
|
||||
serve_page: bool = True,
|
||||
led: Any = None,
|
||||
) -> 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.daemon_threads = True
|
||||
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.token = token or os.environ.get("RDM_BRIDGE_TOKEN") or None # type: ignore[attr-defined]
|
||||
server.logger = logger # type: ignore[attr-defined]
|
||||
|
||||
@@ -29,8 +29,10 @@ from .card import (
|
||||
)
|
||||
from .demo import run_demo
|
||||
from .led import (
|
||||
DEFAULT_ERROR_LED_PIN,
|
||||
DEFAULT_LED_MODE,
|
||||
DEFAULT_LED_PIN,
|
||||
ERROR_BLINK_SECONDS,
|
||||
MODES as LED_MODES,
|
||||
create as create_led,
|
||||
)
|
||||
@@ -143,6 +145,11 @@ def _build_led(args):
|
||||
if args.no_led:
|
||||
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
|
||||
# install does not grow the dependency set.
|
||||
ensure_dependencies(
|
||||
@@ -156,7 +163,11 @@ def _build_led(args):
|
||||
pin=args.led_pin,
|
||||
enabled=True,
|
||||
mode=args.led_mode,
|
||||
active_high=not args.led_active_low,
|
||||
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)),
|
||||
)
|
||||
|
||||
@@ -212,6 +223,8 @@ def _run_bridge(args) -> int:
|
||||
allowed_origins=args.allow_origin,
|
||||
token=args.token,
|
||||
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,
|
||||
@@ -220,7 +233,10 @@ def _run_bridge(args) -> int:
|
||||
|
||||
print(" OK reader on {} ; bridge on http://{}:{} (mode: {})".format(
|
||||
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/led <- LED state; POST to switch one by hand")
|
||||
print(" POST /api/card/claim (single use, for one login)")
|
||||
print(" GET /api/events (SSE stream)")
|
||||
print(" GET /api/last /api/health")
|
||||
@@ -303,6 +319,8 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
"(or set RDM_BRIDGE_TOKEN)")
|
||||
bridge.add_argument("--allow-remote", action="store_true",
|
||||
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",
|
||||
help="also print each event and each HTTP request")
|
||||
|
||||
@@ -318,6 +336,21 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
"blinking means a bad read")
|
||||
led.add_argument("--led-no-error-blink", action="store_true",
|
||||
help="do not blink the LED on a bad read")
|
||||
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
|
||||
|
||||
|
||||
|
||||
+256
-45
@@ -12,21 +12,35 @@ differences:
|
||||
* It is explicit. Whether an LED is actually attached is reported, so "no light
|
||||
came on" is distinguishable from "the card was not read".
|
||||
|
||||
The default 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.
|
||||
Two LEDs, because one cannot say two things at once:
|
||||
|
||||
* 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
|
||||
|
||||
import queue
|
||||
import threading
|
||||
import time
|
||||
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.
|
||||
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.
|
||||
ERROR_BLINK_SECONDS = 1.0
|
||||
|
||||
@@ -59,14 +73,18 @@ class LEDStatus:
|
||||
mode: str = ERROR
|
||||
state: bool = False
|
||||
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,
|
||||
"pin": self.pin,
|
||||
"backend": self.backend,
|
||||
"mode": self.mode,
|
||||
"state": "on" if self.state else "off",
|
||||
"reason": self.reason,
|
||||
"active_high": self.active_high,
|
||||
}
|
||||
|
||||
|
||||
@@ -147,6 +165,39 @@ class _Blinker(threading.Thread):
|
||||
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:
|
||||
"""Non-blocking LED controller driven by card events.
|
||||
|
||||
@@ -162,52 +213,79 @@ class StatusLED:
|
||||
active_high: bool = True,
|
||||
error_blink: bool = True,
|
||||
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._mode = mode if mode in MODES else DEFAULT_LED_MODE
|
||||
self._error_blink = error_blink
|
||||
self._enabled = enabled
|
||||
self._blinker: Optional[_Blinker] = None
|
||||
self._error_pulse = max(0.0, float(error_pulse))
|
||||
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._thread: Optional[threading.Thread] = None
|
||||
|
||||
if not enabled:
|
||||
self.led: Any = NullLED(None, "LED disabled (--no-led)")
|
||||
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
|
||||
|
||||
# Check the library separately from building the pin.
|
||||
#
|
||||
# gpiozero raises BadPinFactory when no pin provider works, and that is
|
||||
# a subclass of ImportError, so catching ImportError around the pin
|
||||
# construction would 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)
|
||||
self.led = NullLED(pin, reason)
|
||||
self.status = LEDStatus(False, pin, "null", self._mode, False,
|
||||
reason)
|
||||
self._log("LED unavailable: {}".format(reason))
|
||||
self.led, usable, reason = _open_led(pin, active_high, self._log, "LED")
|
||||
self.status = LEDStatus(usable, pin,
|
||||
"gpiozero" if usable else "null", self._mode,
|
||||
False, reason, active_high)
|
||||
if usable:
|
||||
self._log("LED on GPIO {} ({} mode, {}, gpiozero)".format(
|
||||
pin, self._mode,
|
||||
"active-high" if active_high else "active-low"))
|
||||
|
||||
# A dedicated error LED, when one is wired. It reports a bad read on its
|
||||
# own pin, so the status light stays free to mean "a card is here".
|
||||
if error_pin is not None and error_pin == pin:
|
||||
# Two LEDs on one pin cannot be driven independently, and opening
|
||||
# the pin twice makes the second open fail anyway. Treat it as "no
|
||||
# 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:
|
||||
try:
|
||||
self.led = GPIOLED(pin, active_high=active_high)
|
||||
self.status = LEDStatus(True, pin, "gpiozero", self._mode,
|
||||
False, "ready")
|
||||
self._log("LED on GPIO {} ({} mode, gpiozero)".format(
|
||||
pin, self._mode))
|
||||
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))
|
||||
self.led = NullLED(pin, reason)
|
||||
self.status = LEDStatus(False, pin, "null", self._mode, False,
|
||||
reason)
|
||||
self._log("LED unavailable: {}".format(reason))
|
||||
self.error_led, e_usable, e_reason = _open_led(
|
||||
error_pin, error_active_high, self._log, "Error LED")
|
||||
self.error_status = LEDStatus(e_usable, error_pin,
|
||||
"gpiozero" if e_usable else "null",
|
||||
ERROR, False, e_reason,
|
||||
error_active_high)
|
||||
if e_usable:
|
||||
self._log("Error LED on GPIO {} (lit for {}s on a bad read, "
|
||||
"{}, gpiozero)".format(
|
||||
error_pin, self._error_pulse,
|
||||
"active-high" if error_active_high
|
||||
else "active-low"))
|
||||
|
||||
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:
|
||||
# A health indicator shows 'alive' immediately. In presence mode the
|
||||
@@ -216,23 +294,108 @@ class StatusLED:
|
||||
self._set_state(True)
|
||||
self.status.state = True
|
||||
|
||||
self._thread = threading.Thread(target=self._worker, daemon=True,
|
||||
name="led-worker")
|
||||
self._thread.start()
|
||||
|
||||
# -- event hooks ------------------------------------------------------
|
||||
|
||||
def insert(self) -> None:
|
||||
"""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)
|
||||
|
||||
def remove(self) -> None:
|
||||
"""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)
|
||||
|
||||
def error(self) -> None:
|
||||
"""A bad read: blink, if blinking is enabled."""
|
||||
self._send(BLINK if self._error_blink else ON)
|
||||
"""A bad read: blink the status LED, or pulse the error LED if wired.
|
||||
|
||||
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 ----------------------------------------------------------
|
||||
|
||||
@@ -246,7 +409,12 @@ class StatusLED:
|
||||
|
||||
def _worker(self) -> None:
|
||||
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:
|
||||
return
|
||||
try:
|
||||
@@ -257,6 +425,20 @@ class StatusLED:
|
||||
finally:
|
||||
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:
|
||||
# Blinking is used only for a bad read, and only by 'error' mode.
|
||||
if command == BLINK and self._mode == ERROR:
|
||||
@@ -289,26 +471,55 @@ class StatusLED:
|
||||
|
||||
def close(self) -> None:
|
||||
self._stop_blink()
|
||||
self._manual_status = False
|
||||
self._manual_error = False
|
||||
if self._thread is not None:
|
||||
self._queue.put(None)
|
||||
try:
|
||||
self._queue.put_nowait(None)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
self.led.off()
|
||||
except Exception:
|
||||
pass
|
||||
self._set_state(False)
|
||||
try:
|
||||
self.error_led.off()
|
||||
except Exception:
|
||||
pass
|
||||
self.error_status.state = False
|
||||
try:
|
||||
self.led.close()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
self.error_led.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def create(
|
||||
enabled: bool = True,
|
||||
pin: int = DEFAULT_LED_PIN,
|
||||
mode: str = DEFAULT_LED_MODE,
|
||||
active_high: bool = True,
|
||||
error_blink: bool = True,
|
||||
logger: Optional[Callable[[str], None]] = None,
|
||||
error_pin: Optional[int] = None,
|
||||
error_active_high: bool = True,
|
||||
error_pulse: float = ERROR_BLINK_SECONDS,
|
||||
) -> 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,
|
||||
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