Add --led-active-low for inverted LED wiring

active_high was hardcoded to True, so an LED wired the other way
round - cathode to the pin, anode to 3.3 V, or an inverting
transistor driver - had no configuration at all. The symptom is easy
to misread as a dark LED: the light actually does the opposite, on
with no card and off with one.

Exposed as --led-active-low on the app and on install.sh, which
rewrites the unit's ExecStart like the other LED options. The
startup line now names the polarity, so the journal says which
configuration is actually running:

  LED on GPIO 23 (presence mode, active-high, gpiozero)

Default is unchanged, so existing installs behave identically.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-10-05 22:11:27 +03:00
parent 04f0ab9efa
commit b357b5755a
4 changed files with 64 additions and 16 deletions
+29 -4
View File
@@ -557,6 +557,7 @@ what `--list-ports` reports.
| `--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 |
| `--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 |
@@ -702,6 +703,9 @@ 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 the
light will do the opposite of what is wanted.
### It cannot break the reader
@@ -720,9 +724,9 @@ Startup reports which of the three states it is in (the mode shown is
`presence` by default):
```
LED on GPIO 23 (presence mode, gpiozero) # working
LED unavailable: gpiozero not installed # library missing
LED unavailable: no usable GPIO on pin 23: ... # no pin factory / pin busy
LED on GPIO 23 (presence mode, active-high, gpiozero) # working
LED unavailable: gpiozero not installed # library missing
LED unavailable: no usable GPIO on pin 23: ... # no pin factory / pin busy
```
That distinction matters: `BadPinFactory` subclasses `ImportError`, so a naive
@@ -742,7 +746,28 @@ 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 |
**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)
```
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,
+11 -4
View File
@@ -13,6 +13,9 @@
# --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
# --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,6 +36,7 @@ INSTALL_SERVICE=1
SERIAL_DEVICE=/dev/serial0
LED_PIN=23
LED_MODE=presence
LED_ACTIVE_LOW=0
HEARTBEAT=2.0
SOURCE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
@@ -45,6 +49,7 @@ while [[ $# -gt 0 ]]; do
--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 ;;
--heartbeat) HEARTBEAT="$2"; shift 2 ;;
--no-service) INSTALL_SERVICE=0; shift ;;
-h|--help) sed -n '2,33p' "$0"; exit 0 ;;
@@ -274,11 +279,11 @@ 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" <<'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) = sys.argv[1:11]
text = open(unit).read()
# The console script is created by the installer, but the .pth file is what
@@ -288,11 +293,13 @@ 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"
led_active_args = " --led-active-low" if led_active_low == "1" else ""
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}{origin_args}"
)
@@ -348,7 +355,7 @@ $(log "Installed")
Bridge : http://127.0.0.1:$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" )
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
+4
View File
@@ -156,6 +156,7 @@ 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,
logger=lambda message: print(" {}".format(message)),
)
@@ -323,6 +324,9 @@ 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")
return parser
+20 -8
View File
@@ -59,14 +59,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,
}
@@ -176,7 +180,7 @@ class StatusLED:
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)
return
# Check the library separately from building the pin.
@@ -192,21 +196,22 @@ class StatusLED:
reason = "gpiozero not installed: {}".format(exc)
self.led = NullLED(pin, reason)
self.status = LEDStatus(False, pin, "null", self._mode, False,
reason)
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")
self._log("LED on GPIO {} ({} mode, gpiozero)".format(
pin, self._mode))
False, "ready", active_high)
self._log("LED on GPIO {} ({} mode, {}, gpiozero)".format(
pin, self._mode,
"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.
reason = ("no usable GPIO on pin {}: {}".format(pin, exc))
self.led = NullLED(pin, reason)
self.status = LEDStatus(False, pin, "null", self._mode, False,
reason)
reason, active_high)
self._log("LED unavailable: {}".format(reason))
if self.status.enabled and self._mode == ERROR:
@@ -306,9 +311,16 @@ 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,
) -> 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.
"""
return StatusLED(pin=pin, enabled=enabled, mode=mode,
active_high=active_high,
error_blink=error_blink, logger=logger)