mirror of
https://github.com/pyMC-dev/pyMC_Repeater.git
synced 2026-08-11 19:32:55 +02:00
Compare commits
188 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 71595c9226 | |||
| fd7ead3697 | |||
| e0dd91b91e | |||
| 6b406a5019 | |||
| b949bdeab8 | |||
| 498303a673 | |||
| 4783b56981 | |||
| 15b683ba11 | |||
| 5ed6c0ef90 | |||
| c000894f8a | |||
| b1a5621a0a | |||
| aba0f5bd09 | |||
| 3208018c6d | |||
| 25e55bdca8 | |||
| d22ba91f19 | |||
| 91025e4970 | |||
| 74f5963a85 | |||
| 9677a39aa8 | |||
| ab2c82db16 | |||
| cc9c81de2a | |||
| 37cd137bbb | |||
| e5c7632700 | |||
| 7e541cd1f1 | |||
| f92dd4ab5f | |||
| 1b3f0490ec | |||
| a6818367e8 | |||
| 07dc287f50 | |||
| 0713b571d8 | |||
| ba2136dfa6 | |||
| 95918dc43d | |||
| bc809c3021 | |||
| e36b477230 | |||
| 6c0f4fb842 | |||
| b58578acd5 | |||
| 4d6993c9e1 | |||
| 34fe07d7b0 | |||
| 7cbaa9115e | |||
| 852939b701 | |||
| 1626b3f307 | |||
| 7d1aa57321 | |||
| 179158e68b | |||
| 827b9a9f98 | |||
| 9eae6ed872 | |||
| 986e22de1f | |||
| af79eaf63f | |||
| 5c947e6c2e | |||
| 40ec2ba293 | |||
| 96b3daf6e8 | |||
| 0a77fe67ce | |||
| db41080dea | |||
| f50919858d | |||
| c7b2b02316 | |||
| d318334288 | |||
| d592af6e19 | |||
| fdd788212d | |||
| c52ae53cc6 | |||
| c0163ce897 | |||
| 3397d972ce | |||
| 4e16fd040d | |||
| cadec00117 | |||
| fdbc85c926 | |||
| c82f0cfce6 | |||
| be56e919fd | |||
| 81a3b70415 | |||
| 9797e08421 | |||
| 3df4b03fd9 | |||
| c5fd41f28a | |||
| 1883bc47be | |||
| b26ebeb807 | |||
| 68a461b965 | |||
| 5eb1fc47ca | |||
| 096c5a8f07 | |||
| 11f749e0e9 | |||
| 799a85ddf9 | |||
| 093ebc2873 | |||
| 2e1d19ab80 | |||
| 92f9fe77ae | |||
| dfe9ba20f3 | |||
| d336c72625 | |||
| 083ad2bc7a | |||
| a9590fac01 | |||
| 8f2888f2d5 | |||
| 7ba26b72cb | |||
| 56e5a93699 | |||
| 8ebcb09eff | |||
| 62d6627fab | |||
| 4e3b2bbc9a | |||
| d6681ab407 | |||
| 3f09e910d9 | |||
| 79d40afc71 | |||
| 9442c51225 | |||
| ffaaa76ea0 | |||
| f641761b05 | |||
| 6d133efdbe | |||
| 6b531e85e7 | |||
| 4569ff8653 | |||
| 06573d2373 | |||
| 01aed0db2b | |||
| 27fa2381ea | |||
| 4d9c560b11 | |||
| f2a72eb203 | |||
| 4d49eb701b | |||
| f18e5909fb | |||
| 3b7de6061b | |||
| 64530a623e | |||
| 7256807fdd | |||
| ba942ca1b7 | |||
| acf8079761 | |||
| 9d971d03b7 | |||
| 110d7c2aec | |||
| 178eaf5b4b | |||
| a53012ba35 | |||
| 19e0f5d3dd | |||
| 2386da2857 | |||
| 3851055b65 | |||
| 2c95c0db0a | |||
| 7370cdc688 | |||
| 38e1fbe3f9 | |||
| 3010703e1b | |||
| dd8d3577cd | |||
| d83d3e07aa | |||
| a92708f9d5 | |||
| 59c6c772d4 | |||
| 50c19be498 | |||
| fdc621f64d | |||
| f5dbd83cda | |||
| 031f7b5e47 | |||
| 8babc375f2 | |||
| 3ca9ac56d8 | |||
| 07a47523ab | |||
| 400e707c3f | |||
| 91918e7cfc | |||
| d82c90a04d | |||
| 7fcad04f49 | |||
| 7bcab773dd | |||
| c35733e8c4 | |||
| 744826199e | |||
| 369b420ae3 | |||
| 7d73ca0df6 | |||
| d11d957318 | |||
| c5c94fe60a | |||
| 55fe9feddd | |||
| 3cb27d3310 | |||
| 0444f01280 | |||
| 2e25467c5d | |||
| 4385befa2c | |||
| ddaa62fa9e | |||
| 24e003b677 | |||
| 7558c5604c | |||
| e0e807f65c | |||
| 4ee2977236 | |||
| 810743fbf2 | |||
| b35b964dbb | |||
| 07c6f14b4b | |||
| d40f39fa87 | |||
| c8b7082d37 | |||
| 985e0c829f | |||
| 2b4012eeb6 | |||
| 9326868f6e | |||
| 289fdb1a16 | |||
| bc19c0fd9b | |||
| 596c96d1f4 | |||
| 155575865a | |||
| 362a477cdd | |||
| d701342951 | |||
| e6fed7bea1 | |||
| 23463b606d | |||
| f96d64a813 | |||
| 6c3252e51c | |||
| 5e7cf3f315 | |||
| e5e2006bbe | |||
| da3dd470ae | |||
| c53e8034e6 | |||
| 95e86b5150 | |||
| bf3b4b5b1b | |||
| cd1c88e9c6 | |||
| 632f1d2d1a | |||
| 25c2a14a81 | |||
| 664f061bb9 | |||
| bf04439357 | |||
| 97bfb0ebaa | |||
| a88dc596d4 | |||
| 6bf252f58b | |||
| c0e625fdeb | |||
| dc2c785f89 | |||
| 552e0b9094 | |||
| 8018b0caf9 | |||
| 4e0f3ef15a |
@@ -62,3 +62,6 @@ data/
|
||||
*.log
|
||||
.DS_Store
|
||||
syncpi.sh
|
||||
|
||||
# Docker
|
||||
/data
|
||||
|
||||
@@ -55,16 +55,6 @@ The repeater supports two radio backends:
|
||||
|
||||
The following hardware is currently supported out-of-the-box:
|
||||
|
||||
Waveshare LoRaWAN/GNSS HAT (SPI Version Only)
|
||||
|
||||
Hardware: Waveshare SX1262 LoRa HAT (SPI interface - UART version not supported)
|
||||
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
|
||||
Note: Only the SPI version is supported. The UART version will not work.
|
||||
|
||||
HackerGadgets uConsole
|
||||
|
||||
Hardware: uConsole RTL-SDR/LoRa/GPS/RTC/USB Hub
|
||||
@@ -102,6 +92,27 @@ HT-RA62 module
|
||||
SPI Bus: SPI0
|
||||
GPIO Pins: CS=21, Reset=18, Busy=20, IRQ=16, use_dio3_tcxo=True, use_dio2_rf=True
|
||||
|
||||
Zindello Industries UltraPeater
|
||||
|
||||
Hardware: EBYTE E22/P 1W Module
|
||||
Platform: Luckfox Pico Ultra/W (NOT A PI DEVICE)
|
||||
Frequency: 868MHz (EU) or 915Mhz (US/AU)
|
||||
Tx Power: Up to 30dBm
|
||||
SPI Bus: SPI0
|
||||
GPIO Pins: CS=16, Reset=22, Busy=11, IRQ=10, TXEN=20 , RXEN=21 (E22 Only), EN=21 (E22P Only), TXLED=9, RXLED=1, use_dio2_rf=False, use_dio3_tcxo=True, use_gpiod_backend=True, gpio_chip=1
|
||||
|
||||
Waveshare LoRaWAN/GNSS HAT (SPI Version Only)
|
||||
|
||||
NO LONGER RECOMMENDED
|
||||
Note: May experience issues on "Narrow" (62.5KHz) settings due to a lack of TCXO
|
||||
Hardware: Waveshare SX1262 LoRa HAT (SPI interface - UART version not supported)
|
||||
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
|
||||
Note: Only the SPI version is supported. The UART version will not work.
|
||||
|
||||
...
|
||||
|
||||
## Screenshots
|
||||
@@ -189,6 +200,18 @@ The configuration file is created and configured during installation at:
|
||||
/etc/pymc_repeater/config.yaml
|
||||
```
|
||||
|
||||
### Optional pyMC_Glass integration
|
||||
The repeater now supports an additive `glass` config section for central control-plane integration.
|
||||
When enabled, it sends periodic `/inform` payloads to pyMC_Glass, receives queued commands, and reports command results on the next inform cycle.
|
||||
|
||||
Minimal example:
|
||||
```yaml
|
||||
glass:
|
||||
enabled: true
|
||||
base_url: "http://localhost:8080"
|
||||
inform_interval_seconds: 30
|
||||
```
|
||||
|
||||
To reconfigure radio and hardware settings after installation, run:
|
||||
```bash
|
||||
sudo bash setup-radio-config.sh /etc/pymc_repeater
|
||||
@@ -320,17 +343,34 @@ This script will:
|
||||
The script will prompt you for each optional removal step.
|
||||
|
||||
|
||||
## Docker
|
||||
## 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.
|
||||
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
|
||||
```
|
||||
|
||||
Just note that you will have to pass in a `config.yaml` into the container. You can create a new config by following the instructions in the [Configuration section](#configuration).
|
||||
|
||||
|
||||
## Roadmap / Planned Features
|
||||
|
||||
- [ ] **Public Map Integration** - Submit repeater location and details to public map for discovery
|
||||
|
||||
+1286
File diff suppressed because it is too large
Load Diff
+79
-97
@@ -6,6 +6,10 @@ 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
|
||||
@@ -16,9 +20,21 @@ repeater:
|
||||
# If not specified, a new identity will be generated
|
||||
identity_file: null
|
||||
|
||||
# Identity key (alternative to identity_file)
|
||||
# Store the private key directly in config as binary (set by convert_firmware_key.sh)
|
||||
# If both identity_file and identity_key are set, identity_key takes precedence
|
||||
# identity_key: null
|
||||
|
||||
# Owner information (shown to clients requesting owner info)
|
||||
owner_info: ""
|
||||
|
||||
# Duplicate packet cache TTL in seconds
|
||||
cache_ttl: 3600
|
||||
|
||||
# Maximum number of hops a flood packet may have already traversed before
|
||||
# this repeater forwards it.
|
||||
max_flood_hops: 64
|
||||
|
||||
# Score-based transmission filtering
|
||||
# Enable quality-based packet filtering and adaptive delays
|
||||
use_score_for_tx: false
|
||||
@@ -112,10 +128,9 @@ repeater:
|
||||
|
||||
# Mesh Network Configuration
|
||||
mesh:
|
||||
# Global flood policy - controls whether the repeater allows or denies flooding by default
|
||||
# true = allow flooding globally, false = deny flooding globally
|
||||
# Individual transport keys can override this setting
|
||||
global_flood_allow: true
|
||||
# Unscoped flood policy - controls whether the repeater allows or denies unscoped flooding
|
||||
# true = allow unscoped flooding, false = deny flooding globally
|
||||
unscoped_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.
|
||||
@@ -262,49 +277,6 @@ duty_cycle:
|
||||
# Maximum airtime per minute in milliseconds
|
||||
max_airtime_per_minute: 3600
|
||||
|
||||
|
||||
# MQTT Publishing Configuration (Optional)
|
||||
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"
|
||||
|
||||
|
||||
# Storage Configuration
|
||||
storage:
|
||||
# Directory for persistent storage files (SQLite, RRD).
|
||||
@@ -322,63 +294,31 @@ storage:
|
||||
# - 1 hour resolution for 1 year
|
||||
|
||||
|
||||
letsmesh:
|
||||
enabled: false
|
||||
mqtt:
|
||||
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
|
||||
# additional_brokers:
|
||||
# - name: "Private Server"
|
||||
# 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
|
||||
# additional_brokers:
|
||||
# - name: "Backup Server"
|
||||
# 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
|
||||
# additional_brokers:
|
||||
# - name: "Custom Primary"
|
||||
# host: "mqtt-1.mydomain.com"
|
||||
# port: 443
|
||||
# audience: "mqtt-1.mydomain.com"
|
||||
# - name: "Custom Backup"
|
||||
# host: "mqtt-2.mydomain.com"
|
||||
# port: 443
|
||||
# audience: "mqtt-2.mydomain.com"
|
||||
# ============================================================
|
||||
|
||||
status_interval: 300
|
||||
status_interval: 300 # How often a status message is sent (in seconds)
|
||||
owner: ""
|
||||
email: ""
|
||||
brokers: []
|
||||
|
||||
# Block specific packet types from being published to LetsMesh
|
||||
# Below is the broker object schema:
|
||||
# enabled: true|false # Enable this specific mqtt broker
|
||||
# name: "" # Internal name for this broker
|
||||
# host: "" # hostname or ip of mqtt endpoints
|
||||
# port: # Typically 443 for websocket endpoints or 1883 for tcp
|
||||
# transport: "tcp" or "websockets"
|
||||
# audience: "" # For JWT auth'd endpoints, this is usually the host unless always stated by endpoint owners
|
||||
# use_jwt_auth: true|false # Does this endpoint require JWT auth
|
||||
# username: "" # Username for basic auth. If empty or missing, uses anonymous access
|
||||
# password: "" # Password for basic auth. Required if username is set
|
||||
# format: letsmesh|mqtt
|
||||
# retain_status: true|false # Sets MQTT "retain" on status messages so they remain on the broker when disconnected. Also enforces a QOS of 1 (guaranteed delivery)
|
||||
|
||||
# Block specific packet types from being published to the MQTT endpoint
|
||||
# If not specified or empty list, all types are published
|
||||
# Available types: REQ, RESPONSE, TXT_MSG, ACK, ADVERT, GRP_TXT,
|
||||
# GRP_DATA, ANON_REQ, PATH, TRACE, RAW_CUSTOM
|
||||
disallowed_packet_types: []
|
||||
# disallowed_packet_types: []
|
||||
# - REQ # Don't publish requests
|
||||
# - RESPONSE # Don't publish responses
|
||||
# - TXT_MSG # Don't publish text messages
|
||||
@@ -391,6 +331,48 @@ letsmesh:
|
||||
# - TRACE # Don't publish trace packets
|
||||
# - RAW_CUSTOM # Don't publish custom raw packets
|
||||
|
||||
# Example of using the US and EU LetsMesh endpoints
|
||||
# brokers:
|
||||
# - name: US West (LetsMesh v1)
|
||||
# host: mqtt-us-v1.letsmesh.net
|
||||
# port: 443
|
||||
# audience: mqtt-us-v1.letsmesh.net
|
||||
# use_jwt_auth: true
|
||||
# enabled: true
|
||||
|
||||
# - name: Europe (LetsMesh v1)
|
||||
# host: mqtt-eu-v1.letsmesh.net
|
||||
# port: 443
|
||||
# audience: mqtt-eu-v1.letsmesh.net
|
||||
# use_jwt_auth: true
|
||||
# enabled: true
|
||||
|
||||
# pyMC_Glass control-plane integration (optional)
|
||||
glass:
|
||||
# Enable repeater -> pyMC_Glass /inform loop
|
||||
enabled: false
|
||||
|
||||
# Base URL of Glass backend
|
||||
# Example local dev: "http://localhost:8080"
|
||||
# Example production: "https://glass.example.com"
|
||||
base_url: "http://localhost:8080"
|
||||
|
||||
# Inform interval in seconds (used as initial/default interval;
|
||||
# backend may override via noop.interval response)
|
||||
inform_interval_seconds: 30
|
||||
|
||||
# HTTP timeout per inform request
|
||||
request_timeout_seconds: 10
|
||||
|
||||
# Verify TLS certificates when using HTTPS
|
||||
verify_tls: true
|
||||
|
||||
# Optional bearer token for future authenticated inform endpoints
|
||||
api_token: ""
|
||||
|
||||
# Where cert_renewal payloads are written
|
||||
cert_store_dir: "/etc/pymc_repeater/glass"
|
||||
|
||||
logging:
|
||||
# Log level: DEBUG, INFO, WARNING, ERROR
|
||||
level: INFO
|
||||
|
||||
@@ -152,20 +152,20 @@ if output_format == "yaml":
|
||||
sys.exit(1)
|
||||
|
||||
# Check for existing key
|
||||
if 'mesh' in config and 'identity_key' in config['mesh']:
|
||||
existing = config['mesh']['identity_key']
|
||||
if 'repeater' in config and 'identity_key' in config['repeater']:
|
||||
existing = config['repeater']['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'] = {}
|
||||
# Ensure repeater section exists
|
||||
if 'repeater' not in config:
|
||||
config['repeater'] = {}
|
||||
|
||||
# Store the full 64-byte key
|
||||
config['mesh']['identity_key'] = key_bytes
|
||||
config['repeater']['identity_key'] = key_bytes
|
||||
|
||||
# Save config atomically
|
||||
backup_path = f"{config_path}.backup.{Path(config_path).stat().st_mtime_ns}"
|
||||
@@ -220,7 +220,7 @@ else:
|
||||
config = yaml.safe_load(f) or {}
|
||||
|
||||
# Check if identity_key exists in config
|
||||
if 'mesh' in config and 'identity_key' in config['mesh']:
|
||||
if 'repeater' in config and 'identity_key' in config['repeater']:
|
||||
print(f"Updating {config_path} to use identity.key file...")
|
||||
|
||||
# Create backup
|
||||
@@ -230,7 +230,7 @@ else:
|
||||
print(f"Created backup: {backup_path}")
|
||||
|
||||
# Remove identity_key from config
|
||||
del config['mesh']['identity_key']
|
||||
del config['repeater']['identity_key']
|
||||
|
||||
# Save updated config
|
||||
with open(config_path, 'w') as f:
|
||||
@@ -240,7 +240,7 @@ else:
|
||||
print(f"✓ Config will now use {identity_path}")
|
||||
print()
|
||||
else:
|
||||
print(f"✓ Config file already configured to use identity.key file")
|
||||
print(f"✓ Config file already configured to use identity.key file (no repeater.identity_key found)")
|
||||
print()
|
||||
|
||||
except Exception as e:
|
||||
|
||||
@@ -6,10 +6,17 @@ services:
|
||||
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
|
||||
|
||||
@@ -12,6 +12,7 @@ RUN apt-get update && apt-get install -y \
|
||||
python3-rrdtool \
|
||||
jq \
|
||||
wget \
|
||||
libusb-1.0-0 \
|
||||
swig \
|
||||
git \
|
||||
build-essential \
|
||||
@@ -26,6 +27,8 @@ 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 .
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
# PR: Compute Packet Hash Once Per Forwarded Packet
|
||||
|
||||
**Branch:** `perf/hash-once`
|
||||
**Base:** `rightup/fix-perfom-speed`
|
||||
**Files changed:** `repeater/engine.py` (1 file, ~51 lines net)
|
||||
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
`packet.calculate_packet_hash()` runs a SHA-256 digest over the full serialised
|
||||
packet bytes, converts the result to a hex string, and uppercases it. Before
|
||||
this change the hot forwarding path triggered this computation **three times per
|
||||
packet**:
|
||||
|
||||
| Call site | Where | When |
|
||||
|-----------|-------|------|
|
||||
| `__call__` line 162 | `pkt_hash_full = packet.calculate_packet_hash()...` | Every received packet |
|
||||
| `flood_forward` / `direct_forward` via `is_duplicate` | `pkt_hash = packet.calculate_packet_hash()...` | Every packet that reaches the forward check |
|
||||
| `flood_forward` / `direct_forward` via `mark_seen` | `pkt_hash = packet_hash or packet.calculate_packet_hash()...` | Every packet that passes the duplicate check |
|
||||
|
||||
And on the drop path, a fourth computation:
|
||||
|
||||
| Call site | Where | When |
|
||||
|-----------|-------|------|
|
||||
| `_get_drop_reason` → `is_duplicate` | `pkt_hash = packet.calculate_packet_hash()...` | Every dropped packet |
|
||||
|
||||
The hash computed in `__call__` was already available as `pkt_hash_full` but was
|
||||
never passed into `process_packet`, `flood_forward`, `direct_forward`,
|
||||
`is_duplicate`, `mark_seen`, or `_get_drop_reason`. Each of those methods
|
||||
recomputed it independently.
|
||||
|
||||
---
|
||||
|
||||
## Root Cause
|
||||
|
||||
The `packet_hash` optional parameter existed on `mark_seen` but not on
|
||||
`is_duplicate`, `flood_forward`, `direct_forward`, `process_packet`, or
|
||||
`_get_drop_reason`. The call chain therefore had no way to propagate the
|
||||
already-computed hash.
|
||||
|
||||
---
|
||||
|
||||
## Solution
|
||||
|
||||
Thread the pre-computed `pkt_hash_full` from `__call__` down through the call
|
||||
chain as an optional `packet_hash: Optional[str] = None` parameter. Each method
|
||||
uses the provided hash if present, or falls back to computing it — preserving
|
||||
backward compatibility for any caller that doesn't have a pre-computed hash.
|
||||
|
||||
```
|
||||
Before:
|
||||
__call__ → calculate_packet_hash() #1
|
||||
→ process_packet
|
||||
→ flood_forward
|
||||
→ is_duplicate → calculate_packet_hash() #2
|
||||
→ mark_seen → calculate_packet_hash() #3
|
||||
(drop path)
|
||||
→ _get_drop_reason
|
||||
→ is_duplicate → calculate_packet_hash() #4
|
||||
|
||||
After:
|
||||
__call__ → calculate_packet_hash() #1 (only computation)
|
||||
→ process_packet(packet_hash=pkt_hash_full)
|
||||
→ flood_forward(packet_hash=pkt_hash_full)
|
||||
→ is_duplicate(packet_hash=pkt_hash_full) uses provided hash ✓
|
||||
→ mark_seen(packet_hash=pkt_hash_full) uses provided hash ✓
|
||||
(drop path)
|
||||
→ _get_drop_reason(packet_hash=pkt_hash_full)
|
||||
→ is_duplicate(packet_hash=pkt_hash_full) uses provided hash ✓
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Methods Changed
|
||||
|
||||
### `is_duplicate(packet, packet_hash=None)`
|
||||
|
||||
```python
|
||||
# Before
|
||||
def is_duplicate(self, packet: Packet) -> bool:
|
||||
pkt_hash = packet.calculate_packet_hash().hex().upper() # always recomputed
|
||||
if pkt_hash in self.seen_packets:
|
||||
return True
|
||||
return False
|
||||
|
||||
# After
|
||||
def is_duplicate(self, packet: Packet, packet_hash: Optional[str] = None) -> bool:
|
||||
"""...
|
||||
INVARIANT: purely synchronous — no await points. The caller relies on
|
||||
is_duplicate + mark_seen being atomic within the asyncio event loop.
|
||||
Do NOT add any await here without revisiting that invariant.
|
||||
"""
|
||||
pkt_hash = packet_hash or packet.calculate_packet_hash().hex().upper()
|
||||
return pkt_hash in self.seen_packets
|
||||
```
|
||||
|
||||
### `_get_drop_reason(packet, packet_hash=None)`
|
||||
|
||||
```python
|
||||
# Before
|
||||
def _get_drop_reason(self, packet: Packet) -> str:
|
||||
if self.is_duplicate(packet): ... # recomputes hash
|
||||
|
||||
# After
|
||||
def _get_drop_reason(self, packet: Packet, packet_hash: Optional[str] = None) -> str:
|
||||
if self.is_duplicate(packet, packet_hash=packet_hash): ... # propagates hash
|
||||
```
|
||||
|
||||
### `flood_forward(packet, packet_hash=None)`
|
||||
|
||||
```python
|
||||
# Before
|
||||
def flood_forward(self, packet: Packet) -> Optional[Packet]:
|
||||
...
|
||||
if self.is_duplicate(packet): ... # recomputes
|
||||
self.mark_seen(packet) # recomputes
|
||||
|
||||
# After
|
||||
def flood_forward(self, packet: Packet, packet_hash: Optional[str] = None) -> Optional[Packet]:
|
||||
"""...
|
||||
INVARIANT: purely synchronous — no await points.
|
||||
"""
|
||||
...
|
||||
if self.is_duplicate(packet, packet_hash=packet_hash): ... # propagates
|
||||
self.mark_seen(packet, packet_hash=packet_hash) # propagates
|
||||
```
|
||||
|
||||
### `direct_forward(packet, packet_hash=None)` — same pattern as `flood_forward`
|
||||
|
||||
### `process_packet(packet, snr=0.0, packet_hash=None)`
|
||||
|
||||
```python
|
||||
# Before
|
||||
def process_packet(self, packet, snr=0.0):
|
||||
fwd_pkt = self.flood_forward(packet) # no hash
|
||||
|
||||
# After
|
||||
def process_packet(self, packet, snr=0.0, packet_hash=None):
|
||||
"""...
|
||||
packet_hash: pre-computed SHA-256 hex from __call__; eliminates 2 SHA-256
|
||||
calls per forwarded packet by propagating the hash through the call chain.
|
||||
"""
|
||||
fwd_pkt = self.flood_forward(packet, packet_hash=packet_hash)
|
||||
```
|
||||
|
||||
### `__call__` — two call-site changes
|
||||
|
||||
```python
|
||||
# Before
|
||||
result = (None if ... else self.process_packet(processed_packet, snr))
|
||||
...
|
||||
drop_reason = processed_packet.drop_reason or self._get_drop_reason(processed_packet)
|
||||
|
||||
# After
|
||||
result = (None if ... else self.process_packet(processed_packet, snr, packet_hash=pkt_hash_full))
|
||||
...
|
||||
drop_reason = processed_packet.drop_reason or self._get_drop_reason(
|
||||
processed_packet, packet_hash=pkt_hash_full
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## What Was Not Changed
|
||||
|
||||
`record_packet_only` (line 446) and `record_duplicate` (line 486) each compute
|
||||
the hash independently. These are separate recording paths (called from the
|
||||
inject path and from the raw-packet subscriber, respectively) that have no
|
||||
`pkt_hash_full` from `__call__` in scope. Changing them would require a larger
|
||||
refactor with no benefit to the forwarding hot path, so they are left unchanged.
|
||||
|
||||
The fallback `packet_hash or packet.calculate_packet_hash()...` pattern in
|
||||
`is_duplicate`, `mark_seen`, and `_build_packet_record` ensures external callers
|
||||
(e.g. `TraceHelper.is_duplicate(packet)` from trace processing) continue to work
|
||||
without any change.
|
||||
|
||||
---
|
||||
|
||||
## Invariant Comments Added
|
||||
|
||||
`flood_forward`, `direct_forward`, and `is_duplicate` now carry explicit docstring
|
||||
invariants:
|
||||
|
||||
> **INVARIANT:** purely synchronous — no await points. The is_duplicate +
|
||||
> mark_seen pair is atomic within the asyncio event loop. Do NOT add any await
|
||||
> here without revisiting that invariant in `__call__` / `process_packet`.
|
||||
|
||||
These invariants were implicit before. Making them explicit means a future
|
||||
contributor adding an `await` inside these methods will see the warning and
|
||||
understand the consequence: the duplicate-check and mark-seen can no longer be
|
||||
guaranteed atomic, allowing the same packet to be forwarded twice under concurrent
|
||||
task dispatch.
|
||||
|
||||
---
|
||||
|
||||
## Quantification
|
||||
|
||||
On a Raspberry Pi running CPython 3.13, `hashlib.sha256` on a 50–200 byte
|
||||
LoRa payload takes approximately 1–3 µs. The `.hex().upper()` string conversion
|
||||
adds another ~0.5 µs. Savings per forwarded packet: ~3–8 µs.
|
||||
|
||||
At 3 packets/second sustained forwarding rate this saves ~10–25 µs/second, which
|
||||
is negligible in absolute terms. The more significant benefit is correctness and
|
||||
clarity:
|
||||
|
||||
- One canonical hash value per packet in the forwarding path.
|
||||
- No possibility of the hash changing between the `is_duplicate` check and the
|
||||
`mark_seen` call if `calculate_packet_hash` had any mutable state (it doesn't,
|
||||
but the pattern is now provably correct).
|
||||
- Explicit invariant documentation closes a latent trap for future contributors.
|
||||
|
||||
---
|
||||
|
||||
## Test Plan
|
||||
|
||||
### Unit tests (no hardware)
|
||||
|
||||
**T1 — Hash computed exactly once per forwarded packet**
|
||||
|
||||
```python
|
||||
async def test_hash_computed_once_for_flood():
|
||||
call_count = 0
|
||||
original = Packet.calculate_packet_hash
|
||||
|
||||
def counting_hash(self):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
return original(self)
|
||||
|
||||
with patch.object(Packet, "calculate_packet_hash", counting_hash):
|
||||
await engine(flood_packet, metadata={})
|
||||
|
||||
assert call_count == 1, f"Expected 1 hash computation, got {call_count}"
|
||||
```
|
||||
|
||||
**T2 — Hash computed exactly once per dropped (duplicate) packet**
|
||||
|
||||
```python
|
||||
async def test_hash_computed_once_for_duplicate():
|
||||
# Mark packet seen first
|
||||
engine.seen_packets[packet.calculate_packet_hash().hex().upper()] = time.time()
|
||||
|
||||
call_count = 0
|
||||
original = Packet.calculate_packet_hash
|
||||
def counting_hash(self):
|
||||
nonlocal call_count; call_count += 1; return original(self)
|
||||
|
||||
with patch.object(Packet, "calculate_packet_hash", counting_hash):
|
||||
await engine(packet, metadata={})
|
||||
|
||||
# One computation in __call__ for pkt_hash_full; should not trigger again
|
||||
# in process_packet → flood_forward → is_duplicate (drop path via _get_drop_reason)
|
||||
assert call_count == 1
|
||||
```
|
||||
|
||||
**T3 — External callers of `is_duplicate` without hash still work**
|
||||
|
||||
```python
|
||||
def test_is_duplicate_without_hash():
|
||||
"""TraceHelper and other external callers pass no hash — must still work."""
|
||||
pkt = make_test_packet()
|
||||
engine.seen_packets[pkt.calculate_packet_hash().hex().upper()] = time.time()
|
||||
|
||||
assert engine.is_duplicate(pkt) is True # no packet_hash arg
|
||||
assert engine.is_duplicate(pkt, packet_hash="WRONGHASH") is False
|
||||
```
|
||||
|
||||
**T4 — mark_seen / is_duplicate agree on the same hash**
|
||||
|
||||
```python
|
||||
def test_mark_then_is_duplicate_consistent():
|
||||
pkt = make_test_packet()
|
||||
pkt_hash = pkt.calculate_packet_hash().hex().upper()
|
||||
|
||||
assert engine.is_duplicate(pkt, packet_hash=pkt_hash) is False
|
||||
engine.mark_seen(pkt, packet_hash=pkt_hash)
|
||||
assert engine.is_duplicate(pkt, packet_hash=pkt_hash) is True
|
||||
# Same result without the pre-computed hash (fallback path)
|
||||
assert engine.is_duplicate(pkt) is True
|
||||
```
|
||||
|
||||
**T5 — flood_forward / direct_forward signatures are backward compatible**
|
||||
|
||||
```python
|
||||
def test_flood_forward_no_hash_arg():
|
||||
"""Callers that don't pass packet_hash must still work (fallback compute)."""
|
||||
pkt = make_flood_packet()
|
||||
result = engine.flood_forward(pkt) # no packet_hash — must not raise
|
||||
assert result is not None or pkt.drop_reason is not None
|
||||
```
|
||||
|
||||
### Integration / field tests (with hardware)
|
||||
|
||||
**T6 — Forwarding throughput unchanged**
|
||||
|
||||
1. Forward 100 packets at maximum duty-cycle budget.
|
||||
2. Verify all eligible packets are forwarded (same count as before change).
|
||||
3. Verify no `Duplicate` drops that were not present before.
|
||||
|
||||
**T7 — Duplicate detection unchanged**
|
||||
|
||||
1. Send the same packet twice within 1 second.
|
||||
2. Verify the first is forwarded and the second is logged as `"Duplicate"`.
|
||||
|
||||
**T8 — CPU profile shows reduced `calculate_packet_hash` calls**
|
||||
|
||||
1. Enable Python profiling (`cProfile`) on the repeater for 60 seconds.
|
||||
2. Compare `calculate_packet_hash` call count before and after.
|
||||
|
||||
**Expected:** call count approximately halved for workloads where most packets
|
||||
are forwarded (≤ 1 call per forwarded packet vs ≥ 3 before).
|
||||
|
||||
---
|
||||
|
||||
## Proof of Correctness
|
||||
|
||||
### Why the fallback `packet_hash or packet.calculate_packet_hash()` is safe
|
||||
|
||||
`packet_hash` is either the correct hash (passed from `__call__`) or `None`.
|
||||
If it is `None`, the fallback computes the hash fresh — identical to the old
|
||||
behaviour. There is no case where a wrong hash is used: the only source of a
|
||||
non-None `packet_hash` is `pkt_hash_full = packet.calculate_packet_hash()...`
|
||||
in `__call__`, computed over the same `processed_packet` (a deep copy of the
|
||||
received packet, unchanged between hash computation and the call to
|
||||
`process_packet`).
|
||||
|
||||
### Why passing the hash through a deep-copied packet is correct
|
||||
|
||||
`processed_packet = copy.deepcopy(packet)` (line 178) happens before
|
||||
`pkt_hash_full` is passed to `process_packet`. The deep copy does not change
|
||||
the packet's wire representation — `calculate_packet_hash()` calls
|
||||
`packet.write_to()` which serialises the packet's fields. The copy has the
|
||||
same fields, so `deepcopy(packet).calculate_packet_hash() == packet.calculate_packet_hash()`.
|
||||
Passing the hash computed from the original to the copy is correct.
|
||||
|
||||
### Why the invariant is critical
|
||||
|
||||
asyncio only yields execution at `await` points. `flood_forward` and
|
||||
`direct_forward` have no `await`, so they run atomically from the event loop's
|
||||
perspective. The `is_duplicate` check and the `mark_seen` call inside them
|
||||
cannot be interleaved with another coroutine. If a future change added an
|
||||
`await` between them, two concurrent `_route_packet` tasks could both pass the
|
||||
duplicate check for the same packet before either marked it seen — sending the
|
||||
same packet twice. The invariant comment documents this so the risk is visible
|
||||
at the point where it could be broken.
|
||||
@@ -0,0 +1,349 @@
|
||||
# PR: Bounded In-Flight Task Counter + Simplified Route Task Management
|
||||
|
||||
**Branch:** `perf/in-flight-cap`
|
||||
**Base:** `rightup/fix-perfom-speed`
|
||||
**Files changed:** `repeater/packet_router.py` (1 file, ~33 lines net)
|
||||
|
||||
---
|
||||
|
||||
## Background
|
||||
|
||||
The queue loop dispatches each incoming packet as an `asyncio.create_task` so TX
|
||||
delay timers run concurrently — this is correct behaviour. The previous
|
||||
implementation tracked these tasks in a `set[asyncio.Task]` (`_route_tasks`) for
|
||||
two reasons:
|
||||
|
||||
1. **Error surfacing** — the done-callback read `task.result()` to log exceptions.
|
||||
2. **Shutdown cancellation** — `stop()` cancelled and awaited all tasks in the set.
|
||||
|
||||
This PR replaces the set with a simple integer counter and tightens the companion
|
||||
deduplication prune threshold.
|
||||
|
||||
---
|
||||
|
||||
## Problems
|
||||
|
||||
### Problem 1 — Unbounded task accumulation
|
||||
|
||||
LoRa airtime naturally limits steady-state throughput to a handful of in-flight
|
||||
tasks at any time. But burst arrivals can spike the count temporarily:
|
||||
|
||||
- **Multi-hop flood amplification**: a single source packet is forwarded by every
|
||||
repeater in range, each of which re-broadcasts it. A node at a mesh junction
|
||||
may receive 5–10 copies within 100 ms, each scheduling a separate `delayed_send`
|
||||
task.
|
||||
- **Collision retries**: hardware-level collisions produce duplicate RF bursts that
|
||||
all arrive within the same RX window.
|
||||
- **Bridge nodes**: high-traffic gateway nodes connect multiple mesh segments and
|
||||
forward both directions simultaneously.
|
||||
|
||||
Under these conditions `_route_tasks` can accumulate dozens of sleeping tasks.
|
||||
Each holds a reference to the packet, the forwarded packet copy, a closure over
|
||||
`delayed_send`, and associated asyncio task overhead. There is no cap; the set
|
||||
grows until the duty-cycle gate finally fires for each task.
|
||||
|
||||
### Problem 2 — `_route_tasks` set adds O(1) cost on every packet but O(n) cost on shutdown
|
||||
|
||||
Every packet adds one entry to `_route_tasks` and removes it in the done-callback.
|
||||
This is O(1) per operation, but the `stop()` shutdown path iterates the entire set
|
||||
to cancel and gather all tasks — O(n) where n is however many tasks happen to be
|
||||
in-flight at shutdown time. On a busy node this could delay clean shutdown.
|
||||
|
||||
### Problem 3 — `_COMPANION_DEDUPE_PRUNE_THRESHOLD = 1000` is too high
|
||||
|
||||
The companion delivery deduplication dict prunes itself only when it exceeds 1000
|
||||
entries. With a 60-second TTL, each PATH/protocol-response packet adds one entry.
|
||||
On a busy mesh with 50+ nodes sending adverts and PATH packets, the dict can grow
|
||||
to hundreds of entries before a prune is triggered — keeping stale entries in
|
||||
memory for up to 60 seconds × 1000/rate entries worth of time.
|
||||
|
||||
---
|
||||
|
||||
## Solution
|
||||
|
||||
### Replace `_route_tasks` set with `_in_flight` counter
|
||||
|
||||
An integer counter provides the same protection (tasks complete; done-callback
|
||||
fires) without holding strong references to each task object:
|
||||
|
||||
```python
|
||||
# __init__
|
||||
self._in_flight: int = 0
|
||||
self._max_in_flight: int = 30
|
||||
|
||||
# _process_queue — drop early if cap reached
|
||||
if self._in_flight >= self._max_in_flight:
|
||||
logger.warning("In-flight task cap reached (%d/%d), dropping packet", ...)
|
||||
continue
|
||||
self._in_flight += 1
|
||||
task = asyncio.create_task(self._route_packet(packet))
|
||||
task.add_done_callback(self._on_route_done)
|
||||
|
||||
# done-callback
|
||||
def _on_route_done(self, task):
|
||||
self._in_flight -= 1
|
||||
if not task.cancelled() and task.exception():
|
||||
logger.error("_route_packet raised: %s", task.exception(), ...)
|
||||
```
|
||||
|
||||
### Cap at 30 concurrent in-flight tasks
|
||||
|
||||
30 is chosen as a ceiling that is:
|
||||
- **Never reached in normal operation**: LoRa airtime at SF8/125 kHz limits
|
||||
throughput to ~2–3 packets per second; with delays of 0.5–5 s each, the
|
||||
steady-state in-flight count is at most 5–15 tasks.
|
||||
- **High enough not to drop legitimate traffic**: a burst of 30 nearly-simultaneous
|
||||
packets would require every node in a large mesh to transmit within 1 second.
|
||||
- **Low enough to protect against pathological scenarios**: a misconfigured node
|
||||
flooding the channel or a software bug causing infinite re-queuing.
|
||||
|
||||
### Tighten companion dedup prune threshold to 200
|
||||
|
||||
200 entries at 60 s TTL means a sweep is triggered after ~200 unique PATH/response
|
||||
packets arrive without any expiry. This is far more than a typical companion
|
||||
session (which sees a handful of active connections) but prevents multi-hour
|
||||
accumulation on a busy mesh.
|
||||
|
||||
---
|
||||
|
||||
## Trade-off: Shutdown Cancellation
|
||||
|
||||
The previous `_route_tasks` set allowed `stop()` to explicitly cancel and await
|
||||
all in-flight tasks on shutdown. The counter approach does not.
|
||||
|
||||
**Why this is acceptable:**
|
||||
|
||||
1. In-flight `_route_packet` tasks are sleeping inside `delayed_send` (waiting for
|
||||
their TX delay timer). When the event loop is shut down — whether via
|
||||
`asyncio.run()` completing, `loop.stop()`, or `SIGTERM` handling — Python
|
||||
cancels all pending tasks automatically.
|
||||
|
||||
2. Even under the old approach, cancelling a sleeping `delayed_send` means the
|
||||
packet is not transmitted. The result is the same whether cancellation happens
|
||||
explicitly in `stop()` or implicitly when the event loop closes.
|
||||
|
||||
3. For a graceful shutdown where we want to *wait* for in-flight packets to
|
||||
complete transmission, the right mechanism is `stop()` awaiting the queue to
|
||||
drain *before* cancelling the router task — not cancelling sleeping tasks.
|
||||
Neither the old code nor this PR implements that, so no regression.
|
||||
|
||||
---
|
||||
|
||||
## Why This Is the Right Approach
|
||||
|
||||
### Alternative A — Keep `_route_tasks` set, add a size cap
|
||||
|
||||
```python
|
||||
if len(self._route_tasks) >= 30:
|
||||
logger.warning(...)
|
||||
continue
|
||||
```
|
||||
|
||||
Works, but the set still holds a strong reference to every Task object for the
|
||||
duration of its sleep. The counter holds an integer. Task objects in Python 3.12+
|
||||
are already strongly referenced by the event loop scheduler; the set reference is
|
||||
redundant for preventing GC cancellation.
|
||||
|
||||
### Alternative B — `asyncio.Semaphore`
|
||||
|
||||
```python
|
||||
self._sem = asyncio.Semaphore(30)
|
||||
async with self._sem:
|
||||
await self._route_packet(packet)
|
||||
```
|
||||
|
||||
Correct but changes the queue loop from fire-and-forget to blocking: the loop
|
||||
would wait at `async with self._sem` for a slot to open, stalling packet reads
|
||||
while a slot is occupied. That reintroduces the queue freeze the concurrent
|
||||
dispatch was designed to prevent. A semaphore is the right tool for *rate-
|
||||
limiting* producers; a counter cap at the dispatch site is the right tool for
|
||||
bounding *background* tasks.
|
||||
|
||||
### Alternative C — Integer counter (this PR)
|
||||
|
||||
- O(1) increment and decrement.
|
||||
- No strong reference to task objects beyond the event loop's own reference.
|
||||
- Drop decision is synchronous and immediate — no sleeping on semaphore.
|
||||
- Error logging preserved in `_on_route_done`.
|
||||
- Simpler code, easier to reason about.
|
||||
|
||||
---
|
||||
|
||||
## Changes — `repeater/packet_router.py` only
|
||||
|
||||
| Location | Change | Reason |
|
||||
|----------|--------|--------|
|
||||
| Module level | Remove `_COMPANION_DEDUPE_PRUNE_THRESHOLD = 1000` | Replaced with inline literal `200`; no need for a named constant for a single usage site |
|
||||
| `__init__` | Remove `self._route_tasks = set()`; add `self._in_flight = 0`, `self._max_in_flight = 30` | Replace set-based tracking with counter |
|
||||
| `stop()` | Remove `_route_tasks` cancellation block | Tasks complete or are cancelled by event loop shutdown; explicit cancellation not needed |
|
||||
| `_on_route_task_done` → `_on_route_done` | Simpler done-callback: decrement counter + log exceptions | Error logging preserved; set management removed |
|
||||
| `_should_deliver_path_to_companions` | `> _COMPANION_DEDUPE_PRUNE_THRESHOLD` → `> 200` with explanatory comment | Lower threshold; comment explains the sizing rationale |
|
||||
| `_process_queue` | Check `_in_flight >= _max_in_flight` before `create_task`; increment `_in_flight`; use `_on_route_done` | Cap accumulation; counter tracks live task count |
|
||||
|
||||
---
|
||||
|
||||
## Test Plan
|
||||
|
||||
### Unit tests (no hardware)
|
||||
|
||||
**T1 — Counter increments and decrements correctly**
|
||||
|
||||
```python
|
||||
async def test_in_flight_counter():
|
||||
router = PacketRouter(mock_daemon)
|
||||
await router.start()
|
||||
|
||||
assert router._in_flight == 0
|
||||
|
||||
# Enqueue a packet that takes time to process
|
||||
async def slow_route(pkt):
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
router._route_packet = slow_route
|
||||
await router.enqueue(make_test_packet())
|
||||
await asyncio.sleep(0.01) # let queue loop run
|
||||
|
||||
assert router._in_flight == 1 # task is sleeping
|
||||
|
||||
await asyncio.sleep(0.15) # task finishes
|
||||
assert router._in_flight == 0 # counter decremented by done-callback
|
||||
```
|
||||
|
||||
**T2 — Cap enforced: packet dropped when at limit**
|
||||
|
||||
```python
|
||||
async def test_cap_drops_packet_at_limit():
|
||||
router = PacketRouter(mock_daemon)
|
||||
router._max_in_flight = 2
|
||||
router._in_flight = 2 # simulate cap reached
|
||||
|
||||
dropped = []
|
||||
original_create_task = asyncio.create_task
|
||||
asyncio.create_task = lambda coro: dropped.append(coro)
|
||||
|
||||
await router._process_queue_once(make_test_packet())
|
||||
|
||||
assert dropped == [], "create_task must not be called when cap is reached"
|
||||
asyncio.create_task = original_create_task
|
||||
```
|
||||
|
||||
**T3 — Exceptions in `_route_packet` are logged, not swallowed**
|
||||
|
||||
```python
|
||||
async def test_exception_logged():
|
||||
router = PacketRouter(mock_daemon)
|
||||
|
||||
async def failing_route(pkt):
|
||||
raise ValueError("simulated error")
|
||||
|
||||
router._route_packet = failing_route
|
||||
with patch("repeater.packet_router.logger") as mock_log:
|
||||
task = asyncio.create_task(failing_route(make_test_packet()))
|
||||
router._in_flight = 1
|
||||
task.add_done_callback(router._on_route_done)
|
||||
await asyncio.gather(task, return_exceptions=True)
|
||||
mock_log.error.assert_called_once()
|
||||
|
||||
assert router._in_flight == 0
|
||||
```
|
||||
|
||||
**T4 — Companion dedup dict pruned at 200, not 1000**
|
||||
|
||||
```python
|
||||
def test_companion_dedup_prune_threshold():
|
||||
router = PacketRouter(mock_daemon)
|
||||
future_time = time.time() + 999
|
||||
|
||||
# Fill with 199 entries (all unexpired) — no prune
|
||||
router._companion_delivered = {f"key{i}": future_time for i in range(199)}
|
||||
pkt = make_path_packet()
|
||||
router._should_deliver_path_to_companions(pkt)
|
||||
assert len(router._companion_delivered) == 200 # added one, no prune yet
|
||||
|
||||
# 201st entry triggers prune — all unexpired so count stays at 201
|
||||
router._companion_delivered[f"key_extra"] = future_time
|
||||
assert len(router._companion_delivered) == 201
|
||||
|
||||
# Force prune by making all existing entries expired
|
||||
past_time = time.time() - 1
|
||||
router._companion_delivered = {f"key{i}": past_time for i in range(201)}
|
||||
router._should_deliver_path_to_companions(pkt)
|
||||
# All expired entries pruned; only the new entry remains
|
||||
assert len(router._companion_delivered) == 1
|
||||
```
|
||||
|
||||
### Integration / field tests (with hardware)
|
||||
|
||||
**T5 — Burst flood: verify cap fires under pathological load**
|
||||
|
||||
1. Configure a test mesh with 4+ nodes all in range of the repeater.
|
||||
2. Have all nodes send a flood packet simultaneously.
|
||||
3. Observe repeater logs.
|
||||
|
||||
**Expected:** `_in_flight` peaks in low single digits (LoRa airtime prevents
|
||||
large bursts); no `"In-flight task cap reached"` warning fires under normal
|
||||
conditions, confirming the cap is never a bottleneck in practice.
|
||||
|
||||
**T6 — Counter reaches zero after all packets processed**
|
||||
|
||||
1. Send a burst of 10 packets.
|
||||
2. Wait 10 seconds (longer than max TX delay of 5 s).
|
||||
3. Query `router._in_flight` from a debug endpoint or log.
|
||||
|
||||
**Expected:** `_in_flight == 0` after all delays expire and packets transmit.
|
||||
|
||||
**T7 — Error in `_route_packet` is logged and counter is decremented**
|
||||
|
||||
1. Temporarily introduce a deliberate exception in `_route_packet`.
|
||||
2. Send a packet.
|
||||
3. Check logs for the error message and verify the repeater continues operating
|
||||
(counter decremented, queue still draining).
|
||||
|
||||
**T8 — Normal forwarding throughput unchanged**
|
||||
|
||||
1. Send packets at a steady rate of 1 every 10 seconds for 5 minutes.
|
||||
2. Verify all packets are forwarded with no warnings or errors.
|
||||
3. Confirm `_in_flight` never exceeds 3–4 during normal operation.
|
||||
|
||||
---
|
||||
|
||||
## Proof of Correctness
|
||||
|
||||
### Counter vs set: why the counter is sufficient
|
||||
|
||||
The `_route_tasks` set solved two problems:
|
||||
|
||||
1. **GC protection**: In Python < 3.12, a task with no strong references other
|
||||
than the event loop's internal weakref could be garbage collected before
|
||||
completing. Python 3.12+ strengthened task references in the event loop.
|
||||
However, even in earlier versions, the set was unnecessary once `create_task`
|
||||
returns — the caller holds the reference, and the done-callback fires reliably
|
||||
because the event loop holds the task alive until completion.
|
||||
|
||||
2. **Explicit shutdown cancellation**: The counter loses this. As argued above,
|
||||
the outcome is identical — sleeping tasks are cancelled either explicitly by
|
||||
`stop()` or implicitly by the event loop at shutdown — and no packet that
|
||||
hasn't been transmitted yet can complete its send after the radio is shut down
|
||||
anyway.
|
||||
|
||||
### Why `_on_route_done` is a done-callback and not a `try/finally` inside `_route_packet`
|
||||
|
||||
A `try/finally` block inside `_route_packet` would also decrement the counter.
|
||||
Done-callbacks are preferable because:
|
||||
|
||||
- They fire even if the task is externally cancelled (e.g. by event loop shutdown),
|
||||
whereas `finally` may not run if `CancelledError` is not caught.
|
||||
- They decouple counter management from `_route_packet` logic — `_route_packet`
|
||||
has no knowledge of or dependency on the cap mechanism.
|
||||
- They keep the pattern consistent with the rest of the codebase's use of
|
||||
`add_done_callback` for task lifecycle management.
|
||||
|
||||
### Why 30 and not a smaller number like 10
|
||||
|
||||
At SF8, 125 kHz bandwidth, a 30-byte payload takes ~111 ms airtime and produces
|
||||
a TX delay of roughly 0.5–3 s. With a 60-second duty-cycle window and 3.6 s
|
||||
max airtime, the node can forward at most ~32 packets per minute at full budget.
|
||||
If all 32 arrive within one second (they cannot physically, but as an upper
|
||||
bound), 32 tasks would be in-flight simultaneously. A cap of 30 is aggressive
|
||||
enough to protect against unbounded growth but not so low that it would drop
|
||||
legitimate traffic under any realistic burst scenario.
|
||||
@@ -0,0 +1,395 @@
|
||||
# PR: Serialise Radio TX and Close Duty-Cycle TOCTOU Race
|
||||
|
||||
**Branch:** `fix/tx-serialization`
|
||||
**Base:** `rightup/fix-perfom-speed`
|
||||
**Files changed:** `repeater/engine.py` (1 file, ~30 lines net)
|
||||
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
Two separate bugs share the same root cause: concurrent `delayed_send` coroutines
|
||||
racing each other at transmission time.
|
||||
|
||||
### Bug 1 — Interleaved SPI/serial commands to the radio
|
||||
|
||||
The queue loop (added in an earlier commit) dispatches each incoming packet as an
|
||||
`asyncio.create_task`, so multiple `delayed_send` coroutines can have their sleep
|
||||
timers running concurrently. That is correct and intentional — it mirrors how
|
||||
firmware nodes use a hardware timer so the radio keeps listening during a TX delay.
|
||||
|
||||
However the LoRa radio is **half-duplex**: it can only transmit one packet at a
|
||||
time. When two delay timers expire at nearly the same moment both coroutines call
|
||||
`dispatcher.send_packet` simultaneously. `send_packet` issues a sequence of
|
||||
SPI/serial register writes to the radio; two tasks interleaving these writes
|
||||
produces undefined radio state and the transmission of neither packet is reliable.
|
||||
|
||||
### Bug 2 — TOCTOU gap in duty-cycle enforcement
|
||||
|
||||
`__call__` calls `can_transmit()` before scheduling a task:
|
||||
|
||||
```python
|
||||
# __call__ (before this fix)
|
||||
can_tx, wait_time = self.airtime_mgr.can_transmit(airtime_ms)
|
||||
if not can_tx:
|
||||
... # drop or defer
|
||||
tx_task = await self.schedule_retransmit(fwd_pkt, delay, airtime_ms, ...)
|
||||
```
|
||||
|
||||
`record_tx()` is only called later, inside `delayed_send`, after the sleep
|
||||
completes. Between the check and the debit there is a window that spans the
|
||||
entire TX delay (up to several seconds). Two packets that both pass the check
|
||||
before either has slept and recorded its airtime will **both** be transmitted even
|
||||
if transmitting both would exceed the duty-cycle budget.
|
||||
|
||||
Under normal single-packet conditions this window is harmless. Under burst
|
||||
conditions — multi-hop amplification, collision retries, or a busy mesh segment
|
||||
where several packets arrive within the same delay window — multiple tasks pass
|
||||
the advisory check simultaneously, and the duty-cycle limit is exceeded.
|
||||
|
||||
---
|
||||
|
||||
## Root Cause
|
||||
|
||||
There is no mutual exclusion around the radio send path. Each `delayed_send`
|
||||
coroutine independently checks duty-cycle, sleeps, and transmits without
|
||||
coordinating with any other concurrent coroutine doing the same thing.
|
||||
|
||||
---
|
||||
|
||||
## Solution
|
||||
|
||||
Add `self._tx_lock = asyncio.Lock()` (initialised in `__init__`) and acquire it
|
||||
inside `delayed_send` **after** the sleep completes:
|
||||
|
||||
```
|
||||
Delay timers run concurrently (unchanged):
|
||||
Task A: sleep(1.2s) ──────────────────► acquire _tx_lock → check → TX A → release
|
||||
Task B: sleep(0.9s) ──────────────────► acquire _tx_lock (waits) ──────────► check → TX B → release
|
||||
Task C: sleep(2.1s) ────────────────────────────────────────────────────────────────► ...
|
||||
|
||||
Radio: one packet at a time, duty-cycle state always stable inside the lock.
|
||||
```
|
||||
|
||||
Inside the lock, a **second** `can_transmit()` call is made immediately before
|
||||
sending. Because only one task holds the lock at a time, airtime state is stable
|
||||
at this point and `record_tx()` follows on success — check and debit are
|
||||
effectively atomic. This closes the TOCTOU window completely.
|
||||
|
||||
The upfront `can_transmit()` in `__call__` is retained as an **advisory** fast
|
||||
path: it still drops or defers packets that are obviously over budget before a
|
||||
delay task is even scheduled, avoiding unnecessary sleep timers. It is no longer
|
||||
the enforcement point.
|
||||
|
||||
---
|
||||
|
||||
## Why This Is the Right Approach
|
||||
|
||||
### Alternative A — Move `record_tx()` before the sleep
|
||||
|
||||
```python
|
||||
# hypothetical
|
||||
self.airtime_mgr.record_tx(airtime_ms) # reserve before sleeping
|
||||
await asyncio.sleep(delay)
|
||||
await self.dispatcher.send_packet(...) # actual TX
|
||||
```
|
||||
|
||||
Records airtime even if the send fails (exception, LBT busy, radio error) —
|
||||
the budget is debited for a packet that was never transmitted. Over time this
|
||||
inflates the apparent airtime, causing the node to throttle legitimate traffic
|
||||
it actually has budget for. Requires a compensating `release_airtime()` on
|
||||
every failure path, creating new complexity and failure modes.
|
||||
|
||||
### Alternative B — A single global advisory check (status quo before this PR)
|
||||
|
||||
Already demonstrated to fail under burst conditions (two tasks both pass before
|
||||
either records its airtime).
|
||||
|
||||
### Alternative C — asyncio.Lock (this PR)
|
||||
|
||||
- Delay timers remain concurrent — no regression on the primary non-blocking TX
|
||||
improvement.
|
||||
- The check-and-debit pair is atomic within the lock — no TOCTOU window.
|
||||
- No phantom airtime on send failure — `record_tx()` is only called on success.
|
||||
- One `asyncio.Lock` object, no new state machines or compensating paths.
|
||||
- The lock is `async`, so it only blocks other TX tasks, not the event loop or
|
||||
the packet RX queue.
|
||||
|
||||
### Why `asyncio.Lock` rather than `threading.Lock`
|
||||
|
||||
The entire repeater runs on a single asyncio event loop. `asyncio.Lock` only
|
||||
yields at `await` points; it does not involve OS threads or context switches.
|
||||
A `threading.Lock` would work but is semantically wrong here (this is not a
|
||||
thread-safety problem) and would block the event loop thread if held across an
|
||||
`await`.
|
||||
|
||||
---
|
||||
|
||||
## Changes
|
||||
|
||||
### `repeater/engine.py`
|
||||
|
||||
**1. Move `import random` to module level**
|
||||
|
||||
```python
|
||||
# before (inside _calculate_tx_delay):
|
||||
def _calculate_tx_delay(self, packet, snr=0.0):
|
||||
import random
|
||||
...
|
||||
|
||||
# after (top of file, with other stdlib imports):
|
||||
import random
|
||||
```
|
||||
|
||||
This is a housekeeping fix bundled with this PR because `random` is a stdlib
|
||||
module that should never be imported inside a hot-path function — Python caches
|
||||
the import after the first call, but the attribute lookup and cache check still
|
||||
run on every call. Moving it to module level is the standard pattern.
|
||||
|
||||
**2. Add `self._tx_lock` to `__init__`**
|
||||
|
||||
```python
|
||||
# Serialise all radio TX calls.
|
||||
#
|
||||
# Background: since the queue loop dispatches each packet as an
|
||||
# asyncio.create_task, multiple _route_packet coroutines can have their
|
||||
# TX delay timers running concurrently — which is the intended behaviour
|
||||
# (firmware nodes do the same with a hardware timer). However, the
|
||||
# LoRa radio is half-duplex: it can only transmit one packet at a time.
|
||||
# Without serialisation, two tasks whose delay timers expire near-
|
||||
# simultaneously both call dispatcher.send_packet, interleaving SPI/serial
|
||||
# commands to the radio and both passing the LBT check before either has
|
||||
# actually transmitted.
|
||||
#
|
||||
# _tx_lock is acquired after each delay sleep and held for the entire
|
||||
# send_packet call. Delays still run concurrently; only the radio
|
||||
# access is serialised. This also eliminates the TOCTOU gap in duty-cycle
|
||||
# enforcement — see schedule_retransmit / delayed_send for details.
|
||||
self._tx_lock = asyncio.Lock()
|
||||
```
|
||||
|
||||
**3. Acquire lock inside `delayed_send`, add authoritative duty-cycle gate**
|
||||
|
||||
```python
|
||||
async def delayed_send():
|
||||
await asyncio.sleep(delay)
|
||||
|
||||
# Acquire the TX lock *after* the delay so that delay timers for
|
||||
# multiple packets still run concurrently (matching firmware). Only
|
||||
# one coroutine enters the radio send path at a time.
|
||||
async with self._tx_lock:
|
||||
# ── Authoritative duty-cycle gate ─────────────────────────────
|
||||
# The upfront can_transmit() call in __call__ is advisory: it
|
||||
# avoids scheduling packets that are obviously over budget, but
|
||||
# it cannot prevent a race between two tasks whose delay timers
|
||||
# expire at almost the same moment. Both tasks pass the advisory
|
||||
# check before either has recorded its airtime, then both try to
|
||||
# transmit.
|
||||
#
|
||||
# Inside _tx_lock only one task runs at a time, so airtime state
|
||||
# is stable here. The check and the subsequent record_tx() are
|
||||
# effectively atomic — no TOCTOU window.
|
||||
if airtime_ms > 0:
|
||||
can_tx_now, _ = self.airtime_mgr.can_transmit(airtime_ms)
|
||||
if not can_tx_now:
|
||||
logger.warning(
|
||||
"Packet dropped at TX time: duty-cycle exceeded "
|
||||
"(airtime=%.1fms)", airtime_ms,
|
||||
)
|
||||
return
|
||||
|
||||
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)
|
||||
...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Invariants Maintained
|
||||
|
||||
| Property | Before | After |
|
||||
|----------|--------|-------|
|
||||
| Delay timers run concurrently | ✅ | ✅ |
|
||||
| Radio accessed by one task at a time | ❌ | ✅ |
|
||||
| Duty-cycle check and debit atomic | ❌ | ✅ |
|
||||
| Airtime recorded only on TX success | ✅ | ✅ |
|
||||
| Event loop not blocked by lock | ✅ | ✅ (asyncio.Lock) |
|
||||
|
||||
---
|
||||
|
||||
## Test Plan
|
||||
|
||||
### Unit tests (can run without hardware)
|
||||
|
||||
**T1 — Serial TX ordering**
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
async def test_tx_serialized():
|
||||
"""Two tasks whose delays expire simultaneously must not interleave."""
|
||||
send_order = []
|
||||
send_lock = asyncio.Lock()
|
||||
|
||||
async def mock_send(pkt, **kw):
|
||||
# Confirm the _tx_lock is already held when we enter send_packet
|
||||
assert send_lock.locked(), "send_packet called without _tx_lock held"
|
||||
send_order.append(pkt)
|
||||
await asyncio.sleep(0) # yield; a second task must not enter here
|
||||
|
||||
engine._tx_lock = send_lock # replace with the mock lock reference
|
||||
engine.dispatcher.send_packet = mock_send
|
||||
|
||||
t1 = asyncio.create_task(engine.schedule_retransmit(pkt_a, delay=0.01, airtime_ms=100))
|
||||
t2 = asyncio.create_task(engine.schedule_retransmit(pkt_b, delay=0.01, airtime_ms=100))
|
||||
await asyncio.gather(t1, t2)
|
||||
|
||||
assert len(send_order) == 2 # both transmitted
|
||||
assert send_order[0] is not send_order[1] # different packets
|
||||
```
|
||||
|
||||
**T2 — Authoritative duty-cycle gate blocks over-budget second packet**
|
||||
|
||||
```python
|
||||
async def test_second_packet_dropped_when_over_budget():
|
||||
"""When first TX fills the budget, second task must be dropped inside the lock."""
|
||||
# Set a tiny budget: 50ms per minute
|
||||
engine.airtime_mgr.max_airtime_per_minute = 50
|
||||
|
||||
sent = []
|
||||
async def mock_send(pkt, **kw):
|
||||
sent.append(pkt)
|
||||
|
||||
engine.dispatcher.send_packet = mock_send
|
||||
|
||||
# Each packet costs ~111ms (SF8, BW125, 30-byte payload) — first passes, second must not
|
||||
t1 = asyncio.create_task(engine.schedule_retransmit(pkt_a, delay=0.01, airtime_ms=111))
|
||||
t2 = asyncio.create_task(engine.schedule_retransmit(pkt_b, delay=0.01, airtime_ms=111))
|
||||
await asyncio.gather(t1, t2)
|
||||
|
||||
assert len(sent) == 1, f"Expected 1 TX, got {len(sent)}"
|
||||
```
|
||||
|
||||
**T3 — Airtime not debited on TX failure**
|
||||
|
||||
```python
|
||||
async def test_airtime_not_recorded_on_send_failure():
|
||||
before = engine.airtime_mgr.total_airtime_ms
|
||||
|
||||
async def failing_send(pkt, **kw):
|
||||
raise RuntimeError("radio error")
|
||||
|
||||
engine.dispatcher.send_packet = failing_send
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
await engine.schedule_retransmit(pkt, delay=0, airtime_ms=100)
|
||||
|
||||
assert engine.airtime_mgr.total_airtime_ms == before, \
|
||||
"Airtime must not be recorded when send raises"
|
||||
```
|
||||
|
||||
**T4 — Advisory check still drops before scheduling (fast path not regressed)**
|
||||
|
||||
```python
|
||||
async def test_advisory_check_still_drops_obvious_overage():
|
||||
"""__call__ should not even schedule a task when clearly over budget."""
|
||||
engine.airtime_mgr.max_airtime_per_minute = 0 # budget exhausted
|
||||
|
||||
tasks_created = []
|
||||
original = asyncio.create_task
|
||||
asyncio.create_task = lambda coro: tasks_created.append(coro) or original(coro)
|
||||
|
||||
await engine(over_budget_packet, metadata={})
|
||||
|
||||
assert not tasks_created, "No task should be created when advisory check fails"
|
||||
```
|
||||
|
||||
### Integration / field tests (with hardware)
|
||||
|
||||
**T5 — Burst scenario: 5 packets arrive within the same delay window**
|
||||
|
||||
1. Connect the repeater to a radio.
|
||||
2. Using a second node, send 5 FLOOD packets in quick succession (< 100 ms apart)
|
||||
with a low RSSI score so the repeater's delay is ~1–2 s for all of them.
|
||||
3. Monitor the radio with a spectrum analyser or a third node running in monitor
|
||||
mode.
|
||||
|
||||
**Expected (after this fix):**
|
||||
- Transmissions are sequential — no overlapping on-air signals.
|
||||
- `Retransmitted packet` log lines appear one after another, each with a non-zero
|
||||
airtime value.
|
||||
- No `Retransmit failed` errors in the log.
|
||||
- Duty-cycle log shows airtime accumulating correctly.
|
||||
|
||||
**Expected (before this fix, to confirm the bug existed):**
|
||||
- Occasional `Retransmit failed` errors under burst load.
|
||||
- Airtime tracking diverging from actual on-air time (double-counted or missed).
|
||||
|
||||
**T6 — Duty-cycle enforcement under burst**
|
||||
|
||||
1. Set `max_airtime_per_minute` to a low value (e.g. 500 ms) in config.
|
||||
2. Send 10 packets rapidly so the repeater tries to forward all 10.
|
||||
3. Observe logs.
|
||||
|
||||
**Expected:**
|
||||
- First N packets transmitted (total airtime ≤ 500 ms).
|
||||
- Subsequent packets log `"Packet dropped at TX time: duty-cycle exceeded"` from
|
||||
inside `delayed_send` (not just the advisory drop).
|
||||
- `airtime_mgr.get_stats()["utilization_percent"]` reads ≤ 100%.
|
||||
|
||||
**T7 — Normal single-packet forwarding not regressed**
|
||||
|
||||
1. Send one packet every 5 seconds (well within duty-cycle budget).
|
||||
2. Verify each packet is forwarded with correct airtime logged.
|
||||
3. Verify no lock contention warnings in the log.
|
||||
|
||||
**T8 — Local TX retry path (local_transmission=True) still works**
|
||||
|
||||
1. Send a command that triggers a local transmission (e.g. a ping reply).
|
||||
2. Briefly block the radio (simulate with a mock) so the first attempt fails.
|
||||
3. Verify the retry fires after 1 s and the packet is eventually transmitted.
|
||||
|
||||
---
|
||||
|
||||
## Proof of Correctness
|
||||
|
||||
### Why `asyncio.Lock` is sufficient (no OS-level synchronisation needed)
|
||||
|
||||
Python's asyncio event loop is **single-threaded**. All coroutines share one
|
||||
thread and only yield execution at `await` points. Between two consecutive
|
||||
`await` calls in a coroutine, the event loop does not switch to another coroutine.
|
||||
|
||||
`asyncio.Lock.acquire()` suspends the current coroutine if the lock is held,
|
||||
returning control to the event loop. `asyncio.Lock.release()` wakes the next
|
||||
waiter. Because `send_packet` is awaited inside the lock, no other TX task can
|
||||
run until the current one releases the lock and the event loop gets a chance to
|
||||
schedule the next waiter.
|
||||
|
||||
There is no possibility of the race seen with `threading.Lock` where an OS thread
|
||||
can be preempted mid-instruction.
|
||||
|
||||
### Why the advisory check in `__call__` cannot be removed
|
||||
|
||||
The advisory check is still necessary as a fast path. If it were removed, every
|
||||
incoming packet — even when the node is clearly at 100% duty-cycle — would
|
||||
schedule a `delayed_send` task that would sleep for the full TX delay (up to
|
||||
several seconds) before the lock drops it. Under a sustained flood of incoming
|
||||
packets this wastes memory and CPU. The advisory check prunes the queue early at
|
||||
negligible cost.
|
||||
|
||||
### Why `record_tx()` must be inside the lock (not before or after)
|
||||
|
||||
- **Before the send:** records airtime for a packet that may never be transmitted
|
||||
(send could fail, LBT could reject it). Budget is overcounted.
|
||||
- **After releasing the lock:** a second task could pass the authoritative
|
||||
`can_transmit()` check between `send_packet` returning and `record_tx()` being
|
||||
called — the TOCTOU window reopens at a smaller scale.
|
||||
- **Inside the lock, after a successful send:** the budget is debited exactly once
|
||||
for exactly the packets that were actually transmitted. The lock ensures no
|
||||
other task reads airtime state between the check and the debit.
|
||||
@@ -4,12 +4,73 @@
|
||||
set -e
|
||||
|
||||
INSTALL_DIR="/opt/pymc_repeater"
|
||||
VENV_DIR="$INSTALL_DIR/venv"
|
||||
VENV_PIP="$VENV_DIR/bin/pip"
|
||||
VENV_PYTHON="$VENV_DIR/bin/python"
|
||||
CONFIG_DIR="/etc/pymc_repeater"
|
||||
LOG_DIR="/var/log/pymc_repeater"
|
||||
SERVICE_USER="repeater"
|
||||
SERVICE_NAME="pymc-repeater"
|
||||
SILENT_MODE="${PYMC_SILENT:-${SILENT:-}}"
|
||||
|
||||
# R2 Wheels Configuration improves install speed on ARM devices
|
||||
R2_BASE_URL="https://wheel.pymc.dev/pymc_build_deps"
|
||||
R2_ENABLED=1 # Set to 0 to disable R2 wheels and always build from source
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Virtual-environment helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Create (or re-create) the dedicated venv for pymc_repeater
|
||||
ensure_venv() {
|
||||
if [ ! -x "$VENV_PYTHON" ]; then
|
||||
echo ">>> Creating virtual environment at $VENV_DIR ..."
|
||||
python3 -m venv --system-site-packages "$VENV_DIR"
|
||||
# Upgrade pip inside the venv
|
||||
"$VENV_PIP" install --upgrade pip setuptools wheel >/dev/null 2>&1 || true
|
||||
fi
|
||||
}
|
||||
|
||||
# Migrate an existing system-pip install into the venv.
|
||||
# Idempotent: safe to call on every upgrade.
|
||||
migrate_to_venv() {
|
||||
echo ">>> Checking for legacy system-pip installation..."
|
||||
|
||||
# 1. Ensure the venv exists
|
||||
ensure_venv
|
||||
|
||||
# 2. Remove legacy PYTHONPATH from the service unit
|
||||
local svc_unit="/etc/systemd/system/pymc-repeater.service"
|
||||
if [ -f "$svc_unit" ]; then
|
||||
if grep -q 'PYTHONPATH' "$svc_unit" 2>/dev/null; then
|
||||
sed -i '/^Environment=.*PYTHONPATH/d' "$svc_unit"
|
||||
echo " ✓ Removed legacy PYTHONPATH from service unit"
|
||||
fi
|
||||
# 3. Fix WorkingDirectory if still pointing at old source
|
||||
if grep -q 'WorkingDirectory=/opt/pymc_repeater' "$svc_unit" 2>/dev/null; then
|
||||
sed -i 's|WorkingDirectory=/opt/pymc_repeater|WorkingDirectory=/var/lib/pymc_repeater|' "$svc_unit"
|
||||
echo " ✓ Fixed WorkingDirectory in service unit"
|
||||
fi
|
||||
# 4. Ensure ExecStart uses the venv python
|
||||
if grep -q 'ExecStart=/usr/bin/python3' "$svc_unit" 2>/dev/null; then
|
||||
sed -i "s|ExecStart=/usr/bin/python3|ExecStart=$VENV_PYTHON|" "$svc_unit"
|
||||
echo " ✓ Updated ExecStart to use venv python"
|
||||
fi
|
||||
systemctl daemon-reload
|
||||
fi
|
||||
|
||||
# 5. Remove the package from system python (best-effort)
|
||||
python3 -m pip uninstall -y pymc_repeater 2>/dev/null || true
|
||||
python3 -m pip uninstall -y pymc_core 2>/dev/null || true
|
||||
echo " ✓ Cleaned up system-level packages (if any)"
|
||||
|
||||
# 6. Remove stale source trees that could shadow the venv package
|
||||
if [ -d "$INSTALL_DIR/repeater" ]; then
|
||||
rm -rf "$INSTALL_DIR/repeater"
|
||||
echo " ✓ Removed stale source tree from $INSTALL_DIR/repeater"
|
||||
fi
|
||||
}
|
||||
|
||||
is_silent_flag() {
|
||||
case "${1:-}" in
|
||||
--silent|-y|silent) return 0 ;;
|
||||
@@ -96,13 +157,14 @@ is_enabled() {
|
||||
|
||||
# Function to get current version
|
||||
get_version() {
|
||||
# Try to read from _version.py first (generated by setuptools_scm)
|
||||
if [ -f "$INSTALL_DIR/repeater/_version.py" ]; then
|
||||
grep "^__version__ = version = " "$INSTALL_DIR/repeater/_version.py" | cut -d"'" -f2 2>/dev/null || echo "unknown"
|
||||
elif [ -f "$INSTALL_DIR/pyproject.toml" ]; then
|
||||
grep "^version" "$INSTALL_DIR/pyproject.toml" | cut -d'"' -f2 2>/dev/null || echo "unknown"
|
||||
# Read version from the pip-installed package in the venv
|
||||
if [ -x "$VENV_PYTHON" ]; then
|
||||
"$VENV_PYTHON" -c "from importlib.metadata import version; print(version('pymc_repeater'))" 2>/dev/null \
|
||||
|| echo "not installed"
|
||||
else
|
||||
echo "not installed"
|
||||
# Fallback: try system python for pre-migration installs
|
||||
python3 -c "from importlib.metadata import version; print(version('pymc_repeater'))" 2>/dev/null \
|
||||
|| echo "not installed"
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -201,8 +263,10 @@ install_repeater() {
|
||||
return
|
||||
fi
|
||||
|
||||
# Welcome screen
|
||||
$DIALOG --backtitle "pyMC Repeater Management" --title "Welcome" --msgbox "\nWelcome to pyMC Repeater Setup\n\nThis installer will configure your Linux system as a LoRa mesh network repeater.\n\nPress OK to continue..." 12 70
|
||||
# Welcome screen (Bypass if the script was passd with the "install" option, assume we want a silent install)
|
||||
if [[ "${1:-}" != "install" ]]; then
|
||||
$DIALOG --backtitle "pyMC Repeater Management" --title "Welcome" --msgbox "\nWelcome to pyMC Repeater Setup\n\nThis installer will configure your Linux system as a LoRa mesh network repeater.\n\nPress OK to continue..." 12 70
|
||||
fi
|
||||
|
||||
# SPI Check - Universal approach that works on all boards (skip for CH341 USB-SPI adapter)
|
||||
SPI_MISSING=0
|
||||
@@ -277,12 +341,16 @@ install_repeater() {
|
||||
|
||||
echo "25"; echo "# Installing system dependencies..."
|
||||
apt-get update -qq
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y libffi-dev libusb-1.0-0 sudo jq pip python3-rrdtool wget swig build-essential python3-dev
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y libffi-dev libusb-1.0-0 sudo jq pip python3-venv python3-rrdtool wget swig build-essential python3-dev
|
||||
# Install polkit (package name varies by distro version)
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y policykit-1 2>/dev/null \
|
||||
|| DEBIAN_FRONTEND=noninteractive apt-get install -y polkitd pkexec 2>/dev/null \
|
||||
|| echo " Warning: Could not install polkit (sudo fallback will be used)"
|
||||
pip install --break-system-packages setuptools_scm >/dev/null 2>&1 || true
|
||||
# setuptools_scm needed for git version detection during build
|
||||
pip install --break-system-packages setuptools_scm >/dev/null 2>&1 || python3 -m pip install --break-system-packages setuptools_scm >/dev/null 2>&1 || true
|
||||
|
||||
echo "28"; echo "# Creating virtual environment..."
|
||||
ensure_venv
|
||||
|
||||
# Install mikefarah yq v4 if not already installed
|
||||
if ! command -v yq &> /dev/null || [[ "$(yq --version 2>&1)" != *"mikefarah/yq"* ]]; then
|
||||
@@ -297,31 +365,7 @@ install_repeater() {
|
||||
wget -qO /usr/local/bin/yq "https://github.com/mikefarah/yq/releases/download/${YQ_VERSION}/${YQ_BINARY}" 2>/dev/null && chmod +x /usr/local/bin/yq
|
||||
fi
|
||||
|
||||
echo "28"; echo "# Generating version file..."
|
||||
cd "$SCRIPT_DIR"
|
||||
# Generate version file using setuptools_scm before copying
|
||||
if [ -d .git ]; then
|
||||
git fetch --tags >/dev/null 2>&1 || true
|
||||
# Write the version file that will be copied
|
||||
python3 -m setuptools_scm >/dev/null 2>&1 || true
|
||||
python3 -c "from setuptools_scm import get_version; get_version(write_to='repeater/_version.py')" >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
# Clean up stale bytecode in source directory before copying
|
||||
find "$SCRIPT_DIR/repeater" -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
|
||||
find "$SCRIPT_DIR/repeater" -type f -name '*.pyc' -delete 2>/dev/null || true
|
||||
|
||||
echo "29"; echo "# Cleaning old installation files..."
|
||||
# Remove old repeater directory to ensure clean install
|
||||
rm -rf "$INSTALL_DIR/repeater" 2>/dev/null || true
|
||||
# Clean up old Python bytecode
|
||||
find "$INSTALL_DIR" -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
|
||||
find "$INSTALL_DIR" -type f -name '*.pyc' -delete 2>/dev/null || true
|
||||
|
||||
echo "30"; echo "# Installing files..."
|
||||
cp -r "$SCRIPT_DIR/repeater" "$INSTALL_DIR/"
|
||||
cp "$SCRIPT_DIR/pyproject.toml" "$INSTALL_DIR/"
|
||||
cp "$SCRIPT_DIR/README.md" "$INSTALL_DIR/"
|
||||
echo "29"; echo "# Installing files..."
|
||||
cp "$SCRIPT_DIR/manage.sh" "$INSTALL_DIR/" 2>/dev/null || true
|
||||
cp "$SCRIPT_DIR/pymc-repeater.service" "$INSTALL_DIR/" 2>/dev/null || true
|
||||
cp "$SCRIPT_DIR/radio-settings.json" /var/lib/pymc_repeater/ 2>/dev/null || true
|
||||
@@ -345,8 +389,12 @@ install_repeater() {
|
||||
fi
|
||||
|
||||
echo "65"; echo "# Setting permissions..."
|
||||
chown -R "$SERVICE_USER:$SERVICE_USER" "$INSTALL_DIR" "$CONFIG_DIR" "$LOG_DIR" /var/lib/pymc_repeater
|
||||
# Venv stays root-owned (pip runs as root); service user only needs read+execute
|
||||
chown -R "$SERVICE_USER:$SERVICE_USER" "$CONFIG_DIR" "$LOG_DIR" /var/lib/pymc_repeater
|
||||
chmod 750 "$CONFIG_DIR" "$LOG_DIR" /var/lib/pymc_repeater
|
||||
# Ensure manage.sh and support files in INSTALL_DIR are accessible
|
||||
chown root:root "$INSTALL_DIR"
|
||||
chmod 755 "$INSTALL_DIR"
|
||||
# Ensure the service user can create subdirectories in their home directory
|
||||
chmod 755 /var/lib/pymc_repeater
|
||||
# Pre-create the .config directory that the service will need
|
||||
@@ -354,9 +402,15 @@ install_repeater() {
|
||||
chown -R "$SERVICE_USER:$SERVICE_USER" /var/lib/pymc_repeater/.config
|
||||
|
||||
# Configure polkit for passwordless service restart
|
||||
echo ">>> Configuring polkit for service management..."
|
||||
mkdir -p /etc/polkit-1/rules.d
|
||||
cat > /etc/polkit-1/rules.d/10-pymc-repeater.rules <<'EOF'
|
||||
|
||||
# Work out which version of polkit is installed
|
||||
|
||||
POLKIT_VERSION=$(pkaction --version 2>/dev/null | awk '{print $NF}')
|
||||
if echo "$POLKIT_VERSION" | awk '{ exit ($1 > 0.105) ? 0 : 1 }'; then
|
||||
echo "Polkit 0.106 or greater detected, using rules file"
|
||||
echo ">>> Configuring polkit for service management..."
|
||||
mkdir -p /etc/polkit-1/rules.d
|
||||
cat > /etc/polkit-1/rules.d/10-pymc-repeater.rules <<'EOF'
|
||||
polkit.addRule(function(action, subject) {
|
||||
if (action.id == "org.freedesktop.systemd1.manage-units" &&
|
||||
action.lookup("unit") == "pymc-repeater.service" &&
|
||||
@@ -365,7 +419,20 @@ polkit.addRule(function(action, subject) {
|
||||
}
|
||||
});
|
||||
EOF
|
||||
chmod 0644 /etc/polkit-1/rules.d/10-pymc-repeater.rules
|
||||
chmod 0644 /etc/polkit-1/rules.d/10-pymc-repeater.rules
|
||||
else
|
||||
echo "Polkit 0.105 or less detected, using pkla file"
|
||||
mkdir -p /etc/polkit-1/localauthority/50-local.d
|
||||
cat > /etc/polkit-1/localauthority/50-local.d/10-pymc-repeater.pkla <<'EOF'
|
||||
[Allow repeater to restart pymc-repeater service]
|
||||
Identity=unix-user:repeater
|
||||
Action=org.freedesktop.systemd1.manage-units
|
||||
ResultAny=yes
|
||||
ResultInactive=yes
|
||||
ResultActive=yes
|
||||
EOF
|
||||
chmod 0644 /etc/polkit-1/localauthority/50-local.d/10-pymc-repeater.pkla
|
||||
fi
|
||||
|
||||
# Also configure sudoers as fallback for service restart
|
||||
echo ">>> Configuring sudoers for service management..."
|
||||
@@ -380,19 +447,65 @@ EOF
|
||||
cat > /usr/local/bin/pymc-do-upgrade <<'UPGRADEEOF'
|
||||
#!/bin/bash
|
||||
# pymc-do-upgrade: invoked by the repeater service user via sudo for OTA upgrades.
|
||||
# Usage: sudo /usr/local/bin/pymc-do-upgrade [channel]
|
||||
# Usage: sudo /usr/local/bin/pymc-do-upgrade [channel] [pretend-version]
|
||||
set -e
|
||||
CHANNEL="${1:-main}"
|
||||
PRETEND_VERSION="${2:-}"
|
||||
VENV_DIR="/opt/pymc_repeater/venv"
|
||||
VENV_PIP="$VENV_DIR/bin/pip"
|
||||
VENV_PYTHON="$VENV_DIR/bin/python"
|
||||
# Validate: only allow safe git ref characters
|
||||
if ! [[ "$CHANNEL" =~ ^[a-zA-Z0-9._/-]{1,80}$ ]]; then
|
||||
echo "Invalid channel name: $CHANNEL" >&2
|
||||
exit 1
|
||||
fi
|
||||
export PIP_ROOT_USER_ACTION=ignore
|
||||
exec python3 -m pip install \
|
||||
--break-system-packages \
|
||||
# If caller supplied a version string, tell setuptools_scm to use it (sudo
|
||||
# strips env vars so it is passed as a positional argument instead).
|
||||
[ -n "$PRETEND_VERSION" ] && export SETUPTOOLS_SCM_PRETEND_VERSION="$PRETEND_VERSION"
|
||||
# ---- Migration: ensure venv exists (handles upgrades from system-pip era) ----
|
||||
if [ ! -x "$VENV_PYTHON" ]; then
|
||||
echo "[pymc-do-upgrade] Creating venv at $VENV_DIR ..."
|
||||
python3 -m venv --system-site-packages "$VENV_DIR"
|
||||
"$VENV_PIP" install --upgrade pip setuptools wheel >/dev/null 2>&1 || true
|
||||
fi
|
||||
# ---- Migration: clean up legacy service unit issues ----
|
||||
SVC_UNIT=/etc/systemd/system/pymc-repeater.service
|
||||
if grep -q 'PYTHONPATH' "$SVC_UNIT" 2>/dev/null; then
|
||||
sed -i '/^Environment=.*PYTHONPATH/d' "$SVC_UNIT"
|
||||
systemctl daemon-reload
|
||||
fi
|
||||
if grep -q 'WorkingDirectory=/opt/pymc_repeater' "$SVC_UNIT" 2>/dev/null; then
|
||||
sed -i 's|WorkingDirectory=/opt/pymc_repeater|WorkingDirectory=/var/lib/pymc_repeater|' "$SVC_UNIT"
|
||||
systemctl daemon-reload
|
||||
fi
|
||||
if grep -q 'ExecStart=/usr/bin/python3' "$SVC_UNIT" 2>/dev/null; then
|
||||
sed -i "s|ExecStart=/usr/bin/python3|ExecStart=$VENV_PYTHON|" "$SVC_UNIT"
|
||||
systemctl daemon-reload
|
||||
fi
|
||||
# ---- Remove stale source trees that shadow the venv package ----
|
||||
[ -d /opt/pymc_repeater/repeater ] && rm -rf /opt/pymc_repeater/repeater
|
||||
# ---- Remove old system-level packages to avoid confusion ----
|
||||
python3 -m pip uninstall -y pymc_repeater 2>/dev/null || true
|
||||
python3 -m pip uninstall -y pymc_core 2>/dev/null || true
|
||||
# ---- Try R2 wheels first for faster OTA upgrades ----
|
||||
R2_BASE_URL="https://wheel.pymc.dev/pymc_build_deps"
|
||||
MACHINE_ARCH=$(uname -m)
|
||||
case "$MACHINE_ARCH" in
|
||||
aarch64) ARCH_TAG="arm64"; PLATFORM_TAG="aarch64" ;;
|
||||
armv7l|armv7) ARCH_TAG="armv7"; PLATFORM_TAG="armv7l" ;;
|
||||
x86_64) ARCH_TAG="x86_64"; PLATFORM_TAG="x86_64" ;;
|
||||
*) ARCH_TAG=""; PLATFORM_TAG="" ;;
|
||||
esac
|
||||
if [ -n "$ARCH_TAG" ]; then
|
||||
PY_TAG=$("$VENV_PYTHON" -c 'import sys; v=f"cp{sys.version_info.major}{sys.version_info.minor}"; print(f"{v}-{v}")' 2>/dev/null || echo "cp311-cp311")
|
||||
WHEEL_BASE="${R2_BASE_URL}/${ARCH_TAG}/${PLATFORM_TAG}/${PY_TAG}"
|
||||
echo "[pymc-do-upgrade] Trying dependencies from R2 wheels..."
|
||||
"$VENV_PIP" install --find-links "${WHEEL_BASE}/index.html" --no-cache-dir "pycryptodome>=3.23.0" "PyNaCl>=1.5.0" cffi "pyyaml>=6.0.0" 2>/dev/null || true
|
||||
fi
|
||||
# ---- Install pymc_repeater from git ----
|
||||
exec "$VENV_PIP" install \
|
||||
--upgrade \
|
||||
--no-cache-dir \
|
||||
--force-reinstall \
|
||||
"pymc_repeater[hardware] @ git+https://github.com/rightup/pyMC_Repeater.git@${CHANNEL}"
|
||||
UPGRADEEOF
|
||||
chmod 0755 /usr/local/bin/pymc-do-upgrade
|
||||
@@ -407,16 +520,13 @@ UPGRADEEOF
|
||||
clear
|
||||
echo "=== Installing Python Dependencies ==="
|
||||
echo ""
|
||||
echo "Installing pymc_repeater and dependencies (including pymc_core from GitHub)..."
|
||||
echo "Installing pymc_repeater and dependencies (including pymc_core from PyPI)..."
|
||||
echo "This may take a few minutes..."
|
||||
echo ""
|
||||
|
||||
SCRIPT_DIR="$(dirname "$0")"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
# Suppress pip root user warnings
|
||||
export PIP_ROOT_USER_ACTION=ignore
|
||||
|
||||
# Calculate version from git for setuptools_scm
|
||||
if [ -d .git ]; then
|
||||
git fetch --tags 2>/dev/null || true
|
||||
@@ -426,16 +536,43 @@ UPGRADEEOF
|
||||
else
|
||||
export SETUPTOOLS_SCM_PRETEND_VERSION="1.0.5"
|
||||
fi
|
||||
|
||||
# Force binary wheels for slow-to-compile packages (much faster on Raspberry Pi)
|
||||
export PIP_ONLY_BINARY=pycryptodome,cffi,PyNaCl,psutil
|
||||
# We don't have any binary wheels available for these on a LuckFox, so we need to ignore them on that platform.
|
||||
if ! grep -q "Luckfox Pico" /proc/device-tree/model 2>/dev/null; then
|
||||
# Force binary wheels for slow-to-compile packages (much faster on Raspberry Pi)
|
||||
export PIP_ONLY_BINARY=pycryptodome,cffi,PyNaCl,psutil
|
||||
fi
|
||||
echo "Note: Using optimized binary wheels for faster installation"
|
||||
echo ""
|
||||
|
||||
# Install with --force-reinstall to ensure fresh pymc_core from GitHub
|
||||
# This reads pymc_core Git URL from pyproject.toml and reinstalls all dependencies
|
||||
echo "Installing pymc_repeater with fresh dependencies from pyproject.toml..."
|
||||
if python3 -m pip install --break-system-packages --no-cache-dir --force-reinstall .[hardware]; then
|
||||
# Ensure venv exists
|
||||
ensure_venv
|
||||
|
||||
echo "Installing pymc_repeater into venv ($VENV_DIR)..."
|
||||
|
||||
# Attempt R2 wheels first for faster installation
|
||||
if [ "$R2_ENABLED" -eq 1 ]; then
|
||||
MACHINE_ARCH=$(uname -m)
|
||||
case "$MACHINE_ARCH" in
|
||||
aarch64) ARCH_TAG="arm64"; PLATFORM_TAG="aarch64" ;;
|
||||
armv7l|armv7) ARCH_TAG="armv7"; PLATFORM_TAG="armv7l" ;;
|
||||
x86_64) ARCH_TAG="x86_64"; PLATFORM_TAG="x86_64" ;;
|
||||
*) ARCH_TAG=""; PLATFORM_TAG="" ;;
|
||||
esac
|
||||
if [ -n "$ARCH_TAG" ]; then
|
||||
PY_TAG=$("$VENV_PYTHON" -c 'import sys; v=f"cp{sys.version_info.major}{sys.version_info.minor}"; print(f"{v}-{v}")' 2>/dev/null || echo "cp311-cp311")
|
||||
WHEEL_BASE="${R2_BASE_URL}/${ARCH_TAG}/${PLATFORM_TAG}/${PY_TAG}"
|
||||
echo " Checking for R2 wheels (${ARCH_TAG}/${PLATFORM_TAG}/${PY_TAG})..."
|
||||
echo " Trying install from R2 pre-built wheels..."
|
||||
"$VENV_PIP" install --find-links "${WHEEL_BASE}/index.html" --no-cache-dir "pycryptodome>=3.23.0" "PyNaCl>=1.5.0" cffi "pyyaml>=6.0.0" 2>/dev/null && R2_SUCCESS=1 || R2_SUCCESS=0
|
||||
if [ "$R2_SUCCESS" -eq 1 ]; then
|
||||
echo " ✓ R2 wheels installed"
|
||||
else
|
||||
echo " - R2 wheels unavailable for this platform/tag, falling back"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
if "$VENV_PIP" install --upgrade --no-cache-dir .[hardware]; then
|
||||
echo ""
|
||||
echo "✓ Python package installation completed successfully!"
|
||||
|
||||
@@ -494,7 +631,9 @@ UPGRADEEOF
|
||||
fi
|
||||
echo "═══════════════════════════════════════════════════════════════"
|
||||
echo ""
|
||||
read -p "Press Enter to return to main menu..." || true
|
||||
if [[ "${1:-}" != "install" ]]; then #Headless install support
|
||||
read -p "Press Enter to return to main menu..." || true
|
||||
fi
|
||||
else
|
||||
show_error "Installation completed but service failed to start!\n\nCheck logs from the main menu for details."
|
||||
fi
|
||||
@@ -607,12 +746,12 @@ upgrade_repeater() {
|
||||
echo "[3/9] Updating system dependencies..."
|
||||
apt-get update -qq
|
||||
|
||||
apt-get install -y libffi-dev libusb-1.0-0 sudo jq pip python3-rrdtool wget swig build-essential python3-dev
|
||||
apt-get install -y libffi-dev libusb-1.0-0 sudo jq pip python3-venv python3-rrdtool wget swig build-essential python3-dev
|
||||
# Install polkit (package name varies by distro version)
|
||||
apt-get install -y policykit-1 2>/dev/null \
|
||||
|| apt-get install -y polkitd pkexec 2>/dev/null \
|
||||
|| echo " Warning: Could not install polkit (sudo fallback will be used)"
|
||||
pip install --break-system-packages setuptools_scm >/dev/null 2>&1 || true
|
||||
pip install --break-system-packages setuptools_scm >/dev/null 2>&1 || python3 -m pip install --break-system-packages setuptools_scm >/dev/null 2>&1 || true
|
||||
|
||||
# Install mikefarah yq v4 if not already installed
|
||||
if ! command -v yq &> /dev/null || [[ "$(yq --version 2>&1)" != *"mikefarah/yq"* ]]; then
|
||||
@@ -627,37 +766,13 @@ upgrade_repeater() {
|
||||
fi
|
||||
echo " ✓ Dependencies updated"
|
||||
|
||||
echo "[3.5/9] Generating version file..."
|
||||
echo "[4/9] Installing files..."
|
||||
SCRIPT_DIR="$(dirname "$0")"
|
||||
cd "$SCRIPT_DIR"
|
||||
# Generate version file using setuptools_scm before copying
|
||||
if [ -d .git ]; then
|
||||
git fetch --tags 2>/dev/null || true
|
||||
# Write the version file that will be copied
|
||||
GENERATED_VERSION=$(python3 -m setuptools_scm 2>&1 || echo "unknown (setuptools_scm not available)")
|
||||
python3 -c "from setuptools_scm import get_version; get_version(write_to='repeater/_version.py')" 2>&1 || echo " Warning: Could not generate _version.py file"
|
||||
echo " Generated version: $GENERATED_VERSION"
|
||||
if ! cp "$SCRIPT_DIR/pymc-repeater.service" /etc/systemd/system/; then
|
||||
echo " ⚠ Warning: Failed to update service file – old service file may remain"
|
||||
fi
|
||||
# Clean up stale bytecode in source directory before copying
|
||||
find "$SCRIPT_DIR/repeater" -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
|
||||
find "$SCRIPT_DIR/repeater" -type f -name '*.pyc' -delete 2>/dev/null || true
|
||||
echo " ✓ Version file generated and bytecode cleaned"
|
||||
|
||||
echo "[3.8/9] Cleaning old installation files..."
|
||||
# Remove old repeater directory to ensure clean upgrade
|
||||
rm -rf "$INSTALL_DIR/repeater" 2>/dev/null || true
|
||||
# Clean up old Python bytecode
|
||||
find "$INSTALL_DIR" -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
|
||||
find "$INSTALL_DIR" -type f -name '*.pyc' -delete 2>/dev/null || true
|
||||
echo " ✓ Old files cleaned"
|
||||
|
||||
echo "[4/9] Installing new files..."
|
||||
cp -r repeater "$INSTALL_DIR/" 2>/dev/null || true
|
||||
cp pyproject.toml "$INSTALL_DIR/" 2>/dev/null || true
|
||||
cp README.md "$INSTALL_DIR/" 2>/dev/null || true
|
||||
cp pymc-repeater.service /etc/systemd/system/ 2>/dev/null || true
|
||||
cp radio-settings.json /var/lib/pymc_repeater/ 2>/dev/null || true
|
||||
cp radio-presets.json /var/lib/pymc_repeater/ 2>/dev/null || true
|
||||
cp "$SCRIPT_DIR/radio-settings.json" /var/lib/pymc_repeater/ 2>/dev/null || true
|
||||
cp "$SCRIPT_DIR/radio-presets.json" /var/lib/pymc_repeater/ 2>/dev/null || true
|
||||
echo " ✓ Files updated"
|
||||
|
||||
echo "[5/9] Validating and updating configuration..."
|
||||
@@ -684,15 +799,25 @@ upgrade_repeater() {
|
||||
echo " ✓ User groups updated"
|
||||
|
||||
echo "[6/9] Fixing permissions..."
|
||||
chown -R "$SERVICE_USER:$SERVICE_USER" "$INSTALL_DIR" "$CONFIG_DIR" "$LOG_DIR" /var/lib/pymc_repeater 2>/dev/null || true
|
||||
|
||||
# Venv stays root-owned (pip runs as root); service user only needs read+execute
|
||||
chown -R "$SERVICE_USER:$SERVICE_USER" "$CONFIG_DIR" "$LOG_DIR" /var/lib/pymc_repeater 2>/dev/null || true
|
||||
chown root:root "$INSTALL_DIR" 2>/dev/null || true
|
||||
chmod 755 "$INSTALL_DIR" 2>/dev/null || true
|
||||
chmod 750 "$CONFIG_DIR" "$LOG_DIR" 2>/dev/null || true
|
||||
chmod 755 /var/lib/pymc_repeater 2>/dev/null || true
|
||||
|
||||
# Pre-create the .config directory that the service will need
|
||||
mkdir -p /var/lib/pymc_repeater/.config/pymc_repeater 2>/dev/null || true
|
||||
chown -R "$SERVICE_USER:$SERVICE_USER" /var/lib/pymc_repeater/.config 2>/dev/null || true
|
||||
|
||||
# Configure polkit for passwordless service restart
|
||||
mkdir -p /etc/polkit-1/rules.d
|
||||
cat > /etc/polkit-1/rules.d/10-pymc-repeater.rules <<'EOF'
|
||||
POLKIT_VERSION=$(pkaction --version 2>/dev/null | awk '{print $NF}')
|
||||
if echo "$POLKIT_VERSION" | awk '{ exit ($1 > 0.105) ? 0 : 1 }'; then
|
||||
echo "Polkit 0.106 or greater detected, using rules file"
|
||||
echo ">>> Configuring polkit for service management..."
|
||||
mkdir -p /etc/polkit-1/rules.d
|
||||
cat > /etc/polkit-1/rules.d/10-pymc-repeater.rules <<'EOF'
|
||||
polkit.addRule(function(action, subject) {
|
||||
if (action.id == "org.freedesktop.systemd1.manage-units" &&
|
||||
action.lookup("unit") == "pymc-repeater.service" &&
|
||||
@@ -701,7 +826,20 @@ polkit.addRule(function(action, subject) {
|
||||
}
|
||||
});
|
||||
EOF
|
||||
chmod 0644 /etc/polkit-1/rules.d/10-pymc-repeater.rules
|
||||
chmod 0644 /etc/polkit-1/rules.d/10-pymc-repeater.rules
|
||||
else
|
||||
echo "Polkit 0.105 or less detected, using pkla file"
|
||||
mkdir -p /etc/polkit-1/localauthority/50-local.d
|
||||
cat > /etc/polkit-1/localauthority/50-local.d/10-pymc-repeater.pkla <<'EOF'
|
||||
[Allow repeater to restart pymc-repeater service]
|
||||
Identity=unix-user:repeater
|
||||
Action=org.freedesktop.systemd1.manage-units
|
||||
ResultAny=yes
|
||||
ResultInactive=yes
|
||||
ResultActive=yes
|
||||
EOF
|
||||
chmod 0644 /etc/polkit-1/localauthority/50-local.d/10-pymc-repeater.pkla
|
||||
fi
|
||||
# Also configure sudoers as fallback for service restart
|
||||
mkdir -p /etc/sudoers.d
|
||||
cat > /etc/sudoers.d/pymc-repeater <<'EOF'
|
||||
@@ -713,20 +851,66 @@ EOF
|
||||
cat > /usr/local/bin/pymc-do-upgrade <<'UPGRADEEOF'
|
||||
#!/bin/bash
|
||||
# pymc-do-upgrade: invoked by the repeater service user via sudo for OTA upgrades.
|
||||
# Usage: sudo /usr/local/bin/pymc-do-upgrade [channel]
|
||||
# Usage: sudo /usr/local/bin/pymc-do-upgrade [channel] [pretend-version]
|
||||
set -e
|
||||
CHANNEL="${1:-main}"
|
||||
PRETEND_VERSION="${2:-}"
|
||||
VENV_DIR="/opt/pymc_repeater/venv"
|
||||
VENV_PIP="$VENV_DIR/bin/pip"
|
||||
VENV_PYTHON="$VENV_DIR/bin/python"
|
||||
# Validate: only allow safe git ref characters
|
||||
if ! [[ "$CHANNEL" =~ ^[a-zA-Z0-9._/-]{1,80}$ ]]; then
|
||||
echo "Invalid channel name: $CHANNEL" >&2
|
||||
exit 1
|
||||
fi
|
||||
export PIP_ROOT_USER_ACTION=ignore
|
||||
exec python3 -m pip install \
|
||||
--break-system-packages \
|
||||
--no-cache-dir \
|
||||
--force-reinstall \
|
||||
"pymc_repeater[hardware] @ git+https://github.com/rightup/pyMC_Repeater.git@${CHANNEL}"
|
||||
# If caller supplied a version string, tell setuptools_scm to use it (sudo
|
||||
# strips env vars so it is passed as a positional argument instead).
|
||||
[ -n "$PRETEND_VERSION" ] && export SETUPTOOLS_SCM_PRETEND_VERSION="$PRETEND_VERSION"
|
||||
# ---- Migration: ensure venv exists (handles upgrades from system-pip era) ----
|
||||
if [ ! -x "$VENV_PYTHON" ]; then
|
||||
echo "[pymc-do-upgrade] Creating venv at $VENV_DIR ..."
|
||||
python3 -m venv --system-site-packages "$VENV_DIR"
|
||||
"$VENV_PIP" install --upgrade pip setuptools wheel >/dev/null 2>&1 || true
|
||||
fi
|
||||
# ---- Migration: clean up legacy service unit issues ----
|
||||
SVC_UNIT=/etc/systemd/system/pymc-repeater.service
|
||||
if grep -q 'PYTHONPATH' "$SVC_UNIT" 2>/dev/null; then
|
||||
sed -i '/^Environment=.*PYTHONPATH/d' "$SVC_UNIT"
|
||||
systemctl daemon-reload
|
||||
fi
|
||||
if grep -q 'WorkingDirectory=/opt/pymc_repeater' "$SVC_UNIT" 2>/dev/null; then
|
||||
sed -i 's|WorkingDirectory=/opt/pymc_repeater|WorkingDirectory=/var/lib/pymc_repeater|' "$SVC_UNIT"
|
||||
systemctl daemon-reload
|
||||
fi
|
||||
if grep -q 'ExecStart=/usr/bin/python3' "$SVC_UNIT" 2>/dev/null; then
|
||||
sed -i "s|ExecStart=/usr/bin/python3|ExecStart=$VENV_PYTHON|" "$SVC_UNIT"
|
||||
systemctl daemon-reload
|
||||
fi
|
||||
# ---- Remove stale source trees that shadow the venv package ----
|
||||
[ -d /opt/pymc_repeater/repeater ] && rm -rf /opt/pymc_repeater/repeater
|
||||
# ---- Remove old system-level packages to avoid confusion ----
|
||||
python3 -m pip uninstall -y pymc_repeater 2>/dev/null || true
|
||||
python3 -m pip uninstall -y pymc_core 2>/dev/null || true
|
||||
# ---- Try R2 wheels first for faster OTA upgrades ----
|
||||
R2_BASE_URL="https://wheel.pymc.dev/pymc_build_deps"
|
||||
MACHINE_ARCH=$(uname -m)
|
||||
case "$MACHINE_ARCH" in
|
||||
aarch64) ARCH_TAG="arm64"; PLATFORM_TAG="aarch64" ;;
|
||||
armv7l|armv7) ARCH_TAG="armv7"; PLATFORM_TAG="armv7l" ;;
|
||||
x86_64) ARCH_TAG="x86_64"; PLATFORM_TAG="x86_64" ;;
|
||||
*) ARCH_TAG=""; PLATFORM_TAG="" ;;
|
||||
esac
|
||||
if [ -n "$ARCH_TAG" ]; then
|
||||
PY_TAG=$("$VENV_PYTHON" -c 'import sys; v=f"cp{sys.version_info.major}{sys.version_info.minor}"; print(f"{v}-{v}")' 2>/dev/null || echo "cp311-cp311")
|
||||
WHEEL_BASE="${R2_BASE_URL}/${ARCH_TAG}/${PLATFORM_TAG}/${PY_TAG}"
|
||||
echo "[pymc-do-upgrade] Trying dependencies from R2 wheels..."
|
||||
"$VENV_PIP" install --find-links "${WHEEL_BASE}/index.html" --no-cache-dir "pycryptodome>=3.23.0" "PyNaCl>=1.5.0" cffi "pyyaml>=6.0.0" 2>/dev/null || true
|
||||
fi
|
||||
# ---- Install pymc_repeater from git ----
|
||||
exec "$VENV_PIP" install \
|
||||
--upgrade \
|
||||
--no-cache-dir \
|
||||
"pymc_repeater[hardware] @ git+https://github.com/rightup/pyMC_Repeater.git@${CHANNEL}"
|
||||
UPGRADEEOF
|
||||
chmod 0755 /usr/local/bin/pymc-do-upgrade
|
||||
echo " ✓ Permissions updated"
|
||||
@@ -737,7 +921,7 @@ UPGRADEEOF
|
||||
|
||||
echo "=== Installing Python Dependencies ==="
|
||||
echo ""
|
||||
echo "Updating pymc_repeater and dependencies (including pymc_core from GitHub)..."
|
||||
echo "Updating pymc_repeater and dependencies (including pymc_core from PyPI)..."
|
||||
echo "This may take a few minutes..."
|
||||
echo ""
|
||||
|
||||
@@ -745,9 +929,6 @@ UPGRADEEOF
|
||||
SCRIPT_DIR="$(dirname "$0")"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
# Suppress pip root user warnings
|
||||
export PIP_ROOT_USER_ACTION=ignore
|
||||
|
||||
# Calculate version from git for setuptools_scm
|
||||
if [ -d .git ]; then
|
||||
git fetch --tags 2>/dev/null || true
|
||||
@@ -758,15 +939,44 @@ UPGRADEEOF
|
||||
export SETUPTOOLS_SCM_PRETEND_VERSION="1.0.5"
|
||||
fi
|
||||
|
||||
# Force binary wheels for slow-to-compile packages (much faster on Raspberry Pi)
|
||||
export PIP_ONLY_BINARY=pycryptodome,cffi,PyNaCl,psutil
|
||||
# We don't have any binary wheels available for these on a LuckFox, so we need to ignore them on that platform.
|
||||
if ! grep -q "Luckfox Pico" /proc/device-tree/model 2>/dev/null; then
|
||||
# Force binary wheels for slow-to-compile packages (much faster on Raspberry Pi)
|
||||
export PIP_ONLY_BINARY=pycryptodome,cffi,PyNaCl,psutil
|
||||
fi
|
||||
echo "Note: Using optimized binary wheels for faster installation"
|
||||
echo ""
|
||||
|
||||
# Install with --force-reinstall to ensure fresh pymc_core from GitHub
|
||||
# This reads pymc_core Git URL from pyproject.toml and reinstalls all dependencies
|
||||
echo "Upgrading pymc_repeater with fresh dependencies from pyproject.toml..."
|
||||
if python3 -m pip install --break-system-packages --no-cache-dir --force-reinstall .[hardware]; then
|
||||
# Migrate from system pip to venv (idempotent)
|
||||
migrate_to_venv
|
||||
|
||||
# Install into the venv (clean, no system-packages flags needed)
|
||||
echo "Upgrading pymc_repeater into venv ($VENV_DIR)..."
|
||||
|
||||
# Attempt R2 wheels first for faster installation
|
||||
if [ "$R2_ENABLED" -eq 1 ]; then
|
||||
MACHINE_ARCH=$(uname -m)
|
||||
case "$MACHINE_ARCH" in
|
||||
aarch64) ARCH_TAG="arm64"; PLATFORM_TAG="aarch64" ;;
|
||||
armv7l|armv7) ARCH_TAG="armv7"; PLATFORM_TAG="armv7l" ;;
|
||||
x86_64) ARCH_TAG="x86_64"; PLATFORM_TAG="x86_64" ;;
|
||||
*) ARCH_TAG=""; PLATFORM_TAG="" ;;
|
||||
esac
|
||||
if [ -n "$ARCH_TAG" ]; then
|
||||
PY_TAG=$("$VENV_PYTHON" -c 'import sys; v=f"cp{sys.version_info.major}{sys.version_info.minor}"; print(f"{v}-{v}")' 2>/dev/null || echo "cp311-cp311")
|
||||
WHEEL_BASE="${R2_BASE_URL}/${ARCH_TAG}/${PLATFORM_TAG}/${PY_TAG}"
|
||||
echo " Checking for R2 wheels (${ARCH_TAG}/${PLATFORM_TAG}/${PY_TAG})..."
|
||||
echo " Trying install from R2 pre-built wheels..."
|
||||
"$VENV_PIP" install --find-links "${WHEEL_BASE}/index.html" --no-cache-dir "pycryptodome>=3.23.0" "PyNaCl>=1.5.0" cffi "pyyaml>=6.0.0" 2>/dev/null && R2_SUCCESS=1 || R2_SUCCESS=0
|
||||
if [ "$R2_SUCCESS" -eq 1 ]; then
|
||||
echo " ✓ R2 wheels installed"
|
||||
else
|
||||
echo " - R2 wheels unavailable for this platform/tag, falling back"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
if "$VENV_PIP" install --upgrade --no-cache-dir .[hardware]; then
|
||||
echo ""
|
||||
echo "✓ Package and dependencies upgraded successfully!"
|
||||
else
|
||||
@@ -885,7 +1095,8 @@ uninstall_repeater() {
|
||||
systemctl daemon-reload
|
||||
|
||||
echo "50"; echo "# Removing polkit and sudoers rules..."
|
||||
rm -f /etc/polkit-1/rules.d/10-pymc-repeater.rules
|
||||
rm -f /etc/polkit-1/rules.d/10-pymc-repeater.rules || true
|
||||
rm -f /etc/polkit-1/localauthority/50-local.d/10-pymc-repeater.pkla || true
|
||||
rm -f /etc/sudoers.d/pymc-repeater
|
||||
rm -f /usr/local/bin/pymc-do-upgrade
|
||||
|
||||
@@ -1134,7 +1345,7 @@ fi
|
||||
# Handle command line arguments
|
||||
case "$1" in
|
||||
"install")
|
||||
install_repeater
|
||||
install_repeater install
|
||||
exit 0
|
||||
;;
|
||||
"upgrade")
|
||||
|
||||
@@ -10,16 +10,18 @@ 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
|
||||
# Start command - use venv python to avoid system package conflicts
|
||||
ExecStart=/opt/pymc_repeater/venv/bin/python -m repeater.main --config /etc/pymc_repeater/config.yaml
|
||||
|
||||
# Restart on failure
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
# Allow up to 10s for graceful shutdown before SIGKILL
|
||||
TimeoutStopSec=10
|
||||
|
||||
# Resource limits
|
||||
MemoryHigh=256M
|
||||
|
||||
|
||||
+6
-3
@@ -30,7 +30,7 @@ keywords = ["mesh", "networking", "lora", "repeater", "daemon", "iot"]
|
||||
|
||||
|
||||
dependencies = [
|
||||
"pymc_core[hardware]@git+https://github.com/rightup/pyMC_core.git@feat/companion",
|
||||
"pymc_core[hardware]==1.0.10",
|
||||
"pyyaml>=6.0.0",
|
||||
"cherrypy>=18.0.0",
|
||||
"paho-mqtt>=1.6.0",
|
||||
@@ -61,9 +61,11 @@ dev = [
|
||||
|
||||
[project.scripts]
|
||||
pymc-repeater = "repeater.main:main"
|
||||
pymc-cli = "repeater.local_cli:main"
|
||||
|
||||
[tool.setuptools]
|
||||
packages = ["repeater"]
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["."]
|
||||
include = ["repeater*"]
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
repeater = [
|
||||
@@ -85,3 +87,4 @@ line_length = 100
|
||||
[tool.setuptools_scm]
|
||||
version_scheme = "guess-next-dev"
|
||||
local_scheme = "no-local-version"
|
||||
version_file = "repeater/_version.py"
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
{
|
||||
"default_board": "luckfox-pimesh-v2",
|
||||
"default_radio_preset": "USA/Canada (Recommended)",
|
||||
"buildroot_hardware": {
|
||||
"luckfox-pimesh-v2": {
|
||||
"name": "Luckfox PiMesh V2",
|
||||
"description": "Luckfox Pico Pi with PiMesh-1W V2 / E22P wiring",
|
||||
"hardware_id": "pimesh-1w-v2",
|
||||
"tx_power": 22,
|
||||
"aliases": [
|
||||
"1",
|
||||
"v2",
|
||||
"pimesh-v2",
|
||||
"pimesh-1w-v2"
|
||||
],
|
||||
"sx1262_overrides": {
|
||||
"cs_pin": -1,
|
||||
"reset_pin": 54,
|
||||
"busy_pin": 122,
|
||||
"irq_pin": 121,
|
||||
"en_pin": 0,
|
||||
"txen_pin": -1,
|
||||
"rxen_pin": -1,
|
||||
"use_dio2_rf": true,
|
||||
"use_dio3_tcxo": true,
|
||||
"dio3_tcxo_voltage": 1.8
|
||||
}
|
||||
},
|
||||
"luckfox-pimesh-v1": {
|
||||
"name": "Luckfox PiMesh V1",
|
||||
"description": "Luckfox Pico Pi with PiMesh-1W V1 wiring",
|
||||
"hardware_id": "pimesh-1w-v1",
|
||||
"tx_power": 22,
|
||||
"aliases": [
|
||||
"2",
|
||||
"v1",
|
||||
"pimesh-v1",
|
||||
"pimesh-1w-v1"
|
||||
],
|
||||
"sx1262_overrides": {
|
||||
"cs_pin": 145,
|
||||
"reset_pin": 54,
|
||||
"busy_pin": 123,
|
||||
"irq_pin": 55,
|
||||
"en_pin": -1,
|
||||
"txen_pin": 52,
|
||||
"rxen_pin": 53,
|
||||
"use_dio2_rf": false,
|
||||
"use_dio3_tcxo": true,
|
||||
"dio3_tcxo_voltage": 1.8
|
||||
}
|
||||
},
|
||||
"luckfox-meshadv": {
|
||||
"name": "Luckfox MeshAdv",
|
||||
"description": "Luckfox Pico Pi with MeshAdv wiring",
|
||||
"hardware_id": "meshadv",
|
||||
"tx_power": 22,
|
||||
"aliases": [
|
||||
"3",
|
||||
"meshadv"
|
||||
],
|
||||
"sx1262_overrides": {
|
||||
"cs_pin": 145,
|
||||
"reset_pin": 54,
|
||||
"busy_pin": 123,
|
||||
"irq_pin": 55,
|
||||
"en_pin": -1,
|
||||
"txen_pin": 52,
|
||||
"rxen_pin": 53,
|
||||
"use_dio2_rf": false,
|
||||
"use_dio3_tcxo": true,
|
||||
"dio3_tcxo_voltage": 1.8
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ class CompanionFrameServer(_BaseFrameServer):
|
||||
companion_hash: str,
|
||||
port: int = 5000,
|
||||
bind_address: str = "0.0.0.0",
|
||||
client_idle_timeout_sec: Optional[int] = 120,
|
||||
client_idle_timeout_sec: Optional[int] = 8 * 60 * 60, # 8 hours
|
||||
sqlite_handler=None,
|
||||
local_hash: Optional[int] = None,
|
||||
stats_getter=None,
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
"""Resolve companion config rows by registration name, identity key, or public key prefix."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, List, Optional, Set, Tuple
|
||||
|
||||
from repeater.companion.utils import normalize_companion_identity_key
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Minimum hex chars for identity_key / public_key prefix disambiguation (4 bytes)
|
||||
_MIN_PREFIX_HEX_LEN = 8
|
||||
|
||||
|
||||
def _companion_registration_name(entry: dict) -> str:
|
||||
n = entry.get("name")
|
||||
if n is None:
|
||||
return ""
|
||||
return str(n).strip()
|
||||
|
||||
|
||||
def identity_key_bytes_from_config(identity_key: Any) -> Optional[bytes]:
|
||||
"""Parse companion identity_key from YAML (str hex or raw bytes)."""
|
||||
if identity_key is None:
|
||||
return None
|
||||
if isinstance(identity_key, (bytes, bytearray, memoryview)):
|
||||
raw = bytes(identity_key)
|
||||
return raw if len(raw) in (32, 64) else None
|
||||
if isinstance(identity_key, str):
|
||||
try:
|
||||
raw = bytes.fromhex(normalize_companion_identity_key(identity_key))
|
||||
except ValueError:
|
||||
return None
|
||||
return raw if len(raw) in (32, 64) else None
|
||||
return None
|
||||
|
||||
|
||||
def identity_key_hex_normalized(identity_key: Any) -> Optional[str]:
|
||||
"""Lowercase hex string of the raw key bytes (64 or 128 chars), or None."""
|
||||
raw = identity_key_bytes_from_config(identity_key)
|
||||
if raw is None:
|
||||
return None
|
||||
return raw.hex().lower()
|
||||
|
||||
|
||||
def derive_companion_public_key_hex(identity_key: Any) -> Optional[str]:
|
||||
"""Return ed25519 public key hex for a companion seed, or None if invalid."""
|
||||
raw = identity_key_bytes_from_config(identity_key)
|
||||
if raw is None:
|
||||
return None
|
||||
try:
|
||||
from pymc_core import LocalIdentity
|
||||
|
||||
identity = LocalIdentity(seed=raw)
|
||||
return identity.get_public_key().hex()
|
||||
except Exception as e:
|
||||
logger.debug("derive_companion_public_key_hex failed: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
def suggest_companion_name_from_pubkey(pubkey_hex: str, prefix_len: int = 8) -> str:
|
||||
"""Stable default registration name: companion_<first prefix_len hex chars of pubkey>."""
|
||||
p = pubkey_hex.strip().lower()
|
||||
if p.startswith("0x"):
|
||||
p = p[2:]
|
||||
if len(p) < prefix_len:
|
||||
prefix = p
|
||||
else:
|
||||
prefix = p[:prefix_len]
|
||||
return f"companion_{prefix}"
|
||||
|
||||
|
||||
def unique_suggested_name(
|
||||
pubkey_hex: str,
|
||||
existing_names: set,
|
||||
prefix_len: int = 8,
|
||||
) -> str:
|
||||
"""Like suggest_companion_name_from_pubkey but appends -2, -3, ... if name collides."""
|
||||
base = suggest_companion_name_from_pubkey(pubkey_hex, prefix_len=prefix_len)
|
||||
if base not in existing_names:
|
||||
return base
|
||||
n = 2
|
||||
while f"{base}-{n}" in existing_names:
|
||||
n += 1
|
||||
return f"{base}-{n}"
|
||||
|
||||
|
||||
def find_companion_index(
|
||||
companions: List[dict],
|
||||
*,
|
||||
name: Optional[str] = None,
|
||||
identity_key: Optional[str] = None,
|
||||
public_key_prefix: Optional[str] = None,
|
||||
) -> Tuple[Optional[int], Optional[str]]:
|
||||
"""
|
||||
Find a single companion list index.
|
||||
|
||||
Lookup priority when multiple fields are set:
|
||||
1) name (non-empty after strip)
|
||||
2) identity_key (full hex or unique prefix)
|
||||
3) public_key_prefix (unique prefix of derived public key hex)
|
||||
|
||||
Returns (index, None) on success, or (None, error_message) on failure.
|
||||
"""
|
||||
name_s = str(name).strip() if name is not None else ""
|
||||
idk = str(identity_key).strip() if identity_key is not None else ""
|
||||
pkp = str(public_key_prefix).strip() if public_key_prefix is not None else ""
|
||||
if pkp.lower().startswith("0x"):
|
||||
pkp = pkp[2:].strip()
|
||||
pkp = pkp.lower()
|
||||
|
||||
if idk:
|
||||
idk = normalize_companion_identity_key(idk).lower()
|
||||
|
||||
if name_s:
|
||||
matches = [i for i, c in enumerate(companions) if _companion_registration_name(c) == name_s]
|
||||
if len(matches) == 1:
|
||||
return matches[0], None
|
||||
if len(matches) == 0:
|
||||
return None, f"Companion '{name_s}' not found"
|
||||
return None, f"Multiple companions named '{name_s}'"
|
||||
|
||||
if idk:
|
||||
if len(idk) < _MIN_PREFIX_HEX_LEN:
|
||||
return None, (
|
||||
f"identity_key lookup must be at least {_MIN_PREFIX_HEX_LEN} hex characters"
|
||||
)
|
||||
exact: List[int] = []
|
||||
prefix_matches: List[int] = []
|
||||
for i, c in enumerate(companions):
|
||||
h = identity_key_hex_normalized(c.get("identity_key"))
|
||||
if not h:
|
||||
continue
|
||||
if h == idk:
|
||||
exact.append(i)
|
||||
elif h.startswith(idk):
|
||||
prefix_matches.append(i)
|
||||
if len(exact) == 1:
|
||||
return exact[0], None
|
||||
if len(exact) > 1:
|
||||
return None, "Multiple companions match identity_key (ambiguous)"
|
||||
if len(prefix_matches) == 1:
|
||||
return prefix_matches[0], None
|
||||
if len(prefix_matches) == 0:
|
||||
return None, "No companion matches identity_key"
|
||||
return None, "Multiple companions match identity_key prefix (ambiguous)"
|
||||
|
||||
if pkp:
|
||||
if len(pkp) < _MIN_PREFIX_HEX_LEN:
|
||||
return None, (
|
||||
f"public_key_prefix must be at least {_MIN_PREFIX_HEX_LEN} hex characters"
|
||||
)
|
||||
matches: List[int] = []
|
||||
for i, c in enumerate(companions):
|
||||
pub = derive_companion_public_key_hex(c.get("identity_key"))
|
||||
if pub and pub.lower().startswith(pkp):
|
||||
matches.append(i)
|
||||
if len(matches) == 1:
|
||||
return matches[0], None
|
||||
if len(matches) == 0:
|
||||
return None, "No companion matches public_key_prefix"
|
||||
return None, "Multiple companions match public_key_prefix (ambiguous)"
|
||||
|
||||
return None, "Missing companion lookup: provide name, identity_key, or public_key_prefix"
|
||||
|
||||
|
||||
def heal_companion_empty_names(companions: List[dict]) -> bool:
|
||||
"""
|
||||
Assign companion_<pubkeyPrefix> names to entries with missing/blank registration names.
|
||||
Mutates companions in place. Returns True if any entry was updated.
|
||||
"""
|
||||
names_in_use: Set[str] = set()
|
||||
for c in companions:
|
||||
n = _companion_registration_name(c)
|
||||
if n:
|
||||
names_in_use.add(n)
|
||||
changed = False
|
||||
for entry in companions:
|
||||
if _companion_registration_name(entry):
|
||||
continue
|
||||
pk = derive_companion_public_key_hex(entry.get("identity_key"))
|
||||
if not pk:
|
||||
logger.warning("Skipping companion name heal: invalid or missing identity_key")
|
||||
continue
|
||||
new_name = unique_suggested_name(pk, names_in_use)
|
||||
entry["name"] = new_name
|
||||
names_in_use.add(new_name)
|
||||
changed = True
|
||||
return changed
|
||||
+50
-25
@@ -11,13 +11,13 @@ logger = logging.getLogger("Config")
|
||||
|
||||
def get_node_info(config: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Extract node name, radio configuration, and LetsMesh settings from config.
|
||||
Extract node name, radio configuration, and MQTT settings from config.
|
||||
|
||||
Args:
|
||||
config: Configuration dictionary
|
||||
|
||||
Returns:
|
||||
Dictionary with node_name, radio_config, and LetsMesh configuration
|
||||
Dictionary with node_name, radio_config, and MQTT configuration
|
||||
"""
|
||||
node_name = config.get("repeater", {}).get("node_name", "PyMC-Repeater")
|
||||
radio_config = config.get("radio", {})
|
||||
@@ -30,26 +30,17 @@ def get_node_info(config: Dict[str, Any]) -> Dict[str, Any]:
|
||||
radio_bw_khz = radio_bw / 1_000
|
||||
radio_config_str = f"{radio_freq_mhz},{radio_bw_khz},{radio_sf},{radio_cr}"
|
||||
|
||||
letsmesh_config = config.get("letsmesh", {})
|
||||
|
||||
from pymc_core.protocol.utils import PAYLOAD_TYPES
|
||||
|
||||
disallowed_types = letsmesh_config.get("disallowed_packet_types", [])
|
||||
type_name_map = {name: code for code, name in PAYLOAD_TYPES.items()}
|
||||
|
||||
disallowed_hex = [type_name_map.get(name.upper(), None) for name in disallowed_types]
|
||||
disallowed_hex = [val for val in disallowed_hex if val is not None] # Filter out invalid names
|
||||
# Handle getting the config from mqtt brokers, falling back to letsmesh if it doesn't exist
|
||||
mqtt_config = config.get("mqtt_brokers", config.get("letsmesh", {}))
|
||||
|
||||
return {
|
||||
"node_name": node_name,
|
||||
"radio_config": radio_config_str,
|
||||
"iata_code": letsmesh_config.get("iata_code", "TEST"),
|
||||
"broker_index": letsmesh_config.get("broker_index", 0),
|
||||
"status_interval": letsmesh_config.get("status_interval", 60),
|
||||
"model": letsmesh_config.get("model", "PyMC-Repeater"),
|
||||
"disallowed_packet_types": disallowed_hex,
|
||||
"email": letsmesh_config.get("email", ""),
|
||||
"owner": letsmesh_config.get("owner", ""),
|
||||
"iata_code": mqtt_config.get("iata_code", "TEST"),
|
||||
"status_interval": mqtt_config.get("status_interval", 60),
|
||||
"model": mqtt_config.get("model", "PyMC-Repeater"),
|
||||
"email": mqtt_config.get("email", ""),
|
||||
"owner": mqtt_config.get("owner", ""),
|
||||
}
|
||||
|
||||
|
||||
@@ -77,9 +68,42 @@ def load_config(config_path: Optional[str] = None) -> Dict[str, Any]:
|
||||
if "mesh" not in config:
|
||||
config["mesh"] = {}
|
||||
|
||||
# Only auto-generate identity_key if not provided
|
||||
if "identity_key" not in config["mesh"]:
|
||||
config["mesh"]["identity_key"] = _load_or_create_identity_key()
|
||||
if "glass" not in config:
|
||||
config["glass"] = {
|
||||
"enabled": False,
|
||||
"base_url": "http://localhost:8080",
|
||||
"inform_interval_seconds": 30,
|
||||
"request_timeout_seconds": 10,
|
||||
"verify_tls": True,
|
||||
"api_token": "",
|
||||
"cert_store_dir": "/etc/pymc_repeater/glass",
|
||||
}
|
||||
|
||||
# Ensure repeater.security exists with defaults for upgrades from older configs
|
||||
if "repeater" not in config:
|
||||
config["repeater"] = {}
|
||||
if "security" not in config["repeater"]:
|
||||
logger.warning(
|
||||
"No 'security' section found under 'repeater' in config. "
|
||||
"Adding defaults — please review and update passwords."
|
||||
)
|
||||
config["repeater"]["security"] = {
|
||||
"max_clients": 1,
|
||||
"admin_password": "admin123",
|
||||
"guest_password": "guest123",
|
||||
"allow_read_only": False,
|
||||
"jwt_secret": "",
|
||||
"jwt_expiry_minutes": 60,
|
||||
}
|
||||
|
||||
# Only auto-generate identity_key if not provided under repeater section
|
||||
if "identity_key" not in config["repeater"]:
|
||||
# Check if identity_file is specified
|
||||
identity_file = config["repeater"].get("identity_file")
|
||||
if identity_file:
|
||||
config["repeater"]["identity_key"] = _load_or_create_identity_key(path=identity_file)
|
||||
else:
|
||||
config["repeater"]["identity_key"] = _load_or_create_identity_key()
|
||||
|
||||
if os.getenv("PYMC_REPEATER_LOG_LEVEL"):
|
||||
if "logging" not in config:
|
||||
@@ -130,12 +154,12 @@ def save_config(config_data: Dict[str, Any], config_path: Optional[str] = None)
|
||||
return False
|
||||
|
||||
|
||||
def update_global_flood_policy(allow: bool, config_path: Optional[str] = None) -> bool:
|
||||
def update_unscoped_flood_policy(allow: bool, config_path: Optional[str] = None) -> bool:
|
||||
"""
|
||||
Update the global flood policy in the configuration.
|
||||
Update the unscoped flood policy in the configuration.
|
||||
|
||||
Args:
|
||||
allow: True to allow flooding globally, False to deny
|
||||
allow: True to allow unscoped flooding, False to deny
|
||||
config_path: Path to config file (uses default if None)
|
||||
|
||||
Returns:
|
||||
@@ -151,12 +175,13 @@ def update_global_flood_policy(allow: bool, config_path: Optional[str] = None) -
|
||||
|
||||
# Set global flood policy
|
||||
config["mesh"]["global_flood_allow"] = allow
|
||||
config["mesh"]["unscoped_flood_allow"] = allow
|
||||
|
||||
# Save updated config
|
||||
return save_config(config, config_path)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update global flood policy: {e}")
|
||||
logger.error(f"Failed to update unscoped flood policy: {e}")
|
||||
return False
|
||||
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ class ConfigManager:
|
||||
|
||||
# Default sections to update if not specified
|
||||
if sections is None:
|
||||
sections = ['repeater', 'delays', 'radio', 'acl', 'identities']
|
||||
sections = ['repeater', 'delays', 'radio', 'acl', 'identities', 'glass']
|
||||
|
||||
# Update each section
|
||||
for section in sections:
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
from .mqtt_handler import MQTTHandler
|
||||
from .glass_handler import GlassHandler
|
||||
from .rrdtool_handler import RRDToolHandler
|
||||
from .sqlite_handler import SQLiteHandler
|
||||
from .storage_collector import StorageCollector
|
||||
|
||||
__all__ = ["SQLiteHandler", "RRDToolHandler", "MQTTHandler", "StorageCollector"]
|
||||
__all__ = ["SQLiteHandler", "RRDToolHandler", "StorageCollector", "GlassHandler"]
|
||||
|
||||
@@ -0,0 +1,957 @@
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import ssl
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
from urllib.parse import urlparse
|
||||
from urllib import error, request
|
||||
|
||||
import psutil
|
||||
try:
|
||||
import paho.mqtt.client as mqtt
|
||||
except ImportError:
|
||||
mqtt = None
|
||||
|
||||
from repeater import __version__
|
||||
from repeater.service_utils import restart_service
|
||||
|
||||
logger = logging.getLogger("GlassHandler")
|
||||
_SENSITIVE_KEY_MARKERS = (
|
||||
"password",
|
||||
"passphrase",
|
||||
"secret",
|
||||
"token",
|
||||
"private_key",
|
||||
"identity_key",
|
||||
"client_key",
|
||||
"api_key",
|
||||
)
|
||||
_SENSITIVE_KEY_EXCEPTIONS = ("pubkey", "public_key")
|
||||
|
||||
|
||||
class GlassHandler:
|
||||
def __init__(self, config: dict, daemon_instance=None, config_manager=None):
|
||||
self.config = config
|
||||
self.daemon_instance = daemon_instance
|
||||
self.config_manager = config_manager
|
||||
|
||||
self.enabled = False
|
||||
self.base_url = "http://localhost:8080"
|
||||
self.request_timeout_seconds = 10
|
||||
self.verify_tls = True
|
||||
self.api_token = ""
|
||||
self.inform_interval_seconds = 30
|
||||
self.cert_store_dir = "/etc/pymc_repeater/glass"
|
||||
self._cert_expires_at: Optional[str] = None
|
||||
self.mqtt_enabled = False
|
||||
self.mqtt_broker_host = "localhost"
|
||||
self.mqtt_broker_port = 1883
|
||||
self.mqtt_base_topic = "glass"
|
||||
self.mqtt_tls_enabled = False
|
||||
self.mqtt_username: Optional[str] = None
|
||||
self.mqtt_password: Optional[str] = None
|
||||
self.client_cert_path: Optional[str] = None
|
||||
self.client_key_path: Optional[str] = None
|
||||
self.ca_cert_path: Optional[str] = None
|
||||
self._mqtt_client = None
|
||||
self._mqtt_ready = False
|
||||
self._mqtt_runtime_signature: Optional[
|
||||
Tuple[
|
||||
str,
|
||||
int,
|
||||
str,
|
||||
bool,
|
||||
bool,
|
||||
Optional[str],
|
||||
Optional[str],
|
||||
Optional[str],
|
||||
Optional[str],
|
||||
Optional[str],
|
||||
]
|
||||
] = None
|
||||
self._managed_settings_filename = "managed.json"
|
||||
|
||||
self._task: Optional[asyncio.Task] = None
|
||||
self._stop_event: Optional[asyncio.Event] = None
|
||||
self._pending_command_results: List[Dict[str, Any]] = []
|
||||
self._pending_lock = asyncio.Lock()
|
||||
|
||||
self._reload_runtime_settings()
|
||||
|
||||
async def start(self) -> None:
|
||||
self._reload_runtime_settings()
|
||||
if not self.enabled:
|
||||
logger.info("Glass integration disabled")
|
||||
self._close_mqtt_publisher()
|
||||
return
|
||||
|
||||
if self._task and not self._task.done():
|
||||
return
|
||||
self._sync_mqtt_publisher()
|
||||
|
||||
self._stop_event = asyncio.Event()
|
||||
self._task = asyncio.create_task(self._run_loop(), name="glass-inform-loop")
|
||||
logger.info(
|
||||
"Glass integration started (base_url=%s, inform_interval=%ss)",
|
||||
self.base_url,
|
||||
self.inform_interval_seconds,
|
||||
)
|
||||
|
||||
async def stop(self) -> None:
|
||||
if self._task:
|
||||
if self._stop_event:
|
||||
self._stop_event.set()
|
||||
|
||||
try:
|
||||
await self._task
|
||||
except Exception as exc:
|
||||
logger.debug("Glass task stop ignored exception: %s", exc)
|
||||
finally:
|
||||
self._task = None
|
||||
self._stop_event = None
|
||||
|
||||
self._close_mqtt_publisher()
|
||||
|
||||
def _reload_runtime_settings(self) -> None:
|
||||
glass_cfg = self.config.get("glass", {})
|
||||
self.enabled = bool(glass_cfg.get("enabled", False))
|
||||
|
||||
base_url = str(glass_cfg.get("base_url", "http://localhost:8080")).strip()
|
||||
self.base_url = base_url.rstrip("/") if base_url else "http://localhost:8080"
|
||||
|
||||
self.request_timeout_seconds = max(3, int(glass_cfg.get("request_timeout_seconds", 10)))
|
||||
self.verify_tls = bool(glass_cfg.get("verify_tls", True))
|
||||
self.api_token = str(glass_cfg.get("api_token", "") or "").strip()
|
||||
self.inform_interval_seconds = self._clamp_interval(
|
||||
int(glass_cfg.get("inform_interval_seconds", self.inform_interval_seconds))
|
||||
)
|
||||
self.cert_store_dir = str(
|
||||
glass_cfg.get("cert_store_dir", "/etc/pymc_repeater/glass") or "/etc/pymc_repeater/glass"
|
||||
)
|
||||
self.client_cert_path = (
|
||||
str(glass_cfg.get("client_cert_path")).strip()
|
||||
if glass_cfg.get("client_cert_path")
|
||||
else None
|
||||
)
|
||||
self.client_key_path = (
|
||||
str(glass_cfg.get("client_key_path")).strip()
|
||||
if glass_cfg.get("client_key_path")
|
||||
else None
|
||||
)
|
||||
self.ca_cert_path = (
|
||||
str(glass_cfg.get("ca_cert_path")).strip()
|
||||
if glass_cfg.get("ca_cert_path")
|
||||
else None
|
||||
)
|
||||
managed_cfg = self._load_managed_settings()
|
||||
parsed_base_url = urlparse(self.base_url)
|
||||
default_host = parsed_base_url.hostname or "localhost"
|
||||
|
||||
self.mqtt_enabled = bool(managed_cfg.get("mqtt_enabled", False))
|
||||
host_value = managed_cfg.get("mqtt_broker_host", default_host)
|
||||
self.mqtt_broker_host = str(host_value or default_host).strip() or default_host
|
||||
try:
|
||||
self.mqtt_broker_port = max(1, int(managed_cfg.get("mqtt_broker_port", 1883)))
|
||||
except (TypeError, ValueError):
|
||||
self.mqtt_broker_port = 1883
|
||||
topic_value = managed_cfg.get("mqtt_base_topic", "glass")
|
||||
self.mqtt_base_topic = str(topic_value or "glass").strip("/")
|
||||
self.mqtt_tls_enabled = bool(managed_cfg.get("mqtt_tls_enabled", False))
|
||||
username = managed_cfg.get("mqtt_username")
|
||||
password = managed_cfg.get("mqtt_password")
|
||||
self.mqtt_username = str(username).strip() if isinstance(username, str) and username else None
|
||||
self.mqtt_password = str(password) if isinstance(password, str) and password else None
|
||||
|
||||
def _managed_settings_path(self) -> Path:
|
||||
return Path(self.cert_store_dir) / self._managed_settings_filename
|
||||
|
||||
def _load_managed_settings(self) -> Dict[str, Any]:
|
||||
path = self._managed_settings_path()
|
||||
if not path.exists():
|
||||
return {}
|
||||
try:
|
||||
raw = json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception as exc:
|
||||
logger.warning("Invalid Glass managed settings file at %s: %s", path, exc)
|
||||
return {}
|
||||
if not isinstance(raw, dict):
|
||||
logger.warning("Ignoring non-object Glass managed settings file at %s", path)
|
||||
return {}
|
||||
return raw
|
||||
|
||||
def _save_managed_settings(self, updates: Dict[str, Any], *, replace: bool) -> Tuple[bool, str]:
|
||||
if not isinstance(updates, dict):
|
||||
return False, "glass_managed must be an object"
|
||||
|
||||
path = self._managed_settings_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
current = {} if replace else self._load_managed_settings()
|
||||
if not isinstance(current, dict):
|
||||
current = {}
|
||||
merged = dict(current)
|
||||
merged.update(updates)
|
||||
try:
|
||||
path.write_text(
|
||||
json.dumps(merged, indent=2, sort_keys=True),
|
||||
encoding="utf-8",
|
||||
)
|
||||
os.chmod(path, 0o600)
|
||||
return True, "Managed settings updated"
|
||||
except Exception as exc:
|
||||
return False, f"Failed writing managed settings: {exc}"
|
||||
|
||||
async def _run_loop(self) -> None:
|
||||
while self._stop_event and not self._stop_event.is_set():
|
||||
self._reload_runtime_settings()
|
||||
self._sync_mqtt_publisher()
|
||||
try:
|
||||
interval = await self._inform_once()
|
||||
except Exception as exc:
|
||||
logger.warning("Glass inform failed: %s", exc)
|
||||
interval = self.inform_interval_seconds
|
||||
|
||||
wait_seconds = self._clamp_interval(interval)
|
||||
if not self._stop_event:
|
||||
break
|
||||
try:
|
||||
await asyncio.wait_for(self._stop_event.wait(), timeout=wait_seconds)
|
||||
except asyncio.TimeoutError:
|
||||
continue
|
||||
|
||||
async def _inform_once(self) -> int:
|
||||
self._reload_runtime_settings()
|
||||
if not self.enabled:
|
||||
return self.inform_interval_seconds
|
||||
|
||||
payload = await self._build_inform_payload()
|
||||
response = await self._post_inform(payload)
|
||||
|
||||
if payload.get("command_results"):
|
||||
async with self._pending_lock:
|
||||
self._pending_command_results = []
|
||||
|
||||
response_type = str(response.get("type", "noop"))
|
||||
response_interval = response.get("interval")
|
||||
|
||||
if response_type == "command":
|
||||
await self._handle_command_response(response)
|
||||
elif response_type == "config_update":
|
||||
ok, message = self._apply_config_update(
|
||||
response.get("config", {}),
|
||||
str(response.get("merge_mode", "patch")),
|
||||
)
|
||||
if ok:
|
||||
logger.info("Applied Glass config update")
|
||||
else:
|
||||
logger.warning("Failed to apply Glass config update: %s", message)
|
||||
elif response_type == "cert_renewal":
|
||||
ok, message = self._apply_cert_renewal(response)
|
||||
if ok:
|
||||
logger.info("Applied Glass certificate renewal")
|
||||
else:
|
||||
logger.warning("Failed to apply Glass certificate renewal: %s", message)
|
||||
elif response_type == "upgrade":
|
||||
logger.warning("Glass upgrade action received but not implemented on repeater")
|
||||
elif response_type != "noop":
|
||||
logger.warning("Unknown Glass response type: %s", response_type)
|
||||
|
||||
if isinstance(response_interval, int):
|
||||
self.inform_interval_seconds = self._clamp_interval(response_interval)
|
||||
return self.inform_interval_seconds
|
||||
|
||||
async def _build_inform_payload(self) -> Dict[str, Any]:
|
||||
if not self.daemon_instance or not getattr(self.daemon_instance, "local_identity", None):
|
||||
raise RuntimeError("Local identity not available for Glass inform")
|
||||
|
||||
stats = self.daemon_instance.get_stats() if self.daemon_instance else {}
|
||||
local_identity = self.daemon_instance.local_identity
|
||||
public_key = bytes(local_identity.get_public_key()).hex()
|
||||
node_name = self.config.get("repeater", {}).get("node_name", "unknown-repeater")
|
||||
|
||||
uptime_seconds = int(stats.get("uptime_seconds", 0))
|
||||
if uptime_seconds <= 0:
|
||||
repeater_handler = getattr(self.daemon_instance, "repeater_handler", None)
|
||||
if repeater_handler and getattr(repeater_handler, "start_time", None):
|
||||
uptime_seconds = max(0, int(time.time() - repeater_handler.start_time))
|
||||
|
||||
tx_total = int(stats.get("sent_flood_count", 0)) + int(stats.get("sent_direct_count", 0))
|
||||
if tx_total <= 0:
|
||||
tx_total = int(stats.get("forwarded_count", 0))
|
||||
|
||||
command_results = await self._get_pending_command_results()
|
||||
settings_snapshot = self._build_settings_snapshot()
|
||||
location = self._extract_location_from_settings(settings_snapshot)
|
||||
|
||||
return {
|
||||
"type": "inform",
|
||||
"version": 1,
|
||||
"node_name": node_name,
|
||||
"pubkey": f"0x{public_key}",
|
||||
"software_version": __version__,
|
||||
"state": self.config.get("repeater", {}).get("mode", "forward"),
|
||||
"location": location,
|
||||
"uptime_seconds": uptime_seconds,
|
||||
"config_hash": self._compute_config_hash(self.config),
|
||||
"cert_expires_at": self._cert_expires_at,
|
||||
"system": self._collect_system_stats(),
|
||||
"radio": {
|
||||
"frequency": int(self.config.get("radio", {}).get("frequency", 0)),
|
||||
"spreading_factor": int(self.config.get("radio", {}).get("spreading_factor", 7)),
|
||||
"bandwidth": int(self.config.get("radio", {}).get("bandwidth", 0)),
|
||||
"tx_power": int(self.config.get("radio", {}).get("tx_power", 0)),
|
||||
"noise_floor_dbm": stats.get("noise_floor_dbm"),
|
||||
"mode": self.config.get("repeater", {}).get("mode", "forward"),
|
||||
},
|
||||
"counters": {
|
||||
"rx_total": int(stats.get("rx_count", 0)),
|
||||
"tx_total": max(0, tx_total),
|
||||
"forwarded": int(stats.get("forwarded_count", 0)),
|
||||
"dropped": int(stats.get("dropped_count", 0)),
|
||||
"duplicates": int(stats.get("flood_dup_count", 0))
|
||||
+ int(stats.get("direct_dup_count", 0)),
|
||||
"airtime_percent": float(stats.get("utilization_percent", 0.0)),
|
||||
},
|
||||
"settings": settings_snapshot,
|
||||
"command_results": command_results,
|
||||
}
|
||||
|
||||
def _build_settings_snapshot(self) -> Dict[str, Any]:
|
||||
normalized = self._normalize_for_hash(self.config)
|
||||
sanitized = self._sanitize_settings_for_export(normalized)
|
||||
if isinstance(sanitized, dict):
|
||||
return sanitized
|
||||
return {}
|
||||
|
||||
def _sanitize_settings_for_export(self, value: Any, key_name: Optional[str] = None) -> Any:
|
||||
if isinstance(value, dict):
|
||||
output: Dict[str, Any] = {}
|
||||
for child_key, child_value in value.items():
|
||||
if self._is_sensitive_key(child_key):
|
||||
output[child_key] = "<redacted>"
|
||||
continue
|
||||
output[child_key] = self._sanitize_settings_for_export(child_value, child_key)
|
||||
return output
|
||||
if isinstance(value, list):
|
||||
return [self._sanitize_settings_for_export(item, key_name) for item in value]
|
||||
return value
|
||||
|
||||
@staticmethod
|
||||
def _is_sensitive_key(key: str) -> bool:
|
||||
lowered = str(key).lower()
|
||||
if any(exception in lowered for exception in _SENSITIVE_KEY_EXCEPTIONS):
|
||||
return False
|
||||
return any(marker in lowered for marker in _SENSITIVE_KEY_MARKERS)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_location(value: Any) -> Optional[str]:
|
||||
if isinstance(value, str):
|
||||
text = value.strip()
|
||||
if not text:
|
||||
return None
|
||||
parts = [part.strip() for part in text.split(",")]
|
||||
if len(parts) != 2:
|
||||
return None
|
||||
try:
|
||||
lat = float(parts[0])
|
||||
lng = float(parts[1])
|
||||
except ValueError:
|
||||
return None
|
||||
elif isinstance(value, dict):
|
||||
lat = value.get("lat", value.get("latitude"))
|
||||
lng = value.get("lng", value.get("longitude"))
|
||||
try:
|
||||
if lat is None or lng is None:
|
||||
return None
|
||||
lat = float(lat)
|
||||
lng = float(lng)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
elif isinstance(value, (list, tuple)) and len(value) == 2:
|
||||
try:
|
||||
lat = float(value[0])
|
||||
lng = float(value[1])
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
else:
|
||||
return None
|
||||
|
||||
if lat < -90 or lat > 90 or lng < -180 or lng > 180:
|
||||
return None
|
||||
return f"{lat:.6f},{lng:.6f}"
|
||||
|
||||
def _extract_location_from_settings(self, settings: Dict[str, Any]) -> Optional[str]:
|
||||
repeater_settings = settings.get("repeater")
|
||||
repeater_dict = repeater_settings if isinstance(repeater_settings, dict) else {}
|
||||
candidates = [
|
||||
settings.get("location"),
|
||||
repeater_dict.get("location"),
|
||||
settings.get("gps"),
|
||||
repeater_dict.get("gps"),
|
||||
{
|
||||
"lat": repeater_dict.get("latitude"),
|
||||
"lng": repeater_dict.get("longitude"),
|
||||
},
|
||||
]
|
||||
for candidate in candidates:
|
||||
location = self._normalize_location(candidate)
|
||||
if location:
|
||||
return location
|
||||
return None
|
||||
|
||||
def _collect_system_stats(self) -> Dict[str, Any]:
|
||||
temperature_c = None
|
||||
try:
|
||||
temperatures = psutil.sensors_temperatures() if hasattr(psutil, "sensors_temperatures") else {}
|
||||
if temperatures:
|
||||
for values in temperatures.values():
|
||||
if values:
|
||||
temperature_c = values[0].current
|
||||
break
|
||||
except Exception:
|
||||
temperature_c = None
|
||||
|
||||
load_avg_1m = None
|
||||
try:
|
||||
if hasattr(os, "getloadavg"):
|
||||
load_avg_1m = float(os.getloadavg()[0])
|
||||
except Exception:
|
||||
load_avg_1m = None
|
||||
|
||||
return {
|
||||
"cpu_percent": float(psutil.cpu_percent(interval=None)),
|
||||
"memory_percent": float(psutil.virtual_memory().percent),
|
||||
"disk_percent": float(psutil.disk_usage("/").percent),
|
||||
"temperature_c": temperature_c,
|
||||
"load_avg_1m": load_avg_1m,
|
||||
}
|
||||
|
||||
async def _post_inform(self, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
loop = asyncio.get_running_loop()
|
||||
return await loop.run_in_executor(None, self._post_inform_sync, payload)
|
||||
|
||||
def _post_inform_sync(self, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
url = f"{self.base_url}/inform"
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if self.api_token:
|
||||
headers["Authorization"] = f"Bearer {self.api_token}"
|
||||
|
||||
body = json.dumps(payload).encode("utf-8")
|
||||
req = request.Request(url=url, data=body, method="POST", headers=headers)
|
||||
ssl_context = self._build_ssl_context(url)
|
||||
|
||||
try:
|
||||
with request.urlopen(
|
||||
req,
|
||||
timeout=self.request_timeout_seconds,
|
||||
context=ssl_context,
|
||||
) as response:
|
||||
response_bytes = response.read()
|
||||
except error.HTTPError as exc:
|
||||
details = ""
|
||||
try:
|
||||
details = exc.read().decode("utf-8")
|
||||
except Exception:
|
||||
details = str(exc)
|
||||
raise RuntimeError(f"HTTP {exc.code}: {details}") from exc
|
||||
except error.URLError as exc:
|
||||
raise RuntimeError(f"Connection error: {exc}") from exc
|
||||
|
||||
if not response_bytes:
|
||||
return {"type": "noop", "interval": self.inform_interval_seconds}
|
||||
|
||||
try:
|
||||
response_payload = json.loads(response_bytes.decode("utf-8"))
|
||||
except Exception as exc:
|
||||
raise RuntimeError("Invalid JSON response from Glass backend") from exc
|
||||
|
||||
if not isinstance(response_payload, dict):
|
||||
raise RuntimeError("Invalid response payload from Glass backend")
|
||||
return response_payload
|
||||
|
||||
def _build_ssl_context(self, url: str) -> Optional[ssl.SSLContext]:
|
||||
if not str(url).startswith("https"):
|
||||
return None
|
||||
|
||||
if self.verify_tls:
|
||||
if self.ca_cert_path:
|
||||
ca_path = self._require_ssl_file(self.ca_cert_path, "ca_cert_path")
|
||||
context = ssl.create_default_context(cafile=ca_path)
|
||||
else:
|
||||
context = ssl.create_default_context()
|
||||
else:
|
||||
context = ssl._create_unverified_context()
|
||||
|
||||
if self.client_cert_path or self.client_key_path:
|
||||
cert_path = self._require_ssl_file(self.client_cert_path, "client_cert_path")
|
||||
key_path = self._require_ssl_file(self.client_key_path, "client_key_path")
|
||||
context.load_cert_chain(certfile=cert_path, keyfile=key_path)
|
||||
|
||||
return context
|
||||
|
||||
@staticmethod
|
||||
def _require_ssl_file(path_value: Optional[str], field_name: str) -> str:
|
||||
if not path_value or not str(path_value).strip():
|
||||
raise RuntimeError(f"Missing {field_name} for Glass TLS configuration")
|
||||
normalized = str(path_value).strip()
|
||||
if not Path(normalized).exists():
|
||||
raise RuntimeError(f"Configured {field_name} does not exist: {normalized}")
|
||||
return normalized
|
||||
|
||||
async def _handle_command_response(self, response: Dict[str, Any]) -> None:
|
||||
command_id = str(response.get("command_id", "")).strip()
|
||||
action = str(response.get("action", "")).strip()
|
||||
params = response.get("params", {})
|
||||
|
||||
if not command_id or not action:
|
||||
logger.warning("Glass command response missing command_id or action")
|
||||
return
|
||||
|
||||
success = False
|
||||
message = "Action failed"
|
||||
details: Optional[Dict[str, Any]] = None
|
||||
try:
|
||||
success, message, details = await self._execute_command_action(action, params)
|
||||
except Exception as exc:
|
||||
success = False
|
||||
message = f"Exception executing action: {exc}"
|
||||
details = None
|
||||
|
||||
await self._queue_command_result(
|
||||
command_id=command_id,
|
||||
status="success" if success else "failed",
|
||||
message=message,
|
||||
details=details,
|
||||
)
|
||||
|
||||
async def _execute_command_action(
|
||||
self,
|
||||
action: str,
|
||||
params: Any,
|
||||
) -> Tuple[bool, str, Optional[Dict[str, Any]]]:
|
||||
params = params if isinstance(params, dict) else {}
|
||||
|
||||
if action == "restart_service":
|
||||
success, message = restart_service()
|
||||
return success, message, None
|
||||
|
||||
if action == "send_advert":
|
||||
if not self.daemon_instance or not hasattr(self.daemon_instance, "send_advert"):
|
||||
return False, "send_advert unavailable", None
|
||||
success = await self.daemon_instance.send_advert()
|
||||
return success, "Advert sent" if success else "Failed to send advert", None
|
||||
|
||||
if action == "set_mode":
|
||||
mode = str(params.get("mode", "")).strip()
|
||||
if mode not in ("forward", "monitor", "no_tx"):
|
||||
return False, "Invalid mode parameter", None
|
||||
success, message = self._apply_config_update(
|
||||
{"repeater": {"mode": mode}},
|
||||
merge_mode="patch",
|
||||
)
|
||||
return success, message, None
|
||||
|
||||
if action == "set_inform_interval":
|
||||
interval = params.get("interval_seconds", params.get("interval"))
|
||||
if not isinstance(interval, int):
|
||||
return False, "interval_seconds must be an integer", None
|
||||
interval = self._clamp_interval(interval)
|
||||
self.inform_interval_seconds = interval
|
||||
success, message = self._apply_config_update(
|
||||
{"glass": {"inform_interval_seconds": interval}},
|
||||
merge_mode="patch",
|
||||
)
|
||||
return success, message, None
|
||||
if action == "rotate_cert":
|
||||
return True, "Certificate rotation requested", None
|
||||
|
||||
if action == "config_update":
|
||||
config_patch = params.get("config", params)
|
||||
merge_mode = str(params.get("merge_mode", "patch"))
|
||||
success, message = self._apply_config_update(config_patch, merge_mode=merge_mode)
|
||||
return success, message, None
|
||||
|
||||
if action == "transport_keys_sync":
|
||||
success, message, details = self._apply_transport_keys_sync(params)
|
||||
return success, message, details
|
||||
|
||||
if action == "set_radio":
|
||||
radio_values = params.get("radio", params)
|
||||
if not isinstance(radio_values, dict):
|
||||
return False, "radio settings must be an object", None
|
||||
success, message = self._apply_config_update({"radio": radio_values}, merge_mode="patch")
|
||||
return success, message, None
|
||||
|
||||
if action == "run_diagnostic":
|
||||
stats = self.daemon_instance.get_stats() if self.daemon_instance else {}
|
||||
return True, (
|
||||
f"rx={int(stats.get('rx_count', 0))}, "
|
||||
f"tx={int(stats.get('forwarded_count', 0))}, "
|
||||
f"dropped={int(stats.get('dropped_count', 0))}"
|
||||
), None
|
||||
|
||||
if action == "export_config":
|
||||
normalized_config = self._normalize_for_hash(self.config)
|
||||
return (
|
||||
True,
|
||||
"Configuration exported",
|
||||
{
|
||||
"config": normalized_config,
|
||||
"config_hash": self._compute_config_hash(self.config),
|
||||
},
|
||||
)
|
||||
|
||||
return False, f"Unsupported action: {action}", None
|
||||
|
||||
def _apply_config_update(self, updates: Any, merge_mode: str = "patch") -> Tuple[bool, str]:
|
||||
if not isinstance(updates, dict) or not updates:
|
||||
return False, "Config update payload must be a non-empty object"
|
||||
merge_mode = merge_mode.lower().strip()
|
||||
|
||||
if merge_mode not in ("patch", "replace"):
|
||||
return False, f"Unsupported merge_mode: {merge_mode}"
|
||||
updates_to_apply = dict(updates)
|
||||
managed_updates = updates_to_apply.pop("glass_managed", None)
|
||||
if managed_updates is not None:
|
||||
managed_ok, managed_message = self._save_managed_settings(
|
||||
managed_updates,
|
||||
replace=merge_mode == "replace",
|
||||
)
|
||||
if not managed_ok:
|
||||
return False, managed_message
|
||||
self._reload_runtime_settings()
|
||||
self._sync_mqtt_publisher()
|
||||
|
||||
if not updates_to_apply:
|
||||
return True, "Managed settings updated"
|
||||
|
||||
sections = list(updates_to_apply.keys())
|
||||
|
||||
if merge_mode == "replace":
|
||||
for section, value in updates_to_apply.items():
|
||||
self.config[section] = value
|
||||
if self.config_manager:
|
||||
saved = self.config_manager.save_to_file()
|
||||
live_updated = self.config_manager.live_update_daemon(sections)
|
||||
return (
|
||||
bool(saved and live_updated),
|
||||
"Config replaced" if saved and live_updated else "Failed to persist replace update",
|
||||
)
|
||||
return True, "Config replaced"
|
||||
|
||||
# patch mode
|
||||
if self.config_manager:
|
||||
result = self.config_manager.update_and_save(
|
||||
updates=updates_to_apply,
|
||||
live_update=True,
|
||||
live_update_sections=sections,
|
||||
)
|
||||
if result.get("success"):
|
||||
if "glass" in sections:
|
||||
self._reload_runtime_settings()
|
||||
self._sync_mqtt_publisher()
|
||||
return True, "Config patched"
|
||||
return False, str(result.get("error", "Failed to patch config"))
|
||||
self._deep_merge(self.config, updates_to_apply)
|
||||
if "glass" in sections:
|
||||
self._reload_runtime_settings()
|
||||
self._sync_mqtt_publisher()
|
||||
return True, "Config patched"
|
||||
|
||||
def _get_sqlite_handler(self):
|
||||
if not self.daemon_instance:
|
||||
return None
|
||||
repeater_handler = getattr(self.daemon_instance, "repeater_handler", None)
|
||||
storage = getattr(repeater_handler, "storage", None)
|
||||
return getattr(storage, "sqlite_handler", None)
|
||||
|
||||
def _apply_transport_keys_sync(
|
||||
self,
|
||||
params: Dict[str, Any],
|
||||
) -> Tuple[bool, str, Optional[Dict[str, Any]]]:
|
||||
if not isinstance(params, dict):
|
||||
return False, "transport_keys_sync params must be an object", None
|
||||
entries = params.get("transport_keys")
|
||||
if not isinstance(entries, list):
|
||||
return False, "transport_keys_sync payload must include a transport_keys list", None
|
||||
sqlite_handler = self._get_sqlite_handler()
|
||||
if sqlite_handler is None:
|
||||
return False, "SQLite handler unavailable for transport key sync", None
|
||||
try:
|
||||
result = sqlite_handler.sync_transport_keys(entries)
|
||||
except Exception as exc:
|
||||
return False, f"Transport key sync failed: {exc}", None
|
||||
payload_hash = params.get("payload_hash")
|
||||
details: Dict[str, Any] = {
|
||||
"applied_nodes": int(result.get("applied_nodes", 0)),
|
||||
"generated_keys": int(result.get("generated_keys", 0)),
|
||||
}
|
||||
if isinstance(payload_hash, str) and payload_hash.strip():
|
||||
details["payload_hash"] = payload_hash
|
||||
return True, f"Applied transport key sync ({details['applied_nodes']} nodes)", details
|
||||
|
||||
def _apply_cert_renewal(self, response: Dict[str, Any]) -> Tuple[bool, str]:
|
||||
client_cert = response.get("client_cert")
|
||||
client_key = response.get("client_key")
|
||||
ca_cert = response.get("ca_cert")
|
||||
|
||||
if not all(isinstance(item, str) and item.strip() for item in (client_cert, client_key, ca_cert)):
|
||||
return False, "Missing certificate payload values"
|
||||
|
||||
cert_dir = Path(self.cert_store_dir)
|
||||
cert_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
client_cert_path = cert_dir / "glass-client.crt"
|
||||
client_key_path = cert_dir / "glass-client.key"
|
||||
ca_cert_path = cert_dir / "glass-ca.crt"
|
||||
|
||||
client_cert_path.write_text(client_cert, encoding="utf-8")
|
||||
client_key_path.write_text(client_key, encoding="utf-8")
|
||||
ca_cert_path.write_text(ca_cert, encoding="utf-8")
|
||||
os.chmod(client_key_path, 0o600)
|
||||
|
||||
return self._apply_config_update(
|
||||
{
|
||||
"glass": {
|
||||
"client_cert_path": str(client_cert_path),
|
||||
"client_key_path": str(client_key_path),
|
||||
"ca_cert_path": str(ca_cert_path),
|
||||
}
|
||||
},
|
||||
merge_mode="patch",
|
||||
)
|
||||
|
||||
async def _get_pending_command_results(self) -> List[Dict[str, Any]]:
|
||||
async with self._pending_lock:
|
||||
return list(self._pending_command_results)
|
||||
|
||||
async def _queue_command_result(
|
||||
self,
|
||||
command_id: str,
|
||||
status: str,
|
||||
message: str,
|
||||
details: Optional[Dict[str, Any]] = None,
|
||||
) -> None:
|
||||
completed_at = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
result = {
|
||||
"command_id": command_id,
|
||||
"status": status,
|
||||
"message": message[:1024] if message else "",
|
||||
"completed_at": completed_at,
|
||||
}
|
||||
if details:
|
||||
result["details"] = details
|
||||
async with self._pending_lock:
|
||||
self._pending_command_results.append(result)
|
||||
|
||||
def publish_telemetry(self, record_type: str, record: Dict[str, Any]) -> None:
|
||||
if not self.enabled or not self.mqtt_enabled or not self._mqtt_ready:
|
||||
return
|
||||
if not self._mqtt_client:
|
||||
return
|
||||
|
||||
node_name = self.config.get("repeater", {}).get("node_name", "unknown-repeater")
|
||||
event_type = "event"
|
||||
event_name: Optional[str] = record_type
|
||||
if record_type in ("packet", "advert"):
|
||||
event_type = record_type
|
||||
event_name = None
|
||||
|
||||
topic = self._mqtt_topic_for_record(node_name=node_name, record_type=record_type)
|
||||
timestamp = self._to_rfc3339_timestamp(record.get("timestamp"))
|
||||
payload = self._normalize_for_hash(record)
|
||||
|
||||
envelope: Dict[str, Any] = {
|
||||
"version": 1,
|
||||
"type": event_type,
|
||||
"topic": topic,
|
||||
"node_name": node_name,
|
||||
"timestamp": timestamp,
|
||||
"payload": payload,
|
||||
}
|
||||
if event_type == "event" and event_name:
|
||||
envelope["event_name"] = event_name
|
||||
|
||||
try:
|
||||
message = json.dumps(envelope, separators=(",", ":"), sort_keys=True, default=str)
|
||||
self._mqtt_client.publish(topic, message, qos=0, retain=False)
|
||||
except Exception as exc:
|
||||
logger.debug("Failed publishing Glass telemetry MQTT message: %s", exc)
|
||||
|
||||
def _mqtt_topic_for_record(self, *, node_name: str, record_type: str) -> str:
|
||||
base = self.mqtt_base_topic.strip("/") or "glass"
|
||||
if record_type in ("packet", "advert"):
|
||||
return f"{base}/{node_name}/{record_type}"
|
||||
return f"{base}/{node_name}/event/{record_type}"
|
||||
|
||||
def _to_rfc3339_timestamp(self, value: Any) -> str:
|
||||
if isinstance(value, (int, float)):
|
||||
dt = datetime.fromtimestamp(float(value), timezone.utc)
|
||||
elif isinstance(value, str):
|
||||
normalized = value.strip()
|
||||
if normalized.endswith("Z"):
|
||||
return normalized
|
||||
try:
|
||||
dt = datetime.fromisoformat(normalized)
|
||||
except ValueError:
|
||||
dt = datetime.now(timezone.utc)
|
||||
elif isinstance(value, datetime):
|
||||
dt = value
|
||||
else:
|
||||
dt = datetime.now(timezone.utc)
|
||||
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
else:
|
||||
dt = dt.astimezone(timezone.utc)
|
||||
return dt.isoformat().replace("+00:00", "Z")
|
||||
|
||||
def _init_mqtt_publisher(self) -> None:
|
||||
if not self.mqtt_enabled:
|
||||
self._close_mqtt_publisher()
|
||||
return
|
||||
if mqtt is None:
|
||||
logger.warning("Glass MQTT telemetry publishing enabled but paho-mqtt is unavailable")
|
||||
self._close_mqtt_publisher()
|
||||
return
|
||||
if self._mqtt_client is not None:
|
||||
return
|
||||
|
||||
try:
|
||||
client = mqtt.Client()
|
||||
if self.mqtt_username:
|
||||
client.username_pw_set(self.mqtt_username, self.mqtt_password)
|
||||
if self.mqtt_tls_enabled:
|
||||
ca_certs = self._require_ssl_file(self.ca_cert_path, "ca_cert_path") if self.ca_cert_path else None
|
||||
certfile = None
|
||||
keyfile = None
|
||||
if self.client_cert_path or self.client_key_path:
|
||||
certfile = self._require_ssl_file(self.client_cert_path, "client_cert_path")
|
||||
keyfile = self._require_ssl_file(self.client_key_path, "client_key_path")
|
||||
cert_reqs = ssl.CERT_REQUIRED if self.verify_tls else ssl.CERT_NONE
|
||||
client.tls_set(
|
||||
ca_certs=ca_certs,
|
||||
certfile=certfile,
|
||||
keyfile=keyfile,
|
||||
cert_reqs=cert_reqs,
|
||||
tls_version=ssl.PROTOCOL_TLS_CLIENT,
|
||||
)
|
||||
if not self.verify_tls:
|
||||
client.tls_insecure_set(True)
|
||||
client.on_connect = self._on_mqtt_connect
|
||||
client.on_disconnect = self._on_mqtt_disconnect
|
||||
client.connect_async(self.mqtt_broker_host, self.mqtt_broker_port, 60)
|
||||
client.loop_start()
|
||||
self._mqtt_client = client
|
||||
self._mqtt_runtime_signature = self._current_mqtt_signature()
|
||||
logger.info(
|
||||
"Glass MQTT telemetry publisher started (%s:%s, base_topic=%s)",
|
||||
self.mqtt_broker_host,
|
||||
self.mqtt_broker_port,
|
||||
self.mqtt_base_topic,
|
||||
)
|
||||
except Exception as exc:
|
||||
self._mqtt_client = None
|
||||
self._mqtt_ready = False
|
||||
self._mqtt_runtime_signature = None
|
||||
logger.warning("Failed to start Glass MQTT telemetry publisher: %s", exc)
|
||||
|
||||
def _close_mqtt_publisher(self) -> None:
|
||||
client = self._mqtt_client
|
||||
self._mqtt_client = None
|
||||
self._mqtt_ready = False
|
||||
self._mqtt_runtime_signature = None
|
||||
if client is None:
|
||||
return
|
||||
try:
|
||||
client.loop_stop()
|
||||
client.disconnect()
|
||||
except Exception as exc:
|
||||
logger.debug("Error stopping Glass MQTT telemetry publisher: %s", exc)
|
||||
|
||||
def _on_mqtt_connect(self, _client, _userdata, _flags, reason_code, _properties=None) -> None:
|
||||
rc = getattr(reason_code, "value", reason_code)
|
||||
if rc == 0:
|
||||
self._mqtt_ready = True
|
||||
logger.info("Glass MQTT telemetry publisher connected")
|
||||
return
|
||||
self._mqtt_ready = False
|
||||
logger.warning("Glass MQTT telemetry publisher connect failed (code=%s)", rc)
|
||||
|
||||
def _on_mqtt_disconnect(self, _client, _userdata, reason_code, _properties=None) -> None:
|
||||
self._mqtt_ready = False
|
||||
rc = getattr(reason_code, "value", reason_code)
|
||||
if rc:
|
||||
logger.warning("Glass MQTT telemetry publisher disconnected (code=%s)", rc)
|
||||
|
||||
def _current_mqtt_signature(
|
||||
self,
|
||||
) -> Tuple[str, int, str, bool, bool, Optional[str], Optional[str], Optional[str], Optional[str], Optional[str]]:
|
||||
return (
|
||||
self.mqtt_broker_host,
|
||||
self.mqtt_broker_port,
|
||||
self.mqtt_base_topic,
|
||||
self.mqtt_tls_enabled,
|
||||
self.verify_tls,
|
||||
self.ca_cert_path,
|
||||
self.client_cert_path,
|
||||
self.client_key_path,
|
||||
self.mqtt_username,
|
||||
self.mqtt_password,
|
||||
)
|
||||
|
||||
def _sync_mqtt_publisher(self) -> None:
|
||||
if not self.enabled or not self.mqtt_enabled:
|
||||
self._close_mqtt_publisher()
|
||||
return
|
||||
if mqtt is None:
|
||||
self._close_mqtt_publisher()
|
||||
return
|
||||
|
||||
signature = self._current_mqtt_signature()
|
||||
if self._mqtt_client is None:
|
||||
self._init_mqtt_publisher()
|
||||
return
|
||||
if self._mqtt_runtime_signature != signature:
|
||||
self._close_mqtt_publisher()
|
||||
self._init_mqtt_publisher()
|
||||
|
||||
@staticmethod
|
||||
def _deep_merge(target: Dict[str, Any], source: Dict[str, Any]) -> None:
|
||||
for key, value in source.items():
|
||||
if (
|
||||
isinstance(value, dict)
|
||||
and isinstance(target.get(key), dict)
|
||||
):
|
||||
GlassHandler._deep_merge(target[key], value)
|
||||
else:
|
||||
target[key] = value
|
||||
|
||||
@staticmethod
|
||||
def _normalize_for_hash(value: Any) -> Any:
|
||||
if isinstance(value, bytes):
|
||||
return value.hex()
|
||||
if isinstance(value, dict):
|
||||
return {k: GlassHandler._normalize_for_hash(v) for k, v in value.items()}
|
||||
if isinstance(value, list):
|
||||
return [GlassHandler._normalize_for_hash(v) for v in value]
|
||||
return value
|
||||
|
||||
@staticmethod
|
||||
def _compute_config_hash(config: dict) -> str:
|
||||
normalized = GlassHandler._normalize_for_hash(config)
|
||||
encoded = json.dumps(normalized, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||||
digest = hashlib.sha256(encoded).hexdigest()
|
||||
return f"sha256:{digest}"
|
||||
|
||||
@staticmethod
|
||||
def _clamp_interval(interval_seconds: int) -> int:
|
||||
if interval_seconds < 5:
|
||||
return 5
|
||||
if interval_seconds > 3600:
|
||||
return 3600
|
||||
return interval_seconds
|
||||
@@ -96,6 +96,7 @@ class _BrokerConnection:
|
||||
self._reconnect_timer = None
|
||||
self._max_reconnect_delay = 300 # 5 minutes max
|
||||
self._jwt_refresh_timer = None
|
||||
self._shutdown_requested = False
|
||||
client_id = f"meshcore_{self.public_key}_{broker['host']}"
|
||||
self.client = mqtt.Client(client_id=client_id, transport="websockets")
|
||||
self.client.on_connect = self._on_connect
|
||||
@@ -132,22 +133,22 @@ class _BrokerConnection:
|
||||
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
|
||||
@@ -155,7 +156,7 @@ 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):
|
||||
@@ -163,19 +164,28 @@ class _BrokerConnection:
|
||||
was_running = self._running
|
||||
self._running = False
|
||||
|
||||
if self._shutdown_requested:
|
||||
logger.info(f"Clean disconnect from {self.broker['name']}")
|
||||
if self._on_disconnect_callback:
|
||||
self._on_disconnect_callback(self.broker["name"])
|
||||
return
|
||||
|
||||
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"])
|
||||
|
||||
def _schedule_reconnect(self, reason: str = "connection lost"):
|
||||
"""Schedule reconnection with exponential backoff"""
|
||||
if self._shutdown_requested:
|
||||
return
|
||||
|
||||
if self._reconnect_timer:
|
||||
self._reconnect_timer.cancel()
|
||||
|
||||
@@ -183,7 +193,7 @@ class _BrokerConnection:
|
||||
delay = min(5 * (2**self._reconnect_attempts), self._max_reconnect_delay)
|
||||
self._reconnect_attempts += 1
|
||||
|
||||
logging.info(
|
||||
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))
|
||||
@@ -192,13 +202,16 @@ class _BrokerConnection:
|
||||
|
||||
def _attempt_reconnect(self, reason: str = "connection lost"):
|
||||
"""Attempt to reconnect to broker with fresh JWT"""
|
||||
if self._shutdown_requested:
|
||||
return
|
||||
|
||||
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:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self._set_jwt_credentials()
|
||||
@@ -208,7 +221,7 @@ class _BrokerConnection:
|
||||
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):
|
||||
@@ -218,15 +231,17 @@ 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):
|
||||
"""Establish connection to broker"""
|
||||
self._shutdown_requested = False
|
||||
|
||||
# Conditional TLS setup
|
||||
if self.use_tls:
|
||||
import ssl
|
||||
@@ -241,7 +256,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']}) ..."
|
||||
)
|
||||
@@ -252,6 +267,7 @@ class _BrokerConnection:
|
||||
|
||||
def disconnect(self):
|
||||
"""Disconnect from broker"""
|
||||
self._shutdown_requested = True
|
||||
self._running = False
|
||||
self._loop_running = False
|
||||
|
||||
@@ -265,7 +281,7 @@ class _BrokerConnection:
|
||||
|
||||
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"""
|
||||
@@ -306,7 +322,7 @@ class _BrokerConnection:
|
||||
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)"
|
||||
)
|
||||
@@ -319,11 +335,12 @@ class _BrokerConnection:
|
||||
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
|
||||
|
||||
self._schedule_reconnect(reason="JWT token expiry")
|
||||
self.client.disconnect()
|
||||
|
||||
|
||||
# ====================================================================
|
||||
@@ -364,11 +381,11 @@ 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(
|
||||
logger.info(
|
||||
f"Multi-broker mode: connecting to all {len(LETSMESH_BROKERS)} built-in brokers"
|
||||
)
|
||||
else:
|
||||
@@ -376,16 +393,16 @@ class MeshCoreToMqttJwtPusher:
|
||||
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:
|
||||
@@ -406,7 +423,9 @@ class MeshCoreToMqttJwtPusher:
|
||||
self.stats_provider = stats_provider
|
||||
self._status_task = None
|
||||
self._running = False
|
||||
self._shutdown_requested = False
|
||||
self._lock = threading.Lock()
|
||||
self._connect_timers: List[threading.Timer] = []
|
||||
|
||||
# Create broker connections
|
||||
self.connections: List[_BrokerConnection] = []
|
||||
@@ -426,10 +445,13 @@ 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"""
|
||||
if self._shutdown_requested:
|
||||
return
|
||||
|
||||
# Publish initial status on first connection
|
||||
if not self._status_task and self.status_interval > 0:
|
||||
self._running = True
|
||||
@@ -439,7 +461,7 @@ 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"""
|
||||
@@ -448,12 +470,15 @@ class MeshCoreToMqttJwtPusher:
|
||||
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"""
|
||||
self._shutdown_requested = False
|
||||
self._connect_timers = []
|
||||
|
||||
for idx, conn in enumerate(self.connections):
|
||||
try:
|
||||
if idx == 0:
|
||||
@@ -462,40 +487,54 @@ 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()
|
||||
self._connect_timers.append(timer)
|
||||
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)"""
|
||||
if self._shutdown_requested:
|
||||
return
|
||||
|
||||
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"""
|
||||
self._shutdown_requested = True
|
||||
|
||||
# Cancel any delayed connect timers first.
|
||||
for timer in self._connect_timers:
|
||||
try:
|
||||
timer.cancel()
|
||||
except Exception:
|
||||
pass
|
||||
self._connect_timers = []
|
||||
|
||||
# Stop the heartbeat loop
|
||||
self._running = False
|
||||
|
||||
# Publish offline status before disconnecting
|
||||
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
|
||||
try:
|
||||
self.publish_status(state="offline", origin=self.node_name, radio_config=self.radio_config)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Disconnect all brokers
|
||||
for conn in self.connections:
|
||||
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")
|
||||
self._status_task = None
|
||||
logger.info("Disconnected from all brokers")
|
||||
|
||||
def _status_heartbeat_loop(self):
|
||||
"""Background thread that publishes periodic status updates"""
|
||||
@@ -507,11 +546,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)
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
@@ -582,10 +621,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
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -19,6 +19,14 @@ class RRDToolHandler:
|
||||
self.rrd_path = self.storage_dir / "metrics.rrd"
|
||||
self.available = RRDTOOL_AVAILABLE
|
||||
self._init_rrd()
|
||||
# Timestamp of the last successful rrdtool.update() call (unix seconds,
|
||||
# aligned to the 60-second RRD step). Used to skip writes whose period
|
||||
# has already been committed — no rrdtool.info() call needed.
|
||||
self._last_rrd_update: int = 0
|
||||
# Read-side cache: rrdtool.fetch() returns 24 h of data and is a
|
||||
# blocking disk read. Cache the result for 60 s — matching the RRD
|
||||
# step size — so repeated dashboard refreshes don't hammer the SD card.
|
||||
self._get_data_cache: tuple = (0.0, None) # (fetched_at, result)
|
||||
|
||||
def _init_rrd(self):
|
||||
if not self.available:
|
||||
@@ -73,20 +81,23 @@ class RRDToolHandler:
|
||||
logger.error(f"Failed to create RRD database: {e}")
|
||||
|
||||
def update_packet_metrics(self, record: dict, cumulative_counts: dict):
|
||||
"""Write packet metrics to RRD, throttled to once per 60-second step.
|
||||
|
||||
RRD enforces a 60-second minimum step between updates. We track the
|
||||
last written timestamp ourselves — no rrdtool.info() call needed, which
|
||||
previously allocated thousands of Python objects per call.
|
||||
"""
|
||||
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))
|
||||
if timestamp <= last_update:
|
||||
return
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to get RRD info for packet update: {e}")
|
||||
# Skip if this packet falls in the same 60-second period we already wrote.
|
||||
if timestamp <= self._last_rrd_update:
|
||||
return
|
||||
|
||||
# Build update string from cumulative counts
|
||||
rx_total = cumulative_counts.get("rx_total", 0)
|
||||
tx_total = cumulative_counts.get("tx_total", 0)
|
||||
drop_total = cumulative_counts.get("drop_total", 0)
|
||||
@@ -97,7 +108,6 @@ class RRDToolHandler:
|
||||
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")
|
||||
@@ -117,6 +127,7 @@ class RRDToolHandler:
|
||||
values = f"{basic_values}:{type_values_str}"
|
||||
|
||||
rrdtool.update(str(self.rrd_path), values)
|
||||
self._last_rrd_update = timestamp
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update RRD packet metrics: {e}")
|
||||
@@ -134,9 +145,20 @@ class RRDToolHandler:
|
||||
)
|
||||
return None
|
||||
|
||||
# Serve from cache if result is still fresh. RRD step is 60 s, so
|
||||
# anything newer than that is guaranteed to be identical to a live fetch.
|
||||
# Only the default (full 24-hour, no explicit bounds) call is cached —
|
||||
# explicit start/end requests always bypass the cache.
|
||||
now = time.time()
|
||||
use_cache = start_time is None and end_time is None
|
||||
if use_cache:
|
||||
cache_fetched_at, cache_result = self._get_data_cache
|
||||
if now - cache_fetched_at < 60.0 and cache_result is not None:
|
||||
return cache_result
|
||||
|
||||
try:
|
||||
if end_time is None:
|
||||
end_time = int(time.time())
|
||||
end_time = int(now)
|
||||
if start_time is None:
|
||||
start_time = end_time - (24 * 3600)
|
||||
|
||||
@@ -192,6 +214,10 @@ class RRDToolHandler:
|
||||
|
||||
result["timestamps"] = timestamps
|
||||
|
||||
# Populate read cache for default (unconstrained) calls only.
|
||||
if use_cache:
|
||||
self._get_data_cache = (now, result)
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,4 @@
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
@@ -5,8 +6,7 @@ from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from .letsmesh_handler import MeshCoreToMqttJwtPusher
|
||||
from .mqtt_handler import MQTTHandler
|
||||
from .mqtt_handler import MeshCoreToMqttPusher
|
||||
from .rrdtool_handler import RRDToolHandler
|
||||
from .sqlite_handler import SQLiteHandler
|
||||
from .storage_utils import PacketRecord
|
||||
@@ -18,6 +18,8 @@ class StorageCollector:
|
||||
def __init__(self, config: dict, local_identity=None, repeater_handler=None):
|
||||
self.config = config
|
||||
self.repeater_handler = repeater_handler
|
||||
self.glass_publish_callback = None
|
||||
self._pending_tasks = set()
|
||||
|
||||
storage_dir_cfg = (
|
||||
config.get("storage", {}).get("storage_dir")
|
||||
@@ -27,45 +29,28 @@ class StorageCollector:
|
||||
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")
|
||||
node_id = local_identity.get_public_key().hex() if local_identity else "unknown"
|
||||
|
||||
self.sqlite_handler = SQLiteHandler(self.storage_dir)
|
||||
self.rrd_handler = RRDToolHandler(self.storage_dir)
|
||||
self.mqtt_handler = MQTTHandler(config.get("mqtt", {}), node_name, node_id)
|
||||
|
||||
# Initialize LetsMesh handler if configured
|
||||
self.letsmesh_handler = None
|
||||
if config.get("letsmesh", {}).get("enabled", False) and local_identity:
|
||||
# Initialize MQTT handler if configured
|
||||
self.mqtt_handler = None
|
||||
if (config.get("mqtt_brokers", {}) or config.get("letsmesh", {}) or config.get("mqtt", {})) and local_identity:
|
||||
try:
|
||||
# Pass local_identity directly (supports both standard and firmware keys)
|
||||
self.letsmesh_handler = MeshCoreToMqttJwtPusher(
|
||||
self.mqtt_handler = MeshCoreToMqttPusher(
|
||||
local_identity=local_identity,
|
||||
config=config,
|
||||
stats_provider=self._get_live_stats,
|
||||
)
|
||||
self.letsmesh_handler.connect()
|
||||
|
||||
# Get disallowed packet types from config
|
||||
from ..config import get_node_info
|
||||
|
||||
node_info = get_node_info(config)
|
||||
self.disallowed_packet_types = set(node_info["disallowed_packet_types"])
|
||||
self.mqtt_handler.connect()
|
||||
|
||||
public_key_hex = local_identity.get_public_key().hex()
|
||||
logger.info(
|
||||
f"LetsMesh handler initialized with public key: {public_key_hex[:16]}..."
|
||||
f"MQTT handler initialized with public key: {public_key_hex[:16]}..."
|
||||
)
|
||||
if self.disallowed_packet_types:
|
||||
logger.info(f"Disallowed packet types: {sorted(self.disallowed_packet_types)}")
|
||||
else:
|
||||
logger.info("All packet types allowed")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize LetsMesh handler: {e}")
|
||||
self.letsmesh_handler = None
|
||||
self.disallowed_packet_types = set()
|
||||
else:
|
||||
self.disallowed_packet_types = set()
|
||||
logger.error(f"Failed to initialize MQTT handler: {e}")
|
||||
self.mqtt_handler = None
|
||||
|
||||
# Initialize hardware stats collector
|
||||
from .hardware_stats import HardwareStatsCollector
|
||||
@@ -75,16 +60,51 @@ class StorageCollector:
|
||||
|
||||
# Initialize WebSocket handler for real-time updates
|
||||
self.websocket_available = False
|
||||
self.websocket_has_connected_clients = lambda: False
|
||||
self._last_ws_stats_broadcast: float = 0.0
|
||||
self._ws_stats_broadcast_interval_sec: float = 5.0
|
||||
try:
|
||||
from .websocket_handler import broadcast_packet, broadcast_stats
|
||||
from .websocket_handler import (
|
||||
broadcast_packet,
|
||||
broadcast_stats,
|
||||
has_connected_clients,
|
||||
)
|
||||
|
||||
self.websocket_broadcast_packet = broadcast_packet
|
||||
self.websocket_broadcast_stats = broadcast_stats
|
||||
self.websocket_has_connected_clients = has_connected_clients
|
||||
self.websocket_available = True
|
||||
logger.info("WebSocket handler initialized for real-time updates")
|
||||
except ImportError:
|
||||
logger.debug("WebSocket handler not available")
|
||||
|
||||
def _track_task(self, task: asyncio.Task):
|
||||
"""Track background task for lifecycle management and error handling."""
|
||||
self._pending_tasks.add(task)
|
||||
|
||||
def on_done(t: asyncio.Task):
|
||||
self._pending_tasks.discard(t)
|
||||
try:
|
||||
t.result()
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(f"Background task error: {e}", exc_info=True)
|
||||
|
||||
task.add_done_callback(on_done)
|
||||
|
||||
def _schedule_background(self, coro_factory, *args, sync_fallback=None):
|
||||
"""Schedule a coroutine if a loop exists; otherwise run sync fallback."""
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
if sync_fallback is not None:
|
||||
sync_fallback(*args)
|
||||
return
|
||||
|
||||
task = loop.create_task(coro_factory(*args))
|
||||
self._track_task(task)
|
||||
|
||||
def _get_live_stats(self) -> dict:
|
||||
"""Get live stats from RepeaterHandler"""
|
||||
if not self.repeater_handler:
|
||||
@@ -130,97 +150,158 @@ class StorageCollector:
|
||||
|
||||
return stats
|
||||
|
||||
def record_packet(self, packet_record: dict, skip_letsmesh_if_invalid: bool = True):
|
||||
"""Record packet to storage and publish to MQTT/LetsMesh
|
||||
def record_packet(self, packet_record: dict, skip_mqtt_if_invalid: bool = True):
|
||||
"""Record packet to storage and publish to MQTT
|
||||
|
||||
Args:
|
||||
packet_record: Dictionary containing packet information
|
||||
skip_letsmesh_if_invalid: If True, don't publish packets with drop_reason to LetsMesh
|
||||
skip_mqtt_if_invalid: If True, don't publish packets with drop_reason to mqtt
|
||||
"""
|
||||
logger.debug(
|
||||
f"Recording packet: type={packet_record.get('type')}, "
|
||||
f"transmitted={packet_record.get('transmitted')}"
|
||||
)
|
||||
|
||||
# Store to local databases and publish to local MQTT
|
||||
# HOT PATH: Store to local databases only (fast, non-blocking)
|
||||
self.sqlite_handler.store_packet(packet_record)
|
||||
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
|
||||
# DEFERRED: Publish to network sinks and WebSocket in background tasks
|
||||
# This prevents network latency from blocking packet processing
|
||||
self._schedule_background(
|
||||
self._deferred_publish,
|
||||
packet_record,
|
||||
skip_mqtt_if_invalid,
|
||||
sync_fallback=self._publish_packet_sync,
|
||||
)
|
||||
|
||||
async def _deferred_publish(self, packet_record: dict, skip_mqtt: bool):
|
||||
"""Deferred background task for all network publishing operations."""
|
||||
try:
|
||||
self._publish_packet_sync(packet_record, skip_mqtt)
|
||||
except Exception as e:
|
||||
logger.error(f"Deferred publish failed: {e}", exc_info=True)
|
||||
|
||||
def _publish_packet_sync(self, packet_record: dict, skip_mqtt: bool):
|
||||
"""Publish packet updates synchronously (used when no asyncio loop is active)."""
|
||||
self._publish_to_glass(packet_record, "packet")
|
||||
|
||||
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,
|
||||
},
|
||||
}
|
||||
)
|
||||
if self.websocket_has_connected_clients():
|
||||
now_mono = time.monotonic()
|
||||
if (
|
||||
now_mono - self._last_ws_stats_broadcast
|
||||
>= self._ws_stats_broadcast_interval_sec
|
||||
):
|
||||
self._last_ws_stats_broadcast = now_mono
|
||||
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},
|
||||
}
|
||||
)
|
||||
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')}"
|
||||
)
|
||||
else:
|
||||
self._publish_to_letsmesh(packet_record)
|
||||
|
||||
def _publish_to_letsmesh(self, packet_record: dict):
|
||||
"""Publish packet to LetsMesh broker if enabled and allowed"""
|
||||
if not self.letsmesh_handler:
|
||||
self._publish_packet_to_mqtt(packet_record)
|
||||
|
||||
def _publish_packet_to_mqtt(self, packet_record: dict):
|
||||
"""Publish packet to mqtt broker if enabled and allowed"""
|
||||
if not self.mqtt_handler:
|
||||
return
|
||||
|
||||
try:
|
||||
packet_type = packet_record.get("type")
|
||||
if packet_type is None:
|
||||
logger.error("Cannot publish to LetsMesh: packet_record missing 'type' field")
|
||||
return
|
||||
|
||||
if packet_type in self.disallowed_packet_types:
|
||||
logger.debug(f"Skipped publishing packet type 0x{packet_type:02X} (disallowed)")
|
||||
logger.error("Cannot publish to mqtt: packet_record missing 'type' field")
|
||||
return
|
||||
|
||||
node_name = self.config.get("repeater", {}).get("node_name", "Unknown")
|
||||
packet = PacketRecord.from_packet_record(
|
||||
packet_record, origin=node_name, origin_id=self.letsmesh_handler.public_key
|
||||
packet_record, origin=node_name, origin_id=self.mqtt_handler.public_key
|
||||
)
|
||||
|
||||
if packet:
|
||||
self.letsmesh_handler.publish_packet(packet.to_dict())
|
||||
logger.debug(f"Published packet type 0x{packet_type:02X} to LetsMesh")
|
||||
self.mqtt_handler.publish_packet(packet.to_dict())
|
||||
logger.debug(f"Published packet type 0x{packet_type:02X} to mqtt")
|
||||
else:
|
||||
logger.debug("Skipped LetsMesh publish: packet missing raw_packet data")
|
||||
logger.debug("Skipped mqtt publish: packet missing raw_packet data")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to publish packet to LetsMesh: {e}", exc_info=True)
|
||||
logger.error(f"Failed to publish packet to mqtt: {e}", exc_info=True)
|
||||
|
||||
def record_advert(self, advert_record: dict):
|
||||
"""Record advert to storage and defer network publishing to background tasks."""
|
||||
self.sqlite_handler.store_advert(advert_record)
|
||||
self.mqtt_handler.publish(advert_record, "advert")
|
||||
self._schedule_background(
|
||||
self._deferred_publish_advert,
|
||||
advert_record,
|
||||
sync_fallback=self._publish_advert_sync,
|
||||
)
|
||||
|
||||
async def _deferred_publish_advert(self, advert_record: dict):
|
||||
"""Deferred background task for advert publishing."""
|
||||
try:
|
||||
self._publish_advert_sync(advert_record)
|
||||
except Exception as e:
|
||||
logger.error(f"Deferred advert publish failed: {e}", exc_info=True)
|
||||
|
||||
def _publish_advert_sync(self, advert_record: dict):
|
||||
if self.mqtt_handler:
|
||||
self.mqtt_handler.publish_mqtt(advert_record, "advert")
|
||||
self._publish_to_glass(advert_record, "advert")
|
||||
|
||||
def record_noise_floor(self, noise_floor_dbm: float):
|
||||
"""Record noise floor to storage and defer network publishing to background tasks."""
|
||||
noise_record = {"timestamp": time.time(), "noise_floor_dbm": noise_floor_dbm}
|
||||
self.sqlite_handler.store_noise_floor(noise_record)
|
||||
self.mqtt_handler.publish(noise_record, "noise_floor")
|
||||
self._schedule_background(
|
||||
self._deferred_publish_noise_floor,
|
||||
noise_record,
|
||||
sync_fallback=self._publish_noise_floor_sync,
|
||||
)
|
||||
|
||||
async def _deferred_publish_noise_floor(self, noise_record: dict):
|
||||
"""Deferred background task for noise floor publishing."""
|
||||
try:
|
||||
self._publish_noise_floor_sync(noise_record)
|
||||
except Exception as e:
|
||||
logger.error(f"Deferred noise floor publish failed: {e}", exc_info=True)
|
||||
|
||||
def _publish_noise_floor_sync(self, noise_record: dict):
|
||||
if self.mqtt_handler:
|
||||
self.mqtt_handler.publish_mqtt(noise_record, "noise_floor")
|
||||
self._publish_to_glass(noise_record, "noise_floor")
|
||||
|
||||
def record_crc_errors(self, count: int):
|
||||
"""Record a batch of CRC errors detected since last poll."""
|
||||
"""Record a batch of CRC errors detected since last poll and defer publishing."""
|
||||
crc_record = {"timestamp": time.time(), "count": count}
|
||||
self.sqlite_handler.store_crc_errors(crc_record)
|
||||
self.mqtt_handler.publish(crc_record, "crc_errors")
|
||||
self._schedule_background(
|
||||
self._deferred_publish_crc_errors,
|
||||
crc_record,
|
||||
sync_fallback=self._publish_crc_errors_sync,
|
||||
)
|
||||
|
||||
async def _deferred_publish_crc_errors(self, crc_record: dict):
|
||||
"""Deferred background task for CRC error publishing."""
|
||||
try:
|
||||
self._publish_crc_errors_sync(crc_record)
|
||||
except Exception as e:
|
||||
logger.error(f"Deferred CRC errors publish failed: {e}", exc_info=True)
|
||||
|
||||
def _publish_crc_errors_sync(self, crc_record: dict):
|
||||
if self.mqtt_handler:
|
||||
self.mqtt_handler.publish_mqtt(crc_record, "crc_errors")
|
||||
self._publish_to_glass(crc_record, "crc_errors")
|
||||
|
||||
def get_crc_error_count(self, hours: int = 24) -> int:
|
||||
return self.sqlite_handler.get_crc_error_count(hours)
|
||||
@@ -247,6 +328,28 @@ class StorageCollector:
|
||||
packet_type, route, start_timestamp, end_timestamp, limit, offset
|
||||
)
|
||||
|
||||
def get_airtime_data(
|
||||
self,
|
||||
start_timestamp: Optional[float] = None,
|
||||
end_timestamp: Optional[float] = None,
|
||||
limit: int = 50000,
|
||||
) -> list:
|
||||
return self.sqlite_handler.get_airtime_data(start_timestamp, end_timestamp, limit)
|
||||
|
||||
def get_airtime_buckets(
|
||||
self,
|
||||
start_timestamp: float,
|
||||
end_timestamp: float,
|
||||
bucket_seconds: int = 60,
|
||||
sf: int = 9,
|
||||
bw_hz: int = 62500,
|
||||
cr: int = 5,
|
||||
preamble: int = 17,
|
||||
) -> dict:
|
||||
return self.sqlite_handler.get_airtime_buckets(
|
||||
start_timestamp, end_timestamp, bucket_seconds, sf, bw_hz, cr, preamble
|
||||
)
|
||||
|
||||
def get_packet_by_hash(self, packet_hash: str) -> Optional[dict]:
|
||||
return self.sqlite_handler.get_packet_by_hash(packet_hash)
|
||||
|
||||
@@ -305,13 +408,28 @@ class StorageCollector:
|
||||
return self.sqlite_handler.get_noise_floor_stats(hours)
|
||||
|
||||
def close(self):
|
||||
self.mqtt_handler.close()
|
||||
if self.letsmesh_handler:
|
||||
# Cancel all pending background tasks
|
||||
for task in self._pending_tasks:
|
||||
if not task.done():
|
||||
task.cancel()
|
||||
|
||||
if self.mqtt_handler:
|
||||
try:
|
||||
self.letsmesh_handler.disconnect()
|
||||
logger.info("LetsMesh handler disconnected")
|
||||
self.mqtt_handler.disconnect()
|
||||
logger.info("MQTT handler disconnected")
|
||||
except Exception as e:
|
||||
logger.error(f"Error disconnecting LetsMesh handler: {e}")
|
||||
logger.error(f"Error disconnecting MQTT handler: {e}")
|
||||
|
||||
def set_glass_publisher(self, publish_callback):
|
||||
self.glass_publish_callback = publish_callback
|
||||
|
||||
def _publish_to_glass(self, record: dict, record_type: str):
|
||||
if not self.glass_publish_callback:
|
||||
return
|
||||
try:
|
||||
self.glass_publish_callback(record_type, record)
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to publish telemetry to Glass MQTT: {e}")
|
||||
|
||||
def create_transport_key(
|
||||
self,
|
||||
|
||||
@@ -10,7 +10,7 @@ class PacketRecord:
|
||||
"""
|
||||
Data class for packet record format.
|
||||
Converts internal packet_record format to standardized publish format.
|
||||
Reusable across MQTT, LetsMesh, and other handlers.
|
||||
Reusable across MQTT and other handlers.
|
||||
"""
|
||||
|
||||
origin: str
|
||||
|
||||
@@ -126,6 +126,11 @@ def broadcast_stats(stats_data: dict):
|
||||
_connected_clients.discard(client)
|
||||
|
||||
|
||||
def has_connected_clients() -> bool:
|
||||
"""Return True when at least one authenticated websocket client is connected."""
|
||||
return bool(_connected_clients)
|
||||
|
||||
|
||||
def _heartbeat_loop():
|
||||
"""Background thread to send periodic pings to all connected clients"""
|
||||
global _heartbeat_running
|
||||
|
||||
+377
-136
@@ -1,9 +1,10 @@
|
||||
import asyncio
|
||||
import copy
|
||||
import logging
|
||||
import random
|
||||
import struct
|
||||
import time
|
||||
from collections import OrderedDict
|
||||
from collections import OrderedDict, deque
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from pymc_core.node.handlers.base import BaseHandler
|
||||
@@ -12,6 +13,7 @@ from pymc_core.protocol.constants import (
|
||||
MAX_PATH_SIZE,
|
||||
PAYLOAD_TYPE_ADVERT,
|
||||
PAYLOAD_TYPE_ANON_REQ,
|
||||
PAYLOAD_TYPE_TRACE,
|
||||
PH_ROUTE_MASK,
|
||||
PH_TYPE_MASK,
|
||||
PH_TYPE_SHIFT,
|
||||
@@ -64,10 +66,12 @@ class RepeaterHandler(BaseHandler):
|
||||
300, config.get("repeater", {}).get("cache_ttl", 3600)
|
||||
) # Min 5 min, default 1 hour
|
||||
self.max_cache_size = 1000
|
||||
self.max_duplicates_per_packet = 20
|
||||
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)
|
||||
self.use_score_for_tx = config.get("repeater", {}).get("use_score_for_tx", False)
|
||||
self.score_threshold = config.get("repeater", {}).get("score_threshold", 0.3)
|
||||
self.max_flood_hops = config.get("repeater", {}).get("max_flood_hops", 64)
|
||||
self.send_advert_interval_hours = config.get("repeater", {}).get(
|
||||
"send_advert_interval_hours", 10
|
||||
)
|
||||
@@ -97,9 +101,17 @@ class RepeaterHandler(BaseHandler):
|
||||
self.rx_count = 0
|
||||
self.forwarded_count = 0
|
||||
self.dropped_count = 0
|
||||
self.recent_packets = []
|
||||
self.max_recent_packets = 50
|
||||
self.recent_packets = deque(maxlen=self.max_recent_packets)
|
||||
self._recent_hash_index = {}
|
||||
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:
|
||||
@@ -113,8 +125,11 @@ class RepeaterHandler(BaseHandler):
|
||||
|
||||
# Initialize background timer tracking
|
||||
self.last_noise_measurement = time.time()
|
||||
self.last_cache_cleanup = time.time()
|
||||
self.last_db_cleanup = time.time()
|
||||
self.noise_floor_interval = NOISE_FLOOR_INTERVAL # 30 seconds
|
||||
self._background_task = None
|
||||
self._cached_noise_floor = None
|
||||
self._last_crc_error_count = 0 # Track radio counter for delta persistence
|
||||
|
||||
# Cache transport keys for efficient lookup
|
||||
@@ -122,6 +137,24 @@ class RepeaterHandler(BaseHandler):
|
||||
self._transport_keys_cache_time = 0
|
||||
self._transport_keys_cache_ttl = 60 # Cache for 60 seconds
|
||||
|
||||
# Serialise all radio TX calls.
|
||||
#
|
||||
# Background: since the queue loop dispatches each packet as an
|
||||
# asyncio.create_task, multiple _route_packet coroutines can have their
|
||||
# TX delay timers running concurrently — which is the intended behaviour
|
||||
# (firmware nodes do the same with a hardware timer). However, the
|
||||
# LoRa radio is half-duplex: it can only transmit one packet at a time.
|
||||
# Without serialisation, two tasks whose delay timers expire near-
|
||||
# simultaneously both call dispatcher.send_packet, interleaving SPI/serial
|
||||
# commands to the radio and both passing the LBT check before either has
|
||||
# actually transmitted.
|
||||
#
|
||||
# _tx_lock is acquired after each delay sleep and held for the entire
|
||||
# send_packet call. Delays still run concurrently; only the radio
|
||||
# access is serialised. This also eliminates the TOCTOU gap in duty-cycle
|
||||
# enforcement — see schedule_retransmit / delayed_send for details.
|
||||
self._tx_lock = asyncio.Lock()
|
||||
|
||||
self._start_background_tasks()
|
||||
|
||||
async def __call__(
|
||||
@@ -131,17 +164,36 @@ class RepeaterHandler(BaseHandler):
|
||||
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
|
||||
pkt_hash_full = packet.calculate_packet_hash().hex().upper()
|
||||
|
||||
# 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'')}, "
|
||||
f"path_len={len(packet.path) if packet.path else 0}, "
|
||||
f"rssi={metadata.get('rssi', 'N/A')}, snr={metadata.get('snr', 'N/A')}, mode={mode}"
|
||||
)
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug(
|
||||
f"RX packet: header=0x{packet.header:02x}, payload_len={len(packet.payload or b'')}, "
|
||||
f"path_len={len(packet.path) if packet.path else 0}, "
|
||||
f"rssi={metadata.get('rssi', 'N/A')}, snr={metadata.get('snr', 'N/A')}, mode={mode}"
|
||||
)
|
||||
|
||||
# clone the packet to avoid modifying the original
|
||||
processed_packet = copy.deepcopy(packet)
|
||||
@@ -158,23 +210,25 @@ class RepeaterHandler(BaseHandler):
|
||||
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)
|
||||
# Process for forwarding (skip if repeat disabled or if this is a local transmission).
|
||||
# Pass pkt_hash_full so flood_forward / direct_forward don't recompute SHA-256.
|
||||
result = (
|
||||
None
|
||||
if (monitor_mode or local_transmission)
|
||||
else self.process_packet(processed_packet, snr)
|
||||
if (not allow_forward or local_transmission)
|
||||
else self.process_packet(processed_packet, snr, packet_hash=pkt_hash_full)
|
||||
)
|
||||
forwarded_path_hashes = None
|
||||
|
||||
# For local transmissions, create a direct transmission result
|
||||
if local_transmission and not monitor_mode:
|
||||
# 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)
|
||||
self.mark_seen(packet, packet_hash=pkt_hash_full)
|
||||
# Calculate transmission delay for local packets
|
||||
delay = self._calculate_tx_delay(packet, snr)
|
||||
result = (packet, delay)
|
||||
forwarded_path_hashes = packet.get_path_hashes_hex()
|
||||
logger.debug(f"Local transmission: calculated delay {delay:.3f}s")
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug(f"Local transmission: calculated delay {delay:.3f}s")
|
||||
|
||||
if result:
|
||||
fwd_pkt, delay = result
|
||||
@@ -264,15 +318,18 @@ 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
|
||||
processed_packet, packet_hash=pkt_hash_full
|
||||
)
|
||||
logger.debug(f"Packet not forwarded: {drop_reason}")
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug(f"Packet not forwarded: {drop_reason}")
|
||||
|
||||
# Extract packet type and route from header
|
||||
if not hasattr(packet, "header") or packet.header is None:
|
||||
@@ -283,17 +340,22 @@ class RepeaterHandler(BaseHandler):
|
||||
header_info = PacketHeaderUtils.parse_header(packet.header)
|
||||
payload_type = header_info["payload_type"]
|
||||
route_type = header_info["route_type"]
|
||||
logger.debug(
|
||||
f"Packet header=0x{packet.header:02x}, type={payload_type}, route={route_type}"
|
||||
)
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug(
|
||||
f"Packet header=0x{packet.header:02x}, type={payload_type}, route={route_type}"
|
||||
)
|
||||
|
||||
# Check if this is a duplicate
|
||||
pkt_hash = packet.calculate_packet_hash().hex().upper()
|
||||
is_dupe = pkt_hash in self.seen_packets and not transmitted
|
||||
is_dupe = pkt_hash_full 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
|
||||
|
||||
display_hashes = (
|
||||
original_path_hashes if original_path_hashes else packet.get_path_hashes_hex()
|
||||
@@ -321,44 +383,40 @@ class RepeaterHandler(BaseHandler):
|
||||
lbt_attempts=lbt_attempts,
|
||||
lbt_backoff_delays_ms=lbt_backoff_delays_ms,
|
||||
lbt_channel_busy=lbt_channel_busy,
|
||||
packet_hash=pkt_hash_full,
|
||||
)
|
||||
|
||||
# Store packet record to persistent storage
|
||||
# Skip LetsMesh only for invalid packets (not duplicates or operational drops)
|
||||
# Skip mqtt only for invalid packets (not duplicates or operational drops)
|
||||
if self.storage:
|
||||
try:
|
||||
# Only skip LetsMesh for actual invalid/bad packets
|
||||
# Only skip mqtt for actual invalid/bad packets
|
||||
invalid_reasons = ["Invalid advert packet", "Empty payload", "Path too long"]
|
||||
skip_letsmesh = drop_reason in invalid_reasons if drop_reason else False
|
||||
self.storage.record_packet(packet_record, skip_letsmesh_if_invalid=skip_letsmesh)
|
||||
skip_mqtt = drop_reason in invalid_reasons if drop_reason else False
|
||||
self.storage.record_packet(packet_record, skip_mqtt_if_invalid=skip_mqtt)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to store packet record: {e}")
|
||||
|
||||
# If this is a duplicate, try to attach it to the original packet
|
||||
if is_dupe and len(self.recent_packets) > 0:
|
||||
# Find the original packet with same hash
|
||||
for idx in range(len(self.recent_packets) - 1, -1, -1):
|
||||
prev_pkt = self.recent_packets[idx]
|
||||
if prev_pkt.get("packet_hash") == packet_record["packet_hash"]:
|
||||
# Add duplicate to original packet's duplicate list
|
||||
if "duplicates" not in prev_pkt:
|
||||
prev_pkt["duplicates"] = []
|
||||
prev_pkt = self._recent_hash_index.get(packet_record["packet_hash"])
|
||||
if prev_pkt is not None:
|
||||
# Add duplicate to original packet's duplicate list
|
||||
if "duplicates" not in prev_pkt:
|
||||
prev_pkt["duplicates"] = []
|
||||
if len(prev_pkt["duplicates"]) < self.max_duplicates_per_packet:
|
||||
prev_pkt["duplicates"].append(packet_record)
|
||||
# Don't add duplicate to main list, just track in original
|
||||
break
|
||||
# Don't add duplicate to main list, just track in original
|
||||
else:
|
||||
# Original not found, add as regular packet
|
||||
self.recent_packets.append(packet_record)
|
||||
self._append_recent_packet(packet_record)
|
||||
else:
|
||||
# Not a duplicate or first occurrence
|
||||
self.recent_packets.append(packet_record)
|
||||
|
||||
if len(self.recent_packets) > self.max_recent_packets:
|
||||
self.recent_packets.pop(0)
|
||||
self._append_recent_packet(packet_record)
|
||||
|
||||
def log_trace_record(self, packet_record: dict) -> None:
|
||||
"""Manually log a packet trace record (used by external callers)"""
|
||||
self.recent_packets.append(packet_record)
|
||||
self._append_recent_packet(packet_record)
|
||||
|
||||
self.rx_count += 1
|
||||
if packet_record.get("transmitted", False):
|
||||
@@ -373,14 +431,14 @@ class RepeaterHandler(BaseHandler):
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to store packet record: {e}")
|
||||
|
||||
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.
|
||||
|
||||
TRACE packets are excluded: TraceHelper.log_trace_record stores the real trace path;
|
||||
packet.path on TRACE holds SNR bytes, not routing hashes.
|
||||
"""
|
||||
if not self.storage:
|
||||
return
|
||||
@@ -392,6 +450,8 @@ class RepeaterHandler(BaseHandler):
|
||||
header_info = PacketHeaderUtils.parse_header(packet.header)
|
||||
payload_type = header_info["payload_type"]
|
||||
route_type = header_info["route_type"]
|
||||
if payload_type == PAYLOAD_TYPE_TRACE:
|
||||
return
|
||||
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)
|
||||
@@ -407,16 +467,66 @@ class RepeaterHandler(BaseHandler):
|
||||
path_hash,
|
||||
src_hash,
|
||||
dst_hash,
|
||||
packet_hash=packet.calculate_packet_hash().hex().upper(),
|
||||
)
|
||||
try:
|
||||
self.storage.record_packet(packet_record, skip_letsmesh_if_invalid=False)
|
||||
self.storage.record_packet(packet_record, skip_mqtt_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)
|
||||
self._append_recent_packet(packet_record)
|
||||
|
||||
def record_duplicate(self, packet: Packet, rssi: int = 0, snr: float = 0.0) -> None:
|
||||
"""Record a known-duplicate packet for UI/storage visibility without forwarding.
|
||||
|
||||
Called by the raw_packet_subscriber path so that path variants blocked
|
||||
by the Dispatcher's payload-based dedup still appear in the UI.
|
||||
"""
|
||||
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
|
||||
self.flood_dup_count += 1
|
||||
elif route_type in (ROUTE_TYPE_DIRECT, ROUTE_TYPE_TRANSPORT_DIRECT):
|
||||
self.recv_direct_count += 1
|
||||
self.direct_dup_count += 1
|
||||
|
||||
header_info = PacketHeaderUtils.parse_header(packet.header)
|
||||
payload_type = header_info["payload_type"]
|
||||
route_type_parsed = 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_parsed, rssi, snr,
|
||||
original_path_hashes, path_hash_size, path_hash,
|
||||
src_hash, dst_hash,
|
||||
transmitted=False,
|
||||
drop_reason="Duplicate",
|
||||
is_duplicate=True,
|
||||
packet_hash=packet.calculate_packet_hash().hex().upper(),
|
||||
)
|
||||
|
||||
if self.storage:
|
||||
try:
|
||||
self.storage.record_packet(packet_record, skip_mqtt_if_invalid=False)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to store duplicate record: {e}")
|
||||
|
||||
# Group under original in recent_packets
|
||||
if len(self.recent_packets) > 0:
|
||||
prev_pkt = self._recent_hash_index.get(packet_record["packet_hash"])
|
||||
if prev_pkt is not None:
|
||||
if "duplicates" not in prev_pkt:
|
||||
prev_pkt["duplicates"] = []
|
||||
prev_pkt["duplicates"].append(packet_record)
|
||||
else:
|
||||
self._append_recent_packet(packet_record)
|
||||
else:
|
||||
self._append_recent_packet(packet_record)
|
||||
|
||||
def cleanup_cache(self):
|
||||
|
||||
@@ -474,9 +584,10 @@ class RepeaterHandler(BaseHandler):
|
||||
lbt_attempts: int = 0,
|
||||
lbt_backoff_delays_ms=None,
|
||||
lbt_channel_busy: bool = False,
|
||||
packet_hash: Optional[str] = None,
|
||||
) -> dict:
|
||||
"""Build a single packet_record dict for storage and recent_packets."""
|
||||
pkt_hash = packet.calculate_packet_hash().hex().upper()
|
||||
pkt_hash = packet_hash or packet.calculate_packet_hash().hex().upper()
|
||||
payload = getattr(packet, "payload", None)
|
||||
payload_len = len(payload or b"")
|
||||
return {
|
||||
@@ -513,9 +624,22 @@ class RepeaterHandler(BaseHandler):
|
||||
"lbt_channel_busy": lbt_channel_busy,
|
||||
}
|
||||
|
||||
def _get_drop_reason(self, packet: Packet) -> str:
|
||||
def _append_recent_packet(self, packet_record: dict) -> None:
|
||||
"""Append packet to bounded recent list and keep hash index aligned."""
|
||||
if len(self.recent_packets) >= self.max_recent_packets:
|
||||
oldest = self.recent_packets.popleft()
|
||||
oldest_hash = oldest.get("packet_hash") if isinstance(oldest, dict) else None
|
||||
if oldest_hash and self._recent_hash_index.get(oldest_hash) is oldest:
|
||||
del self._recent_hash_index[oldest_hash]
|
||||
|
||||
if self.is_duplicate(packet):
|
||||
self.recent_packets.append(packet_record)
|
||||
pkt_hash = packet_record.get("packet_hash") if isinstance(packet_record, dict) else None
|
||||
if pkt_hash:
|
||||
self._recent_hash_index[pkt_hash] = packet_record
|
||||
|
||||
def _get_drop_reason(self, packet: Packet, packet_hash: Optional[str] = None) -> str:
|
||||
|
||||
if self.is_duplicate(packet, packet_hash=packet_hash):
|
||||
return "Duplicate"
|
||||
|
||||
if not packet or not packet.payload:
|
||||
@@ -527,10 +651,10 @@ class RepeaterHandler(BaseHandler):
|
||||
route_type = packet.header & PH_ROUTE_MASK
|
||||
|
||||
if route_type == ROUTE_TYPE_FLOOD:
|
||||
# Check if global flood policy blocked it
|
||||
global_flood_allow = self.config.get("mesh", {}).get("global_flood_allow", True)
|
||||
if not global_flood_allow:
|
||||
return "Global flood policy disabled"
|
||||
# Check if unscoped flood policy blocked it
|
||||
unscoped_flood_allow = self.config.get("mesh", {}).get("unscoped_flood_allow", self.config.get("mesh", {}).get("global_flood_allow", True))
|
||||
if not unscoped_flood_allow:
|
||||
return "Unscoped flood policy disabled"
|
||||
|
||||
if route_type == ROUTE_TYPE_DIRECT:
|
||||
hash_size = packet.get_path_hash_size()
|
||||
@@ -543,16 +667,24 @@ class RepeaterHandler(BaseHandler):
|
||||
# Default reason
|
||||
return "Unknown"
|
||||
|
||||
def is_duplicate(self, packet: Packet) -> bool:
|
||||
def is_duplicate(self, packet: Packet, packet_hash: Optional[str] = None) -> bool:
|
||||
"""Return True if this packet has already been seen.
|
||||
|
||||
pkt_hash = packet.calculate_packet_hash().hex().upper()
|
||||
if pkt_hash in self.seen_packets:
|
||||
return True
|
||||
return False
|
||||
Accepts an optional pre-computed packet_hash to avoid a redundant SHA-256
|
||||
when the caller (e.g. __call__ → process_packet → flood/direct_forward)
|
||||
has already calculated the hash. Falls back to computing it if not provided.
|
||||
|
||||
def mark_seen(self, packet: Packet):
|
||||
INVARIANT: this method is synchronous with no await points. The caller
|
||||
(process_packet / __call__) relies on is_duplicate + mark_seen being
|
||||
effectively atomic within the asyncio event loop. Do NOT add any await
|
||||
here without revisiting that invariant.
|
||||
"""
|
||||
pkt_hash = packet_hash or packet.calculate_packet_hash().hex().upper()
|
||||
return pkt_hash in self.seen_packets
|
||||
|
||||
pkt_hash = packet.calculate_packet_hash().hex().upper()
|
||||
def mark_seen(self, packet: Packet, packet_hash: Optional[str] = None):
|
||||
|
||||
pkt_hash = packet_hash or packet.calculate_packet_hash().hex().upper()
|
||||
self.seen_packets[pkt_hash] = time.time()
|
||||
|
||||
if len(self.seen_packets) > self.max_cache_size:
|
||||
@@ -651,9 +783,10 @@ class RepeaterHandler(BaseHandler):
|
||||
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}'"
|
||||
)
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug(
|
||||
f"Transport code validated for key '{key_name}' with policy '{flood_policy}'"
|
||||
)
|
||||
|
||||
# Update last_used timestamp for this key
|
||||
try:
|
||||
@@ -662,9 +795,10 @@ class RepeaterHandler(BaseHandler):
|
||||
self.storage.update_transport_key(
|
||||
key_id=key_id, last_used=time.time()
|
||||
)
|
||||
logger.debug(
|
||||
f"Updated last_used timestamp for transport key '{key_name}'"
|
||||
)
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
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}"
|
||||
@@ -681,17 +815,23 @@ class RepeaterHandler(BaseHandler):
|
||||
continue
|
||||
|
||||
# No matching transport code found
|
||||
logger.debug(
|
||||
f"Transport code 0x{transport_code_0:04X} denied (checked {len(transport_keys)} keys)"
|
||||
)
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
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}"
|
||||
|
||||
def flood_forward(self, packet: Packet) -> Optional[Packet]:
|
||||
def flood_forward(self, packet: Packet, packet_hash: Optional[str] = None) -> Optional[Packet]:
|
||||
"""Forward a FLOOD packet, appending our hash to the path.
|
||||
|
||||
INVARIANT: purely synchronous — no await points. The is_duplicate +
|
||||
mark_seen pair is atomic within the asyncio event loop. Do NOT add any
|
||||
await here without revisiting that invariant in __call__ / process_packet.
|
||||
"""
|
||||
# Validate
|
||||
valid, reason = self.validate_packet(packet)
|
||||
if not valid:
|
||||
@@ -704,19 +844,20 @@ class RepeaterHandler(BaseHandler):
|
||||
if not packet.drop_reason:
|
||||
packet.drop_reason = "Marked do not retransmit"
|
||||
return None
|
||||
|
||||
# Check unscoped flood policy
|
||||
unscoped_flood_allow = self.config.get("mesh", {}).get("unscoped_flood_allow", self.config.get("mesh", {}).get("global_flood_allow", True))
|
||||
route_type = packet.header & PH_ROUTE_MASK
|
||||
if route_type == ROUTE_TYPE_FLOOD:
|
||||
if not unscoped_flood_allow:
|
||||
packet.drop_reason = "Unscoped flood policy disabled"
|
||||
return None
|
||||
|
||||
# Check global flood policy
|
||||
global_flood_allow = self.config.get("mesh", {}).get("global_flood_allow", True)
|
||||
if not global_flood_allow:
|
||||
route_type = packet.header & PH_ROUTE_MASK
|
||||
if route_type == ROUTE_TYPE_FLOOD or route_type == ROUTE_TYPE_TRANSPORT_FLOOD:
|
||||
|
||||
allowed, check_reason = self._check_transport_codes(packet)
|
||||
if not allowed:
|
||||
packet.drop_reason = check_reason
|
||||
return None
|
||||
else:
|
||||
packet.drop_reason = "Global flood policy disabled"
|
||||
#Check transport scopes flood policy
|
||||
if route_type == ROUTE_TYPE_TRANSPORT_FLOOD:
|
||||
allowed, check_reason = self._check_transport_codes(packet)
|
||||
if not allowed:
|
||||
packet.drop_reason = "Transport code not allowed to flood"
|
||||
return None
|
||||
|
||||
mode = self._get_loop_detect_mode()
|
||||
@@ -724,8 +865,8 @@ class RepeaterHandler(BaseHandler):
|
||||
packet.drop_reason = f"FLOOD loop detected ({mode})"
|
||||
return None
|
||||
|
||||
# Suppress duplicates
|
||||
if self.is_duplicate(packet):
|
||||
# Suppress duplicates — pass pre-computed hash to avoid a second SHA-256.
|
||||
if self.is_duplicate(packet, packet_hash=packet_hash):
|
||||
packet.drop_reason = "Duplicate"
|
||||
return None
|
||||
|
||||
@@ -737,6 +878,10 @@ class RepeaterHandler(BaseHandler):
|
||||
hash_size = packet.get_path_hash_size()
|
||||
hop_count = packet.get_path_hash_count()
|
||||
|
||||
if self.max_flood_hops > 0 and hop_count >= self.max_flood_hops:
|
||||
packet.drop_reason = f"Max flood hops limit reached ({hop_count}/{self.max_flood_hops})"
|
||||
return None
|
||||
|
||||
# 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"
|
||||
@@ -747,7 +892,7 @@ class RepeaterHandler(BaseHandler):
|
||||
packet.drop_reason = "Path would exceed MAX_PATH_SIZE"
|
||||
return None
|
||||
|
||||
self.mark_seen(packet)
|
||||
self.mark_seen(packet, packet_hash=packet_hash)
|
||||
|
||||
# Append hash_size bytes from our public key prefix
|
||||
packet.path.extend(self.local_hash_bytes[:hash_size])
|
||||
@@ -755,8 +900,13 @@ class RepeaterHandler(BaseHandler):
|
||||
|
||||
return packet
|
||||
|
||||
def direct_forward(self, packet: Packet) -> Optional[Packet]:
|
||||
def direct_forward(self, packet: Packet, packet_hash: Optional[str] = None) -> Optional[Packet]:
|
||||
"""Forward a DIRECT packet, removing the first hop from the path.
|
||||
|
||||
INVARIANT: purely synchronous — no await points. The is_duplicate +
|
||||
mark_seen pair is atomic within the asyncio event loop. Do NOT add any
|
||||
await here without revisiting that invariant in __call__ / process_packet.
|
||||
"""
|
||||
# Validate packet (empty payload, oversized path, etc.)
|
||||
valid, reason = self.validate_packet(packet)
|
||||
if not valid:
|
||||
@@ -782,12 +932,12 @@ class RepeaterHandler(BaseHandler):
|
||||
packet.drop_reason = "Direct: not for us"
|
||||
return None
|
||||
|
||||
# Suppress duplicates
|
||||
if self.is_duplicate(packet):
|
||||
# Suppress duplicates — pass pre-computed hash to avoid a second SHA-256.
|
||||
if self.is_duplicate(packet, packet_hash=packet_hash):
|
||||
packet.drop_reason = "Duplicate"
|
||||
return None
|
||||
|
||||
self.mark_seen(packet)
|
||||
self.mark_seen(packet, packet_hash=packet_hash)
|
||||
|
||||
# Remove first hash entry (hash_size bytes)
|
||||
packet.path = bytearray(packet.path[hash_size:])
|
||||
@@ -823,8 +973,6 @@ class RepeaterHandler(BaseHandler):
|
||||
|
||||
def _calculate_tx_delay(self, packet: Packet, snr: float = 0.0) -> float:
|
||||
|
||||
import random
|
||||
|
||||
packet_len = packet.get_raw_length()
|
||||
airtime_ms = self.airtime_mgr.calculate_airtime(packet_len)
|
||||
|
||||
@@ -854,34 +1002,47 @@ class RepeaterHandler(BaseHandler):
|
||||
# score 0.0 → multiplier 1.0 (100% of original)
|
||||
score_multiplier = max(0.2, 1.0 - score)
|
||||
delay_s = delay_s * score_multiplier
|
||||
logger.debug(
|
||||
f"Congestion detected (delay >= 50ms), score={score:.2f}, "
|
||||
f"delay multiplier={score_multiplier:.2f}"
|
||||
)
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug(
|
||||
f"Congestion detected (delay >= 50ms), score={score:.2f}, "
|
||||
f"delay multiplier={score_multiplier:.2f}"
|
||||
)
|
||||
|
||||
# Cap at 5 seconds maximum
|
||||
delay_s = min(delay_s, 5.0)
|
||||
|
||||
logger.debug(
|
||||
f"Route={'FLOOD' if route_type == ROUTE_TYPE_FLOOD else 'DIRECT'}, "
|
||||
f"len={packet_len}B, airtime={airtime_ms:.1f}ms, delay={delay_s:.3f}s"
|
||||
)
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug(
|
||||
f"Route={'FLOOD' if route_type == ROUTE_TYPE_FLOOD else 'DIRECT'}, "
|
||||
f"len={packet_len}B, airtime={airtime_ms:.1f}ms, delay={delay_s:.3f}s"
|
||||
)
|
||||
|
||||
return delay_s
|
||||
|
||||
def process_packet(self, packet: Packet, snr: float = 0.0) -> Optional[Tuple[Packet, float]]:
|
||||
def process_packet(
|
||||
self,
|
||||
packet: Packet,
|
||||
snr: float = 0.0,
|
||||
packet_hash: Optional[str] = None,
|
||||
) -> Optional[Tuple[Packet, float]]:
|
||||
"""Route a received packet to flood_forward or direct_forward.
|
||||
|
||||
packet_hash is the pre-computed SHA-256 hex string from __call__.
|
||||
Passing it here avoids recomputing the hash in flood_forward /
|
||||
direct_forward / is_duplicate / mark_seen — reducing SHA-256 calls
|
||||
from 3 per forwarded packet to 1.
|
||||
"""
|
||||
route_type = packet.header & PH_ROUTE_MASK
|
||||
|
||||
if route_type == ROUTE_TYPE_FLOOD or route_type == ROUTE_TYPE_TRANSPORT_FLOOD:
|
||||
fwd_pkt = self.flood_forward(packet)
|
||||
fwd_pkt = self.flood_forward(packet, packet_hash=packet_hash)
|
||||
if fwd_pkt is None:
|
||||
return None
|
||||
delay = self._calculate_tx_delay(fwd_pkt, snr)
|
||||
return fwd_pkt, delay
|
||||
|
||||
elif route_type == ROUTE_TYPE_DIRECT or route_type == ROUTE_TYPE_TRANSPORT_DIRECT:
|
||||
fwd_pkt = self.direct_forward(packet)
|
||||
fwd_pkt = self.direct_forward(packet, packet_hash=packet_hash)
|
||||
if fwd_pkt is None:
|
||||
return None
|
||||
delay = self._calculate_tx_delay(fwd_pkt, snr)
|
||||
@@ -906,31 +1067,70 @@ class RepeaterHandler(BaseHandler):
|
||||
|
||||
async def delayed_send():
|
||||
await asyncio.sleep(delay)
|
||||
last_error = None
|
||||
|
||||
# Each attempt gets its own lock acquisition so the 1-second retry
|
||||
# backoff (local_transmission only) happens OUTSIDE the lock.
|
||||
# Holding _tx_lock across asyncio.sleep(1.0) would block every other
|
||||
# queued TX task for the full backoff period.
|
||||
#
|
||||
# Loop runs once for relayed packets, twice for local_transmission:
|
||||
# attempt 0 — initial try (no pre-sleep)
|
||||
# attempt 1 — retry after 1s backoff outside the lock
|
||||
for attempt in range(2 if local_transmission else 1):
|
||||
try:
|
||||
await self.dispatcher.send_packet(fwd_pkt, wait_for_ack=False)
|
||||
if attempt > 0:
|
||||
# Back-off OUTSIDE the lock — other tasks can transmit here.
|
||||
logger.info("Retrying local TX in 1s (lock released during backoff)...")
|
||||
await asyncio.sleep(1.0)
|
||||
|
||||
async with self._tx_lock:
|
||||
# ── Authoritative duty-cycle gate ──────────────────────────
|
||||
# The upfront can_transmit() call in __call__ is advisory: it
|
||||
# avoids scheduling packets obviously over budget, but cannot
|
||||
# prevent a race between tasks whose delay timers expire nearly
|
||||
# simultaneously. Both pass the advisory check before either
|
||||
# records airtime, then both attempt to transmit.
|
||||
#
|
||||
# Inside _tx_lock only one task runs at a time. The check and
|
||||
# record_tx() are effectively atomic — no TOCTOU window.
|
||||
# Re-checked every attempt because airtime state may change
|
||||
# while we wait for the lock or sleep through backoff.
|
||||
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
|
||||
can_tx_now, _ = self.airtime_mgr.can_transmit(airtime_ms)
|
||||
if not can_tx_now:
|
||||
logger.warning(
|
||||
"Packet dropped at TX time: duty-cycle exceeded "
|
||||
"(airtime=%.1fms)", airtime_ms,
|
||||
)
|
||||
return
|
||||
|
||||
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:
|
||||
logger.error(f"Retransmit failed (attempt {attempt + 1}): {e}")
|
||||
if local_transmission and attempt == 0:
|
||||
pass # release lock, outer loop sleeps, then retries
|
||||
else:
|
||||
raise
|
||||
|
||||
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
|
||||
@@ -941,6 +1141,10 @@ class RepeaterHandler(BaseHandler):
|
||||
logger.debug(f"Failed to get noise floor: {e}")
|
||||
return None
|
||||
|
||||
def get_cached_noise_floor(self) -> Optional[float]:
|
||||
"""Return the last asynchronously-sampled noise floor value."""
|
||||
return self._cached_noise_floor
|
||||
|
||||
def get_stats(self) -> dict:
|
||||
|
||||
uptime_seconds = time.time() - self.start_time
|
||||
@@ -959,8 +1163,8 @@ class RepeaterHandler(BaseHandler):
|
||||
rx_per_hour = len(packets_last_hour)
|
||||
forwarded_per_hour = sum(1 for p in packets_last_hour if p.get("transmitted", False))
|
||||
|
||||
# Get current noise floor from radio
|
||||
noise_floor_dbm = self.get_noise_floor()
|
||||
# Use cached value sampled by the background timer to avoid serial I/O on stats requests.
|
||||
noise_floor_dbm = self.get_cached_noise_floor()
|
||||
|
||||
# Get CRC error count from radio hardware
|
||||
radio = self.dispatcher.radio if self.dispatcher else None
|
||||
@@ -969,16 +1173,29 @@ class RepeaterHandler(BaseHandler):
|
||||
# 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,
|
||||
"recent_packets": list(self.recent_packets),
|
||||
"neighbors": neighbors,
|
||||
"uptime_seconds": uptime_seconds,
|
||||
"noise_floor_dbm": noise_floor_dbm,
|
||||
@@ -995,7 +1212,7 @@ class RepeaterHandler(BaseHandler):
|
||||
),
|
||||
"latitude": repeater_config.get("latitude", 0.0),
|
||||
"longitude": repeater_config.get("longitude", 0.0),
|
||||
"max_flood_hops": repeater_config.get("max_flood_hops", 3),
|
||||
"max_flood_hops": repeater_config.get("max_flood_hops", 64),
|
||||
"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", {}),
|
||||
@@ -1016,9 +1233,10 @@ class RepeaterHandler(BaseHandler):
|
||||
"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),
|
||||
"unscoped_flood_allow": self.config.get("mesh", {}).get("unscoped_flood_allow", self.config.get("mesh", {}).get("global_flood_allow", True)),
|
||||
"path_hash_mode": self.config.get("mesh", {}).get("path_hash_mode", 0),
|
||||
},
|
||||
"mqtt_brokers": self.config.get("mqtt_brokers", {}),
|
||||
},
|
||||
"public_key": None,
|
||||
}
|
||||
@@ -1049,6 +1267,27 @@ class RepeaterHandler(BaseHandler):
|
||||
await self._send_periodic_advert_async()
|
||||
self.last_advert_time = current_time
|
||||
|
||||
# Prune expired entries from duplicate detection cache (every 60s)
|
||||
if current_time - self.last_cache_cleanup >= 60.0:
|
||||
self.cleanup_cache()
|
||||
self.last_cache_cleanup = current_time
|
||||
|
||||
# Prune old SQLite data (check every 6 hours)
|
||||
if current_time - self.last_db_cleanup >= 21600:
|
||||
if self.storage:
|
||||
try:
|
||||
retention_days = (
|
||||
self.config
|
||||
.get("storage", {})
|
||||
.get("retention", {})
|
||||
.get("sqlite_cleanup_days", 31)
|
||||
)
|
||||
self.storage.cleanup_old_data(days=retention_days)
|
||||
logger.info("Cleaned up SQLite data older than %d days", retention_days)
|
||||
except Exception as e:
|
||||
logger.warning(f"SQLite cleanup failed: {e}")
|
||||
self.last_db_cleanup = current_time
|
||||
|
||||
# Sleep for 5 seconds before next check
|
||||
await asyncio.sleep(5.0)
|
||||
|
||||
@@ -1071,6 +1310,7 @@ class RepeaterHandler(BaseHandler):
|
||||
loop = asyncio.get_running_loop()
|
||||
noise_floor = await loop.run_in_executor(None, self.get_noise_floor)
|
||||
if noise_floor is not None:
|
||||
self._cached_noise_floor = noise_floor
|
||||
self.storage.record_noise_floor(noise_floor)
|
||||
logger.debug(f"Recorded noise floor: {noise_floor} dBm")
|
||||
else:
|
||||
@@ -1125,6 +1365,7 @@ class RepeaterHandler(BaseHandler):
|
||||
self.score_threshold = repeater_config.get("score_threshold", 0.3)
|
||||
self.send_advert_interval_hours = repeater_config.get("send_advert_interval_hours", 10)
|
||||
self.cache_ttl = repeater_config.get("cache_ttl", 60)
|
||||
self.max_flood_hops = repeater_config.get("max_flood_hops", 64)
|
||||
self.loop_detect_mode = self._normalize_loop_detect_mode(
|
||||
self.config.get("mesh", {}).get("loop_detect", LOOP_DETECT_OFF)
|
||||
)
|
||||
|
||||
@@ -8,7 +8,8 @@ Includes adaptive rate limiting based on mesh activity.
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from collections import OrderedDict
|
||||
import itertools
|
||||
from collections import OrderedDict, deque
|
||||
from enum import Enum
|
||||
from typing import Dict, Optional, Tuple
|
||||
|
||||
@@ -123,9 +124,9 @@ class AdvertHelper:
|
||||
self._stats_advert_duplicates = 0
|
||||
self._stats_tier_changes = 0
|
||||
|
||||
# Recent drops tracking (keep last 20)
|
||||
self._recent_drops = []
|
||||
self._max_recent_drops = 20
|
||||
# Recent drops tracking — bounded deque so append is O(1) and the
|
||||
# oldest entry is evicted automatically (no pop(0) O(n) shift needed).
|
||||
self._recent_drops: deque = deque(maxlen=20)
|
||||
|
||||
# Memory management
|
||||
self._last_cleanup = time.time()
|
||||
@@ -194,8 +195,8 @@ class AdvertHelper:
|
||||
|
||||
# 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:])
|
||||
# itertools.islice avoids materialising the full list first (O(n) → O(k))
|
||||
self._known_neighbors = set(itertools.islice(self._known_neighbors, 500))
|
||||
|
||||
if expired_penalties or inactive_pubkeys:
|
||||
logger.debug(
|
||||
@@ -571,10 +572,13 @@ class AdvertHelper:
|
||||
# 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
|
||||
# Remove any existing entry for this pubkey, then append the
|
||||
# updated record. Rebuilding as a deque preserves maxlen so
|
||||
# the oldest entry is evicted automatically — no pop(0) needed.
|
||||
self._recent_drops = deque(
|
||||
(d for d in self._recent_drops if d["pubkey"] != pubkey_short),
|
||||
maxlen=20,
|
||||
)
|
||||
self._recent_drops.append({
|
||||
"pubkey": pubkey_short,
|
||||
"name": node_name,
|
||||
@@ -582,10 +586,6 @@ class AdvertHelper:
|
||||
"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
|
||||
|
||||
@@ -44,11 +44,26 @@ class DiscoveryHelper:
|
||||
log_fn=log_fn or logger.info,
|
||||
debug_log_fn=debug_log_fn,
|
||||
)
|
||||
self._pending_tasks = set()
|
||||
|
||||
# Set up the request callback
|
||||
self.control_handler.set_request_callback(self._on_discovery_request)
|
||||
logger.debug("Discovery handler initialized")
|
||||
|
||||
def _track_task(self, task: asyncio.Task) -> None:
|
||||
self._pending_tasks.add(task)
|
||||
|
||||
def _on_done(done_task: asyncio.Task) -> None:
|
||||
self._pending_tasks.discard(done_task)
|
||||
try:
|
||||
done_task.result()
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(f"Background discovery task failed: {e}", exc_info=True)
|
||||
|
||||
task.add_done_callback(_on_done)
|
||||
|
||||
def _on_discovery_request(self, request_data: dict) -> None:
|
||||
"""
|
||||
Handle incoming discovery request.
|
||||
@@ -115,7 +130,8 @@ class DiscoveryHelper:
|
||||
|
||||
# Send response via router injection
|
||||
if self.packet_injector:
|
||||
asyncio.create_task(self._send_packet_async(response_packet, tag))
|
||||
task = asyncio.create_task(self._send_packet_async(response_packet, tag))
|
||||
self._track_task(task)
|
||||
else:
|
||||
logger.warning("No packet injector available - discovery response not sent")
|
||||
|
||||
|
||||
@@ -22,6 +22,21 @@ class LoginHelper:
|
||||
|
||||
self.handlers = {}
|
||||
self.acls = {} # Per-identity ACLs keyed by hash_byte
|
||||
self._pending_tasks = set()
|
||||
|
||||
def _track_task(self, task: asyncio.Task) -> None:
|
||||
self._pending_tasks.add(task)
|
||||
|
||||
def _on_done(done_task: asyncio.Task) -> None:
|
||||
self._pending_tasks.discard(done_task)
|
||||
try:
|
||||
done_task.result()
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(f"Background login task failed: {e}", exc_info=True)
|
||||
|
||||
task.add_done_callback(_on_done)
|
||||
|
||||
def register_identity(
|
||||
self, name: str, identity, identity_type: str = "room_server", config: dict = None
|
||||
@@ -141,7 +156,8 @@ class LoginHelper:
|
||||
def _send_packet_with_delay(self, packet, delay_ms: int):
|
||||
|
||||
if self.packet_injector:
|
||||
asyncio.create_task(self._delayed_send(packet, delay_ms))
|
||||
task = asyncio.create_task(self._delayed_send(packet, delay_ms))
|
||||
self._track_task(task)
|
||||
else:
|
||||
logger.error("No packet injector configured, cannot send login response")
|
||||
|
||||
|
||||
@@ -31,6 +31,13 @@ class MeshCLI:
|
||||
self.identity = identity
|
||||
self.storage_handler = storage_handler
|
||||
|
||||
# Store event loop reference for thread-safe scheduling
|
||||
import asyncio
|
||||
try:
|
||||
self._event_loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
self._event_loop = None
|
||||
|
||||
# Get repeater config shortcut
|
||||
self.repeater_config = config.get("repeater", {})
|
||||
|
||||
@@ -63,8 +70,12 @@ class MeshCLI:
|
||||
|
||||
def _route_command(self, command: str) -> str:
|
||||
|
||||
# Help
|
||||
if command == "help" or command.startswith("help "):
|
||||
return self._cmd_help(command)
|
||||
|
||||
# System commands
|
||||
if command == "reboot":
|
||||
elif command == "reboot":
|
||||
return self._cmd_reboot()
|
||||
elif command == "advert":
|
||||
return self._cmd_advert()
|
||||
@@ -131,6 +142,105 @@ class MeshCLI:
|
||||
else:
|
||||
return "Unknown command"
|
||||
|
||||
# ==================== Help Command ====================
|
||||
|
||||
def _cmd_help(self, command: str) -> str:
|
||||
"""Show available commands or detailed help for a specific command."""
|
||||
parts = command.split(None, 1)
|
||||
if len(parts) == 2:
|
||||
return self._help_detail(parts[1])
|
||||
|
||||
lines = [
|
||||
"=== pyMC CLI Commands ===",
|
||||
"",
|
||||
"System:",
|
||||
" reboot Restart the repeater service",
|
||||
" advert Send self advertisement",
|
||||
" clock Show current UTC time",
|
||||
" clock sync Sync clock (no-op, uses system time)",
|
||||
" ver Show version info",
|
||||
" password <pw> Change admin password",
|
||||
" clear stats Clear statistics",
|
||||
"",
|
||||
"Get:",
|
||||
" get name Node name",
|
||||
" get radio Radio params (freq,bw,sf,cr)",
|
||||
" get freq Frequency (MHz)",
|
||||
" get tx TX power",
|
||||
" get af Airtime factor",
|
||||
" get repeat Repeat mode (on/off)",
|
||||
" get lat / get lon GPS coordinates",
|
||||
" get role Identity role",
|
||||
" get guest.password Guest password",
|
||||
" get allow.read.only Read-only access setting",
|
||||
" get advert.interval Advert interval (minutes)",
|
||||
" get flood.advert.interval Flood advert interval (hours)",
|
||||
" get flood.max Max flood hops",
|
||||
" get rxdelay RX delay base",
|
||||
" get txdelay TX delay factor",
|
||||
" get direct.txdelay Direct TX delay factor",
|
||||
" get multi.acks Multi-ack count",
|
||||
" get int.thresh Interference threshold",
|
||||
" get agc.reset.interval AGC reset interval",
|
||||
"",
|
||||
"Set: (use 'help set' for details)",
|
||||
" set <param> <value>",
|
||||
"",
|
||||
"Other:",
|
||||
" neighbors List neighbors",
|
||||
" neighbor.remove <key> Remove neighbor by pubkey",
|
||||
" tempradio <freq> <bw> <sf> <cr> <timeout_mins>",
|
||||
" setperm <pubkey> <perm> Set ACL permissions",
|
||||
" log start|stop|erase Logging control",
|
||||
]
|
||||
if self.enable_regions:
|
||||
lines.append(" region ... Region commands")
|
||||
lines += ["", "Type 'help <command>' for details on a specific command."]
|
||||
return "\n".join(lines)
|
||||
|
||||
def _help_detail(self, topic: str) -> str:
|
||||
"""Return detailed help for a specific command topic."""
|
||||
topic = topic.strip()
|
||||
details = {
|
||||
"set": (
|
||||
"Set commands \u2014 set <param> <value>:\n"
|
||||
" set name <name> Set node name\n"
|
||||
" set radio <f> <bw> <sf> <cr> Set radio (restart required)\n"
|
||||
" set freq <mhz> Set frequency (restart required)\n"
|
||||
" set tx <power> Set TX power\n"
|
||||
" set af <factor> Airtime factor\n"
|
||||
" set repeat on|off Enable/disable repeating\n"
|
||||
" set lat <deg> Latitude\n"
|
||||
" set lon <deg> Longitude\n"
|
||||
" set guest.password <pw> Guest password\n"
|
||||
" set allow.read.only on|off Read-only access\n"
|
||||
" set advert.interval <min> 60-240 minutes\n"
|
||||
" set flood.advert.interval <hr> 3-48 hours\n"
|
||||
" set flood.max <hops> Max flood hops (max 64)\n"
|
||||
" set rxdelay <val> RX delay base (>=0)\n"
|
||||
" set txdelay <val> TX delay factor (>=0)\n"
|
||||
" set direct.txdelay <val> Direct TX delay (>=0)\n"
|
||||
" set multi.acks <n> Multi-ack count\n"
|
||||
" set int.thresh <dbm> Interference threshold\n"
|
||||
" set agc.reset.interval <n> AGC reset (rounded to x4)"
|
||||
),
|
||||
"get": "Get commands \u2014 type 'help' to see all 'get' parameters.",
|
||||
"reboot": "Restart the repeater service via systemd.",
|
||||
"advert": "Trigger a self-advertisement flood packet.",
|
||||
"clock": "'clock' shows UTC time. 'clock sync' is a no-op (system time used).",
|
||||
"ver": "Show repeater version and identity type.",
|
||||
"password": "password <new_password> \u2014 Change the admin password.",
|
||||
"tempradio": (
|
||||
"tempradio <freq_mhz> <bw_khz> <sf> <cr> <timeout_mins>\n"
|
||||
" Apply temporary radio parameters that revert after timeout.\n"
|
||||
" freq: 300-2500 MHz, bw: 7-500 kHz, sf: 5-12, cr: 5-8"
|
||||
),
|
||||
"neighbors": "List known neighbor nodes from the routing table.",
|
||||
"setperm": "setperm <pubkey_hex> <permission_int> \u2014 Set ACL permissions for a node.",
|
||||
"log": "log start|stop|erase \u2014 Control logging.",
|
||||
}
|
||||
return details.get(topic, f"No detailed help for '{topic}'. Type 'help' for command list.")
|
||||
|
||||
# ==================== System Commands ====================
|
||||
|
||||
def _cmd_reboot(self) -> str:
|
||||
@@ -159,7 +269,11 @@ class MeshCLI:
|
||||
await asyncio.sleep(1.5)
|
||||
await self.send_advert_callback()
|
||||
|
||||
asyncio.create_task(delayed_advert())
|
||||
if self._event_loop and self._event_loop.is_running():
|
||||
asyncio.run_coroutine_threadsafe(delayed_advert(), self._event_loop)
|
||||
else:
|
||||
return "Error: Event loop not available"
|
||||
|
||||
logger.info("Advert scheduled for sending (1.5s delay)")
|
||||
return "OK - Advert sent"
|
||||
except Exception as e:
|
||||
@@ -236,8 +350,8 @@ class MeshCLI:
|
||||
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)
|
||||
@@ -299,7 +413,7 @@ class MeshCLI:
|
||||
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", 64)
|
||||
return f"> {max_flood}"
|
||||
|
||||
elif param == "rxdelay":
|
||||
@@ -353,11 +467,10 @@ class MeshCLI:
|
||||
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"
|
||||
saved, _ = self.config_manager.save_to_file()
|
||||
self.config_manager.live_update_daemon(["repeater"])
|
||||
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)
|
||||
|
||||
@@ -33,6 +33,7 @@ class ProtocolRequestHelper:
|
||||
radio=None,
|
||||
engine=None,
|
||||
neighbor_tracker=None,
|
||||
config=None,
|
||||
):
|
||||
|
||||
self.identity_manager = identity_manager
|
||||
@@ -41,6 +42,7 @@ class ProtocolRequestHelper:
|
||||
self.radio = radio
|
||||
self.engine = engine
|
||||
self.neighbor_tracker = neighbor_tracker
|
||||
self.config = config or {}
|
||||
|
||||
# Dictionary of core handlers keyed by dest_hash
|
||||
self.handlers = {}
|
||||
@@ -61,6 +63,9 @@ class ProtocolRequestHelper:
|
||||
# Build request handlers dict
|
||||
request_handlers = {
|
||||
REQ_TYPE_GET_STATUS: self._handle_get_status,
|
||||
REQ_TYPE_GET_ACCESS_LIST: self._make_handle_get_access_list(identity_acl),
|
||||
REQ_TYPE_GET_NEIGHBOURS: self._handle_get_neighbours,
|
||||
REQ_TYPE_GET_OWNER_INFO: self._handle_get_owner_info,
|
||||
}
|
||||
|
||||
# Create core handler
|
||||
@@ -129,70 +134,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;
|
||||
# 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
|
||||
|
||||
# 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
|
||||
)
|
||||
# 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
|
||||
|
||||
# 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
|
||||
)
|
||||
# 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
|
||||
|
||||
# Get airtime stats
|
||||
# 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)
|
||||
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
|
||||
|
||||
# Pack struct (little-endian)
|
||||
# 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,
|
||||
@@ -200,7 +208,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,
|
||||
@@ -210,8 +218,153 @@ 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
|
||||
|
||||
def _make_handle_get_access_list(self, identity_acl):
|
||||
"""Create a closure for GET_ACCESS_LIST bound to a specific identity ACL."""
|
||||
def _handler(client, timestamp: int, req_data: bytes):
|
||||
return self._handle_get_access_list(client, timestamp, req_data, identity_acl)
|
||||
return _handler
|
||||
|
||||
def _handle_get_access_list(self, client, timestamp: int, req_data: bytes, identity_acl):
|
||||
"""Return ACL entries: [pub_key_prefix(6) + permissions(1)] per client.
|
||||
|
||||
Admin-only. Matches C++ simple_repeater handleRequest REQ_TYPE_GET_ACCESS_LIST.
|
||||
"""
|
||||
if not hasattr(client, "is_admin") or not client.is_admin():
|
||||
logger.debug("GET_ACCESS_LIST rejected: client is not admin")
|
||||
return None
|
||||
|
||||
# req_data[0] and req_data[1] are reserved bytes; must both be 0
|
||||
if len(req_data) >= 2 and (req_data[0] != 0 or req_data[1] != 0):
|
||||
logger.debug("GET_ACCESS_LIST: reserved bytes non-zero, ignoring")
|
||||
return None
|
||||
|
||||
result = bytearray()
|
||||
for ci in identity_acl.get_all_clients():
|
||||
if ci.permissions == 0:
|
||||
continue # skip deleted entries
|
||||
pubkey = ci.id.get_public_key()
|
||||
result.extend(pubkey[:6]) # 6-byte pub_key prefix
|
||||
result.append(ci.permissions & 0xFF)
|
||||
|
||||
logger.debug("GET_ACCESS_LIST: returning %d entries", len(result) // 7)
|
||||
return bytes(result)
|
||||
|
||||
def _handle_get_neighbours(self, client, timestamp: int, req_data: bytes):
|
||||
"""Return paginated, sorted neighbour list.
|
||||
|
||||
Matches C++ simple_repeater handleRequest REQ_TYPE_GET_NEIGHBOURS.
|
||||
Request: version(1) + count(1) + offset(2 LE) + order_by(1) + pubkey_prefix_len(1) + random(4)
|
||||
Response: total_count(2 LE) + results_count(2 LE) + entries
|
||||
Each entry: pubkey_prefix(N) + heard_seconds_ago(4 LE) + snr(1 signed)
|
||||
"""
|
||||
if len(req_data) < 7:
|
||||
logger.debug("GET_NEIGHBOURS: req_data too short (%d bytes)", len(req_data))
|
||||
return None
|
||||
|
||||
request_version = req_data[0]
|
||||
if request_version != 0:
|
||||
logger.debug("GET_NEIGHBOURS: unsupported version %d", request_version)
|
||||
return None
|
||||
|
||||
count = req_data[1]
|
||||
offset = struct.unpack_from("<H", req_data, 2)[0]
|
||||
order_by = req_data[4]
|
||||
pubkey_prefix_len = min(req_data[5], 32)
|
||||
|
||||
# Fetch neighbours from storage
|
||||
storage = getattr(self.neighbor_tracker, "storage", None) if self.neighbor_tracker else None
|
||||
if not storage or not hasattr(storage, "get_neighbors"):
|
||||
logger.debug("GET_NEIGHBOURS: no storage available")
|
||||
# Return empty result
|
||||
return struct.pack("<HH", 0, 0)
|
||||
|
||||
raw_neighbors = storage.get_neighbors()
|
||||
now = time.time()
|
||||
|
||||
# Build sortable list: (pubkey_hex, heard_seconds_ago, snr)
|
||||
entries = []
|
||||
for pubkey_hex, info in raw_neighbors.items():
|
||||
last_seen = info.get("last_seen", 0) or 0
|
||||
heard_ago = max(0, int(now - last_seen))
|
||||
snr_raw = info.get("snr", 0) or 0
|
||||
# Store SNR as int8 (firmware stores snr * 4 as int8)
|
||||
snr_int = max(-128, min(127, int(snr_raw * 4)))
|
||||
entries.append((pubkey_hex, heard_ago, snr_int))
|
||||
|
||||
# Sort (matches C++ order_by values)
|
||||
if order_by == 0:
|
||||
entries.sort(key=lambda e: e[1]) # newest first (smallest heard_ago)
|
||||
elif order_by == 1:
|
||||
entries.sort(key=lambda e: e[1], reverse=True) # oldest first
|
||||
elif order_by == 2:
|
||||
entries.sort(key=lambda e: e[2], reverse=True) # strongest SNR first
|
||||
elif order_by == 3:
|
||||
entries.sort(key=lambda e: e[2]) # weakest SNR first
|
||||
|
||||
total_count = len(entries)
|
||||
|
||||
# Paginate
|
||||
entry_size = pubkey_prefix_len + 4 + 1
|
||||
max_results_bytes = 130 # firmware buffer limit
|
||||
results = bytearray()
|
||||
results_count = 0
|
||||
|
||||
for i in range(count):
|
||||
idx = i + offset
|
||||
if idx >= total_count:
|
||||
break
|
||||
if len(results) + entry_size > max_results_bytes:
|
||||
break
|
||||
|
||||
pubkey_hex, heard_ago, snr_int = entries[idx]
|
||||
try:
|
||||
pubkey_bytes = bytes.fromhex(pubkey_hex)
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
results.extend(pubkey_bytes[:pubkey_prefix_len])
|
||||
results.extend(struct.pack("<I", heard_ago))
|
||||
results.append(snr_int & 0xFF)
|
||||
results_count += 1
|
||||
|
||||
header = struct.pack("<HH", total_count, results_count)
|
||||
|
||||
logger.debug(
|
||||
"GET_NEIGHBOURS: total=%d, returned=%d, offset=%d, order=%d",
|
||||
total_count, results_count, offset, order_by,
|
||||
)
|
||||
return header + bytes(results)
|
||||
|
||||
def _handle_get_owner_info(self, client, timestamp: int, req_data: bytes):
|
||||
"""Return firmware version, node name, and owner info.
|
||||
|
||||
Matches C++ simple_repeater: sprintf("%s\\n%s\\n%s", FIRMWARE_VERSION, node_name, owner_info)
|
||||
"""
|
||||
repeater_cfg = self.config.get("repeater", {})
|
||||
node_name = repeater_cfg.get("node_name", "pyMC_Repeater")
|
||||
owner_info = repeater_cfg.get("owner_info", "")
|
||||
|
||||
# Version: use package version if available, fallback to "pyMC"
|
||||
try:
|
||||
from importlib.metadata import version as pkg_version
|
||||
fw_version = pkg_version("pymc-repeater")
|
||||
except Exception:
|
||||
fw_version = "pyMC"
|
||||
|
||||
result = f"{fw_version}\n{node_name}\n{owner_info}".encode("utf-8")
|
||||
logger.debug("GET_OWNER_INFO: %s", result.decode("utf-8", errors="replace"))
|
||||
return result
|
||||
|
||||
@@ -88,8 +88,12 @@ class MeshCLI:
|
||||
def _route_command(self, command: str) -> str:
|
||||
"""Route command to appropriate handler method."""
|
||||
|
||||
# Help
|
||||
if command == "help" or command.startswith("help "):
|
||||
return self._cmd_help(command)
|
||||
|
||||
# System commands
|
||||
if command == "reboot":
|
||||
elif command == "reboot":
|
||||
return self._cmd_reboot()
|
||||
elif command == "advert":
|
||||
return self._cmd_advert()
|
||||
@@ -156,7 +160,106 @@ class MeshCLI:
|
||||
else:
|
||||
return "Unknown command"
|
||||
|
||||
# ==================== System Commands ====================
|
||||
# ==================== Help Command ====================
|
||||
|
||||
def _cmd_help(self, command: str) -> str:
|
||||
"""Show available commands or detailed help for a specific command."""
|
||||
parts = command.split(None, 1)
|
||||
if len(parts) == 2:
|
||||
return self._help_detail(parts[1])
|
||||
|
||||
lines = [
|
||||
"=== pyMC CLI Commands ===",
|
||||
"",
|
||||
"System:",
|
||||
" reboot Restart the repeater service",
|
||||
" advert Send self advertisement",
|
||||
" clock Show current UTC time",
|
||||
" clock sync Sync clock (no-op, uses system time)",
|
||||
" ver Show version info",
|
||||
" password <pw> Change admin password",
|
||||
" clear stats Clear statistics",
|
||||
"",
|
||||
"Get:",
|
||||
" get name Node name",
|
||||
" get radio Radio params (freq,bw,sf,cr)",
|
||||
" get freq Frequency (MHz)",
|
||||
" get tx TX power",
|
||||
" get af Airtime factor",
|
||||
" get repeat Repeat mode (on/off)",
|
||||
" get lat / get lon GPS coordinates",
|
||||
" get role Identity role",
|
||||
" get guest.password Guest password",
|
||||
" get allow.read.only Read-only access setting",
|
||||
" get advert.interval Advert interval (minutes)",
|
||||
" get flood.advert.interval Flood advert interval (hours)",
|
||||
" get flood.max Max flood hops",
|
||||
" get rxdelay RX delay base",
|
||||
" get txdelay TX delay factor",
|
||||
" get direct.txdelay Direct TX delay factor",
|
||||
" get multi.acks Multi-ack count",
|
||||
" get int.thresh Interference threshold",
|
||||
" get agc.reset.interval AGC reset interval",
|
||||
"",
|
||||
"Set: (use 'help set' for details)",
|
||||
" set <param> <value>",
|
||||
"",
|
||||
"Other:",
|
||||
" neighbors List neighbors",
|
||||
" neighbor.remove <key> Remove neighbor by pubkey",
|
||||
" tempradio <freq> <bw> <sf> <cr> <timeout_mins>",
|
||||
" setperm <pubkey> <perm> Set ACL permissions",
|
||||
" log start|stop|erase Logging control",
|
||||
]
|
||||
if self.enable_regions:
|
||||
lines.append(" region ... Region commands")
|
||||
lines += ["", "Type 'help <command>' for details on a specific command."]
|
||||
return "\n".join(lines)
|
||||
|
||||
def _help_detail(self, topic: str) -> str:
|
||||
"""Return detailed help for a specific command topic."""
|
||||
topic = topic.strip()
|
||||
details = {
|
||||
"set": (
|
||||
"Set commands — set <param> <value>:\n"
|
||||
" set name <name> Set node name\n"
|
||||
" set radio <f> <bw> <sf> <cr> Set radio (restart required)\n"
|
||||
" set freq <mhz> Set frequency (restart required)\n"
|
||||
" set tx <power> Set TX power\n"
|
||||
" set af <factor> Airtime factor\n"
|
||||
" set repeat on|off Enable/disable repeating\n"
|
||||
" set lat <deg> Latitude\n"
|
||||
" set lon <deg> Longitude\n"
|
||||
" set guest.password <pw> Guest password\n"
|
||||
" set allow.read.only on|off Read-only access\n"
|
||||
" set advert.interval <min> 60-240 minutes\n"
|
||||
" set flood.advert.interval <hr> 3-48 hours\n"
|
||||
" set flood.max <hops> Max flood hops (max 64)\n"
|
||||
" set rxdelay <val> RX delay base (>=0)\n"
|
||||
" set txdelay <val> TX delay factor (>=0)\n"
|
||||
" set direct.txdelay <val> Direct TX delay (>=0)\n"
|
||||
" set multi.acks <n> Multi-ack count\n"
|
||||
" set int.thresh <dbm> Interference threshold\n"
|
||||
" set agc.reset.interval <n> AGC reset (rounded to x4)"
|
||||
),
|
||||
"get": "Get commands — type 'help' to see all 'get' parameters.",
|
||||
"reboot": "Restart the repeater service via systemd.",
|
||||
"advert": "Trigger a self-advertisement flood packet.",
|
||||
"clock": "'clock' shows UTC time. 'clock sync' is a no-op (system time used).",
|
||||
"ver": "Show repeater version and identity type.",
|
||||
"password": "password <new_password> — Change the admin password.",
|
||||
"tempradio": (
|
||||
"tempradio <freq_mhz> <bw_khz> <sf> <cr> <timeout_mins>\n"
|
||||
" Apply temporary radio parameters that revert after timeout.\n"
|
||||
" freq: 300-2500 MHz, bw: 7-500 kHz, sf: 5-12, cr: 5-8"
|
||||
),
|
||||
"neighbors": "List known neighbor nodes from the routing table.",
|
||||
"setperm": "setperm <pubkey_hex> <permission_int> — Set ACL permissions for a node.",
|
||||
"log": "log start|stop|erase — Control logging.",
|
||||
}
|
||||
return details.get(topic, f"No detailed help for '{topic}'. Type 'help' for command list.")
|
||||
|
||||
# ==================== System Commands ==
|
||||
|
||||
def _cmd_reboot(self) -> str:
|
||||
"""Reboot the repeater process."""
|
||||
@@ -242,8 +345,8 @@ class MeshCLI:
|
||||
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)
|
||||
@@ -298,7 +401,7 @@ class MeshCLI:
|
||||
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", 64)
|
||||
return f"> {max_flood}"
|
||||
|
||||
elif param == "rxdelay":
|
||||
@@ -350,10 +453,9 @@ class MeshCLI:
|
||||
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)
|
||||
|
||||
@@ -65,6 +65,21 @@ class TextHelper:
|
||||
|
||||
# Initialize CLI handler later when repeater identity is registered
|
||||
self.cli = None
|
||||
self._pending_tasks = set()
|
||||
|
||||
def _track_task(self, task: asyncio.Task) -> None:
|
||||
self._pending_tasks.add(task)
|
||||
|
||||
def _on_done(done_task: asyncio.Task) -> None:
|
||||
self._pending_tasks.discard(done_task)
|
||||
try:
|
||||
done_task.result()
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(f"Background text task failed: {e}", exc_info=True)
|
||||
|
||||
task.add_done_callback(_on_done)
|
||||
|
||||
def register_identity(
|
||||
self, name: str, identity, identity_type: str = "room_server", radio_config=None
|
||||
@@ -152,7 +167,8 @@ class TextHelper:
|
||||
self.room_servers[hash_byte] = room_server
|
||||
|
||||
# Start sync loop
|
||||
asyncio.create_task(room_server.start())
|
||||
start_task = asyncio.create_task(room_server.start())
|
||||
self._track_task(start_task)
|
||||
|
||||
logger.info(
|
||||
f"Registered room server '{name}': hash=0x{hash_byte:02X}, "
|
||||
|
||||
@@ -9,11 +9,12 @@ of packets through the mesh network.
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, Dict
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from pymc_core.hardware.signal_utils import snr_register_to_db
|
||||
from pymc_core.node.handlers.trace import TraceHandler
|
||||
from pymc_core.protocol.constants import MAX_PATH_SIZE, ROUTE_TYPE_DIRECT
|
||||
from pymc_core.protocol.packet_utils import PathUtils
|
||||
|
||||
logger = logging.getLogger("TraceHelper")
|
||||
|
||||
@@ -21,17 +22,33 @@ logger = logging.getLogger("TraceHelper")
|
||||
class TraceHelper:
|
||||
"""Helper class for processing trace packets in the repeater."""
|
||||
|
||||
def __init__(self, local_hash: int, repeater_handler, packet_injector=None, log_fn=None):
|
||||
def __init__(
|
||||
self,
|
||||
local_hash: int,
|
||||
repeater_handler,
|
||||
packet_injector=None,
|
||||
log_fn=None,
|
||||
local_identity=None,
|
||||
):
|
||||
"""
|
||||
Initialize the trace helper.
|
||||
|
||||
Args:
|
||||
local_hash: The local node's hash identifier
|
||||
local_hash: The local node's 1-byte hash (first byte of pubkey); legacy
|
||||
repeater_handler: The RepeaterHandler instance
|
||||
packet_injector: Callable to inject new packets into the router for sending
|
||||
log_fn: Optional logging function for TraceHandler
|
||||
local_identity: LocalIdentity (or any object with get_public_key()) for
|
||||
multibyte TRACE path matching (Mesh.cpp isHashMatch with 1<<path_sz bytes)
|
||||
"""
|
||||
self.local_hash = local_hash
|
||||
self.local_identity = local_identity
|
||||
self._pubkey_bytes: bytes = b""
|
||||
if local_identity is not None and hasattr(local_identity, "get_public_key"):
|
||||
try:
|
||||
self._pubkey_bytes = bytes(local_identity.get_public_key())
|
||||
except Exception:
|
||||
self._pubkey_bytes = b""
|
||||
self.repeater_handler = repeater_handler
|
||||
self.packet_injector = packet_injector # Function to inject packets into router
|
||||
|
||||
@@ -46,6 +63,11 @@ class TraceHelper:
|
||||
# Create TraceHandler internally as a parsing utility
|
||||
self.trace_handler = TraceHandler(log_fn=log_fn or logger.info)
|
||||
|
||||
def _pubkey_prefix(self, width: int) -> bytes:
|
||||
if width <= 0 or not self._pubkey_bytes:
|
||||
return b""
|
||||
return self._pubkey_bytes[:width]
|
||||
|
||||
async def process_trace_packet(self, packet) -> None:
|
||||
"""
|
||||
Process an incoming trace packet.
|
||||
@@ -57,8 +79,8 @@ class TraceHelper:
|
||||
packet: The trace packet to process
|
||||
"""
|
||||
try:
|
||||
# Only process direct route trace packets
|
||||
if packet.get_route_type() != ROUTE_TYPE_DIRECT or packet.path_len >= MAX_PATH_SIZE:
|
||||
# Only process direct route trace packets (SNR path uses len(packet.path))
|
||||
if packet.get_route_type() != ROUTE_TYPE_DIRECT or len(packet.path) >= MAX_PATH_SIZE:
|
||||
return
|
||||
|
||||
# Parse the trace payload
|
||||
@@ -68,18 +90,31 @@ class TraceHelper:
|
||||
logger.warning(f"Invalid trace packet: {parsed_data.get('error', 'Unknown error')}")
|
||||
return
|
||||
|
||||
trace_path = parsed_data["trace_path"]
|
||||
trace_path_len = len(trace_path)
|
||||
trace_bytes: bytes = parsed_data.get("trace_path_bytes") or b""
|
||||
flags = parsed_data.get("flags", 0)
|
||||
hash_width = PathUtils.trace_payload_hash_width(flags)
|
||||
trace_hops: List[bytes] = parsed_data.get("trace_hops") or []
|
||||
num_hops = len(trace_hops)
|
||||
legacy_trace_path = parsed_data.get("trace_path") or []
|
||||
|
||||
# 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
|
||||
# Store response data (legacy path list + structured hops)
|
||||
ping_info["result"] = {
|
||||
"path": trace_path,
|
||||
"path": legacy_trace_path,
|
||||
"trace_hops": trace_hops,
|
||||
"trace_path_bytes": trace_bytes,
|
||||
"snr": packet.get_snr(),
|
||||
"rssi": getattr(packet, "rssi", 0),
|
||||
"rssi": rssi_val,
|
||||
"received_at": time.time(),
|
||||
}
|
||||
# Signal the waiting coroutine
|
||||
@@ -88,11 +123,11 @@ class TraceHelper:
|
||||
|
||||
# Record the trace packet for dashboard/statistics
|
||||
if self.repeater_handler:
|
||||
packet_record = self._create_trace_record(packet, trace_path, parsed_data)
|
||||
packet_record = self._create_trace_record(packet, parsed_data)
|
||||
self.repeater_handler.log_trace_record(packet_record)
|
||||
|
||||
# Extract and log path SNRs and hashes
|
||||
path_snrs, path_hashes = self._extract_path_info(packet, trace_path)
|
||||
path_snrs, path_hashes = self._extract_path_info(packet, parsed_data)
|
||||
|
||||
# Add packet metadata for logging
|
||||
parsed_data["snr"] = packet.get_snr()
|
||||
@@ -102,16 +137,18 @@ class TraceHelper:
|
||||
logger.info(f"{formatted_response}")
|
||||
logger.info(f"Path SNRs: [{', '.join(path_snrs)}], Hashes: [{', '.join(path_hashes)}]")
|
||||
|
||||
# Check if we should forward this trace packet
|
||||
should_forward = self._should_forward_trace(packet, trace_path, trace_path_len)
|
||||
should_forward = self._should_forward_trace(packet, trace_bytes, flags, hash_width)
|
||||
|
||||
if should_forward:
|
||||
await self._forward_trace_packet(packet, trace_path_len)
|
||||
await self._forward_trace_packet(packet, num_hops)
|
||||
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:
|
||||
self._log_no_forward_reason(packet, trace_bytes, hash_width)
|
||||
if (
|
||||
self.on_trace_complete
|
||||
and self._is_trace_complete(packet, trace_bytes, hash_width)
|
||||
and self.repeater_handler
|
||||
and not self.repeater_handler.is_duplicate(packet)
|
||||
):
|
||||
try:
|
||||
await self.on_trace_complete(packet, parsed_data)
|
||||
except Exception as e:
|
||||
@@ -120,38 +157,56 @@ class TraceHelper:
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing trace packet: {e}")
|
||||
|
||||
def _create_trace_record(self, packet, trace_path: list, parsed_data: dict) -> Dict[str, Any]:
|
||||
def _is_trace_complete(self, packet, trace_bytes: bytes, hash_width: int) -> bool:
|
||||
"""Mirror Mesh.cpp: offset = path_len<<path_sz >= len(trace hash bytes)."""
|
||||
if not trace_bytes or hash_width <= 0:
|
||||
return False
|
||||
snr_count = len(packet.path)
|
||||
return snr_count * hash_width >= len(trace_bytes)
|
||||
|
||||
def _create_trace_record(self, packet, parsed_data: dict) -> Dict[str, Any]:
|
||||
"""
|
||||
Create a packet record for trace packets to log to statistics.
|
||||
|
||||
Args:
|
||||
packet: The trace packet
|
||||
trace_path: The parsed trace path from the payload
|
||||
parsed_data: The parsed trace data
|
||||
parsed_data: Full parse result from TraceHandler
|
||||
|
||||
Returns:
|
||||
A dictionary containing the packet record
|
||||
"""
|
||||
# Format trace path for display
|
||||
trace_path_bytes = [f"{h:02X}" for h in trace_path[:8]]
|
||||
if len(trace_path) > 8:
|
||||
trace_hops: List[bytes] = parsed_data.get("trace_hops") or []
|
||||
legacy = parsed_data.get("trace_path") or []
|
||||
|
||||
trace_path_bytes = [h.hex().upper() for h in trace_hops[:8]]
|
||||
if len(trace_hops) > 8:
|
||||
trace_path_bytes.append("...")
|
||||
path_hash = "[" + ", ".join(trace_path_bytes) + "]"
|
||||
|
||||
# Extract SNR information from the path
|
||||
# Extract SNR information from the path (one SNR byte per hop along trace)
|
||||
path_snrs = []
|
||||
path_snr_details = []
|
||||
for i in range(packet.path_len):
|
||||
if i < len(packet.path):
|
||||
snr_val = packet.path[i]
|
||||
snr_db = snr_register_to_db(snr_val)
|
||||
path_snrs.append(f"{snr_val}({snr_db:.1f}dB)")
|
||||
for i in range(len(packet.path)):
|
||||
snr_val = packet.path[i]
|
||||
snr_db = snr_register_to_db(snr_val)
|
||||
path_snrs.append(f"{snr_val}({snr_db:.1f}dB)")
|
||||
|
||||
# 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}
|
||||
)
|
||||
if i < len(trace_hops):
|
||||
path_snr_details.append(
|
||||
{
|
||||
"hash": trace_hops[i].hex().upper(),
|
||||
"snr_raw": snr_val,
|
||||
"snr_db": snr_db,
|
||||
}
|
||||
)
|
||||
elif i < len(legacy):
|
||||
path_snr_details.append(
|
||||
{
|
||||
"hash": f"{legacy[i]:02X}",
|
||||
"snr_raw": snr_val,
|
||||
"snr_db": snr_db,
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"timestamp": time.time(),
|
||||
@@ -188,69 +243,74 @@ class TraceHelper:
|
||||
"path_hash": path_hash,
|
||||
"src_hash": None,
|
||||
"dst_hash": None,
|
||||
"original_path": [f"{h:02X}" for h in trace_path],
|
||||
"original_path": [h.hex() for h in trace_hops],
|
||||
"forwarded_path": None,
|
||||
# Add trace-specific SNR path information
|
||||
"path_snrs": path_snrs, # ["58(14.5dB)", "19(4.8dB)"]
|
||||
"path_snr_details": path_snr_details, # [{"hash": "29", "snr_raw": 58, "snr_db": 14.5}]
|
||||
"path_snr_details": path_snr_details,
|
||||
"is_trace": True,
|
||||
"raw_packet": packet.write_to().hex() if hasattr(packet, "write_to") else None,
|
||||
}
|
||||
|
||||
def _extract_path_info(self, packet, trace_path: list) -> tuple:
|
||||
def _extract_path_info(self, packet, parsed_data: dict) -> tuple:
|
||||
"""
|
||||
Extract SNR and hash information from the packet path.
|
||||
|
||||
Args:
|
||||
packet: The trace packet
|
||||
trace_path: The parsed trace path from the payload
|
||||
|
||||
Returns:
|
||||
A tuple of (path_snrs, path_hashes) lists
|
||||
A tuple of (path_snrs, path_hashes) display lists
|
||||
"""
|
||||
trace_hops: List[bytes] = parsed_data.get("trace_hops") or []
|
||||
path_snrs = []
|
||||
path_hashes = []
|
||||
|
||||
for i in range(packet.path_len):
|
||||
for i in range(len(packet.path)):
|
||||
if i < len(packet.path):
|
||||
snr_val = packet.path[i]
|
||||
snr_db = snr_register_to_db(snr_val)
|
||||
path_snrs.append(f"{snr_val}({snr_db:.1f}dB)")
|
||||
|
||||
if i < len(trace_path):
|
||||
path_hashes.append(f"0x{trace_path[i]:02x}")
|
||||
if i < len(trace_hops):
|
||||
path_hashes.append(f"0x{trace_hops[i].hex()}")
|
||||
|
||||
return path_snrs, path_hashes
|
||||
|
||||
def _should_forward_trace(self, packet, trace_path: list, trace_path_len: int) -> bool:
|
||||
def _should_forward_trace(
|
||||
self, packet, trace_bytes: bytes, flags: int, hash_width: int
|
||||
) -> bool:
|
||||
"""
|
||||
Determine if this node should forward the trace packet.
|
||||
Uses the same logic as the original working implementation.
|
||||
|
||||
Args:
|
||||
packet: The trace packet
|
||||
trace_path: The parsed trace path from the payload
|
||||
trace_path_len: The length of the trace path
|
||||
|
||||
Returns:
|
||||
True if the packet should be forwarded, False otherwise
|
||||
Mesh.cpp TRACE branch: forward if offset < len and next hash matches identity.
|
||||
offset = pkt->path_len<<path_sz uses SNR count in packet.path (len(packet.path)).
|
||||
"""
|
||||
# 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)
|
||||
)
|
||||
if not trace_bytes or hash_width <= 0:
|
||||
return False
|
||||
snr_count = len(packet.path)
|
||||
byte_off = snr_count * hash_width
|
||||
if byte_off >= len(trace_bytes):
|
||||
return False
|
||||
|
||||
async def _forward_trace_packet(self, packet, trace_path_len: int) -> None:
|
||||
next_hop = trace_bytes[byte_off : byte_off + hash_width]
|
||||
if len(next_hop) != hash_width:
|
||||
return False
|
||||
|
||||
pubkey_pfx = self._pubkey_prefix(hash_width)
|
||||
if len(pubkey_pfx) >= hash_width:
|
||||
match = next_hop == pubkey_pfx[:hash_width]
|
||||
else:
|
||||
match = hash_width == 1 and next_hop[0] == (self.local_hash & 0xFF)
|
||||
|
||||
if not match:
|
||||
return False
|
||||
if not self.repeater_handler:
|
||||
return False
|
||||
return not self.repeater_handler.is_duplicate(packet)
|
||||
|
||||
async def _forward_trace_packet(self, packet, num_hops: 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
|
||||
num_hops: Total hops in trace path (for logging)
|
||||
"""
|
||||
# Update the packet record to show it will be transmitted
|
||||
if self.repeater_handler and hasattr(self.repeater_handler, "recent_packets"):
|
||||
@@ -283,7 +343,8 @@ class TraceHelper:
|
||||
packet.path_len += 1
|
||||
|
||||
logger.info(
|
||||
f"Forwarding trace, stored SNR {current_snr:.1f}dB at position {packet.path_len - 1}"
|
||||
f"Forwarding trace ({num_hops} hop path), stored SNR {current_snr:.1f}dB "
|
||||
f"at SNR index {packet.path_len - 1}"
|
||||
)
|
||||
|
||||
# Inject packet into router for proper routing and transmission
|
||||
@@ -292,26 +353,34 @@ class TraceHelper:
|
||||
else:
|
||||
logger.warning("No packet injector available - trace packet not forwarded")
|
||||
|
||||
def _log_no_forward_reason(self, packet, trace_path: list, trace_path_len: int) -> None:
|
||||
"""
|
||||
Log the reason why a trace packet was not forwarded.
|
||||
|
||||
Args:
|
||||
packet: The trace packet
|
||||
trace_path: The parsed trace path from the payload
|
||||
trace_path_len: The length of the trace path
|
||||
"""
|
||||
if packet.path_len >= trace_path_len:
|
||||
logger.info("Trace completed (reached end of path)")
|
||||
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
|
||||
)
|
||||
logger.info(f"Not our turn (next hop: 0x{expected_hash:02x})")
|
||||
elif self.repeater_handler and self.repeater_handler.is_duplicate(packet):
|
||||
def _log_no_forward_reason(self, packet, trace_bytes: bytes, hash_width: int) -> None:
|
||||
"""Log the reason why this node did not forward the trace."""
|
||||
if self.repeater_handler and self.repeater_handler.is_duplicate(packet):
|
||||
logger.info("Duplicate packet, ignoring")
|
||||
return
|
||||
|
||||
snr_count = len(packet.path)
|
||||
if not trace_bytes or hash_width <= 0:
|
||||
logger.info("Trace: empty path or invalid hash width")
|
||||
return
|
||||
|
||||
if snr_count * hash_width >= len(trace_bytes):
|
||||
logger.info("Trace completed (reached end of path)")
|
||||
return
|
||||
|
||||
byte_off = snr_count * hash_width
|
||||
next_hop = trace_bytes[byte_off : byte_off + hash_width]
|
||||
pubkey_pfx = self._pubkey_prefix(hash_width)
|
||||
if len(next_hop) == hash_width and len(pubkey_pfx) >= hash_width:
|
||||
if next_hop != pubkey_pfx[:hash_width]:
|
||||
logger.info(f"Not our turn (next hop: 0x{next_hop.hex()})")
|
||||
return
|
||||
elif hash_width == 1 and next_hop:
|
||||
if (next_hop[0] & 0xFF) != (self.local_hash & 0xFF):
|
||||
logger.info(f"Not our turn (next hop: 0x{next_hop.hex()})")
|
||||
return
|
||||
|
||||
logger.info("Trace: not forwarded (internal)")
|
||||
|
||||
def register_ping(self, tag: int, target_hash: int) -> asyncio.Event:
|
||||
"""Register a ping request and return an event to wait on.
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
"""
|
||||
MeshCore-compatible Ed25519 vanity key generator.
|
||||
|
||||
Generates Ed25519 keys whose public key hex starts with a user-chosen prefix.
|
||||
Algorithm matches MeshCore's custom scalar clamping (see meshcore-keygen).
|
||||
|
||||
Requires: PyNaCl (pip install PyNaCl)
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import secrets
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from nacl.bindings import crypto_scalarmult_ed25519_base_noclamp
|
||||
|
||||
|
||||
def generate_meshcore_keypair() -> Tuple[bytes, bytes]:
|
||||
"""Generate a MeshCore-compatible Ed25519 keypair.
|
||||
|
||||
Returns:
|
||||
(public_key, private_key) as raw bytes.
|
||||
public_key is 32 bytes, private_key is 64 bytes.
|
||||
"""
|
||||
# 1. Random 32-byte seed
|
||||
seed = secrets.token_bytes(32)
|
||||
|
||||
# 2. SHA-512 hash
|
||||
digest = hashlib.sha512(seed).digest()
|
||||
|
||||
# 3. Ed25519 scalar clamping on first 32 bytes
|
||||
clamped = bytearray(digest[:32])
|
||||
clamped[0] &= 248 # Clear bottom 3 bits
|
||||
clamped[31] &= 63 # Clear top 2 bits
|
||||
clamped[31] |= 64 # Set bit 6
|
||||
|
||||
# 4. Derive public key
|
||||
public_key = crypto_scalarmult_ed25519_base_noclamp(bytes(clamped))
|
||||
|
||||
# 5. Private key = [clamped_scalar][sha512_upper_half]
|
||||
private_key = bytes(clamped) + digest[32:64]
|
||||
|
||||
return public_key, private_key
|
||||
|
||||
|
||||
def generate_vanity_key(
|
||||
prefix: str,
|
||||
max_iterations: int = 5_000_000,
|
||||
) -> Optional[dict]:
|
||||
"""Generate a MeshCore keypair whose public key hex starts with *prefix*.
|
||||
|
||||
Args:
|
||||
prefix: Hex prefix (1-4 chars, case-insensitive).
|
||||
max_iterations: Safety cap to avoid infinite loops.
|
||||
|
||||
Returns:
|
||||
Dict with public_hex, private_hex, attempts on success; None if cap hit.
|
||||
"""
|
||||
target = prefix.upper()
|
||||
|
||||
for attempt in range(1, max_iterations + 1):
|
||||
pub, priv = generate_meshcore_keypair()
|
||||
if pub.hex().upper().startswith(target):
|
||||
return {
|
||||
"public_hex": pub.hex(),
|
||||
"private_hex": priv.hex(),
|
||||
"attempts": attempt,
|
||||
}
|
||||
|
||||
return None
|
||||
@@ -0,0 +1,146 @@
|
||||
"""
|
||||
CLI client for pyMC Repeater.
|
||||
Connects to an already-running repeater daemon via its HTTP API.
|
||||
Reads admin password and HTTP port from the local config.yaml automatically.
|
||||
"""
|
||||
|
||||
import sys
|
||||
|
||||
|
||||
CONFIG_PATHS = [
|
||||
"/etc/pymc_repeater/config.yaml",
|
||||
"config.yaml",
|
||||
]
|
||||
|
||||
|
||||
def _load_config(config_path=None):
|
||||
"""Load repeater config.yaml, trying common paths."""
|
||||
import yaml
|
||||
from pathlib import Path
|
||||
|
||||
paths = [config_path] if config_path else CONFIG_PATHS
|
||||
for p in paths:
|
||||
path = Path(p)
|
||||
if path.is_file():
|
||||
with open(path) as f:
|
||||
return yaml.safe_load(f) or {}
|
||||
return {}
|
||||
|
||||
|
||||
def run_client_cli(host: str = "127.0.0.1", port: int = 8000, password: str = ""):
|
||||
"""
|
||||
Standalone CLI client that connects to a running repeater's HTTP API.
|
||||
"""
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
import json
|
||||
|
||||
base_url = f"http://{host}:{port}"
|
||||
|
||||
# Authenticate to get JWT token
|
||||
token = None
|
||||
if password:
|
||||
try:
|
||||
auth_data = json.dumps({
|
||||
"username": "admin",
|
||||
"password": password,
|
||||
"client_id": "pymc-cli",
|
||||
}).encode()
|
||||
req = urllib.request.Request(
|
||||
f"{base_url}/auth/login",
|
||||
data=auth_data,
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||
result = json.loads(resp.read())
|
||||
token = result.get("token") or result.get("data", {}).get("token")
|
||||
except urllib.error.URLError as e:
|
||||
print(f"Error: Cannot connect to repeater at {base_url} — {e.reason}")
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
print(f"Authentication failed: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
if not token:
|
||||
print("Error: Authentication failed. Check password or repeater status.")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"\npyMC Repeater CLI (connected to {base_url})")
|
||||
print("Type 'help' for available commands, 'exit' to quit.\n")
|
||||
|
||||
while True:
|
||||
try:
|
||||
command = input(">> ").strip()
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
print()
|
||||
break
|
||||
|
||||
if not command:
|
||||
continue
|
||||
if command in ("exit", "quit"):
|
||||
break
|
||||
|
||||
try:
|
||||
payload = json.dumps({"command": command}).encode()
|
||||
req = urllib.request.Request(
|
||||
f"{base_url}/api/cli",
|
||||
data=payload,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {token}",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
result = json.loads(resp.read())
|
||||
if result.get("success"):
|
||||
print(result["data"]["reply"])
|
||||
else:
|
||||
print(f"Error: {result.get('error', 'Unknown error')}")
|
||||
except urllib.error.URLError as e:
|
||||
print(f"Connection error: {e.reason}")
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
|
||||
|
||||
def main():
|
||||
"""Entry point for pymc-cli command."""
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Connect to a running pyMC Repeater and issue CLI commands"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--config", default=None,
|
||||
help="Path to config.yaml (auto-detected if not set)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--host", default=None,
|
||||
help="Repeater HTTP host (default: 127.0.0.1)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--port", type=int, default=None,
|
||||
help="Repeater HTTP port (default: from config or 8000)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
# Load config to get password and port automatically
|
||||
config = _load_config(args.config)
|
||||
repeater_cfg = config.get("repeater", {})
|
||||
security_cfg = repeater_cfg.get("security", {})
|
||||
password = security_cfg.get("admin_password", "")
|
||||
|
||||
if not password:
|
||||
print("Error: No admin_password found in config.yaml.")
|
||||
print("Searched: " + ", ".join(CONFIG_PATHS))
|
||||
sys.exit(1)
|
||||
|
||||
host = args.host or "127.0.0.1"
|
||||
port = args.port or config.get("http", {}).get("port", 8000)
|
||||
|
||||
run_client_cli(host=host, port=port, password=password)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+151
-11
@@ -1,12 +1,16 @@
|
||||
import asyncio
|
||||
import functools
|
||||
import logging
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
import socket
|
||||
import time
|
||||
|
||||
from repeater.companion.utils import validate_companion_node_name, normalize_companion_identity_key
|
||||
from repeater.config import get_radio_for_board, load_config, save_config
|
||||
from repeater.config_manager import ConfigManager
|
||||
from repeater.data_acquisition.glass_handler import GlassHandler
|
||||
from repeater.engine import RepeaterHandler
|
||||
from repeater.handler_helpers import (
|
||||
AdvertHelper,
|
||||
@@ -44,10 +48,13 @@ class RepeaterDaemon:
|
||||
self.text_helper = None
|
||||
self.path_helper = None
|
||||
self.protocol_request_helper = None
|
||||
self.glass_handler = None
|
||||
self.acl = None
|
||||
self.router = None
|
||||
self.companion_bridges: dict[int, object] = {}
|
||||
self.companion_frame_servers: list = []
|
||||
self._shutdown_started = False
|
||||
self._main_task = None
|
||||
|
||||
log_level = config.get("logging", {}).get("level", "INFO")
|
||||
logging.basicConfig(
|
||||
@@ -63,6 +70,28 @@ class RepeaterDaemon:
|
||||
|
||||
logger.info(f"Initializing repeater: {self.config['repeater']['node_name']}")
|
||||
|
||||
#-----------------------------------------------
|
||||
# Get the actual Network IP Address
|
||||
try:
|
||||
# This looks for the IP assigned to the default hostname
|
||||
host_name = socket.gethostname()
|
||||
# We try to get the IP associated with the hostname
|
||||
self.network_ip = socket.gethostbyname(host_name)
|
||||
|
||||
# If that still gives 127.0.x.x, let's try a different internal method
|
||||
if self.network_ip.startswith("127."):
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
# We use a non-routable IP that doesn't require an actual connection
|
||||
s.connect(("10.255.255.255", 1))
|
||||
self.network_ip = s.getsockname()[0]
|
||||
s.close()
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not determine network IP: {e}")
|
||||
self.network_ip = "Unknown"
|
||||
|
||||
logger.info(f"System Network IP: {self.network_ip}")
|
||||
#-----------------------------------------------
|
||||
|
||||
if self.radio is None:
|
||||
radio_type = self.config.get("radio_type", "sx1262")
|
||||
logger.info(f"Initializing radio hardware... (radio_type={radio_type})")
|
||||
@@ -114,7 +143,7 @@ class RepeaterDaemon:
|
||||
logger.info("Identity manager initialized")
|
||||
|
||||
# Set up default repeater identity (not managed by identity manager)
|
||||
identity_key = self.config.get("mesh", {}).get("identity_key")
|
||||
identity_key = self.config.get("repeater", {}).get("identity_key")
|
||||
if not identity_key:
|
||||
logger.error("No identity key found in configuration. Cannot init repeater.")
|
||||
raise RuntimeError("Identity key is required for repeater operation")
|
||||
@@ -170,6 +199,7 @@ class RepeaterDaemon:
|
||||
repeater_handler=self.repeater_handler,
|
||||
packet_injector=self.router.inject_packet,
|
||||
log_fn=logger.info,
|
||||
local_identity=self.local_identity,
|
||||
)
|
||||
logger.info("Trace processing helper initialized")
|
||||
|
||||
@@ -285,6 +315,7 @@ class RepeaterDaemon:
|
||||
radio=self.radio,
|
||||
engine=self.repeater_handler,
|
||||
neighbor_tracker=self.advert_helper,
|
||||
config=self.config,
|
||||
)
|
||||
# Register repeater identity for protocol requests
|
||||
self.protocol_request_helper.register_identity(
|
||||
@@ -303,9 +334,27 @@ class RepeaterDaemon:
|
||||
n,
|
||||
)
|
||||
|
||||
# Subscribe to parsed packets (pre-dedup) so duplicate path variants
|
||||
# still appear in the web UI even though the Dispatcher blocks them.
|
||||
self.dispatcher.add_raw_packet_subscriber(self._on_raw_packet_for_dedup_logging)
|
||||
|
||||
# When trace reaches final node, push PUSH_CODE_TRACE_DATA (0x89) to companion clients (firmware onTraceRecv)
|
||||
self.trace_helper.on_trace_complete = self._on_trace_complete_for_companions
|
||||
|
||||
# Optional pyMC_Glass integration loop (inform/control plane)
|
||||
self.glass_handler = GlassHandler(
|
||||
config=self.config,
|
||||
daemon_instance=self,
|
||||
config_manager=self.config_manager,
|
||||
)
|
||||
await self.glass_handler.start()
|
||||
if (
|
||||
self.repeater_handler
|
||||
and self.repeater_handler.storage
|
||||
and hasattr(self.repeater_handler.storage, "set_glass_publisher")
|
||||
):
|
||||
self.repeater_handler.storage.set_glass_publisher(self.glass_handler.publish_telemetry)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize dispatcher: {e}")
|
||||
raise
|
||||
@@ -432,7 +481,7 @@ class RepeaterDaemon:
|
||||
node_name = settings.get("node_name", name)
|
||||
tcp_port = settings.get("tcp_port", 5000)
|
||||
bind_address = settings.get("bind_address", "0.0.0.0")
|
||||
tcp_timeout_raw = settings.get("tcp_timeout", 120)
|
||||
tcp_timeout_raw = settings.get("tcp_timeout", 8 * 60 * 60) # 8 hours
|
||||
client_idle_timeout_sec = None if tcp_timeout_raw == 0 else int(tcp_timeout_raw)
|
||||
|
||||
def _make_sync_node_name_to_config(companion_name: str):
|
||||
@@ -714,6 +763,21 @@ class RepeaterDaemon:
|
||||
except Exception as e:
|
||||
logger.debug("Push RX raw to companion: %s", e)
|
||||
|
||||
def _on_raw_packet_for_dedup_logging(self, pkt, data: bytes, analysis: dict) -> None:
|
||||
"""Record duplicate packets for UI visibility.
|
||||
|
||||
Called by Dispatcher's raw_packet_subscriber (pre-dedup) so we see
|
||||
all path variants. Only records packets the engine has already seen;
|
||||
novel packets are left for the normal handler path.
|
||||
"""
|
||||
if not self.repeater_handler:
|
||||
return
|
||||
if not self.repeater_handler.is_duplicate(pkt):
|
||||
return # First variant — will reach engine via normal handler path
|
||||
rssi = getattr(pkt, "_rssi", 0) or 0
|
||||
snr = getattr(pkt, "_snr", 0.0) or 0.0
|
||||
self.repeater_handler.record_duplicate(pkt, rssi=rssi, snr=snr)
|
||||
|
||||
async def deliver_control_data(
|
||||
self,
|
||||
snr: float,
|
||||
@@ -745,21 +809,28 @@ class RepeaterDaemon:
|
||||
|
||||
async def _on_trace_complete_for_companions(self, packet, parsed_data) -> None:
|
||||
"""Trace completed at this node: push PUSH_CODE_TRACE_DATA (0x89) to companion clients (firmware onTraceRecv)."""
|
||||
path_len = len(parsed_data.get("trace_path", []))
|
||||
if path_len == 0:
|
||||
path_hashes = parsed_data.get("trace_path_bytes") or b""
|
||||
if not path_hashes:
|
||||
return
|
||||
path_hashes = bytes(parsed_data["trace_path"])
|
||||
flags = parsed_data.get("flags", 0)
|
||||
path_sz = flags & 0x03
|
||||
hash_len = len(path_hashes)
|
||||
expected_snr_len = hash_len >> path_sz
|
||||
if expected_snr_len <= 0:
|
||||
return
|
||||
tag = parsed_data.get("tag", 0)
|
||||
auth_code = parsed_data.get("auth_code", 0)
|
||||
# path_snrs: exactly path_len bytes = (path_len-1) from forwarding hops + 1 (our receive SNR)
|
||||
snr_scaled = max(-128, min(127, int(round(packet.get_snr() * 4))))
|
||||
snr_byte = snr_scaled if snr_scaled >= 0 else (256 + snr_scaled)
|
||||
path_snrs = bytes(packet.path)[: path_len - 1] + bytes([snr_byte])
|
||||
# Firmware: memcpy path_snrs from pkt->path (length hash_len >> path_sz), then final SNR byte
|
||||
raw = bytes(packet.path)[:expected_snr_len]
|
||||
if len(raw) < expected_snr_len:
|
||||
raw = raw + b"\x00" * (expected_snr_len - len(raw))
|
||||
path_snrs = raw
|
||||
for fs in getattr(self, "companion_frame_servers", []):
|
||||
try:
|
||||
fs.push_trace_data(
|
||||
path_len, flags, tag, auth_code, path_hashes, path_snrs, snr_byte
|
||||
await fs.push_trace_data_async(
|
||||
hash_len, flags, tag, auth_code, path_hashes, path_snrs, snr_byte
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug("Push trace data to companion: %s", e)
|
||||
@@ -871,7 +942,7 @@ class RepeaterDaemon:
|
||||
"queue_len": min(255, queue_len),
|
||||
}
|
||||
if stats_type == STATS_TYPE_RADIO:
|
||||
noise_floor = int(engine.get_noise_floor() or 0)
|
||||
noise_floor = int(engine.get_cached_noise_floor() or 0)
|
||||
radio = getattr(self, "dispatcher", None) and getattr(self.dispatcher, "radio", None)
|
||||
if radio:
|
||||
_r = getattr(radio, "get_last_rssi", lambda: 0)
|
||||
@@ -906,6 +977,11 @@ class RepeaterDaemon:
|
||||
logger.error("Cannot send advert: dispatcher or identity not initialized")
|
||||
return False
|
||||
|
||||
mode = self.config.get("repeater", {}).get("mode", "forward")
|
||||
if mode == "no_tx":
|
||||
logger.debug("Adverts disabled in no_tx mode")
|
||||
return False
|
||||
|
||||
try:
|
||||
from pymc_core.protocol import PacketBuilder
|
||||
from pymc_core.protocol.constants import ADVERT_FLAG_HAS_NAME, ADVERT_FLAG_IS_REPEATER
|
||||
@@ -944,8 +1020,39 @@ class RepeaterDaemon:
|
||||
logger.error(f"Failed to send advert: {e}", exc_info=True)
|
||||
return False
|
||||
|
||||
def _signal_shutdown(self, sig, loop):
|
||||
"""Handle SIGTERM/SIGINT by scheduling async shutdown."""
|
||||
if self._shutdown_started:
|
||||
logger.info(f"Received signal {sig.name}, shutdown already in progress")
|
||||
return
|
||||
logger.info(f"Received signal {sig.name}, shutting down...")
|
||||
loop.create_task(self._shutdown())
|
||||
# Cancel run() so dispatcher.run_forever() unwinds cleanly.
|
||||
if self._main_task and not self._main_task.done():
|
||||
self._main_task.cancel()
|
||||
|
||||
async def _shutdown(self):
|
||||
"""Best-effort shutdown: stop background services and release hardware."""
|
||||
if self._shutdown_started:
|
||||
return
|
||||
self._shutdown_started = True
|
||||
|
||||
# Stop companion frame servers first to close client sockets and child workers.
|
||||
for frame_server in getattr(self, "companion_frame_servers", []):
|
||||
try:
|
||||
await frame_server.stop()
|
||||
except Exception as e:
|
||||
logger.warning(f"Companion frame server stop error: {e}")
|
||||
|
||||
# Stop companion bridges to flush/persist state.
|
||||
if hasattr(self, "companion_bridges"):
|
||||
for bridge in self.companion_bridges.values():
|
||||
if hasattr(bridge, "stop"):
|
||||
try:
|
||||
await bridge.stop()
|
||||
except Exception as e:
|
||||
logger.warning(f"Companion bridge stop error: {e}")
|
||||
|
||||
# Stop router
|
||||
if self.router:
|
||||
try:
|
||||
@@ -956,10 +1063,30 @@ class RepeaterDaemon:
|
||||
# Stop HTTP server
|
||||
if self.http_server:
|
||||
try:
|
||||
self.http_server.stop()
|
||||
await asyncio.wait_for(asyncio.to_thread(self.http_server.stop), timeout=3)
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning("Timeout stopping HTTP server")
|
||||
except Exception as e:
|
||||
logger.warning(f"Error stopping HTTP server: {e}")
|
||||
|
||||
# Stop Glass inform loop
|
||||
if self.glass_handler:
|
||||
try:
|
||||
await self.glass_handler.stop()
|
||||
except Exception as e:
|
||||
logger.warning(f"Error stopping Glass handler: {e}")
|
||||
|
||||
# Close storage publishers (MQTT/LetsMesh) to stop their worker threads.
|
||||
try:
|
||||
if self.repeater_handler and self.repeater_handler.storage:
|
||||
await asyncio.wait_for(
|
||||
asyncio.to_thread(self.repeater_handler.storage.close), timeout=5
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning("Timeout closing storage publishers")
|
||||
except Exception as e:
|
||||
logger.warning(f"Error closing storage: {e}")
|
||||
|
||||
# Release radio resources
|
||||
if self.radio and hasattr(self.radio, "cleanup"):
|
||||
try:
|
||||
@@ -976,6 +1103,8 @@ class RepeaterDaemon:
|
||||
except Exception as e:
|
||||
logger.debug(f"CH341 reset skipped/failed: {e}")
|
||||
|
||||
# Do not force-stop the event loop here; asyncio.run() owns loop lifecycle.
|
||||
|
||||
@staticmethod
|
||||
def _detect_container() -> bool:
|
||||
"""Detect if running inside an LXC/Docker/systemd-nspawn container."""
|
||||
@@ -990,6 +1119,15 @@ class RepeaterDaemon:
|
||||
async def run(self):
|
||||
|
||||
logger.info("Repeater daemon started")
|
||||
self._main_task = asyncio.current_task()
|
||||
|
||||
# Register signal handlers for graceful shutdown
|
||||
loop = asyncio.get_running_loop()
|
||||
for sig in (signal.SIGTERM, signal.SIGINT):
|
||||
loop.add_signal_handler(
|
||||
sig,
|
||||
functools.partial(self._signal_shutdown, sig, loop),
|
||||
)
|
||||
|
||||
# Warn if running inside a container (udev rules won't work here)
|
||||
if os.path.exists("/.dockerenv") or os.environ.get("container") or self._detect_container():
|
||||
@@ -1040,6 +1178,8 @@ class RepeaterDaemon:
|
||||
# Run dispatcher (handles RX/TX via pymc_core)
|
||||
try:
|
||||
await self.dispatcher.run_forever()
|
||||
except asyncio.CancelledError:
|
||||
logger.info("Dispatcher loop cancelled for shutdown")
|
||||
except KeyboardInterrupt:
|
||||
logger.info("Shutting down...")
|
||||
for frame_server in getattr(self, "companion_frame_servers", []):
|
||||
|
||||
@@ -47,13 +47,29 @@ class PacketRouter:
|
||||
|
||||
def __init__(self, daemon_instance):
|
||||
self.daemon = daemon_instance
|
||||
self.queue = asyncio.Queue()
|
||||
self.queue = asyncio.Queue(maxsize=500)
|
||||
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 = {}
|
||||
# Safety valve: cap the number of _route_packet tasks sleeping concurrently.
|
||||
# LoRa's airtime budget naturally limits throughput, but burst arrivals
|
||||
# (multi-hop amplification, collision retries) can stack many sleeping
|
||||
# delay tasks before the duty-cycle gate fires. 30 is very generous for
|
||||
# any realistic LoRa network but protects against pathological scenarios
|
||||
# (e.g. a busy bridge node during a mesh-wide flood) exhausting memory or
|
||||
# starving the event loop.
|
||||
self._in_flight: int = 0
|
||||
self._max_in_flight: int = 30
|
||||
# Live set of in-flight tasks — kept in sync with _in_flight via the
|
||||
# done-callback. Used exclusively for shutdown drain; the integer
|
||||
# counter is used for the cap check (faster, single source of truth).
|
||||
self._route_tasks: set = set()
|
||||
# Total packets dropped because the cap was reached. Exposed in logs
|
||||
# at shutdown so operators know whether the cap is actually firing.
|
||||
self._cap_drop_count: int = 0
|
||||
|
||||
async def start(self):
|
||||
self.running = True
|
||||
@@ -68,7 +84,43 @@ class PacketRouter:
|
||||
await self.router_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
# Drain in-flight tasks gracefully, then cancel any that outlast the
|
||||
# timeout. This mirrors what the old _route_tasks set enabled and gives
|
||||
# in-progress packets a fair chance to finish (e.g. their TX delay sleep
|
||||
# + send) before the process exits.
|
||||
if self._route_tasks:
|
||||
pending_snapshot = set(self._route_tasks)
|
||||
logger.info(
|
||||
"Draining %d in-flight route task(s) (5 s timeout)...",
|
||||
len(pending_snapshot),
|
||||
)
|
||||
_, still_pending = await asyncio.wait(pending_snapshot, timeout=5.0)
|
||||
if still_pending:
|
||||
logger.warning(
|
||||
"Cancelling %d route task(s) that did not finish within the shutdown timeout",
|
||||
len(still_pending),
|
||||
)
|
||||
for task in still_pending:
|
||||
task.cancel()
|
||||
await asyncio.gather(*still_pending, return_exceptions=True)
|
||||
|
||||
if self._cap_drop_count:
|
||||
logger.warning(
|
||||
"In-flight cap dropped %d packet(s) during this session — "
|
||||
"consider raising _max_in_flight if this is frequent",
|
||||
self._cap_drop_count,
|
||||
)
|
||||
logger.info("Packet router stopped")
|
||||
|
||||
def _on_route_done(self, task: asyncio.Task) -> None:
|
||||
"""Done-callback for _route_packet tasks: decrement counter and surface errors."""
|
||||
self._in_flight -= 1
|
||||
self._route_tasks.discard(task)
|
||||
if not task.cancelled():
|
||||
exc = task.exception()
|
||||
if exc is not None:
|
||||
logger.error("_route_packet raised: %s", exc, exc_info=exc)
|
||||
|
||||
def _should_deliver_path_to_companions(self, packet) -> bool:
|
||||
"""Return True if this PATH/protocol-response should be delivered to companions (first of duplicates)."""
|
||||
@@ -76,8 +128,15 @@ class PacketRouter:
|
||||
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}
|
||||
# Prune expired entries only when the dict grows large, avoiding a full
|
||||
# dict comprehension on every packet. 200 entries × 60 s TTL means a
|
||||
# sweep only triggers after ~200 unique PATH packets with no expiry — far
|
||||
# more than any realistic companion session, and well below the 1000-entry
|
||||
# threshold that could accumulate over hours without pruning.
|
||||
if len(self._companion_delivered) > 200:
|
||||
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
|
||||
@@ -94,6 +153,12 @@ class PacketRouter:
|
||||
|
||||
async def enqueue(self, packet):
|
||||
"""Add packet to router queue."""
|
||||
if self.queue.full():
|
||||
logger.warning("Packet router queue full (%d), dropping oldest", self.queue.maxsize)
|
||||
try:
|
||||
self.queue.get_nowait()
|
||||
except asyncio.QueueEmpty:
|
||||
pass
|
||||
await self.queue.put(packet)
|
||||
|
||||
async def inject_packet(self, packet, wait_for_ack: bool = False):
|
||||
@@ -112,6 +177,9 @@ class PacketRouter:
|
||||
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)
|
||||
|
||||
@@ -137,7 +205,21 @@ class PacketRouter:
|
||||
while self.running:
|
||||
try:
|
||||
packet = await asyncio.wait_for(self.queue.get(), timeout=0.1)
|
||||
await self._route_packet(packet)
|
||||
# Drop early if the in-flight cap is reached. This is a last-resort
|
||||
# safety valve — under normal operation LoRa airtime and the duty-cycle
|
||||
# gate keep _in_flight well below _max_in_flight.
|
||||
if self._in_flight >= self._max_in_flight:
|
||||
self._cap_drop_count += 1
|
||||
logger.warning(
|
||||
"In-flight task cap reached (%d/%d), dropping packet "
|
||||
"(session total dropped: %d)",
|
||||
self._in_flight, self._max_in_flight, self._cap_drop_count,
|
||||
)
|
||||
continue
|
||||
self._in_flight += 1
|
||||
task = asyncio.create_task(self._route_packet(packet))
|
||||
self._route_tasks.add(task)
|
||||
task.add_done_callback(self._on_route_done)
|
||||
except asyncio.TimeoutError:
|
||||
continue
|
||||
except Exception as e:
|
||||
@@ -155,12 +237,19 @@ class PacketRouter:
|
||||
|
||||
# Route to specific handlers for parsing only
|
||||
if payload_type == TraceHandler.payload_type():
|
||||
# Process trace packet
|
||||
if self.daemon.trace_helper:
|
||||
# Locally injected TRACE requests are TX-only and re-enter the router so
|
||||
# companion delivery can still happen. They are not inbound RF responses,
|
||||
# so skip TraceHelper parsing to avoid matching pending ping tags against
|
||||
# zeroed local metadata.
|
||||
if getattr(packet, "_injected_for_tx", False):
|
||||
processed_by_injection = True
|
||||
elif self.daemon.trace_helper:
|
||||
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)
|
||||
# Do not call _record_for_ui: TraceHelper.log_trace_record already persists the
|
||||
# trace path from the payload. record_packet_only would treat packet.path (SNR bytes)
|
||||
# as routing hashes and log bogus duplicate rows.
|
||||
|
||||
elif payload_type == ControlHandler.payload_type():
|
||||
# Process control/discovery packet
|
||||
@@ -360,6 +449,9 @@ class PacketRouter:
|
||||
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),
|
||||
|
||||
@@ -4,22 +4,57 @@ Provides functions for service control operations like restart.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
from typing import Tuple
|
||||
|
||||
logger = logging.getLogger("ServiceUtils")
|
||||
INIT_SCRIPT = "/etc/init.d/S80pymc-repeater"
|
||||
|
||||
|
||||
def is_buildroot() -> bool:
|
||||
if os.path.exists("/etc/pymc-image-build-id"):
|
||||
return True
|
||||
if os.path.exists("/etc/os-release"):
|
||||
try:
|
||||
with open("/etc/os-release", "r", encoding="utf-8") as handle:
|
||||
return any(line.strip() == "ID=buildroot" for line in handle)
|
||||
except OSError:
|
||||
return False
|
||||
return False
|
||||
|
||||
|
||||
def restart_service() -> Tuple[bool, str]:
|
||||
"""
|
||||
Restart the pymc-repeater service via systemctl.
|
||||
Restart the pymc-repeater service.
|
||||
|
||||
Tries polkit-based restart first (plain systemctl), then falls back
|
||||
to sudo-based restart (requires sudoers.d rule installed by manage.sh).
|
||||
On Buildroot/Luckfox, use the shipped init script directly.
|
||||
On systemd hosts, try polkit-based restart first (plain systemctl), then
|
||||
fall back to sudo-based restart (requires sudoers.d rule installed by
|
||||
manage.sh).
|
||||
|
||||
Returns:
|
||||
Tuple[bool, str]: (success, message)
|
||||
"""
|
||||
if is_buildroot():
|
||||
if not os.path.exists(INIT_SCRIPT):
|
||||
logger.error("Buildroot init script not found: %s", INIT_SCRIPT)
|
||||
return False, f"init script not found: {INIT_SCRIPT}"
|
||||
|
||||
try:
|
||||
subprocess.Popen(
|
||||
["/bin/sh", "-c", f"sleep 1; exec {INIT_SCRIPT} restart >/dev/null 2>&1"],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
stdin=subprocess.DEVNULL,
|
||||
start_new_session=True,
|
||||
)
|
||||
logger.info("Service restart scheduled via Buildroot init script")
|
||||
return True, "Service restart initiated"
|
||||
except Exception as exc:
|
||||
logger.error(f"Buildroot restart failed: {exc}")
|
||||
return False, f"Restart failed: {exc}"
|
||||
|
||||
# Try polkit-based restart first (works on bare metal / VMs with polkit running)
|
||||
try:
|
||||
result = subprocess.run(
|
||||
|
||||
+1055
-88
File diff suppressed because it is too large
Load Diff
@@ -37,6 +37,10 @@ class CompanionAPIEndpoints:
|
||||
self.config = config or {}
|
||||
self.config_manager = config_manager
|
||||
|
||||
http_cfg = self.config.get("http", {}) if isinstance(self.config, dict) else {}
|
||||
self._sse_queue_maxsize = max(32, int(http_cfg.get("sse_queue_maxsize", 64)))
|
||||
self._sse_keepalive_sec = max(5, int(http_cfg.get("sse_keepalive_sec", 15)))
|
||||
|
||||
# SSE clients: each gets a thread-safe queue
|
||||
self._sse_clients: list[queue.Queue] = []
|
||||
self._sse_lock = threading.Lock()
|
||||
@@ -146,6 +150,23 @@ class CompanionAPIEndpoints:
|
||||
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
|
||||
# ------------------------------------------------------------------
|
||||
@@ -323,6 +344,75 @@ class CompanionAPIEndpoints:
|
||||
}
|
||||
)
|
||||
|
||||
@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
|
||||
@@ -580,7 +670,7 @@ class CompanionAPIEndpoints:
|
||||
cherrypy.response.headers["Connection"] = "keep-alive"
|
||||
cherrypy.response.headers["X-Accel-Buffering"] = "no"
|
||||
|
||||
client_queue: queue.Queue = queue.Queue(maxsize=256)
|
||||
client_queue: queue.Queue = queue.Queue(maxsize=self._sse_queue_maxsize)
|
||||
with self._sse_lock:
|
||||
self._sse_clients.append(client_queue)
|
||||
|
||||
@@ -591,12 +681,12 @@ class CompanionAPIEndpoints:
|
||||
|
||||
while True:
|
||||
try:
|
||||
item = client_queue.get(timeout=15.0)
|
||||
item = client_queue.get(timeout=float(self._sse_keepalive_sec))
|
||||
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"
|
||||
# Keep-alive comment frame keeps EventSource connected
|
||||
# without allocating additional JSON payload objects.
|
||||
yield ": keepalive\n\n"
|
||||
except GeneratorExit:
|
||||
pass
|
||||
except Exception as exc:
|
||||
|
||||
@@ -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
+1
-1
@@ -1 +1 @@
|
||||
.glass-card[data-v-c30e5f38]{background:var(--color-glass-bg);-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px);border:1px solid var(--color-glass-border);box-shadow:var(--color-glass-shadow)}
|
||||
.glass-card[data-v-60d82848]{background:var(--color-glass-bg);-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px);border:1px solid var(--color-glass-border);box-shadow:var(--color-glass-shadow)}
|
||||
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 @@
|
||||
import{dt as e,g as t,l as n,pt as r,s as i,u as a,w as o}from"./runtime-core.esm-bundler-HnidnMFy.js";import{h as s}from"./index-BFltqMtv.js";var c={class:`flex items-center justify-between mb-4`},l={class:`text-xl font-semibold text-content-primary dark:text-content-primary`},u={class:`mb-6`},d={key:0,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},f={key:1,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},p={key:2,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},m={class:`text-content-secondary dark:text-content-primary/80 text-base leading-relaxed`},h={class:`flex gap-3`},g=t({__name:`ConfirmDialog`,props:{show:{type:Boolean},title:{default:`Confirm Action`},message:{},confirmText:{default:`Confirm`},cancelText:{default:`Cancel`},variant:{default:`warning`}},emits:[`close`,`confirm`],setup(t,{emit:g}){let _=t,v=g,y=e=>{e.target===e.currentTarget&&v(`close`)},b={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(t,g)=>_.show?(o(),a(`div`,{key:0,onClick:y,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`}},[i(`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:g[3]||=s(()=>{},[`stop`])},[i(`div`,c,[i(`h3`,l,r(_.title),1),i(`button`,{onClick:g[0]||=e=>v(`close`),class:`text-content-secondary dark:text-content-muted hover:text-content-primary dark:hover:text-content-primary transition-colors`},[...g[4]||=[i(`svg`,{class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[i(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M6 18L18 6M6 6l12 12`})],-1)]])]),i(`div`,u,[i(`div`,{class:e([`inline-flex p-3 rounded-xl mb-4`,b[_.variant]])},[_.variant===`danger`?(o(),a(`svg`,d,[...g[5]||=[i(`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)]])):_.variant===`warning`?(o(),a(`svg`,f,[...g[6]||=[i(`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(),a(`svg`,p,[...g[7]||=[i(`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),i(`p`,m,r(_.message),1)]),i(`div`,h,[i(`button`,{onClick:g[1]||=e=>v(`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`},r(_.cancelText),1),i(`button`,{onClick:g[2]||=e=>v(`confirm`),class:e([`flex-1 px-4 py-3 rounded-xl text-white transition-all duration-200`,x[_.variant]])},r(_.confirmText),3)])])])):n(``,!0)}});export{g as t};
|
||||
@@ -1 +0,0 @@
|
||||
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-BABkwxNn.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{_};
|
||||
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 +0,0 @@
|
||||
import{a as e,b as r,i as o,p as n}from"./index-BABkwxNn.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 & 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};
|
||||
@@ -0,0 +1 @@
|
||||
import{f as e,g as t,u as n,w as r}from"./runtime-core.esm-bundler-HnidnMFy.js";var i=t({name:`HelpView`,__name:`Help`,setup(t){return(t,i)=>(r(),n(`div`,null,[...i[0]||=[e(`<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 & 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{i as default};
|
||||
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
.bg-gradient-light[data-v-7d3a3377]{background:linear-gradient(to bottom,#0ea5e966,#06b6d44d)}.bg-gradient-dark[data-v-7d3a3377]{background:linear-gradient(to bottom,#67e8f94d,#a5f3fc26)}.login-card[data-v-7d3a3377]{background:#11191c66;backdrop-filter:blur(40px) saturate(180%);-webkit-backdrop-filter:blur(40px) saturate(180%)}.login-card[data-v-7d3a3377]{background:#ffffffb3}.dark .login-card[data-v-7d3a3377]{background:#11191c66}.input-glass[data-v-7d3a3377]{backdrop-filter:blur(20px);-webkit-backdrop-filter:blur(20px)}.input-glass[data-v-7d3a3377]{background:#ffffffe6;border:1px solid #D1D5DB}.dark .input-glass[data-v-7d3a3377]{background:#ffffff0d;border-color:#ffffff1a}.input-glass[data-v-7d3a3377]:focus{background:#fff}.dark .input-glass[data-v-7d3a3377]:focus{background:#ffffff1a}.input-glass[data-v-7d3a3377]:focus{box-shadow:0 0 0 1px #aae8e833,0 0 20px #aae8e826,inset 0 1px #ffffff1a}.input-glow[data-v-7d3a3377]{opacity:0;transition:opacity .3s ease;box-shadow:inset 0 1px #ffffff0d}.input-glass:focus+.input-glow[data-v-7d3a3377]{opacity:1;box-shadow:0 0 20px #aae8e833,inset 0 1px #ffffff1a}.button-glass[data-v-7d3a3377]{backdrop-filter:blur(20px);-webkit-backdrop-filter:blur(20px);position:relative}.button-glass[data-v-7d3a3377]:before{content:"";position:absolute;inset:0;border-radius:12px;padding:1px;background:linear-gradient(90deg,transparent 0%,rgba(170,232,232,.3) 50%,transparent 100%);-webkit-mask:linear-gradient(#fff 0 0) content-box,linear-gradient(#fff 0 0);-webkit-mask-composite:xor;mask-composite:exclude;transform:translate(-100%);transition:transform 1s ease}.button-glass[data-v-7d3a3377]:hover:not(:disabled):before{transform:translate(100%)}.button-glass[data-v-7d3a3377]{box-shadow:0 0 0 1px #aae8e833,0 4px 16px #0003,inset 0 1px #ffffff1a}.button-glass[data-v-7d3a3377]:hover:not(:disabled){box-shadow:0 0 0 1px #aae8e866,0 0 30px #aae8e84d,0 4px 20px #0000004d,inset 0 1px #ffffff26}.login-content:has(.button-glass:hover:not(:disabled)) .logo-image[data-v-7d3a3377]{filter:brightness(1.4) drop-shadow(0 0 12px rgba(170,232,232,.7));transform:scale(1.02)}.login-content:has(.button-glass:hover:not(:disabled)) .logo-glow[data-v-7d3a3377]{opacity:.6;transform:scale(1.15)}.logo-glow[data-v-7d3a3377]{opacity:0}.dark .logo-glow[data-v-7d3a3377]{opacity:1}@keyframes float-7d3a3377{0%,to{transform:translateY(0)}50%{transform:translateY(-10px)}}@keyframes pulse-slow-7d3a3377{0%,to{opacity:.8;transform:scale(1)}50%{opacity:.6;transform:scale(1.05)}}@keyframes pulse-slower-7d3a3377{0%,to{opacity:.75;transform:scale(1)}50%{opacity:.5;transform:scale(1.08)}}@keyframes pulse-slowest-7d3a3377{0%,to{opacity:.8;transform:scale(1)}50%{opacity:.6;transform:scale(1.06)}}.animate-pulse-slow[data-v-7d3a3377]{animation:pulse-slow-7d3a3377 8s ease-in-out infinite}.animate-pulse-slower[data-v-7d3a3377]{animation:pulse-slower-7d3a3377 10s ease-in-out infinite}.animate-pulse-slowest[data-v-7d3a3377]{animation:pulse-slowest-7d3a3377 12s ease-in-out infinite}@keyframes shake-7d3a3377{0%,to{transform:translate(0)}10%,30%,50%,70%,90%{transform:translate(-5px)}20%,40%,60%,80%{transform:translate(5px)}}.animate-shake[data-v-7d3a3377]{animation:shake-7d3a3377 .5s ease-in-out}.form-group[data-v-7d3a3377]{position:relative}.form-group:hover label[data-v-7d3a3377]{color:#aae8e8e6;transition:color .3s ease}
|
||||
@@ -0,0 +1 @@
|
||||
.bg-gradient-light[data-v-fec81ee3]{background:linear-gradient(#0ea5e966,#06b6d44d)}.bg-gradient-dark[data-v-fec81ee3]{background:linear-gradient(#67e8f94d,#a5f3fc26)}.login-card[data-v-fec81ee3]{-webkit-backdrop-filter:blur(40px)saturate(180%);background:#ffffffb3}.dark .login-card[data-v-fec81ee3]{background:#11191c66}.input-glass[data-v-fec81ee3]{-webkit-backdrop-filter:blur(20px);background:#ffffffe6;border:1px solid #d1d5db}.dark .input-glass[data-v-fec81ee3]{background:#ffffff0d;border-color:#ffffff1a}.input-glass[data-v-fec81ee3]:focus{background:#fff}.dark .input-glass[data-v-fec81ee3]:focus{background:#ffffff1a}.input-glass[data-v-fec81ee3]:focus{box-shadow:0 0 0 1px #aae8e833,0 0 20px #aae8e826,inset 0 1px #ffffff1a}.input-glow[data-v-fec81ee3]{opacity:0;transition:opacity .3s;box-shadow:inset 0 1px #ffffff0d}.input-glass:focus+.input-glow[data-v-fec81ee3]{opacity:1;box-shadow:0 0 20px #aae8e833,inset 0 1px #ffffff1a}.button-glass[data-v-fec81ee3]{-webkit-backdrop-filter:blur(20px);position:relative}.button-glass[data-v-fec81ee3]:before{content:"";-webkit-mask-composite:xor;background:linear-gradient(90deg,#0000 0%,#aae8e84d 50%,#0000 100%);border-radius:12px;padding:1px;transition:transform 1s;position:absolute;inset:0;transform:translate(-100%);-webkit-mask-image:linear-gradient(#fff 0 0),linear-gradient(#fff 0 0);-webkit-mask-position:0 0,0 0;-webkit-mask-size:auto,auto;-webkit-mask-repeat:repeat,repeat;-webkit-mask-clip:content-box,border-box;-webkit-mask-origin:content-box,border-box;-webkit-mask-composite:xor;mask-composite:exclude;-webkit-mask-source-type:auto,auto;mask-mode:match-source,match-source}.button-glass[data-v-fec81ee3]:hover:not(:disabled):before{transform:translate(100%)}.button-glass[data-v-fec81ee3]{box-shadow:0 0 0 1px #aae8e833,0 4px 16px #0003,inset 0 1px #ffffff1a}.button-glass[data-v-fec81ee3]:hover:not(:disabled){box-shadow:0 0 0 1px #aae8e866,0 0 30px #aae8e84d,0 4px 20px #0000004d,inset 0 1px #ffffff26}.login-content:has(.button-glass:hover:not(:disabled)) .logo-image[data-v-fec81ee3]{filter:brightness(1.4)drop-shadow(0 0 12px #aae8e8b3);transform:scale(1.02)}.login-content:has(.button-glass:hover:not(:disabled)) .logo-glow[data-v-fec81ee3]{opacity:.6;transform:scale(1.15)}.logo-glow[data-v-fec81ee3]{opacity:0}.dark .logo-glow[data-v-fec81ee3]{opacity:1}@keyframes float-fec81ee3{0%,to{transform:translateY(0)}50%{transform:translateY(-10px)}}@keyframes pulse-slow-fec81ee3{0%,to{opacity:.8;transform:scale(1)}50%{opacity:.6;transform:scale(1.05)}}@keyframes pulse-slower-fec81ee3{0%,to{opacity:.75;transform:scale(1)}50%{opacity:.5;transform:scale(1.08)}}@keyframes pulse-slowest-fec81ee3{0%,to{opacity:.8;transform:scale(1)}50%{opacity:.6;transform:scale(1.06)}}.animate-pulse-slow[data-v-fec81ee3]{animation:8s ease-in-out infinite pulse-slow-fec81ee3}.animate-pulse-slower[data-v-fec81ee3]{animation:10s ease-in-out infinite pulse-slower-fec81ee3}.animate-pulse-slowest[data-v-fec81ee3]{animation:12s ease-in-out infinite pulse-slowest-fec81ee3}@keyframes shake-fec81ee3{0%,to{transform:translate(0)}10%,30%,50%,70%,90%{transform:translate(-5px)}20%,40%,60%,80%{transform:translate(5px)}}.animate-shake[data-v-fec81ee3]{animation:.5s ease-in-out shake-fec81ee3}@keyframes logo-aura-cycle-fec81ee3{0%,to{filter:brightness()saturate()drop-shadow(0 0 7px #38bdf873)}25%{filter:brightness(1.02)saturate(1.05)drop-shadow(0 0 10px #6366f16b)}50%{filter:brightness()saturate(1.03)drop-shadow(0 0 8px #22d3ee73)}75%{filter:brightness(1.02)saturate(1.05)drop-shadow(0 0 10px #34d3996b)}}.logo-image-animated[data-v-fec81ee3]{will-change:filter;animation:6s ease-in-out infinite logo-aura-cycle-fec81ee3}.form-group[data-v-fec81ee3]{position:relative}.form-group:hover label[data-v-fec81ee3]{color:#aae8e8e6;transition:color .3s}
|
||||
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{dt as e,g as t,l as n,pt as r,s as i,u as a,w as o}from"./runtime-core.esm-bundler-HnidnMFy.js";import{h as s}from"./index-BFltqMtv.js";var c={class:`mb-6`},l={key:0,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},u={key:1,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},d={key:2,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},f={class:`text-content-secondary dark:text-content-primary/80 text-base leading-relaxed`},p={class:`flex`},m=t({__name:`MessageDialog`,props:{show:{type:Boolean},message:{},variant:{default:`success`}},emits:[`close`],setup(t,{emit:m}){let h=t,g=m,_=e=>{e.target===e.currentTarget&&g(`close`)},v={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`},y={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(t,m)=>h.show?(o(),a(`div`,{key:0,onClick:_,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`}},[i(`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:m[1]||=s(()=>{},[`stop`])},[i(`div`,c,[i(`div`,{class:e([`inline-flex p-3 rounded-xl mb-4`,v[h.variant]])},[h.variant===`success`?(o(),a(`svg`,l,[...m[2]||=[i(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M5 13l4 4L19 7`},null,-1)]])):h.variant===`error`?(o(),a(`svg`,u,[...m[3]||=[i(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M6 18L18 6M6 6l12 12`},null,-1)]])):(o(),a(`svg`,d,[...m[4]||=[i(`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),i(`p`,f,r(h.message),1)]),i(`div`,p,[i(`button`,{onClick:m[0]||=e=>g(`close`),class:e([`flex-1 px-4 py-3 rounded-xl text-white transition-all duration-200`,y[h.variant]])},` OK `,2)])])])):n(``,!0)}});export{m as t};
|
||||
@@ -1 +0,0 @@
|
||||
import{a as k,b as o,g,e as r,j as a,t as p,s as x,p as s}from"./index-BABkwxNn.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")},b={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"},u={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",b[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",u[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
@@ -0,0 +1 @@
|
||||
import{n as e}from"./index-BFltqMtv.js";export{e 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
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-a201f2f2]{-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px);background:#ffffff0d;border:1px solid #ffffff1a}.modal-enter-active[data-v-a201f2f2],.modal-leave-active[data-v-a201f2f2]{transition:opacity .3s}.modal-enter-from[data-v-a201f2f2],.modal-leave-to[data-v-a201f2f2]{opacity:0}.modal-enter-active .glass-card[data-v-a201f2f2],.modal-leave-active .glass-card[data-v-a201f2f2]{transition:transform .3s}.modal-enter-from .glass-card[data-v-a201f2f2],.modal-leave-to .glass-card[data-v-a201f2f2]{transform:scale(.9)}.slide-enter-active[data-v-a201f2f2],.slide-leave-active[data-v-a201f2f2]{transition:all .3s}.slide-enter-from[data-v-a201f2f2],.slide-leave-to[data-v-a201f2f2]{opacity:0;transform:translateY(-10px)}@keyframes float-slow-a201f2f2{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-a201f2f2{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-a201f2f2{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-a201f2f2]{will-change:transform, opacity;animation:15s ease-in-out infinite float-slow-a201f2f2}.animate-pulse-slower[data-v-a201f2f2]{will-change:transform, opacity;animation:18s ease-in-out infinite float-slower-a201f2f2}.animate-pulse-slowest[data-v-a201f2f2]{will-change:transform, opacity;animation:20s ease-in-out infinite float-slowest-a201f2f2}
|
||||
File diff suppressed because one or more lines are too long
@@ -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}
|
||||
@@ -1 +0,0 @@
|
||||
.plotly-chart[data-v-8daccd7e]{background:transparent!important}
|
||||
@@ -0,0 +1 @@
|
||||
.plotly-chart[data-v-54d032e1]{background:0 0!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
@@ -1 +0,0 @@
|
||||
.glass-card[data-v-eab6d04d]{-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px);background:#ffffffbf;border:1px solid rgba(0,0,0,.06);box-shadow:0 2px 8px #0000000a}.dark .glass-card[data-v-eab6d04d]{background:#0000004d;border:1px solid rgba(255,255,255,.1);box-shadow:none}.chart-updating[data-v-eab6d04d]{animation:subtle-pulse-eab6d04d .8s ease-in-out}@keyframes subtle-pulse-eab6d04d{0%{transform:scale(1)}50%{transform:scale(1.02)}to{transform:scale(1)}}.chart-container[data-v-eab6d04d]{position:relative;transition:all .3s ease}.chart-container[data-v-eab6d04d]:hover{background:#0000000a}.dark .chart-container[data-v-eab6d04d]:hover{background:#ffffff14}.process-row[data-v-eab6d04d]{transition:all .3s ease}.process-row[data-v-eab6d04d]:hover{background:#00000005;transform:translate(2px)}.dark .process-row[data-v-eab6d04d]:hover{background:#ffffff0d}.process-row-enter-active[data-v-eab6d04d],.process-row-leave-active[data-v-eab6d04d]{transition:all .4s ease}.process-row-enter-from[data-v-eab6d04d]{opacity:0;transform:translateY(-10px) scale(.95)}.process-row-leave-to[data-v-eab6d04d]{opacity:0;transform:translateY(10px) scale(.95)}.process-row-move[data-v-eab6d04d]{transition:transform .4s ease}.cpu-value[data-v-eab6d04d],.memory-value[data-v-eab6d04d]{transition:all .3s ease;padding:2px 6px;border-radius:4px}.cpu-value[data-v-eab6d04d]:hover,.memory-value[data-v-eab6d04d]:hover{background:#f59e0b1a;transform:scale(1.05)}@keyframes value-update-eab6d04d{0%{background:#f59e0b4d}to{background:transparent}}.value-updated[data-v-eab6d04d]{animation:value-update-eab6d04d .6s ease-out}
|
||||
@@ -0,0 +1 @@
|
||||
.glass-card[data-v-fda01968]{-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px);background:#ffffffbf;border:1px solid #0000000f;box-shadow:0 2px 8px #0000000a}.dark .glass-card[data-v-fda01968]{box-shadow:none;background:#0000004d;border:1px solid #ffffff1a}.chart-updating[data-v-fda01968]{animation:.8s ease-in-out subtle-pulse-fda01968}@keyframes subtle-pulse-fda01968{0%{transform:scale(1)}50%{transform:scale(1.02)}to{transform:scale(1)}}.chart-container[data-v-fda01968]{transition:all .3s;position:relative}.chart-container[data-v-fda01968]:hover{background:#0000000a}.dark .chart-container[data-v-fda01968]:hover{background:#ffffff14}.process-row[data-v-fda01968]{transition:all .3s}.process-row[data-v-fda01968]:hover{background:#00000005;transform:translate(2px)}.dark .process-row[data-v-fda01968]:hover{background:#ffffff0d}.process-row-enter-active[data-v-fda01968],.process-row-leave-active[data-v-fda01968]{transition:all .4s}.process-row-enter-from[data-v-fda01968]{opacity:0;transform:translateY(-10px)scale(.95)}.process-row-leave-to[data-v-fda01968]{opacity:0;transform:translateY(10px)scale(.95)}.process-row-move[data-v-fda01968]{transition:transform .4s}.cpu-value[data-v-fda01968],.memory-value[data-v-fda01968]{border-radius:4px;padding:2px 6px;transition:all .3s}.cpu-value[data-v-fda01968]:hover,.memory-value[data-v-fda01968]:hover{background:#f59e0b1a;transform:scale(1.05)}@keyframes value-update-fda01968{0%{background:#f59e0b4d}to{background:0 0}}.value-updated[data-v-fda01968]{animation:.6s ease-out value-update-fda01968}
|
||||
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 +0,0 @@
|
||||
function e(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}export{e as g};
|
||||
@@ -0,0 +1 @@
|
||||
var e=(e,t)=>{let n=e.__vccOpts||e;for(let[e,r]of t)n[e]=r;return n};export{e as t};
|
||||
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
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user