Merge upstream/feat/newRadios into dev-companion-v2-cleanup

- Keep our Vite-built assets and index.html script (index-DyUIpN7m.js)
- Remove upstream-only asset chunks and RoomServers-BxQ-0q-x.js
- README: keep two-backend intro, add upstream CAUTION/compatibility table
- manage.sh: keep dialog/gauge UX and .[hardware]; add CH341 udev, sudoers, libusb, polkit, silent upgrade
- sqlite_handler: add crc_errors table, index, and cleanup from upstream
- engine: add validate_packet and mark_seen in direct_forward; keep our path hash_size/hop_count logic
- advert: keep comment, use current_time = now
- api_endpoints: use restart_service() from service_utils
- config merge: strip user config comments before yq merge (upstream)

Made-with: Cursor
This commit is contained in:
agessaman
2026-03-05 16:43:14 -08:00
35 changed files with 3340 additions and 163 deletions
+115 -3
View File
@@ -35,7 +35,25 @@ The repeater supports two radio backends:
- **SX1262 (SPI)** — Direct connection to LoRa modules (HATs, etc.) as listed below.
- **KISS modem** — Serial TNC using the KISS protocol. Set `radio_type: kiss` in config and configure `kiss.port` and `kiss.baud_rate`.
The following SX1262 hardware is currently supported out-of-the-box:
> [!CAUTION]
> ## Compatibility
>
> ### Supported Radio Interfaces
>
> | Interface | Supported |
> |------------|------------|
> | Native SPI radio SX1262 | ✅ Yes |
> | USBSPI bridge (CH341F) | ✅ Yes |
> | UART-based HATs | ❌ No |
> | SX1302 concentrator boards | ❌ No |
> | SX1303 concentrator boards | ❌ No |
>
> This project supports **single-radio SPI transceivers only**, either:
> - Connected directly via SPI
> - Connected via a CH341F USBSPI adapter
> - Connected using hardware that supports Meshcore Kiss Modem firmware
The following hardware is currently supported out-of-the-box:
Waveshare LoRaWAN/GNSS HAT (SPI Version Only)
@@ -199,6 +217,91 @@ The upgrade script will:
- Restart the service automatically
- Preserve your existing configuration
---
## Installing on Proxmox (LXC Container)
pyMC Repeater can run inside a Proxmox LXC container using a **CH341 USB-to-SPI adapter** for radio communication. This is ideal for headless, always-on deployments without dedicating a full Raspberry Pi.
### Requirements
- **Proxmox VE 7.x or 8.x** host
- **CH341 USB-to-SPI adapter** (VID `1a86`, PID `5512`) connected to the Proxmox host
- **SX1262-based LoRa module** (e.g. Ebyte E22-900M30S) wired to the CH341 adapter
- Internet connectivity for the container
### One-Line Install
Run this on the **Proxmox host** (not inside a container):
```bash
bash -c "$(curl -fsSL https://raw.githubusercontent.com/rightup/pyMC_Repeater/feat/newRadios/scripts/proxmox-install.sh)"
```
> **Tip:** Replace `feat/newRadios` in the URL with whichever branch you want to install.
The installer will interactively prompt you for container settings (hostname, RAM, disk, bridge, etc.) and then:
1. Download a Debian 12 LXC template
2. Create a **privileged** container with USB passthrough
3. Install a host-side udev rule for the CH341 device
4. Clone the repository and pre-seed the config with CH341 GPIO pin mappings
5. Run `manage.sh install` inside the container
6. Display the dashboard URL when finished
### Default Container Settings
| Setting | Default |
|-----------|-----------------|
| Hostname | `pymc-repeater` |
| RAM | 1024 MB |
| Disk | 4 GB |
| CPU cores | 2 |
| Bridge | `vmbr0` |
| Storage | `local-lvm` |
| Password | `pymc` |
### After Installation
```bash
# Enter the container
pct enter <CTID>
# View service logs
journalctl -u pymc-repeater -f
# Access web dashboard
http://<container-ip>:8000
# Manage the repeater
cd /opt/pymc_repeater && bash manage.sh
```
### CH341 GPIO Pin Mapping
The installer pre-configures the CH341 GPIO pins for an E22 module. These differ from the Raspberry Pi BCM pin numbers:
| Function | CH341 GPIO | Pi BCM (default) |
|----------|-----------|-------------------|
| CS | 0 | 21 |
| RXEN | 1 | -1 |
| Reset | 2 | 18 |
| Busy | 4 | 20 |
| IRQ | 6 | 16 |
The installer also enables `use_dio3_tcxo` and `use_dio2_rf` for E22 modules.
### Troubleshooting (Proxmox)
- **USB device not found**: Make sure the CH341 is plugged into the Proxmox host and shows up with `lsusb -d 1a86:5512`
- **Permission denied on USB**: The installer creates a host udev rule (`/etc/udev/rules.d/99-ch341.rules`). Run `udevadm trigger` on the host if needed
- **Container can't see USB**: Verify USB passthrough lines exist in `/etc/pve/lxc/<CTID>.conf`:
```
lxc.cgroup2.devices.allow: c 189:* rwm
lxc.mount.entry: /dev/bus/usb dev/bus/usb none bind,optional,create=dir 0 0
```
- **NoBackendError (libusb)**: The installer installs `libusb-1.0-0` automatically. If you see this error, run `apt-get install libusb-1.0-0` inside the container
@@ -217,6 +320,17 @@ This script will:
The script will prompt you for each optional removal step.
## Docker
You can now run PyMC Repeater from within a [Docker Container](https://www.docker.com/). Checkout the example [Docker Compose](./docker-compose.yml) file before you get started.
```bash
docker compose up -d --force-recreate --build
```
Just note that you will have to pass in a `config.yaml` into the container. You can create a new config by following the instructions in the [Configuration section](#configuration).
## Roadmap / Planned Features
- [ ] **Public Map Integration** - Submit repeater location and details to public map for discovery
@@ -264,8 +378,6 @@ Pre-commit hooks will automatically:
- Lint with flake8
- Fix trailing whitespace and other file issues
## Support
- [Core Lib Documentation](https://rightup.github.io/pyMC_core/)
+51 -3
View File
@@ -41,6 +41,53 @@ repeater:
# with its node information (node type 2 - repeater)
allow_discovery: true
# Incoming advert rate limiter (per advert public key)
# Uses a token bucket to smooth bursts.
advert_rate_limit:
# Master switch for token bucket limiting
enabled: false
# Max burst size allowed immediately per pubkey
# Keep this small for long advert intervals.
bucket_capacity: 2
# Number of tokens added each refill interval
refill_tokens: 1
# Refill interval in seconds (10 hours)
refill_interval_seconds: 36000
# Optional hard minimum spacing between adverts from same pubkey
# Set 0 to disable (recommended - mesh retransmissions are normal in active networks)
min_interval_seconds: 0
# Penalty box for repeat advert limit violations (per pubkey)
advert_penalty_box:
# Master switch for escalating cooldowns
enabled: false
# Number of violations within decay window before cooldown starts
violation_threshold: 2
# Reset violation count if pubkey stays quiet for this long
violation_decay_seconds: 43200
# First penalty duration in seconds
base_penalty_seconds: 21600
# Exponential growth factor for repeated violations
penalty_multiplier: 2.0
# Maximum penalty duration cap
max_penalty_seconds: 86400
# Adaptive rate limiting based on mesh activity
# Rate limits scale with mesh busyness: quiet mesh = lenient, busy mesh = strict
advert_adaptive:
# Master switch for adaptive scaling
enabled: false
# EWMA smoothing factor (0.0-1.0, higher = faster response)
ewma_alpha: 0.1
# Seconds without metrics change before tier change takes effect (hysteresis)
hysteresis_seconds: 300
# Tier thresholds based on adverts per minute EWMA
thresholds:
quiet_max: 0.05 # Below this = QUIET tier (no limiting)
normal_max: 0.20 # Below this = NORMAL tier (1x limits)
busy_max: 0.50 # Below this = BUSY tier (0.5x capacity)
# Above busy_max = CONGESTED tier (0.25x capacity)
# Security settings for login/authentication (shared across all identities)
security:
# Maximum number of authenticated clients (across all identities)
@@ -70,6 +117,10 @@ mesh:
# Individual transport keys can override this setting
global_flood_allow: true
# Flood loop detection mode
# off = disabled, minimal = allow up to 3 self-hashes, moderate = allow up to 1, strict = allow 0
loop_detect: minimal
# Multiple Identity Configuration (Optional)
# Define additional identities for the repeater to manage
# Each identity operates independently with its own key pair and configuration
@@ -150,9 +201,6 @@ radio:
# Sync word (LoRa network ID)
sync_word: 13380
# Enable CRC checking
crc_enabled: true
# Use implicit header mode
implicit_header: false
+200 -63
View File
@@ -1,7 +1,7 @@
#!/bin/bash
# Convert MeshCore firmware 64-byte private key to pyMC_Repeater format
#
# Usage: sudo ./convert_firmware_key.sh <64-byte-hex-key> [config-path]
# Usage: sudo ./convert_firmware_key.sh <64-byte-hex-key> [--output-format=<yaml|identity>] [config-path]
# Example: sudo ./convert_firmware_key.sh 987BDA619630197351F2B3040FD19B2EE0DEE357DD69BBEEE295786FA78A4D5F298B0BF1B7DE73CBC23257CDB2C562F5033DF58C232916432948B0F6BA4448F2
set -e
@@ -9,10 +9,10 @@ set -e
if [ $# -eq 0 ]; then
echo "Error: No key provided"
echo ""
echo "Usage: sudo $0 <64-byte-hex-key> [config-path]"
echo "Usage: sudo $0 <64-byte-hex-key> [--output-format=<yaml|identity>] [config-path]"
echo ""
echo "This script imports a 64-byte MeshCore firmware private key into"
echo "pyMC_Repeater config.yaml for full identity compatibility."
echo "pyMC_Repeater for full identity compatibility."
echo ""
echo "The 64-byte key format: [32-byte scalar][32-byte nonce]"
echo " - Enables same node address as firmware device"
@@ -20,10 +20,17 @@ if [ $# -eq 0 ]; then
echo " - Fully compatible with pyMC_core LocalIdentity"
echo ""
echo "Arguments:"
echo " --output-format: Optional output format (yaml|identity, default: yaml)"
echo " yaml - Store in config.yaml (embedded binary)"
echo " identity - Save to identity.key file (base64 encoded)"
echo " config-path: Optional path to config.yaml (default: /etc/pymc_repeater/config.yaml)"
echo ""
echo "Example:"
echo "Examples:"
echo " # Save to config.yaml (default)"
echo " sudo $0 987BDA619630197351F2B3040FD19B2EE0DEE357DD69BBEEE295786FA78A4D5F298B0BF1B7DE73CBC23257CDB2C562F5033DF58C232916432948B0F6BA4448F2"
echo ""
echo " # Save to identity.key file"
echo " sudo $0 987BDA619630197351F2B3040FD19B2EE0DEE357DD69BBEEE295786FA78A4D5F298B0BF1B7DE73CBC23257CDB2C562F5033DF58C232916432948B0F6BA4448F2 --output-format=identity"
exit 1
fi
@@ -35,6 +42,33 @@ if [ "$EUID" -ne 0 ]; then
fi
FULL_KEY="$1"
OUTPUT_FORMAT="yaml" # Default format
CONFIG_PATH=""
# Parse arguments
shift # Remove the key argument
while [ $# -gt 0 ]; do
case "$1" in
--output-format=*)
OUTPUT_FORMAT="${1#*=}"
;;
*)
CONFIG_PATH="$1"
;;
esac
shift
done
# Validate output format
if [ "$OUTPUT_FORMAT" != "yaml" ] && [ "$OUTPUT_FORMAT" != "identity" ]; then
echo "Error: Invalid output format '$OUTPUT_FORMAT'. Must be 'yaml' or 'identity'"
exit 1
fi
# Set default config path if not provided
if [ -z "$CONFIG_PATH" ]; then
CONFIG_PATH="/etc/pymc_repeater/config.yaml"
fi
# Validate hex string
if ! [[ "$FULL_KEY" =~ ^[0-9a-fA-F]+$ ]]; then
@@ -49,17 +83,28 @@ if [ "$KEY_LEN" -ne 128 ]; then
exit 1
fi
# Get config path
CONFIG_PATH="${2:-/etc/pymc_repeater/config.yaml}"
# Check if config exists
if [ ! -f "$CONFIG_PATH" ]; then
echo "Error: Config file not found: $CONFIG_PATH"
exit 1
# Check if config/identity file location exists (only for yaml format or if saving identity.key)
if [ "$OUTPUT_FORMAT" = "yaml" ]; then
# Check if config exists
if [ ! -f "$CONFIG_PATH" ]; then
echo "Error: Config file not found: $CONFIG_PATH"
exit 1
fi
else
# For identity format, use system-wide location matching config.yaml
IDENTITY_DIR="/etc/pymc_repeater"
IDENTITY_PATH="$IDENTITY_DIR/identity.key"
fi
echo "=== MeshCore Firmware Key Import ==="
echo ""
echo "Output format: $OUTPUT_FORMAT"
if [ "$OUTPUT_FORMAT" = "yaml" ]; then
echo "Target file: $CONFIG_PATH"
else
echo "Target file: $IDENTITY_PATH"
fi
echo ""
echo "Input (64-byte firmware key):"
echo " $FULL_KEY"
echo ""
@@ -70,11 +115,13 @@ import sys
import yaml
import base64
import hashlib
import os
from pathlib import Path
# Import the key
key_hex = "$FULL_KEY"
key_bytes = bytes.fromhex(key_hex)
output_format = "$OUTPUT_FORMAT"
# Verify with pyMC if available
try:
@@ -94,47 +141,116 @@ except ImportError:
print("Warning: PyNaCl not available, skipping verification")
print()
# Load config
config_path = Path("$CONFIG_PATH")
try:
with open(config_path, 'r') as f:
config = yaml.safe_load(f) or {}
except Exception as e:
print(f"Error loading config: {e}")
sys.exit(1)
if output_format == "yaml":
# Save to config.yaml
config_path = Path("$CONFIG_PATH")
try:
with open(config_path, 'r') as f:
config = yaml.safe_load(f) or {}
except Exception as e:
print(f"Error loading config: {e}")
sys.exit(1)
# Check for existing key
if 'mesh' in config and 'identity_key' in config['mesh']:
existing = config['mesh']['identity_key']
if isinstance(existing, bytes):
print(f"WARNING: Existing identity_key found ({len(existing)} bytes)")
# Check for existing key
if 'mesh' in config and 'identity_key' in config['mesh']:
existing = config['mesh']['identity_key']
if isinstance(existing, bytes):
print(f"WARNING: Existing identity_key found ({len(existing)} bytes)")
else:
print(f"WARNING: Existing identity_key found")
print()
# Ensure mesh section exists
if 'mesh' not in config:
config['mesh'] = {}
# Store the full 64-byte key
config['mesh']['identity_key'] = key_bytes
# Save config atomically
backup_path = f"{config_path}.backup.{Path(config_path).stat().st_mtime_ns}"
import shutil
shutil.copy2(config_path, backup_path)
print(f"Created backup: {backup_path}")
try:
with open(config_path, 'w') as f:
yaml.safe_dump(config, f, default_flow_style=False, allow_unicode=True)
print(f"✓ Successfully updated {config_path}")
print()
except Exception as e:
print(f"Error writing config: {e}")
shutil.copy2(backup_path, config_path)
print(f"Restored from backup")
sys.exit(1)
else:
# Save to identity.key file
identity_path = Path("$IDENTITY_PATH")
# Create directory if it doesn't exist
identity_path.parent.mkdir(parents=True, exist_ok=True)
# Check for existing identity.key
if identity_path.exists():
print(f"WARNING: Existing identity.key found at {identity_path}")
backup_path = identity_path.with_suffix('.key.backup')
import shutil
shutil.copy2(identity_path, backup_path)
print(f"Created backup: {backup_path}")
print()
# Save as base64-encoded
try:
with open(identity_path, 'wb') as f:
f.write(base64.b64encode(key_bytes))
os.chmod(identity_path, 0o600) # Restrict permissions
print(f"✓ Successfully saved to {identity_path}")
print(f"✓ File permissions set to 0600 (owner read/write only)")
print()
except Exception as e:
print(f"Error writing identity.key: {e}")
sys.exit(1)
# Update config.yaml to remove embedded identity_key so it uses the file
config_path = Path("$CONFIG_PATH")
if config_path.exists():
try:
with open(config_path, 'r') as f:
config = yaml.safe_load(f) or {}
# Check if identity_key exists in config
if 'mesh' in config and 'identity_key' in config['mesh']:
print(f"Updating {config_path} to use identity.key file...")
# Create backup
backup_path = f"{config_path}.backup.{Path(config_path).stat().st_mtime_ns}"
import shutil
shutil.copy2(config_path, backup_path)
print(f"Created backup: {backup_path}")
# Remove identity_key from config
del config['mesh']['identity_key']
# Save updated config
with open(config_path, 'w') as f:
yaml.safe_dump(config, f, default_flow_style=False, allow_unicode=True)
print(f"✓ Removed embedded identity_key from {config_path}")
print(f"✓ Config will now use {identity_path}")
print()
else:
print(f"✓ Config file already configured to use identity.key file")
print()
except Exception as e:
print(f"Warning: Could not update config.yaml: {e}")
print(f"You may need to manually remove 'identity_key' from {config_path}")
print()
else:
print(f"WARNING: Existing identity_key found")
print()
# Ensure mesh section exists
if 'mesh' not in config:
config['mesh'] = {}
# Store the full 64-byte key
config['mesh']['identity_key'] = key_bytes
# Save config atomically
backup_path = f"{config_path}.backup.{Path(config_path).stat().st_mtime_ns}"
import shutil
shutil.copy2(config_path, backup_path)
print(f"Created backup: {backup_path}")
try:
with open(config_path, 'w') as f:
yaml.safe_dump(config, f, default_flow_style=False, allow_unicode=True)
print(f"✓ Successfully updated {config_path}")
print()
except Exception as e:
print(f"Error writing config: {e}")
shutil.copy2(backup_path, config_path)
print(f"Restored from backup")
sys.exit(1)
print(f"Note: Config file not found at {config_path}")
print(f" Identity will be loaded from {identity_path}")
print()
EOF
@@ -143,20 +259,41 @@ if [ $? -ne 0 ]; then
exit 1
fi
# Offer to restart service
if systemctl is-active --quiet pymc-repeater 2>/dev/null; then
read -p "Restart pymc-repeater service now? (yes/no): " RESTART
if [ "$RESTART" = "yes" ]; then
systemctl restart pymc-repeater
echo "✓ Service restarted"
echo ""
echo "Check logs for new identity:"
echo " sudo journalctl -u pymc-repeater -f | grep -i 'identity\|hash'"
# Offer to restart service (only relevant for yaml format)
if [ "$OUTPUT_FORMAT" = "yaml" ]; then
if systemctl is-active --quiet pymc-repeater 2>/dev/null; then
read -p "Restart pymc-repeater service now? (yes/no): " RESTART
if [ "$RESTART" = "yes" ]; then
systemctl restart pymc-repeater
echo "✓ Service restarted"
echo ""
echo "Check logs for new identity:"
echo " sudo journalctl -u pymc-repeater -f | grep -i 'identity\|hash'"
else
echo "Remember to restart the service:"
echo " sudo systemctl restart pymc-repeater"
fi
else
echo "Remember to restart the service:"
echo " sudo systemctl restart pymc-repeater"
echo "Note: pymc-repeater service is not running"
echo "Start it with: sudo systemctl start pymc-repeater"
fi
else
echo "Note: pymc-repeater service is not running"
echo "Start it with: sudo systemctl start pymc-repeater"
echo "Identity key saved to file."
echo ""
if systemctl is-active --quiet pymc-repeater 2>/dev/null; then
read -p "Restart pymc-repeater service now? (yes/no): " RESTART
if [ "$RESTART" = "yes" ]; then
systemctl restart pymc-repeater
echo "✓ Service restarted"
echo ""
echo "Check logs for new identity:"
echo " sudo journalctl -u pymc-repeater -f | grep -i 'identity\|hash'"
else
echo "Remember to restart the service:"
echo " sudo systemctl restart pymc-repeater"
fi
else
echo "Note: pymc-repeater service is not running"
echo "Start it with: sudo systemctl start pymc-repeater"
fi
fi
+15
View File
@@ -0,0 +1,15 @@
services:
pymc-repeater:
build: .
container_name: pymc-repeater
restart: unless-stopped
ports:
- 8000:8000
devices:
- /dev/spidev0.0
- /dev/gpiochip0
cap_add:
- SYS_RAWIO
volumes:
- ./config.yaml:/etc/pymc_repeater/config.yaml
- ./data:/var/lib/pymc_repeater
+35
View File
@@ -0,0 +1,35 @@
FROM python:3.12-slim-bookworm
ENV INSTALL_DIR=/opt/pymc_repeater \
CONFIG_DIR=/etc/pymc_repeater \
DATA_DIR=/var/lib/pymc_repeater \
PYTHONUNBUFFERED=1 \
SETUPTOOLS_SCM_PRETEND_VERSION_FOR_PYMC_REPEATER=1.0.5
# Install runtime dependencies only
RUN apt-get update && apt-get install -y \
libffi-dev \
python3-rrdtool \
jq \
wget \
swig \
git \
build-essential \
python3-dev \
&& rm -rf /var/lib/apt/lists/*
# Create runtime directories
RUN mkdir -p ${INSTALL_DIR} ${CONFIG_DIR} ${DATA_DIR}
WORKDIR ${INSTALL_DIR}
# Copy source
COPY repeater ./repeater
COPY pyproject.toml .
# Install package
RUN pip install --no-cache-dir .
EXPOSE 8000
ENTRYPOINT ["python3", "-m", "repeater.main", "--config", "/etc/pymc_repeater/config.yaml"]
+239 -49
View File
@@ -8,12 +8,31 @@ CONFIG_DIR="/etc/pymc_repeater"
LOG_DIR="/var/log/pymc_repeater"
SERVICE_USER="repeater"
SERVICE_NAME="pymc-repeater"
SILENT_MODE="${PYMC_SILENT:-${SILENT:-}}"
is_silent_flag() {
case "${1:-}" in
--silent|-y|silent) return 0 ;;
*) return 1 ;;
esac
}
is_interactive_flag() {
case "${1:-}" in
--interactive|-i|interactive) return 0 ;;
*) return 1 ;;
esac
}
# Check if we're running in an interactive terminal
if [ ! -t 0 ] || [ -z "$TERM" ]; then
echo "Error: This script requires an interactive terminal."
echo "Please run from SSH or a local terminal, not via file manager."
exit 1
if [[ "$1" =~ ^(upgrade|start|stop|restart)$ ]] && ! is_interactive_flag "$2"; then
:
else
echo "Error: This script requires an interactive terminal."
echo "Please run from SSH or a local terminal, not via file manager."
exit 1
fi
fi
# Check if whiptail is available, fallback to dialog
@@ -125,7 +144,7 @@ show_main_menu() {
;;
"upgrade")
if is_installed; then
upgrade_repeater
upgrade_repeater "false"
else
show_error "pyMC Repeater is not installed!\n\nUse 'install' first."
fi
@@ -148,19 +167,22 @@ show_main_menu() {
configure_radio
;;
"start")
manage_service "start"
manage_service "start" "false"
;;
"stop")
manage_service "stop"
manage_service "stop" "false"
;;
"restart")
manage_service "restart"
manage_service "restart" "false"
;;
"logs")
clear
echo "=== Live Logs (Press Ctrl+C to return) ==="
echo -e "\033[1;36m╔══════════════════════════════════════════════════════════════════════╗\033[0m"
echo -e "\033[1;36m║\033[0m \033[1;37mpyMC Repeater - Live Logs\033[0m \033[1;36m║\033[0m"
echo -e "\033[1;36m║\033[0m \033[0;90m(Press Ctrl+C to return)\033[0m \033[1;36m║\033[0m"
echo -e "\033[1;36m╚══════════════════════════════════════════════════════════════════════╝\033[0m"
echo ""
journalctl -u "$SERVICE_NAME" -f
journalctl -u "$SERVICE_NAME" -f -o cat --no-hostname | sed -e 's/.*ERROR.*/\x1b[1;31m&\x1b[0m/' -e 's/.*CRITICAL.*/\x1b[1;41;37m&\x1b[0m/' -e 's/.*WARNING.*/\x1b[1;33m&\x1b[0m/' -e 's/.*INFO.*/\x1b[0;32m&\x1b[0m/' -e 's/.*DEBUG.*/\x1b[0;36m&\x1b[0m/'
;;
"status")
show_detailed_status
@@ -182,9 +204,16 @@ install_repeater() {
# Welcome screen
$DIALOG --backtitle "pyMC Repeater Management" --title "Welcome" --msgbox "\nWelcome to pyMC Repeater Setup\n\nThis installer will configure your Linux system as a LoRa mesh network repeater.\n\nPress OK to continue..." 12 70
# SPI Check - Universal approach that works on all boards
# SPI Check - Universal approach that works on all boards (skip for CH341 USB-SPI adapter)
SPI_MISSING=0
if ! ls /dev/spidev* >/dev/null 2>&1; then
USES_CH341=0
if [ -f "$CONFIG_DIR/config.yaml" ]; then
if grep -q "radio_type:.*sx1262_ch341" "$CONFIG_DIR/config.yaml" 2>/dev/null; then
USES_CH341=1
fi
fi
if [ "$USES_CH341" -eq 0 ] && ! ls /dev/spidev* >/dev/null 2>&1; then
# SPI devices not found, check if we're on a Raspberry Pi and can enable it
CONFIG_FILE=""
if [ -f "/boot/firmware/config.txt" ]; then
@@ -226,26 +255,37 @@ install_repeater() {
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# Installation progress
(
echo "0"; echo "# Creating service user..."
echo ""
echo "═══════════════════════════════════════════════════════════════"
echo " Installing pyMC Repeater"
echo "═══════════════════════════════════════════════════════════════"
echo ""
echo ">>> Creating service user..."
if ! id "$SERVICE_USER" &>/dev/null; then
useradd --system --home /var/lib/pymc_repeater --shell /sbin/nologin "$SERVICE_USER"
fi
echo "10"; echo "# Adding user to hardware groups..."
usermod -a -G gpio,i2c,spi "$SERVICE_USER" 2>/dev/null || true
usermod -a -G dialout "$SERVICE_USER" 2>/dev/null || true
for grp in plugdev dialout gpio i2c spi; do
getent group "$grp" >/dev/null 2>&1 && usermod -a -G "$grp" "$SERVICE_USER" 2>/dev/null || true
done
echo "20"; echo "# Creating directories..."
mkdir -p "$INSTALL_DIR" "$CONFIG_DIR" "$LOG_DIR" /var/lib/pymc_repeater
echo "25"; echo "# Installing system dependencies..."
apt-get update -qq
apt-get install -y libffi-dev jq pip python3-rrdtool wget swig build-essential python3-dev
DEBIAN_FRONTEND=noninteractive apt-get install -y libffi-dev libusb-1.0-0 sudo jq pip python3-rrdtool wget swig build-essential python3-dev
# Install polkit (package name varies by distro version)
DEBIAN_FRONTEND=noninteractive apt-get install -y policykit-1 2>/dev/null \
|| DEBIAN_FRONTEND=noninteractive apt-get install -y polkitd pkexec 2>/dev/null \
|| echo " Warning: Could not install polkit (sudo fallback will be used)"
pip install --break-system-packages setuptools_scm >/dev/null 2>&1 || true
# Install mikefarah yq v4 if not already installed
if ! command -v yq &> /dev/null || [[ "$(yq --version 2>&1)" != *"mikefarah/yq"* ]]; then
echo ">>> Installing yq..."
YQ_VERSION="v4.40.5"
YQ_BINARY="yq_linux_arm64"
if [[ "$(uname -m)" == "x86_64" ]]; then
@@ -253,18 +293,17 @@ install_repeater() {
elif [[ "$(uname -m)" == "armv7"* ]]; then
YQ_BINARY="yq_linux_arm"
fi
wget -qO /usr/local/bin/yq "https://github.com/mikefarah/yq/releases/download/${YQ_VERSION}/${YQ_BINARY}" && chmod +x /usr/local/bin/yq
wget -qO /usr/local/bin/yq "https://github.com/mikefarah/yq/releases/download/${YQ_VERSION}/${YQ_BINARY}" 2>/dev/null && chmod +x /usr/local/bin/yq
fi
echo "28"; echo "# Generating version file..."
cd "$SCRIPT_DIR"
# Generate version file using setuptools_scm before copying
if [ -d .git ]; then
git fetch --tags 2>/dev/null || true
git fetch --tags >/dev/null 2>&1 || true
# Write the version file that will be copied
GENERATED_VERSION=$(python3 -m setuptools_scm 2>&1 || echo "unknown (setuptools_scm not available)")
python3 -c "from setuptools_scm import get_version; get_version(write_to='repeater/_version.py')" 2>&1 || echo " Warning: Could not generate _version.py file"
echo " Generated version: $GENERATED_VERSION"
python3 -m setuptools_scm >/dev/null 2>&1 || true
python3 -c "from setuptools_scm import get_version; get_version(write_to='repeater/_version.py')" >/dev/null 2>&1 || true
fi
# Clean up stale bytecode in source directory before copying
@@ -297,6 +336,13 @@ install_repeater() {
cp "$SCRIPT_DIR/pymc-repeater.service" /etc/systemd/system/
systemctl daemon-reload
echo "58"; echo "# Installing udev rules for CH341..."
if [ -f "$SCRIPT_DIR/../pyMC_core/99-ch341.rules" ]; then
cp "$SCRIPT_DIR/../pyMC_core/99-ch341.rules" /etc/udev/rules.d/99-ch341.rules
udevadm control --reload-rules 2>/dev/null || true
udevadm trigger 2>/dev/null || true
fi
echo "65"; echo "# Setting permissions..."
chown -R "$SERVICE_USER:$SERVICE_USER" "$INSTALL_DIR" "$CONFIG_DIR" "$LOG_DIR" /var/lib/pymc_repeater
chmod 750 "$CONFIG_DIR" "$LOG_DIR" /var/lib/pymc_repeater
@@ -307,6 +353,7 @@ install_repeater() {
chown -R "$SERVICE_USER:$SERVICE_USER" /var/lib/pymc_repeater/.config
# Configure polkit for passwordless service restart
echo ">>> Configuring polkit for service management..."
mkdir -p /etc/polkit-1/rules.d
cat > /etc/polkit-1/rules.d/10-pymc-repeater.rules <<'EOF'
polkit.addRule(function(action, subject) {
@@ -319,6 +366,15 @@ polkit.addRule(function(action, subject) {
EOF
chmod 0644 /etc/polkit-1/rules.d/10-pymc-repeater.rules
# Also configure sudoers as fallback for service restart
echo ">>> Configuring sudoers for service management..."
mkdir -p /etc/sudoers.d
cat > /etc/sudoers.d/pymc-repeater <<'EOF'
# Allow repeater user to manage the pymc-repeater service without password
repeater ALL=(root) NOPASSWD: /usr/bin/systemctl restart pymc-repeater, /usr/bin/systemctl stop pymc-repeater, /usr/bin/systemctl start pymc-repeater, /usr/bin/systemctl status pymc-repeater
EOF
chmod 0440 /etc/sudoers.d/pymc-repeater
echo "75"; echo "# Starting service..."
systemctl enable "$SERVICE_NAME"
@@ -394,6 +450,23 @@ EOF
echo " • Set admin password"
echo " 3. Log in to your configured repeater"
echo ""
# Container detection: warn about host-side udev rules
if [ -f /run/host/container-manager ] || [ -n "${container:-}" ] || grep -qsai 'container=' /proc/1/environ 2>/dev/null || [ -f /.dockerenv ]; then
echo "═══════════════════════════════════════════════════════════════"
echo " ⚠ CONTAINER ENVIRONMENT DETECTED"
echo "═══════════════════════════════════════════════════════════════"
echo ""
echo " USB device udev rules do NOT work inside containers."
echo " You MUST install the CH341 udev rule on the HOST machine:"
echo ""
echo " echo 'SUBSYSTEM==\"usb\", ATTR{idVendor}==\"1a86\", ATTR{idProduct}==\"5512\", MODE=\"0666\"' \\"
echo " | sudo tee /etc/udev/rules.d/99-ch341.rules"
echo " sudo udevadm control --reload-rules"
echo " sudo udevadm trigger --subsystem-match=usb --action=change"
echo ""
echo " Then unplug and replug the CH341 USB adapter."
echo ""
fi
echo "═══════════════════════════════════════════════════════════════"
echo ""
read -p "Press Enter to return to main menu..." || true
@@ -472,17 +545,29 @@ reset_repeater() {
# Upgrade function
upgrade_repeater() {
local silent="${1:-false}"
if [ "$EUID" -ne 0 ]; then
show_error "Upgrade requires root privileges.\n\nPlease run: sudo $0"
return
if [[ "$silent" == "true" ]]; then
echo "Upgrade requires root privileges. Please run: sudo $0 upgrade"
else
show_error "Upgrade requires root privileges.\n\nPlease run: sudo $0"
fi
return 1
fi
local current_version=$(get_version)
if ask_yes_no "Confirm Upgrade" "Current version: $current_version\n\nThis will upgrade pyMC Repeater while preserving your configuration.\n\nContinue?"; then
if [[ "$silent" != "true" ]]; then
if ! ask_yes_no "Confirm Upgrade" "Current version: $current_version\n\nThis will upgrade pyMC Repeater while preserving your configuration.\n\nContinue?"; then
return 0
fi
# Show info that upgrade is starting
show_info "Upgrading" "Starting upgrade process...\n\nThis may take a few minutes.\nProgress will be shown in the terminal."
else
echo "Starting upgrade process..."
echo "Current version: $current_version"
fi
echo "=== Upgrade Progress ==="
echo "[1/9] Stopping service..."
@@ -497,7 +582,11 @@ upgrade_repeater() {
echo "[3/9] Updating system dependencies..."
apt-get update -qq
apt-get install -y libffi-dev jq pip python3-rrdtool wget swig build-essential python3-dev
apt-get install -y libffi-dev libusb-1.0-0 sudo jq pip python3-rrdtool wget swig build-essential python3-dev
# Install polkit (package name varies by distro version)
apt-get install -y policykit-1 2>/dev/null \
|| apt-get install -y polkitd pkexec 2>/dev/null \
|| echo " Warning: Could not install polkit (sudo fallback will be used)"
pip install --break-system-packages setuptools_scm >/dev/null 2>&1 || true
# Install mikefarah yq v4 if not already installed
@@ -553,6 +642,22 @@ upgrade_repeater() {
echo " ⚠ Configuration validation failed, keeping existing config"
fi
echo "[5.5/9] Ensuring user groups and udev rules..."
for grp in plugdev dialout gpio i2c spi; do
getent group "$grp" >/dev/null 2>&1 && usermod -a -G "$grp" "$SERVICE_USER" 2>/dev/null || true
done
# Install/update CH341 udev rules
SCRIPT_DIR_UPGRADE="$(cd "$(dirname "$0")" && pwd)"
if [ -f "$SCRIPT_DIR_UPGRADE/../pyMC_core/99-ch341.rules" ]; then
cp "$SCRIPT_DIR_UPGRADE/../pyMC_core/99-ch341.rules" /etc/udev/rules.d/99-ch341.rules
udevadm control --reload-rules 2>/dev/null || true
udevadm trigger 2>/dev/null || true
echo " ✓ CH341 udev rules updated"
elif [ -f /etc/udev/rules.d/99-ch341.rules ]; then
echo " ✓ CH341 udev rules already present"
fi
echo " ✓ User groups updated"
echo "[6/9] Fixing permissions..."
chown -R "$SERVICE_USER:$SERVICE_USER" "$INSTALL_DIR" "$CONFIG_DIR" "$LOG_DIR" /var/lib/pymc_repeater 2>/dev/null || true
chmod 750 "$CONFIG_DIR" "$LOG_DIR" 2>/dev/null || true
@@ -572,6 +677,13 @@ polkit.addRule(function(action, subject) {
});
EOF
chmod 0644 /etc/polkit-1/rules.d/10-pymc-repeater.rules
# Also configure sudoers as fallback for service restart
mkdir -p /etc/sudoers.d
cat > /etc/sudoers.d/pymc-repeater <<'EOF'
# Allow repeater user to manage the pymc-repeater service without password
repeater ALL=(root) NOPASSWD: /usr/bin/systemctl restart pymc-repeater, /usr/bin/systemctl stop pymc-repeater, /usr/bin/systemctl start pymc-repeater, /usr/bin/systemctl status pymc-repeater
EOF
chmod 0440 /etc/sudoers.d/pymc-repeater
echo " ✓ Permissions updated"
echo "[7/9] Reloading systemd..."
@@ -632,13 +744,33 @@ EOF
if is_running; then
echo " ✓ Service is running"
show_info "Upgrade Complete" "Upgrade completed successfully!\n\nVersion: $current_version$new_version\n\n✓ Service is running\n✓ Configuration preserved"
# Container detection: warn about host-side udev rules
local container_note=""
if [ -f /run/host/container-manager ] || [ -n "${container:-}" ] || grep -qsai 'container=' /proc/1/environ 2>/dev/null || [ -f /.dockerenv ]; then
container_note="\n\n⚠ CONTAINER DETECTED:\nUSB udev rules must be set on the HOST, not here.\nSee documentation for CH341 host-side setup."
fi
if [[ "$silent" == "true" ]]; then
echo "Upgrade completed successfully!"
echo "Version: $current_version -> $new_version"
echo "✓ Service is running"
echo "✓ Configuration preserved"
if [[ -n "$container_note" ]]; then
echo "$container_note"
fi
else
show_info "Upgrade Complete" "Upgrade completed successfully!\n\nVersion: $current_version$new_version\n\n✓ Service is running\n✓ Configuration preserved${container_note}"
fi
else
echo " ✗ Service failed to start"
show_error "Upgrade completed but service failed to start!\n\nVersion updated: $current_version$new_version\n\nCheck logs from the main menu for details."
if [[ "$silent" == "true" ]]; then
echo "Upgrade completed but service failed to start!"
echo "Version updated: $current_version -> $new_version"
echo "Check logs from the main menu for details."
else
show_error "Upgrade completed but service failed to start!\n\nVersion updated: $current_version$new_version\n\nCheck logs from the main menu for details."
fi
fi
echo "=== Upgrade Complete ==="
fi
}
# Radio Configuration function
@@ -689,8 +821,13 @@ uninstall_repeater() {
fi
if ask_yes_no "Confirm Uninstall" "This will completely remove pyMC Repeater including:\n\n- Service and files\n- Configuration (backup will be created)\n- Logs and data\n\nThis action cannot be undone!\n\nContinue?"; then
(
echo "0"; echo "# Stopping and disabling service..."
echo ""
echo "═══════════════════════════════════════════════════════════════"
echo " Uninstalling pyMC Repeater"
echo "═══════════════════════════════════════════════════════════════"
echo ""
echo ">>> Stopping and disabling service..."
systemctl stop "$SERVICE_NAME" 2>/dev/null || true
systemctl disable "$SERVICE_NAME" 2>/dev/null || true
@@ -703,6 +840,10 @@ uninstall_repeater() {
rm -f /etc/systemd/system/pymc-repeater.service
systemctl daemon-reload
echo "50"; echo "# Removing polkit and sudoers rules..."
rm -f /etc/polkit-1/rules.d/10-pymc-repeater.rules
rm -f /etc/sudoers.d/pymc-repeater
echo "60"; echo "# Removing installation..."
rm -rf "$INSTALL_DIR"
rm -rf "$CONFIG_DIR"
@@ -724,15 +865,24 @@ uninstall_repeater() {
# Service management
manage_service() {
local action=$1
local silent="${2:-false}"
if [ "$EUID" -ne 0 ]; then
show_error "Service management requires root privileges.\n\nPlease run: sudo $0"
return
if [[ "$silent" == "true" ]]; then
echo "Service management requires root privileges. Please run: sudo $0 $action"
else
show_error "Service management requires root privileges.\n\nPlease run: sudo $0"
fi
return 1
fi
if ! service_exists; then
show_error "Service is not installed."
return
if [[ "$silent" == "true" ]]; then
echo "Service is not installed."
else
show_error "Service is not installed."
fi
return 1
fi
case $action in
@@ -742,21 +892,43 @@ manage_service() {
fi
systemctl start "$SERVICE_NAME"
if is_running; then
show_info "Service Started" "\n✓ pyMC Repeater service has been started successfully."
if [[ "$silent" == "true" ]]; then
echo "✓ pyMC Repeater service has been started successfully."
else
show_info "Service Started" "\n✓ pyMC Repeater service has been started successfully."
fi
else
show_error "Failed to start service!\n\nCheck logs for details."
if [[ "$silent" == "true" ]]; then
echo "Failed to start service!"
echo "Check logs for details."
else
show_error "Failed to start service!\n\nCheck logs for details."
fi
fi
;;
"stop")
systemctl stop "$SERVICE_NAME"
show_info "Service Stopped" "\n✓ pyMC Repeater service has been stopped."
if [[ "$silent" == "true" ]]; then
echo "✓ pyMC Repeater service has been stopped."
else
show_info "Service Stopped" "\n✓ pyMC Repeater service has been stopped."
fi
;;
"restart")
systemctl restart "$SERVICE_NAME"
if is_running; then
show_info "Service Restarted" "\n✓ pyMC Repeater service has been restarted successfully."
if [[ "$silent" == "true" ]]; then
echo "✓ pyMC Repeater service has been restarted successfully."
else
show_info "Service Restarted" "\n✓ pyMC Repeater service has been restarted successfully."
fi
else
show_error "Failed to restart service!\n\nCheck logs for details."
if [[ "$silent" == "true" ]]; then
echo "Failed to restart service!"
echo "Check logs for details."
else
show_error "Failed to restart service!\n\nCheck logs for details."
fi
fi
;;
esac
@@ -849,7 +1021,14 @@ validate_and_update_config() {
# - Adds missing keys from the left operand (example config)
local temp_merged="${config_file}.merged"
if "$YQ_CMD" eval-all '. as $item ireduce ({}; . * $item)' "$updated_example" "$config_file" > "$temp_merged" 2>/dev/null; then
# Strip comments from user config before merge to prevent comment accumulation.
# yq preserves comments from both files, so each upgrade cycle would duplicate
# the header and inline comments. We keep only the example's comments.
local stripped_user="${config_file}.stripped"
"$YQ_CMD" eval '... comments=""' "$config_file" > "$stripped_user" 2>/dev/null || cp "$config_file" "$stripped_user"
if "$YQ_CMD" eval-all '. as $item ireduce ({}; . * $item)' "$updated_example" "$stripped_user" > "$temp_merged" 2>/dev/null; then
rm -f "$stripped_user"
# Verify the merged file is valid YAML
if "$YQ_CMD" eval '.' "$temp_merged" > /dev/null 2>&1; then
mv "$temp_merged" "$config_file"
@@ -864,7 +1043,7 @@ validate_and_update_config() {
fi
else
echo " ✗ Config merge failed, keeping original"
rm -f "$temp_merged"
rm -f "$temp_merged" "$stripped_user"
return 1
fi
}
@@ -877,12 +1056,12 @@ if [ "$1" = "--help" ] || [ "$1" = "-h" ]; then
echo ""
echo "Actions:"
echo " install - Install pyMC Repeater"
echo " upgrade - Upgrade existing installation"
echo " upgrade - Upgrade existing installation (CLI is silent by default; use --interactive to show dialogs)"
echo " uninstall - Remove pyMC Repeater"
echo " config - Configure radio settings"
echo " start - Start the service"
echo " stop - Stop the service"
echo " restart - Restart the service"
echo " start - Start the service (CLI is silent by default; use --interactive to show dialogs)"
echo " stop - Stop the service (CLI is silent by default; use --interactive to show dialogs)"
echo " restart - Restart the service (CLI is silent by default; use --interactive to show dialogs)"
echo " logs - View live logs"
echo " status - Show status"
echo " debug - Show debug information"
@@ -914,7 +1093,11 @@ case "$1" in
exit 0
;;
"upgrade")
upgrade_repeater
silent_mode="true"
if is_interactive_flag "${2:-}" || [[ "$SILENT_MODE" == "0" || "$SILENT_MODE" == "false" ]]; then
silent_mode="false"
fi
upgrade_repeater "$silent_mode"
exit 0
;;
"uninstall")
@@ -926,14 +1109,21 @@ case "$1" in
exit 0
;;
"start"|"stop"|"restart")
manage_service "$1"
silent_mode="true"
if is_interactive_flag "${2:-}" || [[ "$SILENT_MODE" == "0" || "$SILENT_MODE" == "false" ]]; then
silent_mode="false"
fi
manage_service "$1" "$silent_mode"
exit 0
;;
"logs")
clear
echo "=== Live Logs (Press Ctrl+C to return) ==="
echo -e "\033[1;36m╔══════════════════════════════════════════════════════════════════════╗\033[0m"
echo -e "\033[1;36m║\033[0m \033[1;37mpyMC Repeater - Live Logs\033[0m \033[1;36m║\033[0m"
echo -e "\033[1;36m║\033[0m \033[0;90m(Press Ctrl+C to return)\033[0m \033[1;36m║\033[0m"
echo -e "\033[1;36m╚══════════════════════════════════════════════════════════════════════╝\033[0m"
echo ""
journalctl -u "$SERVICE_NAME" -f
journalctl -u "$SERVICE_NAME" -f -o cat --no-hostname | sed -e 's/.*ERROR.*/\x1b[1;31m&\x1b[0m/' -e 's/.*CRITICAL.*/\x1b[1;41;37m&\x1b[0m/' -e 's/.*WARNING.*/\x1b[1;33m&\x1b[0m/' -e 's/.*INFO.*/\x1b[0;32m&\x1b[0m/' -e 's/.*DEBUG.*/\x1b[0;36m&\x1b[0m/'
;;
"status")
show_detailed_status
+2 -2
View File
@@ -28,9 +28,9 @@ StandardOutput=journal
StandardError=journal
SyslogIdentifier=pymc-repeater
# Security (relaxed for proper operation)
NoNewPrivileges=true
# Security (relaxed for service self-restart via sudo)
ReadWritePaths=/var/log/pymc_repeater /var/lib/pymc_repeater /etc/pymc_repeater
SupplementaryGroups=plugdev dialout
[Install]
WantedBy=multi-user.target
+8
View File
@@ -25,6 +25,14 @@
"bandwidth": "250",
"coding_rate": "5"
},
{
"title": "Australia: NSW (Wide)",
"description": "915.800MHz / SF11 / BW250 / CR5",
"frequency": "915.800",
"spreading_factor": "11",
"bandwidth": "250",
"coding_rate": "5"
},
{
"title": "Australia (Narrow)",
"description": "916.575MHz / SF7 / BW62.5 / CR8",
+77 -18
View File
@@ -48,24 +48,8 @@
"use_dio3_tcxo": true,
"use_dio2_rf": true
},
"pimesh-1w-usa": {
"name": "PiMesh-1W (USA)",
"bus_id": 0,
"cs_id": 0,
"cs_pin": 21,
"reset_pin": 18,
"busy_pin": 20,
"irq_pin": 16,
"txen_pin": 13,
"rxen_pin": 12,
"txled_pin": -1,
"rxled_pin": -1,
"tx_power": 30,
"use_dio3_tcxo": true,
"preamble_length": 17
},
"pimesh-1w-uk": {
"name": "PiMesh-1W (UK)",
"pimesh-1w-v1": {
"name": "PiMesh-1W (V1)",
"bus_id": 0,
"cs_id": 0,
"cs_pin": 21,
@@ -80,6 +64,24 @@
"use_dio3_tcxo": true,
"preamble_length": 17
},
"pimesh-1w-v2": {
"name": "PiMesh-1W (V2)",
"bus_id": 0,
"cs_id": 0,
"cs_pin": -1,
"reset_pin": 18,
"busy_pin": 5,
"irq_pin": 6,
"txen_pin": -1,
"rxen_pin": -1,
"txled_pin": -1,
"rxled_pin": -1,
"en_pin": 26,
"tx_power": 22,
"use_dio3_tcxo": true,
"use_dio2_rf": true,
"preamble_length": 17
},
"meshadv-mini": {
"name": "MeshAdv Mini",
"bus_id": 0,
@@ -146,6 +148,24 @@
"use_dio3_tcxo": true,
"preamble_length": 17
},
"femtofox-2W-SX": {
"name": "FemtoFox SX1262 (2W)",
"bus_id": 0,
"cs_id": 0,
"cs_pin": 16,
"gpio_chip": 1,
"use_gpiod_backend": true,
"reset_pin": 25,
"busy_pin": 22,
"irq_pin": 23,
"txen_pin": -1,
"rxen_pin": 24,
"txled_pin": -1,
"rxled_pin": -1,
"tx_power": 8,
"use_dio2_rf": true,
"use_dio3_tcxo": true
},
"nebrahat": {
"name": "NebraHat-2W",
"bus_id": 0,
@@ -185,6 +205,45 @@
"dio3_tcxo_voltage": 1.8,
"preamble_length": 17,
"is_waveshare": false
},
"ultrapeater-e22": {
"name": "Zindello Industries UltraPeater E22",
"bus_id": 0,
"cs_id": 0,
"cs_pin": 16,
"reset_pin": 22,
"busy_pin": 11,
"irq_pin": 10,
"txen_pin": 20,
"rxen_pin": 21,
"txled_pin": 8,
"rxled_pin": 1,
"tx_power": 22,
"use_dio2_rf": false,
"use_dio3_tcxo": true,
"preamble_length": 17,
"use_gpiod_backend": true,
"gpio_chip": 1
},
"ultrapeater-e22p": {
"name": "Zindello Industries UltraPeater E22P",
"bus_id": 0,
"cs_id": 0,
"cs_pin": 16,
"reset_pin": 22,
"busy_pin": 11,
"irq_pin": 10,
"txen_pin": 20,
"rxen_pin": -1,
"en_pin": 21,
"txled_pin": 8,
"rxled_pin": 1,
"tx_power": 22,
"use_dio2_rf": false,
"use_dio3_tcxo": true,
"preamble_length": 17,
"use_gpiod_backend": true,
"gpio_chip": 1
}
}
}
+14 -8
View File
@@ -156,13 +156,18 @@ def update_global_flood_policy(allow: bool, config_path: Optional[str] = None) -
def _load_or_create_identity_key(path: Optional[str] = None) -> bytes:
if path is None:
# Follow XDG spec
xdg_config_home = os.environ.get("XDG_CONFIG_HOME")
if xdg_config_home:
config_dir = Path(xdg_config_home) / "pymc_repeater"
# Check system-wide location first (matches config.yaml location)
system_key_path = Path("/etc/pymc_repeater/identity.key")
if system_key_path.exists():
key_path = system_key_path
else:
config_dir = Path.home() / ".config" / "pymc_repeater"
key_path = config_dir / "identity.key"
# Follow XDG spec
xdg_config_home = os.environ.get("XDG_CONFIG_HOME")
if xdg_config_home:
config_dir = Path(xdg_config_home) / "pymc_repeater"
else:
config_dir = Path.home() / ".config" / "pymc_repeater"
key_path = config_dir / "identity.key"
else:
key_path = Path(path)
@@ -173,8 +178,8 @@ def _load_or_create_identity_key(path: Optional[str] = None) -> bytes:
with open(key_path, "rb") as f:
encoded = f.read()
key = base64.b64decode(encoded)
if len(key) != 32:
raise ValueError(f"Invalid key length: {len(key)}, expected 32")
if len(key) not in (32, 64):
raise ValueError(f"Invalid key length: {len(key)}, expected 32 or 64")
logger.info(f"Loaded existing identity key from {key_path}")
return key
except Exception as e:
@@ -249,6 +254,7 @@ def get_radio_for_board(board_config: dict):
"rxen_pin": _parse_int(spi_config["rxen_pin"]),
"txled_pin": _parse_int(spi_config.get("txled_pin", -1), default=-1),
"rxled_pin": _parse_int(spi_config.get("rxled_pin", -1), default=-1),
"en_pin": _parse_int(spi_config.get("en_pin", -1), default=-1),
"use_dio3_tcxo": spi_config.get("use_dio3_tcxo", False),
"dio3_tcxo_voltage": float(spi_config.get("dio3_tcxo_voltage", 1.8)),
"use_dio2_rf": spi_config.get("use_dio2_rf", False),
+7
View File
@@ -94,6 +94,13 @@ class ConfigManager:
self.daemon.repeater_handler.reload_runtime_config()
logger.info("Reloaded RepeaterHandler runtime config")
# Also reload advert_helper config if repeater section changed
if self.daemon and hasattr(self.daemon, 'advert_helper') and self.daemon.advert_helper:
if 'repeater' in sections:
if hasattr(self.daemon.advert_helper, 'reload_config'):
self.daemon.advert_helper.reload_config()
logger.info("Reloaded AdvertHelper config")
return True
except Exception as e:
+66 -2
View File
@@ -83,6 +83,16 @@ class SQLiteHandler:
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS crc_errors (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp REAL NOT NULL,
count INTEGER NOT NULL DEFAULT 1
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS transport_keys (
@@ -126,6 +136,9 @@ class SQLiteHandler:
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_noise_timestamp ON noise_floor(timestamp)"
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_crc_errors_timestamp ON crc_errors(timestamp)"
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_transport_keys_name ON transport_keys(name)"
)
@@ -683,6 +696,54 @@ class SQLiteHandler:
except Exception as e:
logger.error(f"Failed to store noise floor in SQLite: {e}")
def store_crc_errors(self, record: dict):
"""Store a CRC error batch (delta count since last poll)."""
try:
with sqlite3.connect(self.sqlite_path) as conn:
conn.execute("""
INSERT INTO crc_errors (timestamp, count)
VALUES (?, ?)
""", (
record.get("timestamp", time.time()),
record.get("count", 1)
))
except Exception as e:
logger.error(f"Failed to store CRC errors in SQLite: {e}")
def get_crc_error_count(self, hours: int = 24) -> int:
"""Return total CRC errors within the given time window."""
try:
cutoff = time.time() - (hours * 3600)
with sqlite3.connect(self.sqlite_path) as conn:
row = conn.execute(
"SELECT COALESCE(SUM(count), 0) FROM crc_errors WHERE timestamp > ?",
(cutoff,)
).fetchone()
return row[0] if row else 0
except Exception as e:
logger.error(f"Failed to get CRC error count: {e}")
return 0
def get_crc_error_history(self, hours: int = 24, limit: int = None) -> list:
"""Return CRC error records within the given time window (chronological)."""
try:
cutoff = time.time() - (hours * 3600)
with sqlite3.connect(self.sqlite_path) as conn:
conn.row_factory = sqlite3.Row
query = """
SELECT timestamp, count
FROM crc_errors
WHERE timestamp > ?
ORDER BY timestamp DESC
"""
if limit:
query += f" LIMIT {int(limit)}"
rows = conn.execute(query, (cutoff,)).fetchall()
return [{"timestamp": r["timestamp"], "count": r["count"]} for r in reversed(rows)]
except Exception as e:
logger.error(f"Failed to get CRC error history: {e}")
return []
def get_packet_stats(self, hours: int = 24) -> dict:
try:
cutoff = time.time() - (hours * 3600)
@@ -1101,11 +1162,14 @@ class SQLiteHandler:
result = conn.execute("DELETE FROM noise_floor WHERE timestamp < ?", (cutoff,))
noise_deleted = result.rowcount
result = conn.execute("DELETE FROM crc_errors WHERE timestamp < ?", (cutoff,))
crc_deleted = result.rowcount
conn.commit()
if packets_deleted > 0 or adverts_deleted > 0 or noise_deleted > 0:
if packets_deleted > 0 or adverts_deleted > 0 or noise_deleted > 0 or crc_deleted > 0:
logger.info(
f"Cleaned up {packets_deleted} old packets, {adverts_deleted} old adverts, {noise_deleted} old noise measurements"
f"Cleaned up {packets_deleted} old packets, {adverts_deleted} old adverts, {noise_deleted} old noise measurements, {crc_deleted} old CRC error records"
)
except Exception as e:
@@ -216,6 +216,18 @@ class StorageCollector:
self.sqlite_handler.store_noise_floor(noise_record)
self.mqtt_handler.publish(noise_record, "noise_floor")
def record_crc_errors(self, count: int):
"""Record a batch of CRC errors detected since last poll."""
crc_record = {"timestamp": time.time(), "count": count}
self.sqlite_handler.store_crc_errors(crc_record)
self.mqtt_handler.publish(crc_record, "crc_errors")
def get_crc_error_count(self, hours: int = 24) -> int:
return self.sqlite_handler.get_crc_error_count(hours)
def get_crc_error_history(self, hours: int = 24, limit: int = None) -> list:
return self.sqlite_handler.get_crc_error_history(hours, limit)
def get_packet_stats(self, hours: int = 24) -> dict:
return self.sqlite_handler.get_packet_stats(hours)
+95 -4
View File
@@ -28,6 +28,20 @@ logger = logging.getLogger("RepeaterHandler")
NOISE_FLOOR_INTERVAL = 30.0 # seconds
LOOP_DETECT_OFF = "off"
LOOP_DETECT_MINIMAL = "minimal"
LOOP_DETECT_MODERATE = "moderate"
LOOP_DETECT_STRICT = "strict"
# Thresholds for 1-byte path hashes loop detection.
# Count how many times our own hash already exists in the incoming FLOOD path.
# If occurrences >= threshold, treat as loop and drop.
LOOP_DETECT_MAX_COUNTERS = {
LOOP_DETECT_MINIMAL: 4,
LOOP_DETECT_MODERATE: 2,
LOOP_DETECT_STRICT: 1,
}
class RepeaterHandler(BaseHandler):
@@ -57,6 +71,9 @@ class RepeaterHandler(BaseHandler):
"send_advert_interval_hours", 10
)
self.last_advert_time = time.time()
self.loop_detect_mode = self._normalize_loop_detect_mode(
config.get("mesh", {}).get("loop_detect", LOOP_DETECT_OFF)
)
radio = dispatcher.radio if dispatcher else None
if radio:
@@ -97,6 +114,7 @@ class RepeaterHandler(BaseHandler):
self.last_noise_measurement = time.time()
self.noise_floor_interval = NOISE_FLOOR_INTERVAL # 30 seconds
self._background_task = None
self._last_crc_error_count = 0 # Track radio counter for delta persistence
# Cache transport keys for efficient lookup
self._transport_keys_cache = None
@@ -456,6 +474,34 @@ class RepeaterHandler(BaseHandler):
return True, ""
def _normalize_loop_detect_mode(self, mode) -> str:
if isinstance(mode, str):
normalized = mode.strip().lower()
if normalized in {
LOOP_DETECT_OFF,
LOOP_DETECT_MINIMAL,
LOOP_DETECT_MODERATE,
LOOP_DETECT_STRICT,
}:
return normalized
return LOOP_DETECT_OFF
def _get_loop_detect_mode(self) -> str:
return self.loop_detect_mode
def _is_flood_looped(self, packet: Packet, mode: Optional[str] = None) -> bool:
mode = mode or self._get_loop_detect_mode()
if mode == LOOP_DETECT_OFF:
return False
max_counter = LOOP_DETECT_MAX_COUNTERS.get(mode)
if max_counter is None:
return False
path = packet.path or bytearray()
local_count = sum(1 for hop in path if hop == self.local_hash)
return local_count >= max_counter
def _check_transport_codes(self, packet: Packet) -> Tuple[bool, str]:
if not self.storage:
@@ -576,10 +622,17 @@ class RepeaterHandler(BaseHandler):
packet.drop_reason = "Global flood policy disabled"
return None
mode = self._get_loop_detect_mode()
if self._is_flood_looped(packet, mode):
packet.drop_reason = f"FLOOD loop detected ({mode})"
return None
# Suppress duplicates
if self.is_duplicate(packet):
packet.drop_reason = "Duplicate"
return None
self.mark_seen(packet)
if packet.path is None:
packet.path = bytearray()
@@ -598,12 +651,22 @@ class RepeaterHandler(BaseHandler):
packet.path.extend(self.local_hash_bytes[:hash_size])
packet.path_len = PathUtils.encode_path_len(hash_size, hop_count + 1)
self.mark_seen(packet)
return packet
def direct_forward(self, packet: Packet) -> Optional[Packet]:
# Validate packet (empty payload, oversized path, etc.)
valid, reason = self.validate_packet(packet)
if not valid:
packet.drop_reason = reason
return None
# Check if packet is marked do-not-retransmit
if packet.is_marked_do_not_retransmit():
if not packet.drop_reason:
packet.drop_reason = "Marked do not retransmit"
return None
hash_size = packet.get_path_hash_size()
hop_count = packet.get_path_hash_count()
@@ -622,13 +685,13 @@ class RepeaterHandler(BaseHandler):
packet.drop_reason = "Duplicate"
return None
self.mark_seen(packet)
original_path = list(packet.path)
# Remove first hash entry (hash_size bytes)
packet.path = bytearray(packet.path[hash_size:])
packet.path_len = PathUtils.encode_path_len(hash_size, hop_count - 1)
self.mark_seen(packet)
return packet
@staticmethod
@@ -798,6 +861,10 @@ class RepeaterHandler(BaseHandler):
# Get current noise floor from radio
noise_floor_dbm = self.get_noise_floor()
# Get CRC error count from radio hardware
radio = self.dispatcher.radio if self.dispatcher else None
crc_error_count = getattr(radio, "crc_error_count", 0) if radio else 0
# Get neighbors from database
neighbors = self.storage.get_neighbors() if self.storage else {}
@@ -814,6 +881,7 @@ class RepeaterHandler(BaseHandler):
"neighbors": neighbors,
"uptime_seconds": uptime_seconds,
"noise_floor_dbm": noise_floor_dbm,
"crc_error_count": crc_error_count,
# Add configuration data
"config": {
"node_name": repeater_config.get("node_name", "Unknown"),
@@ -828,6 +896,9 @@ class RepeaterHandler(BaseHandler):
"longitude": repeater_config.get("longitude", 0.0),
"max_flood_hops": repeater_config.get("max_flood_hops", 3),
"advert_interval_minutes": repeater_config.get("advert_interval_minutes", 120),
"advert_rate_limit": repeater_config.get("advert_rate_limit", {}),
"advert_penalty_box": repeater_config.get("advert_penalty_box", {}),
"advert_adaptive": repeater_config.get("advert_adaptive", {}),
},
"radio": self.config.get(
"radio", {}
@@ -862,6 +933,7 @@ class RepeaterHandler(BaseHandler):
# Check noise floor recording (every 30 seconds)
if current_time - self.last_noise_measurement >= self.noise_floor_interval:
await self._record_noise_floor_async()
await self._record_crc_errors_async()
self.last_noise_measurement = current_time
# Check advert sending (every N hours)
@@ -900,6 +972,22 @@ class RepeaterHandler(BaseHandler):
except Exception as e:
logger.error(f"Error recording noise floor: {e}")
async def _record_crc_errors_async(self):
"""Persist CRC error delta from the radio hardware counter."""
if not self.storage:
return
try:
radio = self.dispatcher.radio if self.dispatcher else None
current = getattr(radio, "crc_error_count", 0) if radio else 0
delta = current - self._last_crc_error_count
if delta > 0:
self.storage.record_crc_errors(delta)
logger.debug(f"Recorded {delta} CRC errors (total: {current})")
self._last_crc_error_count = current
except Exception as e:
logger.error(f"Error recording CRC errors: {e}")
async def _send_periodic_advert_async(self):
logger.info(
f"Periodic advert timer triggered (interval: {self.send_advert_interval_hours}h)"
@@ -931,6 +1019,9 @@ class RepeaterHandler(BaseHandler):
self.score_threshold = repeater_config.get("score_threshold", 0.3)
self.send_advert_interval_hours = repeater_config.get("send_advert_interval_hours", 10)
self.cache_ttl = repeater_config.get("cache_ttl", 60)
self.loop_detect_mode = self._normalize_loop_detect_mode(
self.config.get("mesh", {}).get("loop_detect", LOOP_DETECT_OFF)
)
# Note: Radio config changes require restart as they affect hardware
# Note: Airtime manager has its own config reference that gets updated
+509 -2
View File
@@ -2,21 +2,41 @@
Advertisement packet handling helper for pyMC Repeater.
This module processes advertisement packets for neighbor tracking and discovery.
Includes adaptive rate limiting based on mesh activity.
"""
import asyncio
import logging
import time
from enum import Enum
from typing import Dict, Optional, Tuple
from pymc_core.node.handlers.advert import AdvertHandler
logger = logging.getLogger("AdvertHelper")
class MeshActivityTier(Enum):
"""Mesh activity levels for adaptive rate limiting."""
QUIET = "quiet"
NORMAL = "normal"
BUSY = "busy"
CONGESTED = "congested"
# Tier multipliers for rate limit scaling
TIER_MULTIPLIERS = {
MeshActivityTier.QUIET: 0.0, # No rate limiting
MeshActivityTier.NORMAL: 0.5, # Light limiting
MeshActivityTier.BUSY: 1.0, # Standard limiting
MeshActivityTier.CONGESTED: 2.0, # Aggressive limiting
}
class AdvertHelper:
"""Helper class for processing advertisement packets in the repeater."""
def __init__(self, local_identity, storage, log_fn=None):
def __init__(self, local_identity, storage, config=None, log_fn=None):
"""
Initialize the advert helper.
@@ -27,6 +47,7 @@ class AdvertHelper:
"""
self.local_identity = local_identity
self.storage = storage
self.config = config or {}
# Create AdvertHandler internally as a parsing utility
self.advert_handler = AdvertHandler(log_fn=log_fn or logger.info)
@@ -34,6 +55,421 @@ class AdvertHelper:
# Cache for tracking known neighbors (avoid repeated database queries)
self._known_neighbors = set()
repeater_cfg = self.config.get("repeater", {})
# --- Adaptive mode config ---
adaptive_cfg = repeater_cfg.get("advert_adaptive", {})
self._adaptive_enabled = bool(adaptive_cfg.get("enabled", True))
self._ewma_alpha = max(0.01, min(1.0, float(adaptive_cfg.get("ewma_alpha", 0.1))))
self._tier_hysteresis_seconds = max(0.0, float(adaptive_cfg.get("hysteresis_seconds", 300.0)))
# Tier thresholds (packets per minute)
thresholds = adaptive_cfg.get("thresholds", {})
self._threshold_normal = float(thresholds.get("normal", 1.0))
self._threshold_busy = float(thresholds.get("busy", 5.0))
self._threshold_congested = float(thresholds.get("congested", 15.0))
# --- Base rate limit config (scaled by tier) ---
rate_cfg = repeater_cfg.get("advert_rate_limit", {})
self._rate_limit_enabled = bool(rate_cfg.get("enabled", True))
self._base_bucket_capacity = max(1.0, float(rate_cfg.get("bucket_capacity", 2)))
self._base_refill_tokens = max(0.1, float(rate_cfg.get("refill_tokens", 1.0)))
self._base_refill_interval = max(1.0, float(rate_cfg.get("refill_interval_seconds", 36000.0)))
self._base_min_interval = max(0.0, float(rate_cfg.get("min_interval_seconds", 3600.0)))
# --- Penalty box config ---
penalty_cfg = repeater_cfg.get("advert_penalty_box", {})
self._penalty_enabled = bool(penalty_cfg.get("enabled", True))
self._penalty_violation_threshold = max(1, int(penalty_cfg.get("violation_threshold", 2)))
self._penalty_decay_seconds = max(1.0, float(penalty_cfg.get("violation_decay_seconds", 43200.0)))
self._penalty_base_seconds = max(1.0, float(penalty_cfg.get("base_penalty_seconds", 21600.0)))
self._penalty_multiplier = max(1.0, float(penalty_cfg.get("penalty_multiplier", 2.0)))
self._penalty_max_seconds = max(
self._penalty_base_seconds,
float(penalty_cfg.get("max_penalty_seconds", 86400.0)),
)
# --- Per-pubkey state ---
self._bucket_state: Dict[str, dict] = {}
self._penalty_until: Dict[str, float] = {}
self._violation_state: Dict[str, dict] = {}
# --- Adaptive metrics state ---
self._adverts_ewma = 0.0 # EWMA of adverts per minute
self._packets_ewma = 0.0 # EWMA of total packets per minute
self._duplicates_ewma = 0.0 # EWMA of duplicate ratio
self._last_metrics_update = time.time()
self._metrics_window_seconds = 60.0
self._adverts_in_window = 0
self._packets_in_window = 0
self._duplicates_in_window = 0
# Current activity tier with hysteresis
self._current_tier = MeshActivityTier.NORMAL
self._tier_since = time.time()
self._pending_tier: Optional[MeshActivityTier] = None
self._pending_tier_since = 0.0
# Stats counters
self._stats_adverts_allowed = 0
self._stats_adverts_dropped = 0
self._stats_tier_changes = 0
# Recent drops tracking (keep last 20)
self._recent_drops = []
self._max_recent_drops = 20
# Memory management
self._last_cleanup = time.time()
self._cleanup_interval_seconds = 3600.0 # Clean up every hour
self._bucket_state_retention_seconds = 604800.0 # Keep inactive pubkeys for 7 days
self._max_tracked_pubkeys = 10000 # Hard limit on tracked pubkeys
logger.info(
f"Advert limiter: adaptive={self._adaptive_enabled}, "
f"rate_limit={self._rate_limit_enabled}, "
f"bucket={self._base_bucket_capacity:.1f}, "
f"penalty={self._penalty_enabled}"
)
# -------------------------------------------------------------------------
# Memory management
# -------------------------------------------------------------------------
def _cleanup_old_state(self, now: float) -> None:
"""Clean up old/expired entries to prevent unbounded memory growth."""
# 1. Remove expired penalties
expired_penalties = [pk for pk, until in self._penalty_until.items() if until < now]
for pk in expired_penalties:
del self._penalty_until[pk]
# 2. Remove old bucket states for inactive pubkeys
inactive_pubkeys = [
pk for pk, state in self._bucket_state.items()
if now - state.get("last_seen", 0) > self._bucket_state_retention_seconds
]
for pk in inactive_pubkeys:
del self._bucket_state[pk]
# Also clean up related violation state
if pk in self._violation_state:
del self._violation_state[pk]
# 3. Decay old violations based on decay time
for pk, vstate in list(self._violation_state.items()):
last_violation = vstate.get("last_violation", 0)
if now - last_violation > self._penalty_decay_seconds:
# Reset violation count after decay period
vstate["count"] = 0
# 4. Hard limit: if we're tracking too many pubkeys, remove oldest inactive ones
if len(self._bucket_state) > self._max_tracked_pubkeys:
# Sort by last_seen and remove oldest 10%
sorted_pubkeys = sorted(
self._bucket_state.items(),
key=lambda x: x[1].get("last_seen", 0)
)
to_remove = int(len(sorted_pubkeys) * 0.1)
for pk, _ in sorted_pubkeys[:to_remove]:
del self._bucket_state[pk]
if pk in self._violation_state:
del self._violation_state[pk]
if pk in self._penalty_until:
del self._penalty_until[pk]
# 5. Limit known neighbors set to prevent unbounded growth
if len(self._known_neighbors) > 1000:
# Clear the oldest half (simple approach - could be more sophisticated)
self._known_neighbors = set(list(self._known_neighbors)[500:])
if expired_penalties or inactive_pubkeys:
logger.debug(
f"Cleaned up {len(expired_penalties)} expired penalties, "
f"{len(inactive_pubkeys)} inactive pubkeys. "
f"Tracking: {len(self._bucket_state)} buckets, "
f"{len(self._penalty_until)} penalties, "
f"{len(self._known_neighbors)} neighbors"
)
# -------------------------------------------------------------------------
# Adaptive tier calculation
# -------------------------------------------------------------------------
def _update_metrics_window(self, now: float, is_advert: bool = True, is_duplicate: bool = False) -> None:
"""Update rolling metrics window and EWMA."""
elapsed = now - self._last_metrics_update
if elapsed >= self._metrics_window_seconds:
# Calculate rates for window
adverts_per_min = (self._adverts_in_window / elapsed) * 60.0
packets_per_min = (self._packets_in_window / elapsed) * 60.0
dup_ratio = (
self._duplicates_in_window / max(1, self._packets_in_window)
)
# Update EWMA
alpha = self._ewma_alpha
self._adverts_ewma = alpha * adverts_per_min + (1 - alpha) * self._adverts_ewma
self._packets_ewma = alpha * packets_per_min + (1 - alpha) * self._packets_ewma
self._duplicates_ewma = alpha * dup_ratio + (1 - alpha) * self._duplicates_ewma
# Reset window
self._adverts_in_window = 0
self._packets_in_window = 0
self._duplicates_in_window = 0
self._last_metrics_update = now
# Periodic cleanup
if now - self._last_cleanup >= self._cleanup_interval_seconds:
self._cleanup_old_state(now)
self._last_cleanup = now
# Count this event
if is_advert:
self._adverts_in_window += 1
self._packets_in_window += 1
if is_duplicate:
self._duplicates_in_window += 1
def _calculate_target_tier(self) -> MeshActivityTier:
"""Determine target tier based on current EWMA metrics."""
# Combined activity score (adverts + packets weighted)
activity = self._adverts_ewma + (self._packets_ewma * 0.1)
if activity >= self._threshold_congested:
return MeshActivityTier.CONGESTED
elif activity >= self._threshold_busy:
return MeshActivityTier.BUSY
elif activity >= self._threshold_normal:
return MeshActivityTier.NORMAL
else:
return MeshActivityTier.QUIET
def _update_tier(self, now: float) -> None:
"""Update current tier with hysteresis to prevent flapping."""
if not self._adaptive_enabled:
return
target = self._calculate_target_tier()
if target == self._current_tier:
# Stable, clear pending
self._pending_tier = None
return
if self._pending_tier != target:
# New pending tier
self._pending_tier = target
self._pending_tier_since = now
return
# Check hysteresis
if (now - self._pending_tier_since) >= self._tier_hysteresis_seconds:
old_tier = self._current_tier
self._current_tier = target
self._tier_since = now
self._pending_tier = None
self._stats_tier_changes += 1
logger.info(f"Mesh activity tier changed: {old_tier.value}{target.value}")
def get_current_tier(self) -> MeshActivityTier:
"""Get current mesh activity tier."""
return self._current_tier
def _get_effective_limits(self) -> Tuple[float, float, float, float]:
"""Get effective rate limits scaled by current tier."""
if not self._adaptive_enabled:
return (
self._base_bucket_capacity,
self._base_refill_tokens,
self._base_refill_interval,
self._base_min_interval,
)
multiplier = TIER_MULTIPLIERS.get(self._current_tier, 1.0)
if multiplier == 0.0:
# QUIET mode: effectively disable rate limiting
return (100.0, 100.0, 1.0, 0.0)
# Scale intervals UP (stricter) as multiplier increases
return (
self._base_bucket_capacity,
self._base_refill_tokens,
self._base_refill_interval * multiplier,
self._base_min_interval * multiplier,
)
def _refill_tokens_if_needed(self, pubkey: str, now: float) -> dict:
"""Refill token bucket using effective (tier-scaled) limits."""
bucket_cap, refill_tokens, refill_interval, _ = self._get_effective_limits()
state = self._bucket_state.get(pubkey)
if state is None:
state = {
"tokens": bucket_cap,
"last_refill": now,
"last_seen": 0.0,
}
self._bucket_state[pubkey] = state
return state
elapsed = now - state["last_refill"]
if elapsed <= 0:
return state
refill_steps = elapsed / refill_interval
if refill_steps > 0:
state["tokens"] = min(
bucket_cap,
state["tokens"] + (refill_steps * refill_tokens),
)
state["last_refill"] = now
return state
def _record_violation_and_maybe_penalize(self, pubkey: str, now: float) -> None:
if not self._penalty_enabled:
return
state = self._violation_state.get(pubkey)
if state is None:
state = {"count": 0, "last_violation": 0.0}
self._violation_state[pubkey] = state
if (now - state["last_violation"]) > self._penalty_decay_seconds:
state["count"] = 0
state["count"] += 1
state["last_violation"] = now
if state["count"] < self._penalty_violation_threshold:
return
level = state["count"] - self._penalty_violation_threshold
penalty_seconds = min(
self._penalty_max_seconds,
self._penalty_base_seconds * (self._penalty_multiplier**level),
)
new_until = now + penalty_seconds
old_until = self._penalty_until.get(pubkey, 0.0)
if new_until > old_until:
self._penalty_until[pubkey] = new_until
logger.warning(
f"Advert penalty activated for {pubkey[:16]}... "
f"({penalty_seconds:.1f}s, violations={state['count']})"
)
def _allow_advert(self, pubkey: str, now: float) -> Tuple[bool, str]:
"""Check if advert is allowed using adaptive tier-scaled limits."""
# Update metrics and tier
self._update_metrics_window(now, is_advert=True)
self._update_tier(now)
if not self._rate_limit_enabled:
self._stats_adverts_allowed += 1
return True, ""
# QUIET tier bypasses rate limiting
if self._adaptive_enabled and self._current_tier == MeshActivityTier.QUIET:
self._stats_adverts_allowed += 1
return True, ""
penalty_until = self._penalty_until.get(pubkey, 0.0)
if now < penalty_until:
remaining = penalty_until - now
self._stats_adverts_dropped += 1
return False, f"advert penalty box active ({remaining:.1f}s remaining)"
state = self._refill_tokens_if_needed(pubkey, now)
_, _, _, min_interval = self._get_effective_limits()
last_seen = float(state.get("last_seen", 0.0))
if min_interval > 0 and last_seen > 0:
since_last = now - last_seen
if since_last < min_interval:
self._record_violation_and_maybe_penalize(pubkey, now)
self._stats_adverts_dropped += 1
return (
False,
f"advert min-interval hit ({since_last:.2f}s < {min_interval:.2f}s)",
)
if state["tokens"] < 1.0:
self._record_violation_and_maybe_penalize(pubkey, now)
self._stats_adverts_dropped += 1
return False, "advert rate limit exceeded"
state["tokens"] -= 1.0
state["last_seen"] = now
self._stats_adverts_allowed += 1
return True, ""
def record_packet_seen(self, is_duplicate: bool = False) -> None:
"""Record a packet seen for metrics (called by router for non-advert packets)."""
now = time.time()
self._update_metrics_window(now, is_advert=False, is_duplicate=is_duplicate)
def get_rate_limit_stats(self) -> dict:
"""Get comprehensive rate limiting and adaptive tier statistics."""
now = time.time()
bucket_cap, refill_tokens, refill_interval, min_interval = self._get_effective_limits()
# Active penalties
active_penalties = {
pk[:16]: round(until - now, 1)
for pk, until in self._penalty_until.items()
if until > now
}
# Per-pubkey bucket states
bucket_summary = {}
for pk, state in self._bucket_state.items():
bucket_summary[pk[:16]] = {
"tokens": round(state["tokens"], 2),
"last_seen_ago": round(now - state["last_seen"], 1) if state["last_seen"] > 0 else None,
}
return {
"adaptive": {
"enabled": self._adaptive_enabled,
"current_tier": self._current_tier.value,
"tier_since": round(now - self._tier_since, 1),
"pending_tier": self._pending_tier.value if self._pending_tier else None,
"tier_changes": self._stats_tier_changes,
},
"metrics": {
"adverts_per_min_ewma": round(self._adverts_ewma, 2),
"packets_per_min_ewma": round(self._packets_ewma, 2),
"duplicate_ratio_ewma": round(self._duplicates_ewma, 3),
},
"effective_limits": {
"bucket_capacity": bucket_cap,
"refill_tokens": refill_tokens,
"refill_interval_seconds": round(refill_interval, 1),
"min_interval_seconds": round(min_interval, 1),
},
"stats": {
"adverts_allowed": self._stats_adverts_allowed,
"adverts_dropped": self._stats_adverts_dropped,
"drop_rate": round(
self._stats_adverts_dropped / max(1, self._stats_adverts_allowed + self._stats_adverts_dropped),
3,
),
},
"active_penalties": active_penalties,
"tracked_pubkeys": len(self._bucket_state),
"bucket_states": bucket_summary,
"recent_drops": [
{
"pubkey": drop["pubkey"],
"name": drop["name"],
"reason": drop["reason"],
"seconds_ago": round(now - drop["timestamp"], 1)
}
for drop in reversed(self._recent_drops) # Most recent first
],
}
async def process_advert_packet(self, packet, rssi: int, snr: float) -> None:
"""
Process an incoming advertisement packet.
@@ -64,6 +500,34 @@ class AdvertHelper:
pubkey = advert_data["public_key"]
node_name = advert_data["name"]
contact_type = advert_data["contact_type"]
# Per-pubkey rate limiting (token bucket + penalty box)
now = time.time()
allowed, reason = self._allow_advert(pubkey, now)
if not allowed:
logger.warning(f"Dropping advert from '{node_name}' ({pubkey[:16]}...): {reason}")
packet.mark_do_not_retransmit()
packet.drop_reason = reason
# Track recent drop (deduplicate by pubkey)
pubkey_short = pubkey[:16]
# Remove any existing entry for this pubkey
self._recent_drops = [d for d in self._recent_drops if d["pubkey"] != pubkey_short]
# Add the new drop entry
self._recent_drops.append({
"pubkey": pubkey_short,
"name": node_name,
"reason": reason,
"timestamp": now
})
# Keep only last N drops
if len(self._recent_drops) > self._max_recent_drops:
self._recent_drops.pop(0)
return
# Skip our own adverts
if self.local_identity:
@@ -78,7 +542,7 @@ class AdvertHelper:
route_type = packet.header & PH_ROUTE_MASK
# Check if this is a new neighbor (run DB read in thread to avoid blocking event loop)
current_time = time.time()
current_time = now
if pubkey not in self._known_neighbors:
# Only check database if not in cache
if self.storage:
@@ -129,3 +593,46 @@ class AdvertHelper:
except Exception as e:
logger.error(f"Error processing advert packet: {e}", exc_info=True)
def reload_config(self) -> None:
"""Reload rate limiting configuration from self.config (called after live config updates)."""
try:
repeater_cfg = self.config.get("repeater", {})
# Adaptive mode config
adaptive_cfg = repeater_cfg.get("advert_adaptive", {})
self._adaptive_enabled = bool(adaptive_cfg.get("enabled", True))
self._ewma_alpha = max(0.01, min(1.0, float(adaptive_cfg.get("ewma_alpha", 0.1))))
self._tier_hysteresis_seconds = max(0.0, float(adaptive_cfg.get("hysteresis_seconds", 300.0)))
thresholds = adaptive_cfg.get("thresholds", {})
self._threshold_normal = float(thresholds.get("normal", 1.0))
self._threshold_busy = float(thresholds.get("busy", 5.0))
self._threshold_congested = float(thresholds.get("congested", 15.0))
# Base rate limit config
rate_cfg = repeater_cfg.get("advert_rate_limit", {})
self._rate_limit_enabled = bool(rate_cfg.get("enabled", True))
self._base_bucket_capacity = max(1.0, float(rate_cfg.get("bucket_capacity", 2)))
self._base_refill_tokens = max(0.1, float(rate_cfg.get("refill_tokens", 1.0)))
self._base_refill_interval = max(1.0, float(rate_cfg.get("refill_interval_seconds", 36000.0)))
self._base_min_interval = max(0.0, float(rate_cfg.get("min_interval_seconds", 3600.0)))
# Penalty box config
penalty_cfg = repeater_cfg.get("advert_penalty_box", {})
self._penalty_enabled = bool(penalty_cfg.get("enabled", True))
self._penalty_violation_threshold = max(1, int(penalty_cfg.get("violation_threshold", 2)))
self._penalty_decay_seconds = max(1.0, float(penalty_cfg.get("violation_decay_seconds", 43200.0)))
self._penalty_base_seconds = max(1.0, float(penalty_cfg.get("base_penalty_seconds", 21600.0)))
self._penalty_multiplier = max(1.0, float(penalty_cfg.get("penalty_multiplier", 2.0)))
self._penalty_max_seconds = max(
self._penalty_base_seconds,
float(penalty_cfg.get("max_penalty_seconds", 86400.0)),
)
logger.info(
f"Advert limiter config reloaded: adaptive={self._adaptive_enabled}, "
f"rate_limit={self._rate_limit_enabled}, bucket={self._base_bucket_capacity:.1f}"
)
except Exception as e:
logger.error(f"Error reloading advert limiter config: {e}")
+19
View File
@@ -163,6 +163,7 @@ class RepeaterDaemon:
self.advert_helper = AdvertHelper(
local_identity=self.local_identity,
storage=self.repeater_handler.storage if self.repeater_handler else None,
config=self.config,
log_fn=logger.info,
)
logger.info("Advert processing helper initialized")
@@ -931,10 +932,28 @@ class RepeaterDaemon:
except Exception as e:
logger.debug(f"CH341 reset skipped/failed: {e}")
@staticmethod
def _detect_container() -> bool:
"""Detect if running inside an LXC/Docker/systemd-nspawn container."""
try:
with open("/proc/1/environ", "rb") as f:
if b"container=" in f.read():
return True
except (OSError, PermissionError):
pass
return os.path.exists("/run/host/container-manager")
async def run(self):
logger.info("Repeater daemon started")
# Warn if running inside a container (udev rules won't work here)
if os.path.exists("/.dockerenv") or os.environ.get("container") or self._detect_container():
logger.warning(
"Container environment detected. "
"USB device udev rules must be configured on the HOST, not inside this container."
)
try:
await self.initialize()
+38 -7
View File
@@ -14,31 +14,62 @@ def restart_service() -> Tuple[bool, str]:
"""
Restart the pymc-repeater service via systemctl.
Uses polkit for authentication (requires proper polkit rules configured).
NoNewPrivileges systemd flag prevents sudo from working.
Tries polkit-based restart first (plain systemctl), then falls back
to sudo-based restart (requires sudoers.d rule installed by manage.sh).
Returns:
Tuple[bool, str]: (success, message)
"""
# Try polkit-based restart first (works on bare metal / VMs with polkit running)
try:
result = subprocess.run(
["systemctl", "restart", "pymc-repeater"], capture_output=True, text=True, timeout=5
)
if result.returncode == 0:
logger.info("Service restart command executed successfully")
logger.info("Service restart via polkit succeeded")
return True, "Service restart initiated"
stderr = result.stderr or ""
if "Access denied" in stderr or "authorization" in stderr.lower():
logger.info("Polkit denied restart, trying sudo fallback...")
else:
error_msg = result.stderr or "Unknown error"
logger.error(f"Service restart failed: {error_msg}")
return False, f"Restart failed: {error_msg}"
# Some other error, still try sudo
logger.warning(f"systemctl restart failed ({result.returncode}): {stderr.strip()}")
except subprocess.TimeoutExpired:
# Timeout likely means it's restarting - that's success
logger.warning("Service restart command timed out (service may be restarting)")
return True, "Service restart initiated (timeout - likely restarting)"
except FileNotFoundError:
logger.error("systemctl not found")
return False, "systemctl not available"
except Exception as e:
logger.error(f"Error executing restart command: {e}")
logger.warning(f"Polkit restart attempt failed: {e}")
# Fallback: use sudo (requires /etc/sudoers.d/pymc-repeater rule)
try:
result = subprocess.run(
['sudo', '--non-interactive', 'systemctl', 'restart', 'pymc-repeater'],
capture_output=True,
text=True,
timeout=5
)
if result.returncode == 0:
logger.info("Service restart via sudo succeeded")
return True, "Service restart initiated"
else:
error_msg = result.stderr or "Unknown error"
logger.error(f"Service restart via sudo failed: {error_msg}")
return False, f"Restart failed: {error_msg}"
except subprocess.TimeoutExpired:
logger.warning("Sudo restart timed out (service likely restarting)")
return True, "Service restart initiated (timeout - likely restarting)"
except FileNotFoundError:
logger.error("sudo not found - cannot restart service")
return False, "Neither polkit nor sudo available for service restart"
except Exception as e:
logger.error(f"Error executing sudo restart: {e}")
return False, f"Restart command failed: {str(e)}"
+224 -2
View File
@@ -47,6 +47,7 @@ logger = logging.getLogger("HTTPServer")
# POST /api/set_duty_cycle {"enabled": true|false} - Enable/disable duty cycle
# POST /api/update_duty_cycle_config {"enabled": true, "on_time": 300, "off_time": 60} - Update duty cycle config
# POST /api/update_radio_config - Update radio configuration
# POST /api/update_advert_rate_limit_config - Update advert rate limiting settings
# Packets
# GET /api/packet_stats?hours=24 - Get packet statistics
@@ -75,6 +76,7 @@ logger = logging.getLogger("HTTPServer")
# Adverts & Contacts
# GET /api/adverts_by_contact_type?contact_type=X&limit=100&hours=24 - Get adverts by contact type
# GET /api/advert?advert_id=123 - Get specific advert
# GET /api/advert_rate_limit_stats - Get advert rate limiting and adaptive tier stats
# Transport Keys
# GET /api/transport_keys - List all transport keys
@@ -525,8 +527,8 @@ class APIEndpoints:
time.sleep(2) # Give time for response to be sent
try:
# Use systemctl without sudo - polkit rules allow the repeater user to restart the service
subprocess.run(["systemctl", "restart", "pymc-repeater"], check=False)
from repeater.service_utils import restart_service
restart_service()
except Exception as e:
logger.error(f"Failed to restart service: {e}")
@@ -728,6 +730,170 @@ class APIEndpoints:
logger.error(f"Error updating duty cycle config: {e}")
return self._error(str(e))
@cherrypy.expose
@cherrypy.tools.json_out()
@cherrypy.tools.json_in()
def update_advert_rate_limit_config(self):
"""Update advert rate limiting configuration using ConfigManager.
POST /api/update_advert_rate_limit_config
Body: {
"rate_limit_enabled": true,
"bucket_capacity": 2,
"refill_tokens": 1,
"refill_interval_seconds": 36000,
"min_interval_seconds": 3600,
"penalty_enabled": true,
"violation_threshold": 2,
"violation_decay_seconds": 43200,
"base_penalty_seconds": 21600,
"penalty_multiplier": 2.0,
"max_penalty_seconds": 86400,
"adaptive_enabled": true,
"ewma_alpha": 0.1,
"hysteresis_seconds": 300,
"quiet_max": 0.05,
"normal_max": 0.20,
"busy_max": 0.50
}
"""
self._set_cors_headers()
if cherrypy.request.method == "OPTIONS":
return ""
try:
self._require_post()
data = cherrypy.request.json or {}
applied = []
# Ensure config sections exist
if "repeater" not in self.config:
self.config["repeater"] = {}
if "advert_rate_limit" not in self.config["repeater"]:
self.config["repeater"]["advert_rate_limit"] = {}
if "advert_penalty_box" not in self.config["repeater"]:
self.config["repeater"]["advert_penalty_box"] = {}
if "advert_adaptive" not in self.config["repeater"]:
self.config["repeater"]["advert_adaptive"] = {"thresholds": {}}
rate_cfg = self.config["repeater"]["advert_rate_limit"]
penalty_cfg = self.config["repeater"]["advert_penalty_box"]
adaptive_cfg = self.config["repeater"]["advert_adaptive"]
# Rate limit settings
if "rate_limit_enabled" in data:
rate_cfg["enabled"] = bool(data["rate_limit_enabled"])
applied.append(f"rate_limit={'enabled' if rate_cfg['enabled'] else 'disabled'}")
if "bucket_capacity" in data:
cap = max(1, int(data["bucket_capacity"]))
rate_cfg["bucket_capacity"] = cap
applied.append(f"bucket_capacity={cap}")
if "refill_tokens" in data:
tokens = max(1, int(data["refill_tokens"]))
rate_cfg["refill_tokens"] = tokens
applied.append(f"refill_tokens={tokens}")
if "refill_interval_seconds" in data:
interval = max(60, int(data["refill_interval_seconds"]))
rate_cfg["refill_interval_seconds"] = interval
applied.append(f"refill_interval={interval}s")
if "min_interval_seconds" in data:
min_int = max(0, int(data["min_interval_seconds"]))
rate_cfg["min_interval_seconds"] = min_int
applied.append(f"min_interval={min_int}s")
# Penalty box settings
if "penalty_enabled" in data:
penalty_cfg["enabled"] = bool(data["penalty_enabled"])
applied.append(f"penalty={'enabled' if penalty_cfg['enabled'] else 'disabled'}")
if "violation_threshold" in data:
thresh = max(1, int(data["violation_threshold"]))
penalty_cfg["violation_threshold"] = thresh
applied.append(f"violation_threshold={thresh}")
if "violation_decay_seconds" in data:
decay = max(60, int(data["violation_decay_seconds"]))
penalty_cfg["violation_decay_seconds"] = decay
applied.append(f"violation_decay={decay}s")
if "base_penalty_seconds" in data:
base = max(60, int(data["base_penalty_seconds"]))
penalty_cfg["base_penalty_seconds"] = base
applied.append(f"base_penalty={base}s")
if "penalty_multiplier" in data:
mult = max(1.0, float(data["penalty_multiplier"]))
penalty_cfg["penalty_multiplier"] = mult
applied.append(f"penalty_multiplier={mult}")
if "max_penalty_seconds" in data:
max_pen = max(60, int(data["max_penalty_seconds"]))
penalty_cfg["max_penalty_seconds"] = max_pen
applied.append(f"max_penalty={max_pen}s")
# Adaptive settings
if "adaptive_enabled" in data:
adaptive_cfg["enabled"] = bool(data["adaptive_enabled"])
applied.append(f"adaptive={'enabled' if adaptive_cfg['enabled'] else 'disabled'}")
if "ewma_alpha" in data:
alpha = max(0.01, min(1.0, float(data["ewma_alpha"])))
adaptive_cfg["ewma_alpha"] = alpha
applied.append(f"ewma_alpha={alpha}")
if "hysteresis_seconds" in data:
hyst = max(0, int(data["hysteresis_seconds"]))
adaptive_cfg["hysteresis_seconds"] = hyst
applied.append(f"hysteresis={hyst}s")
# Adaptive thresholds
if "thresholds" not in adaptive_cfg:
adaptive_cfg["thresholds"] = {}
if "quiet_max" in data:
adaptive_cfg["thresholds"]["quiet_max"] = float(data["quiet_max"])
applied.append(f"quiet_max={data['quiet_max']}")
if "normal_max" in data:
adaptive_cfg["thresholds"]["normal_max"] = float(data["normal_max"])
applied.append(f"normal_max={data['normal_max']}")
if "busy_max" in data:
adaptive_cfg["thresholds"]["busy_max"] = float(data["busy_max"])
applied.append(f"busy_max={data['busy_max']}")
if not applied:
return self._error("No valid settings provided")
# Save to config file and live update daemon
result = self.config_manager.update_and_save(
updates={},
live_update=True,
live_update_sections=['repeater']
)
logger.info(f"Advert rate limit config updated: {', '.join(applied)}")
return self._success({
"applied": applied,
"persisted": result.get("saved", False),
"live_update": result.get("live_updated", False),
"restart_required": False,
"message": "Advert rate limit settings applied immediately."
})
except cherrypy.HTTPError:
raise
except Exception as e:
logger.error(f"Error updating advert rate limit config: {e}")
return self._error(str(e))
@cherrypy.expose
@cherrypy.tools.json_out()
def check_pymc_console(self):
@@ -1511,6 +1677,40 @@ class APIEndpoints:
logger.error(f"Error fetching noise floor chart data: {e}")
return self._error(e)
@cherrypy.expose
@cherrypy.tools.json_out()
def crc_error_count(self, hours: int = 24):
"""Return total CRC errors within the given time window."""
try:
storage = self._get_storage()
hours = int(hours)
count = storage.get_crc_error_count(hours=hours)
return self._success({
"crc_error_count": count,
"hours": hours
})
except Exception as e:
logger.error(f"Error fetching CRC error count: {e}")
return self._error(e)
@cherrypy.expose
@cherrypy.tools.json_out()
def crc_error_history(self, hours: int = 24, limit: int = None):
"""Return CRC error records within the given time window."""
try:
storage = self._get_storage()
hours = int(hours)
limit = int(limit) if limit else None
history = storage.get_crc_error_history(hours=hours, limit=limit)
return self._success({
"history": history,
"hours": hours,
"count": len(history)
})
except Exception as e:
logger.error(f"Error fetching CRC error history: {e}")
return self._error(e)
@cherrypy.expose
def cad_calibration_stream(self):
cherrypy.response.headers["Content-Type"] = "text/event-stream"
@@ -1596,6 +1796,28 @@ class APIEndpoints:
logger.error(f"Error getting adverts by contact type: {e}")
return self._error(e)
@cherrypy.expose
@cherrypy.tools.json_out()
def advert_rate_limit_stats(self):
"""Get advert rate limiting statistics and adaptive tier info."""
try:
if not self.daemon_instance or not hasattr(self.daemon_instance, 'advert_helper'):
return self._error("Advert helper not available")
advert_helper = self.daemon_instance.advert_helper
if not advert_helper:
return self._error("Advert helper not initialized")
if not hasattr(advert_helper, 'get_rate_limit_stats'):
return self._error("Rate limit stats not supported by this advert helper version")
stats = advert_helper.get_rate_limit_stats()
return self._success(stats)
except Exception as e:
logger.error(f"Error getting advert rate limit stats: {e}")
return self._error(e)
@cherrypy.expose
@cherrypy.tools.json_out()
@cherrypy.tools.json_in()
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1,5 @@
<<<<<<<< HEAD:repeater/web/html/assets/ConfirmDialog.vue_vue_type_script_setup_true_lang-CVxh_fqf.js
import{a as p,b as n,g as m,e as t,s as g,t as s,j as d,p as l}from"./index-DyUIpN7m.js";const f={class:"flex items-center justify-between mb-4"},w={class:"text-xl font-semibold text-content-primary dark:text-content-primary"},v={class:"mb-6"},h={key:0,class:"w-6 h-6",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},y={key:1,class:"w-6 h-6",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},C={key:2,class:"w-6 h-6",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},B={class:"text-content-secondary dark:text-content-primary/80 text-base leading-relaxed"},j={class:"flex gap-3"},_=p({__name:"ConfirmDialog",props:{show:{type:Boolean},title:{default:"Confirm Action"},message:{},confirmText:{default:"Confirm"},cancelText:{default:"Cancel"},variant:{default:"warning"}},emits:["close","confirm"],setup(c,{emit:b}){const o=c,r=b,u=i=>{i.target===i.currentTarget&&r("close")},k={danger:"bg-red-100 dark:bg-red-500/20 border-red-500/30 text-red-600 dark:text-red-400",warning:"bg-yellow-100 dark:bg-yellow-500/20 border-yellow-500/30 text-yellow-600 dark:text-yellow-400",info:"bg-blue-500/20 border-blue-500/30 text-blue-600 dark:text-blue-400"},x={danger:"bg-red-500 hover:bg-red-600",warning:"bg-yellow-500 hover:bg-yellow-600",info:"bg-blue-500 hover:bg-blue-600"};return(i,e)=>o.show?(l(),n("div",{key:0,onClick:u,class:"fixed inset-0 bg-black/40 backdrop-blur-lg z-[99999] flex items-center justify-center p-4",style:{"backdrop-filter":"blur(8px) saturate(180%)",position:"fixed",top:"0",left:"0",right:"0",bottom:"0"}},[t("div",{class:"bg-white dark:bg-surface-elevated backdrop-blur-xl rounded-[20px] p-6 w-full max-w-md border border-stroke-subtle dark:border-white/10",onClick:e[3]||(e[3]=g(()=>{},["stop"]))},[t("div",f,[t("h3",w,s(o.title),1),t("button",{onClick:e[0]||(e[0]=a=>r("close")),class:"text-content-secondary dark:text-content-muted hover:text-content-primary dark:hover:text-content-primary transition-colors"},e[4]||(e[4]=[t("svg",{class:"w-6 h-6",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[t("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M6 18L18 6M6 6l12 12"})],-1)]))]),t("div",v,[t("div",{class:d(["inline-flex p-3 rounded-xl mb-4",k[o.variant]])},[o.variant==="danger"?(l(),n("svg",h,e[5]||(e[5]=[t("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"},null,-1)]))):o.variant==="warning"?(l(),n("svg",y,e[6]||(e[6]=[t("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"},null,-1)]))):(l(),n("svg",C,e[7]||(e[7]=[t("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"},null,-1)])))],2),t("p",B,s(o.message),1)]),t("div",j,[t("button",{onClick:e[1]||(e[1]=a=>r("close")),class:"flex-1 px-4 py-3 rounded-xl bg-background-mute dark:bg-white/5 hover:bg-stroke-subtle dark:hover:bg-white/10 text-content-primary dark:text-content-primary transition-all duration-200 border border-stroke-subtle dark:border-stroke/10"},s(o.cancelText),1),t("button",{onClick:e[2]||(e[2]=a=>r("confirm")),class:d(["flex-1 px-4 py-3 rounded-xl text-white transition-all duration-200",x[o.variant]])},s(o.confirmText),3)])])])):m("",!0)}});export{_};
========
import{a as p,b as n,g as m,e as t,s as g,t as s,j as d,p as l}from"./index-BfUIlcDy.js";const f={class:"flex items-center justify-between mb-4"},w={class:"text-xl font-semibold text-content-primary dark:text-content-primary"},v={class:"mb-6"},h={key:0,class:"w-6 h-6",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},y={key:1,class:"w-6 h-6",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},C={key:2,class:"w-6 h-6",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},B={class:"text-content-secondary dark:text-content-primary/80 text-base leading-relaxed"},j={class:"flex gap-3"},_=p({__name:"ConfirmDialog",props:{show:{type:Boolean},title:{default:"Confirm Action"},message:{},confirmText:{default:"Confirm"},cancelText:{default:"Cancel"},variant:{default:"warning"}},emits:["close","confirm"],setup(c,{emit:b}){const o=c,r=b,u=i=>{i.target===i.currentTarget&&r("close")},k={danger:"bg-red-100 dark:bg-red-500/20 border-red-500/30 text-red-600 dark:text-red-400",warning:"bg-yellow-100 dark:bg-yellow-500/20 border-yellow-500/30 text-yellow-600 dark:text-yellow-400",info:"bg-blue-500/20 border-blue-500/30 text-blue-600 dark:text-blue-400"},x={danger:"bg-red-500 hover:bg-red-600",warning:"bg-yellow-500 hover:bg-yellow-600",info:"bg-blue-500 hover:bg-blue-600"};return(i,e)=>o.show?(l(),n("div",{key:0,onClick:u,class:"fixed inset-0 bg-black/40 backdrop-blur-lg z-[99999] flex items-center justify-center p-4",style:{"backdrop-filter":"blur(8px) saturate(180%)",position:"fixed",top:"0",left:"0",right:"0",bottom:"0"}},[t("div",{class:"bg-white dark:bg-surface-elevated backdrop-blur-xl rounded-[20px] p-6 w-full max-w-md border border-stroke-subtle dark:border-white/10",onClick:e[3]||(e[3]=g(()=>{},["stop"]))},[t("div",f,[t("h3",w,s(o.title),1),t("button",{onClick:e[0]||(e[0]=a=>r("close")),class:"text-content-secondary dark:text-content-muted hover:text-content-primary dark:hover:text-content-primary transition-colors"},e[4]||(e[4]=[t("svg",{class:"w-6 h-6",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[t("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M6 18L18 6M6 6l12 12"})],-1)]))]),t("div",v,[t("div",{class:d(["inline-flex p-3 rounded-xl mb-4",k[o.variant]])},[o.variant==="danger"?(l(),n("svg",h,e[5]||(e[5]=[t("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"},null,-1)]))):o.variant==="warning"?(l(),n("svg",y,e[6]||(e[6]=[t("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"},null,-1)]))):(l(),n("svg",C,e[7]||(e[7]=[t("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"},null,-1)])))],2),t("p",B,s(o.message),1)]),t("div",j,[t("button",{onClick:e[1]||(e[1]=a=>r("close")),class:"flex-1 px-4 py-3 rounded-xl bg-background-mute dark:bg-white/5 hover:bg-stroke-subtle dark:hover:bg-white/10 text-content-primary dark:text-content-primary transition-all duration-200 border border-stroke-subtle dark:border-stroke/10"},s(o.cancelText),1),t("button",{onClick:e[2]||(e[2]=a=>r("confirm")),class:d(["flex-1 px-4 py-3 rounded-xl text-white transition-all duration-200",x[o.variant]])},s(o.confirmText),3)])])])):m("",!0)}});export{_};
>>>>>>>> upstream/feat/newRadios:repeater/web/html/assets/ConfirmDialog.vue_vue_type_script_setup_true_lang-K0cwbhb6.js
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,4 +1,8 @@
<<<<<<<< HEAD:repeater/web/html/assets/Terminal-BAoTtMQy.js
import{L as J,a as zl,r as ut,o as Hl,$ as Ul,P as Rn,D as ql,b as tt,e as Z,g as Yt,t as Is,w as Kl,v as Vl,X as Ji,j as Tn,s as jl,p as it,x as Gl}from"./index-DyUIpN7m.js";/**
========
import{L as J,a as zl,r as ut,o as Hl,$ as Ul,P as Rn,D as ql,b as tt,e as Z,g as Yt,t as Is,w as Kl,v as Vl,X as Ji,j as Tn,s as jl,p as it,x as Gl}from"./index-BfUIlcDy.js";/**
>>>>>>>> upstream/feat/newRadios:repeater/web/html/assets/Terminal-Z9bSifl0.js
* Copyright (c) 2014-2024 The xterm.js authors. All rights reserved.
* @license MIT
*
File diff suppressed because one or more lines are too long
+263
View File
@@ -0,0 +1,263 @@
#!/usr/bin/env bash
# pyMC Repeater - Proxmox LXC Installer
# Creates an LXC container with USB passthrough and installs pyMC Repeater
#
# Usage (run on the Proxmox host):
# bash -c "$(curl -fsSL https://raw.githubusercontent.com/rightup/pyMC_Repeater/main/scripts/proxmox-install.sh)"
#
# License: MIT
# Source: https://github.com/rightup/pyMC_Repeater
set -euo pipefail
# ── Defaults ───────────────────────────────────────────────────────────────
REPO="https://github.com/rightup/pyMC_Repeater.git"
BRANCH="feat/newRadios"
CT_TEMPLATE="debian-12-standard"
CT_RAM=1024
CT_SWAP=512
CT_DISK=4
CT_CORES=2
CT_HOSTNAME="pymc-repeater"
CT_BRIDGE="vmbr0"
CT_STORAGE="local-lvm"
CT_TEMPLATE_STORAGE="local"
CH341_VID="1a86"
CH341_PID="5512"
# ── Colors ─────────────────────────────────────────────────────────────────
RD="\033[01;31m" GN="\033[1;92m" YW="\033[33m" BL="\033[36m" BLD="\033[1m" CL="\033[m"
msg_info() { echo -e " ${BL}${CL} ${1}"; }
msg_ok() { echo -e " ${GN}${CL} ${1}"; }
msg_warn() { echo -e " ${YW}${CL} ${1}"; }
msg_error() { echo -e " ${RD}${CL} ${1}"; }
header() {
clear
echo -e "${BLD}"
echo "═══════════════════════════════════════════════════════════════"
echo " pyMC Repeater - Proxmox LXC Installer"
echo "═══════════════════════════════════════════════════════════════"
echo -e "${CL}"
}
cleanup() {
local exit_code=$?
if [ $exit_code -ne 0 ] && [ -n "${CTID:-}" ] && pct status "$CTID" &>/dev/null; then
echo ""
read -p " Delete the failed container ${CTID}? [y/N]: " -r
if [[ "$REPLY" =~ ^[Yy]$ ]]; then
pct stop "$CTID" 2>/dev/null || true
pct destroy "$CTID" 2>/dev/null || true
msg_ok "Container ${CTID} removed"
fi
fi
}
trap cleanup EXIT
# ── Preflight checks ──────────────────────────────────────────────────────
header
if ! command -v pct &>/dev/null; then
msg_error "This script must be run on a Proxmox VE host."
exit 1
fi
if [ "$EUID" -ne 0 ]; then
msg_error "Please run as root"
exit 1
fi
msg_ok "Running on Proxmox host as root"
# Check for CH341
echo ""
if lsusb -d "${CH341_VID}:${CH341_PID}" &>/dev/null; then
msg_ok "CH341 USB device detected"
else
msg_warn "CH341 USB device not found — plug it in before starting the repeater"
fi
# ── Interactive settings ──────────────────────────────────────────────────
echo ""
echo -e "${BLD}Container Settings${CL} (press Enter for defaults):"
echo ""
read -p " Hostname [${CT_HOSTNAME}]: " -r input; CT_HOSTNAME="${input:-$CT_HOSTNAME}"
read -p " RAM in MB [${CT_RAM}]: " -r input; CT_RAM="${input:-$CT_RAM}"
read -p " Disk in GB [${CT_DISK}]: " -r input; CT_DISK="${input:-$CT_DISK}"
read -p " CPU cores [${CT_CORES}]: " -r input; CT_CORES="${input:-$CT_CORES}"
read -p " Bridge [${CT_BRIDGE}]: " -r input; CT_BRIDGE="${input:-$CT_BRIDGE}"
AVAILABLE_STORAGES=$(pvesm status -content rootdir 2>/dev/null | awk 'NR>1 {print $1}' || echo "local-lvm")
echo " Available storages: ${AVAILABLE_STORAGES}"
read -p " Storage [${CT_STORAGE}]: " -r input; CT_STORAGE="${input:-$CT_STORAGE}"
read -p " Git branch [${BRANCH}]: " -r input; BRANCH="${input:-$BRANCH}"
read -sp " Root password [pymc]: " CT_PASSWORD; echo
CT_PASSWORD="${CT_PASSWORD:-pymc}"
# ── Get next CTID ─────────────────────────────────────────────────────────
CTID=$(pvesh get /cluster/nextid)
# ── Confirmation ──────────────────────────────────────────────────────────
echo ""
echo -e "${BLD}Summary:${CL}"
echo " CTID: ${CTID} Host: ${CT_HOSTNAME} RAM: ${CT_RAM}MB Disk: ${CT_DISK}GB"
echo " Cores: ${CT_CORES} Storage: ${CT_STORAGE} Bridge: ${CT_BRIDGE} Branch: ${BRANCH}"
echo " Mode: privileged (required for USB passthrough)"
echo ""
read -p " Proceed? [Y/n]: " -r
[[ "${REPLY:-Y}" =~ ^[Nn]$ ]] && { msg_warn "Aborted"; exit 0; }
# ── Download template ─────────────────────────────────────────────────────
echo ""
msg_info "Downloading Debian 12 template..."
TEMPLATE_FILE=$(pveam available -section system 2>/dev/null | grep "${CT_TEMPLATE}" | sort -t- -k4 -V | tail -1 | awk '{print $2}')
[ -z "$TEMPLATE_FILE" ] && { msg_error "Template not found. Run: pveam update"; exit 1; }
pveam list "$CT_TEMPLATE_STORAGE" 2>/dev/null | grep -q "$TEMPLATE_FILE" || \
pveam download "$CT_TEMPLATE_STORAGE" "$TEMPLATE_FILE"
msg_ok "Template ready"
# ── Create container ──────────────────────────────────────────────────────
msg_info "Creating LXC container ${CTID}..."
pct create "$CTID" "${CT_TEMPLATE_STORAGE}:vztmpl/${TEMPLATE_FILE}" \
--hostname "$CT_HOSTNAME" \
--memory "$CT_RAM" \
--swap "$CT_SWAP" \
--cores "$CT_CORES" \
--rootfs "${CT_STORAGE}:${CT_DISK}" \
--net0 "name=eth0,bridge=${CT_BRIDGE},ip=dhcp" \
--unprivileged 0 \
--features nesting=1 \
--onboot 1 \
--start 0 \
--password "$CT_PASSWORD" \
--ostype debian
msg_ok "Container created"
# ── USB passthrough ───────────────────────────────────────────────────────
msg_info "Configuring USB passthrough..."
cat >> "/etc/pve/lxc/${CTID}.conf" <<'EOF'
# CH341 USB passthrough for pyMC Repeater
lxc.cgroup2.devices.allow: c 189:* rwm
lxc.mount.entry: /dev/bus/usb dev/bus/usb none bind,optional,create=dir 0 0
EOF
msg_ok "USB passthrough configured"
# ── Host udev rule ────────────────────────────────────────────────────────
msg_info "Installing CH341 udev rule on host..."
echo 'SUBSYSTEM=="usb", ATTR{idVendor}=="1a86", ATTR{idProduct}=="5512", MODE="0666"' \
> /etc/udev/rules.d/99-ch341.rules
udevadm control --reload-rules
udevadm trigger --subsystem-match=usb --action=change
msg_ok "Host udev rule installed"
# ── Start container & wait for network ────────────────────────────────────
msg_info "Starting container..."
pct start "$CTID"
sleep 3
for _ in $(seq 1 30); do
pct exec "$CTID" -- ping -c1 -W1 8.8.8.8 &>/dev/null && break
sleep 1
done
msg_ok "Container running with network"
# ── Bootstrap: install git, clone repo ────────────────────────────────────
msg_info "Installing git inside container..."
pct exec "$CTID" -- bash -c "
export DEBIAN_FRONTEND=noninteractive
# Fix locale warnings
apt-get update -qq
apt-get install -y locales >/dev/null 2>&1
sed -i 's/# en_US.UTF-8/en_US.UTF-8/' /etc/locale.gen
locale-gen >/dev/null 2>&1
echo 'LANG=en_US.UTF-8' > /etc/default/locale
apt-get install -y git whiptail >/dev/null 2>&1
# Enable auto-login on console (no password prompt in Proxmox web console)
mkdir -p /etc/systemd/system/container-getty@1.service.d
cat > /etc/systemd/system/container-getty@1.service.d/override.conf <<'AUTOLOGIN'
[Service]
ExecStart=
ExecStart=-/sbin/agetty --autologin root --noclear --keep-baud tty%I 115200,38400,9600 \$TERM
AUTOLOGIN
systemctl daemon-reload
# Login banner with system info
cat > /etc/profile.d/pymc-motd.sh <<'MOTD'
#!/bin/sh
HOSTNAME=\$(hostname)
IP=\$(hostname -I | awk '{print \$1}')
OS=\$(. /etc/os-release && echo \"\$NAME\")
VER=\$(. /etc/os-release && echo \"\$VERSION_ID\")
echo \"\"
echo \" pyMC Repeater LXC Container\"
echo \" 🌐 GitHub: https://github.com/rightup/pyMC_Repeater\"
echo \"\"
echo \" 🖥️ OS: \$OS - Version: \$VER\"
echo \" 🏠 Hostname: \$HOSTNAME\"
echo \" 💡 IP Address: \$IP\"
echo \" 📡 Dashboard: http://\$IP:8000\"
echo \"\"
echo \" Management: cd /opt/pymc_repeater && bash manage.sh\"
echo \"\"
MOTD
chmod +x /etc/profile.d/pymc-motd.sh
"
msg_ok "Git installed, locale fixed, console auto-login enabled"
msg_info "Cloning pyMC_Repeater (branch: ${BRANCH})..."
pct exec "$CTID" -- bash -c "git clone --branch ${BRANCH} ${REPO} /root/pyMC_Repeater"
msg_ok "Repository cloned"
# Pre-seed config with CH341 radio type and correct GPIO pins
pct exec "$CTID" -- bash -c "
mkdir -p /etc/pymc_repeater
if [ -f /root/pyMC_Repeater/config.yaml.example ]; then
cp /root/pyMC_Repeater/config.yaml.example /etc/pymc_repeater/config.yaml
# Set radio type to CH341
sed -i 's/^radio_type: sx1262$/radio_type: sx1262_ch341/' /etc/pymc_repeater/config.yaml
# Replace Pi BCM GPIO pins with CH341 GPIO pin numbers (0-7)
sed -i 's/cs_pin: 21/cs_pin: 0/' /etc/pymc_repeater/config.yaml
sed -i 's/reset_pin: 18/reset_pin: 2/' /etc/pymc_repeater/config.yaml
sed -i 's/busy_pin: 20/busy_pin: 4/' /etc/pymc_repeater/config.yaml
sed -i 's/irq_pin: 16/irq_pin: 6/' /etc/pymc_repeater/config.yaml
sed -i 's/rxen_pin: -1/rxen_pin: 1/' /etc/pymc_repeater/config.yaml
# Enable TCXO and DIO2 RF switch for E22 module
sed -i 's/use_dio3_tcxo: false/use_dio3_tcxo: true/' /etc/pymc_repeater/config.yaml
sed -i 's/use_dio2_rf: false/use_dio2_rf: true/' /etc/pymc_repeater/config.yaml
fi
"
# ── Run manage.sh install ─────────────────────────────────────────────────
msg_info "Running manage.sh install (this will take several minutes)..."
echo ""
# Use lxc-attach with a pty so manage.sh gets an interactive terminal
lxc-attach -n "$CTID" -- bash -c "cd /root/pyMC_Repeater && TERM=xterm bash manage.sh install"
echo ""
msg_ok "manage.sh install completed"
# ── Get container IP ──────────────────────────────────────────────────────
sleep 2
CT_IP=$(pct exec "$CTID" -- hostname -I 2>/dev/null | awk '{print $1}')
# ── Done ──────────────────────────────────────────────────────────────────
echo ""
echo -e "${BLD}"
echo "═══════════════════════════════════════════════════════════════"
echo " ✓ pyMC Repeater Installation Complete!"
echo "═══════════════════════════════════════════════════════════════"
echo -e "${CL}"
echo -e " Container: ${GN}${CTID}${CL} (${CT_HOSTNAME})"
echo -e " IP Address: ${GN}${CT_IP:-unknown}${CL}"
echo -e " Dashboard: ${GN}http://${CT_IP:-<ip>}:8000${CL}"
echo ""
echo " Next: open the dashboard and complete the setup wizard"
echo " Management: pct enter ${CTID}, then: cd /opt/pymc_repeater && bash manage.sh"
echo ""
echo "═══════════════════════════════════════════════════════════════"
View File
+1305
View File
File diff suppressed because it is too large Load Diff