Files
rdm-card-reader/deploy/install.sh
T
ske087 84569ec9ae Install the GPIO pin backend, and add the Word guide
install.sh created the virtualenv with --system-site-packages so it could
see the pin backend, but never installed one. On a Pi OS image whose
python3-gpiozero does not pull it in, gpiozero then raised BadPinFactory,
the app fell back to a no-op LED, and the reader ran with the light dark
and nothing failing - the failure that is easiest to miss. Install
python3-lgpio (Bookworm and later) or python3-rpi.gpio (older releases)
when neither backend is importable, best-effort like the packages above.

Adds the installation and API guide (DOCX, 36 pages, 4 figures, 25
tables) covering enabling the serial port, wiring, mounting, installing on
Raspberry Pi OS and the complete HTTP API, plus the header diagrams it
references.

.gitignore now excludes the release build outputs: they are generated from
the source tree, and committing them would duplicate the code.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-06 16:39:37 +03:00

471 lines
21 KiB
Bash
Executable File

#!/usr/bin/env bash
#
# Install the RDM card reader as a service that starts at boot and keeps the
# card on the reader available over HTTP for the web page to claim.
#
# Run as root on the Raspberry Pi:
#
# sudo ./deploy/install.sh --origin https://your.web.app
#
# 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)
# --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=()
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
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 ;;
*) echo "unknown option: $1" >&2; exit 2 ;;
esac
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
# ---------------------------------------------------------------------------
log "Checking system packages"
# Both of these need the network, so neither may abort the install: a device
# with no internet usually already has python3, and we can work without them.
apt-get update -qq 2>/dev/null || warn "apt-get update failed (no network?) - continuing"
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
# ---------------------------------------------------------------------------
log "Installing to $INSTALL_DIR"
mkdir -p "$INSTALL_DIR"
# Copy the source tree (excluding local build/test artifacts).
for item in src pyproject.toml README.md LAUNCH.md LICENSE deploy; do
if [[ -e "$SOURCE_DIR/$item" ]]; then
cp -a "$SOURCE_DIR/$item" "$INSTALL_DIR/"
fi
done
log "Building the virtual environment"
# --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"
exit 1
fi
VENV_PY="$INSTALL_DIR/.venv/bin/python"
WHEEL_DIR="$INSTALL_DIR/src/rdm_card_reader/wheels"
# Upgrading pip is optional and needs the network.
"$INSTALL_DIR/.venv/bin/pip" install --quiet --upgrade pip 2>/dev/null \
|| echo " (kept the bundled pip - no network)"
# Install the runtime dependencies from the wheels shipped in the source tree.
#
# Deliberately NOT 'pip install .': that builds the project, which requires
# downloading setuptools into an isolated build environment. With no internet
# it fails with
# Could not find a version that satisfies the requirement setuptools>=68
# even though everything needed to *run* is sitting in the wheel directory.
# Installing the runtime dependencies directly, and putting 'src' on the
# path, achieves the same thing and works offline.
INSTALLED_PACKAGE=0
if [[ -d "$WHEEL_DIR" ]] && "$VENV_PY" -m pip install --quiet --no-index \
--find-links "$WHEEL_DIR" rdm6300 pyserial 2>/dev/null; then
echo " installed rdm6300 and pyserial from the bundled wheels (offline)"
else
echo " bundled wheels unavailable - falling back to PyPI"
if "$VENV_PY" -m pip install --quiet rdm6300 pyserial 2>/dev/null; then
echo " installed rdm6300 and pyserial from PyPI"
else
warn "could not install rdm6300 / pyserial."
warn "The app will try again on first start (it can install from its own"
warn "bundled wheels), but check network access if that also fails."
fi
fi
# Status LED support. Best-effort: the reader works fine without a LED, so a
# failure here must not stop the install.
#
# --no-deps is deliberate: gpiozero declares colorzero, which in turn declares
# setuptools. setuptools is only a packaging requirement and is never imported
# at runtime, so it is not vendored (it would add 818 KB for nothing).
if [[ -d "$WHEEL_DIR" ]] && "$VENV_PY" -m pip install --quiet --no-index \
--no-deps --find-links "$WHEEL_DIR" gpiozero colorzero 2>/dev/null; then
echo " installed gpiozero + colorzero for the status LED (offline)"
else
if "$VENV_PY" -m pip install --quiet gpiozero colorzero 2>/dev/null; then
echo " installed gpiozero + colorzero from PyPI"
else
warn "could not install LED support - the reader still works, the LED"
warn "will be inactive. The app can install it later on first use."
fi
fi
# Make the package importable. The .pth file adds src/ to sys.path, so
# 'python -m rdm_card_reader' resolves from any working directory.
SITE_PACKAGES="$("$VENV_PY" -c 'import sysconfig; print(sysconfig.get_paths()["purelib"])')"
echo "$INSTALL_DIR/src" > "$SITE_PACKAGES/rdm_card_reader_src.pth"
# Console-script equivalent, so the unit and the docs work as written.
# The shebang must be the venv's interpreter by absolute path: '#!/usr/bin/env
# python3' would pick up the *system* python when the venv is not activated,
# and the package would not be importable.
cat > "$INSTALL_DIR/.venv/bin/rdm-card-reader" <<EOF
#!$VENV_PY
import sys
from rdm_card_reader.cli import main
if __name__ == "__main__":
sys.exit(main())
EOF
chmod +x "$INSTALL_DIR/.venv/bin/rdm-card-reader"
# Standalone launcher for running without activating anything.
cat > "$INSTALL_DIR/run.sh" <<EOF
#!/bin/sh
# Convenience launcher: ./run.sh [args...]
exec "$INSTALL_DIR/.venv/bin/rdm-card-reader" "\$@"
EOF
chmod +x "$INSTALL_DIR/run.sh"
# Sanity check: prove it imports and can run before we enable a service.
if "$INSTALL_DIR/.venv/bin/rdm-card-reader" --version >/dev/null 2>&1; then
echo " verified: $("$INSTALL_DIR/.venv/bin/rdm-card-reader" --version)"
else
warn "the reader does not run yet. Check with:"
warn " $INSTALL_DIR/.venv/bin/python -m rdm_card_reader --list-ports"
fi
# ---------------------------------------------------------------------------
# 3. Service account
# ---------------------------------------------------------------------------
log "Creating service account '$SERVICE_USER'"
if ! id -u "$SERVICE_USER" >/dev/null 2>&1; then
# No login shell, no home directory: this account only reads a serial port.
useradd --system --no-create-home --shell /usr/sbin/nologin "$SERVICE_USER"
fi
# Serial access requires the dialout group.
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
# ---------------------------------------------------------------------------
log "Installing the udev rule (/dev/rdm-reader)"
install -m 0644 "$SOURCE_DIR/deploy/99-rdm-reader.rules" \
/etc/udev/rules.d/99-rdm-reader.rules
udevadm control --reload-rules
udevadm trigger --subsystem-match=tty || true
sleep 1
if [[ "$SERIAL_DEVICE" == "/dev/rdm-reader" && ! -e /dev/rdm-reader ]]; then
warn "/dev/rdm-reader does not exist yet."
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
# ---------------------------------------------------------------------------
log "Installing the systemd unit"
UNIT=/etc/systemd/system/rdm-card-reader.service
install -m 0644 "$SOURCE_DIR/deploy/rdm-card-reader.service" "$UNIT"
# Rewrite the ExecStart line with the real install dir, port and origins.
ORIGIN_ARGS=""
for origin in "${ORIGINS[@]:-}"; do
[[ -n "$origin" ]] && ORIGIN_ARGS+=" --allow-origin $origin"
done
python3 - "$UNIT" "$INSTALL_DIR" "$BRIDGE_PORT" "$ORIGIN_ARGS" "$SERVICE_USER" \
"$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,
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
# makes the package importable, so fall back to 'python -m' if it is ever
# absent. systemd's ExecStart has no shell, so no pipes or conditionals.
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"{led_active_args}{error_args}{host_args}{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
# Make the venv readable by the service account.
chown -R "$SERVICE_USER:$SERVICE_USER" "$INSTALL_DIR"
if [[ "$INSTALL_SERVICE" -eq 1 ]]; then
log "Enabling and starting the service"
systemctl daemon-reload
systemctl enable rdm-card-reader.service
systemctl restart rdm-card-reader.service
sleep 2
systemctl --no-pager --lines=15 status rdm-card-reader.service || true
else
log "Skipping service start (--no-service)"
fi
# ---------------------------------------------------------------------------
# Done
# ---------------------------------------------------------------------------
cat <<EOF
$(log "Installed")
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_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
Logs : journalctl -u rdm-card-reader -f
Test a tap : place a card and run the curl above
The service starts automatically on boot. The card stays readable at
/api/card while it is on the reader; POST /api/card/claim hands it over
once, so a page reload cannot replay the same tap into a second login.
EOF