Add a dedicated error LED on GPIO 24
One LED cannot say two things at once, and in the default presence mode the status LED is already carrying "a card is here". The result was that a bad read - a checksum failure - was reported not at all: verified by driving the LED worker, which showed presence mode producing no light on invalid_card while error mode blinked. A second LED on its own pin (GPIO 24 = physical pin 18, matching the workstation app) now pulses for --error-pulse seconds on a bad read, leaving the status light to keep tracking the card. This also gives ERROR_BLINK_SECONDS, previously dead, a real use. --error-led-pin N / --error-led-active-low / --error-pulse S --no-error-led restores the single-LED blink install.sh rewrites the unit's ExecStart for all of it, verified for the default, active-low and no-error-led cases. Also extracts the repeated backend-opening code into _open_led(), which keeps the BadPinFactory-is-an-ImportError subtlety in one place instead of two, and treats the error pin colliding with the status pin as "no error LED" rather than opening one pin twice. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -558,6 +558,9 @@ what `--list-ports` reports.
|
|||||||
| `--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 |
|
| `--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 |
|
||||||
@@ -666,30 +669,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 |
|
||||||
@@ -704,8 +725,9 @@ Two modes, because the light can mean two different things:
|
|||||||
|
|
||||||
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
|
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 the
|
transistor/MOSFET driver that inverts — pass **`--led-active-low`** (or
|
||||||
light will do the opposite of what is wanted.
|
`--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
|
||||||
|
|
||||||
@@ -720,11 +742,12 @@ 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, active-high, 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: gpiozero not installed # library missing
|
||||||
LED unavailable: no usable GPIO on pin 23: ... # no pin factory / pin busy
|
LED unavailable: no usable GPIO on pin 23: ... # no pin factory / pin busy
|
||||||
```
|
```
|
||||||
@@ -747,6 +770,7 @@ 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, active-high, gpiozero)` | LED is initialised — suspect wiring, pin number, 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
|
**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
|
are different faults. If the light is **on with no card and goes dark when a
|
||||||
@@ -867,9 +891,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:
|
||||||
|
|
||||||
@@ -941,18 +966,20 @@ Look up the pin your wire is actually on, and pass the BCM number:
|
|||||||
| 12 | `18` | | 29 | `5` |
|
| 12 | `18` | | 29 | `5` |
|
||||||
| 13 | `27` | | 31 | `6` |
|
| 13 | `27` | | 31 | `6` |
|
||||||
| 15 | `22` | | 32 | `12` |
|
| 15 | `22` | | 32 | `12` |
|
||||||
| **16** | **`23`** (the default) | | 33 | `13` |
|
| **16** | **`23`** (status LED default) | | 33 | `13` |
|
||||||
| 18 | `24` | | 35 | `19` |
|
| **18** | **`24`** (error LED default) | | 35 | `19` |
|
||||||
| 19 | `10` | | 36 | `16` |
|
| 19 | `10` | | 36 | `16` |
|
||||||
| 21 | `9` | | 37 | `26` |
|
| 21 | `9` | | 37 | `26` |
|
||||||
| 22 | `25` | | 38 | `20` |
|
| 22 | `25` | | 38 | `20` |
|
||||||
| **23** | **`11`** | | 40 | `21` |
|
| **23** | **`11`** | | 40 | `21` |
|
||||||
|
|
||||||
So an LED wired to **physical pin 23** needs `--led-pin 11`, and the default
|
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**.
|
`--led-pin 23` requires the wire on **physical pin 16**. The error LED defaults
|
||||||
|
to GPIO 24, which is **physical pin 18**.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo ./deploy/install.sh --device /dev/serial0 --led-pin 11 # wire on physical pin 23
|
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),
|
Do **not** use physical pin 8 or 10 (GPIO 14 / 15 — the reader's own UART),
|
||||||
|
|||||||
+26
-3
@@ -16,6 +16,9 @@
|
|||||||
# --led-active-low LED wired active-low (cathode to the pin, anode to
|
# --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
|
# 3.3 V, or an inverting driver); or the light will be on
|
||||||
# with no card and off with one
|
# 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)
|
||||||
@@ -37,6 +40,9 @@ SERIAL_DEVICE=/dev/serial0
|
|||||||
LED_PIN=23
|
LED_PIN=23
|
||||||
LED_MODE=presence
|
LED_MODE=presence
|
||||||
LED_ACTIVE_LOW=0
|
LED_ACTIVE_LOW=0
|
||||||
|
ERROR_LED_PIN=24
|
||||||
|
ERROR_LED_ACTIVE_LOW=0
|
||||||
|
ERROR_PULSE=1.0
|
||||||
HEARTBEAT=2.0
|
HEARTBEAT=2.0
|
||||||
SOURCE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
SOURCE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
|
||||||
@@ -50,6 +56,10 @@ while [[ $# -gt 0 ]]; do
|
|||||||
--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 ;;
|
--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 ;;
|
||||||
@@ -279,11 +289,13 @@ 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" "$LED_ACTIVE_LOW" "$HEARTBEAT" <<'PY'
|
"$SERIAL_DEVICE" "$LED_PIN" "$LED_MODE" "$LED_ACTIVE_LOW" "$HEARTBEAT" \
|
||||||
|
"$ERROR_LED_PIN" "$ERROR_LED_ACTIVE_LOW" "$ERROR_PULSE" <<'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,
|
||||||
led_active_low, heartbeat) = sys.argv[1:11]
|
led_active_low, heartbeat, error_led_pin, error_led_active_low,
|
||||||
|
error_pulse) = sys.argv[1:14]
|
||||||
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
|
||||||
@@ -295,11 +307,21 @@ if not os.path.exists(wrapper):
|
|||||||
|
|
||||||
led_active_args = " --led-active-low" if led_active_low == "1" else ""
|
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"
|
||||||
|
|
||||||
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"{led_active_args}{origin_args}"
|
f"{led_active_args}{error_args}{origin_args}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -356,6 +378,7 @@ $(log "Installed")
|
|||||||
Bridge : http://127.0.0.1:$BRIDGE_PORT
|
Bridge : http://127.0.0.1:$BRIDGE_PORT
|
||||||
Device : $SERIAL_DEVICE
|
Device : $SERIAL_DEVICE
|
||||||
LED : GPIO $LED_PIN, $LED_MODE mode$( [[ "$LED_ACTIVE_LOW" -eq 1 ]] && echo ", active-low" )
|
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
|
||||||
|
|||||||
@@ -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(
|
||||||
@@ -158,6 +165,9 @@ def _build_led(args):
|
|||||||
mode=args.led_mode,
|
mode=args.led_mode,
|
||||||
active_high=not args.led_active_low,
|
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)),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -327,6 +337,18 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
led.add_argument("--led-active-low", action="store_true",
|
led.add_argument("--led-active-low", action="store_true",
|
||||||
help="LED is wired active-low (cathode to the pin, anode "
|
help="LED is wired active-low (cathode to the pin, anode "
|
||||||
"to 3.3 V); inverts the signal")
|
"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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+168
-40
@@ -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
|
||||||
|
|
||||||
@@ -151,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.
|
||||||
|
|
||||||
@@ -166,13 +213,19 @@ 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
|
||||||
|
|
||||||
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
|
||||||
@@ -181,38 +234,55 @@ class StatusLED:
|
|||||||
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", active_high)
|
"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
|
|
||||||
# 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, active_high)
|
|
||||||
self._log("LED unavailable: {}".format(reason))
|
|
||||||
else:
|
|
||||||
try:
|
|
||||||
self.led = GPIOLED(pin, active_high=active_high)
|
|
||||||
self.status = LEDStatus(True, pin, "gpiozero", self._mode,
|
|
||||||
False, "ready", active_high)
|
|
||||||
self._log("LED on GPIO {} ({} mode, {}, gpiozero)".format(
|
self._log("LED on GPIO {} ({} mode, {}, gpiozero)".format(
|
||||||
pin, self._mode,
|
pin, self._mode,
|
||||||
"active-high" if active_high else "active-low"))
|
"active-high" if active_high else "active-low"))
|
||||||
except Exception as exc:
|
|
||||||
# No usable pin factory (not a Pi), pin already in use, bad pin.
|
# A dedicated error LED, when one is wired. It reports a bad read on its
|
||||||
reason = ("no usable GPIO on pin {}: {}".format(pin, 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, active_high)
|
# 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:
|
||||||
|
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:
|
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
|
||||||
@@ -221,10 +291,6 @@ 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:
|
||||||
@@ -236,8 +302,31 @@ class StatusLED:
|
|||||||
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.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
|
||||||
|
|
||||||
# -- control ----------------------------------------------------------
|
# -- control ----------------------------------------------------------
|
||||||
|
|
||||||
@@ -251,7 +340,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:
|
||||||
@@ -262,6 +356,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:
|
||||||
|
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:
|
||||||
@@ -295,16 +403,28 @@ class StatusLED:
|
|||||||
def close(self) -> None:
|
def close(self) -> None:
|
||||||
self._stop_blink()
|
self._stop_blink()
|
||||||
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(
|
||||||
@@ -314,13 +434,21 @@ def create(
|
|||||||
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,
|
||||||
) -> 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
|
*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
|
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.
|
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,
|
||||||
active_high=active_high,
|
active_high=active_high,
|
||||||
error_blink=error_blink, logger=logger)
|
error_blink=error_blink, logger=logger,
|
||||||
|
error_pin=error_pin, error_active_high=error_active_high,
|
||||||
|
error_pulse=error_pulse)
|
||||||
|
|||||||
Reference in New Issue
Block a user