diff --git a/buildroot-manage.sh b/buildroot-manage.sh new file mode 100644 index 0000000..17b283d --- /dev/null +++ b/buildroot-manage.sh @@ -0,0 +1,1148 @@ +#!/bin/bash +# Buildroot/Luckfox management entrypoint for pyMC Repeater + +set -euo pipefail + +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +INSTALL_DIR="/opt/pymc_repeater" +VENV_DIR="$INSTALL_DIR/venv" +VENV_PIP="$VENV_DIR/bin/pip" +VENV_PYTHON="$VENV_DIR/bin/python" +CONFIG_DIR="/etc/pymc_repeater" +LOG_DIR="/var/log/pymc_repeater" +DATA_DIR="/var/lib/pymc_repeater" +SERVICE_USER="root" +INIT_SCRIPT="/etc/init.d/S80pymc-repeater" +PIDFILE="/var/run/pymc-repeater.pid" +LOGFILE="$LOG_DIR/repeater.log" +SERVICE_NAME="pymc-repeater" +SILENT_MODE="${PYMC_SILENT:-${SILENT:-}}" + +R2_BASE_URL="https://wheel.pymc.dev/pymc_build_deps" +PIWHEELS_INDEX_URL="https://www.piwheels.org/simple" +R2_ENABLED=1 +PYMC_CORE_REPO="${PYMC_CORE_REPO:-https://github.com/rightup/pyMC_core.git}" +PYMC_CORE_REF="${PYMC_CORE_REF:-dev}" +RADIO_SETTINGS_JSON="$SCRIPT_DIR/radio-settings.json" +RADIO_PRESETS_JSON="$SCRIPT_DIR/radio-presets.json" +BUILDROOT_RADIO_SETTINGS_JSON="$SCRIPT_DIR/radio-settings-buildroot.json" +set_wheel_dependencies() { + set -- \ + "cherrypy>=18.0.0" \ + "cherrypy-cors==1.7.0" \ + "paho-mqtt>=1.6.0" \ + "pyjwt>=2.8.0" \ + "ws4py>=0.6.0" \ + "autocommand" \ + "backports.tarfile" \ + "jaraco.collections" \ + "jaraco.text" \ + "jaraco.context" \ + "tempora" \ + "zc.lockfile" \ + "httpagentparser>=1.5" + printf '%s\n' "$@" +} + +stage() { + printf '\n==> %s\n' "$1" +} + +info() { + printf ' - %s\n' "$1" +} + +warn() { + printf ' - %s\n' "$1" >&2 +} + +fail() { + printf '%s\n' "$1" >&2 + exit 1 +} + +need_cmd() { + command -v "$1" >/dev/null 2>&1 || fail "Missing required command: $1" +} + +is_buildroot() { + [ -f /etc/pymc-image-build-id ] && return 0 + [ -f /etc/os-release ] && grep -q '^ID=buildroot$' /etc/os-release 2>/dev/null && return 0 + return 1 +} + +prompt_value() { + local prompt="$1" + local default_value="${2:-}" + local reply="" + + if [ ! -t 0 ]; then + printf '%s\n' "$default_value" + return 0 + fi + + if [ -n "$default_value" ]; then + printf '%s [%s]: ' "$prompt" "$default_value" >&2 + else + printf '%s: ' "$prompt" >&2 + fi + IFS= read -r reply || true + reply=${reply:-$default_value} + printf '%s\n' "$reply" +} + +prompt_secret() { + local prompt="$1" + + python3 - "$prompt" <<'PY' +import os +import sys +import termios +import tty + +prompt = sys.argv[1] +try: + tty_fd = os.open("/dev/tty", os.O_RDWR) +except OSError: + print("Interactive admin password prompt requires a TTY. Set PYMC_ADMIN_PASSWORD instead.", file=sys.stderr) + raise SystemExit(1) + +def read_secret(label: str) -> str: + os.write(tty_fd, f"{label}: ".encode()) + original = termios.tcgetattr(tty_fd) + chars = [] + try: + tty.setraw(tty_fd) + while True: + ch = os.read(tty_fd, 1) + if ch in (b"\r", b"\n"): + os.write(tty_fd, b"\r\n") + return "".join(chars) + if ch == b"\x03": + raise KeyboardInterrupt + if ch in (b"\x7f", b"\x08"): + if chars: + chars.pop() + os.write(tty_fd, b"\b \b") + continue + if not ch or ch[0] < 32: + continue + chars.append(ch.decode(errors="ignore")) + os.write(tty_fd, b"*") + finally: + termios.tcsetattr(tty_fd, termios.TCSADRAIN, original) + +while True: + first = read_secret(prompt) + second = read_secret(f"Confirm {prompt.lower()}") + + if not first: + print(" - Password cannot be empty.", file=sys.stderr) + continue + if len(first) < 6: + print(" - Password must be at least 6 characters.", file=sys.stderr) + continue + if first != second: + print(" - Passwords do not match.", file=sys.stderr) + continue + + print(first) + break +os.close(tty_fd) +PY +} + +ensure_root() { + [ "$(id -u 2>/dev/null || echo 1)" -eq 0 ] || fail "This command must be run as root." +} + +group_exists() { + grep -q "^$1:" /etc/group 2>/dev/null +} + +ensure_group_line() { + local group_name="$1" + local gid="$2" + group_exists "$group_name" && return 0 + printf '%s:x:%s:\n' "$group_name" "$gid" >> /etc/group +} + +ensure_service_user() { + if [ "$SERVICE_USER" = "root" ]; then + return 0 + fi + + if id "$SERVICE_USER" >/dev/null 2>&1; then + return 0 + fi + + if command -v useradd >/dev/null 2>&1; then + useradd --system --home "$DATA_DIR" --shell /sbin/nologin "$SERVICE_USER" + return 0 + fi + + ensure_group_line "$SERVICE_USER" 990 + printf '%s:x:990:990::%s:/sbin/nologin\n' "$SERVICE_USER" "$DATA_DIR" >> /etc/passwd + if [ -f /etc/shadow ]; then + printf '%s:!:19000:0:99999:7:::\n' "$SERVICE_USER" >> /etc/shadow + fi +} + +add_user_to_group() { + local user_name="$1" + local group_name="$2" + local current_line current_members gid escaped_line new_members + + if [ "$user_name" = "root" ]; then + return 0 + fi + + group_exists "$group_name" || return 0 + current_line=$(grep "^${group_name}:" /etc/group 2>/dev/null || true) + [ -n "$current_line" ] || return 0 + current_members=$(printf '%s' "$current_line" | cut -d: -f4) + case ",${current_members}," in + *,"${user_name}",*) return 0 ;; + esac + + if [ -n "$current_members" ]; then + new_members="${current_members},${user_name}" + else + new_members="${user_name}" + fi + gid=$(printf '%s' "$current_line" | cut -d: -f3) + escaped_line=$(printf '%s\n' "$current_line" | sed 's/[].[^$\\*]/\\&/g') + sed -i "s/^${escaped_line}\$/${group_name}:x:${gid}:${new_members}/" /etc/group +} + +install_system_packages() { + if is_buildroot; then + info "Buildroot image detected; using preinstalled packages." + return 0 + fi + + apt-get update -qq + DEBIAN_FRONTEND=noninteractive apt-get install -y \ + libffi-dev libusb-1.0-0 sudo jq pip python3-venv python3-rrdtool wget swig build-essential python3-dev +} + +ensure_venv() { + local recreate=0 + + if [ -f "$VENV_DIR/pyvenv.cfg" ] && grep -Eq '^include-system-site-packages *= *true$' "$VENV_DIR/pyvenv.cfg"; then + stage "Rebuilding virtual environment" + info "Existing venv uses system site-packages and is not supported on Buildroot." + recreate=1 + fi + + if [ "$recreate" -eq 1 ]; then + rm -rf "$VENV_DIR" + fi + + if [ ! -x "$VENV_PYTHON" ]; then + stage "Creating virtual environment" + info "Creating $VENV_DIR" + info "This can take a minute on Buildroot flash storage." + python3 -m venv "$VENV_DIR" + info "Bootstrapping pip, setuptools, and wheel" + "$VENV_PIP" install --upgrade --no-cache-dir pip setuptools wheel + info "Virtual environment is ready" + else + info "Using existing virtual environment at $VENV_DIR" + fi +} + +ensure_venv_build_backend() { + if "$VENV_PYTHON" - <<'PY' +import setuptools +import setuptools.build_meta +import wheel +PY + then + info "venv build backend is ready" + return 0 + fi + + stage "Rebuilding virtual environment" + warn "Existing venv is contaminated or incomplete; recreating it cleanly." + rm -rf "$VENV_DIR" + python3 -m venv "$VENV_DIR" + "$VENV_PIP" install --upgrade --no-cache-dir pip setuptools wheel + + if "$VENV_PYTHON" - <<'PY' +import setuptools +import setuptools.build_meta +import wheel +PY + then + info "venv build backend repaired" + return 0 + fi + + fail "Unable to prepare an isolated venv with setuptools.build_meta on this Buildroot image." +} + +get_r2_wheel_base() { + local machine_arch arch_tag platform_tag py_tag wheel_base + + [ "$R2_ENABLED" -eq 1 ] || return 1 + + machine_arch=$(uname -m) + case "$machine_arch" in + aarch64) arch_tag="arm64"; platform_tag="aarch64" ;; + armv7l|armv7) arch_tag="armv7"; platform_tag="armv7l" ;; + x86_64) arch_tag="x86_64"; platform_tag="x86_64" ;; + *) return 1 ;; + esac + + py_tag=$("$VENV_PYTHON" -c 'import sys; v=f"cp{sys.version_info.major}{sys.version_info.minor}"; print(f"{v}-{v}")' 2>/dev/null || echo "cp311-cp311") + wheel_base="${R2_BASE_URL}/${arch_tag}/${platform_tag}/${py_tag}" + printf '%s\n' "$wheel_base" +} + +preinstall_r2_wheels() { + local wheel_base + + wheel_base=$(get_r2_wheel_base 2>/dev/null || true) + [ -n "$wheel_base" ] || return 0 + + stage "Checking optional wheel cache" + info "Native Python modules come from the Buildroot image." + info "Skipping native wheel preload from ${wheel_base}/index.html" +} + +install_buildroot_dependencies() { + local wheel_base + local deps + + wheel_base=$(get_r2_wheel_base 2>/dev/null || true) + deps=$(set_wheel_dependencies) + stage "Installing Python dependency wheels" + if [ -n "$wheel_base" ]; then + info "Using Rightup wheels: ${wheel_base}/index.html" + fi + info "Using piwheels fallback: ${PIWHEELS_INDEX_URL}" + + if [ -n "$wheel_base" ]; then + # shellcheck disable=SC2086 + "$VENV_PIP" install --upgrade --no-cache-dir --only-binary=:all: \ + --find-links "${wheel_base}/index.html" \ + --extra-index-url "${PIWHEELS_INDEX_URL}" \ + $deps + else + # shellcheck disable=SC2086 + "$VENV_PIP" install --upgrade --no-cache-dir --only-binary=:all: \ + --extra-index-url "${PIWHEELS_INDEX_URL}" \ + $deps + fi +} + +link_system_site_packages() { + local venv_site_dir pth_file system_paths + + venv_site_dir=$("$VENV_PYTHON" - <<'PY' +import site +for path in site.getsitepackages(): + if path.endswith("site-packages"): + print(path) + break +PY +) + [ -n "$venv_site_dir" ] || fail "Could not determine venv site-packages directory." + + system_paths=$(python3 - <<'PY' +import site +for path in site.getsitepackages(): + if path.startswith("/usr/lib/python") and path.endswith("site-packages"): + print(path) +PY +) + + [ -n "$system_paths" ] || return 0 + + pth_file="$venv_site_dir/buildroot-system-site-packages.pth" + printf '%s\n' "$system_paths" > "$pth_file" + info "Linked image-provided Python runtime into the venv" +} + +install_core_into_venv() { + local core_repo core_spec + + core_repo="$PYMC_CORE_REPO" + case "$core_repo" in + *.git) ;; + *) core_repo="${core_repo}.git" ;; + esac + core_spec="pyMC_core[hardware] @ git+${core_repo}@${PYMC_CORE_REF}" + stage "Installing pyMC_core" + info "Repo: ${PYMC_CORE_REPO}" + info "Ref: ${PYMC_CORE_REF}" + "$VENV_PIP" install --upgrade --no-cache-dir --no-deps --no-build-isolation "$core_spec" +} + +install_repeater_package() { + stage "Installing pyMC Repeater into venv" + info "Installing checked-out repo without re-resolving dependencies" + "$VENV_PIP" install --upgrade --no-cache-dir --no-deps --no-build-isolation "$SCRIPT_DIR" +} + +create_init_script() { + cat > "$INIT_SCRIPT" <<'EOF' +#!/bin/sh +DAEMON="__DAEMON__" +PIDFILE="__PIDFILE__" +LOGFILE="__LOGFILE__" +WORKDIR="__WORKDIR__" +CONFIG_FILE="__CONFIG_FILE__" +RUN_AS="__RUN_AS__" + +start() { + mkdir -p "$(dirname "$PIDFILE")" "$(dirname "$LOGFILE")" "$WORKDIR" + if [ -f "$PIDFILE" ] && kill -0 "$(cat "$PIDFILE")" 2>/dev/null; then + echo "__SERVICE_NAME__ is already running." + return 0 + fi + start-stop-daemon --start --quiet --background --make-pidfile --pidfile "$PIDFILE" \ + --chuid "$RUN_AS" --exec /bin/sh -- -c "cd \"$WORKDIR\" && exec \"$DAEMON\" -m repeater.main --config \"$CONFIG_FILE\" >>\"$LOGFILE\" 2>&1" +} + +stop() { + if [ ! -f "$PIDFILE" ]; then + echo "__SERVICE_NAME__ is not running." + return 0 + fi + start-stop-daemon --stop --quiet --retry 5 --pidfile "$PIDFILE" >/dev/null 2>&1 || true + rm -f "$PIDFILE" +} + +status() { + if [ -f "$PIDFILE" ] && kill -0 "$(cat "$PIDFILE")" 2>/dev/null; then + echo "__SERVICE_NAME__ is running." + return 0 + fi + echo "__SERVICE_NAME__ is stopped." + return 1 +} + +case "${1:-}" in + start) start ;; + stop) stop ;; + restart) stop; start ;; + status) status ;; + *) + echo "Usage: $0 {start|stop|restart|status}" + exit 1 + ;; +esac +EOF + sed -i \ + -e "s|__DAEMON__|${VENV_PYTHON}|g" \ + -e "s|__PIDFILE__|${PIDFILE}|g" \ + -e "s|__LOGFILE__|${LOGFILE}|g" \ + -e "s|__WORKDIR__|${DATA_DIR}|g" \ + -e "s|__CONFIG_FILE__|${CONFIG_DIR}/config.yaml|g" \ + -e "s|__RUN_AS__|${SERVICE_USER}|g" \ + -e "s|__SERVICE_NAME__|${SERVICE_NAME}|g" \ + "$INIT_SCRIPT" + chmod 0755 "$INIT_SCRIPT" +} + +get_primary_ip() { + ip -o -4 addr show dev eth0 2>/dev/null | awk 'NR==1 { sub(/\/.*/, "", $4); print $4; exit }' +} + +get_config_value() { + local key_path="$1" + local fallback="${2:-}" + + python3 - "$CONFIG_DIR/config.yaml" "$key_path" "$fallback" <<'PY' +import sys +import yaml + +config_path, key_path, fallback = sys.argv[1:4] +try: + with open(config_path, "r", encoding="utf-8") as fh: + data = yaml.safe_load(fh) or {} +except FileNotFoundError: + print(fallback) + raise SystemExit(0) + +current = data +for part in key_path.split("."): + if isinstance(current, dict) and part in current: + current = current[part] + else: + print(fallback) + raise SystemExit(0) + +if current in (None, ""): + print(fallback) +elif isinstance(current, float): + rendered = ("%f" % current).rstrip("0").rstrip(".") + print(rendered or "0") +else: + print(current) +PY +} + +list_buildroot_boards() { + python3 - "$BUILDROOT_RADIO_SETTINGS_JSON" <<'PY' +import json +import sys + +with open(sys.argv[1], "r", encoding="utf-8") as fh: + data = json.load(fh) + +for index, (key, entry) in enumerate(data.get("buildroot_hardware", {}).items(), start=1): + print(f"{index}|{key}|{entry.get('name', key)}|{entry.get('description', '')}") +PY +} + +resolve_buildroot_board() { + local choice="$1" + + python3 - "$BUILDROOT_RADIO_SETTINGS_JSON" "$choice" <<'PY' +import json +import sys + +with open(sys.argv[1], "r", encoding="utf-8") as fh: + data = json.load(fh) + +choice = sys.argv[2].strip().lower() +boards = list((data.get("buildroot_hardware") or {}).items()) + +for index, (key, entry) in enumerate(boards, start=1): + aliases = {str(index), key.lower(), str(entry.get("name", "")).lower()} + aliases.update(alias.lower() for alias in entry.get("aliases", [])) + if choice in aliases: + print(key) + raise SystemExit(0) + +raise SystemExit(1) +PY +} + +get_default_buildroot_board() { + python3 - "$BUILDROOT_RADIO_SETTINGS_JSON" <<'PY' +import json +import sys + +with open(sys.argv[1], "r", encoding="utf-8") as fh: + data = json.load(fh) + +default_board = data.get("default_board") +boards = data.get("buildroot_hardware", {}) +if default_board and default_board in boards: + print(default_board) +else: + for key in boards: + print(key) + break +PY +} + +get_buildroot_board_label() { + local board_key="$1" + python3 - "$BUILDROOT_RADIO_SETTINGS_JSON" "$board_key" <<'PY' +import json +import sys + +with open(sys.argv[1], "r", encoding="utf-8") as fh: + data = json.load(fh) + +entry = (data.get("buildroot_hardware") or {}).get(sys.argv[2], {}) +print(entry.get("name", sys.argv[2])) +PY +} + +list_radio_presets() { + python3 - "$RADIO_PRESETS_JSON" <<'PY' +import json +import sys + +with open(sys.argv[1], "r", encoding="utf-8") as fh: + data = json.load(fh) + +entries = ((data.get("config") or {}).get("suggested_radio_settings") or {}).get("entries", []) +for index, entry in enumerate(entries, start=1): + print(f"{index}|{entry.get('title', '')}|{entry.get('description', '')}") +PY +} + +resolve_radio_preset() { + local choice="$1" + + python3 - "$RADIO_PRESETS_JSON" "$choice" <<'PY' +import json +import sys + +with open(sys.argv[1], "r", encoding="utf-8") as fh: + data = json.load(fh) + +choice = sys.argv[2].strip().lower() +entries = ((data.get("config") or {}).get("suggested_radio_settings") or {}).get("entries", []) +for index, entry in enumerate(entries, start=1): + title = entry.get("title", "") + if choice in {str(index), title.lower()}: + print(title) + raise SystemExit(0) + +raise SystemExit(1) +PY +} + +get_default_radio_preset() { + python3 - "$BUILDROOT_RADIO_SETTINGS_JSON" <<'PY' +import json +import sys + +with open(sys.argv[1], "r", encoding="utf-8") as fh: + data = json.load(fh) + +print(data.get("default_radio_preset", "")) +PY +} + +get_radio_preset_field() { + local preset_title="$1" + local field="$2" + + python3 - "$RADIO_PRESETS_JSON" "$preset_title" "$field" <<'PY' +import json +import sys + +with open(sys.argv[1], "r", encoding="utf-8") as fh: + data = json.load(fh) + +preset_title, field = sys.argv[2:4] +entries = ((data.get("config") or {}).get("suggested_radio_settings") or {}).get("entries", []) +for entry in entries: + if entry.get("title") == preset_title: + print(entry.get(field, "")) + raise SystemExit(0) + +raise SystemExit(1) +PY +} + +get_radio_frequency_mhz() { + python3 - "$CONFIG_DIR/config.yaml" <<'PY' +import yaml + +with open(__import__("sys").argv[1], "r", encoding="utf-8") as fh: + data = yaml.safe_load(fh) or {} +freq = ((data.get("radio") or {}).get("frequency")) or 910525000 +print(f"{float(freq) / 1_000_000:.3f}".rstrip("0").rstrip(".")) +PY +} + +get_radio_bandwidth_khz() { + python3 - "$CONFIG_DIR/config.yaml" <<'PY' +import yaml + +with open(__import__("sys").argv[1], "r", encoding="utf-8") as fh: + data = yaml.safe_load(fh) or {} +bw = ((data.get("radio") or {}).get("bandwidth")) or 62500 +print(f"{float(bw) / 1000:.3f}".rstrip("0").rstrip(".")) +PY +} + +select_buildroot_board() { + local choice="${LUCKFOX_RADIO_PROFILE:-${PYMC_RADIO_PROFILE:-${PYMC_BUILDROOT_BOARD:-}}}" + local default_board + + default_board=$(get_default_buildroot_board) + + if [ -n "$choice" ]; then + resolve_buildroot_board "$choice" || fail "Unknown Buildroot board choice: $choice" + return 0 + fi + + printf 'Select Luckfox radio board:\n' >&2 + while IFS='|' read -r index key name description; do + [ -n "$index" ] || continue + printf ' %s) %s' "$index" "$name" >&2 + [ -n "$description" ] && printf ' - %s' "$description" >&2 + printf '\n' >&2 + done <&2 + while IFS='|' read -r index title description; do + [ -n "$index" ] || continue + printf ' %s) %s' "$index" "$title" >&2 + [ -n "$description" ] && printf ' - %s' "$description" >&2 + printf '\n' >&2 + done </dev/null +} + +get_version() { + if [ -x "$VENV_PYTHON" ]; then + "$VENV_PYTHON" -c "from importlib.metadata import version; print(version('pymc_repeater'))" 2>/dev/null || echo "not installed" + else + echo "not installed" + fi +} + +doctor() { + stage "Checking Buildroot image baseline" + + for cmd in bash git python3 dialog jq wget sudo sqlite3 start-stop-daemon; do + if command -v "$cmd" >/dev/null 2>&1; then + info "found $cmd" + else + warn "missing $cmd" + fi + done + + if python3 -m venv --help >/dev/null 2>&1; then + info "python venv support available" + else + warn "python venv support missing" + fi + + if python3 - <<'PY' +modules = [ + "sqlite3", + "yaml", + "cherrypy", + "cherrypy_cors", + "autocommand", + "jaraco.collections", + "jaraco.text", + "paho.mqtt.client", + "psutil", + "jwt", + "ws4py", + "nacl", + "periphery", + "spidev", + "serial", + "usb", + "Crypto", +] +for module in modules: + __import__(module) +PY + then + info "python runtime packages are present" + else + warn "python runtime packages are missing" + fi + + for path in /dev/spidev* /dev/gpiochip*; do + [ -e "$path" ] && info "detected $path" + done +} + +check_venv_runtime() { + "$VENV_PYTHON" - <<'PY' +checks = [ + ("import yaml", "PyYAML"), + ("import cherrypy", "CherryPy"), + ("import cherrypy_cors", "cherrypy-cors"), + ("import paho.mqtt.client", "paho-mqtt"), + ("import psutil", "psutil"), + ("import jwt", "PyJWT"), + ("import ws4py", "ws4py"), + ("import nacl", "PyNaCl"), + ("import periphery", "python-periphery"), + ("import spidev", "spidev"), + ("import serial", "pyserial"), + ("import usb", "pyusb"), + ("from Crypto.Cipher import AES", "pycryptodome AES backend"), +] +failed = [] +for stmt, label in checks: + try: + exec(stmt, {}) + except Exception as exc: + failed.append((label, exc)) + +if failed: + print("Buildroot runtime validation failed:") + for label, exc in failed: + print(f" - {label}: {exc}") + raise SystemExit(1) +PY +} + +install_repeater() { + local git_version ip_address + + ensure_root + stage "Preparing Buildroot installation" + install_system_packages + ensure_service_user + + stage "Preparing directories and config" + info "Install dir: $INSTALL_DIR" + info "Config dir: $CONFIG_DIR" + info "Data dir: $DATA_DIR" + mkdir -p "$INSTALL_DIR" "$CONFIG_DIR" "$LOG_DIR" "$DATA_DIR" "$DATA_DIR/.config/pymc_repeater" + chown -R root:root "$CONFIG_DIR" "$LOG_DIR" "$DATA_DIR" + chmod 755 "$INSTALL_DIR" "$DATA_DIR" + chmod 750 "$CONFIG_DIR" "$LOG_DIR" + + cp "$SCRIPT_DIR/config.yaml.example" "$CONFIG_DIR/config.yaml.example" + [ -f "$CONFIG_DIR/config.yaml" ] || cp "$SCRIPT_DIR/config.yaml.example" "$CONFIG_DIR/config.yaml" + cp "$SCRIPT_DIR/radio-settings.json" "$DATA_DIR/" 2>/dev/null || true + cp "$SCRIPT_DIR/radio-presets.json" "$DATA_DIR/" 2>/dev/null || true + + ensure_venv + ensure_venv_build_backend + + if [ -d "$SCRIPT_DIR/.git" ]; then + stage "Inspecting checked-out repo version" + info "Fetching tags for setuptools_scm version detection" + git -C "$SCRIPT_DIR" fetch --tags 2>/dev/null || true + git_version=$(python3 -m setuptools_scm 2>/dev/null || echo "1.0.5") + export SETUPTOOLS_SCM_PRETEND_VERSION="$git_version" + info "Using version: $git_version" + else + export SETUPTOOLS_SCM_PRETEND_VERSION="1.0.5" + info "Using fallback version: 1.0.5" + fi + + preinstall_r2_wheels + install_buildroot_dependencies + install_core_into_venv + install_repeater_package + link_system_site_packages + + stage "Validating installed runtime" + if check_venv_runtime; then + info "Installed Python runtime looks usable" + else + fail "Installed packages are present but one or more native modules are unusable on this image." + fi + + seed_repeater_config + + stage "Writing Buildroot init service" + create_init_script + + stage "Starting service" + "$INIT_SCRIPT" restart + + ip_address=$(get_primary_ip) + if is_running; then + printf '\nService is running on: http://%s:8000\n' "${ip_address}" + else + fail "Installation completed but the service failed to start. Check: sh $0 logs" + fi +} + +upgrade_repeater() { + ensure_root + is_installed || fail "Service is not installed." + + ensure_venv + ensure_venv_build_backend + preinstall_r2_wheels + + stage "Upgrading pyMC Repeater" + install_buildroot_dependencies + install_core_into_venv + install_repeater_package + link_system_site_packages + stage "Validating installed runtime" + if check_venv_runtime; then + info "Installed Python runtime looks usable" + else + fail "Installed packages are present but one or more native modules are unusable on this image." + fi + "$INIT_SCRIPT" restart +} + +uninstall_repeater() { + ensure_root + + stage "Removing service" + service_exists && "$INIT_SCRIPT" stop || true + rm -f "$INIT_SCRIPT" + rm -rf "$INSTALL_DIR" "$CONFIG_DIR" "$LOG_DIR" "$DATA_DIR" +} + +manage_service() { + local action="$1" + ensure_root + service_exists || fail "Service is not installed." + "$INIT_SCRIPT" "$action" +} + +show_status() { + local ip_address version + version=$(get_version) + ip_address=$(get_primary_ip) + + if ! is_installed; then + printf 'Installation Status: Not Installed\n' + return 0 + fi + + printf 'Installation Status: Installed\n' + printf 'Version: %s\n' "$version" + printf 'Install Directory: %s\n' "$INSTALL_DIR" + printf 'Config Directory: %s\n' "$CONFIG_DIR" + printf 'Log File: %s\n' "$LOGFILE" + printf 'Dashboard: http://%s:8000\n' "$ip_address" + if is_running; then + printf 'Service Status: Running\n' + else + printf 'Service Status: Stopped\n' + fi +} + +show_logs() { + mkdir -p "$LOG_DIR" + touch "$LOGFILE" + tail -f "$LOGFILE" +} + +run_debug() { + ensure_root + mkdir -p "$LOG_DIR" "$DATA_DIR" + exec "$VENV_PYTHON" -m repeater.main --config "$CONFIG_DIR/config.yaml" +} + +delegate_to_stock_manage() { + exec bash "$SCRIPT_DIR/manage.sh" "$@" +} + +usage() { + cat <<'EOF' +Usage: bash buildroot-manage.sh + +Commands: + doctor Check Buildroot/Luckfox prerequisites + install Install pyMC Repeater on the Buildroot image + upgrade Upgrade the Buildroot installation from the checked-out repo + config Prompt for repeater settings and rewrite config.yaml + configure Same as config + radio-profile Reapply the Luckfox board radio config only + start Start the init.d service + stop Stop the init.d service + restart Restart the init.d service + status Show Buildroot service status + logs Tail the Buildroot log file + uninstall Remove the Buildroot installation + debug Run repeater.main in the foreground +EOF +} + +case "${1:-}" in + doctor) + doctor + ;; + install) + install_repeater + ;; + upgrade) + upgrade_repeater + ;; + config|configure) + configure_repeater + ;; + radio-profile) + configure_radio_profile + ;; + start|stop|restart) + manage_service "$1" + ;; + status) + show_status + ;; + logs) + show_logs + ;; + uninstall) + uninstall_repeater + ;; + debug) + run_debug + ;; + ""|help|-h|--help) + usage + ;; + *) + fail "Unknown command: ${1}" + ;; +esac diff --git a/radio-settings-buildroot.json b/radio-settings-buildroot.json new file mode 100644 index 0000000..db2d720 --- /dev/null +++ b/radio-settings-buildroot.json @@ -0,0 +1,73 @@ +{ + "default_board": "luckfox-pimesh-v2", + "default_radio_preset": "USA/Canada (Recommended)", + "buildroot_hardware": { + "luckfox-pimesh-v2": { + "name": "Luckfox PiMesh V2", + "description": "Luckfox Pico Pi with PiMesh-1W V2 / E22P wiring", + "hardware_id": "pimesh-1w-v2", + "aliases": [ + "1", + "v2", + "pimesh-v2", + "pimesh-1w-v2" + ], + "sx1262_overrides": { + "cs_pin": -1, + "reset_pin": 54, + "busy_pin": 122, + "irq_pin": 121, + "en_pin": 0, + "txen_pin": -1, + "rxen_pin": -1, + "use_dio2_rf": true, + "use_dio3_tcxo": true, + "dio3_tcxo_voltage": 1.8 + } + }, + "luckfox-pimesh-v1": { + "name": "Luckfox PiMesh V1", + "description": "Luckfox Pico Pi with PiMesh-1W V1 wiring", + "hardware_id": "pimesh-1w-v1", + "aliases": [ + "2", + "v1", + "pimesh-v1", + "pimesh-1w-v1" + ], + "sx1262_overrides": { + "cs_pin": 145, + "reset_pin": 54, + "busy_pin": 123, + "irq_pin": 55, + "en_pin": -1, + "txen_pin": 52, + "rxen_pin": 53, + "use_dio2_rf": false, + "use_dio3_tcxo": true, + "dio3_tcxo_voltage": 1.8 + } + }, + "luckfox-meshadv": { + "name": "Luckfox MeshAdv", + "description": "Luckfox Pico Pi with MeshAdv wiring", + "hardware_id": "meshadv", + "aliases": [ + "3", + "meshadv" + ], + "sx1262_overrides": { + "cs_pin": 145, + "reset_pin": 54, + "busy_pin": 123, + "irq_pin": 55, + "en_pin": -1, + "txen_pin": 52, + "rxen_pin": 53, + "use_dio2_rf": false, + "use_dio3_tcxo": true, + "dio3_tcxo_voltage": 1.8 + } + } + } +} diff --git a/repeater/service_utils.py b/repeater/service_utils.py index 14a1481..a127e48 100644 --- a/repeater/service_utils.py +++ b/repeater/service_utils.py @@ -4,22 +4,57 @@ Provides functions for service control operations like restart. """ import logging +import os import subprocess from typing import Tuple logger = logging.getLogger("ServiceUtils") +INIT_SCRIPT = "/etc/init.d/S80pymc-repeater" + + +def is_buildroot() -> bool: + if os.path.exists("/etc/pymc-image-build-id"): + return True + if os.path.exists("/etc/os-release"): + try: + with open("/etc/os-release", "r", encoding="utf-8") as handle: + return any(line.strip() == "ID=buildroot" for line in handle) + except OSError: + return False + return False def restart_service() -> Tuple[bool, str]: """ - Restart the pymc-repeater service via systemctl. + Restart the pymc-repeater service. - Tries polkit-based restart first (plain systemctl), then falls back - to sudo-based restart (requires sudoers.d rule installed by manage.sh). + On Buildroot/Luckfox, use the shipped init script directly. + On systemd hosts, try polkit-based restart first (plain systemctl), then + fall back to sudo-based restart (requires sudoers.d rule installed by + manage.sh). Returns: Tuple[bool, str]: (success, message) """ + if is_buildroot(): + if not os.path.exists(INIT_SCRIPT): + logger.error("Buildroot init script not found: %s", INIT_SCRIPT) + return False, f"init script not found: {INIT_SCRIPT}" + + try: + subprocess.Popen( + ["/bin/sh", "-c", f"sleep 1; exec {INIT_SCRIPT} restart >/dev/null 2>&1"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + stdin=subprocess.DEVNULL, + start_new_session=True, + ) + logger.info("Service restart scheduled via Buildroot init script") + return True, "Service restart initiated" + except Exception as exc: + logger.error(f"Buildroot restart failed: {exc}") + return False, f"Restart failed: {exc}" + # Try polkit-based restart first (works on bare metal / VMs with polkit running) try: result = subprocess.run( diff --git a/repeater/web/update_endpoints.py b/repeater/web/update_endpoints.py index b5171ee..b45c977 100644 --- a/repeater/web/update_endpoints.py +++ b/repeater/web/update_endpoints.py @@ -743,6 +743,10 @@ def _migrate_service_unit() -> None: """Strip legacy PYTHONPATH, fix WorkingDirectory, and ensure ExecStart uses the venv python in the systemd service unit. """ + if os.path.exists("/etc/pymc-image-build-id"): + logger.info("[Update] Buildroot image detected, skipping systemd unit migration.") + return + import subprocess as _sp _SVC_UNIT = "/etc/systemd/system/pymc-repeater.service" _VENV_PYTHON = "/opt/pymc_repeater/venv/bin/python"