Merge branch 'feat/companion' into iplog

This commit is contained in:
Alan Barrow
2026-03-17 20:51:34 -04:00
committed by GitHub
109 changed files with 14780 additions and 3878 deletions
+9 -1
View File
@@ -34,6 +34,7 @@ debian/pymc-repeater.substvars
# Virtual environments
.venv/
.venv_new/
env/
ENV/
@@ -51,9 +52,16 @@ htmlcov/
# Config
config.yaml
config.yaml.backup
identity.json
# Data
data/
# Logs
*.log
.DS_Store
syncpi.sh
syncpi.sh
# Docker
/data
+146 -6
View File
@@ -30,6 +30,29 @@ The repeater daemon runs continuously as a background process, forwarding LoRa p
## Supported Hardware (Out of the Box)
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`.
> [!CAUTION]
> ## Compatibility
>
> ### Supported Radio Interfaces
>
> | Interface | Supported |
> |------------|------------|
> | Native SPI radio SX1262 | ✅ Yes |
> | USBSPI bridge (CH341F) | ✅ Yes |
> | UART-based HATs | ❌ No |
> | SX1302 concentrator boards | ❌ No |
> | SX1303 concentrator boards | ❌ No |
>
> This project supports **single-radio SPI transceivers only**, either:
> - Connected directly via SPI
> - Connected via a CH341F USBSPI adapter
> - Connected using hardware that supports Meshcore Kiss Modem firmware
The following hardware is currently supported out-of-the-box:
Waveshare LoRaWAN/GNSS HAT (SPI Version Only)
@@ -69,6 +92,16 @@ Frequency Labs meshadv
TX Power: Up to 22dBm
SPI Bus: SPI0
GPIO Pins: CS=21, Reset=18, Busy=20, IRQ=16, TXEN=13, RXEN=12, use_dio3_tcxo=True
HT-RA62 module
Hardware: Heltec HT-RA62 LoRa module
Platform: Raspberry Pi (or compatible single-board computer)
Frequency: 868MHz (EU) or 915MHz (US)
TX Power: Up to 22dBm
SPI Bus: SPI0
GPIO Pins: CS=21, Reset=18, Busy=20, IRQ=16, use_dio3_tcxo=True, use_dio2_rf=True
...
## Screenshots
@@ -184,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
@@ -202,6 +320,34 @@ This script will:
The script will prompt you for each optional removal step.
## Docker Compose
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. It will need some configuration changes based on what hardware you're using (USB vs SPI). Look at the commented out lines to see which hardware requires what lines and only enable what you need.
Here is what you'll need to do in order to get the container running:
1. Copy the `config.yaml.example` to `config.yaml`
```bash
cp ./config.yaml.example ./config.yaml
```
2. Run the configuration script and follow the prompts.
```bash
sudo bash ./setup-radio-config.sh
```
3. Modify the `config.yaml` file with a unique web UI password. This allows you to bypass the `/setup` page when logging for the first time. You can find the value under `repeater.security.admin_password`. Change to _anything_ besides the default of `admin123`.
4. Configure the [docker compose](./docker-compose.yml) to your specific hardware and file paths. Be sure to comment-out or delete lines that aren't required for your hardware. Please note that your hardware devices might be at a different path than those listed in the docker compose file.
5. Build and start the container.
```bash
docker compose up -d --force-recreate --build
```
## Roadmap / Planned Features
- [ ] **Public Map Integration** - Submit repeater location and details to public map for discovery
@@ -249,8 +395,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/)
@@ -276,7 +420,3 @@ This software is intended for educational and experimental purposes. Always test
## License
This project is licensed under the MIT License - see the LICENSE file for details.
+133 -33
View File
@@ -1,9 +1,15 @@
# Default Repeater Configuration
# radio_type: sx1262 | kiss (use kiss for serial KISS TNC modem)
radio_type: sx1262
repeater:
# Node name for logging and identification
node_name: "mesh-repeater-01"
# TX mode: forward | monitor | no_tx (default: forward)
# forward = repeat on; monitor = no repeat but companions/tenants can send; no_tx = all TX off
# mode: forward
# Geographic location (optional)
# Latitude in decimal degrees (-90 to 90)
latitude: 0.0
@@ -39,24 +45,71 @@ 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)
max_clients: 1
# Admin password for full access
admin_password: "admin123"
# Guest password for limited access
guest_password: "guest123"
# Allow read-only access for clients without password/not in ACL
allow_read_only: false
# JWT secret key for signing tokens (auto-generated if not provided)
# Generate with: python -c "import secrets; print(secrets.token_hex(32))"
jwt_secret: ""
# JWT token expiry time in minutes (default: 60 minutes / 1 hour)
# Controls how long users stay logged in before needing to re-authenticate
jwt_expiry_minutes: 60
@@ -68,6 +121,15 @@ mesh:
# Individual transport keys can override this setting
global_flood_allow: true
# Path hash mode for flood packets (0-hop): per-hop hash size in path encoding
# 0 = 1-byte hashes (legacy), 1 = 2-byte, 2 = 3-byte. Must match mesh convention.
# Affects originated adverts and any other flood packets sent by the repeater.
path_hash_mode: 0
# 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
@@ -79,7 +141,7 @@ identities:
# - name: "TestBBS"
# identity_key: "your_room_identity_key_hex_here"
# type: "room_server"
#
#
# # Room-specific settings
# settings:
# node_name: "Test BBS Room"
@@ -87,7 +149,6 @@ identities:
# longitude: 0.0
# admin_password: "room_admin_password"
# guest_password: "room_guest_password"
# Add more room servers as needed
# - name: "SocialHub"
# identity_key: "another_identity_key_hex_here"
@@ -98,7 +159,37 @@ identities:
# longitude: 0.0
# admin_password: "social_admin_123"
# guest_password: "social_guest_123"
# Companion Identities
# Each companion exposes the MeshCore frame protocol over TCP for standard clients.
# One TCP client per companion at a time. Clients connect to repeater-ip:tcp_port.
companions:
# - name: "RepeaterCompanion"
# identity_key: "your_companion_identity_key_hex_here"
# settings:
# node_name: "RepeaterCompanion"
# tcp_port: 5000
# bind_address: "0.0.0.0"
# tcp_timeout: 120 # seconds; default 120 when omitted; 0 = disable (no timeout)
# - name: "BotCompanion"
# identity_key: "another_companion_identity_key_hex"
# settings:
# node_name: "meshcore-bot"
# tcp_port: 5001
# tcp_timeout: 120 # seconds; default 120 when omitted; 0 = disable (no timeout)
# Radio hardware type
# Supported:
# - sx1262 (Linux spidev + system GPIO)
# - sx1262_ch341 (CH341 USB-to-SPI + CH341 GPIO 0-7)
radio_type: sx1262
# CH341 USB-to-SPI adapter settings (only used when radio_type: sx1262_ch341)
# NOTE: VID/PID are integers. Hex is also accepted in YAML, e.g. 0x1A86.
ch341:
vid: 6790 # 0x1A86
pid: 21778 # 0x5512
radio:
# Frequency in Hz (869.618 MHz for EU)
frequency: 869618000
@@ -121,19 +212,25 @@ radio:
# Sync word (LoRa network ID)
sync_word: 13380
# Enable CRC checking
crc_enabled: true
# Use implicit header mode
implicit_header: false
# KISS modem (when radio_type: kiss). Requires pyMC_core with KISS support.
# kiss:
# port: "/dev/ttyUSB0"
# baud_rate: 9600
# SX1262 Hardware Configuration
# NOTE:
# - When radio_type: sx1262, these pins are BCM GPIO numbers.
# - When radio_type: sx1262_ch341, these pins are CH341 GPIO numbers (0-7).
sx1262:
# SPI bus and chip select
# NOTE: For CH341 these are not used but are still required parameters.
bus_id: 0
cs_id: 0
# GPIO pins (BCM numbering)
# GPIO pins
cs_pin: 21
reset_pin: 18
busy_pin: 20
@@ -148,6 +245,8 @@ sx1262:
rxled_pin: -1
use_dio3_tcxo: false
dio3_tcxo_voltage: 1.8
use_dio2_rf: false
# Waveshare hardware flag
is_waveshare: false
@@ -172,39 +271,39 @@ duty_cycle:
mqtt:
# Enable/disable MQTT publishing
enabled: false
# MQTT broker settings
broker: "localhost"
port: 1883 # Use 8883 for TLS/SSL, 80/443/9001 for WebSockets
# Use WebSocket transport instead of standard TCP
# Typically uses ports: 80 (ws://), 443 (wss://), or 9001
use_websockets: false
# Authentication (optional)
username: null
password: null
# TLS/SSL configuration (optional)
# For public brokers with trusted certificates, just enable TLS:
# tls:
# enabled: true
tls:
enabled: false
# Advanced TLS options (usually not needed for public brokers):
# Custom CA certificate for server verification
# Leave null to use system default CA certificates (recommended)
ca_cert: null # e.g., "/etc/ssl/certs/ca-certificates.crt"
# Client certificate and key for mutual TLS (rarely needed)
client_cert: null # e.g., "/etc/pymc/client.crt"
client_key: null # e.g., "/etc/pymc/client.key"
# Skip certificate verification (insecure, not recommended)
insecure: false
# Base topic for publishing
# Messages will be published to: {base_topic}/{node_name}/{packet|advert}
base_topic: "meshcore/repeater"
@@ -212,36 +311,37 @@ mqtt:
# Storage Configuration
storage:
# Directory for persistent storage files (SQLite, RRD)
# Directory for persistent storage files (SQLite, RRD).
# Use a writable path for local/dev (e.g. "./var/pymc_repeater" or "~/var/pymc_repeater").
storage_dir: "/var/lib/pymc_repeater"
# Data retention settings
retention:
# Clean up SQLite records older than this many days
sqlite_cleanup_days: 31
# RRD archives are managed automatically:
# - 1 minute resolution for 1 week
# - 5 minute resolution for 1 month
# - 5 minute resolution for 1 month
# - 1 hour resolution for 1 year
letsmesh:
enabled: false
iata_code: "Test" # e.g., "SFO", "LHR", "Test"
# ============================================================
# BROKER SELECTION MODE - Choose how to connect to brokers
# ============================================================
#
#
# EXAMPLE 1: Single built-in broker (default, most common)
# Connect to Europe only - simple, low bandwidth
broker_index: 0 # 0 = Europe, 1 = US West
# EXAMPLE 2: All built-in brokers for maximum redundancy
# Survives single broker failure, best uptime
# broker_index: -1 # or null - connects to both EU and US
# EXAMPLE 3: Only custom brokers (private/self-hosted)
# Ignores built-in LetsMesh brokers completely
# broker_index: -2
@@ -250,7 +350,7 @@ letsmesh:
# host: "mqtt.myserver.com"
# port: 443
# audience: "mqtt.myserver.com"
# EXAMPLE 4: Single built-in + custom backup
# Use EU primary with your own backup
# broker_index: 0
@@ -259,7 +359,7 @@ letsmesh:
# host: "mqtt-backup.mydomain.com"
# port: 8883
# audience: "mqtt-backup.mydomain.com"
# EXAMPLE 5: All built-in + multiple custom (maximum redundancy)
# EU + US + your own servers - best for critical deployments
# broker_index: -1
@@ -273,14 +373,14 @@ letsmesh:
# port: 443
# audience: "mqtt-2.mydomain.com"
# ============================================================
status_interval: 300
owner: ""
email: ""
# Block specific packet types from being published to LetsMesh
# If not specified or empty list, all types are published
# Available types: REQ, RESPONSE, TXT_MSG, ACK, ADVERT, GRP_TXT,
# Available types: REQ, RESPONSE, TXT_MSG, ACK, ADVERT, GRP_TXT,
# GRP_DATA, ANON_REQ, PATH, TRACE, RAW_CUSTOM
disallowed_packet_types: []
# - REQ # Don't publish requests
+204 -67
View File
@@ -1,7 +1,7 @@
#!/bin/bash
# Convert MeshCore firmware 64-byte private key to pyMC_Repeater format
#
# Usage: sudo ./convert_firmware_key.sh <64-byte-hex-key> [config-path]
# Usage: sudo ./convert_firmware_key.sh <64-byte-hex-key> [--output-format=<yaml|identity>] [config-path]
# Example: sudo ./convert_firmware_key.sh 987BDA619630197351F2B3040FD19B2EE0DEE357DD69BBEEE295786FA78A4D5F298B0BF1B7DE73CBC23257CDB2C562F5033DF58C232916432948B0F6BA4448F2
set -e
@@ -9,10 +9,10 @@ set -e
if [ $# -eq 0 ]; then
echo "Error: No key provided"
echo ""
echo "Usage: sudo $0 <64-byte-hex-key> [config-path]"
echo "Usage: sudo $0 <64-byte-hex-key> [--output-format=<yaml|identity>] [config-path]"
echo ""
echo "This script imports a 64-byte MeshCore firmware private key into"
echo "pyMC_Repeater config.yaml for full identity compatibility."
echo "pyMC_Repeater for full identity compatibility."
echo ""
echo "The 64-byte key format: [32-byte scalar][32-byte nonce]"
echo " - Enables same node address as firmware device"
@@ -20,10 +20,17 @@ if [ $# -eq 0 ]; then
echo " - Fully compatible with pyMC_core LocalIdentity"
echo ""
echo "Arguments:"
echo " --output-format: Optional output format (yaml|identity, default: yaml)"
echo " yaml - Store in config.yaml (embedded binary)"
echo " identity - Save to identity.key file (base64 encoded)"
echo " config-path: Optional path to config.yaml (default: /etc/pymc_repeater/config.yaml)"
echo ""
echo "Example:"
echo "Examples:"
echo " # Save to config.yaml (default)"
echo " sudo $0 987BDA619630197351F2B3040FD19B2EE0DEE357DD69BBEEE295786FA78A4D5F298B0BF1B7DE73CBC23257CDB2C562F5033DF58C232916432948B0F6BA4448F2"
echo ""
echo " # Save to identity.key file"
echo " sudo $0 987BDA619630197351F2B3040FD19B2EE0DEE357DD69BBEEE295786FA78A4D5F298B0BF1B7DE73CBC23257CDB2C562F5033DF58C232916432948B0F6BA4448F2 --output-format=identity"
exit 1
fi
@@ -35,6 +42,33 @@ if [ "$EUID" -ne 0 ]; then
fi
FULL_KEY="$1"
OUTPUT_FORMAT="yaml" # Default format
CONFIG_PATH=""
# Parse arguments
shift # Remove the key argument
while [ $# -gt 0 ]; do
case "$1" in
--output-format=*)
OUTPUT_FORMAT="${1#*=}"
;;
*)
CONFIG_PATH="$1"
;;
esac
shift
done
# Validate output format
if [ "$OUTPUT_FORMAT" != "yaml" ] && [ "$OUTPUT_FORMAT" != "identity" ]; then
echo "Error: Invalid output format '$OUTPUT_FORMAT'. Must be 'yaml' or 'identity'"
exit 1
fi
# Set default config path if not provided
if [ -z "$CONFIG_PATH" ]; then
CONFIG_PATH="/etc/pymc_repeater/config.yaml"
fi
# Validate hex string
if ! [[ "$FULL_KEY" =~ ^[0-9a-fA-F]+$ ]]; then
@@ -49,17 +83,28 @@ if [ "$KEY_LEN" -ne 128 ]; then
exit 1
fi
# Get config path
CONFIG_PATH="${2:-/etc/pymc_repeater/config.yaml}"
# Check if config exists
if [ ! -f "$CONFIG_PATH" ]; then
echo "Error: Config file not found: $CONFIG_PATH"
exit 1
# Check if config/identity file location exists (only for yaml format or if saving identity.key)
if [ "$OUTPUT_FORMAT" = "yaml" ]; then
# Check if config exists
if [ ! -f "$CONFIG_PATH" ]; then
echo "Error: Config file not found: $CONFIG_PATH"
exit 1
fi
else
# For identity format, use system-wide location matching config.yaml
IDENTITY_DIR="/etc/pymc_repeater"
IDENTITY_PATH="$IDENTITY_DIR/identity.key"
fi
echo "=== MeshCore Firmware Key Import ==="
echo ""
echo "Output format: $OUTPUT_FORMAT"
if [ "$OUTPUT_FORMAT" = "yaml" ]; then
echo "Target file: $CONFIG_PATH"
else
echo "Target file: $IDENTITY_PATH"
fi
echo ""
echo "Input (64-byte firmware key):"
echo " $FULL_KEY"
echo ""
@@ -70,71 +115,142 @@ 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:
from nacl.bindings import crypto_scalarmult_ed25519_base_noclamp
scalar = key_bytes[:32]
pubkey = crypto_scalarmult_ed25519_base_noclamp(scalar)
print(f"Derived public key: {pubkey.hex()}")
# Calculate address (MeshCore uses first byte of pubkey directly, not SHA256)
address = pubkey[0]
print(f"Node address: 0x{address:02x}")
print()
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
+2 -2
View File
@@ -38,13 +38,13 @@ case "$1" in
echo "Installing pymc_core[hardware] from PyPI..."
python3 -m pip install --break-system-packages 'pymc_core[hardware]>=1.0.7' || true
fi
# Install packages not available in Debian repos
if ! python3 -c "import cherrypy_cors" 2>/dev/null; then
echo "Installing cherrypy-cors from PyPI..."
python3 -m pip install --break-system-packages 'cherrypy-cors==1.7.0' || true
fi
if ! python3 -c "import ws4py" 2>/dev/null; then
echo "Installing ws4py from PyPI..."
python3 -m pip install --break-system-packages 'ws4py>=0.5.1' || true
+22
View File
@@ -0,0 +1,22 @@
services:
pymc-repeater:
build: .
container_name: pymc-repeater
restart: unless-stopped
ports:
- 8000:8000
devices:
# SPI DEVICES (Your path may differ)
- /dev/spidev0.0
- /dev/gpiochip0
# USB DEVICES (Your path may differ)
- /dev/bus/usb/002:/dev/bus/usb/002
# SPI DEVICES PERMISSIONS
cap_add:
- SYS_RAWIO
# USB DEVICSE PERMISSIONS
group_add:
- plugdev
volumes:
- ./config.yaml:/etc/pymc_repeater/config.yaml
- ./data:/var/lib/pymc_repeater
+38
View File
@@ -0,0 +1,38 @@
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 \
libusb-1.0-0 \
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 .
COPY radio-presets.json .
COPY radio-settings.json .
# Install package
RUN pip install --no-cache-dir .
EXPOSE 8000
ENTRYPOINT ["python3", "-m", "repeater.main", "--config", "/etc/pymc_repeater/config.yaml"]
+449 -200
View File
File diff suppressed because it is too large Load Diff
+3 -4
View File
@@ -10,8 +10,7 @@ Wants=network-online.target
Type=simple
User=repeater
Group=repeater
WorkingDirectory=/opt/pymc_repeater
Environment="PYTHONPATH=/opt/pymc_repeater"
WorkingDirectory=/var/lib/pymc_repeater
# Start command - use python module directly with proper path
ExecStart=/usr/bin/python3 -m repeater.main --config /etc/pymc_repeater/config.yaml
@@ -28,9 +27,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
+13 -5
View File
@@ -29,9 +29,8 @@ classifiers = [
keywords = ["mesh", "networking", "lora", "repeater", "daemon", "iot"]
dependencies = [
"pymc_core[hardware] @ git+https://github.com/rightup/pyMC_core.git@dev",
"pymc_core[hardware]@git+https://github.com/rightup/pyMC_core.git@feat/companion",
"pyyaml>=6.0.0",
"cherrypy>=18.0.0",
"paho-mqtt>=1.6.0",
@@ -44,6 +43,14 @@ dependencies = [
[project.optional-dependencies]
# SX1262/SPI support (Linux only; required for Raspberry Pi HATs)
hardware = [
"pymc_core[hardware]",
]
# RRD metrics (Performance Metrics chart); system librrd required (e.g. apt install rrdtool)
rrd = [
"rrdtool",
]
dev = [
"pytest>=7.4.0",
"pytest-asyncio>=0.21.0",
@@ -55,8 +62,9 @@ dev = [
[project.scripts]
pymc-repeater = "repeater.main:main"
[tool.setuptools]
packages = ["repeater"]
[tool.setuptools.packages.find]
where = ["."]
include = ["repeater*"]
[tool.setuptools.package-data]
repeater = [
@@ -78,4 +86,4 @@ line_length = 100
[tool.setuptools_scm]
version_scheme = "guess-next-dev"
local_scheme = "no-local-version"
version_file = "repeater/_version.py"
+159 -1
View File
@@ -1 +1,159 @@
{"config":{"connect_screen":{"info_message":"The default pin for devices without a screen is 123456. Trouble pairing? Forget the bluetooth device in system settings."},"remote_management":{"repeaters":{"guest_login_enabled":true,"guest_login_disabled_message":"Guest login has been temporarily disabled. Please try again later.","guest_login_passwords":[""],"flood_routed_guest_login_enabled":true,"flood_routed_guest_login_disabled_message":"To avoid overwhelming the mesh with flood packets, please set a path to log in to a repeater as a guest."}},"suggested_radio_settings":{"info_message":"These radio settings have been suggested by the community.","entries":[{"title":"Australia","description":"915.800MHz / SF10 / BW250 / CR5","frequency":"915.800","spreading_factor":"10","bandwidth":"250","coding_rate":"5"},{"title":"Australia: Victoria","description":"916.575MHz / SF7 / BW62.5 / CR8","frequency":"916.575","spreading_factor":"7","bandwidth":"62.5","coding_rate":"8"},{"title":"EU/UK (Narrow)","description":"869.618MHz / SF8 / BW62.5 / CR8","frequency":"869.618","spreading_factor":"8","bandwidth":"62.5","coding_rate":"8"},{"title":"EU/UK (Long Range)","description":"869.525MHz / SF11 / BW250 / CR5","frequency":"869.525","spreading_factor":"11","bandwidth":"250","coding_rate":"5"},{"title":"EU/UK (Medium Range)","description":"869.525MHz / SF10 / BW250 / CR5","frequency":"869.525","spreading_factor":"10","bandwidth":"250","coding_rate":"5"},{"title":"Czech Republic (Narrow)","description":"869.525MHz / SF7 / BW62.5 / CR5","frequency":"869.525","spreading_factor":"7","bandwidth":"62.5","coding_rate":"5"},{"title":"EU 433MHz (Long Range)","description":"433.650MHz / SF11 / BW250 / CR5","frequency":"433.650","spreading_factor":"11","bandwidth":"250","coding_rate":"5"},{"title":"New Zealand","description":"917.375MHz / SF11 / BW250 / CR5","frequency":"917.375","spreading_factor":"11","bandwidth":"250","coding_rate":"5"},{"title":"New Zealand (Narrow)","description":"917.375MHz / SF7 / BW62.5 / CR5","frequency":"917.375","spreading_factor":"7","bandwidth":"62.5","coding_rate":"5"},{"title":"Portugal 433","description":"433.375MHz / SF9 / BW62.5 / CR6","frequency":"433.375","spreading_factor":"9","bandwidth":"62.5","coding_rate":"6"},{"title":"Portugal 868","description":"869.618MHz / SF7 / BW62.5 / CR6","frequency":"869.618","spreading_factor":"7","bandwidth":"62.5","coding_rate":"6"},{"title":"Switzerland","description":"869.618MHz / SF8 / BW62.5 / CR8","frequency":"869.618","spreading_factor":"8","bandwidth":"62.5","coding_rate":"8"},{"title":"USA/Canada (Recommended)","description":"910.525MHz / SF7 / BW62.5 / CR5","frequency":"910.525","spreading_factor":"7","bandwidth":"62.5","coding_rate":"5"},{"title":"USA/Canada (Alternate)","description":"910.525MHz / SF11 / BW250 / CR5","frequency":"910.525","spreading_factor":"11","bandwidth":"250","coding_rate":"5"},{"title":"Vietnam","description":"920.250MHz / SF11 / BW250 / CR5","frequency":"920.250","spreading_factor":"11","bandwidth":"250","coding_rate":"5"}]}}}
{
"config": {
"connect_screen": {
"info_message": "The default pin for devices without a screen is 123456. Trouble pairing? Forget the bluetooth device in system settings."
},
"remote_management": {
"repeaters": {
"guest_login_enabled": true,
"guest_login_disabled_message": "Guest login has been temporarily disabled. Please try again later.",
"guest_login_passwords": [
""
],
"flood_routed_guest_login_enabled": true,
"flood_routed_guest_login_disabled_message": "To avoid overwhelming the mesh with flood packets, please set a path to log in to a repeater as a guest."
}
},
"suggested_radio_settings": {
"info_message": "These radio settings have been suggested by the community.",
"entries": [
{
"title": "Australia",
"description": "915.800MHz / SF10 / BW250 / CR5",
"frequency": "915.800",
"spreading_factor": "10",
"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",
"frequency": "916.575",
"spreading_factor": "7",
"bandwidth": "62.5",
"coding_rate": "8"
},
{
"title": "Australia: SA, WA, QLD",
"description": "923.125MHz / SF8 / BW62.5 / CR8",
"frequency": "923.125",
"spreading_factor": "8",
"bandwidth": "62.5",
"coding_rate": "8"
},
{
"title": "EU/UK (Narrow)",
"description": "869.618MHz / SF8 / BW62.5 / CR8",
"frequency": "869.618",
"spreading_factor": "8",
"bandwidth": "62.5",
"coding_rate": "8"
},
{
"title": "EU/UK (Long Range)",
"description": "869.525MHz / SF11 / BW250 / CR5",
"frequency": "869.525",
"spreading_factor": "11",
"bandwidth": "250",
"coding_rate": "5"
},
{
"title": "EU/UK (Medium Range)",
"description": "869.525MHz / SF10 / BW250 / CR5",
"frequency": "869.525",
"spreading_factor": "10",
"bandwidth": "250",
"coding_rate": "5"
},
{
"title": "Czech Republic (Narrow)",
"description": "869.525MHz / SF7 / BW62.5 / CR5",
"frequency": "869.525",
"spreading_factor": "7",
"bandwidth": "62.5",
"coding_rate": "5"
},
{
"title": "EU 433MHz (Long Range)",
"description": "433.650MHz / SF11 / BW250 / CR5",
"frequency": "433.650",
"spreading_factor": "11",
"bandwidth": "250",
"coding_rate": "5"
},
{
"title": "New Zealand",
"description": "917.375MHz / SF11 / BW250 / CR5",
"frequency": "917.375",
"spreading_factor": "11",
"bandwidth": "250",
"coding_rate": "5"
},
{
"title": "New Zealand (Narrow)",
"description": "917.375MHz / SF7 / BW62.5 / CR5",
"frequency": "917.375",
"spreading_factor": "7",
"bandwidth": "62.5",
"coding_rate": "5"
},
{
"title": "Portugal 433",
"description": "433.375MHz / SF9 / BW62.5 / CR6",
"frequency": "433.375",
"spreading_factor": "9",
"bandwidth": "62.5",
"coding_rate": "6"
},
{
"title": "Portugal 868",
"description": "869.618MHz / SF7 / BW62.5 / CR6",
"frequency": "869.618",
"spreading_factor": "7",
"bandwidth": "62.5",
"coding_rate": "6"
},
{
"title": "Switzerland",
"description": "869.618MHz / SF8 / BW62.5 / CR8",
"frequency": "869.618",
"spreading_factor": "8",
"bandwidth": "62.5",
"coding_rate": "8"
},
{
"title": "USA/Canada (Recommended)",
"description": "910.525MHz / SF7 / BW62.5 / CR5",
"frequency": "910.525",
"spreading_factor": "7",
"bandwidth": "62.5",
"coding_rate": "5"
},
{
"title": "USA/Canada (Alternate)",
"description": "910.525MHz / SF11 / BW250 / CR5",
"frequency": "910.525",
"spreading_factor": "11",
"bandwidth": "250",
"coding_rate": "5"
},
{
"title": "Vietnam",
"description": "920.250MHz / SF11 / BW250 / CR5",
"frequency": "920.250",
"spreading_factor": "11",
"bandwidth": "250",
"coding_rate": "5"
}
]
}
}
}
+132 -15
View File
@@ -16,8 +16,8 @@
"preamble_length": 17,
"is_waveshare": true
},
"uconsole": {
"name": "uConsole LoRa Module",
"uconsole_aiov1": {
"name": "uConsole LoRa Module aio v1",
"bus_id": 1,
"cs_id": 0,
"cs_pin": -1,
@@ -31,24 +31,25 @@
"tx_power": 22,
"preamble_length": 17
},
"pimesh-1w-usa": {
"name": "PiMesh-1W (USA)",
"bus_id": 0,
"uconsole_aio_v2": {
"name": "uConsole LoRa Module aio v2",
"bus_id": 1,
"cs_id": 0,
"cs_pin": 21,
"reset_pin": 18,
"busy_pin": 20,
"irq_pin": 16,
"txen_pin": 13,
"rxen_pin": 12,
"cs_pin": -1,
"reset_pin": 25,
"busy_pin": 24,
"irq_pin": 26,
"txen_pin": -1,
"rxen_pin": -1,
"txled_pin": -1,
"rxled_pin": -1,
"tx_power": 30,
"tx_power": 22,
"preamble_length": 17,
"use_dio3_tcxo": true,
"preamble_length": 17
"use_dio2_rf": true
},
"pimesh-1w-uk": {
"name": "PiMesh-1W (UK)",
"pimesh-1w-v1": {
"name": "PiMesh-1W (V1)",
"bus_id": 0,
"cs_id": 0,
"cs_pin": 21,
@@ -63,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,
@@ -111,6 +130,42 @@
"use_dio2_rf": true,
"preamble_length": 17
},
"femtofox-1W-SX": {
"name": "FemtoFox SX1262 (1W)",
"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": 30,
"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,
@@ -127,6 +182,68 @@
"use_dio3_tcxo": true,
"use_dio2_rf": true,
"preamble_length": 17
},
"ch341-usb-sx1262": {
"name": "CH341 USB-SPI + SX1262 (example)",
"description": "SX1262 via CH341 USB-to-SPI adapter. NOTE: pin numbers are CH341 GPIO 0-7, not BCM.",
"radio_type": "sx1262_ch341",
"vid": 6790,
"pid": 21778,
"bus_id": 0,
"cs_id": 0,
"cs_pin": 0,
"reset_pin": 2,
"busy_pin": 4,
"irq_pin": 6,
"txen_pin": -1,
"rxen_pin": 1,
"txled_pin": -1,
"rxled_pin": -1,
"tx_power": 22,
"use_dio2_rf": true,
"use_dio3_tcxo": true,
"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
}
}
}
+1
View File
@@ -3,6 +3,7 @@ try:
except ImportError:
try:
from importlib.metadata import version
__version__ = version("pymc_repeater")
except Exception:
__version__ = "unknown"
+18 -12
View File
@@ -24,6 +24,7 @@ class AirtimeManager:
self.tx_history = [] # [(timestamp, airtime_ms), ...]
self.window_size = 60 # seconds
self.total_airtime_ms = 0
self.total_rx_airtime_ms = 0
def calculate_airtime(
self,
@@ -37,9 +38,9 @@ class AirtimeManager:
) -> float:
"""
Calculate LoRa packet airtime using the Semtech reference formula.
Reference: https://www.semtech.com/design-support/lora-calculator
Args:
payload_len: Payload length in bytes
spreading_factor: SF7-SF12 (uses config value if None)
@@ -48,35 +49,35 @@ class AirtimeManager:
preamble_len: Preamble symbols (uses config value if None)
crc_enabled: Whether CRC is enabled (default: True)
explicit_header: Whether explicit header mode is used (default: True)
Returns:
Airtime in milliseconds
"""
sf = spreading_factor or self.spreading_factor
bw_khz = (bandwidth_hz or self.bandwidth) / 1000
bw_hz = (bandwidth_hz or self.bandwidth)
cr = coding_rate or self.coding_rate
preamble_len = preamble_len or self.preamble_length
crc = 1 if crc_enabled else 0
h = 0 if explicit_header else 1 # H=0 for explicit, H=1 for implicit
# Low data rate optimization: required for SF11/SF12 at 125kHz
de = 1 if (sf >= 11 and bandwidth_hz <= 125000) else 0
de = 1 if (sf >= 11 and bw_hz <= 125000) else 0
# Symbol time in milliseconds: T_sym = 2^SF / BW_kHz
t_sym = (2 ** sf) / bw_khz
t_sym = (2 ** sf) / (bw_hz / 1000)
# Preamble time: T_preamble = (n_preamble + 4.25) * T_sym
t_preamble = (preamble_len + 4.25) * t_sym
# Payload symbol calculation (Semtech formula):
# n_payload = 8 + ceil(max(8*PL - 4*SF + 28 + 16*CRC - 20*H, 0) / (4*(SF - 2*DE))) * CR
numerator = max(8 * payload_len - 4 * sf + 28 + 16 * crc - 20 * h, 0)
denominator = 4 * (sf - 2 * de)
n_payload = 8 + math.ceil(numerator / denominator) * cr
# Payload time
t_payload = n_payload * t_sym
# Total packet airtime
return t_preamble + t_payload
@@ -110,6 +111,10 @@ class AirtimeManager:
self.total_airtime_ms += airtime_ms
logger.debug(f"TX recorded: {airtime_ms: .1f}ms (total: {self.total_airtime_ms: .0f}ms)")
def record_rx(self, airtime_ms: float):
"""Record received packet airtime (for total RX airtime stats)."""
self.total_rx_airtime_ms += airtime_ms
def get_stats(self) -> dict:
now = time.time()
self.tx_history = [(ts, at) for ts, at in self.tx_history if now - ts < self.window_size]
@@ -122,4 +127,5 @@ class AirtimeManager:
"max_airtime_ms": self.max_airtime_per_minute,
"utilization_percent": utilization,
"total_airtime_ms": self.total_airtime_ms,
"total_rx_airtime_ms": self.total_rx_airtime_ms,
}
+30
View File
@@ -0,0 +1,30 @@
"""Companion identity support for pyMC Repeater.
Exposes the MeshCore companion frame protocol over TCP for standard clients.
"""
from .bridge import RepeaterCompanionBridge
from .constants import (
CMD_APP_START,
CMD_GET_CONTACTS,
CMD_SEND_LOGIN,
CMD_SEND_TXT_MSG,
CMD_SYNC_NEXT_MESSAGE,
PUSH_CODE_MSG_WAITING,
RESP_CODE_ERR,
RESP_CODE_OK,
)
from .frame_server import CompanionFrameServer
__all__ = [
"CompanionFrameServer",
"RepeaterCompanionBridge",
"CMD_APP_START",
"CMD_GET_CONTACTS",
"CMD_SEND_TXT_MSG",
"CMD_SYNC_NEXT_MESSAGE",
"CMD_SEND_LOGIN",
"RESP_CODE_OK",
"RESP_CODE_ERR",
"PUSH_CODE_MSG_WAITING",
]
+122
View File
@@ -0,0 +1,122 @@
"""
Repeater CompanionBridge with SQLite-backed preference persistence.
Persists full NodePrefs as a JSON blob so companion settings (including
auto-add config) survive repeater restarts. Merge-on-load supports
schema evolution when NodePrefs gains or loses fields.
"""
from __future__ import annotations
import dataclasses
import logging
from enum import Enum
from typing import Any, Callable, Optional
from pymc_core.companion import CompanionBridge
logger = logging.getLogger("RepeaterCompanionBridge")
def _to_json_safe(value: Any) -> Any:
"""Convert a value to a JSON-serializable form (avoids TypeError from enums, bytes, etc.)."""
if value is None or isinstance(value, (bool, int, float, str)):
return value
if isinstance(value, Enum):
return value.value
if isinstance(value, bytes):
return value.hex()
if isinstance(value, (list, tuple)):
return [_to_json_safe(v) for v in value]
if isinstance(value, dict):
return {k: _to_json_safe(v) for k, v in value.items()}
if dataclasses.is_dataclass(value) and not isinstance(value, type):
return {f.name: _to_json_safe(getattr(value, f.name)) for f in dataclasses.fields(value)}
return value
class RepeaterCompanionBridge(CompanionBridge):
"""CompanionBridge that persists and loads prefs (full NodePrefs) via SQLite JSON blob."""
def __init__(
self,
identity,
packet_injector: Callable[..., Any],
node_name: str = "pyMC",
adv_type: int = 1,
max_contacts: int = 1000,
max_channels: int = 40,
offline_queue_size: int = 512,
radio_config: Optional[dict] = None,
authenticate_callback: Optional[Callable[..., tuple[bool, int]]] = None,
initial_contacts: Optional[Any] = None,
*,
sqlite_handler=None,
companion_hash: str = "",
on_prefs_saved: Optional[Callable[[str], None]] = None,
) -> None:
self._sqlite_handler = sqlite_handler
self._companion_hash = companion_hash
self._on_prefs_saved = on_prefs_saved
super().__init__(
identity=identity,
packet_injector=packet_injector,
node_name=node_name,
adv_type=adv_type,
max_contacts=max_contacts,
max_channels=max_channels,
offline_queue_size=offline_queue_size,
radio_config=radio_config,
authenticate_callback=authenticate_callback,
initial_contacts=initial_contacts,
)
# Load persisted prefs (e.g. node_name) from SQLite so matching uses last-saved name
self._load_prefs()
def _save_prefs(self) -> None:
"""Persist full NodePrefs as JSON to SQLite."""
if not self._sqlite_handler or not self._companion_hash:
return
try:
prefs_dict = dataclasses.asdict(self.prefs)
prefs_safe = _to_json_safe(prefs_dict)
self._sqlite_handler.companion_save_prefs(
str(self._companion_hash), prefs_safe
)
if self._on_prefs_saved:
try:
self._on_prefs_saved(self.prefs.node_name)
except Exception as e:
logger.warning("Failed to sync node_name to config: %s", e)
except Exception as e:
logger.warning("Failed to persist companion prefs: %s", e)
def _load_prefs(self) -> None:
"""Load prefs from SQLite JSON and merge into self.prefs (only known keys)."""
if not self._sqlite_handler or not self._companion_hash:
return
try:
stored = self._sqlite_handler.companion_load_prefs(self._companion_hash)
if not stored or not isinstance(stored, dict):
return
for key, value in stored.items():
if not hasattr(self.prefs, key):
continue
current = getattr(self.prefs, key)
try:
if value is None:
continue
if isinstance(current, bool):
setattr(self.prefs, key, bool(value))
elif isinstance(current, int):
setattr(self.prefs, key, int(value))
elif isinstance(current, float):
setattr(self.prefs, key, float(value))
elif isinstance(current, str):
setattr(self.prefs, key, str(value))
else:
setattr(self.prefs, key, value)
except (TypeError, ValueError) as e:
logger.debug("Skip prefs key %r: %s", key, e)
except Exception as e:
logger.warning("Failed to load companion prefs: %s", e)
+150
View File
@@ -0,0 +1,150 @@
"""Companion frame protocol constants — re-exported from pyMC_core.
All protocol constants now live in :mod:`pymc_core.companion.constants`.
This module re-exports them so existing repeater imports continue to work.
"""
# Re-exports; F401 ignored for re-exported names.
from pymc_core.companion.constants import ( # noqa: F401
ADV_TYPE_CHAT,
ADV_TYPE_REPEATER,
ADV_TYPE_ROOM,
ADV_TYPE_SENSOR,
ADVERT_LOC_NONE,
ADVERT_LOC_SHARE,
AUTOADD_CHAT,
AUTOADD_OVERWRITE_OLDEST,
AUTOADD_REPEATER,
AUTOADD_ROOM,
AUTOADD_SENSOR,
CMD_ADD_UPDATE_CONTACT,
CMD_APP_START,
CMD_DEVICE_QUERY,
CMD_EXPORT_CONTACT,
CMD_EXPORT_PRIVATE_KEY,
CMD_FACTORY_RESET,
CMD_GET_ADVERT_PATH,
CMD_GET_AUTOADD_CONFIG,
CMD_GET_BATT_AND_STORAGE,
CMD_GET_CHANNEL,
CMD_GET_CONTACT_BY_KEY,
CMD_GET_CONTACTS,
CMD_GET_CUSTOM_VARS,
CMD_GET_DEVICE_TIME,
CMD_GET_STATS,
CMD_GET_TUNING_PARAMS,
CMD_HAS_CONNECTION,
CMD_IMPORT_CONTACT,
CMD_IMPORT_PRIVATE_KEY,
CMD_LOGOUT,
CMD_REBOOT,
CMD_REMOVE_CONTACT,
CMD_RESET_PATH,
CMD_SEND_ANON_REQ,
CMD_SEND_BINARY_REQ,
CMD_SEND_CHANNEL_TXT_MSG,
CMD_SEND_CONTROL_DATA,
CMD_SEND_LOGIN,
CMD_SEND_PATH_DISCOVERY_REQ,
CMD_SEND_RAW_DATA,
CMD_SEND_SELF_ADVERT,
CMD_SEND_STATUS_REQ,
CMD_SEND_TELEMETRY_REQ,
CMD_SEND_TRACE_PATH,
CMD_SEND_TXT_MSG,
CMD_SET_ADVERT_LATLON,
CMD_SET_ADVERT_NAME,
CMD_SET_AUTOADD_CONFIG,
CMD_SET_CHANNEL,
CMD_SET_CUSTOM_VAR,
CMD_SET_DEVICE_PIN,
CMD_SET_DEVICE_TIME,
CMD_SET_FLOOD_SCOPE,
CMD_SET_OTHER_PARAMS,
CMD_SET_RADIO_PARAMS,
CMD_SET_RADIO_TX_POWER,
CMD_SET_TUNING_PARAMS,
CMD_SHARE_CONTACT,
CMD_SIGN_DATA,
CMD_SIGN_FINISH,
CMD_SIGN_START,
CMD_SYNC_NEXT_MESSAGE,
CONTACT_NAME_SIZE,
DEFAULT_MAX_CHANNELS,
DEFAULT_MAX_CONTACTS,
DEFAULT_OFFLINE_QUEUE_SIZE,
DEFAULT_PUBLIC_CHANNEL_SECRET,
DEFAULT_RESPONSE_TIMEOUT_MS,
ERR_CODE_BAD_STATE,
ERR_CODE_FILE_IO_ERROR,
ERR_CODE_ILLEGAL_ARG,
ERR_CODE_NOT_FOUND,
ERR_CODE_TABLE_FULL,
ERR_CODE_UNSUPPORTED_CMD,
FRAME_INBOUND_PREFIX,
FRAME_OUTBOUND_PREFIX,
MAX_FRAME_SIZE,
MAX_PATH_SIZE,
MAX_SIGN_DATA_SIZE,
MSG_SEND_FAILED,
MSG_SEND_SENT_DIRECT,
MSG_SEND_SENT_FLOOD,
PROTOCOL_CODE_ANON_REQ,
PROTOCOL_CODE_BINARY_REQ,
PROTOCOL_CODE_RAW_DATA,
PUB_KEY_SIZE,
PUBLIC_GROUP_PSK,
PUSH_CODE_ADVERT,
PUSH_CODE_BINARY_RESPONSE,
PUSH_CODE_CONTACT_DELETED,
PUSH_CODE_CONTACTS_FULL,
PUSH_CODE_CONTROL_DATA,
PUSH_CODE_LOG_RX_DATA,
PUSH_CODE_LOGIN_FAIL,
PUSH_CODE_LOGIN_SUCCESS,
PUSH_CODE_MSG_WAITING,
PUSH_CODE_NEW_ADVERT,
PUSH_CODE_PATH_DISCOVERY_RESPONSE,
PUSH_CODE_PATH_UPDATED,
PUSH_CODE_RAW_DATA,
PUSH_CODE_SEND_CONFIRMED,
PUSH_CODE_STATUS_RESPONSE,
PUSH_CODE_TELEMETRY_RESPONSE,
PUSH_CODE_TRACE_DATA,
RESP_CODE_ADVERT_PATH,
RESP_CODE_AUTOADD_CONFIG,
RESP_CODE_BATT_AND_STORAGE,
RESP_CODE_CHANNEL_INFO,
RESP_CODE_CHANNEL_MSG_RECV,
RESP_CODE_CHANNEL_MSG_RECV_V3,
RESP_CODE_CONTACT,
RESP_CODE_CONTACT_MSG_RECV,
RESP_CODE_CONTACT_MSG_RECV_V3,
RESP_CODE_CONTACTS_START,
RESP_CODE_CURR_TIME,
RESP_CODE_CUSTOM_VARS,
RESP_CODE_DEVICE_INFO,
RESP_CODE_DISABLED,
RESP_CODE_END_OF_CONTACTS,
RESP_CODE_ERR,
RESP_CODE_EXPORT_CONTACT,
RESP_CODE_NO_MORE_MESSAGES,
RESP_CODE_OK,
RESP_CODE_PRIVATE_KEY,
RESP_CODE_SELF_INFO,
RESP_CODE_SENT,
RESP_CODE_SIGN_START,
RESP_CODE_SIGNATURE,
RESP_CODE_STATS,
RESP_CODE_TUNING_PARAMS,
STATS_TYPE_CORE,
STATS_TYPE_PACKETS,
STATS_TYPE_RADIO,
TELEM_MODE_ALLOW_ALL,
TELEM_MODE_ALLOW_FLAGS,
TELEM_MODE_DENY,
TXT_TYPE_CLI_DATA,
TXT_TYPE_PLAIN,
TXT_TYPE_SIGNED_PLAIN,
BinaryReqType,
)
+178
View File
@@ -0,0 +1,178 @@
"""
Repeater-specific CompanionFrameServer with SQLite persistence.
Thin subclass of :class:`pymc_core.companion.frame_server.CompanionFrameServer`
that adds SQLite-backed message, contact, and channel persistence via a
``sqlite_handler`` dependency.
"""
from __future__ import annotations
import asyncio
import logging
from typing import Optional
from pymc_core.companion.constants import RESP_CODE_NO_MORE_MESSAGES
from pymc_core.companion.frame_server import CompanionFrameServer as _BaseFrameServer
from pymc_core.companion.models import QueuedMessage
logger = logging.getLogger("CompanionFrameServer")
class CompanionFrameServer(_BaseFrameServer):
"""Adds SQLite persistence for messages, contacts, and channels.
Constructor signature is intentionally kept compatible with the
previous monolithic implementation so ``main.py`` call-sites need
zero changes.
"""
def __init__(
self,
bridge,
companion_hash: str,
port: int = 5000,
bind_address: str = "0.0.0.0",
client_idle_timeout_sec: Optional[int] = 120,
sqlite_handler=None,
local_hash: Optional[int] = None,
stats_getter=None,
control_handler=None,
):
super().__init__(
bridge=bridge,
companion_hash=companion_hash,
port=port,
bind_address=bind_address,
client_idle_timeout_sec=client_idle_timeout_sec,
device_model="pyMC-Repeater-Companion",
device_version=None, # use FIRMWARE_VER_CODE from pyMC_core
build_date="13 Feb 2026",
local_hash=local_hash,
stats_getter=stats_getter,
control_handler=control_handler,
)
self.sqlite_handler = sqlite_handler
# -----------------------------------------------------------------
# Persistence hook overrides
# -----------------------------------------------------------------
async def _persist_companion_message(self, msg_dict: dict) -> None:
"""Persist message to SQLite and pop from bridge queue."""
if not self.sqlite_handler:
return
await asyncio.to_thread(
self.sqlite_handler.companion_push_message,
self.companion_hash,
msg_dict,
)
self.bridge.message_queue.pop_last()
def _sync_next_from_persistence(self) -> Optional[QueuedMessage]:
"""Retrieve next message from SQLite when bridge queue is empty."""
if not self.sqlite_handler:
return None
msg_dict = self.sqlite_handler.companion_pop_message(self.companion_hash)
if not msg_dict:
return None
return QueuedMessage(
sender_key=msg_dict.get("sender_key", b""),
txt_type=msg_dict.get("txt_type", 0),
timestamp=msg_dict.get("timestamp", 0),
text=msg_dict.get("text", ""),
is_channel=bool(msg_dict.get("is_channel", False)),
channel_idx=msg_dict.get("channel_idx", 0),
path_len=msg_dict.get("path_len", 0),
)
# -----------------------------------------------------------------
# Non-blocking command overrides (keep event loop responsive)
# -----------------------------------------------------------------
async def _cmd_sync_next_message(self, data: bytes) -> None:
"""Sync next message; run persistence read in thread so SQLite does not block."""
msg = self.bridge.sync_next_message()
if msg is None:
msg = await asyncio.to_thread(self._sync_next_from_persistence)
if msg is None:
self._write_frame(bytes([RESP_CODE_NO_MORE_MESSAGES]))
return
self._write_frame(self._build_message_frame(msg))
@staticmethod
def _contact_to_dict(c) -> dict:
"""Convert a Contact object to a persistence dict."""
pk = c.public_key if isinstance(c.public_key, bytes) else bytes.fromhex(c.public_key)
return {
"pubkey": pk,
"name": c.name,
"adv_type": c.adv_type,
"flags": c.flags,
"out_path_len": c.out_path_len,
"out_path": (
c.out_path
if isinstance(c.out_path, bytes)
else (bytes.fromhex(c.out_path) if c.out_path else b"")
),
"last_advert_timestamp": c.last_advert_timestamp,
"lastmod": c.lastmod,
"gps_lat": c.gps_lat,
"gps_lon": c.gps_lon,
"sync_since": c.sync_since,
}
async def _persist_contact(self, contact) -> None:
"""Upsert a single contact to SQLite (non-blocking)."""
if not self.sqlite_handler:
return
contact_dict = self._contact_to_dict(contact)
await asyncio.to_thread(
self.sqlite_handler.companion_upsert_contact,
self.companion_hash,
contact_dict,
)
async def _save_contacts(self) -> None:
"""Persist all contacts to SQLite (non-blocking)."""
if not self.sqlite_handler:
return
contacts = self.bridge.get_contacts()
dicts = [self._contact_to_dict(c) for c in contacts]
await asyncio.to_thread(
self.sqlite_handler.companion_save_contacts,
self.companion_hash,
dicts,
)
async def _save_channels(self) -> None:
"""Persist channels to SQLite (non-blocking)."""
if not self.sqlite_handler:
return
channels = []
max_ch = getattr(getattr(self.bridge, "channels", None), "max_channels", 40)
for idx in range(max_ch):
ch = self.bridge.get_channel(idx)
if ch is not None:
channels.append(
{
"channel_idx": idx,
"name": ch.name,
"secret": ch.secret,
}
)
await asyncio.to_thread(
self.sqlite_handler.companion_save_channels,
self.companion_hash,
channels,
)
async def stop(self) -> None:
"""Persist contacts and channels before stopping (so they survive daemon restart)."""
if self.sqlite_handler:
try:
await self._save_contacts()
await self._save_channels()
except Exception as e:
logger.warning("Failed to persist contacts/channels on stop: %s", e)
await super().stop()
+25
View File
@@ -0,0 +1,25 @@
"""Shared utilities for Companion (e.g. validation for config sync)."""
_INVALID_NODE_NAME_CHARS = "\n\r\x00"
def normalize_companion_identity_key(identity_key: str) -> str:
"""Strip whitespace and remove optional 0x prefix so fromhex() is consistent across installs."""
s = identity_key.strip()
if s.lower().startswith("0x"):
s = s[2:].strip()
return s
def validate_companion_node_name(value: str) -> str:
"""Validate node_name for config sync: non-empty, max 31 bytes UTF-8, no control chars."""
if not isinstance(value, str):
raise ValueError("node_name must be a string")
s = value.strip()
if not s:
raise ValueError("node_name cannot be empty")
if len(s.encode("utf-8")) > 31:
raise ValueError("node_name too long (max 31 bytes UTF-8)")
if any(c in s for c in _INVALID_NODE_NAME_CHARS):
raise ValueError("node_name contains invalid characters")
return s
+124 -30
View File
@@ -49,7 +49,7 @@ def get_node_info(config: Dict[str, Any]) -> Dict[str, Any]:
"model": letsmesh_config.get("model", "PyMC-Repeater"),
"disallowed_packet_types": disallowed_hex,
"email": letsmesh_config.get("email", ""),
"owner": letsmesh_config.get("owner", "")
"owner": letsmesh_config.get("owner", ""),
}
@@ -107,14 +107,21 @@ def save_config(config_data: Dict[str, Any], config_path: Optional[str] = None)
# Create backup of existing config
config_file = Path(config_path)
if config_file.exists():
backup_path = config_file.with_suffix('.yaml.backup')
backup_path = config_file.with_suffix(".yaml.backup")
config_file.rename(backup_path)
logger.info(f"Created backup at {backup_path}")
# Save new config
with open(config_path, 'w') as f:
yaml.safe_dump(config_data, f, default_flow_style=False, sort_keys=False)
# Save new config (allow_unicode=True so emojis etc. are not escaped as \U0001F47E)
with open(config_path, "w", encoding="utf-8") as f:
yaml.safe_dump(
config_data,
f,
default_flow_style=False,
sort_keys=False,
allow_unicode=True,
width=1000000,
)
logger.info(f"Saved configuration to {config_path}")
return True
@@ -156,13 +163,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 +185,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:
@@ -197,9 +209,20 @@ def _load_or_create_identity_key(path: Optional[str] = None) -> bytes:
def get_radio_for_board(board_config: dict):
radio_type = board_config.get("radio_type", "sx1262").lower()
def _parse_int(value, *, default=None) -> int:
if value is None:
return default
if isinstance(value, int):
return value
if isinstance(value, str):
return int(value.strip().rstrip(','), 0)
raise ValueError(f"Invalid int value type: {type(value)}")
if radio_type == "sx1262":
radio_type = board_config.get("radio_type", "sx1262").lower().strip()
if radio_type == "kiss-modem":
radio_type = "kiss"
if radio_type in ("sx1262", "sx1262_ch341"):
from pymc_core.hardware.sx1262_wrapper import SX1262Radio
# Get radio and SPI configuration - all settings must be in config file
@@ -211,19 +234,36 @@ def get_radio_for_board(board_config: dict):
if not radio_config:
raise ValueError("Missing 'radio' section in configuration file")
# Build config with required fields - no defaults
# CH341 integration: swap SPI transport + GPIO backend to CH341
if radio_type == "sx1262_ch341":
ch341_cfg = board_config.get("ch341")
if not ch341_cfg:
raise ValueError("Missing 'ch341' section in configuration file")
from pymc_core.hardware.lora.LoRaRF.SX126x import set_spi_transport
from pymc_core.hardware.transports.ch341_spi_transport import CH341SPITransport
vid = _parse_int(ch341_cfg.get("vid"), default=0x1A86)
pid = _parse_int(ch341_cfg.get("pid"), default=0x5512)
# Create CH341 transport (also configures CH341 GPIO manager globally)
ch341_spi = CH341SPITransport(vid=vid, pid=pid, auto_setup_gpio=True)
set_spi_transport(ch341_spi)
combined_config = {
"bus_id": spi_config["bus_id"],
"cs_id": spi_config["cs_id"],
"cs_pin": spi_config["cs_pin"],
"reset_pin": spi_config["reset_pin"],
"busy_pin": spi_config["busy_pin"],
"irq_pin": spi_config["irq_pin"],
"txen_pin": spi_config["txen_pin"],
"rxen_pin": spi_config["rxen_pin"],
"txled_pin": spi_config.get("txled_pin", -1),
"rxled_pin": spi_config.get("rxled_pin", -1),
"bus_id": _parse_int(spi_config["bus_id"]),
"cs_id": _parse_int(spi_config["cs_id"]),
"cs_pin": _parse_int(spi_config["cs_pin"]),
"reset_pin": _parse_int(spi_config["reset_pin"]),
"busy_pin": _parse_int(spi_config["busy_pin"]),
"irq_pin": _parse_int(spi_config["irq_pin"]),
"txen_pin": _parse_int(spi_config["txen_pin"]),
"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),
"is_waveshare": spi_config.get("is_waveshare", False),
"frequency": int(radio_config["frequency"]),
@@ -235,6 +275,13 @@ def get_radio_for_board(board_config: dict):
"sync_word": radio_config["sync_word"],
}
# Add optional GPIO parameters if specified in config
# These wont be supported by older versions of pymc_core
if "gpio_chip" in spi_config:
combined_config["gpio_chip"] = _parse_int(spi_config["gpio_chip"], default=0)
if "use_gpiod_backend" in spi_config:
combined_config["use_gpiod_backend"] = spi_config["use_gpiod_backend"]
radio = SX1262Radio.get_instance(**combined_config)
if hasattr(radio, "_initialized") and not radio._initialized:
@@ -245,5 +292,52 @@ def get_radio_for_board(board_config: dict):
return radio
else:
raise RuntimeError(f"Unknown radio type: {radio_type}. Supported: sx1262")
elif radio_type == "kiss":
try:
from pymc_core.hardware.kiss_modem_wrapper import KissModemWrapper
except ImportError:
try:
from pymc_core.hardware.kiss_serial_wrapper import (
KissSerialWrapper as KissModemWrapper,
)
except ImportError:
raise RuntimeError(
"KISS modem support requires pyMC_core with KISS support. "
"Install your fork with: pip install -e /path/to/pyMC_core"
) from None
kiss_config = board_config.get("kiss")
if not kiss_config:
raise ValueError("Missing 'kiss' section in configuration file for radio_type: kiss")
port = kiss_config.get("port")
if not port:
raise ValueError("Missing 'port' in 'kiss' section (e.g. /dev/ttyUSB0)")
baudrate = int(kiss_config.get("baud_rate", 115200))
radio_cfg = board_config.get("radio") or {}
radio_config = {
"frequency": int(radio_cfg.get("frequency", 869618000)),
"bandwidth": int(radio_cfg.get("bandwidth", 62500)),
"spreading_factor": int(radio_cfg.get("spreading_factor", 8)),
"coding_rate": int(radio_cfg.get("coding_rate", 8)),
"tx_power": int(radio_cfg.get("tx_power", 14)),
}
radio = KissModemWrapper(
port=port,
baudrate=baudrate,
radio_config=radio_config,
auto_configure=True,
)
if hasattr(radio, "begin"):
try:
radio.begin()
except Exception as e:
raise RuntimeError(f"Failed to initialize KISS modem: {e}") from e
return radio
raise RuntimeError(
f"Unknown radio type: {radio_type}. Supported: sx1262, sx1262_ch341, kiss (or kiss-modem)"
)
+19
View File
@@ -94,6 +94,25 @@ 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")
# Re-apply dispatcher path hash mode when mesh section changed
if 'mesh' in sections and self.daemon and hasattr(self.daemon, 'dispatcher'):
mesh_cfg = self.daemon.config.get("mesh", {})
path_hash_mode = mesh_cfg.get("path_hash_mode", 0)
if path_hash_mode not in (0, 1, 2):
logger.warning(
f"Invalid mesh.path_hash_mode={path_hash_mode}, must be 0/1/2; using 0"
)
path_hash_mode = 0
self.daemon.dispatcher.set_default_path_hash_mode(path_hash_mode)
logger.info(f"Reloaded path hash mode: mesh.path_hash_mode={path_hash_mode}")
return True
except Exception as e:
+3 -3
View File
@@ -1,6 +1,6 @@
from .sqlite_handler import SQLiteHandler
from .rrdtool_handler import RRDToolHandler
from .mqtt_handler import MQTTHandler
from .rrdtool_handler import RRDToolHandler
from .sqlite_handler import SQLiteHandler
from .storage_collector import StorageCollector
__all__ = ['SQLiteHandler', 'RRDToolHandler', 'MQTTHandler', 'StorageCollector']
__all__ = ["SQLiteHandler", "RRDToolHandler", "MQTTHandler", "StorageCollector"]
+36 -51
View File
@@ -5,13 +5,14 @@ KISS - Keep It Simple Stupid approach.
try:
import psutil
PSUTIL_AVAILABLE = True
except ImportError:
PSUTIL_AVAILABLE = False
psutil = None
import time
import logging
import time
logger = logging.getLogger("HardwareStats")
@@ -26,10 +27,8 @@ class HardwareStatsCollector:
if not PSUTIL_AVAILABLE:
logger.error("psutil not available - cannot collect hardware stats")
return {
"error": "psutil library not available - cannot collect hardware statistics"
}
return {"error": "psutil library not available - cannot collect hardware statistics"}
try:
# Get current timestamp
now = time.time()
@@ -42,10 +41,10 @@ class HardwareStatsCollector:
# Memory stats
memory = psutil.virtual_memory()
# Disk stats
disk = psutil.disk_usage('/')
disk = psutil.disk_usage("/")
# Network stats (total across all interfaces)
net_io = psutil.net_io_counters()
@@ -79,48 +78,39 @@ class HardwareStatsCollector:
"usage_percent": cpu_percent,
"count": cpu_count,
"frequency": cpu_freq.current if cpu_freq else 0,
"load_avg": {
"1min": load_avg[0],
"5min": load_avg[1],
"15min": load_avg[2]
}
"load_avg": {"1min": load_avg[0], "5min": load_avg[1], "15min": load_avg[2]},
},
"memory": {
"total": memory.total,
"available": memory.available,
"used": memory.used,
"usage_percent": memory.percent
"usage_percent": memory.percent,
},
"disk": {
"total": disk.total,
"used": disk.used,
"free": disk.free,
"usage_percent": round((disk.used / disk.total) * 100, 1)
"usage_percent": round((disk.used / disk.total) * 100, 1),
},
"network": {
"bytes_sent": net_io.bytes_sent,
"bytes_recv": net_io.bytes_recv,
"packets_sent": net_io.packets_sent,
"packets_recv": net_io.packets_recv
"packets_recv": net_io.packets_recv,
},
"system": {
"uptime": system_uptime,
"boot_time": boot_time
}
"system": {"uptime": system_uptime, "boot_time": boot_time},
}
# Add temperatures if available
if temperatures:
stats["temperatures"] = temperatures
return stats
except Exception as e:
logger.error(f"Error collecting hardware stats: {e}")
return {
"error": str(e)
}
return {"error": str(e)}
def get_processes_summary(self, limit=10):
"""
Get top processes by CPU and memory usage.
@@ -131,44 +121,39 @@ class HardwareStatsCollector:
return {
"processes": [],
"total_processes": 0,
"error": "psutil library not available - cannot collect process statistics"
"error": "psutil library not available - cannot collect process statistics",
}
try:
processes = []
# Get all processes
for proc in psutil.process_iter(['pid', 'name', 'cpu_percent', 'memory_percent', 'memory_info']):
for proc in psutil.process_iter(
["pid", "name", "cpu_percent", "memory_percent", "memory_info"]
):
try:
pinfo = proc.info
# Calculate memory in MB
memory_mb = 0
if pinfo['memory_info']:
memory_mb = pinfo['memory_info'].rss / 1024 / 1024 # RSS in MB
if pinfo["memory_info"]:
memory_mb = pinfo["memory_info"].rss / 1024 / 1024 # RSS in MB
process_data = {
"pid": pinfo['pid'],
"name": pinfo['name'] or 'Unknown',
"cpu_percent": pinfo['cpu_percent'] or 0.0,
"memory_percent": pinfo['memory_percent'] or 0.0,
"memory_mb": round(memory_mb, 1)
"pid": pinfo["pid"],
"name": pinfo["name"] or "Unknown",
"cpu_percent": pinfo["cpu_percent"] or 0.0,
"memory_percent": pinfo["memory_percent"] or 0.0,
"memory_mb": round(memory_mb, 1),
}
processes.append(process_data)
except (psutil.NoSuchProcess, psutil.AccessDenied):
pass
# Sort by CPU usage and get top processes
top_processes = sorted(processes, key=lambda x: x['cpu_percent'], reverse=True)[:limit]
return {
"processes": top_processes,
"total_processes": len(processes)
}
top_processes = sorted(processes, key=lambda x: x["cpu_percent"], reverse=True)[:limit]
return {"processes": top_processes, "total_processes": len(processes)}
except Exception as e:
logger.error(f"Error collecting process stats: {e}")
return {
"processes": [],
"total_processes": 0,
"error": str(e)
}
return {"processes": [], "total_processes": 0, "error": str(e)}
+170 -101
View File
@@ -1,24 +1,34 @@
import base64
import binascii
import json
import logging
import binascii
import base64
import paho.mqtt.client as mqtt
import threading
from datetime import datetime, timedelta
from typing import Callable, Dict, List, Optional
from datetime import datetime, timedelta, UTC
import paho.mqtt.client as mqtt
from nacl.signing import SigningKey
from typing import Callable, Optional, List, Dict
from .. import __version__
# Try to import datetime.UTC (Python 3.11+) otherwise fallback to timezone.utc
try:
from datetime import UTC
except Exception:
from datetime import timezone
UTC = timezone.utc
from repeater import __version__
# Try to import paho-mqtt error code mappings
try:
from paho.mqtt.reasoncodes import ReasonCode
HAS_REASON_CODES = True
except ImportError:
HAS_REASON_CODES = False
logger = logging.getLogger("LetsMeshHandler")
# --------------------------------------------------------------------
# Helper: Base64URL without padding
# --------------------------------------------------------------------
@@ -117,27 +127,27 @@ class _BrokerConnection:
payload_b64 = b64url(json.dumps(payload, separators=(",", ":")).encode())
signing_input = f"{header_b64}.{payload_b64}".encode()
# Sign using LocalIdentity (supports both standard and firmware keys)
try:
signature = self.local_identity.sign(signing_input)
except Exception as e:
logging.error(f"JWT signing failed for {self.broker['name']}: {e}")
logging.error(f" - public_key: {self.public_key}")
logging.error(f" - signing_input length: {len(signing_input)}")
logger.error(f"JWT signing failed for {self.broker['name']}: {e}")
logger.error(f" - public_key: {self.public_key}")
logger.error(f" - signing_input length: {len(signing_input)}")
raise
signature_hex = binascii.hexlify(signature).decode()
token = f"{header_b64}.{payload_b64}.{signature_hex}"
logging.debug(f"JWT token generated for {self.broker['name']}: {token[:50]}...")
logger.debug(f"JWT token generated for {self.broker['name']}: {token[:50]}...")
return token
def _on_connect(self, client, userdata, flags, rc):
"""MQTT connection callback"""
if rc == 0:
logging.info(f"Connected to {self.broker['name']}")
logger.info(f"Connected to {self.broker['name']}")
self._running = True
self._reconnect_attempts = 0 # Reset counter on success
self._schedule_jwt_refresh() # Schedule proactive JWT refresh
@@ -145,22 +155,22 @@ class _BrokerConnection:
self._on_connect_callback(self.broker["name"])
else:
error_msg = get_mqtt_error_message(rc, is_disconnect=False)
logging.error(f"Failed to connect to {self.broker['name']}: {error_msg}")
logger.error(f"Failed to connect to {self.broker['name']}: {error_msg}")
self._schedule_reconnect()
def _on_disconnect(self, client, userdata, rc):
"""MQTT disconnection callback"""
was_running = self._running
self._running = False
if rc != 0: # Unexpected disconnect
error_msg = get_mqtt_error_message(rc, is_disconnect=True)
logging.warning(f"Disconnected from {self.broker['name']} (rc={rc}): {error_msg}")
logger.warning(f"Disconnected from {self.broker['name']} (rc={rc}): {error_msg}")
if was_running: # Only reconnect if we were intentionally connected
self._schedule_reconnect(reason=error_msg)
else:
logging.info(f"Clean disconnect from {self.broker['name']}")
logger.info(f"Clean disconnect from {self.broker['name']}")
if self._on_disconnect_callback:
self._on_disconnect_callback(self.broker["name"])
@@ -168,37 +178,39 @@ class _BrokerConnection:
"""Schedule reconnection with exponential backoff"""
if self._reconnect_timer:
self._reconnect_timer.cancel()
# Exponential backoff: 5s, 10s, 20s, 40s, 80s, up to max
delay = min(5 * (2 ** self._reconnect_attempts), self._max_reconnect_delay)
delay = min(5 * (2**self._reconnect_attempts), self._max_reconnect_delay)
self._reconnect_attempts += 1
logging.info(f"Scheduling reconnect to {self.broker['name']} in {delay}s (attempt {self._reconnect_attempts}, reason: {reason})")
logger.info(
f"Scheduling reconnect to {self.broker['name']} in {delay}s (attempt {self._reconnect_attempts}, reason: {reason})"
)
self._reconnect_timer = threading.Timer(delay, lambda: self._attempt_reconnect(reason))
self._reconnect_timer.daemon = True
self._reconnect_timer.start()
def _attempt_reconnect(self, reason: str = "connection lost"):
"""Attempt to reconnect to broker with fresh JWT"""
try:
logging.info(f"Attempting reconnection to {self.broker['name']} (reason: {reason})...")
logger.info(f"Attempting reconnection to {self.broker['name']} (reason: {reason})...")
# Stop the loop if it's still running (websocket mode requires clean restart)
try:
self.client.loop_stop()
except:
pass
self._set_jwt_credentials()
# Reconnect and restart loop
self.client.connect(self.broker["host"], self.broker["port"], keepalive=60)
self.client.loop_start()
self._loop_running = True
except Exception as e:
logging.error(f"Reconnection failed for {self.broker['name']}: {e}")
logger.error(f"Reconnection failed for {self.broker['name']}: {e}")
self._schedule_reconnect() # Try again later
def _set_jwt_credentials(self):
"""Set JWT token credentials before connecting (CONNECT handshake only)"""
try:
@@ -206,11 +218,11 @@ class _BrokerConnection:
username = f"v1_{self.public_key}"
self.client.username_pw_set(username=username, password=token)
self._connect_time = datetime.now(UTC)
logging.debug(f"JWT credentials set for {self.broker['name']}")
logging.debug(f"Using username: {username}")
logging.debug(f"Public key: {self.public_key[:16]}...{self.public_key[-16:]}")
logger.debug(f"JWT credentials set for {self.broker['name']}")
logger.debug(f"Using username: {username}")
logger.debug(f"Public key: {self.public_key[:16]}...{self.public_key[-16:]}")
except Exception as e:
logging.error(f"Failed to set JWT credentials for {self.broker['name']}: {e}")
logger.error(f"Failed to set JWT credentials for {self.broker['name']}: {e}")
raise
def connect(self):
@@ -229,7 +241,7 @@ class _BrokerConnection:
# Set JWT credentials before CONNECT handshake
self._set_jwt_credentials()
logging.info(
logger.info(
f"Connecting to {self.broker['name']} "
f"({protocol}://{self.broker['host']}:{self.broker['port']}) ..."
)
@@ -242,7 +254,7 @@ class _BrokerConnection:
"""Disconnect from broker"""
self._running = False
self._loop_running = False
# Cancel any pending timers
if self._reconnect_timer:
self._reconnect_timer.cancel()
@@ -250,10 +262,10 @@ class _BrokerConnection:
if self._jwt_refresh_timer:
self._jwt_refresh_timer.cancel()
self._jwt_refresh_timer = None
self.client.loop_stop()
self.client.disconnect()
logging.info(f"Disconnected from {self.broker['name']}")
logger.info(f"Disconnected from {self.broker['name']}")
def publish(self, topic: str, payload: str, retain: bool = False):
"""Publish message to broker"""
@@ -265,7 +277,7 @@ class _BrokerConnection:
def is_connected(self) -> bool:
"""Check if connection is active"""
return self._running
def has_pending_reconnect(self) -> bool:
"""Check if a reconnection is scheduled"""
return self._reconnect_timer is not None and self._reconnect_timer.is_alive()
@@ -281,33 +293,33 @@ class _BrokerConnection:
stagger_offset = self.broker_index * 0.05
refresh_threshold = 0.80 + stagger_offset
return elapsed >= expiry_seconds * refresh_threshold
def _schedule_jwt_refresh(self):
"""Schedule proactive JWT refresh before token expires"""
if self._jwt_refresh_timer:
self._jwt_refresh_timer.cancel()
expiry_seconds = self.jwt_expiry_minutes * 60
# Stagger refresh by 5% per broker to prevent simultaneous disconnects
# Broker 0: 80%, Broker 1: 85%, Broker 2: 90%, etc.
stagger_offset = self.broker_index * 0.05
refresh_threshold = 0.80 + stagger_offset
refresh_delay = expiry_seconds * refresh_threshold
logging.info(
logger.info(
f"JWT refresh scheduled for {self.broker['name']} in {refresh_delay:.0f}s "
f"({refresh_threshold*100:.0f}% of {self.jwt_expiry_minutes}min token lifetime)"
)
self._jwt_refresh_timer = threading.Timer(refresh_delay, self.reconnect_for_token_expiry)
self._jwt_refresh_timer.daemon = True
self._jwt_refresh_timer.start()
def reconnect_for_token_expiry(self):
"""Proactively reconnect with new JWT before current one expires"""
if not self._running:
return
logging.info(f"JWT token expiring soon for {self.broker['name']}, refreshing...")
logger.info(f"JWT token expiring soon for {self.broker['name']}, refreshing...")
self._running = False
self._jwt_refresh_timer = None
self.client.disconnect() # Triggers clean disconnect, then reconnect via timer
@@ -330,7 +342,7 @@ class MeshCoreToMqttJwtPusher:
# Store local identity and get public key
self.local_identity = local_identity
public_key = local_identity.get_public_key().hex().upper()
# Extract values from config
from ..config import get_node_info
@@ -352,27 +364,29 @@ class MeshCoreToMqttJwtPusher:
if broker_index == -2:
# Custom brokers only - no built-in brokers
self.brokers = []
logging.info("Custom broker mode: using only user-defined brokers")
logger.info("Custom broker mode: using only user-defined brokers")
elif broker_index is None or broker_index == -1:
# Connect to all built-in brokers + additional ones
self.brokers = LETSMESH_BROKERS.copy()
logging.info(f"Multi-broker mode: connecting to all {len(LETSMESH_BROKERS)} built-in brokers")
logger.info(
f"Multi-broker mode: connecting to all {len(LETSMESH_BROKERS)} built-in brokers"
)
else:
if broker_index >= len(LETSMESH_BROKERS):
raise ValueError(f"Invalid broker_index {broker_index}")
self.brokers = [LETSMESH_BROKERS[broker_index]]
logging.info(f"Single broker mode: connecting to {self.brokers[0]['name']}")
logger.info(f"Single broker mode: connecting to {self.brokers[0]['name']}")
# Add additional brokers from config
if additional_brokers:
for broker_config in additional_brokers:
if all(k in broker_config for k in ["name", "host", "port", "audience"]):
self.brokers.append(broker_config)
logging.info(f"Added custom broker: {broker_config['name']}")
logger.info(f"Added custom broker: {broker_config['name']}")
else:
logging.warning(f"Skipping invalid broker config: {broker_config}")
logger.warning(f"Skipping invalid broker config: {broker_config}")
# Validate that we have at least one broker
if not self.brokers:
raise ValueError(
@@ -412,7 +426,7 @@ class MeshCoreToMqttJwtPusher:
)
self.connections.append(conn)
logging.info(f"Initialized with {len(self.connections)} broker connection(s)")
logger.info(f"Initialized with {len(self.connections)} broker connection(s)")
def _on_broker_connected(self, broker_name: str):
"""Callback when a broker connects"""
@@ -425,18 +439,18 @@ class MeshCoreToMqttJwtPusher:
# Start heartbeat thread
self._status_task = threading.Thread(target=self._status_heartbeat_loop, daemon=True)
self._status_task.start()
logging.info(f"Started status heartbeat (interval: {self.status_interval}s)")
logger.info(f"Started status heartbeat (interval: {self.status_interval}s)")
def _on_broker_disconnected(self, broker_name: str):
"""Callback when a broker disconnects"""
# Check if all connections are down AND none have pending reconnects
all_down = all(not conn.is_connected() for conn in self.connections)
any_reconnecting = any(conn.has_pending_reconnect() for conn in self.connections)
if all_down and not any_reconnecting:
logging.warning("All broker connections lost with no pending reconnects")
logger.warning("All broker connections lost with no pending reconnects")
elif all_down:
logging.info("All brokers temporarily disconnected, reconnects pending")
logger.info("All brokers temporarily disconnected, reconnects pending")
def connect(self):
"""Establish connections to all configured brokers"""
@@ -448,19 +462,19 @@ class MeshCoreToMqttJwtPusher:
else:
# Stagger additional brokers using background timers
delay = idx * 30
logging.info(f"Staggering connection to {conn.broker['name']} by {delay}s")
logger.info(f"Staggering connection to {conn.broker['name']} by {delay}s")
timer = threading.Timer(delay, lambda c=conn: self._delayed_connect(c))
timer.daemon = True
timer.start()
except Exception as e:
logging.error(f"Failed to connect to {conn.broker['name']}: {e}")
logger.error(f"Failed to connect to {conn.broker['name']}: {e}")
def _delayed_connect(self, conn):
"""Connect a broker after a delay (called by timer)"""
try:
conn.connect()
except Exception as e:
logging.error(f"Failed to connect to {conn.broker['name']}: {e}")
logger.error(f"Failed to connect to {conn.broker['name']}: {e}")
def disconnect(self):
"""Disconnect from all brokers"""
@@ -471,6 +485,7 @@ class MeshCoreToMqttJwtPusher:
self.publish_status(state="offline", origin=self.node_name, radio_config=self.radio_config)
import time
time.sleep(0.5) # Give time for messages to be sent
# Disconnect all brokers
@@ -478,9 +493,9 @@ class MeshCoreToMqttJwtPusher:
try:
conn.disconnect()
except Exception as e:
logging.error(f"Error disconnecting from {conn.broker['name']}: {e}")
logger.error(f"Error disconnecting from {conn.broker['name']}: {e}")
logging.info("Disconnected from all brokers")
logger.info("Disconnected from all brokers")
def _status_heartbeat_loop(self):
"""Background thread that publishes periodic status updates"""
@@ -492,11 +507,11 @@ class MeshCoreToMqttJwtPusher:
self.publish_status(
state="online", origin=self.node_name, radio_config=self.radio_config
)
logging.debug(f"Status heartbeat sent (next in {self.status_interval}s)")
logger.debug(f"Status heartbeat sent (next in {self.status_interval}s)")
time.sleep(self.status_interval)
except Exception as e:
logging.error(f"Status heartbeat error: {e}")
logger.error(f"Status heartbeat error: {e}")
time.sleep(self.status_interval)
# ----------------------------------------------------------------
@@ -567,10 +582,10 @@ class MeshCoreToMqttJwtPusher:
if conn.is_connected():
result = conn.publish(topic, message, retain=retain)
results.append((conn.broker["name"], result))
logging.debug(f"Published to {conn.broker['name']}/{topic}")
logger.debug(f"Published to {conn.broker['name']}/{topic}")
if not results:
logging.warning(f"No active broker connections for publishing to {topic}")
logger.warning(f"No active broker connections for publishing to {topic}")
return results
@@ -579,46 +594,100 @@ class MeshCoreToMqttJwtPusher:
# Helper Functions
# ====================================================================
def get_mqtt_error_message(rc: int, is_disconnect: bool = False) -> str:
"""
Get human-readable MQTT error message.
Args:
rc: Return code from paho-mqtt
is_disconnect: True if from on_disconnect, False if from on_connect
Returns:
Human-readable error message
"""
if HAS_REASON_CODES:
try:
reason = ReasonCode(rc)
return f"{reason.name}: {reason.value}"
except (ValueError, AttributeError):
pass
# Fallback to manual mappings
connect_errors = {
0: "connection accepted",
1: "incorrect protocol version",
2: "invalid client identifier",
3: "server unavailable",
4: "bad username or password (JWT invalid)",
5: "not authorized (JWT signature/format invalid)",
6: "reserved error code",
}
disconnect_errors = {
0: "normal disconnect",
1: "unacceptable protocol version",
2: "identifier rejected",
3: "server unavailable",
4: "bad username or password",
5: "not authorized",
16: "connection lost / protocol error",
17: "client timeout",
}
error_dict = disconnect_errors if is_disconnect else connect_errors
return error_dict.get(rc, f"unknown error code {rc}")
# ReasonCode object has getName() method and value property
reason = ReasonCode(mqtt.CONNACK if not is_disconnect else mqtt.DISCONNECT, identifier=rc)
name = reason.getName() if hasattr(reason, 'getName') else str(reason)
return f"{name} (code {rc})"
except Exception as e:
# Log the exception for debugging
logger.debug(f"Could not decode reason code {rc}: {e}")
# Fallback to manual mappings - Extended with MQTT v5 codes
connect_errors = {
0: "Connection accepted",
1: "Incorrect protocol version",
2: "Invalid client identifier",
3: "Server unavailable",
4: "Bad username or password (JWT invalid)",
5: "Not authorized (JWT signature/format invalid)",
# MQTT v5 codes
128: "Unspecified error",
129: "Malformed packet",
130: "Protocol error",
131: "Implementation specific error",
132: "Unsupported protocol version",
133: "Client identifier not valid",
134: "Bad username or password",
135: "Not authorized",
136: "Server unavailable",
137: "Server busy",
138: "Banned",
140: "Bad authentication method",
144: "Topic name invalid",
149: "Packet too large",
151: "Quota exceeded",
153: "Payload format invalid",
154: "Retain not supported",
155: "QoS not supported",
156: "Use another server",
157: "Server moved",
159: "Connection rate exceeded",
}
disconnect_errors = {
0: "Normal disconnect",
1: "Unacceptable protocol version",
2: "Identifier rejected",
3: "Server unavailable",
4: "Bad username or password",
5: "Not authorized",
7: "Connection lost / network error",
16: "Connection lost / protocol error",
17: "Client timeout",
# MQTT v5 codes
4: "Disconnect with Will message",
128: "Unspecified error",
129: "Malformed packet",
130: "Protocol error",
131: "Implementation specific error",
135: "Not authorized",
137: "Server busy",
139: "Server shutting down",
141: "Keep alive timeout",
142: "Session taken over",
143: "Topic filter invalid",
144: "Topic name invalid",
147: "Receive maximum exceeded",
148: "Topic alias invalid",
149: "Packet too large",
150: "Message rate too high",
151: "Quota exceeded",
152: "Administrative action",
153: "Payload format invalid",
154: "Retain not supported",
155: "QoS not supported",
156: "Use another server",
157: "Server moved",
158: "Shared subscriptions not supported",
159: "Connection rate exceeded",
160: "Maximum connect time",
161: "Subscription identifiers not supported",
162: "Wildcard subscriptions not supported",
}
error_dict = disconnect_errors if is_disconnect else connect_errors
return error_dict.get(rc, f"Unknown error code {rc}")
+8 -7
View File
@@ -1,10 +1,11 @@
import json
import logging
import ssl
from typing import Dict, Any, Optional
from typing import Any, Dict, Optional
try:
import paho.mqtt.client as mqtt
MQTT_AVAILABLE = True
except ImportError:
MQTT_AVAILABLE = False
@@ -102,17 +103,17 @@ class MQTTHandler:
try:
base_topic = self.mqtt_config.get("base_topic", "meshcore/repeater")
topic = f"{base_topic}/{self.node_name}/{record_type}"
if record_type == "packet":
packet_record = PacketRecord.from_packet_record(
record,
origin=self.node_name,
origin_id=self.node_id
record, origin=self.node_name, origin_id=self.node_id
)
if not packet_record:
logger.debug("Skipping MQTT publish: packet missing required data for PacketRecord")
logger.debug(
"Skipping MQTT publish: packet missing required data for PacketRecord"
)
return
payload = packet_record.to_dict()
logger.debug("Publishing packet using PacketRecord format")
else:
+101 -91
View File
@@ -1,10 +1,11 @@
import logging
import time
from pathlib import Path
from typing import Optional, Dict, Any
from typing import Any, Dict, Optional
try:
import rrdtool
RRDTOOL_AVAILABLE = True
except ImportError:
RRDTOOL_AVAILABLE = False
@@ -23,17 +24,18 @@ class RRDToolHandler:
if not self.available:
logger.warning("RRDTool not available - skipping RRD initialization")
return
if self.rrd_path.exists():
logger.info(f"RRD database exists: {self.rrd_path}")
return
try:
rrdtool.create(
str(self.rrd_path),
"--step", "60",
"--start", str(int(time.time() - 60)),
"--step",
"60",
"--start",
str(int(time.time() - 60)),
"DS:rx_count:COUNTER:120:0:U",
"DS:tx_count:COUNTER:120:0:U",
"DS:drop_count:COUNTER:120:0:U",
@@ -42,7 +44,6 @@ class RRDToolHandler:
"DS:avg_length:GAUGE:120:0:256",
"DS:avg_score:GAUGE:120:0:1",
"DS:neighbor_count:GAUGE:120:0:U",
"DS:type_0:COUNTER:120:0:U",
"DS:type_1:COUNTER:120:0:U",
"DS:type_2:COUNTER:120:0:U",
@@ -60,25 +61,24 @@ class RRDToolHandler:
"DS:type_14:COUNTER:120:0:U",
"DS:type_15:COUNTER:120:0:U",
"DS:type_other:COUNTER:120:0:U",
"RRA:AVERAGE:0.5:1:10080",
"RRA:AVERAGE:0.5:5:8640",
"RRA:AVERAGE:0.5:60:8760",
"RRA:MAX:0.5:1:10080",
"RRA:MIN:0.5:1:10080"
"RRA:MIN:0.5:1:10080",
)
logger.info(f"RRD database created: {self.rrd_path}")
except Exception as e:
logger.error(f"Failed to create RRD database: {e}")
def update_packet_metrics(self, record: dict, cumulative_counts: dict):
if not self.available or not self.rrd_path.exists():
return
try:
timestamp = int(record.get("timestamp", time.time()))
try:
info = rrdtool.info(str(self.rrd_path))
last_update = int(info.get("last_update", timestamp - 60))
@@ -86,104 +86,114 @@ class RRDToolHandler:
return
except Exception as e:
logger.debug(f"Failed to get RRD info for packet update: {e}")
rx_total = cumulative_counts.get("rx_total", 0)
tx_total = cumulative_counts.get("tx_total", 0)
drop_total = cumulative_counts.get("drop_total", 0)
type_counts = cumulative_counts.get("type_counts", {})
type_values = []
for i in range(16):
type_values.append(str(type_counts.get(f"type_{i}", 0)))
type_values.append(str(type_counts.get("type_other", 0)))
# Handle None values for TX packets - use 'U' (unknown) for RRD
rssi = record.get('rssi')
snr = record.get('snr')
score = record.get('score')
rssi_val = 'U' if rssi is None else str(rssi)
snr_val = 'U' if snr is None else str(snr)
score_val = 'U' if score is None else str(score)
length_val = str(record.get('length', 0))
basic_values = f"{timestamp}:{rx_total}:{tx_total}:{drop_total}:" \
f"{rssi_val}:{snr_val}:{length_val}:{score_val}:" \
f"U"
rssi = record.get("rssi")
snr = record.get("snr")
score = record.get("score")
rssi_val = "U" if rssi is None else str(rssi)
snr_val = "U" if snr is None else str(snr)
score_val = "U" if score is None else str(score)
length_val = str(record.get("length", 0))
basic_values = (
f"{timestamp}:{rx_total}:{tx_total}:{drop_total}:"
f"{rssi_val}:{snr_val}:{length_val}:{score_val}:"
f"U"
)
type_values_str = ":".join(type_values)
values = f"{basic_values}:{type_values_str}"
rrdtool.update(str(self.rrd_path), values)
except Exception as e:
logger.error(f"Failed to update RRD packet metrics: {e}")
logger.debug(f"RRD packet update failed - record: {record}")
def get_data(self, start_time: Optional[int] = None, end_time: Optional[int] = None,
resolution: str = "average") -> Optional[dict]:
def get_data(
self,
start_time: Optional[int] = None,
end_time: Optional[int] = None,
resolution: str = "average",
) -> Optional[dict]:
if not self.available or not self.rrd_path.exists():
logger.error(f"RRD not available: available={self.available}, rrd_path exists={self.rrd_path.exists()}")
logger.error(
f"RRD not available: available={self.available}, rrd_path exists={self.rrd_path.exists()}"
)
return None
try:
if end_time is None:
end_time = int(time.time())
if start_time is None:
start_time = end_time - (24 * 3600)
fetch_result = rrdtool.fetch(
str(self.rrd_path),
resolution.upper(),
"--start", str(start_time),
"--end", str(end_time)
"--start",
str(start_time),
"--end",
str(end_time),
)
if not fetch_result:
logger.error("RRD fetch returned None")
return None
(start, end, step), data_sources, data_points = fetch_result
if not data_points:
logger.warning("No data points returned from RRD fetch")
result = {
"start_time": start,
"end_time": end,
"step": step,
"data_sources": data_sources,
"packet_types": {},
"metrics": {}
"metrics": {},
}
timestamps = []
current_time = start
for ds in data_sources:
if ds.startswith('type_'):
if 'packet_types' not in result:
result['packet_types'] = {}
result['packet_types'][ds] = []
if ds.startswith("type_"):
if "packet_types" not in result:
result["packet_types"] = {}
result["packet_types"][ds] = []
else:
result['metrics'][ds] = []
result["metrics"][ds] = []
for point in data_points:
timestamps.append(current_time)
for i, value in enumerate(point):
ds_name = data_sources[i]
if ds_name.startswith('type_'):
result['packet_types'][ds_name].append(value)
if ds_name.startswith("type_"):
result["packet_types"][ds_name].append(value)
else:
result['metrics'][ds_name].append(value)
result["metrics"][ds_name].append(value)
current_time += step
result['timestamps'] = timestamps
result["timestamps"] = timestamps
return result
except Exception as e:
logger.error(f"Failed to get RRD data: {e}")
return None
@@ -192,65 +202,65 @@ class RRDToolHandler:
try:
end_time = int(time.time())
start_time = end_time - (hours * 3600)
rrd_data = self.get_data(start_time, end_time)
if not rrd_data or 'packet_types' not in rrd_data:
if not rrd_data or "packet_types" not in rrd_data:
logger.warning(f"No RRD data available")
return None
type_totals = {}
packet_type_names = {
'type_0': 'Request (REQ)',
'type_1': 'Response (RESPONSE)',
'type_2': 'Plain Text Message (TXT_MSG)',
'type_3': 'Acknowledgment (ACK)',
'type_4': 'Node Advertisement (ADVERT)',
'type_5': 'Group Text Message (GRP_TXT)',
'type_6': 'Group Datagram (GRP_DATA)',
'type_7': 'Anonymous Request (ANON_REQ)',
'type_8': 'Returned Path (PATH)',
'type_9': 'Trace (TRACE)',
'type_10': 'Multi-part Packet',
'type_11': 'Control Packet Data',
'type_12': 'Reserved Type 12',
'type_13': 'Reserved Type 13',
'type_14': 'Reserved Type 14',
'type_15': 'Custom Packet (RAW_CUSTOM)',
'type_other': 'Other Types (>15)'
"type_0": "Request (REQ)",
"type_1": "Response (RESPONSE)",
"type_2": "Plain Text Message (TXT_MSG)",
"type_3": "Acknowledgment (ACK)",
"type_4": "Node Advertisement (ADVERT)",
"type_5": "Group Text Message (GRP_TXT)",
"type_6": "Group Datagram (GRP_DATA)",
"type_7": "Anonymous Request (ANON_REQ)",
"type_8": "Returned Path (PATH)",
"type_9": "Trace (TRACE)",
"type_10": "Multi-part Packet (MULTIPART)",
"type_11": "Control (CONTROL)",
"type_12": "Reserved Type 12",
"type_13": "Reserved Type 13",
"type_14": "Reserved Type 14",
"type_15": "Custom Packet (RAW_CUSTOM)",
"type_other": "Other Types (>15)",
}
total_valid_points = 0
for type_key, data_points in rrd_data['packet_types'].items():
for type_key, data_points in rrd_data["packet_types"].items():
valid_points = [p for p in data_points if p is not None]
total_valid_points += len(valid_points)
if total_valid_points < 10:
logger.warning(f"RRD data too sparse ({total_valid_points} valid points)")
return None
for type_key, data_points in rrd_data['packet_types'].items():
for type_key, data_points in rrd_data["packet_types"].items():
valid_points = [p for p in data_points if p is not None]
if len(valid_points) >= 2:
total = max(valid_points) - min(valid_points)
elif len(valid_points) == 1:
total = valid_points[0]
else:
total = 0
type_name = packet_type_names.get(type_key, type_key)
type_totals[type_name] = max(0, total or 0)
result = {
"hours": hours,
"packet_type_totals": type_totals,
"total_packets": sum(type_totals.values()),
"period": f"{hours} hours",
"data_source": "rrd"
"data_source": "rrd",
}
return result
except Exception as e:
logger.error(f"Failed to get packet type stats from RRD: {e}")
return None
return None
File diff suppressed because it is too large Load Diff
+58 -32
View File
@@ -3,15 +3,14 @@ import logging
import time
from datetime import datetime
from pathlib import Path
from typing import Optional, Dict, Any
from typing import Any, Dict, Optional
from .sqlite_handler import SQLiteHandler
from .rrdtool_handler import RRDToolHandler
from .mqtt_handler import MQTTHandler
from .letsmesh_handler import MeshCoreToMqttJwtPusher
from .mqtt_handler import MQTTHandler
from .rrdtool_handler import RRDToolHandler
from .sqlite_handler import SQLiteHandler
from .storage_utils import PacketRecord
logger = logging.getLogger("StorageCollector")
@@ -19,7 +18,13 @@ class StorageCollector:
def __init__(self, config: dict, local_identity=None, repeater_handler=None):
self.config = config
self.repeater_handler = repeater_handler
self.storage_dir = Path(config.get("storage_dir", "/var/lib/pymc_repeater"))
storage_dir_cfg = (
config.get("storage", {}).get("storage_dir")
or config.get("storage_dir")
or "/var/lib/pymc_repeater"
)
self.storage_dir = Path(storage_dir_cfg)
self.storage_dir.mkdir(parents=True, exist_ok=True)
node_name = config.get("repeater", {}).get("node_name", "unknown")
@@ -61,16 +66,18 @@ class StorageCollector:
self.disallowed_packet_types = set()
else:
self.disallowed_packet_types = set()
# Initialize hardware stats collector
from .hardware_stats import HardwareStatsCollector
self.hardware_stats = HardwareStatsCollector()
logger.info("Hardware stats collector initialized")
# Initialize WebSocket handler for real-time updates
self.websocket_available = False
try:
from .websocket_handler import broadcast_packet, broadcast_stats
self.websocket_broadcast_packet = broadcast_packet
self.websocket_broadcast_stats = broadcast_stats
self.websocket_available = True
@@ -86,23 +93,23 @@ class StorageCollector:
"packets_sent": 0,
"packets_received": 0,
"errors": 0,
"queue_len": 0
"queue_len": 0,
}
uptime_secs = int(time.time() - self.repeater_handler.start_time)
# Get airtime stats
airtime_stats = self.repeater_handler.airtime_mgr.get_stats()
# Get latest noise floor from database
noise_floor = None
try:
recent_noise = self.sqlite_handler.get_noise_floor_history(hours=0.5, limit=1)
if recent_noise and len(recent_noise) > 0:
noise_floor = recent_noise[-1].get('noise_floor_dbm')
noise_floor = recent_noise[-1].get("noise_floor_dbm")
except Exception as e:
logger.debug(f"Could not fetch noise floor: {e}")
stats = {
"uptime_secs": uptime_secs,
"packets_sent": self.repeater_handler.forwarded_count,
@@ -110,22 +117,22 @@ class StorageCollector:
"errors": 0,
"queue_len": 0, # N/A for Python repeater
}
# Add airtime stats
if airtime_stats:
stats["tx_air_secs"] = airtime_stats["total_airtime_ms"] / 1000
stats["current_airtime_ms"] = airtime_stats["current_airtime_ms"]
stats["utilization_percent"] = airtime_stats["utilization_percent"]
# Add noise floor if available
if noise_floor is not None:
stats["noise_floor"] = noise_floor
return stats
def record_packet(self, packet_record: dict, skip_letsmesh_if_invalid: bool = True):
"""Record packet to storage and publish to MQTT/LetsMesh
Args:
packet_record: Dictionary containing packet information
skip_letsmesh_if_invalid: If True, don't publish packets with drop_reason to LetsMesh
@@ -140,28 +147,34 @@ class StorageCollector:
cumulative_counts = self.sqlite_handler.get_cumulative_counts()
self.rrd_handler.update_packet_metrics(packet_record, cumulative_counts)
self.mqtt_handler.publish(packet_record, "packet")
# Broadcast to WebSocket clients for real-time updates
if self.websocket_available:
try:
self.websocket_broadcast_packet(packet_record)
# Broadcast 24-hour packet stats (same as /api/packet_stats?hours=24)
packet_stats_24h = self.sqlite_handler.get_packet_stats(hours=24)
uptime_seconds = time.time() - self.repeater_handler.start_time if self.repeater_handler else 0
self.websocket_broadcast_stats({
"packet_stats": packet_stats_24h,
"system_stats": {
"uptime_seconds": uptime_seconds,
uptime_seconds = (
time.time() - self.repeater_handler.start_time if self.repeater_handler else 0
)
self.websocket_broadcast_stats(
{
"packet_stats": packet_stats_24h,
"system_stats": {
"uptime_seconds": uptime_seconds,
},
}
})
)
except Exception as e:
logger.debug(f"WebSocket broadcast failed: {e}")
# Publish to LetsMesh if enabled (skip invalid packets if requested)
if skip_letsmesh_if_invalid and packet_record.get('drop_reason'):
logger.debug(f"Skipping LetsMesh publish for packet with drop_reason: {packet_record.get('drop_reason')}")
if skip_letsmesh_if_invalid and packet_record.get("drop_reason"):
logger.debug(
f"Skipping LetsMesh publish for packet with drop_reason: {packet_record.get('drop_reason')}"
)
else:
self._publish_to_letsmesh(packet_record)
@@ -203,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)
@@ -246,23 +271,24 @@ class StorageCollector:
def get_neighbors(self) -> dict:
return self.sqlite_handler.get_neighbors()
def get_node_name_by_pubkey(self, pubkey: str) -> Optional[str]:
"""
Lookup node name from adverts table by public key.
Args:
pubkey: Public key in hex string format
Returns:
Node name if found, None otherwise
"""
try:
import sqlite3
with sqlite3.connect(self.sqlite_handler.sqlite_path) as conn:
result = conn.execute(
"SELECT node_name FROM adverts WHERE pubkey = ? AND node_name IS NOT NULL ORDER BY last_seen DESC LIMIT 1",
(pubkey,)
(pubkey,),
).fetchone()
return result[0] if result else None
except Exception as e:
+1 -1
View File
@@ -1,6 +1,6 @@
"""Storage utility classes and functions for data acquisition."""
from dataclasses import dataclass, asdict
from dataclasses import asdict, dataclass
from datetime import datetime
from typing import Optional
+14 -8
View File
@@ -1,19 +1,21 @@
"""
WebSocket handler for real-time packet updates - simple ws4py implementation
"""
import json
import logging
import threading
import time
import cherrypy
from urllib.parse import parse_qs
from ws4py.websocket import WebSocket
import cherrypy
from ws4py.server.cherrypyserver import WebSocketPlugin, WebSocketTool
from ws4py.websocket import WebSocket
logger = logging.getLogger("WebSocket")
# Suppress noisy ws4py error logs for normal disconnections (ConnectionResetError, etc.)
logging.getLogger('ws4py').setLevel(logging.CRITICAL)
logging.getLogger("ws4py").setLevel(logging.CRITICAL)
# Global set of connected clients
_connected_clients = set()
@@ -69,14 +71,18 @@ class PacketWebSocket(WebSocket):
# Auth success - store user and add to connected clients
self.user = payload.get("sub") # type: ignore[attr-defined]
_connected_clients.add(self)
logger.info(f"WebSocket connected ({self.user or 'unknown user'}). Total clients: {len(_connected_clients)}")
logger.info(
f"WebSocket connected ({self.user or 'unknown user'}). Total clients: {len(_connected_clients)}"
)
def closed(self, code, reason=None):
"""Called when a WebSocket connection is closed"""
_connected_clients.discard(self)
user = getattr(self, 'user', 'unknown')
logger.info(f"WebSocket disconnected (user: {user}, code: {code}, reason: {reason}). Total clients: {len(_connected_clients)}")
user = getattr(self, "user", "unknown")
logger.info(
f"WebSocket disconnected (user: {user}, code: {code}, reason: {reason}). Total clients: {len(_connected_clients)}"
)
def received_message(self, message):
"""Handle messages from client"""
try:
+519 -160
View File
@@ -11,16 +11,16 @@ from pymc_core.protocol import Packet
from pymc_core.protocol.constants import (
MAX_PATH_SIZE,
PAYLOAD_TYPE_ADVERT,
PAYLOAD_TYPE_ANON_REQ,
PH_ROUTE_MASK,
PH_TYPE_MASK,
PH_TYPE_SHIFT,
ROUTE_TYPE_DIRECT,
ROUTE_TYPE_FLOOD,
ROUTE_TYPE_TRANSPORT_FLOOD,
ROUTE_TYPE_TRANSPORT_DIRECT,
ROUTE_TYPE_TRANSPORT_FLOOD,
)
from pymc_core.protocol.packet_utils import PacketHeaderUtils, PacketTimingUtils
from pymc_core.protocol.packet_utils import PacketHeaderUtils, PacketTimingUtils, PathUtils
from repeater.airtime import AirtimeManager
from repeater.data_acquisition import StorageCollector
@@ -29,6 +29,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):
@@ -37,15 +51,18 @@ class RepeaterHandler(BaseHandler):
return 0xFF # Special marker (not a real payload type)
def __init__(self, config: dict, dispatcher, local_hash: int, send_advert_func=None):
def __init__(self, config: dict, dispatcher, local_hash: int, *, local_hash_bytes=None, send_advert_func=None):
self.config = config
self.dispatcher = dispatcher
self.local_hash = local_hash
self.local_hash_bytes = local_hash_bytes or bytes([local_hash])
self.send_advert_func = send_advert_func
self.airtime_mgr = AirtimeManager(config)
self.seen_packets = OrderedDict()
self.cache_ttl = max(300, config.get("repeater", {}).get("cache_ttl", 3600)) # Min 5 min, default 1 hour
self.cache_ttl = max(
300, config.get("repeater", {}).get("cache_ttl", 3600)
) # Min 5 min, default 1 hour
self.max_cache_size = 1000
self.tx_delay_factor = config.get("delays", {}).get("tx_delay_factor", 1.0)
self.direct_tx_delay_factor = config.get("delays", {}).get("direct_tx_delay_factor", 0.5)
@@ -55,6 +72,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:
@@ -80,6 +100,13 @@ class RepeaterHandler(BaseHandler):
self.recent_packets = []
self.max_recent_packets = 50
self.start_time = time.time()
# Flood/direct and duplicate counters (for GET_STATUS / firmware RepeaterStats)
self.recv_flood_count = 0
self.recv_direct_count = 0
self.sent_flood_count = 0
self.sent_direct_count = 0
self.flood_dup_count = 0
self.direct_dup_count = 0
# Storage collector for persistent packet logging
try:
@@ -95,24 +122,44 @@ 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
self._transport_keys_cache_time = 0
self._transport_keys_cache_ttl = 60 # Cache for 60 seconds
self._start_background_tasks()
async def __call__(self, packet: Packet, metadata: Optional[dict] = None, local_transmission: bool = False) -> None:
async def __call__(
self, packet: Packet, metadata: Optional[dict] = None, local_transmission: bool = False
) -> None:
if metadata is None:
metadata = {}
self.rx_count += 1
# Only count as receive when packet came from the radio (not locally injected)
if not local_transmission:
self.rx_count += 1
route_type = packet.header & PH_ROUTE_MASK
if route_type in (ROUTE_TYPE_FLOOD, ROUTE_TYPE_TRANSPORT_FLOOD):
self.recv_flood_count += 1
elif route_type in (ROUTE_TYPE_DIRECT, ROUTE_TYPE_TRANSPORT_DIRECT):
self.recv_direct_count += 1
try:
rx_airtime_ms = self.airtime_mgr.calculate_airtime(packet.get_raw_length())
self.airtime_mgr.record_rx(rx_airtime_ms)
except Exception:
pass
# Check if we're in monitor mode (receive only, no forwarding)
route_type = packet.header & PH_ROUTE_MASK
# TX mode: forward (repeat on), monitor (no repeat, tenants can TX), no_tx (all TX off)
mode = self.config.get("repeater", {}).get("mode", "forward")
monitor_mode = mode == "monitor"
if mode not in ("forward", "monitor", "no_tx"):
mode = "forward"
allow_forward = mode == "forward"
allow_local_tx = mode != "no_tx"
logger.debug(
f"RX packet: header=0x{packet.header:02x}, payload_len={len(packet.payload or b'')}, "
@@ -128,62 +175,111 @@ class RepeaterHandler(BaseHandler):
transmitted = False
tx_delay_ms = 0.0
drop_reason = None
lbt_attempts = 0
lbt_backoff_delays_ms = None
lbt_channel_busy = False
original_path = list(packet.path) if packet.path else []
original_path_hashes = packet.get_path_hashes_hex()
path_hash_size = packet.get_path_hash_size()
# Process for forwarding (skip if in monitor mode or if this is a local transmission)
result = None if (monitor_mode or local_transmission) else self.process_packet(processed_packet, snr)
forwarded_path = None
# For local transmissions, create a direct transmission result
if local_transmission and not monitor_mode:
# Process for forwarding (skip if repeat disabled or if this is a local transmission)
result = (
None
if (not allow_forward or local_transmission)
else self.process_packet(processed_packet, snr)
)
forwarded_path_hashes = None
# For local transmissions, create a direct transmission result (if local TX allowed)
if local_transmission and allow_local_tx:
# Mark local packet as seen to prevent duplicate processing when received back
self.mark_seen(packet)
# Calculate transmission delay for local packets
delay = self._calculate_tx_delay(packet, snr)
result = (packet, delay)
forwarded_path = list(packet.path) if packet.path else []
forwarded_path_hashes = packet.get_path_hashes_hex()
logger.debug(f"Local transmission: calculated delay {delay:.3f}s")
if result:
fwd_pkt, delay = result
tx_delay_ms = delay * 1000.0
# Capture the forwarded path (after modification)
forwarded_path = list(fwd_pkt.path) if fwd_pkt.path else []
forwarded_path_hashes = fwd_pkt.get_path_hashes_hex()
# Check duty-cycle before scheduling TX
airtime_ms = self.airtime_mgr.calculate_airtime(fwd_pkt.get_raw_length())
can_tx, wait_time = self.airtime_mgr.can_transmit(airtime_ms)
# LBT metadata (set after any TX path that awaits send)
tx_metadata = None
lbt_attempts = 0
lbt_backoff_delays_ms = None
lbt_channel_busy = False
if not can_tx:
logger.warning(
f"Duty-cycle limit exceeded. Airtime={airtime_ms:.1f}ms, "
f"wait={wait_time:.1f}s before retry"
)
self.dropped_count += 1
drop_reason = "Duty cycle limit"
if local_transmission:
# Defer local TX until duty cycle allows instead of dropping
deferred_delay = delay + wait_time
logger.info(
f"Duty-cycle limit: deferring local TX by {wait_time:.1f}s "
f"(airtime={airtime_ms:.1f}ms)"
)
self.forwarded_count += 1
transmitted = True
tx_task = await self.schedule_retransmit(
fwd_pkt, deferred_delay, airtime_ms, local_transmission=True
)
try:
await tx_task
except Exception as e:
self.forwarded_count -= 1
transmitted = False
drop_reason = "TX failed (deferred)"
logger.warning(f"Deferred local TX failed: {e}")
raise
tx_metadata = getattr(fwd_pkt, "_tx_metadata", None)
if tx_metadata:
lbt_attempts = tx_metadata.get("lbt_attempts", 0)
lbt_backoff_delays_ms = tx_metadata.get(
"lbt_backoff_delays_ms", []
)
lbt_channel_busy = tx_metadata.get("lbt_channel_busy", False)
if lbt_attempts > 0:
total_lbt_delay = sum(lbt_backoff_delays_ms)
logger.info(
f"LBT: {lbt_attempts} attempts, "
f"{total_lbt_delay:.0f}ms delay, "
f"backoffs={lbt_backoff_delays_ms}"
)
else:
logger.warning(
f"Duty-cycle limit exceeded. Airtime={airtime_ms:.1f}ms, "
f"wait={wait_time:.1f}s before retry"
)
self.dropped_count += 1
drop_reason = "Duty cycle limit"
else:
self.forwarded_count += 1
transmitted = True
# Schedule retransmit with delay (returns task)
tx_task = await self.schedule_retransmit(fwd_pkt, delay, airtime_ms)
# Wait for transmission to complete to get LBT metadata
await tx_task
# Extract LBT metadata after transmission
tx_metadata = getattr(fwd_pkt, '_tx_metadata', None)
lbt_attempts = 0
lbt_backoff_delays_ms = None
lbt_channel_busy = False
tx_task = await self.schedule_retransmit(
fwd_pkt, delay, airtime_ms, local_transmission=local_transmission
)
try:
await tx_task
except Exception as e:
self.forwarded_count -= 1
transmitted = False
drop_reason = "TX failed"
logger.warning(f"Local TX failed: {e}")
raise
tx_metadata = getattr(fwd_pkt, "_tx_metadata", None)
if tx_metadata:
lbt_attempts = tx_metadata.get('lbt_attempts', 0)
lbt_backoff_delays_ms = tx_metadata.get('lbt_backoff_delays_ms', [])
lbt_channel_busy = tx_metadata.get('lbt_channel_busy', False)
lbt_attempts = tx_metadata.get("lbt_attempts", 0)
lbt_backoff_delays_ms = tx_metadata.get("lbt_backoff_delays_ms", [])
lbt_channel_busy = tx_metadata.get("lbt_channel_busy", False)
if lbt_attempts > 0:
total_lbt_delay = sum(lbt_backoff_delays_ms)
logger.info(
@@ -192,12 +288,16 @@ class RepeaterHandler(BaseHandler):
)
else:
self.dropped_count += 1
# Determine drop reason from process_packet result
if monitor_mode:
drop_reason = "Monitor mode"
# Determine drop reason
if local_transmission and not allow_local_tx:
drop_reason = "No TX mode"
elif not allow_forward:
drop_reason = "Repeat disabled"
else:
# Check if packet has a specific drop reason set by handlers
drop_reason = processed_packet.drop_reason or self._get_drop_reason(processed_packet)
drop_reason = processed_packet.drop_reason or self._get_drop_reason(
processed_packet
)
logger.debug(f"Packet not forwarded: {drop_reason}")
# Extract packet type and route from header
@@ -217,74 +317,42 @@ class RepeaterHandler(BaseHandler):
pkt_hash = packet.calculate_packet_hash().hex().upper()
is_dupe = pkt_hash in self.seen_packets and not transmitted
# Set drop reason for duplicates
# Set drop reason for duplicates and count flood vs direct dups
if is_dupe and drop_reason is None:
drop_reason = "Duplicate"
if is_dupe:
if route_type in (ROUTE_TYPE_FLOOD, ROUTE_TYPE_TRANSPORT_FLOOD):
self.flood_dup_count += 1
elif route_type in (ROUTE_TYPE_DIRECT, ROUTE_TYPE_TRANSPORT_DIRECT):
self.direct_dup_count += 1
path_hash = None
display_path = (
original_path if original_path else (list(packet.path) if packet.path else [])
display_hashes = (
original_path_hashes if original_path_hashes else packet.get_path_hashes_hex()
)
if display_path and len(display_path) > 0:
# Format path as array of uppercase hex bytes
path_bytes = [f"{b:02X}" for b in display_path[:8]] # First 8 bytes max
if len(display_path) > 8:
path_bytes.append("...")
path_hash = "[" + ", ".join(path_bytes) + "]"
src_hash = None
dst_hash = None
# Payload types with dest_hash and src_hash as first 2 bytes
if payload_type in [0x00, 0x01, 0x02, 0x08]:
if hasattr(packet, "payload") and packet.payload and len(packet.payload) >= 2:
dst_hash = f"{packet.payload[0]:02X}"
src_hash = f"{packet.payload[1]:02X}"
# ADVERT packets have source identifier as first byte
elif payload_type == PAYLOAD_TYPE_ADVERT:
if hasattr(packet, "payload") and packet.payload and len(packet.payload) >= 1:
src_hash = f"{packet.payload[0]:02X}"
path_hash = self._path_hash_display(display_hashes)
src_hash, dst_hash = self._packet_record_src_dst(packet, payload_type)
# Record packet for charts
packet_record = {
"timestamp": time.time(),
"header": (
f"0x{packet.header:02X}"
if hasattr(packet, "header") and packet.header is not None
else None
),
"payload": (
packet.payload.hex() if hasattr(packet, "payload") and packet.payload else None
),
"payload_length": (
len(packet.payload) if hasattr(packet, "payload") and packet.payload else 0
),
"type": payload_type,
"route": route_type,
"length": len(packet.payload or b""),
"rssi": rssi,
"snr": snr,
"score": self.calculate_packet_score(
snr, len(packet.payload or b""), self.radio_config["spreading_factor"]
),
"tx_delay_ms": tx_delay_ms,
"transmitted": transmitted,
"is_duplicate": is_dupe,
"packet_hash": pkt_hash[:16],
"drop_reason": drop_reason,
"path_hash": path_hash,
"src_hash": src_hash,
"dst_hash": dst_hash,
"original_path": ([f"{b:02X}" for b in original_path] if original_path else None),
"forwarded_path": (
[f"{b:02X}" for b in forwarded_path] if forwarded_path is not None else None
),
"raw_packet": packet.write_to().hex() if hasattr(packet, "write_to") else None,
"lbt_attempts": lbt_attempts if transmitted else 0,
"lbt_backoff_delays_ms": lbt_backoff_delays_ms if transmitted and lbt_backoff_delays_ms else None,
"lbt_channel_busy": lbt_channel_busy if transmitted else False,
}
packet_record = self._build_packet_record(
packet,
payload_type,
route_type,
rssi,
snr,
original_path_hashes,
path_hash_size,
path_hash,
src_hash,
dst_hash,
transmitted=transmitted,
drop_reason=drop_reason,
is_duplicate=is_dupe,
forwarded_path=forwarded_path_hashes,
tx_delay_ms=tx_delay_ms,
lbt_attempts=lbt_attempts,
lbt_backoff_delays_ms=lbt_backoff_delays_ms,
lbt_channel_busy=lbt_channel_busy,
)
# Store packet record to persistent storage
# Skip LetsMesh only for invalid packets (not duplicates or operational drops)
@@ -339,6 +407,47 @@ class RepeaterHandler(BaseHandler):
if len(self.recent_packets) > self.max_recent_packets:
self.recent_packets.pop(0)
def record_packet_only(self, packet: Packet, metadata: dict) -> None:
"""Record a packet for UI/storage without running forwarding or duplicate logic.
Used by the packet router for injection-only types (ANON_REQ, ACK, PATH, etc.)
so they still appear in the web UI.
"""
if not self.storage:
return
rssi = metadata.get("rssi", 0)
snr = metadata.get("snr", 0.0)
if not hasattr(packet, "header") or packet.header is None:
logger.debug("record_packet_only: packet missing header, skipping")
return
header_info = PacketHeaderUtils.parse_header(packet.header)
payload_type = header_info["payload_type"]
route_type = header_info["route_type"]
original_path_hashes = packet.get_path_hashes_hex()
path_hash_size = packet.get_path_hash_size()
path_hash = self._path_hash_display(original_path_hashes)
src_hash, dst_hash = self._packet_record_src_dst(packet, payload_type)
packet_record = self._build_packet_record(
packet,
payload_type,
route_type,
rssi,
snr,
original_path_hashes,
path_hash_size,
path_hash,
src_hash,
dst_hash,
)
try:
self.storage.record_packet(packet_record, skip_letsmesh_if_invalid=False)
except Exception as e:
logger.error(f"Failed to store packet record (record_packet_only): {e}")
return
self.recent_packets.append(packet_record)
if len(self.recent_packets) > self.max_recent_packets:
self.recent_packets.pop(0)
def cleanup_cache(self):
now = time.time()
@@ -346,6 +455,94 @@ class RepeaterHandler(BaseHandler):
for k in expired:
del self.seen_packets[k]
def _path_hash_display(self, display_hashes) -> Optional[str]:
"""Build path hash string for packet record from path hashes list."""
if not display_hashes:
return None
display = display_hashes[:8]
if len(display_hashes) > 8:
display = list(display) + ["..."]
return "[" + ", ".join(display) + "]"
def _packet_record_src_dst(
self, packet: Packet, payload_type: int
) -> Tuple[Optional[str], Optional[str]]:
"""Return (src_hash, dst_hash) for packet_record from packet and payload_type."""
src_hash = None
dst_hash = None
payload = getattr(packet, "payload", None)
if payload_type in [0x00, 0x01, 0x02, 0x08]:
if payload and len(payload) >= 2:
dst_hash = f"{payload[0]:02X}"
src_hash = f"{payload[1]:02X}"
elif payload_type == PAYLOAD_TYPE_ADVERT:
if payload and len(payload) >= 1:
src_hash = f"{payload[0]:02X}"
elif payload_type == PAYLOAD_TYPE_ANON_REQ:
if payload and len(payload) >= 1:
dst_hash = f"{payload[0]:02X}"
return (src_hash, dst_hash)
def _build_packet_record(
self,
packet: Packet,
payload_type: int,
route_type: int,
rssi: int,
snr: float,
original_path_hashes,
path_hash_size: int,
path_hash: Optional[str],
src_hash: Optional[str],
dst_hash: Optional[str],
*,
transmitted: bool = False,
drop_reason: Optional[str] = None,
is_duplicate: bool = False,
forwarded_path=None,
tx_delay_ms: float = 0.0,
lbt_attempts: int = 0,
lbt_backoff_delays_ms=None,
lbt_channel_busy: bool = False,
) -> dict:
"""Build a single packet_record dict for storage and recent_packets."""
pkt_hash = packet.calculate_packet_hash().hex().upper()
payload = getattr(packet, "payload", None)
payload_len = len(payload or b"")
return {
"timestamp": time.time(),
"header": (
f"0x{packet.header:02X}"
if hasattr(packet, "header") and packet.header is not None
else None
),
"payload": payload.hex() if payload else None,
"payload_length": len(payload) if payload else 0,
"type": payload_type,
"route": route_type,
"length": payload_len,
"rssi": rssi,
"snr": snr,
"score": self.calculate_packet_score(
snr, payload_len, self.radio_config["spreading_factor"]
),
"tx_delay_ms": tx_delay_ms,
"transmitted": transmitted,
"is_duplicate": is_duplicate,
"packet_hash": pkt_hash[:16],
"drop_reason": drop_reason,
"path_hash": path_hash,
"src_hash": src_hash,
"dst_hash": dst_hash,
"original_path": original_path_hashes or None,
"forwarded_path": forwarded_path,
"path_hash_size": path_hash_size,
"raw_packet": packet.write_to().hex() if hasattr(packet, "write_to") else None,
"lbt_attempts": lbt_attempts,
"lbt_backoff_delays_ms": lbt_backoff_delays_ms,
"lbt_channel_busy": lbt_channel_busy,
}
def _get_drop_reason(self, packet: Packet) -> str:
if self.is_duplicate(packet):
@@ -366,10 +563,11 @@ class RepeaterHandler(BaseHandler):
return "Global flood policy disabled"
if route_type == ROUTE_TYPE_DIRECT:
if not packet.path or len(packet.path) == 0:
hash_size = packet.get_path_hash_size()
if not packet.path or len(packet.path) < hash_size:
return "Direct: no path"
next_hop = packet.path[0]
if next_hop != self.local_hash:
next_hop = bytes(packet.path[:hash_size])
if next_hop != self.local_hash_bytes[:hash_size]:
return "Direct: not for us"
# Default reason
@@ -396,10 +594,41 @@ class RepeaterHandler(BaseHandler):
return False, "Empty payload"
if len(packet.path or []) >= MAX_PATH_SIZE:
return False, f"Path length {len(packet.path or [])} exceeds MAX_PATH_SIZE ({MAX_PATH_SIZE})"
return (
False,
f"Path length {len(packet.path or [])} exceeds MAX_PATH_SIZE ({MAX_PATH_SIZE})",
)
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:
@@ -408,11 +637,13 @@ class RepeaterHandler(BaseHandler):
try:
from pymc_core.protocol.transport_keys import calc_transport_code
# Check cache validity
current_time = time.time()
if (self._transport_keys_cache is None or
current_time - self._transport_keys_cache_time > self._transport_keys_cache_ttl):
if (
self._transport_keys_cache is None
or current_time - self._transport_keys_cache_time > self._transport_keys_cache_ttl
):
# Refresh cache
self._transport_keys_cache = self.storage.get_transport_keys()
self._transport_keys_cache_time = current_time
@@ -425,14 +656,16 @@ class RepeaterHandler(BaseHandler):
# Check if packet has transport codes
if not packet.has_transport_codes():
return False, "No transport codes present"
transport_code_0 = packet.transport_codes[0] # First transport code
payload = packet.get_payload()
payload_type = packet.get_payload_type() if hasattr(packet, 'get_payload_type') else ((packet.header & 0x3C) >> 2)
payload_type = (
packet.get_payload_type()
if hasattr(packet, "get_payload_type")
else ((packet.header & 0x3C) >> 2)
)
# Check packet against each transport key
for key_record in transport_keys:
transport_key_encoded = key_record.get("transport_key")
@@ -441,41 +674,48 @@ class RepeaterHandler(BaseHandler):
if not transport_key_encoded:
continue
try:
import base64
transport_key = base64.b64decode(transport_key_encoded)
expected_code = calc_transport_code(transport_key, packet)
if transport_code_0 == expected_code:
logger.debug(f"Transport code validated for key '{key_name}' with policy '{flood_policy}'")
logger.debug(
f"Transport code validated for key '{key_name}' with policy '{flood_policy}'"
)
# Update last_used timestamp for this key
try:
key_id = key_record.get("id")
if key_id:
self.storage.update_transport_key(
key_id=key_id,
last_used=time.time()
key_id=key_id, last_used=time.time()
)
logger.debug(
f"Updated last_used timestamp for transport key '{key_name}'"
)
logger.debug(f"Updated last_used timestamp for transport key '{key_name}'")
except Exception as e:
logger.warning(f"Failed to update last_used for transport key '{key_name}': {e}")
logger.warning(
f"Failed to update last_used for transport key '{key_name}': {e}"
)
# Check flood policy for this key
if flood_policy == "allow":
return True, ""
else:
return False, f"Transport key '{key_name}' flood policy denied"
except Exception as e:
logger.warning(f"Error checking transport key '{key_name}': {e}")
continue
# No matching transport code found
logger.debug(f"Transport code 0x{transport_code_0:04X} denied (checked {len(transport_keys)} keys)")
logger.debug(
f"Transport code 0x{transport_code_0:04X} denied (checked {len(transport_keys)} keys)"
)
return False, "No matching transport code"
except Exception as e:
logger.error(f"Transport code validation error: {e}")
return False, f"Transport code validation error: {e}"
@@ -509,6 +749,11 @@ 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"
@@ -519,22 +764,51 @@ class RepeaterHandler(BaseHandler):
elif not isinstance(packet.path, bytearray):
packet.path = bytearray(packet.path)
packet.path.append(self.local_hash)
packet.path_len = len(packet.path)
hash_size = packet.get_path_hash_size()
hop_count = packet.get_path_hash_count()
# path_len encodes hop count in 6 bits (0-63); adding ourselves must not exceed 63
if hop_count >= 63:
packet.drop_reason = "Path hop count at maximum (63), cannot append"
return None
# Check path won't exceed MAX_PATH_SIZE after append
if (hop_count + 1) * hash_size > MAX_PATH_SIZE:
packet.drop_reason = "Path would exceed MAX_PATH_SIZE"
return None
self.mark_seen(packet)
# Append hash_size bytes from our public key prefix
packet.path.extend(self.local_hash_bytes[:hash_size])
packet.path_len = PathUtils.encode_path_len(hash_size, hop_count + 1)
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()
# Check if we're the next hop
if not packet.path or len(packet.path) == 0:
if not packet.path or len(packet.path) < hash_size:
packet.drop_reason = "Direct: no path"
return None
next_hop = packet.path[0]
if next_hop != self.local_hash:
next_hop = bytes(packet.path[:hash_size])
if next_hop != self.local_hash_bytes[:hash_size]:
packet.drop_reason = "Direct: not for us"
return None
@@ -543,12 +817,12 @@ class RepeaterHandler(BaseHandler):
packet.drop_reason = "Duplicate"
return None
original_path = list(packet.path)
packet.path = bytearray(packet.path[1:])
packet.path_len = len(packet.path)
self.mark_seen(packet)
# 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)
return packet
@staticmethod
@@ -647,25 +921,55 @@ class RepeaterHandler(BaseHandler):
packet.drop_reason = f"Unknown route type: {route_type}"
return None
async def schedule_retransmit(self, fwd_pkt: Packet, delay: float, airtime_ms: float = 0.0):
"""Schedule a packet retransmission with delay and return the task."""
async def schedule_retransmit(
self,
fwd_pkt: Packet,
delay: float,
airtime_ms: float = 0.0,
local_transmission: bool = False,
):
"""Schedule a packet retransmission with delay and return the task.
If local_transmission is True and the first send fails, retry once after
a short delay (handles transient radio/LBT failures).
"""
async def delayed_send():
await asyncio.sleep(delay)
try:
await self.dispatcher.send_packet(fwd_pkt, wait_for_ack=False)
# Record airtime after successful TX
if airtime_ms > 0:
self.airtime_mgr.record_tx(airtime_ms)
packet_size = fwd_pkt.get_raw_length()
logger.info(
f"Retransmitted packet ({packet_size} bytes, {airtime_ms:.1f}ms airtime)"
)
except Exception as e:
logger.error(f"Retransmit failed: {e}")
last_error = None
for attempt in range(2 if local_transmission else 1):
try:
await self.dispatcher.send_packet(fwd_pkt, wait_for_ack=False)
self._record_packet_sent(fwd_pkt)
if airtime_ms > 0:
self.airtime_mgr.record_tx(airtime_ms)
packet_size = fwd_pkt.get_raw_length()
logger.info(
f"Retransmitted packet ({packet_size} bytes, "
f"{airtime_ms:.1f}ms airtime)"
)
return
except Exception as e:
last_error = e
logger.error(f"Retransmit failed: {e}")
if local_transmission and attempt == 0:
logger.info("Retrying local TX in 1s...")
await asyncio.sleep(1.0)
else:
raise
if last_error is not None:
raise last_error
return asyncio.create_task(delayed_send())
def _record_packet_sent(self, packet: Packet) -> None:
"""Record a packet send for flood/direct stats (forwarded and originated)."""
route = getattr(packet, "header", 0) & PH_ROUTE_MASK
if route in (ROUTE_TYPE_FLOOD, ROUTE_TYPE_TRANSPORT_FLOOD):
self.sent_flood_count += 1
elif route in (ROUTE_TYPE_DIRECT, ROUTE_TYPE_TRANSPORT_DIRECT):
self.sent_direct_count += 1
def get_noise_floor(self) -> Optional[float]:
try:
radio = self.dispatcher.radio if self.dispatcher else None
@@ -697,22 +1001,40 @@ 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 {}
# Format local_hash respecting path_hash_mode
phm = self.config.get("mesh", {}).get("path_hash_mode", 0)
_bc = {0: 1, 1: 2, 2: 3}.get(phm, 1)
_hc = _bc * 2
_val = int.from_bytes(bytes(self.local_hash_bytes[:_bc]), "big")
local_hash_str = f"0x{_val:0{_hc}x}"
stats = {
"local_hash": f"0x{self.local_hash:02x}",
"local_hash": local_hash_str,
"duplicate_cache_size": len(self.seen_packets),
"cache_ttl": self.cache_ttl,
"rx_count": self.rx_count,
"forwarded_count": self.forwarded_count,
"dropped_count": self.dropped_count,
"recv_flood_count": self.recv_flood_count,
"recv_direct_count": self.recv_direct_count,
"sent_flood_count": self.sent_flood_count,
"sent_direct_count": self.sent_direct_count,
"flood_dup_count": self.flood_dup_count,
"direct_dup_count": self.direct_dup_count,
"rx_per_hour": rx_per_hour,
"forwarded_per_hour": forwarded_per_hour,
"recent_packets": self.recent_packets,
"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"),
@@ -720,13 +1042,20 @@ class RepeaterHandler(BaseHandler):
"mode": repeater_config.get("mode", "forward"),
"use_score_for_tx": repeater_config.get("use_score_for_tx", False),
"score_threshold": repeater_config.get("score_threshold", 0.3),
"send_advert_interval_hours": repeater_config.get("send_advert_interval_hours", 10),
"send_advert_interval_hours": repeater_config.get(
"send_advert_interval_hours", 10
),
"latitude": repeater_config.get("latitude", 0.0),
"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", {}), # Read from live config, not cached radio_config
"radio": self.config.get(
"radio", {}
), # Read from live config, not cached radio_config
"duty_cycle": {
"max_airtime_percent": max_duty_cycle_percent,
"enforcement_enabled": duty_cycle_config.get("enforcement_enabled", True),
@@ -737,6 +1066,11 @@ class RepeaterHandler(BaseHandler):
"rx_delay_base": delays_config.get("rx_delay_base", 0.0),
},
"web": self.config.get("web", {}), # Include web configuration
"mesh": {
"loop_detect": self.config.get("mesh", {}).get("loop_detect", "off"),
"global_flood_allow": self.config.get("mesh", {}).get("global_flood_allow", True),
"path_hash_mode": self.config.get("mesh", {}).get("path_hash_mode", 0),
},
},
"public_key": None,
}
@@ -757,6 +1091,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)
@@ -783,7 +1118,10 @@ class RepeaterHandler(BaseHandler):
return
try:
noise_floor = self.get_noise_floor()
# Run in executor so KISS modem's blocking _send_command (up to 5s timeout)
# does not block the event loop and hang the process / delay Ctrl+C.
loop = asyncio.get_running_loop()
noise_floor = await loop.run_in_executor(None, self.get_noise_floor)
if noise_floor is not None:
self.storage.record_noise_floor(noise_floor)
logger.debug(f"Recorded noise floor: {noise_floor} dBm")
@@ -792,6 +1130,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)"
@@ -813,14 +1167,19 @@ class RepeaterHandler(BaseHandler):
try:
# Refresh delay factors
self.tx_delay_factor = self.config.get("delays", {}).get("tx_delay_factor", 1.0)
self.direct_tx_delay_factor = self.config.get("delays", {}).get("direct_tx_delay_factor", 0.5)
self.direct_tx_delay_factor = self.config.get("delays", {}).get(
"direct_tx_delay_factor", 0.5
)
# Refresh repeater settings
repeater_config = self.config.get("repeater", {})
self.use_score_for_tx = repeater_config.get("use_score_for_tx", False)
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
+12 -4
View File
@@ -1,11 +1,19 @@
"""Handler helper modules for pyMC Repeater."""
from .trace import TraceHelper
from .discovery import DiscoveryHelper
from .advert import AdvertHelper
from .discovery import DiscoveryHelper
from .login import LoginHelper
from .text import TextHelper
from .path import PathHelper
from .protocol_request import ProtocolRequestHelper
from .text import TextHelper
from .trace import TraceHelper
__all__ = ["TraceHelper", "DiscoveryHelper", "AdvertHelper", "LoginHelper", "TextHelper", "PathHelper", "ProtocolRequestHelper"]
__all__ = [
"TraceHelper",
"DiscoveryHelper",
"AdvertHelper",
"LoginHelper",
"TextHelper",
"PathHelper",
"ProtocolRequestHelper",
]
+16 -8
View File
@@ -58,7 +58,7 @@ class ACL:
sync_since: int = None,
target_identity_hash: int = None,
target_identity_name: str = None,
target_identity_config: dict = None
target_identity_config: dict = None,
) -> tuple[bool, int]:
target_identity_config = target_identity_config or {}
@@ -79,9 +79,11 @@ class ACL:
# Empty strings are treated as "not set"
admin_pwd = identity_settings.get("admin_password") or None
guest_pwd = identity_settings.get("guest_password") or None
if not admin_pwd and not guest_pwd:
logger.error(f"Room server '{target_identity_name}' has no passwords configured! Set admin_password and/or guest_password in settings.")
logger.error(
f"Room server '{target_identity_name}' has no passwords configured! Set admin_password and/or guest_password in settings."
)
return False, 0
else:
# Repeater uses global passwords from its own security section
@@ -91,10 +93,12 @@ class ACL:
f"Repeater passwords - admin: {'SET' if admin_pwd else 'NONE'}, "
f"guest: {'SET' if guest_pwd else 'NONE'}"
)
if target_identity_name:
logger.debug(f"Authenticating for identity '{target_identity_name}' (room_server={is_room_server})")
logger.debug(
f"Authenticating for identity '{target_identity_name}' (room_server={is_room_server})"
)
pub_key = client_identity.get_public_key()[:PUB_KEY_SIZE]
if not password:
@@ -111,8 +115,12 @@ class ACL:
permissions = 0
logger.debug(f"Comparing password (len={len(password)}) against admin/guest")
logger.debug(f"Admin pwd len={len(admin_pwd) if admin_pwd else 0}, Guest pwd len={len(guest_pwd) if guest_pwd else 0}")
logger.debug(f"Password comparison: '{password}' vs admin='{admin_pwd[:4]}...' ({len(admin_pwd)} chars)")
logger.debug(
f"Admin pwd len={len(admin_pwd) if admin_pwd else 0}, Guest pwd len={len(guest_pwd) if guest_pwd else 0}"
)
logger.debug(
f"Password comparison: '{password}' vs admin='{admin_pwd[:4]}...' ({len(admin_pwd)} chars)"
)
if admin_pwd and password == admin_pwd:
permissions = PERM_ACL_ADMIN
logger.info(f"Admin password validated for '{target_identity_name or 'unknown'}'")
+591 -8
View File
@@ -2,20 +2,42 @@
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 collections import OrderedDict
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.
@@ -26,6 +48,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)
@@ -33,6 +56,467 @@ 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)),
)
# --- Advert dedupe config ---
dedupe_cfg = repeater_cfg.get("advert_dedupe", {})
self._advert_dedupe_ttl_seconds = max(1.0, float(dedupe_cfg.get("ttl_seconds", 120.0)))
self._advert_dedupe_max_hashes = max(100, int(dedupe_cfg.get("max_hashes", 10000)))
# --- Per-pubkey state ---
self._bucket_state: Dict[str, dict] = {}
self._penalty_until: Dict[str, float] = {}
self._violation_state: Dict[str, dict] = {}
self._recent_advert_hashes: OrderedDict[str, float] = OrderedDict()
# --- 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_advert_duplicates = 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}, "
f"dedupe=True"
)
# -------------------------------------------------------------------------
# Memory management
# -------------------------------------------------------------------------
def _cleanup_old_state(self, now: float) -> None:
"""Clean up old/expired entries to prevent unbounded memory growth."""
while self._recent_advert_hashes:
oldest_hash, expires_at = next(iter(self._recent_advert_hashes.items()))
if expires_at > now:
break
self._recent_advert_hashes.pop(oldest_hash, None)
while len(self._recent_advert_hashes) > self._advert_dedupe_max_hashes:
self._recent_advert_hashes.popitem(last=False)
expired_penalties = [pk for pk, until in self._penalty_until.items() if until < now]
for pk in expired_penalties:
del self._penalty_until[pk]
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]
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
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, "
f"{len(self._recent_advert_hashes)} advert hashes"
)
def _dedupe_advert_packet_hash(self, packet, now: float) -> bool:
"""Return True when advert packet hash was already seen recently."""
try:
pkt_hash = packet.calculate_packet_hash().hex().upper()
except Exception:
return False
expires_at = self._recent_advert_hashes.get(pkt_hash)
if expires_at and expires_at > now:
# Move to end so hot hashes remain least likely to be evicted
self._recent_advert_hashes.move_to_end(pkt_hash)
return True
# Track first-seen (or expired hash re-seen)
self._recent_advert_hashes[pkt_hash] = now + self._advert_dedupe_ttl_seconds
self._recent_advert_hashes.move_to_end(pkt_hash)
# Opportunistic cleanup to keep memory bounded between scheduled cleanup runs
while len(self._recent_advert_hashes) > self._advert_dedupe_max_hashes:
self._recent_advert_hashes.popitem(last=False)
return False
# -------------------------------------------------------------------------
# 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,
"adverts_duplicate_reheard": self._stats_advert_duplicates,
"drop_rate": round(
self._stats_adverts_dropped / max(1, self._stats_adverts_allowed + self._stats_adverts_dropped),
3,
),
},
"dedupe": {
"enabled": True,
"ttl_seconds": self._advert_dedupe_ttl_seconds,
"tracked_hashes": len(self._recent_advert_hashes),
"max_hashes": self._advert_dedupe_max_hashes,
},
"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.
@@ -63,6 +547,46 @@ class AdvertHelper:
pubkey = advert_data["public_key"]
node_name = advert_data["name"]
contact_type = advert_data["contact_type"]
now = time.time()
# Re-heard duplicates should be measured but not consume limiter tokens.
if self._dedupe_advert_packet_hash(packet, now):
self._stats_advert_duplicates += 1
self._update_metrics_window(now, is_advert=False, is_duplicate=True)
logger.debug(
"Duplicate advert re-heard from '%s' (%s...), skipping limiter/storage",
node_name,
pubkey[:16],
)
return
# Per-pubkey rate limiting (token bucket + penalty box)
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:
@@ -70,16 +594,22 @@ class AdvertHelper:
if pubkey == local_pubkey:
logger.debug("Ignoring own advert in neighbor tracking")
return
# Get route type from packet header
from pymc_core.protocol.constants import PH_ROUTE_MASK
route_type = packet.header & PH_ROUTE_MASK
# Check if this is a new neighbor
current_time = time.time()
# Check if this is a new neighbor (run DB read in thread to avoid blocking event loop)
current_time = now
if pubkey not in self._known_neighbors:
# Only check database if not in cache
current_neighbors = self.storage.get_neighbors() if self.storage else {}
if self.storage:
current_neighbors = await asyncio.to_thread(
self.storage.get_neighbors
)
else:
current_neighbors = {}
is_new_neighbor = pubkey not in current_neighbors
if is_new_neighbor:
@@ -109,12 +639,65 @@ class AdvertHelper:
"zero_hop": zero_hop,
}
# Store to database
# Store to database (run in thread so event loop stays responsive;
# blocking here can cause companion TCP clients to disconnect)
if self.storage:
try:
self.storage.record_advert(advert_record)
await asyncio.to_thread(
self.storage.record_advert,
advert_record,
)
except Exception as e:
logger.error(f"Failed to store advert record: {e}")
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)),
)
# Advert dedupe config
dedupe_cfg = repeater_cfg.get("advert_dedupe", {})
self._advert_dedupe_ttl_seconds = max(1.0, float(dedupe_cfg.get("ttl_seconds", 120.0)))
self._advert_dedupe_max_hashes = max(100, int(dedupe_cfg.get("max_hashes", 10000)))
logger.info(
f"Advert limiter config reloaded: adaptive={self._adaptive_enabled}, "
f"rate_limit={self._rate_limit_enabled}, bucket={self._base_bucket_capacity:.1f}, "
f"dedupe=True"
)
except Exception as e:
logger.error(f"Error reloading advert limiter config: {e}")
+9 -2
View File
@@ -7,6 +7,7 @@ allowing other nodes to discover repeaters on the mesh network.
import asyncio
import logging
from pymc_core.node.handlers.control import ControlHandler
logger = logging.getLogger("DiscoveryHelper")
@@ -21,6 +22,7 @@ class DiscoveryHelper:
packet_injector=None,
node_type: int = 2,
log_fn=None,
debug_log_fn=None,
):
"""
Initialize the discovery helper.
@@ -30,13 +32,18 @@ class DiscoveryHelper:
packet_injector: Callable to inject new packets into the router for sending
node_type: Node type identifier (2 = Repeater)
log_fn: Optional logging function for ControlHandler
debug_log_fn: Optional logging for verbose ControlHandler messages (e.g. callback
presence). Pass logger.debug to avoid INFO noise when forwarding to companions.
"""
self.local_identity = local_identity
self.packet_injector = packet_injector # Function to inject packets into router
self.node_type = node_type
# Create ControlHandler internally as a parsing utility
self.control_handler = ControlHandler(log_fn=log_fn or logger.info)
self.control_handler = ControlHandler(
log_fn=log_fn or logger.info,
debug_log_fn=debug_log_fn,
)
# Set up the request callback
self.control_handler.set_request_callback(self._on_discovery_request)
+19 -11
View File
@@ -8,6 +8,7 @@ import asyncio
import logging
from pymc_core.node.handlers.login_server import LoginServerHandler
from pymc_core.protocol.constants import PAYLOAD_TYPE_ANON_REQ
logger = logging.getLogger("LoginHelper")
@@ -22,9 +23,11 @@ class LoginHelper:
self.handlers = {}
self.acls = {} # Per-identity ACLs keyed by hash_byte
def register_identity(self, name: str, identity, identity_type: str = "room_server", config: dict = None):
def register_identity(
self, name: str, identity, identity_type: str = "room_server", config: dict = None
):
config = config or {}
hash_byte = identity.get_public_key()[0]
# Create ACL for this identity
@@ -79,9 +82,11 @@ class LoginHelper:
self.acls[hash_byte] = identity_acl
logger.info(f"Created ACL for {identity_type} '{name}': hash=0x{hash_byte:02X}")
# Create auth callback that uses this identity's ACL
def auth_callback_with_context(client_identity, shared_secret, password, timestamp, sync_since=None):
def auth_callback_with_context(
client_identity, shared_secret, password, timestamp, sync_since=None
):
return identity_acl.authenticate_client(
client_identity=client_identity,
shared_secret=shared_secret,
@@ -90,9 +95,9 @@ class LoginHelper:
sync_since=sync_since,
target_identity_hash=hash_byte,
target_identity_name=name,
target_identity_config=config
target_identity_config=config,
)
handler = LoginServerHandler(
local_identity=identity,
log_fn=self.log_fn,
@@ -103,11 +108,9 @@ class LoginHelper:
handler.set_send_packet_callback(self._send_packet_with_delay)
self.handlers[hash_byte] = handler
logger.info(f"Registered {identity_type} '{name}' login handler: hash=0x{hash_byte:02X}")
async def process_login_packet(self, packet):
try:
@@ -123,9 +126,14 @@ class LoginHelper:
packet.mark_do_not_retransmit()
return True
else:
logger.debug(f"No login handler registered for hash 0x{dest_hash:02X}, allowing forward")
# ANON_REQ to other nodes (e.g. owner-info to firmware) is normal; skip log to avoid spam
ptype = getattr(packet, "get_payload_type", lambda: None)()
if ptype != PAYLOAD_TYPE_ANON_REQ:
logger.debug(
f"No login handler registered for hash 0x{dest_hash:02X}, allowing forward"
)
return False
except Exception as e:
logger.error(f"Error processing login packet: {e}")
return False
+242 -236
View File
@@ -1,8 +1,9 @@
import logging
from typing import Optional, Dict, Any, Callable
import yaml
from pathlib import Path
import time
from pathlib import Path
from typing import Any, Callable, Dict, Optional
import yaml
logger = logging.getLogger(__name__)
@@ -10,15 +11,15 @@ logger = logging.getLogger(__name__)
class MeshCLI:
def __init__(
self,
config_path: str,
config: Dict[str, Any],
self,
config_path: str,
config: Dict[str, Any],
config_manager, # ConfigManager instance for save & live updates
identity_type: str = "repeater",
enable_regions: bool = True,
send_advert_callback: Optional[Callable] = None,
identity = None,
storage_handler = None
identity=None,
storage_handler=None,
):
self.config_path = Path(config_path)
@@ -29,39 +30,39 @@ class MeshCLI:
self.send_advert_callback = send_advert_callback
self.identity = identity
self.storage_handler = storage_handler
# Get repeater config shortcut
self.repeater_config = config.get('repeater', {})
self.repeater_config = config.get("repeater", {})
def handle_command(self, sender_pubkey: bytes, command: str, is_admin: bool) -> str:
# Check admin permission first
if not is_admin:
return "Error: Admin permission required"
logger.debug(f"handle_command received: '{command}' (len={len(command)})")
# Extract optional sequence prefix (XX|)
prefix = ""
if len(command) > 4 and command[2] == '|':
if len(command) > 4 and command[2] == "|":
prefix = command[:3]
command = command[3:]
logger.debug(f"Extracted prefix: '{prefix}', remaining command: '{command}'")
# Strip leading/trailing whitespace
command = command.strip()
logger.debug(f"After strip: '{command}'")
# Route to appropriate handler
reply = self._route_command(command)
# Add prefix back to reply if present
if prefix:
return prefix + reply
return reply
def _route_command(self, command: str) -> str:
# System commands
if command == "reboot":
return self._cmd_reboot()
@@ -79,97 +80,98 @@ class MeshCLI:
return self._cmd_clear_stats()
elif command == "ver":
return self._cmd_version()
# Get commands
elif command.startswith("get "):
return self._cmd_get(command[4:])
# Set commands
elif command.startswith("set "):
return self._cmd_set(command[4:])
# ACL commands
elif command.startswith("setperm "):
return self._cmd_setperm(command)
elif command == "get acl":
return "Error: Use 'get acl' via serial console only"
# Region commands (repeaters only)
elif command.startswith("region"):
if self.enable_regions:
return self._cmd_region(command)
else:
return "Error: Region commands not available for room servers"
# Neighbor commands
elif command == "neighbors":
return self._cmd_neighbors()
elif command.startswith("neighbor.remove "):
return self._cmd_neighbor_remove(command)
# Temporary radio params
elif command.startswith("tempradio "):
return self._cmd_tempradio(command)
# Sensor commands
elif command.startswith("sensor "):
return "Error: Sensor commands not implemented in Python repeater"
# GPS commands
elif command.startswith("gps"):
return "Error: GPS commands not implemented in Python repeater"
# Logging commands
elif command.startswith("log "):
return self._cmd_log(command)
# Statistics commands
elif command.startswith("stats-"):
return "Error: Stats commands not fully implemented yet"
else:
return "Unknown command"
# ==================== System Commands ====================
def _cmd_reboot(self) -> str:
"""Reboot the repeater process."""
from repeater.service_utils import restart_service
logger.warning("Reboot command received via mesh CLI")
success, message = restart_service()
if success:
return f"OK - {message}"
else:
return f"Error: {message}"
def _cmd_advert(self) -> str:
"""Send self advertisement."""
if not self.send_advert_callback:
logger.warning("Advert command received but no callback configured")
return "Error: Advert functionality not configured"
try:
import asyncio
async def delayed_advert():
"""Delay advert to let CLI response send first (matches C++ 1500ms delay)."""
await asyncio.sleep(1.5)
await self.send_advert_callback()
asyncio.create_task(delayed_advert())
logger.info("Advert scheduled for sending (1.5s delay)")
return "OK - Advert sent"
except Exception as e:
logger.error(f"Failed to schedule advert: {e}", exc_info=True)
return f"Error: {e}"
def _cmd_clock(self, command: str) -> str:
"""Handle clock commands."""
if command == "clock":
# Display current time
import datetime
dt = datetime.datetime.utcnow()
return f"{dt.hour:02d}:{dt.minute:02d} - {dt.day}/{dt.month}/{dt.year} UTC"
elif command == "clock sync":
@@ -177,91 +179,94 @@ class MeshCLI:
return "OK - clock sync not needed (system time used)"
else:
return "Unknown clock command"
def _cmd_time(self, command: str) -> str:
"""Set time - not supported in Python (use system time)."""
return "Error: Time setting not supported (system time is used)"
def _cmd_password(self, command: str) -> str:
"""Change admin password."""
new_password = command[9:].strip()
if not new_password:
return "Error: Password cannot be empty"
# Update security config
if 'security' not in self.config:
self.config['security'] = {}
self.config['security']['password'] = new_password
if "security" not in self.config:
self.config["security"] = {}
self.config["security"]["password"] = new_password
# Save config and live update
try:
self.config_manager.save_to_file()
self.config_manager.live_update_daemon(['security'])
saved, err = self.config_manager.save_to_file()
if not saved:
logger.error(f"Failed to save password: {err}")
return f"Error: Failed to save config: {err}"
self.config_manager.live_update_daemon(["security"])
return f"password now: {new_password}"
except Exception as e:
logger.error(f"Failed to save password: {e}")
return "Error: Failed to save password"
def _cmd_clear_stats(self) -> str:
"""Clear statistics."""
# TODO: Implement stats clearing
return "Error: Not yet implemented"
def _cmd_version(self) -> str:
"""Get version information."""
role = "room_server" if self.identity_type == "room_server" else "repeater"
version = self.config.get('version', '1.0.0')
version = self.config.get("version", "1.0.0")
return f"pyMC_{role} v{version}"
# ==================== Get Commands ====================
def _cmd_get(self, param: str) -> str:
"""Handle get commands."""
param = param.strip()
logger.debug(f"_cmd_get called with param: '{param}' (len={len(param)})")
if param == "af":
af = self.repeater_config.get('airtime_factor', 1.0)
af = self.repeater_config.get("airtime_factor", 1.0)
return f"> {af}"
elif param == "name":
name = self.repeater_config.get('name', 'Unknown')
name = self.repeater_config.get("name", "Unknown")
return f"> {name}"
elif param == "repeat":
disabled = self.repeater_config.get('disable_forward', False)
return f"> {'off' if disabled else 'on'}"
mode = self.repeater_config.get("mode", "forward")
return f"> {'on' if mode == 'forward' else 'off'}"
elif param == "lat":
lat = self.repeater_config.get('latitude', 0.0)
lat = self.repeater_config.get("latitude", 0.0)
return f"> {lat}"
elif param == "lon":
lon = self.repeater_config.get('longitude', 0.0)
lon = self.repeater_config.get("longitude", 0.0)
return f"> {lon}"
elif param == "radio":
radio = self.config.get('radio', {})
freq_hz = radio.get('frequency', 915000000)
bw_hz = radio.get('bandwidth', 125000)
sf = radio.get('spreading_factor', 7)
cr = radio.get('coding_rate', 5)
radio = self.config.get("radio", {})
freq_hz = radio.get("frequency", 915000000)
bw_hz = radio.get("bandwidth", 125000)
sf = radio.get("spreading_factor", 7)
cr = radio.get("coding_rate", 5)
# Convert Hz to MHz for freq, Hz to kHz for bandwidth (match C++ ftoa output)
freq_mhz = freq_hz / 1_000_000.0
bw_khz = bw_hz / 1_000.0
return f"> {freq_mhz},{bw_khz},{sf},{cr}"
elif param == "freq":
freq_hz = self.config.get('radio', {}).get('frequency', 915000000)
freq_hz = self.config.get("radio", {}).get("frequency", 915000000)
freq_mhz = freq_hz / 1_000_000.0
return f"> {freq_mhz}"
elif param == "tx":
power = self.config.get('radio', {}).get('tx_power', 20)
power = self.config.get("radio", {}).get("tx_power", 20)
return f"> {power}"
elif param == "public.key":
if not self.identity:
return "Error: Identity not available"
@@ -272,263 +277,262 @@ class MeshCLI:
except Exception as e:
logger.error(f"Failed to get public key: {e}")
return f"Error: {e}"
elif param == "role":
role = "room_server" if self.identity_type == "room_server" else "repeater"
return f"> {role}"
elif param == "guest.password":
guest_pw = self.config.get('security', {}).get('guest_password', '')
guest_pw = self.config.get("security", {}).get("guest_password", "")
return f"> {guest_pw}"
elif param == "allow.read.only":
allow = self.config.get('security', {}).get('allow_read_only', False)
allow = self.config.get("security", {}).get("allow_read_only", False)
return f"> {'on' if allow else 'off'}"
elif param == "advert.interval":
interval = self.repeater_config.get('advert_interval_minutes', 120)
interval = self.repeater_config.get("advert_interval_minutes", 120)
return f"> {interval}"
elif param == "flood.advert.interval":
interval = self.repeater_config.get('flood_advert_interval_hours', 24)
interval = self.repeater_config.get("flood_advert_interval_hours", 24)
return f"> {interval}"
elif param == "flood.max":
max_flood = self.repeater_config.get('max_flood_hops', 3)
max_flood = self.repeater_config.get("max_flood_hops", 3)
return f"> {max_flood}"
elif param == "rxdelay":
delay = self.repeater_config.get('rx_delay_base', 0.0)
delay = self.repeater_config.get("rx_delay_base", 0.0)
return f"> {delay}"
elif param == "txdelay":
delay = self.repeater_config.get('tx_delay_factor', 1.0)
delay = self.repeater_config.get("tx_delay_factor", 1.0)
return f"> {delay}"
elif param == "direct.txdelay":
delay = self.repeater_config.get('direct_tx_delay_factor', 0.5)
delay = self.repeater_config.get("direct_tx_delay_factor", 0.5)
return f"> {delay}"
elif param == "multi.acks":
acks = self.repeater_config.get('multi_acks', 0)
acks = self.repeater_config.get("multi_acks", 0)
return f"> {acks}"
elif param == "int.thresh":
thresh = self.repeater_config.get('interference_threshold', -120)
thresh = self.repeater_config.get("interference_threshold", -120)
return f"> {thresh}"
elif param == "agc.reset.interval":
interval = self.repeater_config.get('agc_reset_interval', 0)
interval = self.repeater_config.get("agc_reset_interval", 0)
return f"> {interval}"
else:
return f"??: {param}"
# ==================== Set Commands ====================
def _cmd_set(self, param: str) -> str:
"""Handle set commands."""
parts = param.split(None, 1)
if len(parts) < 2:
return "Error: Missing value"
key, value = parts[0], parts[1]
try:
if key == "af":
self.repeater_config['airtime_factor'] = float(value)
self.config_manager.save_to_file()
self.config_manager.live_update_daemon(['repeater'])
self.repeater_config["airtime_factor"] = float(value)
saved, _ = self.config_manager.save_to_file()
self.config_manager.live_update_daemon(["repeater"])
return "OK"
elif key == "name":
self.repeater_config['node_name'] = value
self.config_manager.save_to_file()
self.config_manager.live_update_daemon(['repeater'])
self.repeater_config["node_name"] = value
saved, _ = self.config_manager.save_to_file()
self.config_manager.live_update_daemon(["repeater"])
return "OK"
elif key == "repeat":
disabled = value.lower() == "off"
self.repeater_config['disable_forward'] = disabled
self.config_manager.save_to_file()
self.config_manager.live_update_daemon(['repeater'])
return f"OK - repeat is now {'OFF' if disabled else 'ON'}"
self.repeater_config["mode"] = "forward" if value.lower() == "on" else "monitor"
saved, _ = self.config_manager.save_to_file()
self.config_manager.live_update_daemon(["repeater"])
return f"OK - repeat is now {'ON' if self.repeater_config['mode'] == 'forward' else 'OFF'}"
elif key == "lat":
self.repeater_config['latitude'] = float(value)
self.config_manager.save_to_file()
self.config_manager.live_update_daemon(['repeater'])
self.repeater_config["latitude"] = float(value)
saved, _ = self.config_manager.save_to_file()
self.config_manager.live_update_daemon(["repeater"])
return "OK"
elif key == "lon":
self.repeater_config['longitude'] = float(value)
self.config_manager.save_to_file()
self.config_manager.live_update_daemon(['repeater'])
self.repeater_config["longitude"] = float(value)
saved, _ = self.config_manager.save_to_file()
self.config_manager.live_update_daemon(["repeater"])
return "OK"
elif key == "radio":
# Format: freq bw sf cr
radio_parts = value.split()
if len(radio_parts) != 4:
return "Error: Expected freq bw sf cr"
if 'radio' not in self.config:
self.config['radio'] = {}
self.config['radio']['frequency'] = float(radio_parts[0])
self.config['radio']['bandwidth'] = float(radio_parts[1])
self.config['radio']['spreading_factor'] = int(radio_parts[2])
self.config['radio']['coding_rate'] = int(radio_parts[3])
self.config_manager.save_to_file()
self.config_manager.live_update_daemon(['radio'])
if "radio" not in self.config:
self.config["radio"] = {}
self.config["radio"]["frequency"] = float(radio_parts[0])
self.config["radio"]["bandwidth"] = float(radio_parts[1])
self.config["radio"]["spreading_factor"] = int(radio_parts[2])
self.config["radio"]["coding_rate"] = int(radio_parts[3])
saved, _ = self.config_manager.save_to_file()
self.config_manager.live_update_daemon(["radio"])
return "OK - restart repeater to apply"
elif key == "freq":
if 'radio' not in self.config:
self.config['radio'] = {}
self.config['radio']['frequency'] = float(value)
self.config_manager.save_to_file()
self.config_manager.live_update_daemon(['radio'])
if "radio" not in self.config:
self.config["radio"] = {}
self.config["radio"]["frequency"] = float(value)
saved, _ = self.config_manager.save_to_file()
self.config_manager.live_update_daemon(["radio"])
return "OK - restart repeater to apply"
elif key == "tx":
if 'radio' not in self.config:
self.config['radio'] = {}
self.config['radio']['tx_power'] = int(value)
self.config_manager.save_to_file()
self.config_manager.live_update_daemon(['radio'])
if "radio" not in self.config:
self.config["radio"] = {}
self.config["radio"]["tx_power"] = int(value)
saved, _ = self.config_manager.save_to_file()
self.config_manager.live_update_daemon(["radio"])
return "OK"
elif key == "guest.password":
if 'security' not in self.config:
self.config['security'] = {}
self.config['security']['guest_password'] = value
self.config_manager.save_to_file()
self.config_manager.live_update_daemon(['security'])
if "security" not in self.config:
self.config["security"] = {}
self.config["security"]["guest_password"] = value
saved, _ = self.config_manager.save_to_file()
self.config_manager.live_update_daemon(["security"])
return "OK"
elif key == "allow.read.only":
if 'security' not in self.config:
self.config['security'] = {}
self.config['security']['allow_read_only'] = value.lower() == "on"
self.config_manager.save_to_file()
self.config_manager.live_update_daemon(['security'])
if "security" not in self.config:
self.config["security"] = {}
self.config["security"]["allow_read_only"] = value.lower() == "on"
saved, _ = self.config_manager.save_to_file()
self.config_manager.live_update_daemon(["security"])
return "OK"
elif key == "advert.interval":
mins = int(value)
if mins > 0 and (mins < 60 or mins > 240):
return "Error: interval range is 60-240 minutes"
self.repeater_config['advert_interval_minutes'] = mins
self.config_manager.save_to_file()
self.config_manager.live_update_daemon(['repeater'])
self.repeater_config["advert_interval_minutes"] = mins
saved, _ = self.config_manager.save_to_file()
self.config_manager.live_update_daemon(["repeater"])
return "OK"
elif key == "flood.advert.interval":
hours = int(value)
if (hours > 0 and hours < 3) or hours > 48:
return "Error: interval range is 3-48 hours"
self.repeater_config['flood_advert_interval_hours'] = hours
self.config_manager.save_to_file()
self.config_manager.live_update_daemon(['repeater'])
self.repeater_config["flood_advert_interval_hours"] = hours
saved, _ = self.config_manager.save_to_file()
self.config_manager.live_update_daemon(["repeater"])
return "OK"
elif key == "flood.max":
max_val = int(value)
if max_val > 64:
return "Error: max 64"
self.repeater_config['max_flood_hops'] = max_val
self.config_manager.save_to_file()
self.config_manager.live_update_daemon(['repeater'])
self.repeater_config["max_flood_hops"] = max_val
saved, _ = self.config_manager.save_to_file()
self.config_manager.live_update_daemon(["repeater"])
return "OK"
elif key == "rxdelay":
delay = float(value)
if delay < 0:
return "Error: cannot be negative"
self.repeater_config['rx_delay_base'] = delay
self.config_manager.save_to_file()
self.config_manager.live_update_daemon(['repeater', 'delays'])
self.repeater_config["rx_delay_base"] = delay
saved, _ = self.config_manager.save_to_file()
self.config_manager.live_update_daemon(["repeater", "delays"])
return "OK"
elif key == "txdelay":
delay = float(value)
if delay < 0:
return "Error: cannot be negative"
self.repeater_config['tx_delay_factor'] = delay
self.config_manager.save_to_file()
self.config_manager.live_update_daemon(['repeater', 'delays'])
self.repeater_config["tx_delay_factor"] = delay
saved, _ = self.config_manager.save_to_file()
self.config_manager.live_update_daemon(["repeater", "delays"])
return "OK"
elif key == "direct.txdelay":
delay = float(value)
if delay < 0:
return "Error: cannot be negative"
self.repeater_config['direct_tx_delay_factor'] = delay
self.config_manager.save_to_file()
self.config_manager.live_update_daemon(['repeater', 'delays'])
self.repeater_config["direct_tx_delay_factor"] = delay
saved, _ = self.config_manager.save_to_file()
self.config_manager.live_update_daemon(["repeater", "delays"])
return "OK"
elif key == "multi.acks":
self.repeater_config['multi_acks'] = int(value)
self.config_manager.save_to_file()
self.config_manager.live_update_daemon(['repeater'])
self.repeater_config["multi_acks"] = int(value)
saved, _ = self.config_manager.save_to_file()
self.config_manager.live_update_daemon(["repeater"])
return "OK"
elif key == "int.thresh":
self.repeater_config['interference_threshold'] = int(value)
self.config_manager.save_to_file()
self.config_manager.live_update_daemon(['repeater'])
self.repeater_config["interference_threshold"] = int(value)
saved, _ = self.config_manager.save_to_file()
self.config_manager.live_update_daemon(["repeater"])
return "OK"
elif key == "agc.reset.interval":
interval = int(value)
# Round to nearest multiple of 4
rounded = (interval // 4) * 4
self.repeater_config['agc_reset_interval'] = rounded
self.config_manager.save_to_file()
self.config_manager.live_update_daemon(['repeater'])
self.repeater_config["agc_reset_interval"] = rounded
saved, _ = self.config_manager.save_to_file()
self.config_manager.live_update_daemon(["repeater"])
return f"OK - interval rounded to {rounded}"
else:
return f"unknown config: {key}"
except ValueError as e:
return f"Error: invalid value - {e}"
except Exception as e:
logger.error(f"Set command error: {e}")
return f"Error: {e}"
# ==================== ACL Commands ====================
def _cmd_setperm(self, command: str) -> str:
"""Set permissions for a public key."""
# Format: setperm {pubkey-hex} {permissions-int}
parts = command[8:].split()
if len(parts) < 2:
return "Err - bad params"
pubkey_hex = parts[0]
try:
permissions = int(parts[1])
except ValueError:
return "Err - invalid permissions"
# TODO: Apply permissions via ACL
logger.info(f"setperm command: {pubkey_hex} -> {permissions}")
return "Error: Not yet implemented - use config file"
# ==================== Region Commands ====================
def _cmd_region(self, command: str) -> str:
"""Handle region commands."""
parts = command.split()
if len(parts) == 1:
return "Error: Region commands not implemented in Python repeater"
subcommand = parts[1]
if subcommand == "load":
return "Error: Region commands not implemented"
elif subcommand == "save":
@@ -537,80 +541,82 @@ class MeshCLI:
return "Error: Region commands not implemented"
else:
return "Err - ??"
# ==================== Neighbor Commands ====================
def _cmd_neighbors(self) -> str:
"""List neighbors."""
if not self.storage_handler:
return "Error: Storage not available"
try:
neighbors = self.storage_handler.get_neighbors()
if not neighbors:
return "No neighbors discovered yet"
# Filter to only show repeaters and zero hop nodes
filtered_neighbors = {
pubkey: info for pubkey, info in neighbors.items()
if info.get('is_repeater', False) or info.get('zero_hop', False)
pubkey: info
for pubkey, info in neighbors.items()
if info.get("is_repeater", False) or info.get("zero_hop", False)
}
if not filtered_neighbors:
return "No repeaters or zero hop neighbors discovered yet"
# Format output similar to C++ version
# Format: "<pubkey_prefix> heard Xs ago"
import time
current_time = int(time.time())
lines = []
for pubkey, info in filtered_neighbors.items():
last_seen = info.get('last_seen', 0)
last_seen = info.get("last_seen", 0)
seconds_ago = int(current_time - last_seen)
# Get first 4 bytes of pubkey as hex (match C++ format)
pubkey_short = pubkey[:8] if len(pubkey) >= 8 else pubkey
snr = info.get('snr', 0) or 0
snr = info.get("snr", 0) or 0
# Format: <4byte_hex>:<seconds_ago>:<snr> (matches C++ format)
lines.append(f"{pubkey_short}:{seconds_ago}:{int(snr)}")
return "\n".join(lines)
except Exception as e:
logger.error(f"Failed to list neighbors: {e}", exc_info=True)
return f"Error: {e}"
def _cmd_neighbor_remove(self, command: str) -> str:
"""Remove a neighbor."""
pubkey_hex = command[16:].strip()
if not pubkey_hex:
return "ERR: Missing pubkey"
# TODO: Remove neighbor from routing table
logger.info(f"neighbor.remove: {pubkey_hex}")
return "Error: Not yet implemented"
# ==================== Temporary Radio Commands ====================
def _cmd_tempradio(self, command: str) -> str:
"""Apply temporary radio parameters."""
# Format: tempradio {freq} {bw} {sf} {cr} {timeout_mins}
parts = command[10:].split()
if len(parts) < 5:
return "Error: Expected freq bw sf cr timeout_mins"
try:
freq = float(parts[0])
bw = float(parts[1])
sf = int(parts[2])
cr = int(parts[3])
timeout_mins = int(parts[4])
# Validate
if not (300.0 <= freq <= 2500.0):
return "Error: invalid frequency"
@@ -622,16 +628,16 @@ class MeshCLI:
return "Error: invalid coding rate"
if timeout_mins <= 0:
return "Error: invalid timeout"
# TODO: Apply temporary radio parameters
logger.info(f"tempradio: {freq}MHz {bw}kHz SF{sf} CR4/{cr} for {timeout_mins}min")
return "Error: Not yet implemented"
except ValueError:
return "Error, invalid params"
# ==================== Logging Commands ====================
def _cmd_log(self, command: str) -> str:
"""Handle log commands."""
if command == "log start":
+20 -18
View File
@@ -13,20 +13,20 @@ class PathHelper:
async def process_path_packet(self, packet):
from pymc_core.protocol.crypto import CryptoUtils
try:
if len(packet.payload) < 2:
return False
dest_hash = packet.payload[0]
src_hash = packet.payload[1]
# Get the ACL for this destination identity
identity_acl = self.acl_dict.get(dest_hash)
if not identity_acl:
logger.debug(f"No ACL for dest 0x{dest_hash:02X}, allowing forward")
return False
# Find the client by source hash
client = None
for client_info in identity_acl.get_all_clients():
@@ -34,57 +34,59 @@ class PathHelper:
if pubkey[0] == src_hash:
client = client_info
break
if not client:
logger.debug(f"PATH packet from unknown client 0x{src_hash:02X}, allowing forward")
return False
# Get shared secret for decryption
shared_secret = client.shared_secret
if not shared_secret or len(shared_secret) == 0:
logger.debug(f"No shared secret for client 0x{src_hash:02X}, cannot decrypt PATH")
return False
# Decrypt the PATH packet payload
# Payload format: dest_hash(1) + src_hash(1) + mac(2) + encrypted_data
if len(packet.payload) < 4:
logger.debug(f"PATH packet too short: {len(packet.payload)} bytes")
return False
mac_and_data = packet.payload[2:] # Skip dest_hash and src_hash
aes_key = shared_secret[:16]
decrypted = CryptoUtils.mac_then_decrypt(aes_key, shared_secret, mac_and_data)
if not decrypted:
logger.debug(f"Failed to decrypt PATH packet from 0x{src_hash:02X}")
return False
# Parse decrypted PATH data
# Format: path_len(1) + path[path_len] + extra_type(1) + extra[...]
if len(decrypted) < 1:
logger.debug(f"Decrypted PATH data too short")
return False
path_len = decrypted[0]
if len(decrypted) < 1 + path_len:
logger.debug(f"PATH data truncated: need {1 + path_len} bytes, got {len(decrypted)}")
logger.debug(
f"PATH data truncated: need {1 + path_len} bytes, got {len(decrypted)}"
)
return False
path_data = decrypted[1:1 + path_len]
path_data = decrypted[1 : 1 + path_len]
# Update client's out_path (same as C++ memcpy)
client.out_path = bytearray(path_data)
client.out_path_len = path_len
client.last_activity = int(time.time())
logger.info(
f"Updated out_path for client 0x{src_hash:02X} -> 0x{dest_hash:02X}: "
f"path_len={path_len}, path={[hex(b) for b in path_data]}"
)
# Don't mark as do_not_retransmit - let it forward normally
return False
except Exception as e:
logger.error(f"Error processing PATH packet: {e}", exc_info=True)
return False
+92 -60
View File
@@ -10,12 +10,13 @@ import struct
import time
from pymc_core.node.handlers.protocol_request import (
ProtocolRequestHandler,
REQ_TYPE_GET_STATUS,
REQ_TYPE_GET_TELEMETRY_DATA,
REQ_TYPE_GET_ACCESS_LIST,
REQ_TYPE_GET_NEIGHBOURS,
SERVER_RESPONSE_DELAY_MS
REQ_TYPE_GET_OWNER_INFO,
REQ_TYPE_GET_STATUS,
REQ_TYPE_GET_TELEMETRY_DATA,
SERVER_RESPONSE_DELAY_MS,
ProtocolRequestHandler,
)
logger = logging.getLogger("ProtocolRequestHelper")
@@ -23,8 +24,16 @@ logger = logging.getLogger("ProtocolRequestHelper")
class ProtocolRequestHelper:
"""Provides repeater-specific protocol request handlers."""
def __init__(self, identity_manager, packet_injector=None, acl_dict=None, radio=None, engine=None, neighbor_tracker=None):
def __init__(
self,
identity_manager,
packet_injector=None,
acl_dict=None,
radio=None,
engine=None,
neighbor_tracker=None,
):
self.identity_manager = identity_manager
self.packet_injector = packet_injector
@@ -71,9 +80,10 @@ class ProtocolRequestHelper:
}
logger.info(f"Registered protocol request handler for '{name}': hash=0x{hash_byte:02X}")
def _create_acl_contacts_wrapper(self, acl):
"""Create contacts wrapper from ACL."""
class ACLContactsWrapper:
def __init__(self, identity_acl):
self._acl = identity_acl
@@ -119,60 +129,73 @@ class ProtocolRequestHelper:
return False
def _handle_get_status(self, client, timestamp: int, req_data: bytes):
"""Build 56-byte RepeaterStats (firmware layout from MeshCore simple_repeater/MyMesh.h)."""
# RepeaterStats: uint16 batt, uint16 curr_tx_queue_len, int16 noise_floor, int16 last_rssi,
# uint32 n_packets_recv, n_packets_sent, total_air_time_secs, total_up_time_secs,
# n_sent_flood, n_sent_direct, n_recv_flood, n_recv_direct,
# uint16 err_events, int16 last_snr (×4), uint16 n_direct_dups, n_flood_dups,
# uint32 total_rx_air_time_secs, n_recv_errors → 56 bytes
# C++ struct RepeaterStats (44 bytes total):
# uint16_t batt_milli_volts;
# uint16_t curr_tx_queue_len;
# int16_t noise_floor;
# int16_t last_rssi;
# uint32_t n_packets_recv;
# uint32_t n_packets_sent;
# uint32_t total_air_time_secs;
# uint32_t total_up_time_secs;
# uint32_t n_sent_flood;
# uint32_t n_sent_direct;
# uint32_t n_recv_flood;
# uint32_t n_recv_direct;
# uint32_t err_events;
# int16_t last_snr;
# uint32_t n_direct_dups;
# uint32_t n_flood_dups;
# uint32_t total_rx_air_time_secs;
# Get stats from radio/engine
noise_floor = int(self.radio.get_noise_floor() * 1.0) if self.radio else -120
last_rssi = int(self.radio.last_rssi) if self.radio and hasattr(self.radio, 'last_rssi') else -120
last_snr = int((self.radio.last_snr * 4.0) if self.radio and hasattr(self.radio, 'last_snr') else 0)
# Get packet counts
n_packets_recv = self.radio.packets_received if self.radio and hasattr(self.radio, 'packets_received') else 0
n_packets_sent = self.radio.packets_sent if self.radio and hasattr(self.radio, 'packets_sent') else 0
# Get airtime stats
# Uptime: use engine start_time when available (fixes wrong "20521 days" from time.time())
if self.engine and hasattr(self.engine, "start_time"):
total_up_time_secs = int(time.time() - self.engine.start_time)
else:
total_up_time_secs = 0
# Radio: noise floor, last RSSI, last SNR (firmware stores SNR × 4)
if self.radio:
noise_floor = int(getattr(self.radio, "get_noise_floor", lambda: 0)() or 0)
if callable(getattr(self.radio, "get_last_rssi", None)):
last_rssi = int(self.radio.get_last_rssi() or -120)
else:
last_rssi = int(getattr(self.radio, "last_rssi", -120) or -120)
if callable(getattr(self.radio, "get_last_snr", None)):
last_snr = int((self.radio.get_last_snr() or 0) * 4)
else:
last_snr = int((getattr(self.radio, "last_snr", 0) or 0) * 4)
else:
noise_floor = 0
last_rssi = -120
last_snr = 0
# Packet counts: prefer engine (rx_count, forwarded_count); fall back to radio if present
if self.engine:
n_packets_recv = getattr(self.engine, "rx_count", 0)
n_packets_sent = getattr(self.engine, "forwarded_count", 0)
elif self.radio:
n_packets_recv = getattr(self.radio, "packets_received", 0) or 0
n_packets_sent = getattr(self.radio, "packets_sent", 0) or 0
else:
n_packets_recv = 0
n_packets_sent = 0
# Airtime (AirtimeManager uses total_airtime_ms for TX; total_rx_airtime_ms if we track RX)
total_air_time_secs = 0
total_rx_air_time_secs = 0
if self.engine and hasattr(self.engine, 'airtime_manager'):
total_air_time_secs = int(self.engine.airtime_manager.total_tx_airtime_ms / 1000)
# Get routing stats
n_sent_flood = 0
n_sent_direct = 0
n_recv_flood = 0
n_recv_direct = 0
n_direct_dups = 0
n_flood_dups = 0
if self.engine:
n_sent_flood = getattr(self.engine, 'sent_flood_count', 0)
n_sent_direct = getattr(self.engine, 'sent_direct_count', 0)
n_recv_flood = getattr(self.engine, 'recv_flood_count', 0)
n_recv_direct = getattr(self.engine, 'recv_direct_count', 0)
n_direct_dups = getattr(self.engine, 'direct_dup_count', 0)
n_flood_dups = getattr(self.engine, 'flood_dup_count', 0)
# Pack struct (little-endian)
am = getattr(self.engine, "airtime_mgr", None) or getattr(
self.engine, "airtime_manager", None
)
if am is not None:
total_air_time_secs = int(getattr(am, "total_airtime_ms", 0) or 0) // 1000
total_rx_air_time_secs = int(getattr(am, "total_rx_airtime_ms", 0) or 0) // 1000
# Routing stats (flood/direct and dups - from engine when available)
n_sent_flood = getattr(self.engine, "sent_flood_count", 0) if self.engine else 0
n_sent_direct = getattr(self.engine, "sent_direct_count", 0) if self.engine else 0
n_recv_flood = getattr(self.engine, "recv_flood_count", 0) if self.engine else 0
n_recv_direct = getattr(self.engine, "recv_direct_count", 0) if self.engine else 0
n_direct_dups = getattr(self.engine, "direct_dup_count", 0) if self.engine else 0
n_flood_dups = getattr(self.engine, "flood_dup_count", 0) if self.engine else 0
n_recv_errors = (
int(getattr(self.radio, "crc_error_count", 0) or 0)
if self.radio
else 0
)
# Pack 56-byte RepeaterStats (layout matches firmware)
stats = struct.pack(
'<HHhhIIIIIIIIIhIII',
"<HHhhIIIIIIIIHhHHII",
0, # batt_milli_volts (not available on Pi)
0, # curr_tx_queue_len (TODO)
noise_floor,
@@ -180,7 +203,7 @@ class ProtocolRequestHelper:
n_packets_recv,
n_packets_sent,
total_air_time_secs,
int(time.time()), # total_up_time_secs
total_up_time_secs,
n_sent_flood,
n_sent_direct,
n_recv_flood,
@@ -190,8 +213,17 @@ class ProtocolRequestHelper:
n_direct_dups,
n_flood_dups,
total_rx_air_time_secs,
n_recv_errors,
)
logger.debug(f"GET_STATUS: noise={noise_floor}dBm, rssi={last_rssi}dBm, snr={last_snr/4}dB")
logger.debug(
"GET_STATUS: uptime=%ds, noise=%ddBm, rssi=%ddBm, snr=%.1fdB, rx=%s, tx=%s",
total_up_time_secs,
noise_floor,
last_rssi,
last_snr / 4.0,
n_packets_recv,
n_packets_sent,
)
return stats
+120 -119
View File
@@ -5,10 +5,11 @@ Only users with admin permissions (via ACL) can execute these commands.
"""
import logging
from typing import Optional, Dict, Any, Callable
import yaml
from pathlib import Path
import time
from pathlib import Path
from typing import Any, Callable, Dict, Optional
import yaml
logger = logging.getLogger(__name__)
@@ -23,10 +24,10 @@ class MeshCLI:
def __init__(
self,
config_path: str,
config: Dict[str, Any],
config: Dict[str, Any],
save_config_callback: Callable,
identity_type: str = "repeater",
enable_regions: bool = True
enable_regions: bool = True,
):
"""
Initialize the CLI handler.
@@ -43,10 +44,10 @@ class MeshCLI:
self.save_config = save_config_callback
self.identity_type = identity_type
self.enable_regions = enable_regions
# Get repeater config shortcut
self.repeater_config = config.get('repeater', {})
self.repeater_config = config.get("repeater", {})
def handle_command(self, sender_pubkey: bytes, command: str, is_admin: bool) -> str:
"""
Handle an incoming command from a client.
@@ -64,10 +65,10 @@ class MeshCLI:
return "Error: Admin permission required"
logger.debug(f"handle_command received: '{command}' (len={len(command)})")
# Extract optional sequence prefix (XX|)
prefix = ""
if len(command) > 4 and command[2] == '|':
if len(command) > 4 and command[2] == "|":
prefix = command[:3]
command = command[3:]
logger.debug(f"Extracted prefix: '{prefix}', remaining command: '{command}'")
@@ -180,6 +181,7 @@ class MeshCLI:
if command == "clock":
# Display current time
import datetime
dt = datetime.datetime.utcnow()
return f"{dt.hour:02d}:{dt.minute:02d} - {dt.day}/{dt.month}/{dt.year} UTC"
elif command == "clock sync":
@@ -198,13 +200,13 @@ class MeshCLI:
if not new_password:
return "Error: Password cannot be empty"
# Update security config
if 'security' not in self.config:
self.config['security'] = {}
self.config['security']['password'] = new_password
if "security" not in self.config:
self.config["security"] = {}
self.config["security"]["password"] = new_password
# Save config
try:
self.save_config()
@@ -221,56 +223,56 @@ class MeshCLI:
def _cmd_version(self) -> str:
"""Get version information."""
role = "room_server" if self.identity_type == "room_server" else "repeater"
version = self.config.get('version', '1.0.0')
version = self.config.get("version", "1.0.0")
return f"pyMC_{role} v{version}"
# ==================== Get Commands ====================
def _cmd_get(self, param: str) -> str:
"""Handle get commands."""
param = param.strip()
logger.debug(f"_cmd_get called with param: '{param}' (len={len(param)})")
if param == "af":
af = self.repeater_config.get('airtime_factor', 1.0)
af = self.repeater_config.get("airtime_factor", 1.0)
return f"> {af}"
elif param == "name":
name = self.repeater_config.get('name', 'Unknown')
name = self.repeater_config.get("name", "Unknown")
return f"> {name}"
elif param == "repeat":
disabled = self.repeater_config.get('disable_forward', False)
return f"> {'off' if disabled else 'on'}"
mode = self.repeater_config.get("mode", "forward")
return f"> {'on' if mode == 'forward' else 'off'}"
elif param == "lat":
lat = self.repeater_config.get('latitude', 0.0)
lat = self.repeater_config.get("latitude", 0.0)
return f"> {lat}"
elif param == "lon":
lon = self.repeater_config.get('longitude', 0.0)
lon = self.repeater_config.get("longitude", 0.0)
return f"> {lon}"
elif param == "radio":
radio = self.config.get('radio', {})
freq_hz = radio.get('frequency', 915000000)
bw_hz = radio.get('bandwidth', 125000)
sf = radio.get('spreading_factor', 7)
cr = radio.get('coding_rate', 5)
radio = self.config.get("radio", {})
freq_hz = radio.get("frequency", 915000000)
bw_hz = radio.get("bandwidth", 125000)
sf = radio.get("spreading_factor", 7)
cr = radio.get("coding_rate", 5)
# Convert Hz to MHz for freq, Hz to kHz for bandwidth (match C++ ftoa output)
freq_mhz = freq_hz / 1_000_000.0
bw_khz = bw_hz / 1_000.0
return f"> {freq_mhz},{bw_khz},{sf},{cr}"
elif param == "freq":
freq_hz = self.config.get('radio', {}).get('frequency', 915000000)
freq_hz = self.config.get("radio", {}).get("frequency", 915000000)
freq_mhz = freq_hz / 1_000_000.0
return f"> {freq_mhz}"
elif param == "tx":
power = self.config.get('radio', {}).get('tx_power', 20)
power = self.config.get("radio", {}).get("tx_power", 20)
return f"> {power}"
elif param == "public.key":
# TODO: Get from identity
return "Error: Not yet implemented"
@@ -278,51 +280,51 @@ class MeshCLI:
elif param == "role":
role = "room_server" if self.identity_type == "room_server" else "repeater"
return f"> {role}"
elif param == "guest.password":
guest_pw = self.config.get('security', {}).get('guest_password', '')
guest_pw = self.config.get("security", {}).get("guest_password", "")
return f"> {guest_pw}"
elif param == "allow.read.only":
allow = self.config.get('security', {}).get('allow_read_only', False)
allow = self.config.get("security", {}).get("allow_read_only", False)
return f"> {'on' if allow else 'off'}"
elif param == "advert.interval":
interval = self.repeater_config.get('advert_interval_minutes', 120)
interval = self.repeater_config.get("advert_interval_minutes", 120)
return f"> {interval}"
elif param == "flood.advert.interval":
interval = self.repeater_config.get('flood_advert_interval_hours', 24)
interval = self.repeater_config.get("flood_advert_interval_hours", 24)
return f"> {interval}"
elif param == "flood.max":
max_flood = self.repeater_config.get('max_flood_hops', 3)
max_flood = self.repeater_config.get("max_flood_hops", 3)
return f"> {max_flood}"
elif param == "rxdelay":
delay = self.repeater_config.get('rx_delay_base', 0.0)
delay = self.repeater_config.get("rx_delay_base", 0.0)
return f"> {delay}"
elif param == "txdelay":
delay = self.repeater_config.get('tx_delay_factor', 1.0)
delay = self.repeater_config.get("tx_delay_factor", 1.0)
return f"> {delay}"
elif param == "direct.txdelay":
delay = self.repeater_config.get('direct_tx_delay_factor', 0.5)
delay = self.repeater_config.get("direct_tx_delay_factor", 0.5)
return f"> {delay}"
elif param == "multi.acks":
acks = self.repeater_config.get('multi_acks', 0)
acks = self.repeater_config.get("multi_acks", 0)
return f"> {acks}"
elif param == "int.thresh":
thresh = self.repeater_config.get('interference_threshold', -120)
thresh = self.repeater_config.get("interference_threshold", -120)
return f"> {thresh}"
elif param == "agc.reset.interval":
interval = self.repeater_config.get('agc_reset_interval', 0)
interval = self.repeater_config.get("agc_reset_interval", 0)
return f"> {interval}"
else:
return f"??: {param}"
@@ -335,144 +337,143 @@ class MeshCLI:
return "Error: Missing value"
key, value = parts[0], parts[1]
try:
if key == "af":
self.repeater_config['airtime_factor'] = float(value)
self.repeater_config["airtime_factor"] = float(value)
self.save_config()
return "OK"
elif key == "name":
self.repeater_config['name'] = value
self.repeater_config["name"] = value
self.save_config()
return "OK"
elif key == "repeat":
disabled = value.lower() == "off"
self.repeater_config['disable_forward'] = disabled
self.repeater_config["mode"] = "forward" if value.lower() == "on" else "monitor"
self.save_config()
return f"OK - repeat is now {'OFF' if disabled else 'ON'}"
return f"OK - repeat is now {'ON' if self.repeater_config['mode'] == 'forward' else 'OFF'}"
elif key == "lat":
self.repeater_config['latitude'] = float(value)
self.repeater_config["latitude"] = float(value)
self.save_config()
return "OK"
elif key == "lon":
self.repeater_config['longitude'] = float(value)
self.repeater_config["longitude"] = float(value)
self.save_config()
return "OK"
elif key == "radio":
# Format: freq bw sf cr
radio_parts = value.split()
if len(radio_parts) != 4:
return "Error: Expected freq bw sf cr"
if 'radio' not in self.config:
self.config['radio'] = {}
self.config['radio']['frequency'] = float(radio_parts[0])
self.config['radio']['bandwidth'] = float(radio_parts[1])
self.config['radio']['spreading_factor'] = int(radio_parts[2])
self.config['radio']['coding_rate'] = int(radio_parts[3])
if "radio" not in self.config:
self.config["radio"] = {}
self.config["radio"]["frequency"] = float(radio_parts[0])
self.config["radio"]["bandwidth"] = float(radio_parts[1])
self.config["radio"]["spreading_factor"] = int(radio_parts[2])
self.config["radio"]["coding_rate"] = int(radio_parts[3])
self.save_config()
return "OK - restart repeater to apply"
elif key == "freq":
if 'radio' not in self.config:
self.config['radio'] = {}
self.config['radio']['frequency'] = float(value)
if "radio" not in self.config:
self.config["radio"] = {}
self.config["radio"]["frequency"] = float(value)
self.save_config()
return "OK - restart repeater to apply"
elif key == "tx":
if 'radio' not in self.config:
self.config['radio'] = {}
self.config['radio']['tx_power'] = int(value)
if "radio" not in self.config:
self.config["radio"] = {}
self.config["radio"]["tx_power"] = int(value)
self.save_config()
return "OK"
elif key == "guest.password":
if 'security' not in self.config:
self.config['security'] = {}
self.config['security']['guest_password'] = value
if "security" not in self.config:
self.config["security"] = {}
self.config["security"]["guest_password"] = value
self.save_config()
return "OK"
elif key == "allow.read.only":
if 'security' not in self.config:
self.config['security'] = {}
self.config['security']['allow_read_only'] = value.lower() == "on"
if "security" not in self.config:
self.config["security"] = {}
self.config["security"]["allow_read_only"] = value.lower() == "on"
self.save_config()
return "OK"
elif key == "advert.interval":
mins = int(value)
if mins > 0 and (mins < 60 or mins > 240):
return "Error: interval range is 60-240 minutes"
self.repeater_config['advert_interval_minutes'] = mins
self.repeater_config["advert_interval_minutes"] = mins
self.save_config()
return "OK"
elif key == "flood.advert.interval":
hours = int(value)
if (hours > 0 and hours < 3) or hours > 48:
return "Error: interval range is 3-48 hours"
self.repeater_config['flood_advert_interval_hours'] = hours
self.repeater_config["flood_advert_interval_hours"] = hours
self.save_config()
return "OK"
elif key == "flood.max":
max_val = int(value)
if max_val > 64:
return "Error: max 64"
self.repeater_config['max_flood_hops'] = max_val
self.repeater_config["max_flood_hops"] = max_val
self.save_config()
return "OK"
elif key == "rxdelay":
delay = float(value)
if delay < 0:
return "Error: cannot be negative"
self.repeater_config['rx_delay_base'] = delay
self.repeater_config["rx_delay_base"] = delay
self.save_config()
return "OK"
elif key == "txdelay":
delay = float(value)
if delay < 0:
return "Error: cannot be negative"
self.repeater_config['tx_delay_factor'] = delay
self.repeater_config["tx_delay_factor"] = delay
self.save_config()
return "OK"
elif key == "direct.txdelay":
delay = float(value)
if delay < 0:
return "Error: cannot be negative"
self.repeater_config['direct_tx_delay_factor'] = delay
self.repeater_config["direct_tx_delay_factor"] = delay
self.save_config()
return "OK"
elif key == "multi.acks":
self.repeater_config['multi_acks'] = int(value)
self.repeater_config["multi_acks"] = int(value)
self.save_config()
return "OK"
elif key == "int.thresh":
self.repeater_config['interference_threshold'] = int(value)
self.repeater_config["interference_threshold"] = int(value)
self.save_config()
return "OK"
elif key == "agc.reset.interval":
interval = int(value)
# Round to nearest multiple of 4
rounded = (interval // 4) * 4
self.repeater_config['agc_reset_interval'] = rounded
self.repeater_config["agc_reset_interval"] = rounded
self.save_config()
return f"OK - interval rounded to {rounded}"
else:
return f"unknown config: {key}"
+178 -169
View File
@@ -1,9 +1,9 @@
import asyncio
import logging
import time
from typing import Optional, Dict
from typing import Dict, Optional
from pymc_core.protocol import PacketBuilder, CryptoUtils
from pymc_core.protocol import CryptoUtils, PacketBuilder
from pymc_core.protocol.constants import PAYLOAD_TYPE_TXT_MSG
logger = logging.getLogger("RoomServer")
@@ -51,7 +51,7 @@ class GlobalRateLimiter:
self.min_gap = min_gap_seconds # Minimum gap between consecutive messages
self.lock = asyncio.Lock() # Only one transmission at a time
self.last_release_time = 0
async def acquire(self):
async with self.lock:
@@ -64,7 +64,7 @@ class GlobalRateLimiter:
await asyncio.sleep(wait_time)
# Lock is now held - caller can transmit
# Will be released when context exits
def release(self):
self.last_release_time = time.time()
@@ -82,43 +82,48 @@ class RoomServer:
max_posts: int = 32,
config_path: str = None,
config: dict = None,
config_manager = None,
send_advert_callback = None
config_manager=None,
send_advert_callback=None,
):
self.room_hash = room_hash
self.room_name = room_name
self.local_identity = local_identity
self.db = sqlite_handler
self.packet_injector = packet_injector
self.acl = acl
# Create send_advert callback for this room server
async def send_room_advert():
"""Send advertisement for this specific room server."""
if not packet_injector or not local_identity:
logger.error(f"Room '{room_name}': Cannot send advert - missing injector or identity")
logger.error(
f"Room '{room_name}': Cannot send advert - missing injector or identity"
)
return False
try:
from pymc_core.protocol import PacketBuilder
from pymc_core.protocol.constants import ADVERT_FLAG_HAS_NAME, ADVERT_FLAG_IS_ROOM_SERVER
from pymc_core.protocol.constants import (
ADVERT_FLAG_HAS_NAME,
ADVERT_FLAG_IS_ROOM_SERVER,
)
# Get room config
room_config = config.get('identities', {}).get('room_servers', [])
room_config = config.get("identities", {}).get("room_servers", [])
room_settings = {}
for rs in room_config:
if rs.get('name') == room_name:
room_settings = rs.get('settings', {})
if rs.get("name") == room_name:
room_settings = rs.get("settings", {})
break
# Use room-specific name and location
node_name = room_settings.get('room_name', room_name)
latitude = room_settings.get('latitude', 0.0)
longitude = room_settings.get('longitude', 0.0)
node_name = room_settings.get("room_name", room_name)
latitude = room_settings.get("latitude", 0.0)
longitude = room_settings.get("longitude", 0.0)
flags = ADVERT_FLAG_IS_ROOM_SERVER | ADVERT_FLAG_HAS_NAME
packet = PacketBuilder.create_advert(
local_identity=local_identity,
name=node_name,
@@ -129,21 +134,24 @@ class RoomServer:
flags=flags,
route_type="flood",
)
# Send via packet injector
await packet_injector(packet, wait_for_ack=False)
logger.info(f"Room '{room_name}': Sent flood advert '{node_name}' at ({latitude:.6f}, {longitude:.6f})")
logger.info(
f"Room '{room_name}': Sent flood advert '{node_name}' at ({latitude:.6f}, {longitude:.6f})"
)
return True
except Exception as e:
logger.error(f"Room '{room_name}': Failed to send advert: {e}", exc_info=True)
return False
# Initialize CLI handler for room server commands
self.cli = None
if config_path and config and config_manager:
from .mesh_cli import MeshCLI
self.cli = MeshCLI(
config_path,
config,
@@ -152,10 +160,10 @@ class RoomServer:
enable_regions=False, # Room servers don't support region commands
send_advert_callback=send_room_advert,
identity=local_identity,
storage_handler=sqlite_handler
storage_handler=sqlite_handler,
)
logger.info(f"Room '{room_name}': Initialized CLI handler with identity and storage")
# Enforce hard limit (match C++ MAX_UNSYNCED_POSTS)
if max_posts > MAX_UNSYNCED_POSTS:
logger.warning(
@@ -164,45 +172,45 @@ class RoomServer:
)
max_posts = MAX_UNSYNCED_POSTS
self.max_posts = max_posts
# Round-robin state
self.next_client_idx = 0
self.next_push_time = 0
# Cleanup tracking
self.last_cleanup_time = time.time()
self.cleanup_interval = 600 # Cleanup every 10 minutes
# Safety and monitoring
self.client_post_times = {} # Track last N post times per client for rate limiting
self.consecutive_sync_errors = 0 # Circuit breaker counter
self.last_eviction_check = time.time()
self.eviction_check_interval = 300 # Check every 5 minutes
# Initialize global rate limiter (singleton)
global _global_push_limiter
if _global_push_limiter is None:
_global_push_limiter = GlobalRateLimiter(GLOBAL_MIN_GAP_BETWEEN_MESSAGES)
self.global_limiter = _global_push_limiter
# Background task handle
self._sync_task = None
self._running = False
logger.info(
f"RoomServer initialized: name='{room_name}', "
f"hash=0x{room_hash:02X}, max_posts={max_posts}"
)
async def start(self):
if self._running:
logger.warning(f"Room '{self.room_name}' sync loop already running")
return
self._running = True
self._sync_task = asyncio.create_task(self._sync_loop())
logger.info(f"Room '{self.room_name}' sync loop started")
async def stop(self):
self._running = False
if self._sync_task:
@@ -212,14 +220,14 @@ class RoomServer:
except asyncio.CancelledError:
pass
logger.info(f"Room '{self.room_name}' sync loop stopped")
async def add_post(
self,
client_pubkey: bytes,
message_text: str,
sender_timestamp: int,
txt_type: int = TXT_TYPE_PLAIN,
allow_server_author: bool = False
allow_server_author: bool = False,
) -> bool:
try:
@@ -230,20 +238,19 @@ class RoomServer:
f"exceeds max length ({len(message_text)} > {MAX_MESSAGE_LENGTH}), truncating"
)
message_text = message_text[:MAX_MESSAGE_LENGTH]
# SAFETY: Rate limit per client
client_key = client_pubkey.hex()
now = time.time()
if client_key not in self.client_post_times:
self.client_post_times[client_key] = []
# Remove timestamps older than 1 minute
self.client_post_times[client_key] = [
t for t in self.client_post_times[client_key]
if now - t < 60
t for t in self.client_post_times[client_key] if now - t < 60
]
# Check rate limit
if len(self.client_post_times[client_key]) >= MAX_POSTS_PER_CLIENT_PER_MINUTE:
logger.warning(
@@ -251,13 +258,13 @@ class RoomServer:
f"exceeded rate limit ({MAX_POSTS_PER_CLIENT_PER_MINUTE} posts/min), dropping message"
)
return False
# Record this post time
self.client_post_times[client_key].append(now)
# Use our RTC time for post_timestamp
post_timestamp = time.time()
# Store to database
msg_id = self.db.insert_room_message(
room_hash=f"0x{self.room_hash:02X}",
@@ -265,22 +272,22 @@ class RoomServer:
message_text=message_text,
post_timestamp=post_timestamp,
sender_timestamp=sender_timestamp,
txt_type=txt_type
txt_type=txt_type,
)
if msg_id:
logger.info(
f"Room '{self.room_name}': New post #{msg_id} from "
f"{client_pubkey[:4].hex()}: {message_text[:50]}"
)
# Log authenticated clients count for debugging distribution
all_clients = self.acl.get_all_clients()
logger.info(
f"Room '{self.room_name}': Message stored, will distribute to "
f"{len(all_clients)} authenticated client(s)"
)
# Update client's sync_since to this message's timestamp
# This prevents the author from receiving their own message back
# Also update activity timestamp (they're clearly active if posting)
@@ -292,43 +299,43 @@ class RoomServer:
room_hash=f"0x{self.room_hash:02X}",
client_pubkey=client_pubkey.hex(),
sync_since=post_timestamp, # Don't send this message back to author
last_activity=time.time()
last_activity=time.time(),
)
# Trigger push notification
self.next_push_time = time.time() + (PUSH_NOTIFY_DELAY_MS / 1000.0)
return True
else:
logger.error(f"Failed to store message to database")
return False
except Exception as e:
logger.error(f"Error adding post: {e}", exc_info=True)
return False
async def push_post_to_client(self, client_info, post: Dict) -> bool:
try:
# SAFETY: Global transmission lock - only ONE message on radio at a time
# This is critical because LoRa is serial (0.5-9s airtime per message)
await self.global_limiter.acquire()
# SAFETY: Check client failure backoff
sync_state = self.db.get_client_sync(
room_hash=f"0x{self.room_hash:02X}",
client_pubkey=client_info.id.get_public_key().hex()
client_pubkey=client_info.id.get_public_key().hex(),
)
if sync_state:
failures = sync_state.get('push_failures', 0)
failures = sync_state.get("push_failures", 0)
if failures > 0:
# Apply exponential backoff
backoff_idx = min(failures, len(RETRY_BACKOFF_SCHEDULE) - 1)
backoff_delay = RETRY_BACKOFF_SCHEDULE[backoff_idx]
last_failure_time = sync_state.get('updated_at', 0)
last_failure_time = sync_state.get("updated_at", 0)
time_since_failure = time.time() - last_failure_time
if time_since_failure < backoff_delay:
wait_time = backoff_delay - time_since_failure
logger.debug(
@@ -336,33 +343,30 @@ class RoomServer:
f"in backoff (failure {failures}), waiting {wait_time:.0f}s"
)
return False # Skip this client for now
# Build message payload
timestamp = int(time.time())
flags = (TXT_TYPE_SIGNED_PLAIN << 2) # Include author prefix
flags = TXT_TYPE_SIGNED_PLAIN << 2 # Include author prefix
# Author prefix (first 4 bytes of pubkey)
author_pubkey = bytes.fromhex(post['author_pubkey'])
author_pubkey = bytes.fromhex(post["author_pubkey"])
author_prefix = author_pubkey[:4]
# Plaintext: timestamp(4) + flags(1) + author_prefix(4) + text
message_bytes = post['message_text'].encode('utf-8')
message_bytes = post["message_text"].encode("utf-8")
plaintext = (
timestamp.to_bytes(4, 'little') +
bytes([flags]) +
author_prefix +
message_bytes
timestamp.to_bytes(4, "little") + bytes([flags]) + author_prefix + message_bytes
)
# Calculate expected ACK (same algorithm as pymc_core)
attempt = 0
pack_data = PacketBuilder._pack_timestamp_data(timestamp, attempt, message_bytes)
ack_hash = CryptoUtils.sha256(pack_data + client_info.id.get_public_key())[:4]
expected_ack_crc = int.from_bytes(ack_hash, 'little')
expected_ack_crc = int.from_bytes(ack_hash, "little")
# Determine routing based on stored out_path
route_type = "flood" if client_info.out_path_len < 0 else "direct"
# Create datagram
packet = PacketBuilder.create_datagram(
ptype=PAYLOAD_TYPE_TXT_MSG,
@@ -370,41 +374,42 @@ class RoomServer:
local_identity=self.local_identity,
secret=client_info.shared_secret,
plaintext=plaintext,
route_type=route_type
route_type=route_type,
)
# Add stored path for direct routing
if route_type == "direct" and len(client_info.out_path) > 0:
packet.path = bytearray(client_info.out_path[:client_info.out_path_len])
packet.path = bytearray(client_info.out_path[: client_info.out_path_len])
packet.path_len = client_info.out_path_len
# Calculate ACK timeout
if route_type == "flood":
ack_timeout = PUSH_ACK_TIMEOUT_FLOOD_MS / 1000.0
else:
path_len = client_info.out_path_len if client_info.out_path_len >= 0 else 0
ack_timeout = (PUSH_TIMEOUT_BASE_MS + PUSH_ACK_TIMEOUT_FACTOR_MS * (path_len + 1)) / 1000.0
ack_timeout = (
PUSH_TIMEOUT_BASE_MS + PUSH_ACK_TIMEOUT_FACTOR_MS * (path_len + 1)
) / 1000.0
# Update client sync state with pending ACK
self.db.upsert_client_sync(
room_hash=f"0x{self.room_hash:02X}",
client_pubkey=client_info.id.get_public_key().hex(),
pending_ack_crc=expected_ack_crc,
push_post_timestamp=post['post_timestamp'],
ack_timeout_time=time.time() + ack_timeout
push_post_timestamp=post["post_timestamp"],
ack_timeout_time=time.time() + ack_timeout,
)
# Send packet (dispatcher will track ACK automatically)
# This blocks for the entire transmission duration (0.5-9 seconds)
success = await self.packet_injector(packet, wait_for_ack=True)
# SAFETY: Release transmission lock AFTER send completes
self.global_limiter.release()
if success:
# ACK received! Update sync state
await self._handle_ack_received(
client_info.id.get_public_key(),
post['post_timestamp']
client_info.id.get_public_key(), post["post_timestamp"]
)
logger.info(
f"Room '{self.room_name}': Pushed post to "
@@ -417,13 +422,13 @@ class RoomServer:
f"Room '{self.room_name}': Push to "
f"0x{client_info.id.get_public_key()[0]:02X} timed out"
)
return success
except Exception as e:
logger.error(f"Error pushing post to client: {e}", exc_info=True)
return False
async def _handle_ack_received(self, client_pubkey: bytes, post_timestamp: float):
try:
@@ -434,29 +439,28 @@ class RoomServer:
sync_since=post_timestamp,
pending_ack_crc=0,
push_failures=0,
last_activity=time.time()
last_activity=time.time(),
)
except Exception as e:
logger.error(f"Error handling ACK received: {e}")
async def _handle_ack_timeout(self, client_pubkey: bytes):
try:
# Get current sync state
sync_state = self.db.get_client_sync(
room_hash=f"0x{self.room_hash:02X}",
client_pubkey=client_pubkey.hex()
room_hash=f"0x{self.room_hash:02X}", client_pubkey=client_pubkey.hex()
)
if sync_state:
# Increment failure counter, clear pending_ack
failures = sync_state.get('push_failures', 0) + 1
failures = sync_state.get("push_failures", 0) + 1
self.db.upsert_client_sync(
room_hash=f"0x{self.room_hash:02X}",
client_pubkey=client_pubkey.hex(),
push_failures=failures,
pending_ack_crc=0
pending_ack_crc=0,
)
if failures >= 3:
logger.warning(
f"Room '{self.room_name}': Client 0x{client_pubkey[0]:02X} "
@@ -464,86 +468,86 @@ class RoomServer:
)
except Exception as e:
logger.error(f"Error handling ACK timeout: {e}")
def get_unsynced_count(self, client_pubkey: bytes) -> int:
try:
# Get client's sync state
sync_state = self.db.get_client_sync(
room_hash=f"0x{self.room_hash:02X}",
client_pubkey=client_pubkey.hex()
room_hash=f"0x{self.room_hash:02X}", client_pubkey=client_pubkey.hex()
)
sync_since = sync_state['sync_since'] if sync_state else 0
sync_since = sync_state["sync_since"] if sync_state else 0
return self.db.get_unsynced_count(
room_hash=f"0x{self.room_hash:02X}",
client_pubkey=client_pubkey.hex(),
sync_since=sync_since
sync_since=sync_since,
)
except Exception as e:
logger.error(f"Error getting unsynced count: {e}")
return 0
async def _evict_failed_clients(self):
try:
now = time.time()
all_sync_states = self.db.get_all_room_clients(f"0x{self.room_hash:02X}")
for sync_state in all_sync_states:
client_pubkey_hex = sync_state['client_pubkey']
push_failures = sync_state.get('push_failures', 0)
last_activity = sync_state.get('last_activity', 0)
client_pubkey_hex = sync_state["client_pubkey"]
push_failures = sync_state.get("push_failures", 0)
last_activity = sync_state.get("last_activity", 0)
# Skip already-evicted clients (marked with last_activity=0)
if last_activity == 0:
continue
evict = False
reason = ""
# Check max failures
if push_failures >= MAX_PUSH_FAILURES:
evict = True
reason = f"max failures ({push_failures})"
# Check inactivity timeout
elif now - last_activity > INACTIVE_CLIENT_TIMEOUT:
evict = True
reason = f"inactive for {(now - last_activity) / 60:.0f} minutes"
if evict:
# Remove from database
self.db.upsert_client_sync(
room_hash=f"0x{self.room_hash:02X}",
client_pubkey=client_pubkey_hex,
last_activity=0 # Mark as evicted
last_activity=0, # Mark as evicted
)
# Remove from ACL
client_pubkey = bytes.fromhex(client_pubkey_hex)
self.acl.remove_client(client_pubkey)
logger.info(
f"Room '{self.room_name}': Evicted client "
f"0x{client_pubkey[0]:02X} ({reason})"
)
except Exception as e:
logger.error(f"Error evicting failed clients: {e}", exc_info=True)
async def _sync_loop(self):
# SAFETY: Stagger room startup to prevent thundering herd
import random
startup_delay = random.uniform(0, 5) # 0-5 second random delay
await asyncio.sleep(startup_delay)
logger.info(f"Room '{self.room_name}' sync loop starting (delayed {startup_delay:.1f}s)")
while self._running:
try:
await asyncio.sleep(SYNC_PUSH_INTERVAL_MS / 1000.0)
# SAFETY: Circuit breaker - stop if too many consecutive errors
if self.consecutive_sync_errors >= MAX_CONSECUTIVE_SYNC_ERRORS:
logger.error(
@@ -553,21 +557,21 @@ class RoomServer:
await asyncio.sleep(DB_ERROR_RETRY_DELAY)
self.consecutive_sync_errors = 0 # Reset after pause
continue
# SAFETY: Periodic eviction check (every 5 minutes)
if time.time() - self.last_eviction_check > self.eviction_check_interval:
await self._evict_failed_clients()
self.last_eviction_check = time.time()
# Periodic cleanup check (every 10 minutes)
if time.time() - self.last_cleanup_time > self.cleanup_interval:
await self._cleanup_old_messages()
self.last_cleanup_time = time.time()
# Check if it's time to push
if time.time() < self.next_push_time:
continue
# Get all clients for this room
all_clients = self.acl.get_all_clients()
if not all_clients:
@@ -575,60 +579,66 @@ class RoomServer:
# to avoid log spam when room is idle
self.next_push_time = time.time() + 1.0 # Check again in 1 second
continue
# SAFETY: Limit number of clients
if len(all_clients) > MAX_CLIENTS_PER_ROOM:
logger.warning(
f"Room '{self.room_name}': Too many clients ({len(all_clients)} > {MAX_CLIENTS_PER_ROOM})"
)
all_clients = all_clients[:MAX_CLIENTS_PER_ROOM]
# Check for ACK timeouts first
await self._check_ack_timeouts()
# Track how many clients we've checked in this iteration
clients_checked = 0
max_checks = len(all_clients)
# Round-robin: find next active client
while clients_checked < max_checks:
# Get next client
if self.next_client_idx >= len(all_clients):
self.next_client_idx = 0
client = all_clients[self.next_client_idx]
self.next_client_idx = (self.next_client_idx + 1) % len(all_clients)
clients_checked += 1
# Get client sync state
sync_state = self.db.get_client_sync(
room_hash=f"0x{self.room_hash:02X}",
client_pubkey=client.id.get_public_key().hex()
client_pubkey=client.id.get_public_key().hex(),
)
# Skip if already waiting for ACK, evicted, or max failures
if sync_state:
pending_ack = sync_state.get('pending_ack_crc', 0)
last_activity = sync_state.get('last_activity', 0)
push_failures = sync_state.get('push_failures', 0)
pending_ack = sync_state.get("pending_ack_crc", 0)
last_activity = sync_state.get("last_activity", 0)
push_failures = sync_state.get("push_failures", 0)
if pending_ack != 0:
logger.debug(f"Skipping client 0x{client.id.get_public_key()[0]:02X} (waiting for ACK)")
logger.debug(
f"Skipping client 0x{client.id.get_public_key()[0]:02X} (waiting for ACK)"
)
continue
if last_activity == 0:
logger.debug(f"Skipping client 0x{client.id.get_public_key()[0]:02X} (evicted)")
logger.debug(
f"Skipping client 0x{client.id.get_public_key()[0]:02X} (evicted)"
)
continue
if push_failures >= 3:
logger.debug(f"Skipping client 0x{client.id.get_public_key()[0]:02X} (max failures)")
logger.debug(
f"Skipping client 0x{client.id.get_public_key()[0]:02X} (max failures)"
)
continue
sync_since = sync_state.get('sync_since', 0)
sync_since = sync_state.get("sync_since", 0)
else:
# Initialize sync state for new client
# Use sync_since from ACL client (sent during login) if available
sync_since = client.sync_since if hasattr(client, 'sync_since') else 0
sync_since = client.sync_since if hasattr(client, "sync_since") else 0
logger.info(
f"Room '{self.room_name}': Initializing client "
f"0x{client.id.get_public_key()[0]:02X} with sync_since={sync_since}"
@@ -637,17 +647,17 @@ class RoomServer:
room_hash=f"0x{self.room_hash:02X}",
client_pubkey=client.id.get_public_key().hex(),
sync_since=sync_since,
last_activity=time.time()
last_activity=time.time(),
)
# Find next unsynced message for this client
unsynced = self.db.get_unsynced_messages(
room_hash=f"0x{self.room_hash:02X}",
client_pubkey=client.id.get_public_key().hex(),
sync_since=sync_since,
limit=1
limit=1,
)
if unsynced:
post = unsynced[0]
logger.debug(
@@ -656,7 +666,7 @@ class RoomServer:
)
# Check if enough time has passed since post creation
now = time.time()
if now >= post['post_timestamp'] + POST_SYNC_DELAY_SECS:
if now >= post["post_timestamp"] + POST_SYNC_DELAY_SECS:
# Push this post
await self.push_post_to_client(client, post)
self.next_push_time = time.time() + (SYNC_PUSH_INTERVAL_MS / 1000.0)
@@ -668,15 +678,15 @@ class RoomServer:
else:
# No unsynced posts for this client, try next client
continue
# If we checked all clients and none were active/ready
if clients_checked >= max_checks:
# All clients skipped or no messages - wait longer before next check
self.next_push_time = time.time() + 5.0 # Wait 5 seconds
# SAFETY: Reset error counter on successful iteration
self.consecutive_sync_errors = 0
except asyncio.CancelledError:
break
except Exception as e:
@@ -684,35 +694,34 @@ class RoomServer:
self.consecutive_sync_errors += 1
logger.error(
f"Room '{self.room_name}': Sync loop error #{self.consecutive_sync_errors}: {e}",
exc_info=True
exc_info=True,
)
# SAFETY: Back off on errors
backoff = min(self.consecutive_sync_errors, 10) # Cap at 10 seconds
await asyncio.sleep(backoff)
logger.info(f"Room '{self.room_name}' sync loop stopped")
async def _check_ack_timeouts(self):
try:
now = time.time()
all_sync_states = self.db.get_all_room_clients(f"0x{self.room_hash:02X}")
for sync_state in all_sync_states:
if sync_state['pending_ack_crc'] != 0:
timeout_time = sync_state.get('ack_timeout_time', 0)
if sync_state["pending_ack_crc"] != 0:
timeout_time = sync_state.get("ack_timeout_time", 0)
if now >= timeout_time:
# ACK timeout
client_pubkey = bytes.fromhex(sync_state['client_pubkey'])
client_pubkey = bytes.fromhex(sync_state["client_pubkey"])
await self._handle_ack_timeout(client_pubkey)
except Exception as e:
logger.error(f"Error checking ACK timeouts: {e}")
async def _cleanup_old_messages(self):
try:
deleted = self.db.cleanup_old_messages(
room_hash=f"0x{self.room_hash:02X}",
keep_count=self.max_posts
room_hash=f"0x{self.room_hash:02X}", keep_count=self.max_posts
)
if deleted > 0:
logger.info(f"Room '{self.room_name}': Cleaned up {deleted} old messages")
+179 -140
View File
@@ -12,6 +12,7 @@ import struct
import time
from pymc_core.node.handlers.text import TextMessageHandler
from .mesh_cli import MeshCLI
from .room_server import RoomServer
@@ -24,9 +25,18 @@ TXT_TYPE_CLI_DATA = 0x01
class TextHelper:
def __init__(self, identity_manager, packet_injector=None, acl_dict=None, log_fn=None,
config_path: str = None, config: dict = None, config_manager=None,
sqlite_handler=None, send_advert_callback=None):
def __init__(
self,
identity_manager,
packet_injector=None,
acl_dict=None,
log_fn=None,
config_path: str = None,
config: dict = None,
config_manager=None,
sqlite_handler=None,
send_advert_callback=None,
):
self.identity_manager = identity_manager
self.packet_injector = packet_injector
@@ -34,47 +44,43 @@ class TextHelper:
self.acl_dict = acl_dict or {} # Per-identity ACLs keyed by hash_byte
self.sqlite_handler = sqlite_handler # For room server database operations
self.send_advert_callback = send_advert_callback # Callback to send repeater advert
# Dictionary of handlers keyed by dest_hash
self.handlers = {}
# Dictionary of room servers keyed by dest_hash
self.room_servers = {}
# Track repeater identity for CLI commands
self.repeater_hash = None
# Store config for later use
self.config_path = config_path
self.config = config
self.config_manager = config_manager
# Store for later CLI initialization (needs identity and storage)
self.config_path = config_path
self.config = config
# Initialize CLI handler later when repeater identity is registered
self.cli = None
def register_identity(
self,
name: str,
identity,
identity_type: str = "room_server",
radio_config=None
self, name: str, identity, identity_type: str = "room_server", radio_config=None
):
hash_byte = identity.get_public_key()[0]
# Get ACL for this identity
identity_acl = self.acl_dict.get(hash_byte)
if not identity_acl:
logger.warning(f"Cannot register identity '{name}': no ACL for hash 0x{hash_byte:02X}")
return
# Create a contacts wrapper from this identity's ACL
acl_contacts = self._create_acl_contacts_wrapper(identity_acl)
# Create TextMessageHandler for this identity
handler = TextMessageHandler(
local_identity=identity,
@@ -83,7 +89,7 @@ class TextHelper:
send_packet_fn=self._send_packet,
radio_config=radio_config,
)
# Register by dest hash
hash_byte = identity.get_public_key()[0]
self.handlers[hash_byte] = {
@@ -92,12 +98,12 @@ class TextHelper:
"name": name,
"type": identity_type,
}
# Track repeater identity for CLI commands
if identity_type == "repeater":
self.repeater_hash = hash_byte
logger.info(f"Set repeater hash for CLI: 0x{hash_byte:02X}")
# Initialize CLI handler now that we have the repeater identity
if self.config_path and self.config and self.config_manager:
self.cli = MeshCLI(
@@ -108,18 +114,20 @@ class TextHelper:
enable_regions=True,
send_advert_callback=self.send_advert_callback,
identity=identity,
storage_handler=self.sqlite_handler
storage_handler=self.sqlite_handler,
)
logger.info("Initialized CLI handler for repeater commands with identity and storage")
logger.info(
"Initialized CLI handler for repeater commands with identity and storage"
)
# Create RoomServer instance for room_server identities
if identity_type == "room_server" and self.sqlite_handler:
try:
from .room_server import MAX_UNSYNCED_POSTS
room_config = radio_config or {}
max_posts = room_config.get('max_posts', MAX_UNSYNCED_POSTS)
max_posts = room_config.get("max_posts", MAX_UNSYNCED_POSTS)
# Enforce hard limit
if max_posts > MAX_UNSYNCED_POSTS:
logger.warning(
@@ -127,7 +135,7 @@ class TextHelper:
f"of {MAX_UNSYNCED_POSTS}, capping to {MAX_UNSYNCED_POSTS}"
)
max_posts = MAX_UNSYNCED_POSTS
room_server = RoomServer(
room_hash=hash_byte,
room_name=name,
@@ -138,31 +146,29 @@ class TextHelper:
max_posts=max_posts,
config_path=self.config_path,
config=self.config,
config_manager=self.config_manager
config_manager=self.config_manager,
)
self.room_servers[hash_byte] = room_server
# Start sync loop
asyncio.create_task(room_server.start())
logger.info(
f"Registered room server '{name}': hash=0x{hash_byte:02X}, "
f"max_posts={max_posts}"
)
except Exception as e:
logger.error(f"Failed to create room server '{name}': {e}", exc_info=True)
logger.info(
f"Registered {identity_type} '{name}' text handler: hash=0x{hash_byte:02X}"
)
logger.info(f"Registered {identity_type} '{name}' text handler: hash=0x{hash_byte:02X}")
def _create_acl_contacts_wrapper(self, acl):
class ACLContactsWrapper:
def __init__(self, identity_acl):
self._acl = identity_acl
@property
def contacts(self):
contact_list = []
@@ -172,10 +178,10 @@ class TextHelper:
def __init__(self, client):
self.public_key = client.id.get_public_key().hex()
self.name = f"client_{self.public_key[:8]}"
contact_list.append(ContactProxy(client_info))
return contact_list
return ACLContactsWrapper(acl)
async def process_text_packet(self, packet):
@@ -183,20 +189,20 @@ class TextHelper:
try:
if len(packet.payload) < 2:
return False
dest_hash = packet.payload[0]
src_hash = packet.payload[1]
handler_info = self.handlers.get(dest_hash)
if handler_info:
logger.debug(
f"Routing text message to '{handler_info['name']}': "
f"dest=0x{dest_hash:02X}, src=0x{src_hash:02X}"
)
# Let handler decrypt the message first
await handler_info["handler"](packet)
# Call placeholder for custom processing
await self._on_message_received(
identity_name=handler_info["name"],
@@ -205,16 +211,14 @@ class TextHelper:
dest_hash=dest_hash,
src_hash=src_hash,
)
# Mark packet as handled
packet.mark_do_not_retransmit()
return True
else:
logger.debug(
f"No text handler for hash 0x{dest_hash:02X}, allowing forward"
)
logger.debug(f"No text handler for hash 0x{dest_hash:02X}, allowing forward")
return False
except Exception as e:
logger.error(f"Error processing text packet: {e}")
return False
@@ -230,128 +234,137 @@ class TextHelper:
# Placeholder - can be overridden or callback can be added
logger.debug(
f"Message received for {identity_type} '{identity_name}' "
f"from 0x{src_hash:02X}"
f"Message received for {identity_type} '{identity_name}' " f"from 0x{src_hash:02X}"
)
# Extract decrypted message if available
if hasattr(packet, "decrypted") and packet.decrypted:
message_text = packet.decrypted.get("text", "<unknown>")
# Clean message text - remove null bytes and trailing whitespace
message_text = message_text.rstrip('\x00').rstrip()
logger.info(
f"[{identity_type}:{identity_name}] Message: {message_text}"
)
message_text = message_text.rstrip("\x00").rstrip()
logger.info(f"[{identity_type}:{identity_name}] Message: {message_text}")
# Handle room server messages
if identity_type == "room_server" and dest_hash in self.room_servers:
room_server = self.room_servers[dest_hash]
# Check if this is a CLI command FIRST (before storing as post)
if self._is_cli_command(message_text):
# Handle CLI command - do NOT store as post
if room_server and room_server.cli:
try:
# Check admin permission
is_admin = self._check_admin_permission_for_identity(src_hash, dest_hash)
is_admin = self._check_admin_permission_for_identity(
src_hash, dest_hash
)
if not is_admin:
logger.warning(f"Room '{identity_name}': CLI command denied from 0x{src_hash:02X} (not admin)")
logger.warning(
f"Room '{identity_name}': CLI command denied from 0x{src_hash:02X} (not admin)"
)
return
# Get sender's full pubkey
identity_acl = self.acl_dict.get(dest_hash)
sender_pubkey = bytes([src_hash]) + b'\x00' * 31 # Default
sender_pubkey = bytes([src_hash]) + b"\x00" * 31 # Default
if identity_acl:
for client_info in identity_acl.get_all_clients():
if client_info.id.get_public_key()[0] == src_hash:
sender_pubkey = client_info.id.get_public_key()
break
# Handle CLI command
reply = room_server.cli.handle_command(
sender_pubkey=sender_pubkey,
command=message_text,
is_admin=is_admin
sender_pubkey=sender_pubkey, command=message_text, is_admin=is_admin
)
logger.info(f"Room '{identity_name}': CLI command from 0x{src_hash:02X}: {message_text[:50]} -> {reply[:100]}")
logger.info(
f"Room '{identity_name}': CLI command from 0x{src_hash:02X}: {message_text[:50]} -> {reply[:100]}"
)
# Send reply back to sender
handler_info = self.handlers.get(dest_hash)
if handler_info:
await self._send_cli_reply(packet, reply, handler_info)
except Exception as e:
logger.error(f"Error processing room server CLI command: {e}", exc_info=True)
logger.error(
f"Error processing room server CLI command: {e}", exc_info=True
)
# CLI command handled, don't store as post
return
# NOT a CLI command - store as regular room post
try:
# Get sender's full pubkey
identity_acl = self.acl_dict.get(dest_hash)
sender_pubkey = bytes([src_hash]) + b'\x00' * 31 # Default
sender_pubkey = bytes([src_hash]) + b"\x00" * 31 # Default
if identity_acl:
for client_info in identity_acl.get_all_clients():
if client_info.id.get_public_key()[0] == src_hash:
sender_pubkey = client_info.id.get_public_key()
break
# Store message as post
sender_timestamp = int(time.time())
success = await room_server.add_post(
client_pubkey=sender_pubkey,
message_text=message_text,
sender_timestamp=sender_timestamp,
txt_type=TXT_TYPE_PLAIN
txt_type=TXT_TYPE_PLAIN,
)
if success:
logger.info(f"Room '{identity_name}': New post from {sender_pubkey[:4].hex()}: {message_text[:50]}")
logger.info(
f"Room '{identity_name}': New post from {sender_pubkey[:4].hex()}: {message_text[:50]}"
)
except Exception as e:
logger.error(f"Error storing room post: {e}", exc_info=True)
return
# Check if this is a CLI command to the repeater (AFTER decryption)
if dest_hash == self.repeater_hash and self.cli and self._is_cli_command(message_text):
try:
# Check admin permission
is_admin = self._check_admin_permission_for_identity(src_hash, self.repeater_hash)
is_admin = self._check_admin_permission_for_identity(
src_hash, self.repeater_hash
)
# If not admin, log and return without sending reply
if not is_admin:
logger.warning(f"CLI command denied from 0x{src_hash:02X} (not admin): {message_text[:50]}")
logger.warning(
f"CLI command denied from 0x{src_hash:02X} (not admin): {message_text[:50]}"
)
return
# Get client for full public key
repeater_acl = self.acl_dict.get(self.repeater_hash)
sender_pubkey = bytes([src_hash]) + b'\x00' * 31 # Default
sender_pubkey = bytes([src_hash]) + b"\x00" * 31 # Default
if repeater_acl:
for client_info in repeater_acl.get_all_clients():
if client_info.id.get_public_key()[0] == src_hash:
sender_pubkey = client_info.id.get_public_key()
break
# Handle CLI command
reply = self.cli.handle_command(
sender_pubkey=sender_pubkey,
command=message_text,
is_admin=is_admin
sender_pubkey=sender_pubkey, command=message_text, is_admin=is_admin
)
logger.info(f"CLI command from 0x{src_hash:02X}: {message_text[:50]} -> {reply[:100]}")
logger.info(
f"CLI command from 0x{src_hash:02X}: {message_text[:50]} -> {reply[:100]}"
)
# Send reply back to sender
handler_info = self.handlers.get(dest_hash)
if handler_info:
await self._send_cli_reply(packet, reply, handler_info)
except Exception as e:
logger.error(f"Error processing CLI command: {e}", exc_info=True)
@@ -381,7 +394,7 @@ class TextHelper:
}
for hash_byte, info in self.handlers.items()
]
async def cleanup(self):
"""Cleanup room servers and handlers."""
# Stop all room server sync loops
@@ -390,52 +403,68 @@ class TextHelper:
await room_server.stop()
except Exception as e:
logger.error(f"Error stopping room server: {e}")
logger.info("TextHelper cleanup complete")
def _is_cli_command(self, message: str) -> bool:
"""Check if message looks like a CLI command."""
# Strip optional sequence prefix (XX|)
if len(message) > 4 and message[2] == '|':
if len(message) > 4 and message[2] == "|":
message = message[3:].strip()
# Check for known command prefixes
command_prefixes = [
"get ", "set ", "reboot", "advert", "clock", "time ",
"password ", "clear ", "ver", "board", "neighbors", "neighbor.",
"tempradio ", "setperm ", "region", "sensor ", "gps", "log ",
"stats-", "start ota"
"get ",
"set ",
"reboot",
"advert",
"clock",
"time ",
"password ",
"clear ",
"ver",
"board",
"neighbors",
"neighbor.",
"tempradio ",
"setperm ",
"region",
"sensor ",
"gps",
"log ",
"stats-",
"start ota",
]
return any(message.startswith(prefix) for prefix in command_prefixes)
def _check_admin_permission(self, src_hash: int) -> bool:
"""Check if sender has admin permissions for repeater (legacy method)."""
return self._check_admin_permission_for_identity(src_hash, self.repeater_hash)
def _check_admin_permission_for_identity(self, src_hash: int, identity_hash: int) -> bool:
"""Check if sender has admin permissions (bit 0x02) for a specific identity."""
# Get the identity's ACL
identity_acl = self.acl_dict.get(identity_hash)
if not identity_acl:
return False
# Get client by hash byte
clients = identity_acl.get_all_clients()
for client_info in clients:
pubkey = client_info.id.get_public_key()
if pubkey[0] == src_hash:
# Check admin bit (0x02 = PERM_ACL_ADMIN)
permissions = getattr(client_info, 'permissions', 0)
permissions = getattr(client_info, "permissions", 0)
PERM_ACL_ADMIN = 0x02
return (permissions & 0x02) == PERM_ACL_ADMIN
return False
async def _send_cli_reply(self, original_packet, reply_text: str, handler_info: dict):
"""
Send CLI reply back to sender using TXT_MSG datagram.
Follows the C++ pattern (lines 603-609 in MyMesh.cpp):
- Creates TXT_MSG datagram with TXT_TYPE_CLI_DATA flag
- Encrypts with shared secret from ACL client
@@ -443,77 +472,87 @@ class TextHelper:
* if out_path_len < 0: sendFlood()
* else: sendDirect() with stored out_path
"""
from pymc_core.protocol import PacketBuilder, Identity
from pymc_core.protocol.constants import PAYLOAD_TYPE_TXT_MSG
import time
from pymc_core.protocol import Identity, PacketBuilder
from pymc_core.protocol.constants import PAYLOAD_TYPE_TXT_MSG
try:
src_hash = original_packet.payload[1]
dest_hash = original_packet.payload[0]
incoming_route = original_packet.get_route_type()
logger.debug(f"CLI reply: original packet dest=0x{dest_hash:02X}, src=0x{src_hash:02X}, incoming_route={incoming_route}")
logger.debug(
f"CLI reply: original packet dest=0x{dest_hash:02X}, src=0x{src_hash:02X}, incoming_route={incoming_route}"
)
# Find the client in the DESTINATION identity's ACL (not always repeater!)
# dest_hash is the identity that received the command (repeater OR room server)
identity_acl = self.acl_dict.get(dest_hash)
if not identity_acl:
logger.error(f"No ACL found for identity 0x{dest_hash:02X} for CLI reply")
return
client = None
for client_info in identity_acl.get_all_clients():
pubkey = client_info.id.get_public_key()
if pubkey[0] == src_hash:
client = client_info
break
if not client:
logger.error(f"Client 0x{src_hash:02X} not found in identity 0x{dest_hash:02X} ACL for CLI reply")
logger.error(
f"Client 0x{src_hash:02X} not found in identity 0x{dest_hash:02X} ACL for CLI reply"
)
return
# Get shared secret from client
shared_secret = client.shared_secret
if not shared_secret or len(shared_secret) == 0:
logger.error(f"No shared secret for client 0x{src_hash:02X}")
return
# Build reply packet payload
# Format: timestamp(4) + flags(1) + reply_text
timestamp = int(time.time())
TXT_TYPE_CLI_DATA = 0x01
flags = (TXT_TYPE_CLI_DATA << 2) # Upper 6 bits are txt_type
reply_bytes = reply_text.encode('utf-8')
plaintext = timestamp.to_bytes(4, 'little') + bytes([flags]) + reply_bytes
flags = TXT_TYPE_CLI_DATA << 2 # Upper 6 bits are txt_type
reply_bytes = reply_text.encode("utf-8")
plaintext = timestamp.to_bytes(4, "little") + bytes([flags]) + reply_bytes
# Decide routing based on client->out_path_len (C++ pattern)
# out_path is populated by PATH packets, NOT from incoming text message route
route_type = "flood" if client.out_path_len < 0 else "direct"
logger.debug(f"CLI reply: client.out_path_len={client.out_path_len}, using route_type={route_type}")
logger.debug(
f"CLI reply: client.out_path_len={client.out_path_len}, using route_type={route_type}"
)
reply_packet = PacketBuilder.create_datagram(
ptype=PAYLOAD_TYPE_TXT_MSG,
dest=client.id,
local_identity=handler_info["identity"],
secret=shared_secret,
plaintext=plaintext,
route_type=route_type
route_type=route_type,
)
# Add path for direct routing if available from PATH packets
if client.out_path_len >= 0 and len(client.out_path) > 0:
reply_packet.path = bytearray(client.out_path[:client.out_path_len])
reply_packet.path = bytearray(client.out_path[: client.out_path_len])
reply_packet.path_len = client.out_path_len
logger.debug(f"CLI reply: Added stored out_path - path_len={reply_packet.path_len}, path={[hex(b) for b in reply_packet.path]}")
logger.debug(
f"CLI reply: Added stored out_path - path_len={reply_packet.path_len}, path={[hex(b) for b in reply_packet.path]}"
)
# Send with delay (CLI_REPLY_DELAY_MILLIS = 600ms in C++)
CLI_REPLY_DELAY_MS = 600
await asyncio.sleep(CLI_REPLY_DELAY_MS / 1000.0)
await self._send_packet(reply_packet, wait_for_ack=False)
logger.info(f"CLI reply sent to 0x{src_hash:02X} via {route_type.upper()}: {reply_text[:50]}")
logger.info(
f"CLI reply sent to 0x{src_hash:02X} via {route_type.upper()}: {reply_text[:50]}"
)
except Exception as e:
logger.error(f"Error sending CLI reply: {e}", exc_info=True)
+75 -43
View File
@@ -9,7 +9,7 @@ of packets through the mesh network.
import asyncio
import logging
import time
from typing import Dict, Any
from typing import Any, Dict
from pymc_core.hardware.signal_utils import snr_register_to_db
from pymc_core.node.handlers.trace import TraceHandler
@@ -34,10 +34,15 @@ class TraceHelper:
self.local_hash = local_hash
self.repeater_handler = repeater_handler
self.packet_injector = packet_injector # Function to inject packets into router
# Ping callback system - track pending ping requests by tag
self.pending_pings = {} # {tag: {'event': asyncio.Event(), 'result': dict, 'target': int, 'sent_at': float}}
self.pending_pings = (
{}
) # {tag: {'event': asyncio.Event(), 'result': dict, 'target': int, 'sent_at': float}}
# Optional: when trace reaches final node, call this (packet, parsed_data) to push 0x89 to companions
self.on_trace_complete = None # async (packet, parsed_data) -> None
# Create TraceHandler internally as a parsing utility
self.trace_handler = TraceHandler(log_fn=log_fn or logger.info)
@@ -60,9 +65,7 @@ class TraceHelper:
parsed_data = self.trace_handler._parse_trace_payload(packet.payload)
if not parsed_data.get("valid", False):
logger.warning(
f"Invalid trace packet: {parsed_data.get('error', 'Unknown error')}"
)
logger.warning(f"Invalid trace packet: {parsed_data.get('error', 'Unknown error')}")
return
trace_path = parsed_data["trace_path"]
@@ -71,16 +74,23 @@ class TraceHelper:
# Check if this is a response to one of our pings
trace_tag = parsed_data.get("tag")
if trace_tag in self.pending_pings:
rssi_val = getattr(packet, "rssi", 0)
if rssi_val == 0:
logger.warning(
f"Ignoring trace response for tag {trace_tag} "
"with RSSI=0 (no signal data)"
)
return # wait for a valid response or let timeout handle it
ping_info = self.pending_pings[trace_tag]
# Store response data
ping_info['result'] = {
'path': trace_path,
'snr': packet.get_snr(),
'rssi': getattr(packet, "rssi", 0),
'received_at': time.time()
ping_info["result"] = {
"path": trace_path,
"snr": packet.get_snr(),
"rssi": rssi_val,
"received_at": time.time(),
}
# Signal the waiting coroutine
ping_info['event'].set()
ping_info["event"].set()
logger.info(f"Ping response received for tag {trace_tag}")
# Record the trace packet for dashboard/statistics
@@ -107,6 +117,12 @@ class TraceHelper:
else:
# This is the final destination or can't forward - just log and record
self._log_no_forward_reason(packet, trace_path, trace_path_len)
# When trace completed (reached end of path), push PUSH_CODE_TRACE_DATA (0x89) to companions (firmware onTraceRecv)
if packet.path_len >= trace_path_len and self.on_trace_complete:
try:
await self.on_trace_complete(packet, parsed_data)
except Exception as e:
logger.debug("on_trace_complete error: %s", e)
except Exception as e:
logger.error(f"Error processing trace packet: {e}")
@@ -140,27 +156,37 @@ class TraceHelper:
# Add detailed SNR info if we have the corresponding hash
if i < len(trace_path):
path_snr_details.append({
"hash": f"{trace_path[i]:02X}",
"snr_raw": snr_val,
"snr_db": snr_db
})
path_snr_details.append(
{"hash": f"{trace_path[i]:02X}", "snr_raw": snr_val, "snr_db": snr_db}
)
return {
"timestamp": time.time(),
"header": f"0x{packet.header:02X}" if hasattr(packet, "header") and packet.header is not None else None,
"payload": packet.payload.hex() if hasattr(packet, "payload") and packet.payload else None,
"payload_length": len(packet.payload) if hasattr(packet, "payload") and packet.payload else 0,
"header": (
f"0x{packet.header:02X}"
if hasattr(packet, "header") and packet.header is not None
else None
),
"payload": (
packet.payload.hex() if hasattr(packet, "payload") and packet.payload else None
),
"payload_length": (
len(packet.payload) if hasattr(packet, "payload") and packet.payload else 0
),
"type": packet.get_payload_type(), # 0x09 for trace
"route": packet.get_route_type(), # Should be direct (1)
"route": packet.get_route_type(), # Should be direct (1)
"length": len(packet.payload or b""),
"rssi": getattr(packet, "rssi", 0),
"snr": getattr(packet, "snr", 0.0),
"score": self.repeater_handler.calculate_packet_score(
getattr(packet, "snr", 0.0),
len(packet.payload or b""),
self.repeater_handler.radio_config.get("spreading_factor", 8)
) if self.repeater_handler else 0.0,
"score": (
self.repeater_handler.calculate_packet_score(
getattr(packet, "snr", 0.0),
len(packet.payload or b""),
self.repeater_handler.radio_config.get("spreading_factor", 8),
)
if self.repeater_handler
else 0.0
),
"tx_delay_ms": 0,
"transmitted": False,
"is_duplicate": False,
@@ -217,21 +243,24 @@ class TraceHelper:
True if the packet should be forwarded, False otherwise
"""
# Use the exact logic from the original working code
return (packet.path_len < trace_path_len and
len(trace_path) > packet.path_len and
trace_path[packet.path_len] == self.local_hash and
self.repeater_handler and not self.repeater_handler.is_duplicate(packet))
return (
packet.path_len < trace_path_len
and len(trace_path) > packet.path_len
and trace_path[packet.path_len] == self.local_hash
and self.repeater_handler
and not self.repeater_handler.is_duplicate(packet)
)
async def _forward_trace_packet(self, packet, trace_path_len: int) -> None:
"""
Forward a trace packet by appending SNR and sending via injection.
Args:
packet: The trace packet to forward
trace_path_len: The length of the trace path
"""
# Update the packet record to show it will be transmitted
if self.repeater_handler and hasattr(self.repeater_handler, 'recent_packets'):
if self.repeater_handler and hasattr(self.repeater_handler, "recent_packets"):
packet_hash = packet.calculate_packet_hash().hex().upper()[:16]
for record in reversed(self.repeater_handler.recent_packets):
if record.get("packet_hash") == packet_hash:
@@ -284,41 +313,44 @@ class TraceHelper:
elif len(trace_path) <= packet.path_len:
logger.info("Path index out of bounds")
elif trace_path[packet.path_len] != self.local_hash:
expected_hash = trace_path[packet.path_len] if packet.path_len < len(trace_path) else None
expected_hash = (
trace_path[packet.path_len] if packet.path_len < len(trace_path) else None
)
logger.info(f"Not our turn (next hop: 0x{expected_hash:02x})")
elif self.repeater_handler and self.repeater_handler.is_duplicate(packet):
logger.info("Duplicate packet, ignoring")
def register_ping(self, tag: int, target_hash: int) -> asyncio.Event:
"""Register a ping request and return an event to wait on.
Args:
tag: The unique trace tag for this ping
target_hash: The hash of the target node
Returns:
asyncio.Event that will be set when response is received
"""
event = asyncio.Event()
self.pending_pings[tag] = {
'event': event,
'result': None,
'target': target_hash,
'sent_at': time.time()
"event": event,
"result": None,
"target": target_hash,
"sent_at": time.time(),
}
logger.debug(f"Registered ping with tag {tag} for target 0x{target_hash:02x}")
return event
def cleanup_stale_pings(self, max_age_seconds: int = 30):
"""Remove pending pings older than max_age_seconds.
Args:
max_age_seconds: Maximum age in seconds before a ping is considered stale
"""
current_time = time.time()
stale_tags = [
tag for tag, info in self.pending_pings.items()
if current_time - info['sent_at'] > max_age_seconds
tag
for tag, info in self.pending_pings.items()
if current_time - info["sent_at"] > max_age_seconds
]
for tag in stale_tags:
self.pending_pings.pop(tag)
+21 -18
View File
@@ -1,20 +1,20 @@
import logging
from typing import Dict, Optional, Tuple, Any
from typing import Any, Dict, Optional, Tuple
logger = logging.getLogger("IdentityManager")
class IdentityManager:
def __init__(self, config: dict):
self.config = config
self.identities: Dict[int, Tuple[Any, dict, str]] = {}
self.named_identities: Dict[str, Tuple[Any, dict, str]] = {}
self.registered_hashes: Dict[int, str] = {}
def register_identity(self, name: str, identity, config: dict, identity_type: str):
hash_byte = identity.get_public_key()[0]
if hash_byte in self.identities:
existing_name = self.registered_hashes.get(hash_byte, "unknown")
logger.error(
@@ -22,40 +22,43 @@ class IdentityManager:
f"conflicts with existing identity '{existing_name}'"
)
return False
self.identities[hash_byte] = (identity, config, identity_type)
self.named_identities[name] = (identity, config, identity_type)
self.registered_hashes[hash_byte] = f"{identity_type}:{name}"
logger.info(
f"Identity registered: name={name}, hash=0x{hash_byte:02X}, type={identity_type}"
)
return True
def get_identity_by_hash(self, hash_byte: int) -> Optional[Tuple[Any, dict, str]]:
return self.identities.get(hash_byte)
def get_identity_by_name(self, name: str) -> Optional[Tuple[Any, dict, str]]:
return self.named_identities.get(name)
def has_identity(self, hash_byte: int) -> bool:
return hash_byte in self.identities
def list_identities(self) -> list:
identities = []
for hash_byte, (identity, config, id_type) in self.identities.items():
name = self.registered_hashes.get(hash_byte, "unknown")
identities.append({
"hash": f"0x{hash_byte:02X}",
"name": name,
"type": id_type,
"address": identity.get_address_bytes().hex() if identity else "N/A"
})
identities.append(
{
"hash": f"0x{hash_byte:02X}",
"name": name,
"type": id_type,
"address": identity.get_address_bytes().hex() if identity else "N/A",
"public_key": identity.get_public_key().hex() if identity else None,
}
)
return identities
def has_identity_type(self, identity_type: str) -> bool:
return any(id_type == identity_type for _, _, id_type in self.identities.values())
def get_identities_by_type(self, identity_type: str) -> list:
results = []
for name, (identity, config, id_type) in self.named_identities.items():
+695 -140
View File
File diff suppressed because it is too large Load Diff
+273 -31
View File
@@ -1,16 +1,48 @@
import asyncio
import logging
import time
from pymc_core.node.handlers.trace import TraceHandler
from pymc_core.node.handlers.control import ControlHandler
from pymc_core.node.handlers.ack import AckHandler
from pymc_core.node.handlers.advert import AdvertHandler
from pymc_core.node.handlers.control import ControlHandler
from pymc_core.node.handlers.group_text import GroupTextHandler
from pymc_core.node.handlers.login_response import LoginResponseHandler
from pymc_core.node.handlers.login_server import LoginServerHandler
from pymc_core.node.handlers.text import TextMessageHandler
from pymc_core.node.handlers.path import PathHandler
from pymc_core.node.handlers.protocol_request import ProtocolRequestHandler
from pymc_core.node.handlers.protocol_response import ProtocolResponseHandler
from pymc_core.node.handlers.text import TextMessageHandler
from pymc_core.node.handlers.trace import TraceHandler
from pymc_core.protocol.constants import (
PH_ROUTE_MASK,
ROUTE_TYPE_DIRECT,
ROUTE_TYPE_TRANSPORT_DIRECT,
)
logger = logging.getLogger("PacketRouter")
# Deliver PATH and protocol-response (PATH) to companion at most once per logical packet
# so the client is not spammed with duplicate telemetry when the mesh delivers multiple copies.
_COMPANION_DEDUPE_TTL_SEC = 60.0
def _companion_dedup_key(packet) -> str | None:
"""Return a stable key for companion delivery deduplication, or None if not available."""
try:
return packet.calculate_packet_hash().hex().upper()
except Exception:
return None
def _is_direct_final_hop(packet) -> bool:
"""True if packet is DIRECT (or TRANSPORT_DIRECT) with empty path — we're the final destination."""
route = getattr(packet, "header", 0) & PH_ROUTE_MASK
if route != ROUTE_TYPE_DIRECT and route != ROUTE_TYPE_TRANSPORT_DIRECT:
return False
path = getattr(packet, "path", None)
return not path or len(path) == 0
class PacketRouter:
def __init__(self, daemon_instance):
@@ -18,7 +50,11 @@ class PacketRouter:
self.queue = asyncio.Queue()
self.running = False
self.router_task = None
# Serialize injects so one local TX completes before the next is processed
self._inject_lock = asyncio.Lock()
# Hash -> expiry time; skip delivering same PATH/protocol-response to companions more than once
self._companion_delivered = {}
async def start(self):
self.running = True
self.router_task = asyncio.create_task(self._process_queue())
@@ -34,6 +70,28 @@ class PacketRouter:
pass
logger.info("Packet router stopped")
def _should_deliver_path_to_companions(self, packet) -> bool:
"""Return True if this PATH/protocol-response should be delivered to companions (first of duplicates)."""
key = _companion_dedup_key(packet)
if not key:
return True
now = time.time()
# Prune expired
self._companion_delivered = {k: v for k, v in self._companion_delivered.items() if v > now}
if key in self._companion_delivered:
return False
self._companion_delivered[key] = now + _COMPANION_DEDUPE_TTL_SEC
return True
def _record_for_ui(self, packet, metadata: dict) -> None:
"""Record an injection-only packet for the web UI (storage + recent_packets)."""
handler = getattr(self.daemon, "repeater_handler", None)
if handler and getattr(handler, "storage", None):
try:
handler.record_packet_only(packet, metadata)
except Exception as e:
logger.debug("Record for UI failed: %s", e)
async def enqueue(self, packet):
"""Add packet to router queue."""
await self.queue.put(packet)
@@ -42,17 +100,38 @@ class PacketRouter:
try:
metadata = {
"rssi": getattr(packet, "rssi", 0),
"snr": getattr(packet, "snr", 0.0),
"snr": getattr(packet, "snr", 0.0),
"timestamp": getattr(packet, "timestamp", 0),
}
# Use local_transmission=True to bypass forwarding logic
await self.daemon.repeater_handler(packet, metadata, local_transmission=True)
# Serialize injects so one local TX completes before the next runs
# (avoids duty-cycle or dispatcher races where a later packet goes out first)
async with self._inject_lock:
# Use local_transmission=True to bypass forwarding logic
await self.daemon.repeater_handler(
packet, metadata, local_transmission=True
)
# Mark so when this packet is dequeued we don't pass to engine again (avoid double-send / double-count)
packet._injected_for_tx = True
# Enqueue so router can deliver to companion(s): TXT_MSG -> dest bridge, ACK -> all bridges (sender sees ACK)
await self.enqueue(packet)
packet_len = len(packet.payload) if packet.payload else 0
logger.debug(f"Injected packet processed by engine as local transmission ({packet_len} bytes)")
logger.debug(
f"Injected packet processed by engine as local transmission ({packet_len} bytes)"
)
# Log protocol REQ (e.g. status/telemetry) so we can confirm target node
ptype = getattr(packet, "get_payload_type", lambda: None)()
if ptype == ProtocolRequestHandler.payload_type() and packet.payload and packet_len >= 1:
logger.info(
"Injected protocol REQ: dest=0x%02x, payload=%d bytes",
packet.payload[0],
packet_len,
)
return True
except Exception as e:
logger.error(f"Error injecting packet through engine: {e}")
return False
@@ -66,13 +145,17 @@ class PacketRouter:
continue
except Exception as e:
logger.error(f"Router error: {e}", exc_info=True)
async def _route_packet(self, packet):
payload_type = packet.get_payload_type()
processed_by_injection = False
metadata = {
"rssi": getattr(packet, "rssi", 0),
"snr": getattr(packet, "snr", 0.0),
"timestamp": getattr(packet, "timestamp", 0),
}
# Route to specific handlers for parsing only
if payload_type == TraceHandler.payload_type():
# Process trace packet
@@ -80,50 +163,209 @@ class PacketRouter:
await self.daemon.trace_helper.process_trace_packet(packet)
# Skip engine processing for trace packets - they're handled by trace helper
processed_by_injection = True
self._record_for_ui(packet, metadata)
elif payload_type == ControlHandler.payload_type():
# Process control/discovery packet
if self.daemon.discovery_helper:
await self.daemon.discovery_helper.control_handler(packet)
packet.mark_do_not_retransmit()
# Deliver to companions via daemon (frame servers push PUSH_CODE_CONTROL_DATA 0x8E)
deliver = getattr(self.daemon, "deliver_control_data", None)
if deliver:
snr = getattr(packet, "_snr", None) or getattr(packet, "snr", 0.0)
rssi = getattr(packet, "_rssi", None) or getattr(packet, "rssi", 0)
path_len = getattr(packet, "path_len", 0) or 0
path_bytes = (
bytes(getattr(packet, "path", []))
if getattr(packet, "path", None) is not None
else b""
)[:path_len]
payload_bytes = bytes(packet.payload) if packet.payload else b""
await deliver(snr, rssi, path_len, path_bytes, payload_bytes)
elif payload_type == AdvertHandler.payload_type():
# Process advertisement packet for neighbor tracking
if self.daemon.advert_helper:
rssi = getattr(packet, "rssi", 0)
snr = getattr(packet, "snr", 0.0)
await self.daemon.advert_helper.process_advert_packet(packet, rssi, snr)
# Also feed adverts to companion bridges (for contact/path updates)
for bridge in getattr(self.daemon, "companion_bridges", {}).values():
try:
await bridge.process_received_packet(packet)
except Exception as e:
logger.debug(f"Companion bridge advert error: {e}")
elif payload_type == LoginServerHandler.payload_type():
# Process ANON_REQ login packet for all identities
if self.daemon.login_helper:
# Route to companion if dest is a companion; else to login_helper (for logging into this repeater).
# When dest is remote (not handled), pass to engine so DIRECT/FLOOD ANON_REQ can be forwarded.
# Our own injected ANON_REQ is suppressed by the engine's duplicate (mark_seen) check.
dest_hash = packet.payload[0] if packet.payload else None
companion_bridges = getattr(self.daemon, "companion_bridges", {})
if dest_hash is not None and dest_hash in companion_bridges:
await companion_bridges[dest_hash].process_received_packet(packet)
processed_by_injection = True
elif self.daemon.login_helper:
handled = await self.daemon.login_helper.process_login_packet(packet)
# Only skip forwarding if we actually handled it
if handled:
processed_by_injection = True
if processed_by_injection:
self._record_for_ui(packet, metadata)
elif payload_type == AckHandler.payload_type():
# ACK has no dest in payload (4-byte CRC only); deliver to all bridges so sender sees send_confirmed.
# Do not set processed_by_injection so packet also reaches engine for DIRECT forwarding when we're a middle hop.
companion_bridges = getattr(self.daemon, "companion_bridges", {})
for bridge in companion_bridges.values():
try:
await bridge.process_received_packet(packet)
except Exception as e:
logger.debug(f"Companion bridge ACK error: {e}")
elif payload_type == TextMessageHandler.payload_type():
# Process TXT_MSG packet for all identities
if self.daemon.text_helper:
dest_hash = packet.payload[0] if packet.payload else None
companion_bridges = getattr(self.daemon, "companion_bridges", {})
if dest_hash is not None and dest_hash in companion_bridges:
await companion_bridges[dest_hash].process_received_packet(packet)
processed_by_injection = True
self._record_for_ui(packet, metadata)
elif self.daemon.text_helper:
handled = await self.daemon.text_helper.process_text_packet(packet)
# Only skip forwarding if we actually handled it
if handled:
processed_by_injection = True
self._record_for_ui(packet, metadata)
elif payload_type == PathHandler.payload_type():
# Process PATH packet to update client out_path for direct routing
if self.daemon.path_helper:
dest_hash = packet.payload[0] if packet.payload else None
companion_bridges = getattr(self.daemon, "companion_bridges", {})
if dest_hash is not None and dest_hash in companion_bridges:
if self._should_deliver_path_to_companions(packet):
await companion_bridges[dest_hash].process_received_packet(packet)
# Do not set processed_by_injection so packet also reaches engine for DIRECT forwarding when we're a middle hop.
elif companion_bridges and self._should_deliver_path_to_companions(packet):
# Dest not in bridges: path-return with ephemeral dest (e.g. multi-hop login).
# Deliver to all bridges; each will try to decrypt and ignore if not relevant.
for bridge in companion_bridges.values():
try:
await bridge.process_received_packet(packet)
except Exception as e:
logger.debug(f"Companion bridge PATH error: {e}")
logger.debug(
"PATH dest=0x%02x (anon) delivered to %d bridge(s) for matching",
dest_hash or 0,
len(companion_bridges),
)
# Do not set processed_by_injection so packet also reaches engine for DIRECT forwarding when we're a middle hop.
elif self.daemon.path_helper:
await self.daemon.path_helper.process_path_packet(packet)
# Note: process_path_packet returns False to allow forwarding
elif payload_type == LoginResponseHandler.payload_type():
# PAYLOAD_TYPE_RESPONSE (0x01): payload is dest_hash(1)+src_hash(1)+encrypted.
# Deliver to the bridge that is the destination, or to all bridges when the
# response is addressed to this repeater (path-based reply: firmware sends
# to first hop instead of original requester).
# Do not set processed_by_injection so packet also reaches engine for DIRECT forwarding when we're a middle hop.
dest_hash = packet.payload[0] if packet.payload and len(packet.payload) >= 1 else None
companion_bridges = getattr(self.daemon, "companion_bridges", {})
local_hash = getattr(self.daemon, "local_hash", None)
if dest_hash is not None and dest_hash in companion_bridges:
try:
await companion_bridges[dest_hash].process_received_packet(packet)
logger.info(
"RESPONSE dest=0x%02x delivered to companion bridge",
dest_hash,
)
except Exception as e:
logger.debug(f"Companion bridge RESPONSE error: {e}")
elif dest_hash == local_hash and companion_bridges:
# Response addressed to this repeater (e.g. path-based reply to first hop)
for bridge in companion_bridges.values():
try:
await bridge.process_received_packet(packet)
except Exception as e:
logger.debug(f"Companion bridge RESPONSE error: {e}")
logger.info(
"RESPONSE dest=0x%02x (local) delivered to %d companion bridge(s)",
dest_hash,
len(companion_bridges),
)
elif companion_bridges:
# Dest not in bridges and not local: likely ANON_REQ response (dest = ephemeral
# sender hash). Deliver to all bridges; each will try to decrypt and ignore if
# not relevant (firmware-like behavior, works with multiple companion bridges).
for bridge in companion_bridges.values():
try:
await bridge.process_received_packet(packet)
except Exception as e:
logger.debug(f"Companion bridge RESPONSE error: {e}")
logger.debug(
"RESPONSE dest=0x%02x (anon) delivered to %d bridge(s) for matching",
dest_hash or 0,
len(companion_bridges),
)
if companion_bridges and _is_direct_final_hop(packet):
# DIRECT with empty path: we're the final hop; don't pass to engine (it would drop with "Direct: no path")
processed_by_injection = True
self._record_for_ui(packet, metadata)
elif payload_type == ProtocolResponseHandler.payload_type():
# PAYLOAD_TYPE_PATH (0x08): protocol responses (telemetry, binary, etc.).
# Deliver at most once per logical packet so the client is not spammed with duplicates.
# Do not set processed_by_injection so packet also reaches engine for DIRECT forwarding when we're a middle hop.
companion_bridges = getattr(self.daemon, "companion_bridges", {})
if companion_bridges and self._should_deliver_path_to_companions(packet):
for bridge in companion_bridges.values():
try:
await bridge.process_received_packet(packet)
except Exception as e:
logger.debug(f"Companion bridge RESPONSE error: {e}")
if companion_bridges and _is_direct_final_hop(packet):
# DIRECT with empty path: we're the final hop; ensure delivery to all bridges (anon)
if not self._should_deliver_path_to_companions(packet):
for bridge in companion_bridges.values():
try:
await bridge.process_received_packet(packet)
except Exception as e:
logger.debug(f"Companion bridge RESPONSE (final hop) error: {e}")
processed_by_injection = True
self._record_for_ui(packet, metadata)
elif payload_type == ProtocolRequestHandler.payload_type():
# Process protocol request packet (status, telemetry, neighbors, etc.)
if self.daemon.protocol_request_helper:
dest_hash = packet.payload[0] if packet.payload else None
companion_bridges = getattr(self.daemon, "companion_bridges", {})
if dest_hash is not None and dest_hash in companion_bridges:
await companion_bridges[dest_hash].process_received_packet(packet)
processed_by_injection = True
self._record_for_ui(packet, metadata)
elif self.daemon.protocol_request_helper:
handled = await self.daemon.protocol_request_helper.process_request_packet(packet)
if handled:
processed_by_injection = True
self._record_for_ui(packet, metadata)
elif companion_bridges and _is_direct_final_hop(packet):
# DIRECT with empty path: we're the final hop; deliver to all bridges for anon matching
for bridge in companion_bridges.values():
try:
await bridge.process_received_packet(packet)
except Exception as e:
logger.debug(f"Companion bridge REQ (final hop) error: {e}")
processed_by_injection = True
self._record_for_ui(packet, metadata)
elif payload_type == GroupTextHandler.payload_type():
# GRP_TXT: pass to all companions (they filter by channel); still forward
companion_bridges = getattr(self.daemon, "companion_bridges", {})
for bridge in companion_bridges.values():
try:
await bridge.process_received_packet(packet)
except Exception as e:
logger.debug(f"Companion bridge GRP_TXT error: {e}")
# Only pass to repeater engine if not already processed by injection
# Skip engine for packets we injected for TX (already sent; avoid double-send/double-count)
if getattr(packet, "_injected_for_tx", False):
processed_by_injection = True
if self.daemon.repeater_handler and not processed_by_injection:
metadata = {
"rssi": getattr(packet, "rssi", 0),
+41 -12
View File
@@ -2,6 +2,7 @@
Service management utilities for pyMC Repeater.
Provides functions for service control operations like restart.
"""
import logging
import subprocess
from typing import Tuple
@@ -13,34 +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
["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)}"
+10 -8
View File
@@ -1,12 +1,14 @@
from .http_server import HTTPStatsServer, StatsApp, LogBuffer, _log_buffer
from .api_endpoints import APIEndpoints
from .cad_calibration_engine import CADCalibrationEngine
from .http_server import HTTPStatsServer, LogBuffer, StatsApp, _log_buffer
from .update_endpoints import UpdateAPIEndpoints
__all__ = [
'HTTPStatsServer',
'StatsApp',
'LogBuffer',
'APIEndpoints',
'CADCalibrationEngine',
'_log_buffer'
]
"HTTPStatsServer",
"StatsApp",
"LogBuffer",
"APIEndpoints",
"CADCalibrationEngine",
"UpdateAPIEndpoints",
"_log_buffer",
]
+1878 -1113
View File
File diff suppressed because it is too large Load Diff
+2 -6
View File
@@ -1,9 +1,5 @@
from .jwt_handler import JWTHandler
from .api_tokens import APITokenManager
from .jwt_handler import JWTHandler
from .middleware import require_auth
__all__ = [
'JWTHandler',
'APITokenManager',
'require_auth'
]
__all__ = ["JWTHandler", "APITokenManager", "require_auth"]
+9 -14
View File
@@ -1,8 +1,8 @@
import secrets
import hmac
import hashlib
from typing import Optional, List, Dict
import hmac
import logging
import secrets
from typing import Dict, List, Optional
logger = logging.getLogger(__name__)
@@ -11,18 +11,14 @@ class APITokenManager:
def __init__(self, sqlite_handler, secret_key: str):
self.db = sqlite_handler
self.secret_key = secret_key.encode('utf-8')
self.secret_key = secret_key.encode("utf-8")
def generate_api_token(self) -> str:
return secrets.token_hex(32)
def hash_token(self, token: str) -> str:
return hmac.new(
self.secret_key,
token.encode('utf-8'),
hashlib.sha256
).hexdigest()
return hmac.new(self.secret_key, token.encode("utf-8"), hashlib.sha256).hexdigest()
def create_token(self, name: str) -> tuple[int, str]:
plaintext_token = self.generate_api_token()
token_hash = self.hash_token(plaintext_token)
@@ -43,7 +39,6 @@ class APITokenManager:
logger.info(f"Revoked API token ID {token_id}")
return deleted
def list_tokens(self) -> List[Dict]:
return self.db.list_api_tokens()
+7 -6
View File
@@ -1,4 +1,5 @@
import logging
import cherrypy
logger = logging.getLogger("HTTPServer")
@@ -40,10 +41,10 @@ def check_auth():
cherrypy.request.user = {
"username": payload.get("sub"),
"client_id": payload.get("client_id"),
"auth_type": "jwt"
"auth_type": "jwt",
}
return
# Check for JWT token in query parameter (for EventSource/SSE)
# EventSource doesn't support custom headers, so we use query param
query_token = cherrypy.request.params.get("token")
@@ -54,7 +55,7 @@ def check_auth():
cherrypy.request.user = {
"username": payload.get("sub"),
"client_id": payload.get("client_id"),
"auth_type": "jwt_query"
"auth_type": "jwt_query",
}
# Remove token from params to avoid exposing it in logs
del cherrypy.request.params["token"]
@@ -69,15 +70,15 @@ def check_auth():
cherrypy.request.user = {
"token_id": token_info["id"],
"token_name": token_info["name"],
"auth_type": "api_token"
"auth_type": "api_token",
}
return
# No valid authentication found
logger.warning(f"Unauthorized access attempt to {cherrypy.request.path_info}")
raise cherrypy.HTTPError(401, "Unauthorized - Valid JWT or API token required")
# Register the tool
cherrypy.tools.require_auth = cherrypy.Tool('before_handler', check_auth)
cherrypy.tools.require_auth = cherrypy.Tool("before_handler", check_auth)
logger.info("CherryPy require_auth tool registered")
+10 -13
View File
@@ -1,10 +1,12 @@
import jwt
import logging
import time
from typing import Dict, Optional
import logging
import jwt
logger = logging.getLogger(__name__)
class JWTHandler:
def __init__(self, secret: str, expiry_minutes: int = 15):
self.secret = secret
@@ -14,21 +16,16 @@ class JWTHandler:
now = int(time.time())
expiry = now + (self.expiry_minutes * 60)
payload = {
'sub': username,
'exp': expiry,
'iat': now,
'client_id': client_id
}
token = jwt.encode(payload, self.secret, algorithm='HS256')
payload = {"sub": username, "exp": expiry, "iat": now, "client_id": client_id}
token = jwt.encode(payload, self.secret, algorithm="HS256")
logger.info(f"Created JWT for user '{username}' with client_id '{client_id[:8]}...'")
return token
def verify_jwt(self, token: str) -> Optional[Dict]:
try:
payload = jwt.decode(token, self.secret, algorithms=['HS256'])
payload = jwt.decode(token, self.secret, algorithms=["HS256"])
return payload
except jwt.ExpiredSignatureError:
logger.warning("JWT token expired")
+28 -27
View File
@@ -1,6 +1,7 @@
import cherrypy
from functools import wraps
import logging
from functools import wraps
import cherrypy
logger = logging.getLogger(__name__)
@@ -10,56 +11,56 @@ def require_auth(func):
@wraps(func)
def wrapper(*args, **kwargs):
# Skip authentication for OPTIONS requests (CORS preflight)
if cherrypy.request.method == 'OPTIONS':
if cherrypy.request.method == "OPTIONS":
return func(*args, **kwargs)
# Get auth handlers from global cherrypy config (not app config)
jwt_handler = cherrypy.config.get('jwt_handler')
token_manager = cherrypy.config.get('token_manager')
jwt_handler = cherrypy.config.get("jwt_handler")
token_manager = cherrypy.config.get("token_manager")
if not jwt_handler or not token_manager:
logger.error("Auth handlers not configured")
raise cherrypy.HTTPError(500, "Authentication not configured")
# Try JWT authentication first
auth_header = cherrypy.request.headers.get('Authorization', '')
if auth_header.startswith('Bearer '):
auth_header = cherrypy.request.headers.get("Authorization", "")
if auth_header.startswith("Bearer "):
token = auth_header[7:] # Remove 'Bearer ' prefix
payload = jwt_handler.verify_jwt(token)
if payload:
# JWT is valid
cherrypy.request.user = {
'username': payload['sub'],
'client_id': payload['client_id'],
'auth_type': 'jwt'
"username": payload["sub"],
"client_id": payload["client_id"],
"auth_type": "jwt",
}
return func(*args, **kwargs)
else:
logger.warning("Invalid or expired JWT token")
# Try API token authentication
api_key = cherrypy.request.headers.get('X-API-Key', '')
api_key = cherrypy.request.headers.get("X-API-Key", "")
if api_key:
token_info = token_manager.verify_token(api_key)
if token_info:
# API token is valid
cherrypy.request.user = {
'username': 'api_token',
'token_name': token_info['name'],
'token_id': token_info['id'],
'auth_type': 'api_token'
"username": "api_token",
"token_name": token_info["name"],
"token_id": token_info["id"],
"auth_type": "api_token",
}
return func(*args, **kwargs)
else:
logger.warning("Invalid API token")
# No valid authentication found
logger.warning(f"Unauthorized access attempt to {cherrypy.request.path_info}")
cherrypy.response.status = 401
cherrypy.response.headers['Content-Type'] = 'application/json'
return {'success': False, 'error': 'Unauthorized - Valid JWT or API token required'}
return wrapper
cherrypy.response.headers["Content-Type"] = "application/json"
return {"success": False, "error": "Unauthorized - Valid JWT or API token required"}
return wrapper
+140 -117
View File
@@ -3,13 +3,13 @@ import logging
import random
import threading
import time
from typing import Dict, Any, Optional
from typing import Any, Dict, Optional
logger = logging.getLogger("HTTPServer")
class CADCalibrationEngine:
def __init__(self, daemon_instance=None, event_loop=None):
self.daemon_instance = daemon_instance
self.event_loop = event_loop
@@ -19,26 +19,28 @@ class CADCalibrationEngine:
self.progress = {"current": 0, "total": 0}
self.clients = set() # SSE clients
self.calibration_thread = None
def get_test_ranges(self, spreading_factor: int):
"""Get CAD test ranges"""
# Higher values = less sensitive, lower values = more sensitive
# Test from LESS sensitive to MORE sensitive to find the sweet spot
sf_ranges = {
7: (range(22, 30, 1), range(12, 20, 1)),
8: (range(22, 30, 1), range(12, 20, 1)),
9: (range(24, 32, 1), range(14, 22, 1)),
10: (range(26, 34, 1), range(16, 24, 1)),
11: (range(28, 36, 1), range(18, 26, 1)),
12: (range(30, 38, 1), range(20, 28, 1)),
7: (range(22, 30, 1), range(12, 20, 1)),
8: (range(22, 30, 1), range(12, 20, 1)),
9: (range(24, 32, 1), range(14, 22, 1)),
10: (range(26, 34, 1), range(16, 24, 1)),
11: (range(28, 36, 1), range(18, 26, 1)),
12: (range(30, 38, 1), range(20, 28, 1)),
}
return sf_ranges.get(spreading_factor, sf_ranges[8])
async def test_cad_config(self, radio, det_peak: int, det_min: int, samples: int = 20) -> Dict[str, Any]:
async def test_cad_config(
self, radio, det_peak: int, det_min: int, samples: int = 20
) -> Dict[str, Any]:
detections = 0
baseline_detections = 0
# First, get baseline with very insensitive settings (should detect nothing)
baseline_samples = 5
for _ in range(baseline_samples):
@@ -50,10 +52,10 @@ class CADCalibrationEngine:
except Exception:
pass
await asyncio.sleep(0.1) # 100ms between baseline samples
# Wait before actual test
await asyncio.sleep(0.5)
# Now test the actual configuration
for i in range(samples):
try:
@@ -62,226 +64,247 @@ class CADCalibrationEngine:
detections += 1
except Exception:
pass
# Variable delay to avoid sampling artifacts
delay = 0.05 + (i % 3) * 0.05 # 50ms, 100ms, 150ms rotation
await asyncio.sleep(delay)
# Calculate adjusted detection rate
baseline_rate = (baseline_detections / baseline_samples) * 100
detection_rate = (detections / samples) * 100
# Subtract baseline noise
adjusted_rate = max(0, detection_rate - baseline_rate)
return {
'det_peak': det_peak,
'det_min': det_min,
'samples': samples,
'detections': detections,
'detection_rate': detection_rate,
'baseline_rate': baseline_rate,
'adjusted_rate': adjusted_rate, # This is the useful metric
'sensitivity_score': self._calculate_sensitivity_score(det_peak, det_min, adjusted_rate)
"det_peak": det_peak,
"det_min": det_min,
"samples": samples,
"detections": detections,
"detection_rate": detection_rate,
"baseline_rate": baseline_rate,
"adjusted_rate": adjusted_rate, # This is the useful metric
"sensitivity_score": self._calculate_sensitivity_score(
det_peak, det_min, adjusted_rate
),
}
def _calculate_sensitivity_score(self, det_peak: int, det_min: int, adjusted_rate: float) -> float:
def _calculate_sensitivity_score(
self, det_peak: int, det_min: int, adjusted_rate: float
) -> float:
# Ideal detection rate is around 10-30% for good sensitivity without false positives
ideal_rate = 20.0
rate_penalty = abs(adjusted_rate - ideal_rate) / ideal_rate
# Prefer moderate sensitivity settings (not too extreme)
sensitivity_penalty = (abs(det_peak - 25) + abs(det_min - 15)) / 20.0
# Lower penalty = higher score
score = max(0, 100 - (rate_penalty * 50) - (sensitivity_penalty * 20))
return score
def broadcast_to_clients(self, data):
# Store the message for clients to pick up
self.last_message = data
# Also store in a queue for clients to consume
if not hasattr(self, 'message_queue'):
if not hasattr(self, "message_queue"):
self.message_queue = []
self.message_queue.append(data)
def calibration_worker(self, samples: int, delay_ms: int):
try:
# Get radio from daemon instance
if not self.daemon_instance:
self.broadcast_to_clients({"type": "error", "message": "No daemon instance available"})
self.broadcast_to_clients(
{"type": "error", "message": "No daemon instance available"}
)
return
radio = getattr(self.daemon_instance, 'radio', None)
radio = getattr(self.daemon_instance, "radio", None)
if not radio:
self.broadcast_to_clients({"type": "error", "message": "Radio instance not available"})
self.broadcast_to_clients(
{"type": "error", "message": "Radio instance not available"}
)
return
if not hasattr(radio, 'perform_cad'):
self.broadcast_to_clients({"type": "error", "message": "Radio does not support CAD"})
if not hasattr(radio, "perform_cad"):
self.broadcast_to_clients(
{"type": "error", "message": "Radio does not support CAD"}
)
return
# Get spreading factor from daemon instance
config = getattr(self.daemon_instance, 'config', {})
config = getattr(self.daemon_instance, "config", {})
radio_config = config.get("radio", {})
sf = radio_config.get("spreading_factor", 8)
# Get test ranges
peak_range, min_range = self.get_test_ranges(sf)
total_tests = len(peak_range) * len(min_range)
self.progress = {"current": 0, "total": total_tests}
self.broadcast_to_clients({
"type": "status",
"message": f"Starting calibration: SF{sf}, {total_tests} tests",
"test_ranges": {
"peak_min": min(peak_range),
"peak_max": max(peak_range),
"min_min": min(min_range),
"min_max": max(min_range),
"spreading_factor": sf,
"total_tests": total_tests
self.broadcast_to_clients(
{
"type": "status",
"message": f"Starting calibration: SF{sf}, {total_tests} tests",
"test_ranges": {
"peak_min": min(peak_range),
"peak_max": max(peak_range),
"min_min": min(min_range),
"min_max": max(min_range),
"spreading_factor": sf,
"total_tests": total_tests,
},
}
})
)
current = 0
peak_list = list(peak_range)
min_list = list(min_range)
# Create all test combinations
test_combinations = []
for det_peak in peak_list:
for det_min in min_list:
test_combinations.append((det_peak, det_min))
# Sort by distance from center for center-out pattern
peak_center = (max(peak_list) + min(peak_list)) / 2
min_center = (max(min_list) + min(min_list)) / 2
def distance_from_center(combo):
peak, min_val = combo
return ((peak - peak_center) ** 2 + (min_val - min_center) ** 2) ** 0.5
# Sort by distance from center
test_combinations.sort(key=distance_from_center)
# Randomize within bands for better coverage
band_size = max(1, len(test_combinations) // 8) # Create 8 bands
randomized_combinations = []
for i in range(0, len(test_combinations), band_size):
band = test_combinations[i:i + band_size]
band = test_combinations[i : i + band_size]
random.shuffle(band) # Randomize within each band
randomized_combinations.extend(band)
# Run calibration in event loop with center-out randomized pattern
if self.event_loop:
for det_peak, det_min in randomized_combinations:
if not self.running:
break
current += 1
self.progress["current"] = current
# Update progress
self.broadcast_to_clients({
"type": "progress",
"current": current,
"total": total_tests,
"peak": det_peak,
"min": det_min
})
self.broadcast_to_clients(
{
"type": "progress",
"current": current,
"total": total_tests,
"peak": det_peak,
"min": det_min,
}
)
# Run the test
future = asyncio.run_coroutine_threadsafe(
self.test_cad_config(radio, det_peak, det_min, samples),
self.event_loop
self.test_cad_config(radio, det_peak, det_min, samples), self.event_loop
)
try:
result = future.result(timeout=30) # 30 second timeout per test
# Store result
key = f"{det_peak}-{det_min}"
self.results[key] = result
# Send result to clients
self.broadcast_to_clients({
"type": "result",
**result
})
self.broadcast_to_clients({"type": "result", **result})
except Exception as e:
logger.error(f"CAD test failed for peak={det_peak}, min={det_min}: {e}")
# Delay between tests
if self.running and delay_ms > 0:
time.sleep(delay_ms / 1000.0)
if self.running:
# Find best result based on sensitivity score (not just detection rate)
best_result = None
recommended_result = None
if self.results:
# Find result with highest sensitivity score (best balance)
best_result = max(self.results.values(), key=lambda x: x.get('sensitivity_score', 0))
best_result = max(
self.results.values(), key=lambda x: x.get("sensitivity_score", 0)
)
# Also find result with ideal adjusted detection rate (10-30%)
ideal_results = [r for r in self.results.values() if 10 <= r.get('adjusted_rate', 0) <= 30]
ideal_results = [
r for r in self.results.values() if 10 <= r.get("adjusted_rate", 0) <= 30
]
if ideal_results:
# Among ideal results, pick the one with best sensitivity score
recommended_result = max(ideal_results, key=lambda x: x.get('sensitivity_score', 0))
recommended_result = max(
ideal_results, key=lambda x: x.get("sensitivity_score", 0)
)
else:
recommended_result = best_result
self.broadcast_to_clients({
"type": "completed",
"message": "Calibration completed",
"results": {
"best": best_result,
"recommended": recommended_result,
"total_tests": len(self.results)
} if best_result else None
})
self.broadcast_to_clients(
{
"type": "completed",
"message": "Calibration completed",
"results": (
{
"best": best_result,
"recommended": recommended_result,
"total_tests": len(self.results),
}
if best_result
else None
),
}
)
else:
self.broadcast_to_clients({"type": "status", "message": "Calibration stopped"})
except Exception as e:
logger.error(f"Calibration worker error: {e}")
self.broadcast_to_clients({"type": "error", "message": str(e)})
finally:
self.running = False
def start_calibration(self, samples: int = 8, delay_ms: int = 100):
if self.running:
return False
self.running = True
self.results.clear()
self.progress = {"current": 0, "total": 0}
self.clear_message_queue() # Clear any old messages
# Start calibration in separate thread
self.calibration_thread = threading.Thread(
target=self.calibration_worker,
args=(samples, delay_ms)
target=self.calibration_worker, args=(samples, delay_ms)
)
self.calibration_thread.daemon = True
self.calibration_thread.start()
return True
def stop_calibration(self):
self.running = False
if self.calibration_thread:
self.calibration_thread.join(timeout=2)
def clear_message_queue(self):
if hasattr(self, 'message_queue'):
self.message_queue.clear()
if hasattr(self, "message_queue"):
self.message_queue.clear()
+720
View File
@@ -0,0 +1,720 @@
"""
Companion Bridge REST API and SSE event stream endpoints.
Mounted as a nested CherryPy object at /api/companion/ via APIEndpoints.
Provides browser-accessible REST endpoints that proxy into the CompanionBridge
async methods, plus a Server-Sent Events stream for real-time push callbacks.
"""
import asyncio
import json
import logging
import queue
import threading
import time
from typing import Optional
import cherrypy
from repeater.companion.utils import validate_companion_node_name
from .auth.middleware import require_auth
logger = logging.getLogger("CompanionAPI")
class CompanionAPIEndpoints:
"""REST + SSE endpoints for a companion bridge.
CherryPy auto-mounts this at ``/api/companion/`` when assigned as
``APIEndpoints.companion``. All async bridge calls are dispatched
to the daemon's event loop via ``asyncio.run_coroutine_threadsafe``.
"""
def __init__(self, daemon_instance=None, event_loop=None, config=None, config_manager=None):
self.daemon_instance = daemon_instance
self.event_loop = event_loop
self.config = config or {}
self.config_manager = config_manager
# SSE clients: each gets a thread-safe queue
self._sse_clients: list[queue.Queue] = []
self._sse_lock = threading.Lock()
# Flag: have we registered push callbacks yet?
self._callbacks_registered = False
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
def _get_bridge(self, name: Optional[str] = None, companion_hash: Optional[int] = None):
"""Return the companion bridge, or raise 503/404 if unavailable.
Resolution order (mirrors room-server pattern):
1. *name* look up via identity_manager by registered name.
2. *companion_hash* direct lookup in ``companion_bridges`` dict.
3. Neither return the first (and typically only) bridge.
"""
if not self.daemon_instance:
raise cherrypy.HTTPError(503, "Daemon not initialized")
bridges = getattr(self.daemon_instance, "companion_bridges", {})
if not bridges:
raise cherrypy.HTTPError(503, "No companion bridges configured")
# --- resolve by name via identity_manager (same pattern as room servers) ---
if name is not None:
identity_manager = getattr(self.daemon_instance, "identity_manager", None)
if identity_manager:
for reg_name, identity, _cfg in identity_manager.get_identities_by_type(
"companion"
):
if reg_name == name:
hash_byte = identity.get_public_key()[0]
bridge = bridges.get(hash_byte)
if bridge:
return bridge
raise cherrypy.HTTPError(404, f"Companion '{name}' not found")
# --- resolve by hash (fallback) ---
if companion_hash is not None:
bridge = bridges.get(companion_hash)
if not bridge:
msg = f"Companion 0x{companion_hash:02X} not found" # noqa: E231
raise cherrypy.HTTPError(404, msg)
return bridge
# --- default: first bridge ---
return next(iter(bridges.values()))
def _resolve_bridge_params(self, params) -> dict:
"""Extract optional companion name/hash from request params.
Returns kwargs suitable for ``_get_bridge(**result)``.
Follows the room-server convention: ``companion_name`` is the
primary selector, ``companion_hash`` is the fallback.
"""
name = params.get("companion_name")
raw_hash = params.get("companion_hash")
result: dict = {}
if name is not None:
result["name"] = str(name)
elif raw_hash is not None:
try:
result["companion_hash"] = int(str(raw_hash), 0)
except (ValueError, TypeError):
raise cherrypy.HTTPError(400, "Invalid companion_hash")
return result
def _run_async(self, coro, timeout: float = 30.0):
"""Run an async coroutine on the daemon event loop and return result."""
if self.event_loop is None:
raise cherrypy.HTTPError(503, "Event loop not available")
future = asyncio.run_coroutine_threadsafe(coro, self.event_loop)
return future.result(timeout=timeout)
@staticmethod
def _success(data, **kwargs):
result = {"success": True, "data": data}
result.update(kwargs)
return result
@staticmethod
def _error(msg):
return {"success": False, "error": str(msg)}
def _require_post(self):
if cherrypy.request.method != "POST":
cherrypy.response.headers["Allow"] = "POST"
raise cherrypy.HTTPError(405, "Method not allowed. Use POST.")
def _get_json_body(self) -> dict:
"""Read and parse the JSON request body."""
try:
raw = cherrypy.request.body.read()
return json.loads(raw) if raw else {}
except (json.JSONDecodeError, ValueError) as exc:
raise cherrypy.HTTPError(400, f"Invalid JSON body: {exc}")
def _pub_key_from_hex(self, hex_str: str) -> bytes:
"""Decode a hex public key, raising 400 on error."""
try:
key = bytes.fromhex(hex_str)
if len(key) != 32:
raise ValueError("Expected 32-byte key")
return key
except (ValueError, TypeError) as exc:
raise cherrypy.HTTPError(400, f"Invalid public key: {exc}")
def _get_sqlite_handler(self):
"""Return the repeater's sqlite_handler, or raise 503 if unavailable."""
if not self.daemon_instance:
raise cherrypy.HTTPError(503, "Daemon not initialized")
if (
not hasattr(self.daemon_instance, "repeater_handler")
or not self.daemon_instance.repeater_handler
):
raise cherrypy.HTTPError(503, "Repeater handler not initialized")
storage = getattr(self.daemon_instance.repeater_handler, "storage", None)
if not storage:
raise cherrypy.HTTPError(503, "Storage not initialized")
sqlite_handler = getattr(storage, "sqlite_handler", None)
if not sqlite_handler:
raise cherrypy.HTTPError(503, "SQLite storage not available")
return sqlite_handler
# ------------------------------------------------------------------
# SSE push-event plumbing
# ------------------------------------------------------------------
def _ensure_callbacks(self):
"""Register push callbacks on the bridge (once)."""
if self._callbacks_registered:
return
try:
bridge = self._get_bridge()
except cherrypy.HTTPError:
return # bridge not yet available
def _make_cb(event_name):
"""Create a callback that serialises event data for SSE clients."""
def _cb(*args, **kwargs):
payload = self._serialise_event(event_name, args, kwargs)
self._broadcast_sse(payload)
return _cb
callback_names = [
"message_received",
"channel_message_received",
"advert_received",
"contact_path_updated",
"send_confirmed",
"login_result",
]
for name in callback_names:
register_fn = getattr(bridge, f"on_{name}", None)
if register_fn:
register_fn(_make_cb(name))
self._callbacks_registered = True
@staticmethod
def _serialise_event(event_name: str, args: tuple, kwargs: dict) -> dict:
"""Convert callback arguments to a JSON-safe dict."""
data: dict = {"event": event_name, "timestamp": int(time.time())}
for i, arg in enumerate(args):
data[f"arg{i}"] = _to_json_safe(arg)
for k, v in kwargs.items():
data[k] = _to_json_safe(v)
return data
def _broadcast_sse(self, payload: dict):
"""Put *payload* into every active SSE client queue."""
with self._sse_lock:
dead = []
for q in self._sse_clients:
try:
q.put_nowait(payload)
except queue.Full:
dead.append(q)
for q in dead:
self._sse_clients.remove(q)
# ==================================================================
# REST Endpoints
# ==================================================================
# ----- Index / listing -----
@cherrypy.expose
@cherrypy.tools.json_out()
@require_auth
def index(self, **kwargs):
"""GET /api/companion/ — list configured companions."""
bridges = getattr(self.daemon_instance, "companion_bridges", {})
identity_manager = getattr(self.daemon_instance, "identity_manager", None)
# Build name lookup from identity_manager (same pattern as room servers)
name_by_hash: dict[int, str] = {}
if identity_manager:
for reg_name, identity, _cfg in identity_manager.get_identities_by_type("companion"):
name_by_hash[identity.get_public_key()[0]] = reg_name
items = []
for h, b in bridges.items():
items.append(
{
"companion_name": name_by_hash.get(h, ""),
"companion_hash": f"0x{h:02X}", # noqa: E231
"node_name": b.prefs.node_name,
"public_key": b.get_public_key().hex(),
"is_running": b.is_running,
"contacts_count": b.contacts.get_count(),
"channels_count": b.channels.get_count(),
}
)
return self._success(items)
# ----- Identity -----
@cherrypy.expose
@cherrypy.tools.json_out()
@require_auth
def self_info(self, **kwargs):
"""GET /api/companion/self_info — node identity and preferences."""
bridge = self._get_bridge(**self._resolve_bridge_params(kwargs))
prefs = bridge.get_self_info()
return self._success(
{
"public_key": bridge.get_public_key().hex(),
"node_name": prefs.node_name,
"adv_type": prefs.adv_type,
"tx_power_dbm": prefs.tx_power_dbm,
"frequency_hz": prefs.frequency_hz,
"bandwidth_hz": prefs.bandwidth_hz,
"spreading_factor": prefs.spreading_factor,
"coding_rate": prefs.coding_rate,
"latitude": prefs.latitude,
"longitude": prefs.longitude,
}
)
# ----- Contacts -----
@cherrypy.expose
@cherrypy.tools.json_out()
@require_auth
def contacts(self, **kwargs):
"""GET /api/companion/contacts — list all contacts."""
bridge = self._get_bridge(**self._resolve_bridge_params(kwargs))
since = int(kwargs.get("since", 0))
contacts = bridge.get_contacts(since=since)
items = []
for c in contacts:
items.append(
{
"public_key": (
c.public_key.hex() if isinstance(c.public_key, bytes) else c.public_key
),
"name": c.name,
"adv_type": c.adv_type,
"flags": c.flags,
"out_path_len": c.out_path_len,
"last_advert_timestamp": c.last_advert_timestamp,
"lastmod": c.lastmod,
"gps_lat": c.gps_lat,
"gps_lon": c.gps_lon,
}
)
return self._success(items)
@cherrypy.expose
@cherrypy.tools.json_out()
@require_auth
def contact(self, **kwargs):
"""GET /api/companion/contact?pub_key=<hex> — get single contact."""
bridge = self._get_bridge(**self._resolve_bridge_params(kwargs))
pk_hex = kwargs.get("pub_key")
if not pk_hex:
raise cherrypy.HTTPError(400, "pub_key required")
pub_key = self._pub_key_from_hex(pk_hex)
c = bridge.get_contact_by_key(pub_key)
if not c:
raise cherrypy.HTTPError(404, "Contact not found")
return self._success(
{
"public_key": (
c.public_key.hex() if isinstance(c.public_key, bytes) else c.public_key
),
"name": c.name,
"adv_type": c.adv_type,
"flags": c.flags,
"out_path_len": c.out_path_len,
"out_path": c.out_path.hex() if isinstance(c.out_path, bytes) else "",
"last_advert_timestamp": c.last_advert_timestamp,
"lastmod": c.lastmod,
"gps_lat": c.gps_lat,
"gps_lon": c.gps_lon,
}
)
@cherrypy.expose
@cherrypy.tools.json_out()
@require_auth
def import_repeater_contacts(self, **kwargs):
"""POST /api/companion/import_repeater_contacts {companion_name, contact_types?, hours?, limit?}
Import repeater adverts into this companion's contact store (one-time seed).
Optional: contact_types (list), hours (only adverts seen in last N hours),
limit (max contacts to import, capped by companion max_contacts).
Results are sorted by last_seen DESC. After import, contacts are hot-reloaded.
"""
self._require_post()
body = self._get_json_body()
companion_name = body.get("companion_name")
if not companion_name:
raise cherrypy.HTTPError(400, "companion_name required")
contact_types = body.get("contact_types")
if contact_types is not None:
if not isinstance(contact_types, list):
raise cherrypy.HTTPError(400, "contact_types must be a list")
allowed = {"companion", "repeater", "room_server", "sensor"}
for t in contact_types:
if not isinstance(t, str) or t not in allowed:
raise cherrypy.HTTPError(
400,
f"contact_types must contain only: companion, repeater, room_server, sensor (got {t!r})",
)
if not contact_types:
contact_types = None
hours = body.get("hours")
if hours is not None:
try:
hours = int(hours)
except (TypeError, ValueError):
raise cherrypy.HTTPError(400, "hours must be a positive integer")
if hours < 1:
raise cherrypy.HTTPError(400, "hours must be a positive integer")
limit = body.get("limit")
if limit is not None:
try:
limit = int(limit)
except (TypeError, ValueError):
raise cherrypy.HTTPError(400, "limit must be a positive integer")
if limit < 1:
raise cherrypy.HTTPError(400, "limit must be a positive integer")
bridge = self._get_bridge(**self._resolve_bridge_params(body))
if limit is not None:
max_contacts = getattr(bridge, "max_contacts", 1000)
limit = min(limit, max_contacts)
companion_hash = getattr(bridge, "_companion_hash", None)
if not companion_hash:
raise cherrypy.HTTPError(503, "Companion hash not available")
sqlite_handler = self._get_sqlite_handler()
count = sqlite_handler.companion_import_repeater_contacts(
companion_hash,
contact_types=contact_types,
hours=hours,
limit=limit,
)
contact_rows = sqlite_handler.companion_load_contacts(companion_hash)
if contact_rows:
records = []
for row in contact_rows:
d = dict(row)
d["public_key"] = d.pop("pubkey", d.get("public_key", b""))
records.append(d)
bridge.contacts.load_from_dicts(records)
return self._success({"imported": count})
# ----- Channels -----
@cherrypy.expose
@cherrypy.tools.json_out()
@require_auth
def channels(self, **kwargs):
"""GET /api/companion/channels — list configured channels."""
try:
bridge = self._get_bridge(**self._resolve_bridge_params(kwargs))
items = []
for idx in range(bridge.channels.max_channels):
ch = bridge.channels.get(idx)
if ch:
items.append(
{
"index": idx,
"name": ch.name,
# Don't expose the PSK secret over REST
}
)
return self._success(items)
except cherrypy.HTTPError:
raise
except Exception as exc:
logger.error(f"channels endpoint error: {exc}", exc_info=True)
return self._error(str(exc))
# ----- Statistics -----
@cherrypy.expose
@cherrypy.tools.json_out()
@require_auth
def stats(self, **kwargs):
"""GET /api/companion/stats?type=packets — local companion stats."""
bridge = self._get_bridge(**self._resolve_bridge_params(kwargs))
stats_type_map = {"core": 0, "radio": 1, "packets": 2}
stype = stats_type_map.get(kwargs.get("type", "packets"), 2)
return self._success(bridge.get_stats(stype))
# ----- Messaging -----
@cherrypy.expose
@cherrypy.tools.json_out()
@require_auth
def send_text(self, **kwargs):
"""POST /api/companion/send_text {pub_key, text, txt_type?, companion_name?}"""
self._require_post()
body = self._get_json_body()
bridge = self._get_bridge(**self._resolve_bridge_params(body))
pub_key = self._pub_key_from_hex(body.get("pub_key", ""))
text = body.get("text", "")
if not text:
raise cherrypy.HTTPError(400, "text required")
txt_type = int(body.get("txt_type", 0))
result = self._run_async(bridge.send_text_message(pub_key, text, txt_type=txt_type))
return self._success(
{
"sent": result.success,
"is_flood": result.is_flood,
"expected_ack": result.expected_ack,
}
)
@cherrypy.expose
@cherrypy.tools.json_out()
@require_auth
def send_channel_message(self, **kwargs):
"""POST /api/companion/send_channel_message {channel_idx, text, companion_name?}"""
self._require_post()
body = self._get_json_body()
bridge = self._get_bridge(**self._resolve_bridge_params(body))
channel_idx = int(body.get("channel_idx", 0))
text = body.get("text", "")
if not text:
raise cherrypy.HTTPError(400, "text required")
success = self._run_async(bridge.send_channel_message(channel_idx, text))
return self._success({"sent": success})
# ----- Login -----
@cherrypy.expose
@cherrypy.tools.json_out()
@require_auth
def login(self, **kwargs):
"""POST /api/companion/login {pub_key, password?, companion_name?}"""
self._require_post()
body = self._get_json_body()
bridge = self._get_bridge(**self._resolve_bridge_params(body))
pub_key = self._pub_key_from_hex(body.get("pub_key", ""))
password = body.get("password", "")
result = self._run_async(bridge.send_login(pub_key, password), timeout=15.0)
return self._success(_to_json_safe(result))
# ----- Status / Telemetry Requests -----
@cherrypy.expose
@cherrypy.tools.json_out()
@require_auth
def request_status(self, **kwargs):
"""POST /api/companion/request_status {pub_key, timeout?, companion_name?}"""
self._require_post()
body = self._get_json_body()
bridge = self._get_bridge(**self._resolve_bridge_params(body))
pub_key = self._pub_key_from_hex(body.get("pub_key", ""))
timeout = float(body.get("timeout", 15.0))
result = self._run_async(
bridge.send_status_request(pub_key, timeout=timeout),
timeout=timeout + 5.0,
)
return self._success(_to_json_safe(result))
@cherrypy.expose
@cherrypy.tools.json_out()
@require_auth
def request_telemetry(self, **kwargs):
"""POST /api/companion/request_telemetry.
Body: pub_key, want_base?, want_location?, want_environment?,
timeout?, companion_name?
On success, telemetry_data includes raw_bytes (LPP hex), sensors (parsed),
and frame_bytes (hex): companion-style frame 0x8B + 0 + 6B pubkey prefix + LPP.
"""
self._require_post()
try:
body = self._get_json_body()
bridge = self._get_bridge(**self._resolve_bridge_params(body))
pub_key = self._pub_key_from_hex(body.get("pub_key", ""))
timeout = float(body.get("timeout", 20.0))
result = self._run_async(
bridge.send_telemetry_request(
pub_key,
want_base=bool(body.get("want_base", True)),
want_location=bool(body.get("want_location", True)),
want_environment=bool(body.get("want_environment", True)),
timeout=timeout,
),
timeout=timeout + 5.0,
)
# Ensure all values are JSON-serialisable (telemetry may contain bytes)
return self._success(_to_json_safe(result))
except cherrypy.HTTPError:
raise
except Exception as exc:
logger.error(f"request_telemetry endpoint error: {exc}", exc_info=True)
return self._error(str(exc))
# ----- Repeater Commands -----
@cherrypy.expose
@cherrypy.tools.json_out()
@require_auth
def send_command(self, **kwargs):
"""POST /api/companion/send_command {pub_key, command, parameters?, companion_name?}"""
self._require_post()
body = self._get_json_body()
bridge = self._get_bridge(**self._resolve_bridge_params(body))
pub_key = self._pub_key_from_hex(body.get("pub_key", ""))
command = body.get("command", "")
if not command:
raise cherrypy.HTTPError(400, "command required")
parameters = body.get("parameters")
result = self._run_async(
bridge.send_repeater_command(pub_key, command, parameters),
timeout=20.0,
)
return self._success(_to_json_safe(result))
# ----- Path / Routing -----
@cherrypy.expose
@cherrypy.tools.json_out()
@require_auth
def reset_path(self, **kwargs):
"""POST /api/companion/reset_path {pub_key, companion_name?}"""
self._require_post()
body = self._get_json_body()
bridge = self._get_bridge(**self._resolve_bridge_params(body))
pub_key = self._pub_key_from_hex(body.get("pub_key", ""))
ok = bridge.reset_path(pub_key)
return self._success({"reset": ok})
# ----- Device Configuration -----
@cherrypy.expose
@cherrypy.tools.json_out()
@require_auth
def set_advert_name(self, **kwargs):
"""POST /api/companion/set_advert_name {advert_name, companion_name?}"""
self._require_post()
body = self._get_json_body()
bridge = self._get_bridge(**self._resolve_bridge_params(body))
name = body.get("advert_name", body.get("name", ""))
if not name:
raise cherrypy.HTTPError(400, "name required")
try:
validated_name = validate_companion_node_name(name)
except ValueError as e:
raise cherrypy.HTTPError(400, str(e)) from e
bridge.set_advert_name(validated_name)
# Optionally sync node_name to config.yaml so it survives restart
companion_name = body.get("companion_name")
if companion_name is None and getattr(self.daemon_instance, "identity_manager", None):
pubkey = bridge.get_public_key()
for reg_name, identity, _ in self.daemon_instance.identity_manager.get_identities_by_type(
"companion"
):
if identity.get_public_key() == pubkey:
companion_name = reg_name
break
if companion_name and self.config_manager:
companions = (self.config.get("identities") or {}).get("companions") or []
for entry in companions:
if entry.get("name") == companion_name:
if "settings" not in entry:
entry["settings"] = {}
entry["settings"]["node_name"] = validated_name
try:
if not self.config_manager.save_to_file():
logger.warning("Failed to save config after set_advert_name")
except Exception as e:
logger.warning("Error saving config after set_advert_name: %s", e)
break
return self._success({"name": bridge.prefs.node_name})
@cherrypy.expose
@cherrypy.tools.json_out()
@require_auth
def set_advert_location(self, **kwargs):
"""POST /api/companion/set_advert_location {latitude, longitude, companion_name?}"""
self._require_post()
body = self._get_json_body()
bridge = self._get_bridge(**self._resolve_bridge_params(body))
lat = float(body.get("latitude", 0.0))
lon = float(body.get("longitude", 0.0))
bridge.set_advert_latlon(lat, lon)
return self._success({"latitude": lat, "longitude": lon})
# ==================================================================
# SSE Event Stream
# ==================================================================
@cherrypy.expose
def events(self, **kwargs):
"""GET /api/companion/events — Server-Sent Events stream for push callbacks.
Connect with ``EventSource('/api/companion/events?token=JWT')``.
Auth is handled by the CherryPy tool-level require_auth (supports
query-param JWT tokens needed by the browser EventSource API).
"""
self._ensure_callbacks()
cherrypy.response.headers["Content-Type"] = "text/event-stream"
cherrypy.response.headers["Cache-Control"] = "no-cache"
cherrypy.response.headers["Connection"] = "keep-alive"
cherrypy.response.headers["X-Accel-Buffering"] = "no"
client_queue: queue.Queue = queue.Queue(maxsize=256)
with self._sse_lock:
self._sse_clients.append(client_queue)
def generate():
try:
payload = {"event": "connected", "timestamp": int(time.time())}
yield f"data: {json.dumps(payload)}\n\n"
while True:
try:
item = client_queue.get(timeout=15.0)
yield f"data: {json.dumps(item)}\n\n"
except queue.Empty:
# Keep-alive comment
payload = {"event": "keepalive", "timestamp": int(time.time())}
yield f"data: {json.dumps(payload)}\n\n"
except GeneratorExit:
pass
except Exception as exc:
logger.debug(f"SSE stream ended: {exc}")
finally:
with self._sse_lock:
if client_queue in self._sse_clients:
self._sse_clients.remove(client_queue)
return generate()
events._cp_config = {"response.stream": True}
# ======================================================================
# Utility: make arbitrary objects JSON-serialisable for SSE events
# ======================================================================
def _to_json_safe(obj):
"""Convert common companion objects to JSON-safe dicts/values."""
if obj is None or isinstance(obj, (bool, int, float, str)):
return obj
if isinstance(obj, bytes):
return obj.hex()
if isinstance(obj, bytearray):
return bytes(obj).hex()
if isinstance(obj, dict):
return {k: _to_json_safe(v) for k, v in obj.items()}
if isinstance(obj, (list, tuple)):
return [_to_json_safe(v) for v in obj]
# Dataclass / namedtuple with __dict__
if hasattr(obj, "__dict__"):
return {k: _to_json_safe(v) for k, v in obj.__dict__.items() if not k.startswith("_")}
return str(obj)
+230
View File
@@ -0,0 +1,230 @@
"""
WebSocket proxy for the companion frame protocol.
Bridges browser WebSocket to the companion TCP frame server.
Raw byte pipe no parsing, all protocol logic lives in the client.
"""
import logging
import socket
import threading
from urllib.parse import parse_qs
import cherrypy
from ws4py.websocket import WebSocket
logger = logging.getLogger("CompanionWSProxy")
# Set by http_server.py before CherryPy starts
_daemon = None
def set_daemon(instance):
global _daemon
_daemon = instance
class CompanionFrameWebSocket(WebSocket):
def opened(self):
"""Authenticate, resolve companion, open TCP socket, start reader."""
# JWT auth — same pattern as PacketWebSocket
jwt_handler = cherrypy.config.get("jwt_handler")
qs = ""
if hasattr(self, "environ"):
qs = self.environ.get("QUERY_STRING", "")
params = parse_qs(qs)
token = params.get("token", [None])[0]
companion_name = params.get("companion_name", [None])[0]
if not jwt_handler:
logger.warning("Connection rejected: no JWT handler configured")
self.close(code=1011, reason="server configuration error")
return
if not token:
logger.warning("Connection rejected: missing token")
self.close(code=1008, reason="unauthorized")
return
try:
payload = jwt_handler.verify_jwt(token)
if not payload:
logger.warning("Connection rejected: invalid token")
self.close(code=1008, reason="unauthorized")
return
except Exception as e:
logger.warning(f"Auth error: {e}")
self.close(code=1008, reason="unauthorized")
return
if not companion_name:
logger.warning("Connection rejected: missing companion_name")
self.close(code=1008, reason="missing companion_name")
return
# Resolve companion TCP port + bind address from config
resolved = self._resolve_tcp_endpoint(companion_name)
if resolved is None:
logger.warning(f"Connection rejected: companion '{companion_name}' not found")
self.close(code=1008, reason="companion not found")
return
tcp_host, tcp_port = resolved
# Open TCP socket to the companion frame server
try:
self._tcp = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self._tcp.settimeout(5.0)
self._tcp.connect((tcp_host, tcp_port))
self._tcp.settimeout(None)
logger.debug(f"TCP connected to {tcp_host}:{tcp_port} for '{companion_name}'")
except Exception as e:
logger.error(f"TCP connect failed for '{companion_name}' {tcp_host}:{tcp_port}: {e}")
self._tcp = None
self.close(code=1011, reason="TCP connect failed")
return
self._closing = False
self._companion_name = companion_name
self._reader = threading.Thread(
target=self._tcp_to_ws, daemon=True, name=f"ws-tcp-{companion_name}"
)
self._reader.start()
user = payload.get("sub", "unknown")
logger.info(f"Companion WS opened: user={user}, companion={companion_name}, tcp={tcp_host}:{tcp_port}")
def received_message(self, message):
"""WS → TCP"""
tcp = getattr(self, "_tcp", None)
if tcp is None or getattr(self, "_closing", True):
return
try:
data = message.data
if isinstance(data, str):
data = data.encode("latin-1")
tcp.sendall(data)
except Exception as e:
name = getattr(self, "_companion_name", "?")
logger.warning(f"WS→TCP send failed for '{name}': {e}")
self._teardown()
def closed(self, code, reason=None):
name = getattr(self, "_companion_name", "?")
logger.info(f"Companion WS closed: companion={name}, code={code}, reason={reason}")
self._teardown()
# ── internal ─────────────────────────────────────────────────────────
def _resolve_tcp_endpoint(self, companion_name):
"""Look up companion TCP host + port from daemon config.
Returns ``(host, port)`` tuple or ``None`` if the companion can't be
resolved. When ``bind_address`` is ``0.0.0.0`` (all interfaces) we
connect via ``127.0.0.1``; otherwise we use the configured address.
"""
if not _daemon:
logger.warning("_resolve_tcp_endpoint: daemon not set")
return None
identity_manager = getattr(_daemon, "identity_manager", None)
bridges = getattr(_daemon, "companion_bridges", {})
if not identity_manager:
logger.warning("_resolve_tcp_endpoint: no identity_manager")
return None
if not bridges:
logger.warning("_resolve_tcp_endpoint: no companion_bridges (dict empty or missing)")
return None
# Find the companion identity by name and verify its bridge is running
found = False
for name, identity, _cfg in identity_manager.get_identities_by_type("companion"):
if name == companion_name:
h = identity.get_public_key()[0]
if h in bridges:
found = True
else:
logger.warning(
f"_resolve_tcp_endpoint: companion '{companion_name}' identity found "
f"(hash=0x{h:02x}) but no bridge registered for that hash. "
f"Known bridge hashes: {[f'0x{k:02x}' for k in bridges.keys()]}"
)
break
else:
# Loop completed without finding the name
known = [n for n, _, _ in identity_manager.get_identities_by_type("companion")]
logger.warning(
f"_resolve_tcp_endpoint: companion '{companion_name}' not in identity_manager. "
f"Known companions: {known}"
)
if not found:
return None
# Look up TCP port + bind address from config
companions = _daemon.config.get("identities", {}).get("companions") or []
for entry in companions:
if entry.get("name") == companion_name:
settings = entry.get("settings") or {}
port = settings.get("tcp_port", 5000)
bind = settings.get("bind_address", "0.0.0.0")
# 0.0.0.0 = all interfaces — connect via loopback
host = "127.0.0.1" if bind == "0.0.0.0" else bind
logger.debug(f"_resolve_tcp_endpoint: '{companion_name}'{host}:{port}")
return (host, port)
logger.warning(
f"_resolve_tcp_endpoint: '{companion_name}' found in identity_manager but missing from config"
)
return None
def _tcp_to_ws(self):
"""TCP → WS reader loop"""
name = getattr(self, "_companion_name", "?")
tcp = getattr(self, "_tcp", None)
if tcp is None:
return
try:
while not getattr(self, "_closing", True):
data = tcp.recv(4096)
if not data:
logger.info(f"TCP→WS: frame server closed connection for '{name}'")
break
try:
self.send(data, binary=True)
except Exception as e:
logger.warning(f"TCP→WS: WS send failed for '{name}': {e}")
break
except OSError as e:
# Socket error (connection reset, etc.) — normal during teardown
if not getattr(self, "_closing", True):
logger.warning(f"TCP→WS: socket error for '{name}': {e}")
except Exception as e:
logger.warning(f"TCP→WS: unexpected error for '{name}': {e}")
finally:
self._teardown()
def _teardown(self):
if getattr(self, "_closing", True):
return
self._closing = True
name = getattr(self, "_companion_name", "?")
logger.debug(f"Tearing down WS proxy for '{name}'")
tcp = getattr(self, "_tcp", None)
if tcp:
try:
tcp.close()
except Exception:
pass
self._tcp = None
try:
self.close()
except Exception:
pass
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 +1 @@
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-sHch0610.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 m,e as n,h as p,f as t,x as g,t as s,k as d,q as l}from"./index-Cc9qVZfW.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"},M={class:"flex gap-3"},j=m({__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,k=i=>{i.target===i.currentTarget&&r("close")},u={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:k,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",u[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",M,[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)])])])):p("",!0)}});export{j as _};
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 +1 @@
import{a as e,b as r,i as o,p as n}from"./index-sHch0610.js";const d=e({name:"HelpView",__name:"Help",setup(a){return(i,t)=>(n(),r("div",null,t[0]||(t[0]=[o('<div class="glass-card backdrop-blur border border-stroke-subtle dark:border-white/10 rounded-[15px] p-8"><h1 class="text-content-primary dark:text-content-primary text-2xl font-semibold mb-6">Help &amp; Documentation</h1><div class="text-center py-12"><div class="text-primary mb-6"><svg class="w-20 h-20 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.746 0 3.332.477 4.5 1.253v13C19.832 18.477 18.246 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"></path></svg></div><h2 class="text-content-primary dark:text-content-primary text-xl font-medium mb-3">pyMC Repeater Wiki</h2><p class="text-content-secondary dark:text-content-muted mb-8 max-w-md mx-auto"> Access documentation, setup guides, troubleshooting tips, and community resources on our official wiki. </p><a href="https://github.com/rightup/pyMC_Repeater/wiki" target="_blank" rel="noopener noreferrer" class="inline-flex items-center gap-2 bg-primary hover:bg-primary/80 text-white dark:text-background font-medium py-3 px-6 rounded-xl transition-colors duration-200"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path></svg> Visit Wiki Documentation </a><div class="mt-8 text-xs text-content-muted dark:text-content-muted"> Opens in a new tab </div></div></div>',1)])))}});export{d as default};
import{a as e,e as r,j as o,q as n}from"./index-Cc9qVZfW.js";const d=e({name:"HelpView",__name:"Help",setup(a){return(i,t)=>(n(),r("div",null,t[0]||(t[0]=[o('<div class="glass-card backdrop-blur border border-stroke-subtle dark:border-white/10 rounded-[15px] p-8"><h1 class="text-content-primary dark:text-content-primary text-2xl font-semibold mb-6">Help &amp; Documentation</h1><div class="text-center py-12"><div class="text-primary mb-6"><svg class="w-20 h-20 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.746 0 3.332.477 4.5 1.253v13C19.832 18.477 18.246 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"></path></svg></div><h2 class="text-content-primary dark:text-content-primary text-xl font-medium mb-3">pyMC Repeater Wiki</h2><p class="text-content-secondary dark:text-content-muted mb-8 max-w-md mx-auto"> Access documentation, setup guides, troubleshooting tips, and community resources on our official wiki. </p><a href="https://github.com/rightup/pyMC_Repeater/wiki" target="_blank" rel="noopener noreferrer" class="inline-flex items-center gap-2 bg-primary hover:bg-primary/80 text-white dark:text-background font-medium py-3 px-6 rounded-xl transition-colors duration-200"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path></svg> Visit Wiki Documentation </a><div class="mt-8 text-xs text-content-muted dark:text-content-muted"> Opens in a new tab </div></div></div>',1)])))}});export{d as default};
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
@@ -0,0 +1 @@
import{a as k,e as o,h as g,f as r,k as a,t as p,x,q as s}from"./index-Cc9qVZfW.js";const f={class:"mb-6"},m={key:0,class:"w-6 h-6",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},v={key:1,class:"w-6 h-6",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},h={key:2,class:"w-6 h-6",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},w={class:"text-content-secondary dark:text-content-primary/80 text-base leading-relaxed"},C={class:"flex"},B=k({__name:"MessageDialog",props:{show:{type:Boolean},message:{},variant:{default:"success"}},emits:["close"],setup(i,{emit:d}){const t=i,l=d,c=n=>{n.target===n.currentTarget&&l("close")},u={success:"bg-green-100 dark:bg-green-500/20 border-green-600/40 dark:border-green-500/30 text-green-600 dark:text-green-400",error:"bg-red-100 dark:bg-red-500/20 border-red-500/30 text-red-600 dark:text-red-400",info:"bg-blue-500/20 border-blue-500/30 text-blue-600 dark:text-blue-400"},b={success:"bg-green-500 hover:bg-green-600",error:"bg-red-500 hover:bg-red-600",info:"bg-blue-500 hover:bg-blue-600"};return(n,e)=>t.show?(s(),o("div",{key:0,onClick:c,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"}},[r("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[1]||(e[1]=x(()=>{},["stop"]))},[r("div",f,[r("div",{class:a(["inline-flex p-3 rounded-xl mb-4",u[t.variant]])},[t.variant==="success"?(s(),o("svg",m,e[2]||(e[2]=[r("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M5 13l4 4L19 7"},null,-1)]))):t.variant==="error"?(s(),o("svg",v,e[3]||(e[3]=[r("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M6 18L18 6M6 6l12 12"},null,-1)]))):(s(),o("svg",h,e[4]||(e[4]=[r("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),r("p",w,p(t.message),1)]),r("div",C,[r("button",{onClick:e[0]||(e[0]=y=>l("close")),class:a(["flex-1 px-4 py-3 rounded-xl text-white transition-all duration-200",b[t.variant]])}," OK ",2)])])])):g("",!0)}});export{B as _};
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
@@ -0,0 +1 @@
.glass-card[data-v-693a052e]{background:#ffffff0d;-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px);border:1px solid rgba(255,255,255,.1)}.modal-enter-active[data-v-693a052e],.modal-leave-active[data-v-693a052e]{transition:opacity .3s ease}.modal-enter-from[data-v-693a052e],.modal-leave-to[data-v-693a052e]{opacity:0}.modal-enter-active .glass-card[data-v-693a052e],.modal-leave-active .glass-card[data-v-693a052e]{transition:transform .3s ease}.modal-enter-from .glass-card[data-v-693a052e],.modal-leave-to .glass-card[data-v-693a052e]{transform:scale(.9)}.slide-enter-active[data-v-693a052e],.slide-leave-active[data-v-693a052e]{transition:all .3s ease}.slide-enter-from[data-v-693a052e],.slide-leave-to[data-v-693a052e]{opacity:0;transform:translateY(-10px)}@keyframes float-slow-693a052e{0%,to{opacity:.8;transform:translate(0) scale(1) rotate(-24.22deg)}50%{opacity:.6;transform:translate(20px,-20px) scale(1.05) rotate(-24.22deg)}}@keyframes float-slower-693a052e{0%,to{opacity:.75;transform:translate(0) scale(1) rotate(-24.22deg)}50%{opacity:.5;transform:translate(-30px,20px) scale(1.08) rotate(-24.22deg)}}@keyframes float-slowest-693a052e{0%,to{opacity:.8;transform:translate(0) scale(1) rotate(-24.22deg)}50%{opacity:.55;transform:translate(25px,25px) scale(1.1) rotate(-24.22deg)}}.animate-pulse-slow[data-v-693a052e]{animation:float-slow-693a052e 15s ease-in-out infinite;will-change:transform,opacity}.animate-pulse-slower[data-v-693a052e]{animation:float-slower-693a052e 18s ease-in-out infinite;will-change:transform,opacity}.animate-pulse-slowest[data-v-693a052e]{animation:float-slowest-693a052e 20s ease-in-out infinite;will-change:transform,opacity}
@@ -1 +0,0 @@
.glass-card[data-v-20a8772f]{background:#ffffff0d;-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px);border:1px solid rgba(255,255,255,.1)}.modal-enter-active[data-v-20a8772f],.modal-leave-active[data-v-20a8772f]{transition:opacity .3s ease}.modal-enter-from[data-v-20a8772f],.modal-leave-to[data-v-20a8772f]{opacity:0}.modal-enter-active .glass-card[data-v-20a8772f],.modal-leave-active .glass-card[data-v-20a8772f]{transition:transform .3s ease}.modal-enter-from .glass-card[data-v-20a8772f],.modal-leave-to .glass-card[data-v-20a8772f]{transform:scale(.9)}.slide-enter-active[data-v-20a8772f],.slide-leave-active[data-v-20a8772f]{transition:all .3s ease}.slide-enter-from[data-v-20a8772f],.slide-leave-to[data-v-20a8772f]{opacity:0;transform:translateY(-10px)}@keyframes float-slow-20a8772f{0%,to{opacity:.8;transform:translate(0) scale(1) rotate(-24.22deg)}50%{opacity:.6;transform:translate(20px,-20px) scale(1.05) rotate(-24.22deg)}}@keyframes float-slower-20a8772f{0%,to{opacity:.75;transform:translate(0) scale(1) rotate(-24.22deg)}50%{opacity:.5;transform:translate(-30px,20px) scale(1.08) rotate(-24.22deg)}}@keyframes float-slowest-20a8772f{0%,to{opacity:.8;transform:translate(0) scale(1) rotate(-24.22deg)}50%{opacity:.55;transform:translate(25px,25px) scale(1.1) rotate(-24.22deg)}}.animate-pulse-slow[data-v-20a8772f]{animation:float-slow-20a8772f 15s ease-in-out infinite;will-change:transform,opacity}.animate-pulse-slower[data-v-20a8772f]{animation:float-slower-20a8772f 18s ease-in-out infinite;will-change:transform,opacity}.animate-pulse-slowest[data-v-20a8772f]{animation:float-slowest-20a8772f 20s ease-in-out infinite;will-change:transform,opacity}
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
.plotly-chart[data-v-8daccd7e]{background:transparent!important}
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
.plotly-chart[data-v-c51a7a30]{background:transparent!important}
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 +1 @@
import{M as x,c as s}from"./index-sHch0610.js";const l={7:-7.5,8:-10,9:-12.5,10:-15,11:-17.5,12:-20},d=-116,i=8,u=5;function y(t,e){return t-e}function S(t){return l[t]??l[i]}function f(t,e){const r=e+u;if(t<=e){const o=t<=e-5?0:1;return{bars:o,color:"text-red-600 dark:text-red-400",snr:t,quality:o===0?"none":"poor"}}if(t<r){const n=(t-e)/u<.5?2:3;return{bars:n,color:n===2?"text-orange-600 dark:text-orange-400":"text-yellow-600 dark:text-yellow-400",snr:t,quality:"fair"}}const a=t-r>=10?5:4;return{bars:a,color:a===5?"text-green-600 dark:text-green-400":"text-green-600 dark:text-green-300",snr:t,quality:a===5?"excellent":"good"}}function N(){const t=x(),e=s(()=>t.noiseFloorDbm??d),r=s(()=>t.stats?.config?.radio?.spreading_factor??i),c=s(()=>S(r.value));return{getSignalQuality:o=>{if(!o||o>0||o<-120)return{bars:0,color:"text-gray-400 dark:text-gray-500",snr:-999,quality:"none"};const n=y(o,e.value),g=Math.max(-30,Math.min(20,n));return f(g,c.value)},noiseFloor:e,spreadingFactor:r,minSNR:c}}export{N as u};
import{M as x,c as s}from"./index-Cc9qVZfW.js";const l={7:-7.5,8:-10,9:-12.5,10:-15,11:-17.5,12:-20},d=-116,i=8,u=5;function y(t,e){return t-e}function S(t){return l[t]??l[i]}function f(t,e){const r=e+u;if(t<=e){const o=t<=e-5?0:1;return{bars:o,color:"text-red-600 dark:text-red-400",snr:t,quality:o===0?"none":"poor"}}if(t<r){const n=(t-e)/u<.5?2:3;return{bars:n,color:n===2?"text-orange-600 dark:text-orange-400":"text-yellow-600 dark:text-yellow-400",snr:t,quality:"fair"}}const a=t-r>=10?5:4;return{bars:a,color:a===5?"text-green-600 dark:text-green-400":"text-green-600 dark:text-green-300",snr:t,quality:a===5?"excellent":"good"}}function N(){const t=x(),e=s(()=>t.noiseFloorDbm??d),r=s(()=>t.stats?.config?.radio?.spreading_factor??i),c=s(()=>S(r.value));return{getSignalQuality:o=>{if(!o||o>0||o<-120)return{bars:0,color:"text-gray-400 dark:text-gray-500",snr:-999,quality:"none"};const n=y(o,e.value),g=Math.max(-30,Math.min(20,n));return f(g,c.value)},noiseFloor:e,spreadingFactor:r,minSNR:c}}export{N as u};
+2 -2
View File
@@ -8,8 +8,8 @@
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
<script type="module" crossorigin src="/assets/index-sHch0610.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Dk6Oh8NN.css">
<script type="module" crossorigin src="/assets/index-Cc9qVZfW.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BjKKBBqN.css">
</head>
<body>
<div id="app"></div>
+142 -105
View File
@@ -14,15 +14,22 @@ from pymc_core.protocol.utils import PAYLOAD_TYPES, ROUTE_TYPES
from repeater import __version__
from repeater.data_acquisition import SQLiteHandler
from .api_endpoints import APIEndpoints
from .auth_endpoints import AuthEndpoints
from .auth.jwt_handler import JWTHandler
from .auth.api_tokens import APITokenManager
from .auth import cherrypy_tool # Import to register the tool
from .auth.api_tokens import APITokenManager
from .auth.jwt_handler import JWTHandler
from .auth_endpoints import AuthEndpoints
# WebSocket support
try:
from repeater.data_acquisition.websocket_handler import PacketWebSocket, init_websocket, broadcast_packet
from repeater.data_acquisition.websocket_handler import (
PacketWebSocket,
broadcast_packet,
init_websocket,
)
from .companion_ws_proxy import CompanionFrameWebSocket, set_daemon as _set_companion_daemon
WEBSOCKET_AVAILABLE = True
except ImportError:
WEBSOCKET_AVAILABLE = False
@@ -61,40 +68,41 @@ _log_buffer = LogBuffer(max_lines=100)
class DocEndpoint:
"""Simple wrapper to serve API docs at /doc"""
def __init__(self, api_endpoints):
self.api_endpoints = api_endpoints
@cherrypy.expose
def index(self, **kwargs):
"""Serve Swagger UI at /doc"""
return self.api_endpoints.docs()
@cherrypy.expose
def docs(self):
"""Serve Swagger UI at /doc/docs"""
return self.api_endpoints.docs()
@cherrypy.expose
def openapi_json(self):
"""Serve OpenAPI spec in JSON format at /doc/openapi.json"""
import os
import yaml
import json
spec_path = os.path.join(os.path.dirname(__file__), 'openapi.yaml')
import os
import yaml
spec_path = os.path.join(os.path.dirname(__file__), "openapi.yaml")
try:
with open(spec_path, 'r') as f:
with open(spec_path, "r") as f:
spec_content = yaml.safe_load(f)
cherrypy.response.headers['Content-Type'] = 'application/json'
return json.dumps(spec_content).encode('utf-8')
cherrypy.response.headers["Content-Type"] = "application/json"
return json.dumps(spec_content).encode("utf-8")
except FileNotFoundError:
cherrypy.response.status = 404
return json.dumps({"error": "OpenAPI spec not found"}).encode('utf-8')
return json.dumps({"error": "OpenAPI spec not found"}).encode("utf-8")
except Exception as e:
cherrypy.response.status = 500
return json.dumps({"error": f"Error loading OpenAPI spec: {e}"}).encode('utf-8')
return json.dumps({"error": f"Error loading OpenAPI spec: {e}"}).encode("utf-8")
class StatsApp:
@@ -116,7 +124,7 @@ class StatsApp:
self.pub_key = pub_key
self.dashboard_template = None
self.config = config or {}
# Path to the compiled Vue.js application
# Use web_path from config if provided, otherwise use default
default_html_dir = os.path.join(os.path.dirname(__file__), "html")
@@ -124,8 +132,10 @@ class StatsApp:
self.html_dir = web_path if web_path is not None else default_html_dir
# Create nested API object for routing
self.api = APIEndpoints(stats_getter, send_advert_func, self.config, event_loop, daemon_instance, config_path)
self.api = APIEndpoints(
stats_getter, send_advert_func, self.config, event_loop, daemon_instance, config_path
)
# Create doc endpoint for API documentation
self.doc = DocEndpoint(self.api)
@@ -134,7 +144,7 @@ class StatsApp:
"""Serve the Vue.js application index.html."""
index_path = os.path.join(self.html_dir, "index.html")
try:
with open(index_path, 'r', encoding='utf-8') as f:
with open(index_path, "r", encoding="utf-8") as f:
return f.read()
except FileNotFoundError:
raise cherrypy.HTTPError(404, "Application not found. Please build the frontend first.")
@@ -148,19 +158,18 @@ class StatsApp:
# Handle OPTIONS requests for any path
if cherrypy.request.method == "OPTIONS":
return ""
# Let API routes pass through
if args and args[0] == 'api':
if args and args[0] == "api":
raise cherrypy.NotFound()
# Handle WebSocket routes
if args and len(args) >= 2 and args[0] == 'ws' and args[1] == 'packets':
if args and len(args) >= 2 and args[0] == "ws" and args[1] in ("packets", "companion_frame"):
# WebSocket tool will intercept this
return ""
# For all other routes, serve the Vue.js app (client-side routing)
return self.index()
class HTTPStatsServer:
@@ -183,20 +192,30 @@ class HTTPStatsServer:
self.port = port
self.config = config or {}
self.config_path = config_path
self.daemon_instance = daemon_instance
# Initialize authentication handlers
self._init_auth_handlers()
self.app = StatsApp(
stats_getter, node_name, pub_key, send_advert_func, config, event_loop, daemon_instance, config_path
stats_getter,
node_name,
pub_key,
send_advert_func,
config,
event_loop,
daemon_instance,
config_path,
)
# Create auth endpoints (APIEndpoints has the config_manager)
self.auth_app = AuthEndpoints(self.config, self.jwt_handler, self.token_manager, self.app.api.config_manager)
self.auth_app = AuthEndpoints(
self.config, self.jwt_handler, self.token_manager, self.app.api.config_manager
)
# Create documentation endpoints as separate app
self.doc_app = DocEndpoint(self.app.api)
# Set up CORS at the server level if enabled
self._cors_enabled = self.config.get("web", {}).get("cors_enabled", False)
logger.info(f"CORS enabled: {self._cors_enabled}")
@@ -207,43 +226,46 @@ class HTTPStatsServer:
repeater_config = self.config.get("repeater", {})
security_config = repeater_config.get("security", {})
jwt_secret = security_config.get("jwt_secret", "")
if not jwt_secret:
# Auto-generate JWT secret
jwt_secret = secrets.token_hex(32)
logger.warning("No JWT secret found in config, auto-generated one. Please save this to config.yaml:")
logger.warning(
"No JWT secret found in config, auto-generated one. Please save this to config.yaml:"
)
# Try to save to config if config_path is available
if self.config_path:
try:
import yaml
with open(self.config_path, 'r') as f:
with open(self.config_path, "r") as f:
config_data = yaml.safe_load(f) or {}
if 'repeater' not in config_data:
config_data['repeater'] = {}
if 'security' not in config_data['repeater']:
config_data['repeater']['security'] = {}
config_data['repeater']['security']['jwt_secret'] = jwt_secret
with open(self.config_path, 'w') as f:
if "repeater" not in config_data:
config_data["repeater"] = {}
if "security" not in config_data["repeater"]:
config_data["repeater"]["security"] = {}
config_data["repeater"]["security"]["jwt_secret"] = jwt_secret
with open(self.config_path, "w") as f:
yaml.dump(config_data, f, default_flow_style=False)
logger.info(f"Saved auto-generated JWT secret to {self.config_path}")
except Exception as e:
logger.error(f"Failed to save JWT secret to config: {e}")
# Initialize JWT handler with configurable expiry (default 1 hour)
jwt_expiry_minutes = security_config.get("jwt_expiry_minutes", 60)
self.jwt_handler = JWTHandler(jwt_secret, expiry_minutes=jwt_expiry_minutes)
logger.info(f"JWT handler initialized (token expiry: {jwt_expiry_minutes} minutes)")
# Initialize API token manager
storage_dir = self.config.get("storage", {}).get("storage_dir", ".")
# Ensure storage directory exists
os.makedirs(storage_dir, exist_ok=True)
# Initialize SQLiteHandler and APITokenManager
self.sqlite_handler = SQLiteHandler(Path(storage_dir))
self.token_manager = APITokenManager(self.sqlite_handler, jwt_secret)
@@ -254,29 +276,25 @@ class HTTPStatsServer:
# Configure CORS to allow Authorization header
# cherrypy-cors will handle preflight requests automatically
cherrypy_cors.install()
logger.info("CORS support enabled with Authorization header")
def _json_error_handler(self, status, message, traceback, version):
"""Return JSON error responses instead of HTML for API endpoints"""
cherrypy.response.headers["Content-Type"] = "application/json"
return json.dumps({
"success": False,
"error": message
})
return json.dumps({"success": False, "error": message})
def start(self):
try:
if self._cors_enabled:
self._setup_server_cors()
default_html_dir = os.path.join(os.path.dirname(__file__), "html")
web_path = self.config.get("web", {}).get("web_path")
html_dir = web_path if web_path is not None else default_html_dir
assets_dir = os.path.join(html_dir, "assets")
next_dir = os.path.join(html_dir, "_next")
@@ -288,11 +306,11 @@ class HTTPStatsServer:
# "tools.gzip.mime_types": ["application/json", "text/html", "text/plain"],
# Ensure proper content types for static files
"tools.staticfile.content_types": {
'js': 'application/javascript',
'css': 'text/css',
'html': 'text/html; charset=utf-8',
'svg': 'image/svg+xml',
'txt': 'text/plain'
"js": "application/javascript",
"css": "text/css",
"html": "text/html; charset=utf-8",
"svg": "image/svg+xml",
"txt": "text/plain",
},
},
# Require authentication for all /api endpoints
@@ -330,7 +348,7 @@ class HTTPStatsServer:
"tools.staticfile.filename": os.path.join(html_dir, "favicon.ico"),
},
}
# Add WebSocket configuration to main config if available
if WEBSOCKET_AVAILABLE:
try:
@@ -340,33 +358,46 @@ class HTTPStatsServer:
"tools.websocket.handler_cls": PacketWebSocket,
"tools.trailing_slash.on": False,
"tools.require_auth.on": False,
"tools.gzip.on": False,
"tools.gzip.on": False,
}
logger.info("WebSocket endpoint configured at /ws/packets")
# Companion frame proxy (binary WS ↔ TCP byte pipe)
if self.daemon_instance:
_set_companion_daemon(self.daemon_instance)
config["/ws/companion_frame"] = {
"tools.websocket.on": True,
"tools.websocket.handler_cls": CompanionFrameWebSocket,
"tools.trailing_slash.on": False,
"tools.require_auth.on": False,
"tools.gzip.on": False,
}
logger.info("WebSocket endpoint configured at /ws/companion_frame")
except Exception as e:
logger.error(f"Failed to initialize WebSocket: {e}")
import traceback
logger.error(traceback.format_exc())
# Add CORS configuration if enabled
if self._cors_enabled:
cors_config = {
"cors.expose.on": True,
"tools.response_headers.on": True,
"tools.response_headers.headers": [
('Access-Control-Allow-Origin', '*'),
('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'),
('Access-Control-Allow-Headers', 'Authorization, Content-Type, X-API-Key'),
('Access-Control-Allow-Credentials', 'true'),
("Access-Control-Allow-Origin", "*"),
("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS"),
("Access-Control-Allow-Headers", "Authorization, Content-Type, X-API-Key"),
("Access-Control-Allow-Credentials", "true"),
],
# Disable automatic trailing slash redirects to prevent CORS issues
"tools.trailing_slash.on": False,
}
# Apply CORS to paths
config["/"].update(cors_config)
config["/api"].update(cors_config)
# Add Vue.js assets support only if assets directory exists
if os.path.isdir(assets_dir):
config["/assets"] = {
@@ -374,12 +405,12 @@ class HTTPStatsServer:
"tools.staticdir.dir": assets_dir,
# Set proper content types for assets
"tools.staticdir.content_types": {
'js': 'application/javascript',
'css': 'text/css',
'map': 'application/json'
"js": "application/javascript",
"css": "text/css",
"map": "application/json",
},
}
# Add Next.js support only if _next directory exists
if os.path.isdir(next_dir):
config["/_next"] = {
@@ -387,9 +418,9 @@ class HTTPStatsServer:
"tools.staticdir.dir": next_dir,
# Set proper content types for Next.js assets
"tools.staticdir.content_types": {
'js': 'application/javascript',
'css': 'text/css',
'map': 'application/json'
"js": "application/javascript",
"css": "text/css",
"map": "application/json",
},
}
@@ -421,13 +452,13 @@ class HTTPStatsServer:
# Mount main app
cherrypy.tree.mount(self.app, "/", config)
# Mount auth endpoints
auth_config = {
"/": {
"tools.response_headers.on": True,
"tools.response_headers.headers": [
('Content-Type', 'application/json'),
("Content-Type", "application/json"),
],
# Disable automatic trailing slash redirects
"tools.trailing_slash.on": False,
@@ -436,42 +467,48 @@ class HTTPStatsServer:
if self._cors_enabled:
auth_config["/"]["cors.expose.on"] = True
# Add CORS headers for OPTIONS requests
auth_config["/"]["tools.response_headers.headers"].extend([
('Access-Control-Allow-Origin', '*'),
('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'),
('Access-Control-Allow-Headers', 'Authorization, Content-Type, X-API-Key'),
('Access-Control-Allow-Credentials', 'true'),
])
auth_config["/"]["tools.response_headers.headers"].extend(
[
("Access-Control-Allow-Origin", "*"),
("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS"),
("Access-Control-Allow-Headers", "Authorization, Content-Type, X-API-Key"),
("Access-Control-Allow-Credentials", "true"),
]
)
cherrypy.tree.mount(self.auth_app, "/auth", auth_config)
# Mount documentation endpoints as separate app (no auth required for docs)
doc_config = {
"/": {
"tools.require_auth.on": False, # Docs are publicly accessible
"tools.response_headers.on": True,
"tools.response_headers.headers": [
('Content-Type', 'text/html; charset=utf-8'),
("Content-Type", "text/html; charset=utf-8"),
],
"tools.trailing_slash.on": False,
}
}
if self._cors_enabled:
doc_config["/"]["cors.expose.on"] = True
doc_config["/"]["tools.response_headers.headers"].extend([
('Access-Control-Allow-Origin', '*'),
('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'),
('Access-Control-Allow-Headers', 'Authorization, Content-Type, X-API-Key'),
])
doc_config["/"]["tools.response_headers.headers"].extend(
[
("Access-Control-Allow-Origin", "*"),
("Access-Control-Allow-Methods", "GET, POST, OPTIONS"),
("Access-Control-Allow-Headers", "Authorization, Content-Type, X-API-Key"),
]
)
cherrypy.tree.mount(self.doc_app, "/doc", doc_config)
# Store auth handlers in cherrypy config for middleware access
cherrypy.config.update({
"jwt_handler": self.jwt_handler,
"token_manager": self.token_manager,
"security_config": self.config.get("security", {}),
})
cherrypy.config.update(
{
"jwt_handler": self.jwt_handler,
"token_manager": self.token_manager,
"security_config": self.config.get("security", {}),
}
)
# Completely disable access logging
cherrypy.log.access_log.propagate = False
+22 -16
View File
@@ -3,7 +3,7 @@ info:
title: pyMC Repeater API
description: |
REST API for pyMC Repeater - LoRa mesh network repeater with room server functionality.
## Features
- System statistics and monitoring
- Packet history and analysis
@@ -414,7 +414,9 @@ paths:
post:
tags: [System]
summary: Set repeater mode
description: Switch between forward and monitor modes
description: |
Set TX mode. forward = repeat on; monitor = no repeat but companions/tenants can send;
no_tx = all transmission disabled (receive-only).
requestBody:
required: true
content:
@@ -425,10 +427,11 @@ paths:
properties:
mode:
type: string
enum: [forward, monitor]
enum: [forward, monitor, no_tx]
description: |
- forward: Active forwarding mode (default)
- monitor: Passive monitoring only
- forward: Repeat received packets; allow all local TX (default)
- monitor: Do not repeat; allow local TX (companions, adverts, etc.)
- no_tx: Do not repeat; no local TX (receive-only)
example: forward
examples:
forward:
@@ -437,6 +440,9 @@ paths:
monitor:
value:
mode: monitor
no_tx:
value:
mode: no_tx
responses:
'200':
description: Mode changed
@@ -726,7 +732,7 @@ paths:
summary: Get RRD time-series data
description: |
Retrieve Round-Robin Database metrics for graphing.
**Note:** This endpoint extracts parameters from the request internally.
Parameters are handled automatically by the backend.
responses:
@@ -821,7 +827,7 @@ paths:
summary: Get system metrics graph data
description: |
Returns time-series data for system metrics like packet counts, RSSI, SNR, etc.
Available metrics:
- rx_count: Received packets
- tx_count: Transmitted packets
@@ -1557,7 +1563,7 @@ paths:
summary: Get ACL information for all identities
description: |
Get ACL configuration and statistics for all registered identities.
Returns information including:
- Identity name, type, and hash
- Max clients allowed
@@ -1741,7 +1747,7 @@ paths:
summary: Get room messages
description: |
Retrieve messages from a room with pagination.
**Max Messages Per Room**: 32 (hard limit)
- Older messages auto-deleted every 10 minutes
- Cannot be increased beyond 32
@@ -1847,15 +1853,15 @@ paths:
summary: Post message to room
description: |
Add a new message to a room server. Message will be distributed to all synced clients.
**Special author values:**
- `"server"` or `"system"` - System message, goes to ALL clients (API only)
- Any hex string - Normal message, NOT sent to that client
**Security:**
- Radio messages cannot use server key (blocked)
- API messages can use server key (for announcements)
**Rate Limits:**
- 10 messages/minute per author_pubkey
- 160 bytes max message length
@@ -1992,7 +1998,7 @@ paths:
summary: Get room statistics
description: |
Get detailed statistics for one or all room servers.
**Room Limits:**
- 32 messages maximum per room (hard limit)
- Messages auto-expire every 10 minutes
@@ -2101,7 +2107,7 @@ paths:
summary: Get room clients
description: |
List all clients synced to a room with their status.
**Client Filtering:**
- Clients only receive messages where author_pubkey ≠ client_pubkey
- unsynced_count shows pending messages for each client
@@ -2335,7 +2341,7 @@ components:
type: boolean
description: Client is currently active (synced within timeout period)
example: true
Identity:
type: object
required: [name, type, hash, public_key]
@@ -2385,7 +2391,7 @@ components:
default: 32
description: Maximum messages to keep (room_server only, hard limit 32)
example: 32
ACLClient:
type: object
required: [public_key, public_key_full, address, permissions]
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -152,7 +152,7 @@ if [ -n "$DEB_FILE" ]; then
# Run lintian to check package quality
log_step "Running lintian checks..."
lintian "$DEB_FILE" || log_warn "Lintian found some issues (non-fatal)"
log_info ""
log_info "════════════════════════════════════════════════════════════"
log_info "Production build complete!"

Some files were not shown because too many files have changed in this diff Show More