031cf0f25a
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.
262 lines
10 KiB
Bash
Executable File
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
|