Files
pymc_console-dist/manage.sh
GitHub Actions Bot c833f38d2d Sync build v0.7.6
Automated sync from private repository.
Commit: dfac664b03825429657dc638d9f0861b6b8a1fa7
2025-12-30 01:33:42 +00:00

2927 lines
113 KiB
Bash
Executable File

#!/bin/bash
# pyMC Console Management Script
# Install, Upgrade, Configure, and Manage pymc_console stack
#
# INSTALLATION FLOW (mirrors upstream pyMC_Repeater):
# 1. User clones pymc_console to their preferred location (e.g., ~/pymc_console)
# 2. User runs: sudo ./manage.sh install
# 3. This script clones pyMC_Repeater as a sibling directory (e.g., ~/pyMC_Repeater)
# 4. Applies patches to the clone, then copies files to /opt/pymc_repeater
# 5. Installs Python packages from the clone directory
# 6. Overlays our React dashboard to the installation
#
# This matches upstream's flow where manage.sh runs from within a cloned repo
# and copies files to /opt. This makes it easier to:
# - Submit patches as PRs to upstream
# - Stay compatible with upstream updates
# - Allow users to switch between console and vanilla pyMC_Repeater
# ============================================================================
# Bootstrap Self-Healing (runs BEFORE anything else)
# ============================================================================
# Fixes chicken-and-egg: if git history diverged (e.g., after force-push),
# the old manage.sh can't pull the new manage.sh. This check runs early
# and resyncs if needed, then re-execs to run the updated script.
_bootstrap_self_heal() {
local script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Only heal if we're in a git repo and running as root (upgrade/install context)
[ ! -d "$script_dir/.git" ] && return 0
[ "$EUID" -ne 0 ] && return 0
# Skip if BOOTSTRAP_DONE is set (prevents infinite loop)
[ -n "$BOOTSTRAP_DONE" ] && return 0
cd "$script_dir" || return 0
git config --global --add safe.directory "$script_dir" 2>/dev/null || true
git fetch origin 2>/dev/null || return 0
local local_hash=$(git rev-parse HEAD 2>/dev/null)
local remote_hash=$(git rev-parse origin/main 2>/dev/null || git rev-parse origin/master 2>/dev/null)
# If already up-to-date, nothing to do
[ -z "$remote_hash" ] && return 0
[ "$local_hash" = "$remote_hash" ] && return 0
# Check if fast-forward is possible
if git merge-base --is-ancestor HEAD "$remote_hash" 2>/dev/null; then
# Fast-forward works, let normal upgrade flow handle it
return 0
fi
# History diverged! Fix it now.
echo -e "\033[1;33m⚠ Detected diverged git history - auto-healing...\033[0m"
if git reset --hard "origin/main" 2>/dev/null || git reset --hard "origin/master" 2>/dev/null; then
echo -e "\033[0;32m✓ Repository synced - restarting with updated script...\033[0m"
echo ""
export BOOTSTRAP_DONE=1
exec "$script_dir/manage.sh" "$@"
else
echo -e "\033[0;31m✗ Auto-heal failed. Manual fix: cd $script_dir && git fetch && git reset --hard origin/main\033[0m"
fi
}
# Run bootstrap check, passing through all args for re-exec
_bootstrap_self_heal "$@"
set -e
# ============================================================================
# Path Configuration
# ============================================================================
# Script location (where pymc_console was cloned)
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# pyMC_Repeater clone location (sibling to pymc_console)
# e.g., if SCRIPT_DIR is ~/dev/pymc_console, CLONE_DIR is ~/dev/pyMC_Repeater
CLONE_DIR="$(dirname "$SCRIPT_DIR")/pyMC_Repeater"
# Installation paths (where files are deployed - matches upstream)
# INSTALL_DIR: Where pyMC_Repeater is installed (matches upstream standard)
# CONSOLE_DIR: Where pymc_console stores its files (radio presets, dashboard, etc.)
# UI_DIR: Where our React dashboard is installed (separate from upstream Vue.js)
INSTALL_DIR="/opt/pymc_repeater"
CONSOLE_DIR="/opt/pymc_console"
UI_DIR="/opt/pymc_console/web/html"
CONFIG_DIR="/etc/pymc_repeater"
LOG_DIR="/var/log/pymc_repeater"
SERVICE_USER="repeater"
# Legacy alias for compatibility
REPEATER_DIR="$INSTALL_DIR"
# Service name (backend serves both API and static frontend)
BACKEND_SERVICE="pymc-repeater"
# Default branch for installations
DEFAULT_BRANCH="dev"
# Colors for terminal output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
WHITE='\033[97m' # Bright white for glow effect
BOLD='\033[1m'
DIM='\033[2m'
NC='\033[0m' # No Color
# Status indicators
CHECK="${GREEN}${NC}"
CROSS="${RED}${NC}"
ARROW="${CYAN}${NC}"
SPINNER_CHARS='⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'
# ============================================================================
# Progress Display Functions
# ============================================================================
# Print a step header
print_step() {
local step_num="$1"
local total_steps="$2"
local description="$3"
echo ""
echo -e "${BOLD}${CYAN}[$step_num/$total_steps]${NC} ${BOLD}$description${NC}"
}
# Print success message
print_success() {
echo -e " ${CHECK} $1"
}
# Print error message
print_error() {
echo -e " ${CROSS} ${RED}$1${NC}"
}
# Print info message
print_info() {
echo -e " ${ARROW} $1"
}
# Print warning message
print_warning() {
echo -e " ${YELLOW}${NC} $1"
}
# Run a command with spinner and capture output
run_with_spinner() {
local description="$1"
shift
local cmd="$@"
local log_file=$(mktemp)
local pid
local i=0
# Start command in background
eval "$cmd" > "$log_file" 2>&1 &
pid=$!
# Show spinner while command runs
printf " ${DIM}%s${NC} " "$description"
while kill -0 $pid 2>/dev/null; do
printf "\r ${CYAN}%s${NC} %s" "${SPINNER_CHARS:i++%${#SPINNER_CHARS}:1}" "$description"
sleep 0.1
done
# Get exit status
wait $pid
local exit_code=$?
# Clear spinner line and show result
printf "\r " # Clear the line
if [ $exit_code -eq 0 ]; then
echo -e "${CHECK} $description"
rm -f "$log_file"
return 0
else
echo -e "${CROSS} ${RED}$description${NC}"
echo -e " ${DIM}Log output:${NC}"
tail -20 "$log_file" | sed 's/^/ /'
rm -f "$log_file"
return 1
fi
}
# Run a command and show immediate output (for long operations)
run_with_output() {
local description="$1"
shift
local cmd="$@"
echo -e " ${ARROW} $description"
echo -e " ${DIM}─────────────────────────────────────────${NC}"
# Run command with indented output
if eval "$cmd" 2>&1 | sed 's/^/ /'; then
echo -e " ${DIM}─────────────────────────────────────────${NC}"
print_success "$description completed"
return 0
else
echo -e " ${DIM}─────────────────────────────────────────${NC}"
print_error "$description failed"
return 1
fi
}
# Show a progress bar (updates in place)
# Usage: show_progress_bar current total [description]
show_progress_bar() {
local current=$1
local total=$2
local description="${3:-}"
local width=30
local percent=$((current * 100 / total))
local filled=$((current * width / total))
local empty=$((width - filled))
# Build the bar
local bar=""
for ((i=0; i<filled; i++)); do bar+="█"; done
for ((i=0; i<empty; i++)); do bar+="░"; done
# Print with carriage return to update in place
printf "\r ${CYAN}[${bar}]${NC} ${percent}%% ${DIM}${description}${NC} "
}
# Run a long command with elapsed time display
run_with_elapsed_time() {
local description="$1"
shift
local cmd="$@"
local log_file=$(mktemp)
local pid
local start_time=$(date +%s)
# Start command in background
eval "$cmd" > "$log_file" 2>&1 &
pid=$!
# Show elapsed time while command runs
printf " ${ARROW} %s " "$description"
while kill -0 $pid 2>/dev/null; do
local elapsed=$(($(date +%s) - start_time))
local mins=$((elapsed / 60))
local secs=$((elapsed % 60))
printf "\r ${CYAN}${NC} %s ${DIM}(%dm %02ds)${NC} " "$description" $mins $secs
sleep 1
done
# Get exit status
wait $pid
local exit_code=$?
local elapsed=$(($(date +%s) - start_time))
local mins=$((elapsed / 60))
local secs=$((elapsed % 60))
# Clear line and show result
printf "\r " # Clear
if [ $exit_code -eq 0 ]; then
echo -e "${CHECK} $description ${DIM}(${mins}m ${secs}s)${NC}"
rm -f "$log_file"
return 0
else
echo -e "${CROSS} ${RED}$description${NC} ${DIM}(${mins}m ${secs}s)${NC}"
echo -e " ${DIM}Log output:${NC}"
tail -20 "$log_file" | sed 's/^/ /'
rm -f "$log_file"
return 1
fi
}
# Run pip install with real progress bar
# Parses pip output to show download/install progress
run_pip_with_progress() {
local description="$1"
shift
local cmd="$@"
local log_file=$(mktemp)
local progress_file=$(mktemp)
local pid
local start_time=$(date +%s)
local width=30
# Start command in background, capturing output for parsing
eval "$cmd" 2>&1 | tee "$log_file" | while IFS= read -r line; do
# Look for pip progress indicators
if [[ "$line" =~ Downloading\ .*\ \(([0-9.]+)\ ([kMG]?B)\) ]]; then
echo "Downloading..." > "$progress_file"
elif [[ "$line" =~ Installing\ collected\ packages ]]; then
echo "Installing..." > "$progress_file"
elif [[ "$line" =~ Successfully\ installed ]]; then
echo "Done" > "$progress_file"
fi
done &
pid=$!
# Show progress while command runs
local phase="Starting"
printf " ${ARROW} %s " "$description"
while kill -0 $pid 2>/dev/null; do
local elapsed=$(($(date +%s) - start_time))
local mins=$((elapsed / 60))
local secs=$((elapsed % 60))
# Read current phase if available
[ -f "$progress_file" ] && phase=$(cat "$progress_file" 2>/dev/null || echo "$phase")
# Build animated bar
local anim_pos=$(( (elapsed * 2) % width ))
local bar=""
for ((i=0; i<width; i++)); do
if [ $i -eq $anim_pos ] || [ $i -eq $((anim_pos + 1)) ]; then
bar+="█"
else
bar+="░"
fi
done
printf "\r ${CYAN}[${bar}]${NC} %s ${DIM}(%dm %02ds)${NC} " "$phase" $mins $secs
sleep 0.5
done
# Get exit status
wait $pid
local exit_code=$?
local elapsed=$(($(date +%s) - start_time))
local mins=$((elapsed / 60))
local secs=$((elapsed % 60))
# Cleanup
rm -f "$progress_file"
# Clear line and show result
printf "\r%-80s\r" " " # Clear the line
if [ $exit_code -eq 0 ]; then
echo -e " ${CHECK} $description ${DIM}(${mins}m ${secs}s)${NC}"
rm -f "$log_file"
return 0
else
echo -e " ${CROSS} ${RED}$description${NC} ${DIM}(${mins}m ${secs}s)${NC}"
echo -e " ${DIM}Log output:${NC}"
tail -20 "$log_file" | sed 's/^/ /'
rm -f "$log_file"
return 1
fi
}
# Attempt cubic-in-out easing using bash integer math (approximation)
# Returns position 0-100 given input 0-100
cubic_ease_inout() {
local t=$1 # 0-100
if [ $t -lt 50 ]; then
# Ease in: 4 * t^3 (scaled)
echo $(( (4 * t * t * t) / 10000 ))
else
# Ease out: 1 - (-2t + 2)^3 / 2
local p=$((100 - t))
echo $(( 100 - (4 * p * p * p) / 10000 ))
fi
}
# Calculate velocity (derivative) of cubic ease-in-out at point t
# Returns 0-100 where 100 is max velocity (at t=50, the inflection point)
cubic_ease_velocity() {
local t=$1 # 0-100
# Derivative of cubic ease-in-out: 6t(1-t) scaled to 0-100
# Max velocity occurs at t=50 (middle of the curve)
# At t=0 or t=100, velocity is 0 (stationary at endpoints)
local velocity=$(( (6 * t * (100 - t)) / 100 ))
# Normalize to 0-100 range (max is 150 at t=50, so scale by 2/3)
echo $(( (velocity * 100) / 150 ))
}
# Run npm with animated progress bar
run_npm_with_progress() {
local description="$1"
shift
local cmd="$@"
local log_file=$(mktemp)
local pid
local start_time=$(date +%s)
local width=40
local cycle_frames=40 # frames per half-cycle (faster, smoother animation)
local cursor_width=2 # narrower cursor for higher fidelity
# Start command in background
eval "$cmd" > "$log_file" 2>&1 &
pid=$!
# Show animated progress bar while command runs
printf " ${ARROW} %s " "$description"
local frame=0
while kill -0 $pid 2>/dev/null; do
local elapsed=$(($(date +%s) - start_time))
local mins=$((elapsed / 60))
local secs=$((elapsed % 60))
# Calculate position in cycle (0 to cycle_frames*2)
local cycle_pos=$(( frame % (cycle_frames * 2) ))
local going_right=1
[ $cycle_pos -ge $cycle_frames ] && going_right=0
# Get linear position within half-cycle (0-100)
local linear_t
if [ $going_right -eq 1 ]; then
linear_t=$(( (cycle_pos * 100) / cycle_frames ))
else
linear_t=$(( ((cycle_frames * 2 - cycle_pos) * 100) / cycle_frames ))
fi
# Apply cubic easing
local eased_t=$(cubic_ease_inout $linear_t)
# Calculate velocity for motion blur and glow effects
local velocity=$(cubic_ease_velocity $linear_t)
# Convert to bar position
local anim_pos=$(( (eased_t * (width - cursor_width)) / 100 ))
# Determine trail intensity based on velocity (motion blur when fast)
# velocity 0-60: no trail, 60-85: near trail, 85+: full trail
local show_near_trail=0
[ $velocity -gt 60 ] && show_near_trail=1
local show_far_trail=0
[ $velocity -gt 85 ] && show_far_trail=1
# Glow on cursor at apex velocity (tight window: 90-100%)
local cursor_glow=0
[ $velocity -gt 90 ] && cursor_glow=1
# Build bar with velocity-based effects
local bar=""
for ((j=0; j<width; j++)); do
local dist_from_cursor
if [ $j -lt $anim_pos ]; then
dist_from_cursor=$((anim_pos - j))
elif [ $j -ge $((anim_pos + cursor_width)) ]; then
dist_from_cursor=$((j - anim_pos - cursor_width + 1))
else
dist_from_cursor=0
fi
# Build character with motion blur effect
if [ $dist_from_cursor -eq 0 ]; then
# Solid cursor - glow at apex velocity (just turns white)
if [ $cursor_glow -eq 1 ]; then
bar+="${WHITE}${CYAN}" # White cursor at peak velocity
else
bar+="█" # Normal cyan cursor
fi
elif [ $dist_from_cursor -eq 1 ] && [ $show_near_trail -eq 1 ]; then
bar+="▓" # Near motion blur - appears when moving
elif [ $dist_from_cursor -eq 2 ] && [ $show_far_trail -eq 1 ]; then
bar+="▒" # Far motion blur - only at high speed
else
bar+="░" # Empty background
fi
done
printf "\r ${CYAN}[${bar}]${NC} %s ${DIM}(%dm %02ds)${NC} " "$description" $mins $secs
sleep 0.033 # ~30fps for smoother animation
((frame++)) || true
done
# Get exit status
wait $pid
local exit_code=$?
local elapsed=$(($(date +%s) - start_time))
local mins=$((elapsed / 60))
local secs=$((elapsed % 60))
# Clear line and show result
printf "\r%-80s\r" " " # Clear the line
if [ $exit_code -eq 0 ]; then
echo -e " ${CHECK} $description ${DIM}(${mins}m ${secs}s)${NC}"
rm -f "$log_file"
return 0
else
echo -e " ${CROSS} ${RED}$description${NC} ${DIM}(${mins}m ${secs}s)${NC}"
echo -e " ${DIM}Log output:${NC}"
tail -30 "$log_file" | sed 's/^/ /'
rm -f "$log_file"
return 1
fi
}
# Run git clone with real-time progress display
# Shows actual git progress (objects, files) as they're received
run_git_clone_with_progress() {
local branch="$1"
local repo_url="$2"
local target_dir="$3"
local start_time=$(date +%s)
echo -e " ${ARROW} Cloning from ${CYAN}github.com/rightup/pyMC_Repeater${NC}"
echo -e " ${DIM}────────────────────────────────────────${NC}"
# Run git clone with progress, parse and display key lines
git clone -b "$branch" --progress "$repo_url" "$target_dir" 2>&1 | while IFS= read -r line; do
# Parse git progress output
if [[ "$line" =~ ^Cloning ]]; then
printf "\r ${DIM}%-50s${NC}" "Initializing..."
elif [[ "$line" =~ ^remote:\ Enumerating ]]; then
printf "\r ${DIM}%-50s${NC}" "Enumerating objects..."
elif [[ "$line" =~ ^remote:\ Counting ]]; then
printf "\r ${DIM}%-50s${NC}" "Counting objects..."
elif [[ "$line" =~ ^remote:\ Compressing ]]; then
# Extract percentage if present
if [[ "$line" =~ ([0-9]+)% ]]; then
printf "\r ${CYAN}Compressing:${NC} ${BASH_REMATCH[1]}%%%-30s" " "
fi
elif [[ "$line" =~ ^Receiving\ objects ]]; then
# Extract percentage
if [[ "$line" =~ ([0-9]+)% ]]; then
printf "\r ${CYAN}Receiving:${NC} ${BASH_REMATCH[1]}%%%-30s" " "
fi
elif [[ "$line" =~ ^Resolving\ deltas ]]; then
# Extract percentage
if [[ "$line" =~ ([0-9]+)% ]]; then
printf "\r ${CYAN}Resolving:${NC} ${BASH_REMATCH[1]}%%%-30s" " "
fi
elif [[ "$line" =~ ^Updating\ files ]]; then
# Extract percentage
if [[ "$line" =~ ([0-9]+)% ]]; then
printf "\r ${CYAN}Extracting:${NC} ${BASH_REMATCH[1]}%%%-30s" " "
fi
fi
done
local exit_code=${PIPESTATUS[0]}
local elapsed=$(($(date +%s) - start_time))
# Clear progress line
printf "\r%-60s\r" " "
echo -e " ${DIM}────────────────────────────────────────${NC}"
if [ $exit_code -eq 0 ]; then
print_success "Repository cloned ${DIM}(${elapsed}s)${NC}"
return 0
else
print_error "Clone failed"
return 1
fi
}
# Print installation banner
print_banner() {
clear
echo ""
echo -e "${BOLD}${CYAN}pyMC Console Installer${NC}"
echo -e "${DIM}React Dashboard + LoRa Mesh Network Repeater${NC}"
echo ""
}
# Print completion summary
print_completion() {
local ip_address="$1"
echo ""
echo -e "${GREEN}${BOLD}Installation Complete!${NC} ${CHECK}"
echo ""
# Version and branch summary
echo -e "${BOLD}Installed Versions:${NC}"
local core_ver=$(get_core_version)
local repeater_ver=$(get_repeater_version)
local console_ver=$(get_console_version)
local repeater_branch=$(get_repeater_branch)
local core_branch=$(get_core_branch_from_toml "$CLONE_DIR")
echo -e " ${DIM}pyMC Core:${NC} ${CYAN}v${core_ver}${NC} ${DIM}@${core_branch}${NC}"
echo -e " ${DIM}pyMC Repeater:${NC} ${CYAN}v${repeater_ver}${NC} ${DIM}@${repeater_branch}${NC}"
echo -e " ${DIM}pyMC Console:${NC} ${CYAN}${console_ver}${NC}"
echo ""
# Disk usage report
echo -e "${BOLD}Disk Usage:${NC}"
local install_size=$(du -sh "$REPEATER_DIR" 2>/dev/null | cut -f1 || echo "N/A")
local config_size=$(du -sh "$CONFIG_DIR" 2>/dev/null | cut -f1 || echo "N/A")
echo -e " ${DIM}Installation:${NC} $install_size"
echo -e " ${DIM}Configuration:${NC} $config_size"
echo ""
echo -e "${BOLD}Access your dashboard:${NC}"
echo -e " ${ARROW} Dashboard: ${CYAN}http://$ip_address:8000/${NC}"
echo -e " ${DIM}(API endpoints also available at /api/*)${NC}"
echo ""
}
# Cleanup function for error handling
cleanup_on_error() {
echo ""
print_error "Installation failed!"
echo ""
echo -e " ${YELLOW}Partial installation may remain. To clean up:${NC}"
echo -e " ${DIM}sudo ./manage.sh uninstall${NC}"
echo ""
echo -e " ${YELLOW}Check the error messages above for details.${NC}"
echo -e " ${YELLOW}Common issues:${NC}"
echo -e " ${DIM}- Network connectivity problems${NC}"
echo -e " ${DIM}- Missing system dependencies${NC}"
echo -e " ${DIM}- Insufficient disk space${NC}"
echo -e " ${DIM}- Permission issues${NC}"
echo ""
}
# ============================================================================
# TUI Setup
# ============================================================================
# Check if running in interactive terminal
check_terminal() {
if [ ! -t 0 ] || [ -z "$TERM" ]; then
echo "Error: This script requires an interactive terminal."
echo "Please run from SSH or a local terminal."
exit 1
fi
}
# Setup dialog/whiptail
setup_dialog() {
if command -v whiptail &> /dev/null; then
DIALOG="whiptail"
elif command -v dialog &> /dev/null; then
DIALOG="dialog"
else
echo "TUI interface requires whiptail or dialog."
if [ "$EUID" -eq 0 ]; then
echo "Installing whiptail..."
apt-get update -qq && apt-get install -y whiptail
DIALOG="whiptail"
else
echo ""
echo "Please install whiptail: sudo apt-get install -y whiptail"
exit 1
fi
fi
}
# ============================================================================
# Dialog Helper Functions
# ============================================================================
show_info() {
$DIALOG --backtitle "pyMC Console Management" --title "$1" --msgbox "$2" 14 70
}
show_error() {
$DIALOG --backtitle "pyMC Console Management" --title "Error" --msgbox "$1" 10 60
}
ask_yes_no() {
$DIALOG --backtitle "pyMC Console Management" --title "$1" --yesno "$2" 12 70
}
get_input() {
local title="$1"
local prompt="$2"
local default="$3"
$DIALOG --backtitle "pyMC Console Management" --title "$title" --inputbox "$prompt" 10 70 "$default" 3>&1 1>&2 2>&3
}
# ============================================================================
# Status Check Functions
# ============================================================================
is_installed() {
[ -d "$REPEATER_DIR" ] && [ -f "$REPEATER_DIR/pyproject.toml" ]
}
backend_running() {
systemctl is-active "$BACKEND_SERVICE" >/dev/null 2>&1
}
# Get pymc_core branch/ref from a pyproject.toml file
# Usage: get_core_branch_from_toml [path_to_dir_with_toml]
# Returns: branch name (e.g., "feat/anon-req", "main") or "unknown"
get_core_branch_from_toml() {
local dir="${1:-$CLONE_DIR}"
local toml_file="$dir/pyproject.toml"
if [ ! -f "$toml_file" ]; then
echo "unknown"
return
fi
# Extract pymc_core git reference from pyproject.toml
# Format: "pymc_core[hardware] @ git+https://github.com/rightup/pyMC_core.git@feat/anon-req"
local branch
branch=$(grep -i 'pymc_core.*@.*git+' "$toml_file" 2>/dev/null | sed -n 's/.*\.git@\([^"]*\).*/\1/p' | head -1)
if [ -n "$branch" ]; then
echo "$branch"
else
echo "unknown"
fi
}
# Get pyMC_Repeater branch from clone directory
get_repeater_branch() {
if [ -d "$CLONE_DIR/.git" ]; then
cd "$CLONE_DIR" 2>/dev/null && git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "unknown"
else
echo "unknown"
fi
}
# Get pyMC Repeater version from installed pyproject.toml
get_version() {
if [ -f "$REPEATER_DIR/pyproject.toml" ]; then
grep "^version" "$REPEATER_DIR/pyproject.toml" | cut -d'"' -f2 2>/dev/null || echo "unknown"
else
echo "not installed"
fi
}
# Get pyMC Repeater version (alias for clarity)
get_repeater_version() {
get_version
}
# Get pymc_core version from installed pip package
get_core_version() {
# Try to get version from pip
local version
version=$(pip3 show pymc_core 2>/dev/null | grep "^Version:" | awk '{print $2}')
if [ -n "$version" ]; then
echo "$version"
else
echo "unknown"
fi
}
# Get pyMC Console (UI) version from installed VERSION file or GitHub API
get_console_version() {
local ui_dir="$UI_DIR"
if [ -d "$ui_dir" ]; then
# First, try to read VERSION file (created during build)
if [ -f "$ui_dir/VERSION" ]; then
local ver=$(cat "$ui_dir/VERSION" 2>/dev/null | tr -d '[:space:]')
if [ -n "$ver" ]; then
echo "v$ver"
return 0
fi
fi
# Fallback: check GitHub API for latest release
local latest_tag
latest_tag=$(curl -s --max-time 3 "https://api.github.com/repos/${UI_REPO}/releases/latest" 2>/dev/null | grep -oP '"tag_name":\s*"\K[^"]+' | head -1)
if [ -n "$latest_tag" ]; then
echo "$latest_tag"
else
echo "installed"
fi
else
echo "not installed"
fi
}
get_status_display() {
if ! is_installed; then
echo "Not Installed"
else
local version=$(get_version)
local status="Stopped"
backend_running && status="Running"
echo "v$version | Service: $status"
fi
}
# ============================================================================
# Install Function
# ============================================================================
do_install() {
# Check if already installed
if is_installed; then
show_error "pyMC Console is already installed!\n\npyMC_Repeater: $INSTALL_DIR\n\nUse 'upgrade' to update or 'uninstall' first."
return 1
fi
# Check root
if [ "$EUID" -ne 0 ]; then
show_error "Installation requires root privileges.\n\nPlease run: sudo $0 install"
return 1
fi
# Branch selection
local branch="${1:-}"
if [ -z "$branch" ]; then
branch=$($DIALOG --backtitle "pyMC Console Management" --title "Select Branch" --menu "\nSelect the branch to install from:" 14 60 4 \
"dev" "Development branch (recommended)" \
"main" "Stable release" \
"custom" "Enter custom branch name" 3>&1 1>&2 2>&3)
if [ -z "$branch" ]; then
return 0 # User cancelled
fi
if [ "$branch" = "custom" ]; then
branch=$(get_input "Custom Branch" "Enter the branch name:" "dev")
if [ -z "$branch" ]; then
return 0
fi
fi
fi
# Welcome screen
$DIALOG --backtitle "pyMC Console Management" --title "Welcome" --msgbox "\nWelcome to pyMC Console Setup\n\nThis will install:\n- pyMC Repeater (LoRa mesh repeater)\n- pyMC Console (React dashboard)\n\nBranch: $branch\nClone: $CLONE_DIR\nInstall: $INSTALL_DIR\n\nPress OK to continue..." 18 70
# SPI Check (Raspberry Pi)
check_spi
# Set up error handling
trap cleanup_on_error ERR
# Print banner
print_banner
echo -e " ${DIM}Branch: $branch${NC}"
echo -e " ${DIM}Clone: $CLONE_DIR${NC}"
echo -e " ${DIM}Install: $INSTALL_DIR${NC}"
local total_steps=6
# =========================================================================
# Step 1: Install prerequisites (whiptail needed by upstream)
# =========================================================================
print_step 1 $total_steps "Installing prerequisites"
run_with_spinner "Updating package lists" "apt-get update -qq" || {
print_error "Failed to update package lists"
return 1
}
# Install whiptail (needed by upstream) and git
run_with_spinner "Installing required packages" "apt-get install -y whiptail git curl" || {
print_error "Failed to install prerequisites"
return 1
}
# Install yq (we use it for config manipulation)
if ! command -v yq &> /dev/null || [[ "$(yq --version 2>&1)" != *"mikefarah/yq"* ]]; then
run_with_spinner "Installing yq" "install_yq_silent" || print_warning "yq installation failed (non-critical)"
else
print_success "yq already installed"
fi
# =========================================================================
# Step 2: Clone pyMC_Repeater
# =========================================================================
print_step 2 $total_steps "Cloning pyMC_Repeater@$branch"
# Remove existing clone if present (fresh install)
if [ -d "$CLONE_DIR" ]; then
print_info "Removing existing clone at $CLONE_DIR"
rm -rf "$CLONE_DIR"
fi
# Mark directories as safe for git (running as root on user-owned dir)
git config --global --add safe.directory "$CLONE_DIR" 2>/dev/null || true
git config --global --add safe.directory "$INSTALL_DIR" 2>/dev/null || true
run_git_clone_with_progress "$branch" "https://github.com/rightup/pyMC_Repeater.git" "$CLONE_DIR" || {
print_error "Failed to clone pyMC_Repeater"
print_info "Check if branch '$branch' exists"
return 1
}
# Show verified git info so user can confirm what was cloned
cd "$CLONE_DIR"
local git_branch=$(git rev-parse --abbrev-ref HEAD)
local git_commit=$(git rev-parse --short HEAD)
local git_date=$(git log -1 --format=%cd --date=short)
local git_msg=$(git log -1 --format=%s | cut -c1-50)
echo -e " ${BOLD}Source Verification${NC}"
echo -e " Branch: ${CYAN}${git_branch}${NC}"
echo -e " Commit: ${CYAN}${git_commit}${NC} ${DIM}(${git_date})${NC}"
echo -e " Message: ${DIM}${git_msg}...${NC}"
echo ""
# =========================================================================
# Step 3: Run upstream installer (via UPSTREAM INSTALLATION MANAGER)
# =========================================================================
print_step 3 $total_steps "Running pyMC_Repeater installer"
# This runs upstream's manage.sh install with our fake dialog to bypass TUI
# Upstream handles: user creation, directories, deps, pip install, service, config
run_upstream_installer "install" "$branch" || {
print_error "Upstream installation failed"
return 1
}
# =========================================================================
# Step 4: Apply patches to installed files
# =========================================================================
print_step 4 $total_steps "Applying pyMC Console patches"
# Apply patches to /opt/pymc_repeater (the installed location, not the clone)
print_info "Patching installed files..."
# PATCH 1 & 5 removed - merged upstream in PR #36 (feat/identity branch)
patch_logging_section "$INSTALL_DIR" # PATCH 2: Ensure logging section exists
patch_log_level_api "$INSTALL_DIR" # PATCH 3: Log level toggle API
patch_mesh_cli "$INSTALL_DIR" # PATCH 4: MeshCore CLI parity
patch_private_key_api "$INSTALL_DIR" # PATCH 6: Private key get/set API
# =========================================================================
# Step 5: Install dashboard and console extras
# =========================================================================
print_step 5 $total_steps "Installing pyMC Console dashboard"
# Create console directory for our extras
mkdir -p "$CONSOLE_DIR"
# Copy radio settings files to console dir
if [ -f "$CLONE_DIR/radio-settings.json" ]; then
cp "$CLONE_DIR/radio-settings.json" "$CONSOLE_DIR/"
print_success "Copied radio-settings.json"
fi
if [ -f "$CLONE_DIR/radio-presets.json" ]; then
cp "$CLONE_DIR/radio-presets.json" "$CONSOLE_DIR/"
print_success "Copied radio-presets.json"
fi
# Install our React dashboard (overlays upstream's Vue.js frontend)
install_static_frontend || {
print_error "Frontend installation failed"
return 1
}
# Fix permissions for console directory
chown -R "$SERVICE_USER:$SERVICE_USER" "$CONSOLE_DIR" 2>/dev/null || true
# =========================================================================
# Step 6: Finalize installation
# =========================================================================
print_step 6 $total_steps "Finalizing installation"
# Stop service for now - we'll start it after user configures radio
# Upstream may have started it, so stop to avoid running with default config
systemctl stop "$BACKEND_SERVICE" 2>/dev/null || true
print_success "Installation files ready"
print_info "Service will start after radio configuration"
# Clear error trap
trap - ERR
# =========================================================================
# Radio Configuration (terminal-based)
# =========================================================================
echo ""
echo -e "${BOLD}${CYAN}Radio Configuration${NC}"
echo -e "${DIM}Configure your radio settings for your region and hardware${NC}"
echo ""
configure_radio_terminal
# NOW start the service with user's configuration
print_info "Starting service with your configuration..."
systemctl daemon-reload
systemctl start "$BACKEND_SERVICE" 2>/dev/null || true
sleep 2
if backend_running; then
print_success "Backend service running"
else
print_warning "Service may need GPIO configuration - use './manage.sh gpio'"
fi
# Show completion
local ip_address=$(hostname -I | awk '{print $1}')
print_completion "$ip_address"
echo -e "${BOLD}Manage your installation:${NC}"
echo -e " ${DIM}./manage.sh settings${NC} - Configure radio"
echo -e " ${DIM}./manage.sh gpio${NC} - Configure GPIO pins"
echo -e " ${DIM}./manage.sh${NC} - Full management menu"
echo ""
}
# ============================================================================
# Upgrade Function
# ============================================================================
do_upgrade() {
if ! is_installed; then
show_error "pyMC Console is not installed!\n\nUse 'install' first."
return 1
fi
if [ "$EUID" -ne 0 ]; then
show_error "Upgrade requires root privileges.\n\nPlease run: sudo $0 upgrade"
return 1
fi
# Self-update: pull latest pymc_console repo first, then re-exec if updated
local script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
if [ -d "$script_dir/.git" ]; then
echo ""
print_info "Checking for pymc_console updates..."
git config --global --add safe.directory "$script_dir" 2>/dev/null || true
cd "$script_dir"
# Check if there are updates available
git fetch origin 2>/dev/null || true
local local_hash=$(git rev-parse HEAD 2>/dev/null)
local remote_hash=$(git rev-parse origin/main 2>/dev/null || git rev-parse origin/master 2>/dev/null)
if [ -n "$remote_hash" ] && [ "$local_hash" != "$remote_hash" ]; then
print_info "Updates available, pulling..."
# Try fast-forward first, fall back to reset if history diverged (e.g., after force-push)
if git pull --ff-only 2>/dev/null; then
print_success "pymc_console updated - restarting with new version..."
echo ""
exec "$script_dir/manage.sh" upgrade
elif git reset --hard "origin/main" 2>/dev/null || git reset --hard "origin/master" 2>/dev/null; then
print_success "pymc_console synced (history was rewritten) - restarting..."
echo ""
exec "$script_dir/manage.sh" upgrade
else
print_warning "Could not auto-update pymc_console (continuing with current version)"
print_info "You may need to manually run: cd $script_dir && git fetch && git reset --hard origin/main"
fi
else
print_success "pymc_console is up to date"
fi
echo ""
fi
# Capture current versions BEFORE upgrade
local current_repeater_ver=$(get_repeater_version)
local current_core_ver=$(get_core_version)
local current_console_ver=$(get_console_version)
# Get current branch from clone directory or default to dev
local current_branch="dev"
if [ -d "$CLONE_DIR/.git" ]; then
cd "$CLONE_DIR" 2>/dev/null || true
current_branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "dev")
fi
# Show current versions and upgrade type selection
local upgrade_type
upgrade_type=$($DIALOG --backtitle "pyMC Console Management" --title "Upgrade Options" --menu "
Current Installed Versions:
─────────────────────────────────────
pyMC Core: v${current_core_ver}
pyMC Repeater: v${current_repeater_ver}
pyMC Console: ${current_console_ver}
─────────────────────────────────────
Select upgrade type:" 19 65 2 \
"<Console>" "Console Only" \
"<Package>" "Full pyMC Stack" 3>&1 1>&2 2>&3)
if [ -z "$upgrade_type" ]; then
return 0
fi
local branch="$current_branch"
local skip_backend=false
if [ "$upgrade_type" = "<Console>" ]; then
# Console-only upgrade
skip_backend=true
if ! ask_yes_no "Confirm Console Upgrade" "
This will ONLY update the pyMC Console dashboard.
pyMC Core and pyMC Repeater will NOT be modified.
Current Console: ${current_console_ver}
New Console: Latest from GitHub
Continue?"; then
return 0
fi
else
# Full upgrade - select branch
branch=$($DIALOG --backtitle "pyMC Console Management" --title "Select Branch" --menu "
Full upgrade will update:
• pyMC Core (mesh library)
• pyMC Repeater (backend)
• pyMC Console (dashboard)
Current branch: $current_branch
Select the branch for pyMC Repeater:" 18 60 4 \
"dev" "Development branch (recommended)" \
"main" "Stable release" \
"keep" "Keep current branch ($current_branch)" \
"custom" "Enter custom branch name" 3>&1 1>&2 2>&3)
if [ -z "$branch" ]; then
return 0 # User cancelled
fi
if [ "$branch" = "keep" ]; then
branch="$current_branch"
elif [ "$branch" = "custom" ]; then
branch=$(get_input "Custom Branch" "Enter the branch name:" "$current_branch")
if [ -z "$branch" ]; then
return 0
fi
fi
if ! ask_yes_no "Confirm Full Upgrade" "
This will update ALL components:
pyMC Core: v${current_core_ver} → (via pip)
pyMC Repeater: v${current_repeater_ver}$branch branch
pyMC Console: ${current_console_ver} → Latest
Your configuration will be preserved.
Continue?"; then
return 0
fi
fi
# Print banner
print_banner
if [ "$skip_backend" = true ]; then
echo -e " ${DIM}Upgrade type: Console Only${NC}"
else
echo -e " ${DIM}Upgrade type: Full (Core + Repeater + Console)${NC}"
echo -e " ${DIM}Target branch: $branch${NC}"
fi
echo ""
echo -e " ${BOLD}Current Versions:${NC}"
echo -e " ${DIM}pyMC Core:${NC} v${current_core_ver}"
echo -e " ${DIM}pyMC Repeater:${NC} v${current_repeater_ver}"
echo -e " ${DIM}pyMC Console:${NC} ${current_console_ver}"
local total_steps
if [ "$skip_backend" = true ]; then
total_steps=3
else
total_steps=5
fi
local step_num=0
# =========================================================================
# Step 1: Backup configuration (both paths)
# =========================================================================
((step_num++)) || true
print_step $step_num $total_steps "Backing up configuration"
local backup_file="$CONFIG_DIR/config.yaml.backup.$(date +%Y%m%d_%H%M%S)"
if [ -f "$CONFIG_DIR/config.yaml" ]; then
cp "$CONFIG_DIR/config.yaml" "$backup_file"
print_success "Backup saved to: $backup_file"
else
print_info "No existing config to backup"
fi
# =========================================================================
# CONSOLE-ONLY PATH: Skip backend, just update dashboard
# =========================================================================
if [ "$skip_backend" = true ]; then
# Step 2: Update dashboard only
((step_num++)) || true
print_step $step_num $total_steps "Updating pyMC Console dashboard"
install_static_frontend || {
print_error "Dashboard update failed"
return 1
}
# Step 3: Restart service
((step_num++)) || true
print_step $step_num $total_steps "Restarting service"
systemctl restart "$BACKEND_SERVICE" 2>/dev/null || true
sleep 2
if backend_running; then
print_success "Service running"
else
print_warning "Service may need configuration"
fi
# Show completion (console only)
local new_console_ver=$(get_console_version)
local ip_address=$(hostname -I 2>/dev/null | awk '{print $1}' || echo "localhost")
echo ""
echo -e "${BOLD}${GREEN}════════════════════════════════════════════════════════════${NC}"
echo -e "${BOLD}${GREEN} Console Upgrade Complete!${NC}"
echo -e "${BOLD}${GREEN}════════════════════════════════════════════════════════════${NC}"
echo ""
# Get branch info
local repeater_branch=$(get_repeater_branch)
local core_branch=$(get_core_branch_from_toml "$CLONE_DIR")
echo -e " ${BOLD}Versions:${NC}"
echo -e " ${DIM}pyMC Core:${NC} v${current_core_ver} ${DIM}(unchanged)${NC} ${DIM}@${core_branch}${NC}"
echo -e " ${DIM}pyMC Repeater:${NC} v${current_repeater_ver} ${DIM}(unchanged)${NC} ${DIM}@${repeater_branch}${NC}"
echo -e " ${CHECK} pyMC Console: ${DIM}${current_console_ver}${NC}${CYAN}${new_console_ver}${NC}"
echo ""
echo -e " ${CHECK} Configuration preserved"
echo -e " ${CHECK} Dashboard: ${CYAN}http://$ip_address:8000${NC}"
echo ""
return 0
fi
# =========================================================================
# FULL UPGRADE PATH: Update Repeater, Core, and Console
# =========================================================================
# Step 2: Update pyMC_Repeater clone
((step_num++)) || true
print_step $step_num $total_steps "Updating pyMC_Repeater@$branch"
# Mark directories as safe for git (running as root on user-owned dir)
git config --global --add safe.directory "$CLONE_DIR" 2>/dev/null || true
git config --global --add safe.directory "$INSTALL_DIR" 2>/dev/null || true
# If clone doesn't exist, clone fresh
if [ ! -d "$CLONE_DIR/.git" ]; then
print_info "Clone not found, creating fresh clone..."
rm -rf "$CLONE_DIR" 2>/dev/null || true
run_git_clone_with_progress "$branch" "https://github.com/rightup/pyMC_Repeater.git" "$CLONE_DIR" || {
print_error "Failed to clone pyMC_Repeater"
return 1
}
else
cd "$CLONE_DIR"
run_with_spinner "Fetching updates" "git fetch origin" || {
print_error "Failed to fetch updates"
return 1
}
# Reset any local changes (from previous patches)
git reset --hard HEAD 2>/dev/null || true
git clean -fd 2>/dev/null || true
git checkout "$branch" 2>/dev/null || git checkout -b "$branch" "origin/$branch" 2>/dev/null
run_with_spinner "Pulling latest changes" "git pull origin $branch" || {
print_error "Failed to pull branch $branch"
return 1
}
print_success "Repository updated"
fi
# Show verified git info so user can confirm what was pulled
cd "$CLONE_DIR"
local git_branch=$(git rev-parse --abbrev-ref HEAD)
local git_commit=$(git rev-parse --short HEAD)
local git_date=$(git log -1 --format=%cd --date=short)
local git_msg=$(git log -1 --format=%s | cut -c1-50)
echo -e " ${BOLD}Source Verification${NC}"
echo -e " Branch: ${CYAN}${git_branch}${NC}"
echo -e " Commit: ${CYAN}${git_commit}${NC} ${DIM}(${git_date})${NC}"
echo -e " Message: ${DIM}${git_msg}...${NC}"
echo ""
# Step 3: Run upstream upgrade (Repeater + Core via pip)
((step_num++)) || true
print_step $step_num $total_steps "Running pyMC_Repeater upgrade (includes pyMC Core)"
# This runs upstream's manage.sh upgrade with our fake dialog to bypass TUI
# Upstream handles: stopping service, updating files, pip install, config merge, starting service
run_upstream_installer "upgrade" "$branch" || {
print_error "Upstream upgrade failed"
return 1
}
# Step 4: Apply patches and update dashboard
((step_num++)) || true
print_step $step_num $total_steps "Applying pyMC Console patches & dashboard"
# Apply patches to /opt/pymc_repeater (the installed location)
print_info "Patching installed files..."
patch_api_endpoints "$INSTALL_DIR" # PATCH 1: Radio config API endpoint
patch_logging_section "$INSTALL_DIR" # PATCH 2: Ensure logging section exists
patch_log_level_api "$INSTALL_DIR" # PATCH 3: Log level toggle API
patch_mesh_cli "$INSTALL_DIR" # PATCH 4: MeshCore CLI parity
patch_stats_api "$INSTALL_DIR" # PATCH 5: Extend stats API with MeshCore config
patch_private_key_api "$INSTALL_DIR" # PATCH 6: Private key get/set API
# Ensure --log-level DEBUG
if [ -f /etc/systemd/system/pymc-repeater.service ]; then
if ! grep -q '\-\-log-level DEBUG' /etc/systemd/system/pymc-repeater.service; then
sed -i 's|--config /etc/pymc_repeater/config.yaml$|--config /etc/pymc_repeater/config.yaml --log-level DEBUG|' \
/etc/systemd/system/pymc-repeater.service
systemctl daemon-reload
print_success "Added --log-level DEBUG for RX timing fix"
fi
fi
# Update dashboard from GitHub Releases
install_static_frontend || {
print_warning "Dashboard update failed - service will continue with existing UI"
}
# Step 5: Restart service with patches
((step_num++)) || true
print_step $step_num $total_steps "Restarting service"
systemctl restart "$BACKEND_SERVICE" 2>/dev/null || true
sleep 2
if backend_running; then
print_success "Service running"
else
print_warning "Service may need configuration"
fi
# Show completion with version details (all components)
local new_repeater_ver=$(get_repeater_version)
local new_core_ver=$(get_core_version)
local new_console_ver=$(get_console_version)
local ip_address=$(hostname -I 2>/dev/null | awk '{print $1}' || echo "localhost")
echo ""
echo -e "${BOLD}${GREEN}════════════════════════════════════════════════════════════${NC}"
echo -e "${BOLD}${GREEN} Full Upgrade Complete!${NC}"
echo -e "${BOLD}${GREEN}════════════════════════════════════════════════════════════${NC}"
echo ""
# Get branch info from the updated clone
local core_branch=$(get_core_branch_from_toml "$CLONE_DIR")
echo -e " ${BOLD}Versions:${NC}"
echo -e " ${CHECK} pyMC Core: ${DIM}v${current_core_ver}${NC}${CYAN}v${new_core_ver}${NC} ${DIM}@${core_branch}${NC}"
echo -e " ${CHECK} pyMC Repeater: ${DIM}v${current_repeater_ver}${NC}${CYAN}v${new_repeater_ver}${NC} ${DIM}@${branch}${NC}"
echo -e " ${CHECK} pyMC Console: ${DIM}${current_console_ver}${NC}${CYAN}${new_console_ver}${NC}"
echo ""
echo -e " ${CHECK} Configuration preserved"
echo -e " ${CHECK} Dashboard: ${CYAN}http://$ip_address:8000${NC}"
echo ""
}
# ============================================================================
# Terminal-based Radio Configuration (for install flow)
# ============================================================================
configure_radio_terminal() {
local config_file="$CONFIG_DIR/config.yaml"
if [ ! -f "$config_file" ]; then
print_warning "Config file not found, skipping radio configuration"
return 0
fi
# Node name
local current_name=$(yq '.repeater.node_name' "$config_file" 2>/dev/null || echo "mesh-repeater")
local random_suffix=$(printf "%04d" $((RANDOM % 10000)))
local default_name="pyRpt${random_suffix}"
if [ "$current_name" = "mesh-repeater-01" ] || [ "$current_name" = "mesh-repeater" ]; then
current_name="$default_name"
fi
echo -e " ${BOLD}Node Name${NC}"
read -p " Enter repeater name [$current_name]: " node_name
node_name=${node_name:-$current_name}
yq -i ".repeater.node_name = \"$node_name\"" "$config_file"
print_success "Node name: $node_name"
echo ""
# Radio preset selection
echo -e " ${BOLD}Radio Preset${NC}"
echo -e " ${DIM}Select a preset or choose custom to enter manual values${NC}"
echo ""
# Fetch presets from API or local files
local presets_json=""
presets_json=$(curl -s --max-time 5 https://api.meshcore.nz/api/v1/config 2>/dev/null)
if [ -z "$presets_json" ]; then
if [ -f "$CONSOLE_DIR/radio-presets.json" ]; then
presets_json=$(cat "$CONSOLE_DIR/radio-presets.json")
elif [ -f "$REPEATER_DIR/radio-presets.json" ]; then
presets_json=$(cat "$REPEATER_DIR/radio-presets.json")
fi
fi
local preset_count=0
local preset_titles=()
local preset_freqs=()
local preset_sfs=()
local preset_bws=()
local preset_crs=()
if [ -n "$presets_json" ]; then
while IFS= read -r line; do
local title=$(echo "$line" | jq -r '.title')
local freq=$(echo "$line" | jq -r '.frequency')
local sf=$(echo "$line" | jq -r '.spreading_factor')
local bw=$(echo "$line" | jq -r '.bandwidth')
local cr=$(echo "$line" | jq -r '.coding_rate')
if [ -n "$title" ] && [ "$title" != "null" ]; then
((preset_count++)) || true
preset_titles+=("$title")
preset_freqs+=("$freq")
preset_sfs+=("$sf")
preset_bws+=("$bw")
preset_crs+=("$cr")
echo -e " ${CYAN}$preset_count)${NC} $title ${DIM}(${freq}MHz SF$sf BW${bw}kHz)${NC}"
fi
done < <(echo "$presets_json" | jq -c '.[]' 2>/dev/null)
fi
# If no presets loaded, show fallback options with descriptions
if [ $preset_count -eq 0 ]; then
echo -e " ${YELLOW}Could not fetch presets from API. Showing common options:${NC}"
echo ""
# Fallback presets - matches upstream api.meshcore.nz/api/v1/config + WestCoastMesh
preset_titles=("USA/Canada (Recommended)" "Australia" "EU/UK (Long Range)" "EU/UK (Narrow)" "New Zealand" "New Zealand (Narrow)" "WestCoastMesh US")
preset_freqs=("910.525" "915.800" "869.525" "869.618" "917.375" "917.375" "927.875")
preset_sfs=("7" "10" "11" "8" "11" "7" "7")
preset_bws=("62.5" "250" "250" "62.5" "250" "62.5" "62.5")
preset_crs=("5" "5" "5" "8" "5" "5" "5")
preset_count=${#preset_titles[@]}
echo -e " ${CYAN}1)${NC} USA/Canada ${DIM}(910.525MHz SF7 BW62.5kHz CR5 - Recommended)${NC}"
echo -e " ${CYAN}2)${NC} Australia ${DIM}(915.800MHz SF10 BW250kHz CR5)${NC}"
echo -e " ${CYAN}3)${NC} EU/UK Long Range ${DIM}(869.525MHz SF11 BW250kHz CR5)${NC}"
echo -e " ${CYAN}4)${NC} EU/UK Narrow ${DIM}(869.618MHz SF8 BW62.5kHz CR8)${NC}"
echo -e " ${CYAN}5)${NC} New Zealand ${DIM}(917.375MHz SF11 BW250kHz CR5)${NC}"
echo -e " ${CYAN}6)${NC} New Zealand Narrow ${DIM}(917.375MHz SF7 BW62.5kHz CR5)${NC}"
echo -e " ${CYAN}7)${NC} WestCoastMesh US ${DIM}(927.875MHz SF7 BW62.5kHz CR5 - SoCal optimized)${NC}"
fi
echo -e " ${CYAN}C)${NC} Custom ${DIM}(enter values manually)${NC}"
echo ""
read -p " Select preset [1-$preset_count] or C for custom: " preset_choice
local freq_mhz bw_khz sf cr
if [[ "$preset_choice" =~ ^[Cc]$ ]]; then
# Custom values
echo ""
echo -e " ${BOLD}Custom Radio Settings${NC}"
local current_freq=$(yq '.radio.frequency' "$config_file" 2>/dev/null || echo "869618000")
local current_freq_mhz=$(awk "BEGIN {printf \"%.3f\", $current_freq / 1000000}")
read -p " Frequency in MHz [$current_freq_mhz]: " freq_mhz
freq_mhz=${freq_mhz:-$current_freq_mhz}
local current_sf=$(yq '.radio.spreading_factor' "$config_file" 2>/dev/null || echo "8")
read -p " Spreading Factor (7-12) [$current_sf]: " sf
sf=${sf:-$current_sf}
local current_bw=$(yq '.radio.bandwidth' "$config_file" 2>/dev/null || echo "62500")
local current_bw_khz=$(awk "BEGIN {printf \"%.1f\", $current_bw / 1000}")
read -p " Bandwidth in kHz [$current_bw_khz]: " bw_khz
bw_khz=${bw_khz:-$current_bw_khz}
local current_cr=$(yq '.radio.coding_rate' "$config_file" 2>/dev/null || echo "8")
read -p " Coding Rate (5-8) [$current_cr]: " cr
cr=${cr:-$current_cr}
# Apply custom settings
local freq_hz=$(awk "BEGIN {printf \"%.0f\", $freq_mhz * 1000000}")
local bw_hz=$(awk "BEGIN {printf \"%.0f\", $bw_khz * 1000}")
yq -i ".radio.frequency = $freq_hz" "$config_file"
yq -i ".radio.spreading_factor = $sf" "$config_file"
yq -i ".radio.bandwidth = $bw_hz" "$config_file"
yq -i ".radio.coding_rate = $cr" "$config_file"
echo ""
print_success "Radio: ${freq_mhz}MHz SF$sf BW${bw_khz}kHz CR$cr"
elif [[ "$preset_choice" =~ ^[0-9]+$ ]] && [ "$preset_choice" -ge 1 ] && [ "$preset_choice" -le "$preset_count" ]; then
# Use preset
local idx=$((preset_choice - 1))
freq_mhz="${preset_freqs[$idx]}"
sf="${preset_sfs[$idx]}"
bw_khz="${preset_bws[$idx]}"
cr="${preset_crs[$idx]}"
print_success "Using preset: ${preset_titles[$idx]}"
# Apply settings
local freq_hz=$(awk "BEGIN {printf \"%.0f\", $freq_mhz * 1000000}")
local bw_hz=$(awk "BEGIN {printf \"%.0f\", $bw_khz * 1000}")
yq -i ".radio.frequency = $freq_hz" "$config_file"
yq -i ".radio.spreading_factor = $sf" "$config_file"
yq -i ".radio.bandwidth = $bw_hz" "$config_file"
yq -i ".radio.coding_rate = $cr" "$config_file"
echo ""
print_success "Radio: ${freq_mhz}MHz SF$sf BW${bw_khz}kHz CR$cr"
else
print_warning "Invalid selection, keeping current radio settings"
fi
# Hardware selection (before TX power so user can override hardware default)
echo ""
echo -e " ${BOLD}Hardware Selection${NC}"
echo -e " ${DIM}Select your LoRa hardware for GPIO configuration${NC}"
echo ""
configure_hardware_terminal "$config_file"
# TX Power (after hardware selection so user's choice takes precedence)
echo -e " ${BOLD}TX Power${NC}"
local current_power=$(yq '.radio.tx_power' "$config_file" 2>/dev/null || echo "22")
read -p " TX Power in dBm [$current_power]: " tx_power
tx_power=${tx_power:-$current_power}
yq -i ".radio.tx_power = $tx_power" "$config_file"
print_success "TX Power: ${tx_power}dBm"
echo ""
}
# Terminal-based hardware/GPIO configuration
configure_hardware_terminal() {
local config_file="${1:-$CONFIG_DIR/config.yaml}"
local hw_config=""
# Find hardware presets file
if [ -f "$CONSOLE_DIR/radio-settings.json" ]; then
hw_config="$CONSOLE_DIR/radio-settings.json"
elif [ -f "$REPEATER_DIR/radio-settings.json" ]; then
hw_config="$REPEATER_DIR/radio-settings.json"
fi
if [ -z "$hw_config" ] || [ ! -f "$hw_config" ]; then
print_warning "Hardware presets not found, skipping GPIO configuration"
print_info "Configure GPIO manually with: ./manage.sh gpio"
return 0
fi
# Build hardware options
local hw_count=0
local hw_keys=()
local hw_names=()
while IFS= read -r key; do
local name=$(jq -r ".hardware.\"$key\".name" "$hw_config" 2>/dev/null)
if [ -n "$name" ] && [ "$name" != "null" ]; then
((hw_count++)) || true
hw_keys+=("$key")
hw_names+=("$name")
echo -e " ${CYAN}$hw_count)${NC} $name"
fi
done < <(jq -r '.hardware | keys[]' "$hw_config" 2>/dev/null)
echo -e " ${CYAN}C)${NC} Custom GPIO ${DIM}(enter pins manually)${NC}"
echo ""
read -p " Select hardware [1-$hw_count] or C for custom: " hw_choice
if [[ "$hw_choice" =~ ^[Cc]$ ]]; then
# Custom GPIO
echo ""
echo -e " ${BOLD}Custom GPIO Configuration${NC} ${YELLOW}(BCM pin numbering)${NC}"
local current_cs=$(yq '.sx1262.cs_pin' "$config_file" 2>/dev/null || echo "21")
read -p " Chip Select pin [$current_cs]: " cs_pin
cs_pin=${cs_pin:-$current_cs}
local current_reset=$(yq '.sx1262.reset_pin' "$config_file" 2>/dev/null || echo "18")
read -p " Reset pin [$current_reset]: " reset_pin
reset_pin=${reset_pin:-$current_reset}
local current_busy=$(yq '.sx1262.busy_pin' "$config_file" 2>/dev/null || echo "20")
read -p " Busy pin [$current_busy]: " busy_pin
busy_pin=${busy_pin:-$current_busy}
local current_irq=$(yq '.sx1262.irq_pin' "$config_file" 2>/dev/null || echo "16")
read -p " IRQ pin [$current_irq]: " irq_pin
irq_pin=${irq_pin:-$current_irq}
local current_txen=$(yq '.sx1262.txen_pin' "$config_file" 2>/dev/null || echo "-1")
read -p " TX Enable pin (-1 to disable) [$current_txen]: " txen_pin
txen_pin=${txen_pin:-$current_txen}
local current_rxen=$(yq '.sx1262.rxen_pin' "$config_file" 2>/dev/null || echo "-1")
read -p " RX Enable pin (-1 to disable) [$current_rxen]: " rxen_pin
rxen_pin=${rxen_pin:-$current_rxen}
# Apply custom GPIO
yq -i ".sx1262.cs_pin = $cs_pin" "$config_file"
yq -i ".sx1262.reset_pin = $reset_pin" "$config_file"
yq -i ".sx1262.busy_pin = $busy_pin" "$config_file"
yq -i ".sx1262.irq_pin = $irq_pin" "$config_file"
yq -i ".sx1262.txen_pin = $txen_pin" "$config_file"
yq -i ".sx1262.rxen_pin = $rxen_pin" "$config_file"
echo ""
print_success "Custom GPIO: CS=$cs_pin RST=$reset_pin BUSY=$busy_pin IRQ=$irq_pin"
elif [[ "$hw_choice" =~ ^[0-9]+$ ]] && [ "$hw_choice" -ge 1 ] && [ "$hw_choice" -le "$hw_count" ]; then
# Use preset
local idx=$((hw_choice - 1))
local hw_key="${hw_keys[$idx]}"
local hw_name="${hw_names[$idx]}"
local preset=$(jq ".hardware.\"$hw_key\"" "$hw_config" 2>/dev/null)
if [ -n "$preset" ] && [ "$preset" != "null" ]; then
# Extract all GPIO settings
local bus_id=$(echo "$preset" | jq -r '.bus_id // 0')
local cs_id=$(echo "$preset" | jq -r '.cs_id // 0')
local cs_pin=$(echo "$preset" | jq -r '.cs_pin // 21')
local reset_pin=$(echo "$preset" | jq -r '.reset_pin // 18')
local busy_pin=$(echo "$preset" | jq -r '.busy_pin // 20')
local irq_pin=$(echo "$preset" | jq -r '.irq_pin // 16')
local txen_pin=$(echo "$preset" | jq -r '.txen_pin // -1')
local rxen_pin=$(echo "$preset" | jq -r '.rxen_pin // -1')
local is_waveshare=$(echo "$preset" | jq -r '.is_waveshare // false')
local use_dio3_tcxo=$(echo "$preset" | jq -r '.use_dio3_tcxo // false')
local tx_power=$(echo "$preset" | jq -r '.tx_power // 22')
local preamble_length=$(echo "$preset" | jq -r '.preamble_length // 17')
# Apply to config
yq -i ".sx1262.bus_id = $bus_id" "$config_file"
yq -i ".sx1262.cs_id = $cs_id" "$config_file"
yq -i ".sx1262.cs_pin = $cs_pin" "$config_file"
yq -i ".sx1262.reset_pin = $reset_pin" "$config_file"
yq -i ".sx1262.busy_pin = $busy_pin" "$config_file"
yq -i ".sx1262.irq_pin = $irq_pin" "$config_file"
yq -i ".sx1262.txen_pin = $txen_pin" "$config_file"
yq -i ".sx1262.rxen_pin = $rxen_pin" "$config_file"
yq -i ".sx1262.is_waveshare = $is_waveshare" "$config_file"
yq -i ".sx1262.use_dio3_tcxo = $use_dio3_tcxo" "$config_file"
# Note: tx_power is set as default but user can override in next step
yq -i ".radio.tx_power = $tx_power" "$config_file"
yq -i ".radio.preamble_length = $preamble_length" "$config_file"
echo ""
print_success "Hardware: $hw_name"
print_success "GPIO: CS=$cs_pin RST=$reset_pin BUSY=$busy_pin IRQ=$irq_pin"
if [ "$txen_pin" != "-1" ]; then
print_info "TX/RX Enable: TXEN=$txen_pin RXEN=$rxen_pin"
fi
print_info "Default TX Power: ${tx_power}dBm (you can change this next)"
fi
else
print_warning "Invalid selection, keeping current GPIO settings"
print_info "Configure GPIO later with: ./manage.sh gpio"
fi
echo ""
}
# ============================================================================
# Settings Function (Radio Configuration) - TUI version for manage.sh menu
# ============================================================================
do_settings() {
if [ ! -f "$CONFIG_DIR/config.yaml" ]; then
show_error "Configuration file not found!\n\nPlease install pyMC Console first."
return 1
fi
while true; do
local current_name=$(yq '.repeater.node_name' "$CONFIG_DIR/config.yaml" 2>/dev/null || echo "unknown")
local current_freq=$(yq '.radio.frequency' "$CONFIG_DIR/config.yaml" 2>/dev/null || echo "0")
local current_freq_mhz=$(awk "BEGIN {printf \"%.3f\", $current_freq / 1000000}")
local current_sf=$(yq '.radio.spreading_factor' "$CONFIG_DIR/config.yaml" 2>/dev/null || echo "0")
local current_bw=$(yq '.radio.bandwidth' "$CONFIG_DIR/config.yaml" 2>/dev/null || echo "0")
local current_bw_khz=$(awk "BEGIN {printf \"%.1f\", $current_bw / 1000}")
local current_power=$(yq '.radio.tx_power' "$CONFIG_DIR/config.yaml" 2>/dev/null || echo "0")
CHOICE=$($DIALOG --backtitle "pyMC Console Management" --title "Radio Settings" --menu "\nCurrent Configuration:\n Name: $current_name\n Freq: ${current_freq_mhz}MHz | SF$current_sf | BW${current_bw_khz}kHz | ${current_power}dBm\n\nSelect setting to change:" 20 70 8 \
"name" "Node name ($current_name)" \
"preset" "Load radio preset (frequency, SF, BW, CR)" \
"frequency" "Frequency (${current_freq_mhz}MHz)" \
"power" "TX Power (${current_power}dBm)" \
"spreading" "Spreading Factor (SF$current_sf)" \
"bandwidth" "Bandwidth (${current_bw_khz}kHz)" \
"apply" "Apply changes and restart" \
"back" "Back to main menu" 3>&1 1>&2 2>&3)
case $CHOICE in
"name")
local new_name=$(get_input "Node Name" "Enter repeater node name:" "$current_name")
if [ -n "$new_name" ]; then
yq -i ".repeater.node_name = \"$new_name\"" "$CONFIG_DIR/config.yaml"
show_info "Updated" "Node name set to: $new_name"
fi
;;
"preset")
select_radio_preset
;;
"frequency")
local new_freq=$(get_input "Frequency" "Enter frequency in MHz (e.g., 869.618):" "$current_freq_mhz")
if [ -n "$new_freq" ]; then
local freq_hz=$(awk "BEGIN {printf \"%.0f\", $new_freq * 1000000}")
yq -i ".radio.frequency = $freq_hz" "$CONFIG_DIR/config.yaml"
show_info "Updated" "Frequency set to: ${new_freq}MHz"
fi
;;
"power")
local new_power=$(get_input "TX Power" "Enter TX power in dBm (e.g., 14):" "$current_power")
if [ -n "$new_power" ]; then
yq -i ".radio.tx_power = $new_power" "$CONFIG_DIR/config.yaml"
show_info "Updated" "TX Power set to: ${new_power}dBm"
fi
;;
"spreading")
local new_sf=$(get_input "Spreading Factor" "Enter spreading factor (7-12):" "$current_sf")
if [ -n "$new_sf" ]; then
yq -i ".radio.spreading_factor = $new_sf" "$CONFIG_DIR/config.yaml"
show_info "Updated" "Spreading factor set to: SF$new_sf"
fi
;;
"bandwidth")
local new_bw=$(get_input "Bandwidth" "Enter bandwidth in kHz (e.g., 62.5):" "$current_bw_khz")
if [ -n "$new_bw" ]; then
local bw_hz=$(awk "BEGIN {printf \"%.0f\", $new_bw * 1000}")
yq -i ".radio.bandwidth = $bw_hz" "$CONFIG_DIR/config.yaml"
show_info "Updated" "Bandwidth set to: ${new_bw}kHz"
fi
;;
"apply")
if [ "$EUID" -eq 0 ]; then
systemctl restart "$BACKEND_SERVICE" 2>/dev/null || true
sleep 2
if backend_running; then
show_info "Applied" "Configuration applied and service restarted successfully!"
else
show_error "Service failed to restart!\n\nCheck logs: journalctl -u $BACKEND_SERVICE"
fi
else
show_info "Note" "Run as root to restart services automatically.\n\nManually restart with:\nsudo systemctl restart $BACKEND_SERVICE"
fi
;;
"back"|"")
return 0
;;
esac
done
}
select_radio_preset() {
# Fetch presets from API or use local file
local presets_json=""
echo "Fetching radio presets..." >&2
presets_json=$(curl -s --max-time 5 https://api.meshcore.nz/api/v1/config 2>/dev/null)
if [ -z "$presets_json" ]; then
if [ -f "$CONSOLE_DIR/radio-presets.json" ]; then
presets_json=$(cat "$CONSOLE_DIR/radio-presets.json")
elif [ -f "$REPEATER_DIR/radio-presets.json" ]; then
presets_json=$(cat "$REPEATER_DIR/radio-presets.json")
else
show_error "Could not fetch radio presets from API and no local file found."
return 1
fi
fi
# Build menu from presets
local menu_items=()
local index=1
while IFS= read -r line; do
local title=$(echo "$line" | jq -r '.title')
local freq=$(echo "$line" | jq -r '.frequency')
local sf=$(echo "$line" | jq -r '.spreading_factor')
local bw=$(echo "$line" | jq -r '.bandwidth')
menu_items+=("$index" "$title (${freq}MHz SF$sf BW$bw)")
((index++)) || true
done < <(echo "$presets_json" | jq -c '.[]' 2>/dev/null)
if [ ${#menu_items[@]} -eq 0 ]; then
show_error "No presets found in configuration."
return 1
fi
local selection=$($DIALOG --backtitle "pyMC Console Management" --title "Radio Presets" --menu "Select a radio preset:" 20 70 10 "${menu_items[@]}" 3>&1 1>&2 2>&3)
if [ -n "$selection" ]; then
local preset=$(echo "$presets_json" | jq -c ".[$((selection-1))]" 2>/dev/null)
if [ -n "$preset" ] && [ "$preset" != "null" ]; then
local freq=$(echo "$preset" | jq -r '.frequency')
local sf=$(echo "$preset" | jq -r '.spreading_factor')
local bw=$(echo "$preset" | jq -r '.bandwidth')
local cr=$(echo "$preset" | jq -r '.coding_rate')
local title=$(echo "$preset" | jq -r '.title')
local freq_hz=$(awk "BEGIN {printf \"%.0f\", $freq * 1000000}")
local bw_hz=$(awk "BEGIN {printf \"%.0f\", $bw * 1000}")
yq -i ".radio.frequency = $freq_hz" "$CONFIG_DIR/config.yaml"
yq -i ".radio.spreading_factor = $sf" "$CONFIG_DIR/config.yaml"
yq -i ".radio.bandwidth = $bw_hz" "$CONFIG_DIR/config.yaml"
yq -i ".radio.coding_rate = $cr" "$CONFIG_DIR/config.yaml"
show_info "Preset Applied" "Applied preset: $title\n\nFrequency: ${freq}MHz\nSpreading Factor: SF$sf\nBandwidth: ${bw}kHz\nCoding Rate: $cr\n\nRemember to apply changes to restart the service."
fi
fi
}
# ============================================================================
# GPIO Function (Advanced Hardware Configuration)
# ============================================================================
do_gpio() {
# Show warning first
if ! ask_yes_no "⚠️ Advanced Configuration" "\nWARNING: GPIO Configuration\n\nThese settings are for ADVANCED USERS ONLY.\n\nIncorrect GPIO settings can:\n- Prevent radio communication\n- Cause hardware damage\n- Make the repeater non-functional\n\nOnly proceed if you know your hardware pinout!\n\nContinue?"; then
return 0
fi
if [ ! -f "$CONFIG_DIR/config.yaml" ]; then
show_error "Configuration file not found!\n\nPlease install pyMC Console first."
return 1
fi
while true; do
# Read current GPIO settings
local cs_pin=$(yq '.sx1262.cs_pin' "$CONFIG_DIR/config.yaml" 2>/dev/null || echo "-1")
local reset_pin=$(yq '.sx1262.reset_pin' "$CONFIG_DIR/config.yaml" 2>/dev/null || echo "-1")
local busy_pin=$(yq '.sx1262.busy_pin' "$CONFIG_DIR/config.yaml" 2>/dev/null || echo "-1")
local irq_pin=$(yq '.sx1262.irq_pin' "$CONFIG_DIR/config.yaml" 2>/dev/null || echo "-1")
local txen_pin=$(yq '.sx1262.txen_pin' "$CONFIG_DIR/config.yaml" 2>/dev/null || echo "-1")
local rxen_pin=$(yq '.sx1262.rxen_pin' "$CONFIG_DIR/config.yaml" 2>/dev/null || echo "-1")
CHOICE=$($DIALOG --backtitle "pyMC Console Management" --title "GPIO Configuration ⚠️" --menu "\nCurrent GPIO Pins (BCM numbering):\n CS: $cs_pin | Reset: $reset_pin | Busy: $busy_pin\n IRQ: $irq_pin | TXEN: $txen_pin | RXEN: $rxen_pin\n\nSelect option:" 20 70 8 \
"preset" "Load hardware preset" \
"cs" "Chip Select pin ($cs_pin)" \
"reset" "Reset pin ($reset_pin)" \
"busy" "Busy pin ($busy_pin)" \
"irq" "IRQ pin ($irq_pin)" \
"txen" "TX Enable pin ($txen_pin, -1=disabled)" \
"rxen" "RX Enable pin ($rxen_pin, -1=disabled)" \
"apply" "Apply changes and restart" \
"back" "Back to main menu" 3>&1 1>&2 2>&3)
case $CHOICE in
"preset")
select_hardware_preset
;;
"cs")
local new_pin=$(get_input "Chip Select Pin" "Enter CS pin (BCM numbering):" "$cs_pin")
[ -n "$new_pin" ] && yq -i ".sx1262.cs_pin = $new_pin" "$CONFIG_DIR/config.yaml"
;;
"reset")
local new_pin=$(get_input "Reset Pin" "Enter Reset pin (BCM numbering):" "$reset_pin")
[ -n "$new_pin" ] && yq -i ".sx1262.reset_pin = $new_pin" "$CONFIG_DIR/config.yaml"
;;
"busy")
local new_pin=$(get_input "Busy Pin" "Enter Busy pin (BCM numbering):" "$busy_pin")
[ -n "$new_pin" ] && yq -i ".sx1262.busy_pin = $new_pin" "$CONFIG_DIR/config.yaml"
;;
"irq")
local new_pin=$(get_input "IRQ Pin" "Enter IRQ pin (BCM numbering):" "$irq_pin")
[ -n "$new_pin" ] && yq -i ".sx1262.irq_pin = $new_pin" "$CONFIG_DIR/config.yaml"
;;
"txen")
local new_pin=$(get_input "TX Enable Pin" "Enter TXEN pin (-1 to disable):" "$txen_pin")
[ -n "$new_pin" ] && yq -i ".sx1262.txen_pin = $new_pin" "$CONFIG_DIR/config.yaml"
;;
"rxen")
local new_pin=$(get_input "RX Enable Pin" "Enter RXEN pin (-1 to disable):" "$rxen_pin")
[ -n "$new_pin" ] && yq -i ".sx1262.rxen_pin = $new_pin" "$CONFIG_DIR/config.yaml"
;;
"apply")
if [ "$EUID" -eq 0 ]; then
systemctl restart "$BACKEND_SERVICE" 2>/dev/null || true
sleep 2
if backend_running; then
show_info "Applied" "GPIO configuration applied and service restarted!"
else
show_error "Service failed to restart!\n\nGPIO settings may be incorrect.\nCheck logs: journalctl -u $BACKEND_SERVICE"
fi
else
show_info "Note" "Run as root to restart services automatically."
fi
;;
"back"|"")
return 0
;;
esac
done
}
select_hardware_preset() {
local hw_config=""
if [ -f "$CONSOLE_DIR/radio-settings.json" ]; then
hw_config="$CONSOLE_DIR/radio-settings.json"
elif [ -f "$REPEATER_DIR/radio-settings.json" ]; then
hw_config="$REPEATER_DIR/radio-settings.json"
else
show_error "Hardware configuration file not found!"
return 1
fi
# Build menu from hardware presets
local menu_items=()
# Use keys_unsorted to preserve JSON insertion order (matches upstream grep-based parsing)
while IFS= read -r key; do
local name=$(jq -r ".hardware.\"$key\".name" "$hw_config")
menu_items+=("$key" "$name")
done < <(jq -r '.hardware | keys_unsorted[]' "$hw_config" 2>/dev/null)
if [ ${#menu_items[@]} -eq 0 ]; then
show_error "No hardware presets found."
return 1
fi
local selection=$($DIALOG --backtitle "pyMC Console Management" --title "Hardware Presets" --menu "Select your hardware:" 20 70 10 "${menu_items[@]}" 3>&1 1>&2 2>&3)
if [ -n "$selection" ]; then
local preset=$(jq ".hardware.\"$selection\"" "$hw_config" 2>/dev/null)
if [ -n "$preset" ] && [ "$preset" != "null" ]; then
# Apply all GPIO settings from preset
local bus_id=$(echo "$preset" | jq -r '.bus_id // 0')
local cs_id=$(echo "$preset" | jq -r '.cs_id // 0')
local cs_pin=$(echo "$preset" | jq -r '.cs_pin // 21')
local reset_pin=$(echo "$preset" | jq -r '.reset_pin // 18')
local busy_pin=$(echo "$preset" | jq -r '.busy_pin // 20')
local irq_pin=$(echo "$preset" | jq -r '.irq_pin // 16')
local txen_pin=$(echo "$preset" | jq -r '.txen_pin // -1')
local rxen_pin=$(echo "$preset" | jq -r '.rxen_pin // -1')
local is_waveshare=$(echo "$preset" | jq -r '.is_waveshare // false')
local use_dio3_tcxo=$(echo "$preset" | jq -r '.use_dio3_tcxo // false')
local tx_power=$(echo "$preset" | jq -r '.tx_power // 14')
yq -i ".sx1262.bus_id = $bus_id" "$CONFIG_DIR/config.yaml"
yq -i ".sx1262.cs_id = $cs_id" "$CONFIG_DIR/config.yaml"
yq -i ".sx1262.cs_pin = $cs_pin" "$CONFIG_DIR/config.yaml"
yq -i ".sx1262.reset_pin = $reset_pin" "$CONFIG_DIR/config.yaml"
yq -i ".sx1262.busy_pin = $busy_pin" "$CONFIG_DIR/config.yaml"
yq -i ".sx1262.irq_pin = $irq_pin" "$CONFIG_DIR/config.yaml"
yq -i ".sx1262.txen_pin = $txen_pin" "$CONFIG_DIR/config.yaml"
yq -i ".sx1262.rxen_pin = $rxen_pin" "$CONFIG_DIR/config.yaml"
yq -i ".sx1262.is_waveshare = $is_waveshare" "$CONFIG_DIR/config.yaml"
yq -i ".sx1262.use_dio3_tcxo = $use_dio3_tcxo" "$CONFIG_DIR/config.yaml"
yq -i ".radio.tx_power = $tx_power" "$CONFIG_DIR/config.yaml"
local name=$(echo "$preset" | jq -r '.name')
show_info "Preset Applied" "Applied hardware preset: $name\n\nGPIO Pins:\n CS: $cs_pin | Reset: $reset_pin\n Busy: $busy_pin | IRQ: $irq_pin\n TXEN: $txen_pin | RXEN: $rxen_pin\n\nTX Power: ${tx_power}dBm\n\nRemember to apply changes to restart."
fi
fi
}
# ============================================================================
# Service Control Functions
# ============================================================================
do_start() {
if [ "$EUID" -ne 0 ]; then
show_error "Service control requires root privileges.\n\nPlease run: sudo $0 start"
return 1
fi
echo "Starting service..."
systemctl start "$BACKEND_SERVICE" 2>/dev/null || true
sleep 2
local status="✗"
backend_running && status="✓"
show_info "Service Started" "\npyMC Repeater: $status\n\nDashboard: http://$(hostname -I | awk '{print $1}'):8000/"
}
do_stop() {
if [ "$EUID" -ne 0 ]; then
show_error "Service control requires root privileges.\n\nPlease run: sudo $0 stop"
return 1
fi
echo "Stopping service..."
systemctl stop "$BACKEND_SERVICE" 2>/dev/null || true
show_info "Service Stopped" "\n✓ pyMC Repeater service has been stopped."
}
do_restart() {
if [ "$EUID" -ne 0 ]; then
show_error "Service control requires root privileges.\n\nPlease run: sudo $0 restart"
return 1
fi
echo "Restarting service..."
systemctl restart "$BACKEND_SERVICE" 2>/dev/null || true
sleep 2
local status="✗"
backend_running && status="✓"
show_info "Service Restarted" "\npyMC Repeater: $status\n\nDashboard: http://$(hostname -I | awk '{print $1}'):8000/"
}
# ============================================================================
# Uninstall Function
# ============================================================================
do_uninstall() {
# Get site-packages path for checking leftovers
local site_packages
site_packages=$(python3 -c "import site; print(site.getsitepackages()[0])" 2>/dev/null || echo "/usr/local/lib/python3/dist-packages")
# Check for ANY installation (old paths, new paths, or site-packages leftovers)
local found_install=false
[ -d "$INSTALL_DIR" ] && found_install=true
[ -d "$CONSOLE_DIR" ] && found_install=true
[ -d "/opt/pymc_console/pymc_repeater" ] && found_install=true # Old path
[ -f "/etc/systemd/system/pymc-repeater.service" ] && found_install=true
[ -d "$site_packages/repeater" ] && found_install=true # pip leftovers
[ -d "$site_packages/pymc_core" ] && found_install=true # pip leftovers
if [ "$found_install" = false ]; then
show_error "pyMC Console is not installed."
return 1
fi
if [ "$EUID" -ne 0 ]; then
show_error "Uninstall requires root privileges.\n\nPlease run: sudo $0 uninstall"
return 1
fi
# Check if clone directory exists
local has_clone=false
[ -d "$CLONE_DIR" ] && has_clone=true
local uninstall_msg="\nThis will COMPLETELY REMOVE:\n\n- pyMC Repeater service and files\n- pyMC Console frontend\n- Python packages (pymc_repeater, pymc_core)\n- Configuration files\n- Log files\n- Service user"
if [ "$has_clone" = true ]; then
uninstall_msg="$uninstall_msg\n\nNote: The clone at $CLONE_DIR will be kept.\nYou can remove it manually if desired."
fi
uninstall_msg="$uninstall_msg\n\nThis action cannot be undone!\n\nContinue?"
if ! ask_yes_no "⚠️ Confirm Uninstall" "$uninstall_msg"; then
return 0
fi
clear
echo "=== pyMC Console Uninstall ==="
echo ""
# =========================================================================
# Step 1: Run upstream uninstaller (simple - no fancy progress bar needed)
# =========================================================================
echo "[1/4] Removing pyMC_Repeater..."
# Always do manual cleanup - it's fast and reliable
# (upstream's uninstaller uses TUI which is complex to wrap)
systemctl stop "$BACKEND_SERVICE" 2>/dev/null || true
systemctl disable "$BACKEND_SERVICE" 2>/dev/null || true
rm -f /etc/systemd/system/pymc-repeater.service
systemctl daemon-reload
rm -rf "$INSTALL_DIR"
rm -rf "$CONFIG_DIR"
rm -rf "$LOG_DIR"
rm -rf /var/lib/pymc_repeater
if id "$SERVICE_USER" &>/dev/null; then
userdel "$SERVICE_USER" 2>/dev/null || true
fi
echo " ✓ pyMC_Repeater removed"
# =========================================================================
# Step 2: Remove pyMC Console extras (not handled by upstream)
# =========================================================================
echo "[2/4] Removing pyMC Console extras..."
rm -rf "$CONSOLE_DIR"
rm -rf "/opt/pymc_console" # Old path
echo " ✓ Console directories removed"
# =========================================================================
# Step 3: Clean up any leftover site-packages (pip leftovers)
# =========================================================================
echo "[3/4] Cleaning up Python packages..."
pip uninstall -y pymc_repeater 2>/dev/null || true
pip uninstall -y pymc_core 2>/dev/null || true
pip uninstall -y pymc-repeater 2>/dev/null || true
pip uninstall -y pymc-core 2>/dev/null || true
# Remove any leftover directories
rm -rf "$site_packages/repeater" 2>/dev/null || true
rm -rf "$site_packages/pymc_core" 2>/dev/null || true
rm -rf "$site_packages/pymc_repeater"* 2>/dev/null || true
rm -rf "$site_packages/pymc_core"* 2>/dev/null || true
echo " ✓ Python packages cleaned"
# =========================================================================
# Step 4: Handle clone directory
# =========================================================================
echo "[4/4] Finalizing..."
echo ""
echo "=== Uninstall Complete ==="
echo ""
# Offer to delete clone directory
if [ "$has_clone" = true ]; then
if ask_yes_no "Remove Clone?" "\nThe pyMC_Repeater clone still exists at:\n$CLONE_DIR\n\nWould you like to remove it as well?"; then
rm -rf "$CLONE_DIR"
echo " ✓ Clone directory removed"
else
echo " Clone directory preserved at: $CLONE_DIR"
fi
echo ""
fi
show_info "Uninstall Complete" "\npyMC Console has been completely removed.\n\nThank you for using pyMC Console!"
}
# ============================================================================
# Helper Functions
# ============================================================================
check_spi() {
# Skip SPI check on non-Linux systems (macOS, etc.)
if [[ "$(uname -s)" != "Linux" ]]; then
return 0
fi
# Check if SPI is already loaded via kernel module
if grep -q "spi" /proc/modules 2>/dev/null; then
return 0
fi
# Check for spidev devices (works on Ubuntu and other distros)
if ls /dev/spidev* &>/dev/null; then
return 0
fi
# Check if spi_bcm2835 or spi_bcm2708 modules are available (Raspberry Pi)
if lsmod 2>/dev/null | grep -q "spi_bcm"; then
return 0
fi
# Check if spidev module is loaded
if lsmod 2>/dev/null | grep -q "spidev"; then
return 0
fi
# Raspberry Pi / Ubuntu on Pi: check config.txt locations
local config_file=""
if [ -f "/boot/firmware/config.txt" ]; then
# Ubuntu on Raspberry Pi uses /boot/firmware/
config_file="/boot/firmware/config.txt"
elif [ -f "/boot/config.txt" ]; then
# Raspberry Pi OS uses /boot/
config_file="/boot/config.txt"
fi
if [ -n "$config_file" ]; then
# Raspberry Pi (any OS) - can enable via config.txt
if grep -q "dtparam=spi=on" "$config_file" 2>/dev/null; then
return 0
fi
if ask_yes_no "SPI Not Enabled" "\nSPI interface is required but not enabled!\n\nWould you like to enable it now?\n(This will require a reboot)"; then
echo "dtparam=spi=on" >> "$config_file"
show_info "SPI Enabled" "\nSPI has been enabled.\n\nSystem will reboot now.\nPlease run this script again after reboot."
reboot
else
show_error "SPI is required for LoRa radio operation.\n\nPlease enable SPI manually and run this script again."
exit 1
fi
else
# Generic Linux (Ubuntu x86, other SBCs, etc.)
# Try to load spidev module
if modprobe spidev 2>/dev/null; then
if ls /dev/spidev* &>/dev/null; then
return 0
fi
fi
# Still no SPI - warn user
if ! ask_yes_no "SPI Check" "\nCould not verify SPI is enabled.\n\nFor LoRa radio operation, ensure SPI is enabled on your system.\n\nOn Ubuntu/Debian, you may need to:\n- Load the spidev module: sudo modprobe spidev\n- Enable SPI in device tree overlays\n- Check your hardware supports SPI\n\nContinue anyway?"; then
exit 1
fi
fi
}
install_yq() {
if ! command -v yq &> /dev/null || [[ "$(yq --version 2>&1)" != *"mikefarah/yq"* ]]; then
echo "Installing yq..."
install_yq_silent
fi
}
# Silent version for use with spinner
install_yq_silent() {
local YQ_VERSION="v4.40.5"
local YQ_BINARY="yq_linux_arm64"
if [[ "$(uname -m)" == "x86_64" ]]; then
YQ_BINARY="yq_linux_amd64"
elif [[ "$(uname -m)" == "armv7"* ]]; then
YQ_BINARY="yq_linux_arm"
elif [[ "$(uname -s)" == "Darwin" ]]; then
YQ_BINARY="yq_darwin_arm64"
[[ "$(uname -m)" == "x86_64" ]] && YQ_BINARY="yq_darwin_amd64"
fi
wget -qO /usr/local/bin/yq "https://github.com/mikefarah/yq/releases/download/${YQ_VERSION}/${YQ_BINARY}" && chmod +x /usr/local/bin/yq
}
# ============================================================================
# UPSTREAM INSTALLATION MANAGER
# ============================================================================
# This section handles running pyMC_Repeater's native manage.sh installer.
# We run upstream's installer directly (user sees their native TUI), then
# apply our patches and overlay our dashboard afterward.
#
# The approach:
# 1. Clone/update pyMC_Repeater to a sibling directory
# 2. Run upstream's manage.sh (install/upgrade) in foreground - user sees TUI
# 3. Apply our patches to the installed files (/opt/pymc_repeater)
# 4. Overlay our React dashboard
# 5. Run our radio configuration
#
# Note: Upstream's radio config script is temporarily renamed during install
# so we can run our own configuration flow instead.
# ============================================================================
# Run upstream's manage.sh with a specific action
# Usage: run_upstream_installer <action> [branch]
# Actions: install, upgrade
#
# Strategy: Let upstream run in foreground so user sees its native TUI.
# We skip upstream's radio config and do our own after.
run_upstream_installer() {
local action="$1"
local branch="${2:-$DEFAULT_BRANCH}"
local upstream_script="$CLONE_DIR/manage.sh"
local exit_code=0
# Verify clone exists
if [ ! -f "$upstream_script" ]; then
print_error "Upstream manage.sh not found at $upstream_script"
return 1
fi
# Temporarily rename setup-radio-config.sh to skip upstream's radio config
# We run our own config after installation
local radio_config_script="$CLONE_DIR/setup-radio-config.sh"
local radio_config_backup=""
if [ -f "$radio_config_script" ]; then
radio_config_backup="${radio_config_script}.pymc_backup"
mv "$radio_config_script" "$radio_config_backup"
fi
echo ""
echo -e " ${DIM}────────────────────────────────────────────────────────${NC}"
echo -e " ${BOLD}Running pyMC_Repeater $action...${NC}"
echo -e " ${DIM}You'll see the upstream installer's interface below.${NC}"
echo -e " ${DIM}────────────────────────────────────────────────────────${NC}"
echo ""
# Run upstream's manage.sh directly in foreground
# User sees the native TUI (whiptail dialogs, progress bars, etc.)
(
cd "$CLONE_DIR"
bash "$upstream_script" "$action"
)
exit_code=$?
echo ""
echo -e " ${DIM}────────────────────────────────────────────────────────${NC}"
# Restore radio config script if we backed it up
if [ -n "$radio_config_backup" ] && [ -f "$radio_config_backup" ]; then
mv "$radio_config_backup" "$radio_config_script"
fi
if [ $exit_code -eq 0 ]; then
echo -e " ${CHECK} pyMC_Repeater $action completed"
return 0
else
echo -e " ${CROSS} ${RED}pyMC_Repeater $action failed${NC}"
return 1
fi
}
# ============================================================================
# PATCH REGISTRY
# ============================================================================
# Core patches that enhance pyMC_Repeater with pyMC Console features.
# These patches are candidates for upstream PR submission.
#
# REMOVED (Merged Upstream in PR #36 - feat/identity branch):
# - patch_api_endpoints - /api/update_radio_config endpoint
# - patch_stats_api - Extended /api/stats with max_flood_hops, advert_interval_minutes, rx_delay_base
#
# Remaining Patches:
#
# 2. patch_logging_section (main.py)
# - Ensures config['logging'] exists before setting level from --log-level arg
# - Fixes KeyError when service starts with --log-level DEBUG
# - PR Status: Pending
#
# 3. patch_log_level_api (api_endpoints.py)
# - Adds POST /api/set_log_level endpoint
# - Allows web UI to toggle log level (INFO/DEBUG) and restart service
# - PR Status: Pending
#
# 4. patch_mesh_cli (mesh_cli.py)
# - Enhances mesh CLI with MeshCore CommonCLI.cpp parity
# - tempradio with auto-revert timer, reboot, stats-*, board, neighbor.remove
# - Implemented via external Python patch script (patches/mesh_cli_enhancements.py)
# - PR Status: Pending
#
# 6. patch_private_key_api (mesh_cli.py)
# - Adds get/set prv.key for private key management via Terminal
# - Stores key in config['mesh']['identity_key']
# - PR Status: Pending
#
# NOTE: patch_static_file_serving was removed
# Upstream's default() method already returns index.html for all unknown routes,
# which is exactly what a true SPA needs. React Router handles client-side routing.
#
# NOTE: GPIO patches (Fix A-D) were removed after discovery that the real issue
# was a race condition in pymc_core's interrupt initialization. Adding --log-level
# DEBUG to the service provides enough delay for the asyncio event loop to
# initialize before interrupt callbacks are registered. See create_backend_service().
#
# To generate clean patches for upstream PR:
# 1. Clone fresh pyMC_Repeater
# 2. Apply patches via manage.sh upgrade
# 3. git diff > patches/feature-name.patch
# ============================================================================
# ------------------------------------------------------------------------------
# PATCH 1: Radio Configuration API Endpoint [REMOVED - MERGED UPSTREAM PR #36]
# ------------------------------------------------------------------------------
# This patch was merged upstream in PR #36 to the feat/identity branch.
# The patch is preserved below (commented out) for reference and for users
# who may still be on main/dev branches before the PR is merged there.
# ------------------------------------------------------------------------------
# Patch removed - see PR #36: https://github.com/rightup/pyMC_Repeater/pull/36
patch_api_endpoints() {
# DISABLED: This patch was merged upstream in PR #36 (feat/identity branch)
# See: https://github.com/rightup/pyMC_Repeater/pull/36
# The /api/update_radio_config endpoint is now part of upstream pyMC_Repeater
print_info "API endpoints patch skipped (merged upstream in PR #36)"
return 0
}
# ------------------------------------------------------------------------------
# PATCH 3: Log Level API Endpoint
# ------------------------------------------------------------------------------
# File: repeater/web/api_endpoints.py
# Purpose: Allow web UI to toggle log level (INFO/DEBUG) without SSH
# Changes:
# - Add POST /api/set_log_level endpoint
# - Updates config.yaml -> logging.level
# - Restarts pymc-repeater service to apply change
# - Returns success/failure
# ------------------------------------------------------------------------------
patch_log_level_api() {
local target_dir="${1:-$CLONE_DIR}"
local api_file="$target_dir/repeater/web/api_endpoints.py"
if [ ! -f "$api_file" ]; then
print_warning "api_endpoints.py not found, skipping log level patch"
return 0
fi
# Check if already patched
if grep -q 'def set_log_level' "$api_file" 2>/dev/null; then
print_info "Log level API already patched"
return 0
fi
# Use Python to add the endpoint
python3 << PATCHEOF
import re
api_file = "$api_file"
with open(api_file, 'r') as f:
content = f.read()
# Add set_log_level endpoint after update_radio_config (or save_cad_settings if radio config not present)
set_log_level_code = '''
@cherrypy.expose
@cherrypy.tools.json_out()
@cherrypy.tools.json_in()
def set_log_level(self):
"""Set log level and restart service to apply
POST /api/set_log_level
Body: {"level": "DEBUG" | "INFO" | "WARNING"}
Returns: {"success": true, "data": {"level": "DEBUG", "restarting": true}}
"""
import subprocess
try:
self._require_post()
data = cherrypy.request.json or {}
level = data.get("level", "").upper()
if level not in ("DEBUG", "INFO", "WARNING", "ERROR"):
return self._error("Invalid log level. Use DEBUG, INFO, WARNING, or ERROR")
# Update config.yaml
config_path = getattr(self, '_config_path', '/etc/pymc_repeater/config.yaml')
# Ensure logging section exists
if "logging" not in self.config:
self.config["logging"] = {}
self.config["logging"]["level"] = level
# Save config
self._save_config_to_file(config_path)
logger.info(f"Log level changed to {level}, restarting service...")
# Schedule service restart in background (so we can return response first)
# Use subprocess.Popen to not wait for completion
subprocess.Popen(
["systemctl", "restart", "pymc-repeater"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True
)
return self._success({
"level": level,
"restarting": True,
"message": f"Log level set to {level}. Service restarting..."
})
except cherrypy.HTTPError:
raise
except Exception as e:
logger.error(f"Error setting log level: {e}")
return self._error(str(e))
'''
# Find insertion point - after update_radio_config if it exists, otherwise after save_cad_settings
if 'def update_radio_config' in content:
# Insert after update_radio_config
pattern = r'( def update_radio_config\(self\):.*?return self\._error\(str\(e\)\))'
match = re.search(pattern, content, re.DOTALL)
if match:
insert_pos = match.end()
content = content[:insert_pos] + set_log_level_code + content[insert_pos:]
else:
# Fall back to inserting after save_cad_settings
pattern = r'( def save_cad_settings\(self\):.*?return self\._error\(e\))'
match = re.search(pattern, content, re.DOTALL)
if match:
insert_pos = match.end()
content = content[:insert_pos] + set_log_level_code + content[insert_pos:]
with open(api_file, 'w') as f:
f.write(content)
print("Patched api_endpoints.py with set_log_level")
PATCHEOF
# Verify patch was applied
if grep -q 'def set_log_level' "$api_file" 2>/dev/null; then
print_success "Patched api_endpoints.py with set_log_level"
else
print_warning "Log level API patch may not have applied correctly"
fi
}
# ------------------------------------------------------------------------------
# PATCH 2: Ensure logging section exists before setting level (main.py)
# ------------------------------------------------------------------------------
patch_logging_section() {
local target_dir="${1:-$CLONE_DIR}"
local main_file="$target_dir/repeater/main.py"
if [ ! -f "$main_file" ]; then
print_warning "main.py not found, skipping logging patch"
return 0
fi
# Check if already patched (upstream may have fixed this)
if grep -q 'if "logging" not in config' "$main_file" 2>/dev/null; then
print_info "Logging section already guarded (upstream fix)"
return 0
fi
# Only patch if the vulnerable pattern exists
if grep -q 'if args.log_level:' "$main_file" 2>/dev/null; then
python3 << PATCHEOF
import io, sys
path = "$main_file"
with open(path, 'r') as f:
s = f.read()
old = """
if args.log_level:
config[\"logging\"][\"level\"] = args.log_level
"""
new = """
if args.log_level:
if \"logging\" not in config:
config[\"logging\"] = {}
config[\"logging\"][\"level\"] = args.log_level
"""
if old in s and new not in s:
s = s.replace(old, new)
else:
# Try a more flexible replacement using lines
lines = s.splitlines(True)
out = []
i = 0
while i < len(lines):
line = lines[i]
if line.strip().startswith("if args.log_level"):
out.append(line)
i += 1
if i < len(lines) and "config[\"logging\"][\"level\"]" in lines[i]:
indent = lines[i].split('c')[0] # leading spaces
out.append(f"{indent}if \"logging\" not in config:\n")
out.append(f"{indent} config[\"logging\"] = {{}}\n")
out.append(lines[i])
i += 1
continue
out.append(line)
i += 1
s = ''.join(out)
with open(path, 'w') as f:
f.write(s)
print("Patched logging section in main.py")
PATCHEOF
# Verify
if grep -q 'if "logging" not in config' "$main_file"; then
print_success "Patched logging section in main.py"
else
print_warning "Logging patch may not have applied"
fi
else
print_info "No log_level handling found - may be older version"
fi
}
# ------------------------------------------------------------------------------
# PATCH 4: MeshCore CLI Parity (mesh_cli.py)
# ------------------------------------------------------------------------------
# File: repeater/handler_helpers/mesh_cli.py
# Purpose: Enhance mesh CLI with MeshCore CommonCLI.cpp parity
# Changes:
# - tempradio with auto-revert timer (saves config, reverts after timeout)
# - reboot via systemctl restart
# - neighbor.remove implementation
# - clear stats implementation
# - stats-packets, stats-radio, stats-core commands
# - board command for platform info
# PR Status: Pending upstream submission
# ------------------------------------------------------------------------------
patch_mesh_cli() {
local target_dir="${1:-$CLONE_DIR}"
local mesh_cli_file="$target_dir/repeater/handler_helpers/mesh_cli.py"
local script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
local patch_script="$script_dir/patches/mesh_cli_enhancements.py"
if [ ! -f "$mesh_cli_file" ]; then
print_warning "mesh_cli.py not found, skipping MeshCore CLI patch"
return 0
fi
# Check if already patched (look for our marker comment)
if grep -q 'pymc_console: tempradio' "$mesh_cli_file" 2>/dev/null; then
print_info "MeshCore CLI patch already applied"
return 0
fi
# Check if patch script exists
if [ ! -f "$patch_script" ]; then
print_warning "Patch script not found: $patch_script"
print_info "Skipping MeshCore CLI enhancements"
return 0
fi
# Run the Python patch script
if python3 "$patch_script" "$mesh_cli_file" 2>/dev/null; then
print_success "Applied MeshCore CLI parity patch"
else
print_warning "MeshCore CLI patch may not have applied correctly"
fi
}
# ------------------------------------------------------------------------------
# PATCH 5: Stats API Extension [REMOVED - MERGED UPSTREAM PR #36]
# ------------------------------------------------------------------------------
# This patch was merged upstream in PR #36 to the feat/identity branch.
# The /api/stats endpoint now includes max_flood_hops, advert_interval_minutes,
# and rx_delay_base in the response.
# ------------------------------------------------------------------------------
# Patch removed - see PR #36: https://github.com/rightup/pyMC_Repeater/pull/36
patch_stats_api() {
# Patch disabled - merged upstream in PR #36 (feat/identity branch)
print_info "Stats API patch skipped (merged upstream in PR #36)"
return 0
}
# ------------------------------------------------------------------------------
# PATCH 6: Private Key API (mesh_cli.py)
# ------------------------------------------------------------------------------
# File: repeater/handler_helpers/mesh_cli.py
# Purpose: Enable get/set prv.key for private key management via Terminal
# Changes:
# - get prv.key: Returns 32-byte Ed25519 signing key seed in hex
# - set prv.key: Stores key in config['mesh']['identity_key'], requires restart
# Security: Only accessible to admin users via authenticated CLI
# PR Status: Pending upstream submission
# ------------------------------------------------------------------------------
patch_private_key_api() {
local target_dir="${1:-$CLONE_DIR}"
local mesh_cli_file="$target_dir/repeater/handler_helpers/mesh_cli.py"
local script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
local patch_script="$script_dir/patches/private_key_api.py"
if [ ! -f "$mesh_cli_file" ]; then
print_warning "mesh_cli.py not found, skipping private key API patch"
return 0
fi
# Check if already patched
if grep -q 'pymc_console: prv.key' "$mesh_cli_file" 2>/dev/null; then
print_info "Private key API patch already applied"
return 0
fi
# Check if patch script exists
if [ ! -f "$patch_script" ]; then
print_warning "Patch script not found: $patch_script"
print_info "Skipping private key API"
return 0
fi
# Run the Python patch script
if python3 "$patch_script" "$mesh_cli_file" 2>/dev/null; then
print_success "Applied private key API patch"
else
print_warning "Private key API patch may not have applied correctly"
fi
}
install_backend_service() {
# Copy upstream's service file as base (from clone directory)
local service_file="$CLONE_DIR/pymc-repeater.service"
# Fall back to install dir if clone doesn't have it
if [ ! -f "$service_file" ] && [ -f "$INSTALL_DIR/pymc-repeater.service" ]; then
service_file="$INSTALL_DIR/pymc-repeater.service"
fi
if [ -f "$service_file" ]; then
cp "$service_file" /etc/systemd/system/pymc-repeater.service
# WORKAROUND: Add --log-level DEBUG to fix pymc_core timing bug on Pi 5
# Issue: asyncio event loop not ready when interrupt callbacks register
# The DEBUG flag slows down initialization enough for the event loop to start
# TODO: File upstream issue at github.com/rightup/pyMC_core
sed -i 's|--config /etc/pymc_repeater/config.yaml$|--config /etc/pymc_repeater/config.yaml --log-level DEBUG|' \
/etc/systemd/system/pymc-repeater.service
print_success "Installed upstream service file"
print_info "Added --log-level DEBUG for RX timing fix"
else
print_error "Service file not found in pyMC_Repeater repo"
return 1
fi
}
# GitHub repository for UI releases (public distribution repo)
UI_REPO="dmduran12/pymc_console-dist"
UI_RELEASE_URL="https://github.com/${UI_REPO}/releases"
# Download and install dashboard from GitHub Releases
# Installs to separate directory (UI_DIR) instead of overwriting upstream Vue.js
# Configures web.web_path in config.yaml to point to our dashboard
install_static_frontend() {
local version="${1:-latest}"
local target_dir="$UI_DIR"
local config_file="$CONFIG_DIR/config.yaml"
local temp_file="/tmp/pymc-ui-$$.tar.gz"
local download_url
# Construct download URL
if [ "$version" = "latest" ]; then
download_url="${UI_RELEASE_URL}/latest/download/pymc-ui-latest.tar.gz"
else
download_url="${UI_RELEASE_URL}/download/${version}/pymc-ui-${version}.tar.gz"
fi
print_info "Downloading dashboard ($version)..."
# Download with curl (preferred - handles redirects better) or wget
if command -v curl &> /dev/null; then
if ! curl -fsSL -o "$temp_file" "$download_url"; then
print_error "Failed to download dashboard from $download_url"
rm -f "$temp_file"
return 1
fi
elif command -v wget &> /dev/null; then
# wget needs explicit redirect following for GitHub releases
if ! wget -q --max-redirect=5 -O "$temp_file" "$download_url"; then
print_error "Failed to download dashboard from $download_url"
print_info "Check your internet connection or try a specific version"
rm -f "$temp_file"
return 1
fi
else
print_error "Neither curl nor wget found - cannot download dashboard"
return 1
fi
# Verify download (check file exists and has content)
if [ ! -s "$temp_file" ]; then
print_error "Downloaded file is empty - release may not exist"
print_info "Available releases: ${UI_RELEASE_URL}"
rm -f "$temp_file"
return 1
fi
# Remove existing dashboard if present (clean upgrade)
if [ -d "$target_dir" ]; then
rm -rf "$target_dir"
fi
# Create parent directories
mkdir -p "$(dirname "$target_dir")"
mkdir -p "$target_dir"
# Extract to target directory
if ! tar -xzf "$temp_file" -C "$target_dir" 2>/dev/null; then
print_error "Failed to extract dashboard archive"
rm -f "$temp_file"
return 1
fi
# Clean up temp file
rm -f "$temp_file"
# Set permissions
chown -R "$SERVICE_USER:$SERVICE_USER" "$CONSOLE_DIR" 2>/dev/null || true
# Configure CherryPy to serve our dashboard instead of built-in Vue.js
# This sets web.web_path in config.yaml
if [ -f "$config_file" ] && command -v yq &> /dev/null; then
# Ensure web section exists
if ! yq eval '.web' "$config_file" 2>/dev/null | grep -q -v "null"; then
yq -i '.web = {}' "$config_file" 2>/dev/null || true
fi
# Set web_path to our dashboard location
yq -i ".web.web_path = \"$target_dir\"" "$config_file" 2>/dev/null || {
print_warning "Could not set web_path in config - manual configuration may be required"
}
print_success "Configured web_path: $target_dir"
else
print_warning "Could not configure web_path - yq not available or config missing"
print_info "Manually set web.web_path in $config_file to: $target_dir"
fi
local size=$(du -sh "$target_dir" 2>/dev/null | cut -f1 || echo "unknown")
print_success "Dashboard installed ($size)"
print_info "Upstream Vue.js preserved at: $INSTALL_DIR/repeater/web/html/"
print_info "Dashboard will be served at http://<ip>:8000/"
return 0
}
# Get available UI versions from GitHub
get_ui_versions() {
local releases
releases=$(curl -s "https://api.github.com/repos/${UI_REPO}/releases" 2>/dev/null |
grep -oP '"tag_name":\s*"\K[^"]+' | head -10)
echo "$releases"
}
merge_config() {
local user_config="$1"
local example_config="$2"
if [ ! -f "$user_config" ] || [ ! -f "$example_config" ]; then
echo " Config merge skipped (files not found)"
return 0
fi
if ! command -v yq &> /dev/null; then
echo " Config merge skipped (yq not available)"
return 0
fi
local temp_merged="${user_config}.merged"
if yq eval-all '. as $item ireduce ({}; . * $item)' "$example_config" "$user_config" > "$temp_merged" 2>/dev/null; then
if yq eval '.' "$temp_merged" > /dev/null 2>&1; then
mv "$temp_merged" "$user_config"
echo " ✓ Configuration merged (user settings preserved, new options added)"
else
rm -f "$temp_merged"
echo " ⚠ Merge validation failed, keeping original"
fi
else
rm -f "$temp_merged"
echo " ⚠ Merge failed, keeping original"
fi
}
# ============================================================================
# Main Menu
# ============================================================================
show_main_menu() {
local status=$(get_status_display)
CHOICE=$($DIALOG --backtitle "pyMC Console Management" --title "pyMC Console" --menu "\nStatus: $status\n\nChoose an action:" 20 70 10 \
"install" "Install pyMC Console (fresh install)" \
"upgrade" "Upgrade existing installation" \
"settings" "Configure radio settings" \
"gpio" "GPIO configuration (advanced)" \
"start" "Start services" \
"stop" "Stop services" \
"restart" "Restart services" \
"logs" "View live logs" \
"uninstall" "Uninstall pyMC Console" \
"exit" "Exit" 3>&1 1>&2 2>&3)
case $CHOICE in
"install") do_install ;;
"upgrade") do_upgrade ;;
"settings") do_settings ;;
"gpio") do_gpio ;;
"start") do_start ;;
"stop") do_stop ;;
"restart") do_restart ;;
"logs")
clear
echo "=== Live Logs (Press Ctrl+C to return) ==="
echo ""
journalctl -u "$BACKEND_SERVICE" -f
;;
"uninstall") do_uninstall ;;
"exit"|"") exit 0 ;;
esac
}
# ============================================================================
# CLI Help
# ============================================================================
show_help() {
echo "pyMC Console Management Script"
echo ""
echo "Usage: $0 [command]"
echo ""
echo "Commands:"
echo " install Install pyMC Console (fresh install)"
echo " upgrade Upgrade existing installation"
echo " settings Configure radio settings"
echo " gpio GPIO configuration (advanced)"
echo " start Start pyMC Repeater service"
echo " stop Stop pyMC Repeater service"
echo " restart Restart pyMC Repeater service"
echo " uninstall Completely remove pyMC Console"
echo ""
echo "Run without arguments for interactive menu."
}
# ============================================================================
# Main Entry Point
# ============================================================================
# Handle CLI arguments
case "$1" in
"--help"|"-h")
show_help
exit 0
;;
"install")
check_terminal
setup_dialog
do_install "$2"
exit 0
;;
"upgrade")
check_terminal
setup_dialog
do_upgrade
exit 0
;;
"settings")
check_terminal
setup_dialog
do_settings
exit 0
;;
"gpio")
check_terminal
setup_dialog
do_gpio
exit 0
;;
"start")
check_terminal
setup_dialog
do_start
exit 0
;;
"stop")
check_terminal
setup_dialog
do_stop
exit 0
;;
"restart")
check_terminal
setup_dialog
do_restart
exit 0
;;
"uninstall")
check_terminal
setup_dialog
do_uninstall
exit 0
;;
esac
# Interactive menu mode
check_terminal
setup_dialog
while true; do
show_main_menu
done