Files
pyMC_Repeater/buildroot-manage.sh
T
2026-04-23 23:21:15 -04:00

1287 lines
35 KiB
Bash

#!/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:-}"
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
}
detect_local_git_ref() {
local branch tag
if ! git -C "$SCRIPT_DIR" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
return 1
fi
branch=$(git -C "$SCRIPT_DIR" symbolic-ref --quiet --short HEAD 2>/dev/null || true)
if [ -n "$branch" ]; then
printf '%s\n' "$branch"
return 0
fi
tag=$(git -C "$SCRIPT_DIR" describe --tags --exact-match 2>/dev/null || true)
if [ -n "$tag" ]; then
printf '%s\n' "$tag"
return 0
fi
return 1
}
remote_ref_exists() {
local repo="$1"
local ref="$2"
[ -n "$ref" ] || return 1
git ls-remote --exit-code --heads --tags "$repo" "$ref" >/dev/null 2>&1
}
resolve_core_ref() {
local candidate fallback
if [ -n "$PYMC_CORE_REF" ]; then
printf '%s\n' "$PYMC_CORE_REF"
return 0
fi
candidate=$(detect_local_git_ref 2>/dev/null || true)
if [ -n "$candidate" ] && remote_ref_exists "$PYMC_CORE_REPO" "$candidate"; then
printf '%s\n' "$candidate"
return 0
fi
fallback="dev"
printf '%s\n' "$fallback"
}
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 == "admin123":
print(" - Password cannot be the default admin123.", file=sys.stderr)
continue
if first != second:
print(" - Passwords do not match.", file=sys.stderr)
continue
print(first)
break
os.close(tty_fd)
PY
}
validate_node_name() {
local node_name="$1"
python3 - "$node_name" <<'PY'
import sys
node_name = sys.argv[1].strip()
if not node_name:
print("Repeater name cannot be empty.", file=sys.stderr)
raise SystemExit(1)
if node_name == "mesh-repeater-01":
print("Repeater name cannot be the default mesh-repeater-01.", file=sys.stderr)
raise SystemExit(1)
if len(node_name.encode("utf-8")) > 31:
print("Repeater name is too long (max 31 UTF-8 bytes).", file=sys.stderr)
raise SystemExit(1)
PY
}
validate_admin_password() {
local admin_password="$1"
python3 - "$admin_password" <<'PY'
import sys
password = sys.argv[1]
if not password:
print("Admin password cannot be empty.", file=sys.stderr)
raise SystemExit(1)
if len(password) < 6:
print("Admin password must be at least 6 characters.", file=sys.stderr)
raise SystemExit(1)
if password == "admin123":
print("Admin password cannot be the default admin123.", file=sys.stderr)
raise SystemExit(1)
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."
}
cleanup_legacy_install_state() {
local removed=0
local path
for path in \
"$INSTALL_DIR/repeater" \
"$INSTALL_DIR/pymc_core" \
"$INSTALL_DIR/pyMC_Repeater" \
"$INSTALL_DIR/pyMC_core"
do
if [ -e "$path" ]; then
rm -rf "$path"
removed=1
info "Removed stale source tree at $path"
fi
done
if [ "$removed" -eq 0 ]; then
info "No stale source-tree paths found under $INSTALL_DIR"
fi
}
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_ref core_spec
core_repo="$PYMC_CORE_REPO"
case "$core_repo" in
*.git) ;;
*) core_repo="${core_repo}.git" ;;
esac
core_ref=$(resolve_core_ref)
core_spec="pyMC_core[hardware] @ git+${core_repo}@${core_ref}"
stage "Installing pyMC_core"
info "Repo: ${PYMC_CORE_REPO}"
info "Ref: ${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
}
get_buildroot_board_field() {
local board_key="$1"
local field="$2"
python3 - "$BUILDROOT_RADIO_SETTINGS_JSON" "$board_key" "$field" <<'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], {})
value = entry.get(sys.argv[3], "")
if value is None:
value = ""
print(value)
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 <<EOF
$(list_buildroot_boards)
EOF
choice=$(prompt_value "Board" "$default_board")
resolve_buildroot_board "$choice" || fail "Unknown Buildroot board choice: $choice"
}
select_radio_preset() {
local choice="${PYMC_RADIO_PRESET:-}"
local default_preset
default_preset=$(get_default_radio_preset)
if [ -n "$choice" ]; then
resolve_radio_preset "$choice" || fail "Unknown radio preset choice: $choice"
return 0
fi
printf 'Select radio preset:\n' >&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 <<EOF
$(list_radio_presets)
EOF
choice=$(prompt_value "Preset" "$default_preset")
resolve_radio_preset "$choice" || fail "Unknown radio preset choice: $choice"
}
write_repeater_config() {
local node_name="$1"
local admin_password="$2"
local jwt_secret="$3"
local freq_mhz="$4"
local sf="$5"
local bw_khz="$6"
local coding_rate="$7"
local tx_power="$8"
local board_key="$9"
python3 - "$CONFIG_DIR/config.yaml" "$RADIO_SETTINGS_JSON" "$BUILDROOT_RADIO_SETTINGS_JSON" "$node_name" "$admin_password" "$jwt_secret" "$freq_mhz" "$sf" "$bw_khz" "$coding_rate" "$tx_power" "$board_key" <<'PY'
import json
import sys
import yaml
(
config_path,
radio_settings_path,
buildroot_settings_path,
node_name,
admin_password,
jwt_secret,
freq_mhz,
sf,
bw_khz,
coding_rate,
tx_power,
board_key,
) = sys.argv[1:13]
with open(config_path, "r", encoding="utf-8") as fh:
data = yaml.safe_load(fh) or {}
with open(radio_settings_path, "r", encoding="utf-8") as fh:
radio_settings = json.load(fh)
with open(buildroot_settings_path, "r", encoding="utf-8") as fh:
buildroot_settings = json.load(fh)
board = (buildroot_settings.get("buildroot_hardware") or {}).get(board_key)
if not board:
raise SystemExit(f"Unknown Buildroot board: {board_key}")
hardware = ((radio_settings.get("hardware") or {}).get(board.get("hardware_id")) or {}).copy()
sx1262 = hardware.copy()
sx1262.update(board.get("sx1262_overrides") or {})
sx1262.setdefault("bus_id", 0)
sx1262.setdefault("cs_id", 0)
sx1262.setdefault("txled_pin", -1)
sx1262.setdefault("rxled_pin", -1)
sx1262.setdefault("is_waveshare", False)
repeater = data.setdefault("repeater", {})
security = repeater.setdefault("security", {})
radio = data.setdefault("radio", {})
repeater["node_name"] = node_name
security["admin_password"] = admin_password
security["jwt_secret"] = jwt_secret
radio["frequency"] = int(round(float(freq_mhz) * 1_000_000))
radio["spreading_factor"] = int(sf)
radio["bandwidth"] = int(round(float(bw_khz) * 1000))
radio["coding_rate"] = int(coding_rate)
radio["tx_power"] = int(tx_power)
data["radio_type"] = hardware.get("radio_type", "sx1262")
data["sx1262"] = sx1262
if data["radio_type"] == "sx1262_ch341":
ch341 = data.setdefault("ch341", {})
if "vid" in hardware:
ch341["vid"] = hardware["vid"]
if "pid" in hardware:
ch341["pid"] = hardware["pid"]
with open(config_path, "w", encoding="utf-8") as fh:
yaml.safe_dump(data, fh, sort_keys=False)
PY
}
seed_repeater_config() {
local node_name admin_password jwt_secret board_key board_name preset_title freq_mhz sf bw_khz coding_rate tx_power
stage "Configuring repeater"
node_name="${PYMC_NODE_NAME:-}"
if [ -n "$node_name" ]; then
validate_node_name "$node_name" || fail "Invalid repeater name in PYMC_NODE_NAME."
else
while :; do
node_name=$(prompt_value "Repeater name" "$(get_config_value repeater.node_name luckfox-repeater)")
if validate_node_name "$node_name"; then
break
fi
done
fi
board_key=$(select_buildroot_board)
board_name=$(get_buildroot_board_label "$board_key")
info "Selected board: $board_name"
admin_password="${PYMC_ADMIN_PASSWORD:-}"
if [ -n "$admin_password" ]; then
validate_admin_password "$admin_password" || fail "Invalid admin password in PYMC_ADMIN_PASSWORD."
else
admin_password=$(prompt_secret "Admin password")
fi
jwt_secret="${PYMC_JWT_SECRET:-}"
[ -n "$jwt_secret" ] || jwt_secret=$(python3 -c 'import secrets; print(secrets.token_hex(32))')
preset_title=$(select_radio_preset)
info "Using preset: $preset_title"
freq_mhz="${PYMC_RADIO_FREQUENCY_MHZ:-$(get_radio_preset_field "$preset_title" frequency)}"
sf="${PYMC_RADIO_SF:-$(get_radio_preset_field "$preset_title" spreading_factor)}"
bw_khz="${PYMC_RADIO_BANDWIDTH_KHZ:-$(get_radio_preset_field "$preset_title" bandwidth)}"
coding_rate="${PYMC_RADIO_CODING_RATE:-$(get_radio_preset_field "$preset_title" coding_rate)}"
tx_power="${PYMC_RADIO_TX_POWER_DBM:-$(get_buildroot_board_field "$board_key" tx_power)}"
[ -n "$tx_power" ] || tx_power="$(get_config_value radio.tx_power 22)"
write_repeater_config "$node_name" "$admin_password" "$jwt_secret" "$freq_mhz" "$sf" "$bw_khz" "$coding_rate" "$tx_power" "$board_key"
info "Saved config for ${node_name}"
}
configure_repeater() {
ensure_root
[ -f "$CONFIG_DIR/config.yaml" ] || fail "Config file is missing. Run install first."
seed_repeater_config
if service_exists; then
"$INIT_SCRIPT" restart
fi
}
configure_radio_profile() {
local board_key
ensure_root
[ -f "$CONFIG_DIR/config.yaml" ] || fail "Config file is missing. Run install first."
board_key=$(select_buildroot_board)
write_repeater_config \
"$(get_config_value repeater.node_name luckfox-repeater)" \
"$(get_config_value repeater.security.admin_password admin123)" \
"$(get_config_value repeater.security.jwt_secret "$(python3 -c 'import secrets; print(secrets.token_hex(32))')")" \
"$(get_radio_frequency_mhz)" \
"$(get_config_value radio.spreading_factor 7)" \
"$(get_radio_bandwidth_khz)" \
"$(get_config_value radio.coding_rate 5)" \
"${PYMC_RADIO_TX_POWER_DBM:-$(get_buildroot_board_field "$board_key" tx_power)}" \
"$board_key"
info "Applied board profile: $(get_buildroot_board_label "$board_key")"
if service_exists; then
"$INIT_SCRIPT" restart
fi
}
service_exists() {
[ -x "$INIT_SCRIPT" ]
}
is_installed() {
[ -d "$INSTALL_DIR" ] && service_exists
}
is_running() {
[ -f "$PIDFILE" ] && kill -0 "$(cat "$PIDFILE")" 2>/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
stage "Cleaning legacy install state"
cleanup_legacy_install_state
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
stage "Cleaning legacy install state"
cleanup_legacy_install_state
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"
}
delegate_to_stock_manage() {
exec bash "$SCRIPT_DIR/manage.sh" "$@"
}
usage() {
cat <<'EOF'
Usage: bash buildroot-manage.sh <command>
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
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
;;
""|help|-h|--help)
usage
;;
*)
fail "Unknown command: ${1}"
;;
esac