#!/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) # --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 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 ;; --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; } if [[ $EUID -ne 0 ]]; then echo "Please run as root: sudo $0 $*" >&2 exit 1 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" # --------------------------------------------------------------------------- # 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" < "$INSTALL_DIR/run.sh" </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" <<'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) = sys.argv[1:14] 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" 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 = ( 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}{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 <