mirror of
https://github.com/pyMC-dev/pyMC_Repeater.git
synced 2026-08-11 03:12:51 +02:00
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:
@@ -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 |
|
||||
> | USB–SPI 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 USB–SPI 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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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"]
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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),
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)}"
|
||||
|
||||
@@ -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
@@ -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 "═══════════════════════════════════════════════════════════════"
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user