diff --git a/README.md b/README.md index 32821af..cdaa9e3 100644 --- a/README.md +++ b/README.md @@ -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, diff --git a/deploy/install.sh b/deploy/install.sh index 73ffc96..151c79b 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -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 diff --git a/src/rdm_card_reader/cli.py b/src/rdm_card_reader/cli.py index 9b8231c..3156a3c 100644 --- a/src/rdm_card_reader/cli.py +++ b/src/rdm_card_reader/cli.py @@ -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 diff --git a/src/rdm_card_reader/led.py b/src/rdm_card_reader/led.py index 68cc485..1b9b289 100644 --- a/src/rdm_card_reader/led.py +++ b/src/rdm_card_reader/led.py @@ -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)