Add keyboard-wedge typing and LED event support
- keyboard.py: emulate a commercial USB RFID reader by typing card ids into the focused window via a /dev/uinput virtual keyboard - led.py / bridge.py: drive the status LED from card events - cli.py, card.py: wire up the new options and event flow - deploy: harden install.sh and the systemd unit - README: document keyboard mode, LED support, and deployment Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
+110
-9
@@ -10,10 +10,19 @@
|
||||
# Options:
|
||||
# --origin URL web app origin allowed to read the API (repeatable)
|
||||
# --port PORT HTTP port for the bridge (default 8765)
|
||||
# --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'
|
||||
# --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)
|
||||
# --user NAME service account (default rdmcard)
|
||||
# --no-service install and configure, but do not enable the service
|
||||
#
|
||||
# The default device is /dev/serial0, the Raspberry Pi's on-board UART, because
|
||||
# that is how the reader is normally wired (header pins 8/10). For a
|
||||
# USB-to-serial adapter pass --device /dev/rdm-reader, the udev alias.
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
ORIGINS=()
|
||||
@@ -21,6 +30,10 @@ BRIDGE_PORT=8765
|
||||
INSTALL_DIR=/opt/rdm-card-reader
|
||||
SERVICE_USER=rdmcard
|
||||
INSTALL_SERVICE=1
|
||||
SERIAL_DEVICE=/dev/serial0
|
||||
LED_PIN=23
|
||||
LED_MODE=presence
|
||||
HEARTBEAT=2.0
|
||||
SOURCE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
@@ -29,8 +42,12 @@ while [[ $# -gt 0 ]]; do
|
||||
--port) BRIDGE_PORT="$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 ;;
|
||||
--heartbeat) HEARTBEAT="$2"; shift 2 ;;
|
||||
--no-service) INSTALL_SERVICE=0; shift ;;
|
||||
-h|--help) sed -n '2,20p' "$0"; exit 0 ;;
|
||||
-h|--help) sed -n '2,33p' "$0"; exit 0 ;;
|
||||
*) echo "unknown option: $1" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
@@ -67,7 +84,14 @@ for item in src pyproject.toml README.md LAUNCH.md LICENSE deploy; do
|
||||
done
|
||||
|
||||
log "Building the virtual environment"
|
||||
if ! python3 -m venv "$INSTALL_DIR/.venv"; then
|
||||
# --system-site-packages is what makes the status LED work. gpiozero is pure
|
||||
# Python, but the pin backend it needs (lgpio on Bookworm, RPi.GPIO on older
|
||||
# releases) is an apt package installed into the *system* Python. A plain venv
|
||||
# cannot see it, so gpiozero finds no usable pin factory, raises BadPinFactory
|
||||
# and the app falls back to a no-op LED: the reader works and the light stays
|
||||
# dark, with nothing failing. Re-running this on an existing venv updates
|
||||
# pyvenv.cfg in place, so an upgrade picks the flag up too.
|
||||
if ! python3 -m venv --system-site-packages "$INSTALL_DIR/.venv"; then
|
||||
warn "could not create the virtualenv."
|
||||
warn "On Debian/Raspberry Pi OS this usually means python3-venv is missing:"
|
||||
warn " sudo apt-get install python3-venv"
|
||||
@@ -172,6 +196,17 @@ fi
|
||||
usermod -aG dialout "$SERVICE_USER"
|
||||
echo " $SERVICE_USER is in group dialout"
|
||||
|
||||
# The status LED drives /dev/gpiochip*, which is root:gpio on modern Raspberry
|
||||
# Pi OS (Bookworm onward, gpiozero via lgpio). Without this group the service
|
||||
# cannot open the chip, gpiozero falls back to a no-op LED, and the reader
|
||||
# works with no light - the failure that is easiest to miss. Older releases
|
||||
# (Bullseye, RPi.GPIO) use /dev/gpiomem and need no extra group, hence the
|
||||
# group check rather than an unconditional usermod.
|
||||
if getent group gpio >/dev/null 2>&1; then
|
||||
usermod -aG gpio "$SERVICE_USER"
|
||||
echo " $SERVICE_USER is in group gpio (status LED)"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. Stable device name
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -182,15 +217,49 @@ udevadm control --reload-rules
|
||||
udevadm trigger --subsystem-match=tty || true
|
||||
sleep 1
|
||||
|
||||
if [[ -e /dev/rdm-reader ]]; then
|
||||
echo " /dev/rdm-reader -> $(readlink -f /dev/rdm-reader)"
|
||||
else
|
||||
if [[ "$SERIAL_DEVICE" == "/dev/rdm-reader" && ! -e /dev/rdm-reader ]]; then
|
||||
warn "/dev/rdm-reader does not exist yet."
|
||||
warn "The reader may not be plugged in, or it uses a different chip."
|
||||
warn "The USB adapter may not be plugged in, or it uses a different chip."
|
||||
warn "Run '$INSTALL_DIR/.venv/bin/rdm-card-reader --list-ports --verbose-ports'"
|
||||
warn "then edit /etc/udev/rules.d/99-rdm-reader.rules to match your adapter."
|
||||
warn "Reader wired to the Pi's UART pins instead? That is the default device:"
|
||||
warn "re-run without --device (or with --device /dev/serial0)."
|
||||
fi
|
||||
|
||||
# Refuse to enable a service that can only exit 1. A unit that fails on every
|
||||
# start burns its StartLimitBurst within seconds and then stays dead until
|
||||
# someone runs 'systemctl reset-failed' by hand - a confusing failure to debug
|
||||
# remotely, and one that silently returns "installed successfully".
|
||||
if [[ ! -e "$SERIAL_DEVICE" ]]; then
|
||||
warn "$SERIAL_DEVICE does not exist, so the service could never start."
|
||||
warn "Nothing has been enabled. Fix the device, then re-run this installer."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Prove the port can actually be opened, not merely that it exists. Existence
|
||||
# alone is not enough: a node with no hardware behind it still fails to open.
|
||||
if ! "$INSTALL_DIR/.venv/bin/python" - "$SERIAL_DEVICE" <<'PY'
|
||||
import sys
|
||||
|
||||
import serial
|
||||
|
||||
try:
|
||||
serial.Serial(sys.argv[1], 9600, timeout=1).close()
|
||||
except Exception as exc:
|
||||
print(" x {}: {}".format(sys.argv[1], exc))
|
||||
raise SystemExit(1)
|
||||
PY
|
||||
then
|
||||
warn "could not open $SERIAL_DEVICE at 9600 baud."
|
||||
warn "For the on-board UART check that it is enabled:"
|
||||
warn " sudo raspi-config # Interface Options -> Serial Port"
|
||||
warn " 'login shell over serial?' -> No"
|
||||
warn " 'serial port hardware?' -> Yes"
|
||||
warn "Nothing has been enabled. Fix the above, then re-run this installer."
|
||||
exit 1
|
||||
fi
|
||||
echo " $SERIAL_DEVICE opened at 9600 baud"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. Service
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -204,10 +273,12 @@ for origin in "${ORIGINS[@]:-}"; do
|
||||
[[ -n "$origin" ]] && ORIGIN_ARGS+=" --allow-origin $origin"
|
||||
done
|
||||
|
||||
python3 - "$UNIT" "$INSTALL_DIR" "$BRIDGE_PORT" "$ORIGIN_ARGS" "$SERVICE_USER" <<'PY'
|
||||
python3 - "$UNIT" "$INSTALL_DIR" "$BRIDGE_PORT" "$ORIGIN_ARGS" "$SERVICE_USER" \
|
||||
"$SERIAL_DEVICE" "$LED_PIN" "$LED_MODE" "$HEARTBEAT" <<'PY'
|
||||
import os, re, sys
|
||||
|
||||
unit, install_dir, port, origin_args, user = sys.argv[1:6]
|
||||
(unit, install_dir, port, origin_args, user, device, led_pin, led_mode,
|
||||
heartbeat) = sys.argv[1:10]
|
||||
text = open(unit).read()
|
||||
|
||||
# The console script is created by the installer, but the .pth file is what
|
||||
@@ -219,11 +290,38 @@ if not os.path.exists(wrapper):
|
||||
|
||||
command = (
|
||||
f"{wrapper} --serve "
|
||||
f"--port /dev/rdm-reader --bridge-port {port} --mode login{origin_args}"
|
||||
f"--port {device} --bridge-port {port} --mode login "
|
||||
f"--led-pin {led_pin} --led-mode {led_mode} --heartbeat {heartbeat}"
|
||||
f"{origin_args}"
|
||||
)
|
||||
|
||||
|
||||
def device_unit(path):
|
||||
"""systemd device unit for *path*, e.g. /dev/rdm-reader -> dev-rdm\\x2dreader.device."""
|
||||
name = path[len("/dev/"):] if path.startswith("/dev/") else path
|
||||
name = name.replace("-", "\\x2d").replace("/", "-")
|
||||
return f"dev-{name}.device"
|
||||
|
||||
|
||||
# Replace the whole (possibly line-continued) ExecStart definition.
|
||||
text = re.sub(r"ExecStart=[\s\S]*?(?=\n\n|\nRestart=)", f"ExecStart={command}", text, count=1)
|
||||
text = re.sub(r"^User=.*$", f"User={user}", text, count=1, flags=re.M)
|
||||
# Order after the device we actually open. The shipped unit names a device
|
||||
# that is not the one the service reads, so systemd never really waited for it.
|
||||
text = re.sub(r"^After=.*$", f"After={device_unit(device)} network-online.target",
|
||||
text, count=1, flags=re.M)
|
||||
# DevicePolicy=closed means only the devices listed here are usable, so the
|
||||
# list has to cover everything the service touches: the serial port, and the
|
||||
# GPIO chip the status LED drives. Omitting the chip is a silent failure - the
|
||||
# reader keeps working and only the LED stays dark - so it is easy to miss.
|
||||
# gpiochip0 is the header on Pi 1-4; Pi 5 uses gpiochip4.
|
||||
device_allow = "\n".join([
|
||||
f"DeviceAllow={device} rw",
|
||||
"DeviceAllow=/dev/gpiochip0 rw",
|
||||
"DeviceAllow=/dev/gpiochip4 rw",
|
||||
])
|
||||
text = re.sub(r"^DeviceAllow=.*(?:\nDeviceAllow=.*)*", device_allow, text,
|
||||
count=1, flags=re.M)
|
||||
open(unit, "w").write(text)
|
||||
PY
|
||||
|
||||
@@ -249,6 +347,9 @@ cat <<EOF
|
||||
$(log "Installed")
|
||||
|
||||
Bridge : http://127.0.0.1:$BRIDGE_PORT
|
||||
Device : $SERIAL_DEVICE
|
||||
LED : GPIO $LED_PIN, $LED_MODE mode
|
||||
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
|
||||
Logs : journalctl -u rdm-card-reader -f
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
Description=RDM card reader HTTP bridge (serves card taps for web login)
|
||||
Documentation=file:/opt/rdm-card-reader/README.md
|
||||
# The serial device must exist before we start, otherwise the reader opens the
|
||||
# wrong tty or none at all.
|
||||
After=dev-serial.device network-online.target
|
||||
# wrong tty or none at all. install.sh rewrites this line for the device given
|
||||
# with --device, since only it knows whether the reader is on the on-board UART
|
||||
# (/dev/serial0, the default) or a USB adapter (/dev/rdm-reader).
|
||||
After=dev-serial0.device network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
# Do not restart forever if the reader is genuinely absent. These are [Unit]
|
||||
@@ -17,22 +19,29 @@ User=rdmcard
|
||||
Group=dialout
|
||||
WorkingDirectory=/opt/rdm-card-reader
|
||||
|
||||
# --serve exposes the card on http://127.0.0.1:8765/api/card
|
||||
# --port points at the udev alias, so the reader is the same device every boot.
|
||||
# --serve runs the HTTP bridge, which is what makes the reader available as an
|
||||
# API on http://127.0.0.1:8765. --port points at the device the reader is
|
||||
# actually wired to; install.sh rewrites this line from --device, and only it
|
||||
# knows whether that is a USB adapter (/dev/rdm-reader) or the on-board UART
|
||||
# (/dev/serial0). --led-pin is rewritten from --led-pin for the same reason.
|
||||
# Autodetect refuses to guess if more than one USB adapter is present, which is
|
||||
# the safe behaviour: a wrong guess means authenticating the wrong person.
|
||||
# The wheels for rdm6300 and pyserial are bundled inside the package, so no
|
||||
# --wheel-dir is needed here: the install works with no internet.
|
||||
ExecStart=/opt/rdm-card-reader/.venv/bin/rdm-card-reader \
|
||||
--serve \
|
||||
--port /dev/rdm-reader \
|
||||
--port /dev/serial0 \
|
||||
--mode login \
|
||||
--allow-origin https://your.web.app
|
||||
|
||||
--led-pin 23 \
|
||||
--led-mode presence \
|
||||
--heartbeat 2.0
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
|
||||
# The bridge only needs to read the serial port and listen on loopback.
|
||||
# The bridge reads the serial port, drives the status LED and listens on
|
||||
# loopback. DeviceAllow is rewritten by install.sh alongside --port: with
|
||||
# DevicePolicy=closed a device missing from this list is denied outright, and
|
||||
# omitting the GPIO chip leaves the LED dark while everything else works.
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
@@ -44,7 +53,9 @@ RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
|
||||
RestrictNamespaces=true
|
||||
LockPersonality=true
|
||||
MemoryDenyWriteExecute=true
|
||||
DeviceAllow=/dev/rdm-reader rw
|
||||
DeviceAllow=/dev/serial0 rw
|
||||
DeviceAllow=/dev/gpiochip0 rw
|
||||
DeviceAllow=/dev/gpiochip4 rw
|
||||
DevicePolicy=closed
|
||||
|
||||
# stdout/stderr go here; watch with: journalctl -u rdm-card-reader -f
|
||||
|
||||
Reference in New Issue
Block a user