Files
rdm-card-reader/deploy/install.sh
T
ske087 031cf0f25a Add a status LED driven by card events
The workstation app lit an LED on GPIO 23 with the card. This brings that
across, with three deliberate differences: it never blocks the reader, it
cannot be fatal, and its state is reported.

WMT toggled the LED directly inside card_inserted, on the reader thread, where
a slow write stalls card detection. Here every change is queued to a worker, so
insert() returns immediately (tested with a deliberately slow pin).

Two modes, because the light can mean two things:
  error    (default) lit while the reader runs, dark while reading, blinks on a
                     bad read - a health indicator
  presence           lit only while a card is on the reader

gpiozero raises BadPinFactory on a machine with no GPIO, and that subclasses
ImportError - so a single except ImportError reports 'gpiozero is not
installed' when the library is present but has no usable pin provider. The
library check and the pin construction are therefore separate.

Vendors gpiozero + colorzero (~180 KB) so the LED installs offline too, and
deliberately not setuptools: gpiozero declares it via colorzero, but it is only
a packaging requirement and is never imported at runtime. Installing to the
bundled wheels therefore uses --no-deps, saving 818 KB.

Verified end to end over a pseudo-terminal with a fake pin: a real RDM6300
frame drives the LED through the app's own CLI in both modes.
2026-10-01 16:43:29 +03:00

262 lines
10 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)
# --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
#
set -euo pipefail
ORIGINS=()
BRIDGE_PORT=8765
INSTALL_DIR=/opt/rdm-card-reader
SERVICE_USER=rdmcard
INSTALL_SERVICE=1
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 ;;
--no-service) INSTALL_SERVICE=0; shift ;;
-h|--help) sed -n '2,20p' "$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 tests 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"
if ! python3 -m venv "$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"
# ---------------------------------------------------------------------------
# 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 [[ -e /dev/rdm-reader ]]; then
echo " /dev/rdm-reader -> $(readlink -f /dev/rdm-reader)"
else
warn "/dev/rdm-reader does not exist yet."
warn "The reader 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."
fi
# ---------------------------------------------------------------------------
# 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" <<'PY'
import os, re, sys
unit, install_dir, port, origin_args, user = sys.argv[1:6]
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"
command = (
f"{wrapper} --serve "
f"--port /dev/rdm-reader --bridge-port {port} --mode login{origin_args}"
)
# 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)
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
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