Initial commit: PyMC Repeater Daemon

This commit sets up the initial project structure for the PyMC Repeater Daemon.
It includes base configuration files, dependency definitions, and scaffolding
for the main daemon service responsible for handling PyMC repeating operations.
This commit is contained in:
Lloyd
2025-10-24 23:13:48 +01:00
parent ad04a14359
commit 97256eb132
27 changed files with 7697 additions and 0 deletions

1
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1 @@
buy_me_a_coffee: rightup

50
.gitignore vendored Normal file
View File

@@ -0,0 +1,50 @@
# .gitignore
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
pip-wheel-metadata/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
# Virtual environments
.venv/
env/
ENV/
# Testing
.pytest_cache/
.coverage
htmlcov/
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# Config
config.yaml
identity.json
# Logs
*.log
.DS_Store

40
.pre-commit-config.yaml Normal file
View File

@@ -0,0 +1,40 @@
# Pre-commit hooks for mc_repeater
# Install: pip install pre-commit
# Setup: pre-commit install
# Run manually: pre-commit run --all-files
repos:
# Generic file hygiene checks
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.4.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: check-added-large-files
# Python formatting (Black) - apply to all Python files
- repo: https://github.com/psf/black
rev: 24.4.2
hooks:
- id: black
language_version: python3
args: ["--line-length=100"]
# Python import sorting (isort) - apply to all Python files
- repo: https://github.com/pycqa/isort
rev: 5.13.2
hooks:
- id: isort
args: ["--profile", "black", "--line-length=100"]
# Python linting (flake8) - strict settings for code quality
- repo: https://github.com/pycqa/flake8
rev: 6.0.0
hooks:
- id: flake8
# Strict but reasonable settings
args: [
"--max-line-length=100",
"--extend-ignore=E203,W503"
]

164
README.md Normal file
View File

@@ -0,0 +1,164 @@
# pyMC_repeater
Repeater Daemon in Python using the `pymc_core` Lib.
---
I started **pyMC_core** as a way to really get under the skin of **MeshCore** — to see how it ticked and why it behaved the way it did.
After a few late nights of tinkering, testing, and head-scratching, I shared what Id learned with the community.
The response was honestly overwhelming — loads of encouragement, great feedback, and a few people asking if I could spin it into a lightweight **repeater daemon** that would run happily on low-power, Pi-class hardware.
That challenge shaped much of what followed:
- I went with a lightweight HTTP server (**CherryPy**) instead of a full-fat framework.
- I stuck with simple polling over WebSockets — its more reliable, has fewer dependencies, and is far less resource hungry.
- I kept the architecture focused on being **clear, modular, and hackable** rather than chasing performance numbers.
Theres still plenty of room for this project to grow and improve — but youve got to start somewhere!
My hope is that **pyMC_repeater** serves as a solid, approachable foundation that others can learn from, build on, and maybe even have a bit of fun with along the way.
> **Id love to see these repeaters out in the wild — actually running in real networks and production setups.**
> My own testing so far has been in a very synthetic environment with little to no other users in my area,
> so feedback from real-world deployments would be incredibly valuable!
---
## Overview
The repeater daemon runs continuously as a background process, forwarding LoRa packets using `pymc_core`'s Dispatcher and packet routing.
...
## Screenshots
### Dashboard
![Dashboard](docs/dashboard.png)
*Real-time monitoring dashboard showing packet statistics, neighbor discovery, and system status*
### Statistics
![Statistics](docs/stats.png)
*statistics and performance metrics*
## Installation
**Clone the Repository:**
```bash
git clone https://github.com/rightup/pyMC_Repeater.git
cd pyMC_Repeater
```
**Quick Install:**
```bash
sudo bash deploy.sh
```
This script will:
- Create a dedicated `repeater` service user with hardware access
- Install files to `/opt/pymc_repeater`
- Create configuration directory at `/etc/pymc_repeater`
- Setup log directory at `/var/log/pymc_repeater`
- **Launch interactive radio & hardware configuration wizard**
- Install and enable systemd service
**After Installation:**
```bash
# View live logs
sudo journalctl -u pymc-repeater -f
# Access web dashboard
http://<repeater-ip>:8000
```
**Development Install:**
```bash
pip install -e .
```
## Configuration
The configuration file is created and configured during installation at:
```
/etc/pymc_repeater/config.yaml
```
To reconfigure radio and hardware settings after installation, run:
```bash
sudo bash setup-radio-config.sh /etc/pymc_repeater
sudo systemctl restart pymc-repeater
```
## Uninstallation
```bash
sudo bash uninstall.sh
```
This script will:
- Stop and disable the systemd service
- Remove the installation directory
- Optionally remove configuration, logs, and user data
- Optionally remove the service user account
The script will prompt you for each optional removal step.
## Roadmap / Planned Features
- [ ] **Public Map Integration** - Submit repeater location and details to public map for discovery
- [ ] **Remote Administration over LoRa** - Manage repeater configuration remotely via LoRa mesh
- [ ] **Trace Request Handling** - Respond to trace/diagnostic requests from mesh network
## Contributing
I welcome contributions! To contribute to pyMC_repeater:
1. **Fork the repository** and clone your fork
2. **Create a feature branch** from the `dev` branch:
```bash
git checkout -b feature/your-feature-name dev
```
3. **Make your changes** and test with **real** hardware
4. **Commit with clear messages**:
```bash
git commit -m "feat: description of changes"
```
5. **Push to your fork** and submit a **Pull Request to the `dev` branch**
- Include a clear description of the changes
- Reference any related issues
### Development Setup
```bash
# Install in development mode with dev tools (black, pytest, isort, mypy, etc)
pip install -e ".[dev]"
# Setup pre-commit hooks for code quality
pip install pre-commit
pre-commit install
# Manually run pre-commit checks on all files
pre-commit run --all-files
```
**Note:** Hardware support (LoRa radio drivers) is included in the base installation automatically via `pymc_core[hardware]`.
Pre-commit hooks will automatically:
- Format code with Black
- Sort imports with isort
- Lint with flake8
- Fix trailing whitespace and other file issues
## Support
- [Core Lib Documentation](https://rightup.github.io/pyMC_core/)
- [Meshcore Discord](https://discord.gg/fThwBrRc3Q)
## License
This project is licensed under the MIT License - see the LICENSE file for details.

102
config.yaml.example Normal file
View File

@@ -0,0 +1,102 @@
# Default Repeater Configuration
repeater:
# Node name for logging and identification
node_name: "mesh-repeater-01"
# Geographic location (optional)
# Latitude in decimal degrees (-90 to 90)
latitude: 0.0
# Longitude in decimal degrees (-180 to 180)
longitude: 0.0
# Path to identity file (public/private key)
# If not specified, a new identity will be generated
identity_file: null
# Duplicate packet cache TTL in seconds
cache_ttl: 60
# Score-based transmission filtering
# Enable quality-based packet filtering and adaptive delays
use_score_for_tx: false
# Score threshold for quality monitoring (future use)
# Currently reserved for potential future features like dashboard alerts,
# proactive statistics collection, or advanced filtering strategies.
# Changing this value has no effect on current packet processing.
score_threshold: 0.3
# Automatic advertisement interval in hours
# The repeater will send an advertisement packet at this interval
# Set to 0 to disable automatic adverts (manual only via web interface)
# Range: 0 (disabled) to 24+ hours
# Recommended: 10 hours for typical deployments
send_advert_interval_hours: 10
radio:
# Frequency in Hz (869.618 MHz for EU)
frequency: 869618000
# TX power in dBm
tx_power: 14
# Bandwidth in Hz (62500 = 62.5 kHz)
bandwidth: 62500
# LoRa spreading factor (7-12)
spreading_factor: 8
# Coding rate (5-8)
coding_rate: 8
# Preamble length in symbols
preamble_length: 17
# Sync word (LoRa network ID)
sync_word: 13380
# Enable CRC checking
crc_enabled: true
# Use implicit header mode
implicit_header: false
# SX1262 Hardware Configuration
sx1262:
# SPI bus and chip select
bus_id: 0
cs_id: 0
# GPIO pins (BCM numbering)
cs_pin: 21
reset_pin: 18
busy_pin: 20
irq_pin: 16
# TX/RX enable pins (-1 to disable)
txen_pin: -1
rxen_pin: -1
# Waveshare hardware flag
is_waveshare: false
delays:
# TX delay factor for flood mode (multiplier)
tx_delay_factor: 1.0
# TX delay factor for direct mode (faster)
direct_tx_delay_factor: 0.5
duty_cycle:
# Maximum airtime per minute in milliseconds
# US/AU FCC limit: 100% duty cycle (3600ms/min)
# EU ETSI limit: 1% duty cycle (36ms/min)
max_airtime_per_minute: 3600
logging:
# Log level: DEBUG, INFO, WARNING, ERROR
level: INFO
# Log format
format: "%(asctime)s - %(name)s - %(levelname)s - %(message)s"

110
deploy.sh Normal file
View File

@@ -0,0 +1,110 @@
#!/bin/bash
# Deployment script for pyMC Repeater
set -e
INSTALL_DIR="/opt/pymc_repeater"
CONFIG_DIR="/etc/pymc_repeater"
LOG_DIR="/var/log/pymc_repeater"
SERVICE_USER="repeater"
echo "=== pyMC Repeater Deployment ==="
# Check if running as root
if [ "$EUID" -ne 0 ]; then
echo "Error: This script must be run as root"
exit 1
fi
# Create service user
if ! id "$SERVICE_USER" &>/dev/null; then
echo "Creating service user: $SERVICE_USER"
useradd --system --home /var/lib/pymc_repeater --shell /sbin/nologin "$SERVICE_USER"
fi
# Add service user to required groups for hardware access
echo "Adding $SERVICE_USER to hardware groups..."
usermod -a -G gpio,i2c,spi "$SERVICE_USER" 2>/dev/null || true
usermod -a -G dialout "$SERVICE_USER" 2>/dev/null || true
# Create directories
echo "Creating directories..."
mkdir -p "$INSTALL_DIR"
mkdir -p "$CONFIG_DIR"
mkdir -p "$LOG_DIR"
mkdir -p /var/lib/pymc_repeater
# Copy files
echo "Installing files..."
cp -r repeater "$INSTALL_DIR/"
cp pyproject.toml "$INSTALL_DIR/"
cp README.md "$INSTALL_DIR/"
cp setup-radio-config.sh "$INSTALL_DIR/"
cp radio-settings.json "$INSTALL_DIR/"
# Copy config files
echo "Installing configuration..."
cp config.yaml.example "$CONFIG_DIR/config.yaml.example"
# Create actual config if it doesn't exist (optional, will use defaults if missing)
if [ ! -f "$CONFIG_DIR/config.yaml" ]; then
echo "Creating config file from example..."
cp config.yaml.example "$CONFIG_DIR/config.yaml"
echo "NOTE: Default config created. Customize $CONFIG_DIR/config.yaml as needed."
else
echo "Existing config file found, keeping it."
fi
# Setup radio configuration from API
echo ""
echo "=== Radio Configuration Setup ==="
read -p "Would you like to configure radio settings from community presets? (y/n) " -n 1 -r
echo
if [[ $REPLY =~ ^[Yy]$ ]]; then
# Install jq if not already installed
if ! command -v jq &> /dev/null; then
echo "Installing jq..."
apt-get update -qq
apt-get install -y jq
fi
bash setup-radio-config.sh "$CONFIG_DIR"
else
echo "Skipping radio configuration setup."
fi
# Install systemd service
echo "Installing systemd service..."
cp pymc-repeater.service /etc/systemd/system/
systemctl daemon-reload
# Set permissions
echo "Setting permissions..."
chown -R "$SERVICE_USER:$SERVICE_USER" "$INSTALL_DIR" "$CONFIG_DIR" "$LOG_DIR" /var/lib/pymc_repeater
chmod 750 "$CONFIG_DIR" "$LOG_DIR"
chmod 750 /var/lib/pymc_repeater
# Install Python package
echo "Installing Python package..."
cd "$INSTALL_DIR"
# Use --break-system-packages for system-wide installation
# This is safe here since we're installing in a dedicated directory
pip install --break-system-packages -e .
echo ""
echo "=== Installation Complete ==="
echo ""
echo "Enabling and starting service..."
systemctl enable pymc-repeater
systemctl start pymc-repeater
echo ""
echo "Service status:"
systemctl is-active pymc-repeater && echo "✓ Service is running" || echo "✗ Service failed to start"
echo ""
echo "Next steps:"
echo "1. Check live logs:"
echo " journalctl -u pymc-repeater -f"
echo ""
echo "2. Access web dashboard:"
echo " http://$(hostname -I | awk '{print $1}'):8000"
echo "----------------------------------"

BIN
docs/dashboard.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

BIN
docs/stats.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

39
pymc-repeater.service Normal file
View File

@@ -0,0 +1,39 @@
"""
Systemd service file template for Py MC - Meshcore Repeater Daemon.
Install as /etc/systemd/system/pymc-repeater.service
"""
[Unit]
Description=pyMC Repeater Daemon
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=repeater
Group=repeater
WorkingDirectory=/opt/pymc_repeater
Environment="PYTHONPATH=/opt/pymc_repeater"
# Start command - use python module directly with proper path
ExecStart=/usr/bin/python3 -m repeater.main --config /etc/pymc_repeater/config.yaml
# Restart on failure
Restart=on-failure
RestartSec=5
# Resource limits
CPUQuota=50%
MemoryLimit=256M
# Logging
StandardOutput=journal
StandardError=journal
SyslogIdentifier=pymc-repeater
# Security (relaxed for proper operation)
NoNewPrivileges=true
ReadWritePaths=/var/log/pymc_repeater /var/lib/pymc_repeater /etc/pymc_repeater
[Install]
WantedBy=multi-user.target

57
pyproject.toml Normal file
View File

@@ -0,0 +1,57 @@
[build-system]
requires = ["setuptools>=61.0", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "pymc_repeater"
version = "1.0.0"
authors = [
{name = "Lloyd", email = "lloyd@rightup.co.uk"},
]
description = "PyMC Repeater Daemon"
readme = "README.md"
license = {text = "MIT"}
requires-python = ">=3.8"
classifiers = [
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Operating System :: POSIX :: Linux",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Topic :: Communications",
"Topic :: System :: Networking",
]
keywords = ["mesh", "networking", "lora", "repeater", "daemon", "iot"]
dependencies = [
"pymc_core[hardware]>=1.0.1",
"pyyaml>=6.0.0",
"cherrypy>=18.0.0",
]
[project.optional-dependencies]
dev = [
"pytest>=7.4.0",
"pytest-asyncio>=0.21.0",
"black>=23.0.0",
"isort>=5.12.0",
"mypy>=1.7.0",
]
[project.scripts]
pymc-repeater = "repeater.main:main"
[tool.setuptools]
packages = ["repeater"]
[tool.black]
line-length = 100
target-version = ['py38', 'py39', 'py310', 'py311', 'py312']
[tool.isort]
profile = "black"
line_length = 100

44
radio-settings.json Normal file
View File

@@ -0,0 +1,44 @@
{
"hardware": {
"waveshare": {
"name": "Waveshare LoRa HAT",
"bus_id": 0,
"cs_id": 0,
"cs_pin": 21,
"reset_pin": 18,
"busy_pin": 20,
"irq_pin": 16,
"txen_pin": 13,
"rxen_pin": 12,
"tx_power": 22,
"preamble_length": 17,
"is_waveshare": true
},
"uconsole": {
"name": "uConsole LoRa Module",
"bus_id": 1,
"cs_id": 0,
"cs_pin": -1,
"reset_pin": 25,
"busy_pin": 24,
"irq_pin": 26,
"txen_pin": -1,
"rxen_pin": -1,
"tx_power": 22,
"preamble_length": 17
},
"meshadv-mini": {
"name": "MeshAdv Mini",
"bus_id": 0,
"cs_id": 0,
"cs_pin": 8,
"reset_pin": 24,
"busy_pin": 20,
"irq_pin": 16,
"txen_pin": -1,
"rxen_pin": 12,
"tx_power": 22,
"preamble_length": 17
}
}
}

1
repeater/__init__.py Normal file
View File

@@ -0,0 +1 @@
__version__ = "1.0.0"

78
repeater/airtime.py Normal file
View File

@@ -0,0 +1,78 @@
import logging
import time
from typing import Tuple
logger = logging.getLogger("AirtimeManager")
class AirtimeManager:
def __init__(self, config: dict):
self.config = config
self.max_airtime_per_minute = config.get("duty_cycle", {}).get(
"max_airtime_per_minute", 3600
)
# Track airtime in rolling window
self.tx_history = [] # [(timestamp, airtime_ms), ...]
self.window_size = 60 # seconds
self.total_airtime_ms = 0
def calculate_airtime(
self,
payload_len: int,
spreading_factor: int = 7,
bandwidth_hz: int = 125000,
) -> float:
bw_khz = bandwidth_hz / 1000
symbol_time = (2**spreading_factor) / bw_khz
preamble_time = 8 * symbol_time
payload_symbols = (payload_len + 4.25) * 8
payload_time = payload_symbols * symbol_time
total_ms = preamble_time + payload_time
return total_ms
def can_transmit(self, airtime_ms: float) -> Tuple[bool, float]:
enforcement_enabled = self.config.get("duty_cycle", {}).get("enforcement_enabled", True)
if not enforcement_enabled:
# Duty cycle enforcement disabled - always allow
return True, 0.0
now = time.time()
# Remove old entries outside window
self.tx_history = [(ts, at) for ts, at in self.tx_history if now - ts < self.window_size]
# Calculate current airtime in window
current_airtime = sum(at for _, at in self.tx_history)
if current_airtime + airtime_ms <= self.max_airtime_per_minute:
return True, 0.0
# Calculate wait time until oldest entry expires
if self.tx_history:
oldest_ts, oldest_at = self.tx_history[0]
wait_time = (oldest_ts + self.window_size) - now
return False, max(0, wait_time)
return False, 1.0
def record_tx(self, airtime_ms: float):
self.tx_history.append((time.time(), airtime_ms))
self.total_airtime_ms += airtime_ms
logger.debug(f"TX recorded: {airtime_ms: .1f}ms (total: {self.total_airtime_ms: .0f}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]
current_airtime = sum(at for _, at in self.tx_history)
utilization = (current_airtime / self.max_airtime_per_minute) * 100
return {
"current_airtime_ms": current_airtime,
"max_airtime_ms": self.max_airtime_per_minute,
"utilization_percent": utilization,
"total_airtime_ms": self.total_airtime_ms,
}

137
repeater/config.py Normal file
View File

@@ -0,0 +1,137 @@
import base64
import logging
import os
from pathlib import Path
from typing import Any, Dict, Optional
import yaml
logger = logging.getLogger("Config")
def load_config(config_path: Optional[str] = None) -> Dict[str, Any]:
if config_path is None:
config_path = os.getenv("PYMC_REPEATER_CONFIG", "/etc/pymc_repeater/config.yaml")
# Check if config file exists
if not Path(config_path).exists():
raise FileNotFoundError(
f"Configuration file not found: {config_path}\n"
f"Please create a config file. Example: \n"
f" sudo cp {Path(config_path).parent}/config.yaml.example {config_path}\n"
f" sudo nano {config_path}"
)
# Load from file - no defaults, all settings must be in config file
try:
with open(config_path) as f:
config = yaml.safe_load(f) or {}
logger.info(f"Loaded config from {config_path}")
except Exception as e:
raise RuntimeError(f"Failed to load configuration from {config_path}: {e}") from e
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 os.getenv("PYMC_REPEATER_LOG_LEVEL"):
if "logging" not in config:
config["logging"] = {}
config["logging"]["level"] = os.getenv("PYMC_REPEATER_LOG_LEVEL")
return config
def _load_or_create_identity_key(path: Optional[str] = None) -> bytes:
if path is None:
# Follow XDG spec
xdg_config_home = os.environ.get("XDG_CONFIG_HOME")
if xdg_config_home:
config_dir = Path(xdg_config_home) / "pymc_repeater"
else:
config_dir = Path.home() / ".config" / "pymc_repeater"
key_path = config_dir / "identity.key"
else:
key_path = Path(path)
key_path.parent.mkdir(parents=True, exist_ok=True)
if key_path.exists():
try:
with open(key_path, "rb") as f:
encoded = f.read()
key = base64.b64decode(encoded)
if len(key) != 32:
raise ValueError(f"Invalid key length: {len(key)}, expected 32")
logger.info(f"Loaded existing identity key from {key_path}")
return key
except Exception as e:
logger.warning(f"Failed to load identity key: {e}")
# Generate new random key
key = os.urandom(32)
# Save it
try:
with open(key_path, "wb") as f:
f.write(base64.b64encode(key))
os.chmod(key_path, 0o600) # Restrict permissions
logger.info(f"Generated and stored new identity key at {key_path}")
except Exception as e:
logger.warning(f"Failed to save identity key: {e}")
return key
def get_radio_for_board(board_config: dict):
radio_type = board_config.get("radio_type", "sx1262").lower()
if radio_type == "sx1262":
from pymc_core.hardware.sx1262_wrapper import SX1262Radio
# Get radio and SPI configuration - all settings must be in config file
spi_config = board_config.get("sx1262")
if not spi_config:
raise ValueError("Missing 'sx1262' section in configuration file")
radio_config = board_config.get("radio")
if not radio_config:
raise ValueError("Missing 'radio' section in configuration file")
# Build config with required fields - no defaults
combined_config = {
"bus_id": spi_config["bus_id"],
"cs_id": spi_config["cs_id"],
"cs_pin": spi_config["cs_pin"],
"reset_pin": spi_config["reset_pin"],
"busy_pin": spi_config["busy_pin"],
"irq_pin": spi_config["irq_pin"],
"txen_pin": spi_config["txen_pin"],
"rxen_pin": spi_config["rxen_pin"],
"is_waveshare": spi_config.get("is_waveshare", False),
"frequency": int(radio_config["frequency"]),
"tx_power": radio_config["tx_power"],
"spreading_factor": radio_config["spreading_factor"],
"bandwidth": int(radio_config["bandwidth"]),
"coding_rate": radio_config["coding_rate"],
"preamble_length": radio_config["preamble_length"],
"sync_word": radio_config["sync_word"],
}
radio = SX1262Radio.get_instance(**combined_config)
if hasattr(radio, "_initialized") and not radio._initialized:
try:
radio.begin()
except RuntimeError as e:
raise RuntimeError(f"Failed to initialize SX1262 radio: {e}") from e
return radio
else:
raise RuntimeError(f"Unknown radio type: {radio_type}. Supported: sx1262")

667
repeater/engine.py Normal file
View File

@@ -0,0 +1,667 @@
import asyncio
import logging
import time
from collections import OrderedDict
from typing import Any, Dict, Optional, Tuple
from pymc_core.node.handlers.base import BaseHandler
from pymc_core.protocol import Packet
from pymc_core.protocol.constants import (
MAX_PATH_SIZE,
PAYLOAD_TYPE_ADVERT,
PH_ROUTE_MASK,
ROUTE_TYPE_DIRECT,
ROUTE_TYPE_FLOOD,
)
from pymc_core.protocol.packet_utils import PacketHeaderUtils
from repeater.airtime import AirtimeManager
logger = logging.getLogger("RepeaterHandler")
class PacketTimingUtils:
@staticmethod
def estimate_airtime_ms(
packet_length_bytes: int, radio_config: Optional[Dict[str, Any]] = None
) -> float:
if radio_config is None:
radio_config = {
"spreading_factor": 10,
"bandwidth": 250000,
"coding_rate": 5,
"preamble_length": 8,
}
sf = radio_config.get("spreading_factor", 10)
bw = radio_config.get("bandwidth", 250000) # Hz
cr = radio_config.get("coding_rate", 5)
preamble = radio_config.get("preamble_length", 8)
# LoRa symbol duration: Ts = 2^SF / BW
symbol_duration_ms = (2**sf) / (bw / 1000)
# Number of payload symbols
payload_symbols = max(
8, int((8 * packet_length_bytes - 4 * sf + 28 + 16) / (4 * (sf - 2))) * (cr + 4)
)
# Total time = preamble + payload
preamble_ms = (preamble + 4.25) * symbol_duration_ms
payload_ms = payload_symbols * symbol_duration_ms
return preamble_ms + payload_ms
class RepeaterHandler(BaseHandler):
@staticmethod
def payload_type() -> int:
return 0xFF # Special marker (not a real payload type)
def __init__(self, config: dict, dispatcher, local_hash: int, send_advert_func=None):
self.config = config
self.dispatcher = dispatcher
self.local_hash = local_hash
self.send_advert_func = send_advert_func
self.airtime_mgr = AirtimeManager(config)
self.seen_packets = OrderedDict()
self.cache_ttl = config.get("repeater", {}).get("cache_ttl", 60)
self.max_cache_size = 1000
self.tx_delay_factor = config.get("delays", {}).get("tx_delay_factor", 1.0)
self.direct_tx_delay_factor = config.get("delays", {}).get("direct_tx_delay_factor", 0.5)
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.send_advert_interval_hours = config.get("repeater", {}).get(
"send_advert_interval_hours", 10
)
self.last_advert_time = time.time()
radio = dispatcher.radio if dispatcher else None
if radio:
self.radio_config = {
"spreading_factor": getattr(radio, "spreading_factor", 8),
"bandwidth": getattr(radio, "bandwidth", 125000),
"coding_rate": getattr(radio, "coding_rate", 8),
"preamble_length": getattr(radio, "preamble_length", 17),
"frequency": getattr(radio, "frequency", 915000000),
"tx_power": getattr(radio, "tx_power", 14),
}
logger.info(
f"radio settings: SF={self.radio_config['spreading_factor']}, "
f"BW={self.radio_config['bandwidth']}Hz, CR={self.radio_config['coding_rate']}"
)
else:
raise RuntimeError("Radio object not available - cannot initialize repeater")
# Statistics tracking for dashboard
self.rx_count = 0
self.forwarded_count = 0
self.dropped_count = 0
self.recent_packets = []
self.max_recent_packets = 50
self.start_time = time.time() # For uptime calculation
# Neighbor tracking (repeaters discovered via adverts)
self.neighbors = {}
async def __call__(self, packet: Packet, metadata: Optional[dict] = None) -> None:
if metadata is None:
metadata = {}
# Track incoming packet
self.rx_count += 1
# Check if it's time to send a periodic advertisement
await self._check_and_send_periodic_advert()
# Check if we're in monitor mode (receive only, no forwarding)
mode = self.config.get("repeater", {}).get("mode", "forward")
monitor_mode = mode == "monitor"
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}"
)
snr = metadata.get("snr", 0.0)
rssi = metadata.get("rssi", 0)
transmitted = False
tx_delay_ms = 0.0
drop_reason = None
original_path = list(packet.path) if packet.path else []
# Process for forwarding (skip if in monitor mode)
result = None if monitor_mode else self.process_packet(packet, snr)
forwarded_path = None
if result:
fwd_pkt, delay = result
tx_delay_ms = delay * 1000.0
# Capture the forwarded path (after modification)
forwarded_path = list(fwd_pkt.path) if fwd_pkt.path else []
# Check duty-cycle before scheduling TX
packet_bytes = (
fwd_pkt.write_to() if hasattr(fwd_pkt, "write_to") else fwd_pkt.payload or b""
)
airtime_ms = PacketTimingUtils.estimate_airtime_ms(len(packet_bytes), self.radio_config)
can_tx, wait_time = self.airtime_mgr.can_transmit(airtime_ms)
if not can_tx:
logger.warning(
f"Duty-cycle limit exceeded. Airtime={airtime_ms: .1f}ms, "
f"wait={wait_time: .1f}s before retry"
)
self.dropped_count += 1
drop_reason = "Duty cycle limit"
else:
self.forwarded_count += 1
transmitted = True
# Schedule retransmit with delay
await self.schedule_retransmit(fwd_pkt, delay, airtime_ms)
else:
self.dropped_count += 1
# Determine drop reason from process_packet result
if monitor_mode:
drop_reason = "Monitor mode"
else:
drop_reason = self._get_drop_reason(packet)
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:
logger.error(f"Packet missing header attribute! Packet: {packet}")
payload_type = 0
route_type = 0
else:
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}"
)
# Check if this is a duplicate
pkt_hash = self._packet_hash(packet)
is_dupe = pkt_hash in self.seen_packets and not transmitted
# Set drop reason for duplicates
if is_dupe and drop_reason is None:
drop_reason = "Duplicate"
# Process adverts for neighbor tracking
if payload_type == PAYLOAD_TYPE_ADVERT:
self._process_advert(packet, rssi, snr)
path_hash = None
display_path = (
original_path if original_path else (list(packet.path) if packet.path else [])
)
if display_path and len(display_path) > 0:
# Format path as array of uppercase hex bytes
path_bytes = [f"{b: 02X}" for b in display_path[:8]] # First 8 bytes max
if len(display_path) > 8:
path_bytes.append("...")
path_hash = "[" + ", ".join(path_bytes) + "]"
src_hash = None
dst_hash = None
# Payload types with dest_hash and src_hash as first 2 bytes
if payload_type in [0x00, 0x01, 0x02, 0x08]:
if hasattr(packet, "payload") and packet.payload and len(packet.payload) >= 2:
dst_hash = f"{packet.payload[0]: 02X}"
src_hash = f"{packet.payload[1]: 02X}"
# ADVERT packets have source identifier as first byte
elif payload_type == PAYLOAD_TYPE_ADVERT:
if hasattr(packet, "payload") and packet.payload and len(packet.payload) >= 1:
src_hash = f"{packet.payload[0]: 02X}"
# Record packet for charts
packet_record = {
"timestamp": time.time(),
"type": payload_type,
"route": route_type,
"length": len(packet.payload or b""),
"rssi": rssi,
"snr": snr,
"score": self.calculate_packet_score(
snr, len(packet.payload or b""), self.radio_config["spreading_factor"]
),
"tx_delay_ms": tx_delay_ms,
"transmitted": transmitted,
"is_duplicate": is_dupe,
"packet_hash": pkt_hash[:16],
"drop_reason": drop_reason,
"path_hash": path_hash,
"src_hash": src_hash,
"dst_hash": dst_hash,
"original_path": ([f"{b: 02X}" for b in original_path] if original_path else None),
"forwarded_path": (
[f"{b: 02X}" for b in forwarded_path] if forwarded_path is not None else None
),
}
# 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["duplicates"].append(packet_record)
# Don't add duplicate to main list, just track in original
break
else:
# Original not found, add as regular packet
self.recent_packets.append(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)
def cleanup_cache(self):
now = time.time()
expired = [k for k, ts in self.seen_packets.items() if now - ts > self.cache_ttl]
for k in expired:
del self.seen_packets[k]
def _packet_hash(self, packet: Packet) -> str:
if len(packet.payload or b"") >= 8:
return packet.payload[:8].hex()
return (packet.payload or b"").hex()
def _get_drop_reason(self, packet: Packet) -> str:
if self.is_duplicate(packet):
return "Duplicate"
if not packet or not packet.payload:
return "Empty payload"
if len(packet.path or []) >= MAX_PATH_SIZE:
return "Path too long"
route_type = packet.header & PH_ROUTE_MASK
if route_type == ROUTE_TYPE_DIRECT:
if not packet.path or len(packet.path) == 0:
return "Direct: no path"
next_hop = packet.path[0]
if next_hop != self.local_hash:
return "Direct: not for us"
# Default reason
return "Unknown"
def _process_advert(self, packet: Packet, rssi: int, snr: float):
try:
from pymc_core.protocol.constants import ADVERT_FLAG_IS_REPEATER
from pymc_core.protocol.utils import (
decode_appdata,
get_contact_type_name,
parse_advert_payload,
)
# Parse advert payload
if not packet.payload or len(packet.payload) < 40:
return
advert_data = parse_advert_payload(packet.payload)
pubkey = advert_data.get("pubkey", "")
# Skip our own adverts
if self.dispatcher and hasattr(self.dispatcher, "local_identity"):
local_pubkey = self.dispatcher.local_identity.get_public_key().hex()
if pubkey == local_pubkey:
logger.debug("Ignoring own advert in neighbor tracking")
return
appdata = advert_data.get("appdata", b"")
if not appdata:
return
appdata_decoded = decode_appdata(appdata)
flags = appdata_decoded.get("flags", 0)
is_repeater = bool(flags & ADVERT_FLAG_IS_REPEATER)
if not is_repeater:
return # Not a repeater, skip
from pymc_core.protocol.utils import determine_contact_type_from_flags
contact_type_id = determine_contact_type_from_flags(flags)
contact_type = get_contact_type_name(contact_type_id)
# Extract neighbor info
node_name = appdata_decoded.get("node_name", "Unknown")
latitude = appdata_decoded.get("latitude")
longitude = appdata_decoded.get("longitude")
current_time = time.time()
# Update or create neighbor entry
if pubkey not in self.neighbors:
self.neighbors[pubkey] = {
"node_name": node_name,
"contact_type": contact_type,
"latitude": latitude,
"longitude": longitude,
"first_seen": current_time,
"last_seen": current_time,
"rssi": rssi,
"snr": snr,
"advert_count": 1,
}
logger.info(f"Discovered new repeater: {node_name} ({pubkey[:16]}...)")
else:
# Update existing neighbor
neighbor = self.neighbors[pubkey]
neighbor["node_name"] = node_name # Update name in case it changed
neighbor["contact_type"] = contact_type
neighbor["latitude"] = latitude
neighbor["longitude"] = longitude
neighbor["last_seen"] = current_time
neighbor["rssi"] = rssi
neighbor["snr"] = snr
neighbor["advert_count"] = neighbor.get("advert_count", 0) + 1
except Exception as e:
logger.debug(f"Error processing advert for neighbor tracking: {e}")
def is_duplicate(self, packet: Packet) -> bool:
pkt_hash = self._packet_hash(packet)
if pkt_hash in self.seen_packets:
logger.debug(f"Duplicate suppressed: {pkt_hash[:16]}")
return True
return False
def mark_seen(self, packet: Packet):
pkt_hash = self._packet_hash(packet)
self.seen_packets[pkt_hash] = time.time()
if len(self.seen_packets) > self.max_cache_size:
self.seen_packets.popitem(last=False)
def validate_packet(self, packet: Packet) -> Tuple[bool, str]:
if not packet or not packet.payload:
return False, "Empty payload"
if len(packet.path or []) >= MAX_PATH_SIZE:
return False, "Path at max size"
return True, ""
def flood_forward(self, packet: Packet) -> Optional[Packet]:
# Validate
valid, reason = self.validate_packet(packet)
if not valid:
logger.debug(f"Flood validation failed: {reason}")
return None
# Suppress duplicates
if self.is_duplicate(packet):
return None
if packet.path is None:
packet.path = bytearray()
elif not isinstance(packet.path, bytearray):
packet.path = bytearray(packet.path)
packet.path.append(self.local_hash)
packet.path_len = len(packet.path)
self.mark_seen(packet)
logger.debug(f"Flood: forwarding with path len {packet.path_len}")
return packet
def direct_forward(self, packet: Packet) -> Optional[Packet]:
# Check if we're the next hop
if not packet.path or len(packet.path) == 0:
logger.debug("Direct: no path")
return None
next_hop = packet.path[0]
if next_hop != self.local_hash:
logger.debug(
f"Direct: not our hop (next={next_hop: 02X}, local={self.local_hash: 02X})"
)
return None
original_path = list(packet.path)
packet.path = bytearray(packet.path[1:])
packet.path_len = len(packet.path)
old_path = [f"{b: 02X}" for b in original_path]
new_path = [f"{b: 02X}" for b in packet.path]
logger.debug(f"Direct: forwarding, path {old_path} -> {new_path}")
return packet
@staticmethod
def calculate_packet_score(snr: float, packet_len: int, spreading_factor: int = 8) -> float:
# SNR thresholds per SF (from MeshCore RadioLibWrappers.cpp)
snr_thresholds = {7: -7.5, 8: -10.0, 9: -12.5, 10: -15.0, 11: -17.5, 12: -20.0}
if spreading_factor < 7:
return 0.0
threshold = snr_thresholds.get(spreading_factor, -10.0)
# Below threshold = no chance of success
if snr < threshold:
return 0.0
# Success rate based on SNR above threshold
success_rate_based_on_snr = (snr - threshold) / 10.0
# Collision penalty: longer packets more likely to collide (max 256 bytes)
collision_penalty = 1.0 - (packet_len / 256.0)
# Combined score
score = success_rate_based_on_snr * collision_penalty
return max(0.0, min(1.0, score))
def _calculate_tx_delay(self, packet: Packet, snr: float = 0.0) -> float:
import random
packet_len = len(packet.payload) if packet.payload else 0
airtime_ms = PacketTimingUtils.estimate_airtime_ms(packet_len, self.radio_config)
route_type = packet.header & PH_ROUTE_MASK
# Base delay calculations
# this part took me along time to get right well i hope i got it right ;-)
if route_type == ROUTE_TYPE_FLOOD:
# Flood packets: random(0-5) * (airtime * 52/50 / 2) * tx_delay_factor
# This creates collision avoidance with tunable delay
base_delay_ms = (airtime_ms * 52 / 50) / 2.0 # From C++ implementation
random_mult = random.uniform(0, 5) # Random multiplier for collision avoidance
delay_ms = base_delay_ms * random_mult * self.tx_delay_factor
delay_s = delay_ms / 1000.0
else: # DIRECT
# Direct packets: use direct_tx_delay_factor (already in seconds)
# direct_tx_delay_factor is stored as seconds in config
delay_s = self.direct_tx_delay_factor
# Apply score-based delay adjustment ONLY if delay >= 50ms threshold
# (matching C++ reactive behavior in Dispatcher::calcRxDelay)
if delay_s >= 0.05 and self.use_score_for_tx:
score = self.calculate_packet_score(snr, packet_len)
# Higher score = shorter delay: max(0.2, 1.0 - score)
# score 1.0 → multiplier 0.2 (20% of original)
# 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}"
)
# 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"
)
return delay_s
def process_packet(self, packet: Packet, snr: float = 0.0) -> Optional[Tuple[Packet, float]]:
route_type = packet.header & PH_ROUTE_MASK
if route_type == ROUTE_TYPE_FLOOD:
fwd_pkt = self.flood_forward(packet)
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:
fwd_pkt = self.direct_forward(packet)
if fwd_pkt is None:
return None
delay = self._calculate_tx_delay(fwd_pkt, snr)
return fwd_pkt, delay
else:
logger.debug(f"Unknown route type: {route_type}")
return None
async def schedule_retransmit(self, fwd_pkt: Packet, delay: float, airtime_ms: float = 0.0):
async def delayed_send():
await asyncio.sleep(delay)
try:
await self.dispatcher.send_packet(fwd_pkt, wait_for_ack=False)
# Record airtime after successful TX
if airtime_ms > 0:
self.airtime_mgr.record_tx(airtime_ms)
packet_size = len(fwd_pkt.payload)
logger.info(
f"Retransmitted packet ({packet_size} bytes, {airtime_ms: .1f}ms airtime)"
)
except Exception as e:
logger.error(f"Retransmit failed: {e}")
asyncio.create_task(delayed_send())
async def _check_and_send_periodic_advert(self):
if self.send_advert_interval_hours <= 0 or not self.send_advert_func:
return
current_time = time.time()
interval_seconds = self.send_advert_interval_hours * 3600 # Convert hours to seconds
time_since_last_advert = current_time - self.last_advert_time
# Check if interval has elapsed
if time_since_last_advert >= interval_seconds:
logger.info(
f"Periodic advert interval elapsed ({time_since_last_advert: .0f}s >= "
f"{interval_seconds: .0f}s). Sending advert..."
)
try:
# Call the send_advert function
success = await self.send_advert_func()
if success:
self.last_advert_time = current_time
logger.info("Periodic advert sent successfully")
else:
logger.warning("Failed to send periodic advert")
except Exception as e:
logger.error(f"Error sending periodic advert: {e}", exc_info=True)
def get_stats(self) -> dict:
uptime_seconds = time.time() - self.start_time
# Get config sections
repeater_config = self.config.get("repeater", {})
duty_cycle_config = self.config.get("duty_cycle", {})
delays_config = self.config.get("delays", {})
max_airtime_ms = duty_cycle_config.get("max_airtime_per_minute", 3600)
max_duty_cycle_percent = (max_airtime_ms / 60000) * 100 # 60000ms = 1 minute
# Calculate actual hourly rates (packets in last 3600 seconds)
now = time.time()
packets_last_hour = [p for p in self.recent_packets if now - p["timestamp"] < 3600]
rx_per_hour = len(packets_last_hour)
forwarded_per_hour = sum(1 for p in packets_last_hour if p.get("transmitted", False))
stats = {
"local_hash": f"0x{self.local_hash: 02x}",
"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,
"rx_per_hour": rx_per_hour,
"forwarded_per_hour": forwarded_per_hour,
"recent_packets": self.recent_packets,
"neighbors": self.neighbors,
"uptime_seconds": uptime_seconds,
# Add configuration data
"config": {
"node_name": repeater_config.get("node_name", "Unknown"),
"repeater": {
"mode": repeater_config.get("mode", "forward"),
"use_score_for_tx": self.use_score_for_tx,
"score_threshold": self.score_threshold,
"send_advert_interval_hours": self.send_advert_interval_hours,
"latitude": repeater_config.get("latitude", 0.0),
"longitude": repeater_config.get("longitude", 0.0),
},
"radio": {
"frequency": self.radio_config.get("frequency", 0),
"tx_power": self.radio_config.get("tx_power", 0),
"bandwidth": self.radio_config.get("bandwidth", 0),
"spreading_factor": self.radio_config.get("spreading_factor", 0),
"coding_rate": self.radio_config.get("coding_rate", 0),
"preamble_length": self.radio_config.get("preamble_length", 0),
},
"duty_cycle": {
"max_airtime_percent": max_duty_cycle_percent,
"enforcement_enabled": duty_cycle_config.get("enforcement_enabled", True),
},
"delays": {
"tx_delay_factor": delays_config.get("tx_delay_factor", 1.0),
"direct_tx_delay_factor": delays_config.get("direct_tx_delay_factor", 0.5),
},
},
"public_key": None,
}
# Add airtime stats
stats.update(self.airtime_mgr.get_stats())
return stats

459
repeater/http_server.py Normal file
View File

@@ -0,0 +1,459 @@
import logging
import os
import re
from collections import deque
from datetime import datetime
from typing import Callable, Optional
import cherrypy
from pymc_core.protocol.utils import PAYLOAD_TYPES, ROUTE_TYPES
from repeater import __version__
logger = logging.getLogger("HTTPServer")
# In-memory log buffer
class LogBuffer(logging.Handler):
def __init__(self, max_lines=100):
super().__init__()
self.logs = deque(maxlen=max_lines)
self.setFormatter(logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s"))
def emit(self, record):
try:
msg = self.format(record)
self.logs.append(
{
"message": msg,
"timestamp": datetime.fromtimestamp(record.created).isoformat(),
"level": record.levelname,
}
)
except Exception:
self.handleError(record)
# Global log buffer instance
_log_buffer = LogBuffer(max_lines=100)
class APIEndpoints:
def __init__(
self,
stats_getter: Optional[Callable] = None,
send_advert_func: Optional[Callable] = None,
config: Optional[dict] = None,
event_loop=None,
):
self.stats_getter = stats_getter
self.send_advert_func = send_advert_func
self.config = config or {}
self.event_loop = event_loop # Store reference to main event loop
@cherrypy.expose
@cherrypy.tools.json_out()
def stats(self):
try:
stats = self.stats_getter() if self.stats_getter else {}
stats["version"] = __version__
return stats
except Exception as e:
logger.error(f"Error serving stats: {e}")
return {"error": str(e)}
@cherrypy.expose
@cherrypy.tools.json_out()
def send_advert(self):
if cherrypy.request.method != "POST":
return {"success": False, "error": "Method not allowed"}
if not self.send_advert_func:
return {"success": False, "error": "Send advert function not configured"}
try:
import asyncio
if self.event_loop is None:
return {"success": False, "error": "Event loop not available"}
future = asyncio.run_coroutine_threadsafe(self.send_advert_func(), self.event_loop)
result = future.result(timeout=10) # Wait up to 10 seconds
if result:
return {"success": True, "message": "Advert sent successfully"}
else:
return {"success": False, "error": "Failed to send advert"}
except Exception as e:
logger.error(f"Error sending advert: {e}", exc_info=True)
return {"success": False, "error": str(e)}
@cherrypy.expose
@cherrypy.tools.json_out()
@cherrypy.tools.json_in()
def set_mode(self):
if cherrypy.request.method != "POST":
return {"success": False, "error": "Method not allowed"}
try:
data = cherrypy.request.json
new_mode = data.get("mode", "forward")
if new_mode not in ["forward", "monitor"]:
return {"success": False, "error": "Invalid mode. Must be 'forward' or 'monitor'"}
# Update config
if "repeater" not in self.config:
self.config["repeater"] = {}
self.config["repeater"]["mode"] = new_mode
logger.info(f"Mode changed to: {new_mode}")
return {"success": True, "mode": new_mode}
except Exception as e:
logger.error(f"Error setting mode: {e}", exc_info=True)
return {"success": False, "error": str(e)}
@cherrypy.expose
@cherrypy.tools.json_out()
@cherrypy.tools.json_in()
def set_duty_cycle(self):
if cherrypy.request.method != "POST":
return {"success": False, "error": "Method not allowed"}
try:
data = cherrypy.request.json
enabled = data.get("enabled", True)
# Update config
if "duty_cycle" not in self.config:
self.config["duty_cycle"] = {}
self.config["duty_cycle"]["enforcement_enabled"] = enabled
logger.info(f"Duty cycle enforcement {'enabled' if enabled else 'disabled'}")
return {"success": True, "enabled": enabled}
except Exception as e:
logger.error(f"Error setting duty cycle: {e}", exc_info=True)
return {"success": False, "error": str(e)}
@cherrypy.expose
@cherrypy.tools.json_out()
def logs(self):
try:
logs = list(_log_buffer.logs)
return {
"logs": (
logs
if logs
else [
{
"message": "No logs available",
"timestamp": datetime.now().isoformat(),
"level": "INFO",
}
]
)
}
except Exception as e:
logger.error(f"Error fetching logs: {e}")
return {"error": str(e), "logs": []}
class StatsApp:
def __init__(
self,
stats_getter: Optional[Callable] = None,
template_dir: Optional[str] = None,
node_name: str = "Repeater",
pub_key: str = "",
send_advert_func: Optional[Callable] = None,
config: Optional[dict] = None,
event_loop=None,
):
self.stats_getter = stats_getter
self.template_dir = template_dir
self.node_name = node_name
self.pub_key = pub_key
self.dashboard_template = None
self.config = config or {}
# Create nested API object for routing
self.api = APIEndpoints(stats_getter, send_advert_func, self.config, event_loop)
# Load template on init
if template_dir:
template_path = os.path.join(template_dir, "dashboard.html")
try:
with open(template_path, "r") as f:
self.dashboard_template = f.read()
logger.info(f"Loaded template from {template_path}")
except FileNotFoundError:
logger.error(f"Template not found: {template_path}")
@cherrypy.expose
def index(self):
"""Serve dashboard HTML."""
return self._serve_template("dashboard.html")
@cherrypy.expose
def neighbors(self):
"""Serve neighbors page."""
return self._serve_template("neighbors.html")
@cherrypy.expose
def statistics(self):
"""Serve statistics page."""
return self._serve_template("statistics.html")
@cherrypy.expose
def configuration(self):
"""Serve configuration page."""
return self._serve_template("configuration.html")
@cherrypy.expose
def logs(self):
"""Serve logs page."""
return self._serve_template("logs.html")
@cherrypy.expose
def help(self):
"""Serve help documentation."""
return self._serve_template("help.html")
def _serve_template(self, template_name: str):
"""Serve HTML template with stats."""
if not self.template_dir:
return "<h1>Error</h1><p>Template directory not configured</p>"
if not self.dashboard_template:
return "<h1>Error</h1><p>Template not loaded</p>"
try:
template_path = os.path.join(self.template_dir, template_name)
with open(template_path, "r") as f:
template_content = f.read()
nav_path = os.path.join(self.template_dir, "nav.html")
nav_content = ""
try:
with open(nav_path, "r") as f:
nav_content = f.read()
except FileNotFoundError:
logger.warning(f"Navigation template not found: {nav_path}")
stats = self.stats_getter() if self.stats_getter else {}
if "uptime_seconds" not in stats or not isinstance(
stats.get("uptime_seconds"), (int, float)
):
stats["uptime_seconds"] = 0
# Calculate uptime in hours
uptime_seconds = stats.get("uptime_seconds", 0)
uptime_hours = int(uptime_seconds // 3600) if uptime_seconds else 0
# Determine current page for nav highlighting
page_map = {
"dashboard.html": "dashboard",
"neighbors.html": "neighbors",
"statistics.html": "statistics",
"configuration.html": "configuration",
"logs.html": "logs",
"help.html": "help",
}
current_page = page_map.get(template_name, "")
# Prepare basic substitutions
html = template_content
html = html.replace("{{ node_name }}", str(self.node_name))
html = html.replace("{{ last_updated }}", datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
html = html.replace("{{ page }}", current_page)
# Replace navigation placeholder with actual nav content
if "<!-- NAVIGATION_PLACEHOLDER -->" in html:
nav_substitutions = nav_content
nav_substitutions = nav_substitutions.replace(
"{{ node_name }}", str(self.node_name)
)
nav_substitutions = nav_substitutions.replace("{{ pub_key }}", str(self.pub_key))
nav_substitutions = nav_substitutions.replace(
"{{ last_updated }}", datetime.now().strftime("%Y-%m-%d %H:%M:%S")
)
# Handle active state for nav items
nav_substitutions = nav_substitutions.replace(
"{{ ' active' if page == 'dashboard' else '' }}",
" active" if current_page == "dashboard" else "",
)
nav_substitutions = nav_substitutions.replace(
"{{ ' active' if page == 'neighbors' else '' }}",
" active" if current_page == "neighbors" else "",
)
nav_substitutions = nav_substitutions.replace(
"{{ ' active' if page == 'statistics' else '' }}",
" active" if current_page == "statistics" else "",
)
nav_substitutions = nav_substitutions.replace(
"{{ ' active' if page == 'configuration' else '' }}",
" active" if current_page == "configuration" else "",
)
nav_substitutions = nav_substitutions.replace(
"{{ ' active' if page == 'logs' else '' }}",
" active" if current_page == "logs" else "",
)
nav_substitutions = nav_substitutions.replace(
"{{ ' active' if page == 'help' else '' }}",
" active" if current_page == "help" else "",
)
html = html.replace("<!-- NAVIGATION_PLACEHOLDER -->", nav_substitutions)
# Build packets table HTML for dashboard
if template_name == "dashboard.html":
recent_packets = stats.get("recent_packets", [])
packets_table = ""
if recent_packets:
for pkt in recent_packets[-20:]: # Last 20 packets
time_obj = datetime.fromtimestamp(pkt.get("timestamp", 0))
time_str = time_obj.strftime("%H:%M:%S")
pkt_type = PAYLOAD_TYPES.get(
pkt.get("type", 0), f"0x{pkt.get('type', 0): 02x}"
)
route_type = pkt.get("route", 0)
route = ROUTE_TYPES.get(route_type, f"UNKNOWN_{route_type}")
status = "OK TX" if pkt.get("transmitted") else "WAIT"
# Get proper CSS class for route type
route_class = route.lower().replace("_", "-")
snr_val = pkt.get("snr", 0.0)
score_val = pkt.get("score", 0)
delay_val = pkt.get("tx_delay_ms", 0)
packets_table += (
"<tr>"
f"<td>{time_str}</td>"
f'<td><span class="packet-type">{pkt_type}</span></td>'
f'<td><span class="route-{route_class}">{route}</span></td>'
f"<td>{pkt.get('length', 0)}</td>"
f"<td>{pkt.get('rssi', 0)}</td>"
f"<td>{snr_val: .1f}</td>"
f'<td><span class="score">{score_val: .2f}</span></td>'
f"<td>{delay_val: .0f}</td>"
f"<td>{status}</td>"
"</tr>"
)
else:
packets_table = """
<tr>
<td colspan="9" class="empty-message">
No packets received yet - waiting for traffic...
</td>
</tr>
"""
# Add dashboard-specific substitutions
html = html.replace("{{ rx_count }}", str(stats.get("rx_count", 0)))
html = html.replace("{{ forwarded_count }}", str(stats.get("forwarded_count", 0)))
html = html.replace("{{ dropped_count }}", str(stats.get("dropped_count", 0)))
html = html.replace("{{ uptime_hours }}", str(uptime_hours))
# Replace tbody with actual packets
tbody_pattern = r'<tbody id="packet-table">.*?</tbody>'
tbody_replacement = f'<tbody id="packet-table">\n{packets_table}\n</tbody>'
html = re.sub(
tbody_pattern,
tbody_replacement,
html,
flags=re.DOTALL,
)
return html
except Exception as e:
logger.error(f"Error rendering template {template_name}: {e}", exc_info=True)
return f"<h1>Error</h1><p>{str(e)}</p>"
class HTTPStatsServer:
def __init__(
self,
host: str = "0.0.0.0",
port: int = 8000,
stats_getter: Optional[Callable] = None,
template_dir: Optional[str] = None,
node_name: str = "Repeater",
pub_key: str = "",
send_advert_func: Optional[Callable] = None,
config: Optional[dict] = None,
event_loop=None,
):
self.host = host
self.port = port
self.app = StatsApp(
stats_getter, template_dir, node_name, pub_key, send_advert_func, config, event_loop
)
def start(self):
try:
# Serve static files from templates directory
static_dir = (
self.app.template_dir if self.app.template_dir else os.path.dirname(__file__)
)
config = {
"/": {
"tools.sessions.on": False,
},
"/static": {
"tools.staticdir.on": True,
"tools.staticdir.dir": static_dir,
},
}
cherrypy.config.update(
{
"server.socket_host": self.host,
"server.socket_port": self.port,
"engine.autoreload.on": False,
"log.screen": False,
"log.access_file": "", # Disable access log file
"log.error_file": "", # Disable error log file
}
)
cherrypy.tree.mount(self.app, "/", config)
# Completely disable access logging
cherrypy.log.access_log.propagate = False
cherrypy.log.error_log.setLevel(logging.ERROR)
cherrypy.engine.start()
server_url = "http://{}:{}".format(self.host, self.port)
logger.info(f"HTTP stats server started on {server_url}")
except Exception as e:
logger.error(f"Failed to start HTTP server: {e}")
raise
def stop(self):
try:
cherrypy.engine.exit()
logger.info("HTTP stats server stopped")
except Exception as e:
logger.warning(f"Error stopping HTTP server: {e}")

271
repeater/main.py Normal file
View File

@@ -0,0 +1,271 @@
import asyncio
import logging
import os
import sys
from repeater.config import get_radio_for_board, load_config
from repeater.engine import RepeaterHandler
from repeater.http_server import HTTPStatsServer, _log_buffer
logger = logging.getLogger("RepeaterDaemon")
class RepeaterDaemon:
def __init__(self, config: dict, radio=None):
self.config = config
self.radio = radio
self.dispatcher = None
self.repeater_handler = None
self.local_hash = None
self.local_identity = None
self.http_server = None
# Setup logging
log_level = config.get("logging", {}).get("level", "INFO")
logging.basicConfig(
level=getattr(logging, log_level),
format=config.get("logging", {}).get("format"),
)
# Add log buffer handler to capture logs for web display
root_logger = logging.getLogger()
_log_buffer.setLevel(getattr(logging, log_level))
root_logger.addHandler(_log_buffer)
async def initialize(self):
logger.info(f"Initializing repeater: {self.config['repeater']['node_name']}")
if self.radio is None:
logger.info("Initializing radio hardware...")
try:
self.radio = get_radio_for_board(self.config)
logger.info("Radio hardware initialized")
except Exception as e:
logger.error(f"Failed to initialize radio hardware: {e}")
raise RuntimeError("Repeater requires real LoRa hardware") from e
# Create dispatcher from pymc_core
try:
from pymc_core import LocalIdentity
from pymc_core.node.dispatcher import Dispatcher
self.dispatcher = Dispatcher(self.radio)
logger.info("Dispatcher initialized")
identity_key = self.config.get("mesh", {}).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")
local_identity = LocalIdentity(seed=identity_key)
self.local_identity = local_identity
self.dispatcher.local_identity = local_identity
# Get the actual hash from the identity (first byte of public key)
pubkey = local_identity.get_public_key()
self.local_hash = pubkey[0]
logger.info(f"Local identity set: {local_identity.get_address_bytes().hex()}")
local_hash_hex = f"0x{self.local_hash: 02x}"
logger.info(f"Local node hash (from identity): {local_hash_hex}")
# Override _is_own_packet to always return False
self.dispatcher._is_own_packet = lambda pkt: False
self.repeater_handler = RepeaterHandler(
self.config, self.dispatcher, self.local_hash, send_advert_func=self.send_advert
)
self.dispatcher.register_fallback_handler(self._repeater_callback)
logger.info("Repeater handler registered (forwarder mode)")
except Exception as e:
logger.error(f"Failed to initialize dispatcher: {e}")
raise
async def _repeater_callback(self, packet):
if self.repeater_handler:
metadata = {
"rssi": getattr(packet, "rssi", 0),
"snr": getattr(packet, "snr", 0.0),
"timestamp": getattr(packet, "timestamp", 0),
}
await self.repeater_handler(packet, metadata)
def _get_keypair(self):
"""Create a PyNaCl SigningKey for map API."""
try:
from nacl.signing import SigningKey
if not self.local_identity:
return None
# Get the seed from config
identity_key = self.config.get("mesh", {}).get("identity_key")
if not identity_key:
return None
# Convert to bytes if it's a hex string, otherwise use as-is
if isinstance(identity_key, str):
seed_bytes = bytes.fromhex(identity_key)
else:
seed_bytes = identity_key
signing_key = SigningKey(seed_bytes)
return signing_key
except Exception as e:
logger.warning(f"Failed to create keypair for map API: {e}")
return None
def get_stats(self) -> dict:
if self.repeater_handler:
stats = self.repeater_handler.get_stats()
# Add public key if available
if self.local_identity:
try:
pubkey = self.local_identity.get_public_key()
stats["public_key"] = pubkey.hex()
except Exception:
stats["public_key"] = None
return stats
return {}
async def send_advert(self) -> bool:
if not self.dispatcher or not self.local_identity:
logger.error("Cannot send advert: dispatcher or identity not initialized")
return False
try:
from pymc_core.protocol import PacketBuilder
from pymc_core.protocol.constants import ADVERT_FLAG_HAS_NAME, ADVERT_FLAG_IS_REPEATER
# Get node name and location from config
repeater_config = self.config.get("repeater", {})
node_name = repeater_config.get("node_name", "Repeater")
latitude = repeater_config.get("latitude", 0.0)
longitude = repeater_config.get("longitude", 0.0)
flags = ADVERT_FLAG_IS_REPEATER | ADVERT_FLAG_HAS_NAME
packet = PacketBuilder.create_advert(
local_identity=self.local_identity,
name=node_name,
lat=latitude,
lon=longitude,
feature1=0,
feature2=0,
flags=flags,
route_type="flood",
)
# Send via dispatcher
await self.dispatcher.send_packet(packet)
# Mark our own advert as seen to prevent re-forwarding it
if self.repeater_handler:
self.repeater_handler.mark_seen(packet)
logger.debug("Marked own advert as seen in duplicate cache")
logger.info(f"Sent flood advert '{node_name}' at ({latitude: .6f}, {longitude: .6f})")
return True
except Exception as e:
logger.error(f"Failed to send advert: {e}", exc_info=True)
return False
async def run(self):
logger.info("Repeater daemon started")
await self.initialize()
# Start HTTP stats server
http_port = self.config.get("http", {}).get("port", 8000)
http_host = self.config.get("http", {}).get("host", "0.0.0.0")
template_dir = os.path.join(os.path.dirname(__file__), "templates")
node_name = self.config.get("repeater", {}).get("node_name", "Repeater")
# Format public key for display
pub_key_formatted = ""
if self.local_identity:
pub_key_hex = self.local_identity.get_public_key().hex()
# Format as <first8...last8>
if len(pub_key_hex) >= 16:
pub_key_formatted = f"{pub_key_hex[:8]}...{pub_key_hex[-8:]}"
else:
pub_key_formatted = pub_key_hex
# Get the current event loop (the main loop where the radio was initialized)
current_loop = asyncio.get_event_loop()
self.http_server = HTTPStatsServer(
host=http_host,
port=http_port,
stats_getter=self.get_stats,
template_dir=template_dir,
node_name=node_name,
pub_key=pub_key_formatted,
send_advert_func=self.send_advert,
config=self.config, # Pass the config reference
event_loop=current_loop, # Pass the main event loop
)
try:
self.http_server.start()
except Exception as e:
logger.error(f"Failed to start HTTP server: {e}")
# Run dispatcher (handles RX/TX via pymc_core)
try:
await self.dispatcher.run_forever()
except KeyboardInterrupt:
logger.info("Shutting down...")
if self.http_server:
self.http_server.stop()
def main():
import argparse
parser = argparse.ArgumentParser(description="pyMC Repeater Daemon")
parser.add_argument(
"--config",
help="Path to config file (default: /etc/pymc_repeater/config.yaml)",
)
parser.add_argument(
"--log-level",
choices=["DEBUG", "INFO", "WARNING", "ERROR"],
help="Log level (default: INFO)",
)
args = parser.parse_args()
# Load configuration
config = load_config(args.config)
if args.log_level:
config["logging"]["level"] = args.log_level
# Don't initialize radio here - it will be done inside the async event loop
daemon = RepeaterDaemon(config, radio=None)
# Run
try:
asyncio.run(daemon.run())
except KeyboardInterrupt:
logger.info("Repeater stopped")
except Exception as e:
logger.error(f"Fatal error: {e}", exc_info=True)
sys.exit(1)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,216 @@
<!DOCTYPE html>
<html>
<head>
<title>pyMC Repeater - Configuration</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<div class="layout">
<!-- Navigation Component -->
<!-- NAVIGATION_PLACEHOLDER -->
<!-- Main Content -->
<main class="content">
<header>
<h1>Configuration</h1>
<p>System configuration and settings</p>
</header>
<div class="info-box">
Configuration is read-only. To modify settings, edit the config file and restart the daemon.
</div>
<!-- Radio Configuration -->
<h2>Radio Settings</h2>
<div class="config-section">
<div class="config-item">
<div class="config-label">Frequency</div>
<div class="config-value" id="radio-freq">Loading...</div>
</div>
<div class="config-item">
<div class="config-label">Spreading Factor</div>
<div class="config-value" id="radio-sf">Loading...</div>
</div>
<div class="config-item">
<div class="config-label">Bandwidth</div>
<div class="config-value" id="radio-bw">Loading...</div>
</div>
<div class="config-item">
<div class="config-label">TX Power</div>
<div class="config-value" id="radio-tx">Loading...</div>
</div>
<div class="config-item">
<div class="config-label">Coding Rate</div>
<div class="config-value" id="radio-cr">Loading...</div>
</div>
<div class="config-item">
<div class="config-label">Preamble Length</div>
<div class="config-value" id="radio-preamble">Loading...</div>
</div>
</div>
<!-- Repeater Configuration -->
<h2>Repeater Settings</h2>
<div class="config-section">
<div class="config-item">
<div class="config-label">Node Name</div>
<div class="config-value" id="node-name">Loading...</div>
</div>
<div class="config-item">
<div class="config-label">Local Hash</div>
<div class="config-value" id="local-hash">Loading...</div>
</div>
<div class="config-item">
<div class="config-label">Public Key</div>
<div class="config-value" id="public-key" style="word-break: break-all; font-family: monospace; font-size: 0.9em;">Loading...</div>
</div>
<div class="config-item">
<div class="config-label">Latitude</div>
<div class="config-value" id="latitude">Loading...</div>
</div>
<div class="config-item">
<div class="config-label">Longitude</div>
<div class="config-value" id="longitude">Loading...</div>
</div>
<div class="config-item">
<div class="config-label">Mode</div>
<div class="config-value" id="repeater-mode">Loading...</div>
</div>
<div class="config-item">
<div class="config-label">Periodic Advertisement Interval</div>
<div class="config-value" id="send-advert-interval">Loading...</div>
<div class="config-help">How often the repeater sends an advertisement packet (0 = disabled)</div>
</div>
</div>
<!-- Duty Cycle -->
<h2>Duty Cycle</h2>
<div class="config-section">
<div class="config-item">
<div class="config-label">Max Airtime %</div>
<div class="config-value" id="duty-cycle">Loading...</div>
</div>
<div class="config-item">
<div class="config-label">Enforcement</div>
<div class="config-value" id="duty-enforcement">Loading...</div>
</div>
</div>
<!-- TX Delays -->
<h2>Transmission Delays</h2>
<div class="config-section">
<div class="config-item">
<div class="config-label">Flood TX Delay Factor</div>
<div class="config-value" id="tx-delay-factor">Loading...</div>
<div class="config-help">Multiplier for flood packet transmission delays (collision avoidance)</div>
</div>
<div class="config-item">
<div class="config-label">Direct TX Delay Factor</div>
<div class="config-value" id="direct-tx-delay-factor">Loading...</div>
<div class="config-help">Base delay for direct-routed packet transmission (seconds)</div>
</div>
</div>
</main>
</div>
<script>
let currentConfig = {};
function loadConfiguration() {
fetch('/api/stats')
.then(r => r.json())
.then(data => {
currentConfig = data;
const config = data.config || {};
const radio = config.radio || {};
const dutyCycle = config.duty_cycle || {};
const delays = config.delays || {};
// Update radio settings
if (radio.frequency) {
document.getElementById('radio-freq').textContent = (radio.frequency / 1000000).toFixed(3) + ' MHz';
}
if (radio.spreading_factor) {
document.getElementById('radio-sf').textContent = radio.spreading_factor;
}
if (radio.bandwidth) {
document.getElementById('radio-bw').textContent = (radio.bandwidth / 1000).toFixed(1) + ' kHz';
}
if (radio.tx_power !== undefined) {
document.getElementById('radio-tx').textContent = radio.tx_power + ' dBm';
}
if (radio.coding_rate) {
document.getElementById('radio-cr').textContent = '4/' + radio.coding_rate;
}
if (radio.preamble_length) {
document.getElementById('radio-preamble').textContent = radio.preamble_length + ' symbols';
}
// Update repeater settings
if (config.node_name) {
document.getElementById('node-name').textContent = config.node_name;
}
if (data.local_hash) {
document.getElementById('local-hash').textContent = data.local_hash;
}
if (data.public_key) {
document.getElementById('public-key').textContent = data.public_key;
} else {
document.getElementById('public-key').textContent = 'Not set';
}
if (config.repeater && config.repeater.latitude !== undefined) {
const lat = config.repeater.latitude;
document.getElementById('latitude').textContent = lat && lat !== 0 ? lat.toFixed(6) : 'Not set';
}
if (config.repeater && config.repeater.longitude !== undefined) {
const lng = config.repeater.longitude;
document.getElementById('longitude').textContent = lng && lng !== 0 ? lng.toFixed(6) : 'Not set';
}
if (config.repeater && config.repeater.mode) {
const mode = config.repeater.mode;
document.getElementById('repeater-mode').textContent =
mode.charAt(0).toUpperCase() + mode.slice(1);
}
if (config.repeater && config.repeater.send_advert_interval_hours !== undefined) {
const interval = config.repeater.send_advert_interval_hours;
if (interval === 0) {
document.getElementById('send-advert-interval').textContent = 'Disabled';
} else {
document.getElementById('send-advert-interval').textContent = interval + ' hour' + (interval !== 1 ? 's' : '');
}
}
// Update duty cycle
if (dutyCycle.max_airtime_percent !== undefined) {
document.getElementById('duty-cycle').textContent = dutyCycle.max_airtime_percent.toFixed(1) + '%';
}
document.getElementById('duty-enforcement').textContent =
dutyCycle.enforcement_enabled ? 'Enabled' : 'Disabled';
// Update delays
if (delays.tx_delay_factor !== undefined) {
document.getElementById('tx-delay-factor').textContent = delays.tx_delay_factor.toFixed(2) + 'x';
}
if (delays.direct_tx_delay_factor !== undefined) {
document.getElementById('direct-tx-delay-factor').textContent = delays.direct_tx_delay_factor.toFixed(2) + 's';
}
})
.catch(e => {
console.error('Error loading configuration:', e);
// Show error in UI
document.querySelectorAll('.config-value').forEach(el => {
if (el.textContent === 'Loading...') {
el.textContent = 'Error';
el.style.color = '#f48771';
}
});
});
}
// Load configuration on page load
document.addEventListener('DOMContentLoaded', loadConfiguration);
</script>
</body>
</html>

View File

@@ -0,0 +1,712 @@
<!DOCTYPE html>
<html>
<head>
<title>pyMC Repeater Stats</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="https://cdn.jsdelivr.net/npm/chart.js@3.9.1"></script>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<div class="layout">
<!-- Navigation Component -->
<!-- NAVIGATION_PLACEHOLDER -->
<!-- Main Content -->
<main class="content">
<header>
<h1>Repeater Dashboard</h1>
<div class="header-info">
<span>System Status: <strong>Active</strong></span>
<span>Updated: <strong id="update-time">{{ last_updated }}</strong></span>
</div>
</header>
<!-- Stats Grid -->
<div class="stats-grid">
<div class="stat-card success">
<div class="stat-label">RX Packets</div>
<div class="stat-value" id="rx-count">{{ rx_count }}<span class="stat-unit">total</span></div>
</div>
<div class="stat-card success">
<div class="stat-label">Forwarded</div>
<div class="stat-value" id="forwarded-count">{{ forwarded_count }}<span class="stat-unit">packets</span></div>
</div>
<div class="stat-card warning">
<div class="stat-label">Uptime</div>
<div class="stat-value" id="uptime">{{ uptime_hours }}<span class="stat-unit">h</span></div>
</div>
<div class="stat-card error">
<div class="stat-label">Dropped</div>
<div class="stat-value" id="dropped-count">{{ dropped_count }}<span class="stat-unit">packets</span></div>
</div>
</div>
<!-- Charts Section -->
<h2>Performance Metrics</h2>
<div class="charts-grid">
<div class="chart-card">
<h3>Packet Rate (RX/TX per hour)</h3>
<div class="chart-container">
<canvas id="packetRateChart"></canvas>
</div>
</div>
<div class="chart-card">
<h3>Signal Quality Distribution</h3>
<div class="chart-container">
<canvas id="signalQualityChart"></canvas>
</div>
</div>
</div>
<!-- Packets Table -->
<h2>Recent Packets</h2>
<div class="table-container">
<table>
<thead>
<tr>
<th>Time</th>
<th>Type</th>
<th>Route</th>
<th>Len</th>
<th>Path / Hashes</th>
<th>RSSI</th>
<th>SNR</th>
<th>Score</th>
<th>TX Delay</th>
<th>Status</th>
</tr>
</thead>
<tbody id="packet-table">
<tr>
<td colspan="11" class="empty-message">
No packets received yet - waiting for traffic...
</td>
</tr>
</tbody>
</table>
</div>
<div class="refresh-info">
Real-time updates enabled
</div>
</main>
</div>
<script>
// Initialize charts
let packetRateChart = null;
let signalQualityChart = null;
function initCharts() {
const chartOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
labels: {
color: '#d4d4d4'
}
},
filler: {
propagate: true
}
},
scales: {
x: {
ticks: { color: '#999' },
grid: { color: '#333' }
},
y: {
ticks: { color: '#999' },
grid: { color: '#333' }
}
}
};
// Packet rate chart
let packetRateCtx = document.getElementById('packetRateChart').getContext('2d');
packetRateChart = new Chart(packetRateCtx, {
type: 'line',
data: {
labels: [],
datasets: [
{
label: 'RX/hour',
data: [],
borderColor: '#4ec9b0',
backgroundColor: 'rgba(78, 201, 176, 0.1)',
borderWidth: 2,
fill: true,
tension: 0.4,
pointBackgroundColor: '#4ec9b0',
pointBorderColor: '#4ec9b0'
},
{
label: 'TX/hour',
data: [],
borderColor: '#6a9955',
backgroundColor: 'rgba(106, 153, 85, 0.1)',
borderWidth: 2,
fill: true,
tension: 0.4,
pointBackgroundColor: '#6a9955',
pointBorderColor: '#6a9955'
}
]
},
options: chartOptions
});
// Signal quality chart
let signalQualityCtx = document.getElementById('signalQualityChart').getContext('2d');
signalQualityChart = new Chart(signalQualityCtx, {
type: 'bar',
data: {
labels: ['Excellent', 'Good', 'Fair', 'Poor'],
datasets: [{
label: 'Packet Count',
data: [0, 0, 0, 0],
backgroundColor: ['#6a9955', '#4ec9b0', '#dcdcaa', '#f48771'],
borderRadius: 4
}]
},
options: chartOptions
});
}
function updateStats() {
fetch('/api/stats')
.then(r => r.json())
.then(data => {
// Update stat cards
document.getElementById('rx-count').textContent = data.rx_count || 0;
document.getElementById('forwarded-count').textContent = data.forwarded_count || 0;
document.getElementById('dropped-count').textContent = data.dropped_count || 0;
// Safely update uptime - handle missing or invalid values
const uptimeSeconds = data.uptime_seconds || 0;
const uptimeHours = Math.floor(uptimeSeconds / 3600);
document.getElementById('uptime').innerHTML = uptimeHours + '<span class="stat-unit">h</span>';
document.getElementById('update-time').textContent = new Date().toLocaleTimeString();
// Update packet table with local hash for highlighting
if (data.recent_packets) {
const localHash = data.local_hash ? data.local_hash.replace('0x', '').toUpperCase() : null;
updatePacketTable(data.recent_packets, localHash);
}
// Update charts
updateCharts(data);
})
.catch(e => console.error('Error fetching stats:', e));
}
// Helper function to create signal strength bars with SNR value
function getSignalBars(snr, spreadingFactor = 8) {
// SNR thresholds per SF (matching engine.py)
const snrThresholds = {7: -7.5, 8: -10.0, 9: -12.5, 10: -15.0, 11: -17.5, 12: -20.0};
const threshold = snrThresholds[spreadingFactor] || -10.0;
let level, className;
if (snr >= threshold + 10) {
level = 4; className = 'signal-excellent';
} else if (snr >= threshold + 5) {
level = 3; className = 'signal-good';
} else if (snr >= threshold) {
level = 2; className = 'signal-fair';
} else {
level = 1; className = 'signal-poor';
}
return `<div class="signal-container">
<span class="signal-bars ${className}" title="Signal: ${className.replace('signal-', '')}">${'<span class="signal-bar"></span>'.repeat(4)}</span>
<span class="snr-value">${snr.toFixed(1)} dB</span>
</div>`;
} function updatePacketTable(packets, localHash) {
const tbody = document.getElementById('packet-table');
if (!packets || packets.length === 0) {
tbody.innerHTML = `
<tr>
<td colspan="9" class="empty-message">
No packets received yet - waiting for traffic...
</td>
</tr>
`;
return;
}
tbody.innerHTML = packets.slice(-20).map(pkt => {
const time = new Date(pkt.timestamp * 1000).toLocaleTimeString();
// Match pyMC_core PAYLOAD_TYPES exactly (from constants.py)
const typeNames = {
0: 'REQ',
1: 'RESPONSE',
2: 'TXT_MSG',
3: 'ACK',
4: 'ADVERT',
5: 'GRP_TXT',
6: 'GRP_DATA',
7: 'ANON_REQ',
8: 'PATH',
9: 'TRACE',
15: 'RAW_CUSTOM'
};
const type = typeNames[pkt.type] || `0x${pkt.type.toString(16).toUpperCase()}`;
// Match pyMC_core ROUTE_TYPES exactly
const routeNames = {
0: 'TRANSPORT_FLOOD',
1: 'FLOOD',
2: 'DIRECT',
3: 'TRANSPORT_DIRECT'
};
const route = routeNames[pkt.route] || `UNKNOWN_${pkt.route}`;
const status = pkt.transmitted ? 'FORWARDED' : 'DROPPED';
const hasDuplicates = pkt.duplicates && pkt.duplicates.length > 0;
// Format path/hashes column - compact layout for mobile
let pathHashesHtml = '';
// Build path display with transformation
if (pkt.path_hash) {
let pathDisplay = pkt.path_hash;
if (localHash) {
pathDisplay = pathDisplay.replace(
new RegExp(`\\b${localHash}\\b`, 'g'),
`<span class="my-hash" title="This repeater (${localHash})">${localHash}</span>`
);
}
// Check if path was transformed
if (pkt.transmitted && pkt.original_path && pkt.forwarded_path !== undefined && pkt.forwarded_path !== null) {
const origPath = `[${pkt.original_path.join(', ')}]`;
const fwdPath = pkt.forwarded_path.length > 0 ? `[${pkt.forwarded_path.join(', ')}]` : '[]';
if (origPath !== fwdPath) {
// Compact inline transformation
pathHashesHtml = `<div class="path-info"><span class="path-hash">${pathDisplay}</span> <span class="path-arrow">→</span> <span class="path-hash">${fwdPath}</span></div>`;
} else {
pathHashesHtml = `<div class="path-info"><span class="path-hash">${pathDisplay}</span></div>`;
}
} else {
pathHashesHtml = `<div class="path-info"><span class="path-hash">${pathDisplay}</span></div>`;
}
}
// Add src→dst on separate line for clarity
if (pkt.src_hash && pkt.dst_hash) {
pathHashesHtml += `<div class="route-info"><span class="src-dst-hash">${pkt.src_hash}${pkt.dst_hash}</span></div>`;
} else if (pkt.src_hash || pkt.dst_hash) {
pathHashesHtml += `<div class="route-info"><span class="src-dst-hash">${pkt.src_hash || '?'}${pkt.dst_hash || '?'}</span></div>`;
}
if (!pathHashesHtml) {
pathHashesHtml = '<span class="na">-</span>';
}
// Format status with drop reason on separate line
let statusHtml = `<span class="status-${status === 'FORWARDED' ? 'tx' : 'dropped'}">${status}</span>`;
if (!pkt.transmitted && pkt.drop_reason) {
statusHtml += `<br><small class="drop-reason">${pkt.drop_reason}</small>`;
}
if (hasDuplicates) {
statusHtml += ` <span class="dupe-badge">${pkt.duplicates.length} dupe${pkt.duplicates.length > 1 ? 's' : ''}</span>`;
}
let mainRow = `
<tr class="${hasDuplicates ? 'has-duplicates' : ''}">
<td data-label="Time">${time}</td>
<td data-label="Type"><span class="packet-type">${type}</span></td>
<td data-label="Route"><span class="route-${route.toLowerCase().replace('_', '-')}">${route}</span></td>
<td data-label="Len">${pkt.length}B</td>
<td data-label="Path/Hashes">${pathHashesHtml}</td>
<td data-label="RSSI">${pkt.rssi}</td>
<td data-label="SNR">${getSignalBars(pkt.snr)}</td>
<td data-label="Score"><span class="score">${pkt.score.toFixed(2)}</span></td>
<td data-label="TX Delay">${pkt.tx_delay_ms.toFixed(0)}ms</td>
<td data-label="Status">${statusHtml}</td>
</tr>
`;
// Add duplicate rows (always visible)
if (hasDuplicates) {
mainRow += pkt.duplicates.map(dupe => {
const dupeTime = new Date(dupe.timestamp * 1000).toLocaleTimeString();
const dupeRoute = routeNames[dupe.route] || `UNKNOWN_${dupe.route}`;
// Format duplicate path/hashes - match main row format
let dupePathHashesHtml = '';
if (dupe.path_hash) {
let dupePathDisplay = dupe.path_hash;
if (localHash) {
dupePathDisplay = dupePathDisplay.replace(
new RegExp(`\\b${localHash}\\b`, 'g'),
`<span class="my-hash" title="This repeater (${localHash})">${localHash}</span>`
);
}
dupePathHashesHtml = `<div class="path-info"><span class="path-hash">${dupePathDisplay}</span></div>`;
}
if (dupe.src_hash && dupe.dst_hash) {
dupePathHashesHtml += `<div class="route-info"><span class="src-dst-hash">${dupe.src_hash}${dupe.dst_hash}</span></div>`;
} else if (dupe.src_hash || dupe.dst_hash) {
dupePathHashesHtml += `<div class="route-info"><span class="src-dst-hash">${dupe.src_hash || '?'}${dupe.dst_hash || '?'}</span></div>`;
}
if (!dupePathHashesHtml) {
dupePathHashesHtml = '<span class="na">-</span>';
}
// Format duplicate status
let dupeStatusHtml = '<span class="status-dropped">DROPPED</span>';
if (dupe.drop_reason) {
dupeStatusHtml += `<br><small class="drop-reason">${dupe.drop_reason}</small>`;
}
return `
<tr class="duplicate-row">
<td data-label="Time" style="padding-left: 30px;">↳ ${dupeTime}</td>
<td data-label="Type"><span class="packet-type-dim">${type}</span></td>
<td data-label="Route"><span class="route-${dupeRoute.toLowerCase().replace('_', '-')}">${dupeRoute}</span></td>
<td data-label="Len">${dupe.length}B</td>
<td data-label="Path/Hashes">${dupePathHashesHtml}</td>
<td data-label="RSSI">${dupe.rssi}</td>
<td data-label="SNR">${getSignalBars(dupe.snr)}</td>
<td data-label="Score"><span class="score">${dupe.score.toFixed(2)}</span></td>
<td data-label="TX Delay">${dupe.tx_delay_ms.toFixed(0)}ms</td>
<td data-label="Status">${dupeStatusHtml}</td>
</tr>
`;
}).join('');
}
return mainRow;
}).join('');
}
// Track previous values to detect changes
let lastRxPerHour = -1;
let lastTxPerHour = -1;
function updateCharts(data) {
if (!packetRateChart) return;
// Use actual hourly rates from backend (packets in last 3600 seconds)
const rxPerHour = data.rx_per_hour || 0;
const txPerHour = data.forwarded_per_hour || 0;
// Only update packet rate chart if values changed
if (rxPerHour !== lastRxPerHour || txPerHour !== lastTxPerHour) {
// Add current timestamp as label
const currentTime = new Date().toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
hour12: false
});
packetRateChart.data.labels.push(currentTime);
packetRateChart.data.datasets[0].data.push(rxPerHour);
packetRateChart.data.datasets[1].data.push(txPerHour);
// Keep only last 10 data points
if (packetRateChart.data.labels.length > 10) {
packetRateChart.data.labels.shift();
packetRateChart.data.datasets[0].data.shift();
packetRateChart.data.datasets[1].data.shift();
}
packetRateChart.update();
lastRxPerHour = rxPerHour;
lastTxPerHour = txPerHour;
}
// Get recent packets for signal quality chart (still use last minute for responsiveness)
const now = Date.now() / 1000;
const recentPackets = (data.recent_packets || []).filter(p => now - p.timestamp < 60);
// Update signal quality distribution
const excellent = recentPackets.filter(p => p.score > 0.75).length;
const good = recentPackets.filter(p => p.score > 0.5 && p.score <= 0.75).length;
const fair = recentPackets.filter(p => p.score > 0.25 && p.score <= 0.5).length;
const poor = recentPackets.filter(p => p.score <= 0.25).length;
signalQualityChart.data.datasets[0].data = [excellent, good, fair, poor];
signalQualityChart.update();
}
// Handle Send Advert button
function sendAdvert() {
const btn = document.getElementById('send-advert-btn');
if (!btn) return;
const icon = btn.querySelector('.icon');
const iconHTML = icon ? icon.outerHTML : '';
btn.disabled = true;
btn.innerHTML = iconHTML + 'Sending...';
fetch('/api/send_advert', {
method: 'POST'
})
.then(r => r.json())
.then(data => {
if (data.success) {
btn.innerHTML = iconHTML + 'Sent!';
setTimeout(() => {
btn.innerHTML = iconHTML + 'Send Advert';
btn.disabled = false;
}, 2000);
} else {
btn.innerHTML = iconHTML + 'Error';
console.error('Failed to send advert:', data.error);
setTimeout(() => {
btn.innerHTML = iconHTML + 'Send Advert';
btn.disabled = false;
}, 2000);
}
})
.catch(e => {
console.error('Error sending advert:', e);
btn.innerHTML = iconHTML + 'Error';
setTimeout(() => {
btn.innerHTML = iconHTML + 'Send Advert';
btn.disabled = false;
}, 2000);
});
}
// Initialize on load
document.addEventListener('DOMContentLoaded', () => {
initCharts();
updateStats();
// Auto-update every 5 seconds
setInterval(updateStats, 5000);
// Attach send advert button handler
const sendAdvertBtn = document.getElementById('send-advert-btn');
if (sendAdvertBtn) {
sendAdvertBtn.addEventListener('click', sendAdvert);
}
});
</script>
<style>
/* GitHub link styling */
.github-link {
display: inline-flex;
align-items: center;
margin-left: 12px;
color: #d4d4d4;
text-decoration: none;
transition: color 0.2s, transform 0.2s;
vertical-align: middle;
}
.github-link:hover {
color: #4ec9b0;
transform: scale(1.1);
}
.github-link svg {
display: block;
}
.has-duplicates {
border-left: 3px solid #4ec9b0;
}
.duplicate-row {
background-color: rgba(244, 135, 113, 0.05);
}
.duplicate-row td {
font-size: 0.9em;
color: #999;
}
.packet-type-dim {
opacity: 0.6;
}
.dupe-badge {
display: inline-block;
padding: 2px 6px;
background-color: rgba(244, 135, 113, 0.2);
border-radius: 3px;
font-size: 0.85em;
color: #f48771;
margin-left: 4px;
}
.status-dropped {
color: #f48771;
font-weight: 500;
}
.status-tx {
color: #4ade80;
font-weight: 500;
}
.hash {
font-family: 'Courier New', monospace;
font-size: 0.85em;
color: #4ec9b0;
background-color: rgba(78, 201, 176, 0.1);
padding: 2px 4px;
border-radius: 3px;
}
/* Signal strength bars */
.signal-container {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
}
.signal-bars {
display: inline-flex;
align-items: flex-end;
gap: 2px;
height: 14px;
}
.signal-bar {
width: 3px;
background-color: #333;
border-radius: 1px;
transition: background-color 0.3s;
}
.signal-bar:nth-child(1) { height: 25%; }
.signal-bar:nth-child(2) { height: 50%; }
.signal-bar:nth-child(3) { height: 75%; }
.signal-bar:nth-child(4) { height: 100%; }
/* Signal strength colors */
.signal-excellent .signal-bar { background-color: #4ade80; }
.signal-good .signal-bar:nth-child(-n+3) { background-color: #4ade80; }
.signal-fair .signal-bar:nth-child(-n+2) { background-color: #fbbf24; }
.signal-poor .signal-bar:nth-child(1) { background-color: #f48771; }
/* SNR value styling */
.snr-value {
font-size: 0.8em;
color: #999;
white-space: nowrap;
} /* Path/Hashes column layout */
.path-info {
display: flex;
align-items: center;
gap: 4px;
flex-wrap: nowrap;
margin-bottom: 4px;
font-size: 0.85em;
}
.route-info {
font-size: 0.9em;
opacity: 0.85;
}
.path-hash {
font-family: 'Courier New', monospace;
font-size: 0.85em;
color: #dcdcaa;
background: rgba(220, 220, 170, 0.1);
padding: 2px 6px;
border-radius: 4px;
font-weight: 500;
white-space: nowrap;
border: 1px solid rgba(220, 220, 170, 0.2);
}
.path-arrow {
color: #4ec9b0;
font-weight: bold;
font-size: 1.1em;
padding: 0 2px;
}
.my-hash {
background: rgba(86, 156, 214, 0.2);
color: #569cd6;
font-weight: 700;
padding: 2px 6px;
border-radius: 4px;
border: 1px solid rgba(86, 156, 214, 0.4);
}
.src-dst-hash {
font-family: 'Courier New', monospace;
font-size: 0.85em;
color: #4ec9b0;
font-weight: 500;
white-space: nowrap;
}
/* Make the arrow larger and more visible */
td {
line-height: 1.4;
}
.drop-reason {
color: #888;
font-size: 0.8em;
font-style: italic;
display: block;
margin-top: 2px;
}
.na {
color: #666;
font-style: italic;
}
/* Mobile optimization */
@media (max-width: 768px) {
/* Keep path info on same line, allow wrapping if needed */
.path-info {
flex-wrap: wrap;
gap: 4px;
margin-bottom: 6px;
}
.path-hash {
font-size: 0.8em;
padding: 3px 6px;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
}
.my-hash {
font-size: 0.8em;
padding: 3px 6px;
}
.route-info {
font-size: 0.85em;
margin-top: 2px;
}
.src-dst-hash {
font-size: 0.8em;
padding: 2px 6px;
background: rgba(78, 201, 176, 0.15);
border-radius: 4px;
border: 1px solid rgba(78, 201, 176, 0.3);
}
/* Better mobile card spacing */
tbody tr {
padding: var(--spacing-md);
}
td[data-label="Path/Hashes"] {
display: block;
width: 100%;
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
td[data-label="Status"] {
display: block;
width: 100%;
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
}
</style>
</body>
</html>

View File

@@ -0,0 +1,934 @@
<!DOCTYPE html>
<html>
<head>
<title>pyMC Repeater - Help</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="/static/style.css">
<style>
.help-container {
display: flex;
gap: 2rem;
max-width: 1400px;
<div class="table-column">
<h4>RSSI</h4>
<div class="column-desc">Received Signal Strength Indicator (dBm)</div>
<div class="column-detail">
Measures signal power. <strong>More negative = weaker signal</strong><br>
<strong>Excellent:</strong> -80 to -100 dBm (strong)<br>
<strong>Good:</strong> -100 to -120 dBm (acceptable)<br>
<strong>Poor:</strong> -120 to -140 dBm (weak, may be unreliable)<br>
<strong>Note:</strong> RSSI is displayed for monitoring but does NOT directly affect packet score calculation. Score is based purely on SNR and packet length, matching the C++ MeshCore algorithm. However, RSSI typically correlates with SNR - better RSSI usually means better SNR.
</div>
</div>: 0 auto;
}
.help-sidebar {
width: 280px;
position: sticky;
top: 20px;
height: fit-content;
}
.help-toc {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
padding: 1.5rem;
font-size: 0.9rem;
}
.help-toc h3 {
margin: 0 0 1rem 0;
font-size: 0.9rem;
text-transform: uppercase;
color: #60a5fa;
font-weight: 600;
letter-spacing: 0.05em;
}
.help-toc ul {
list-style: none;
padding: 0;
margin: 0;
}
.help-toc li {
margin: 0.5rem 0;
}
.help-toc a {
color: #a8b1c3;
text-decoration: none;
display: block;
padding: 0.4rem 0.8rem;
border-radius: 6px;
transition: all 250ms ease;
}
.help-toc a:hover {
color: #60a5fa;
background: rgba(96, 165, 250, 0.1);
padding-left: 1.1rem;
}
.help-toc a.active {
color: #3b82f6;
background: rgba(59, 130, 246, 0.15);
font-weight: 600;
}
.help-content {
flex: 1;
min-width: 0;
}
.help-section {
margin-bottom: 4rem;
scroll-margin-top: 100px;
}
.help-section h2 {
font-size: 1.75rem;
margin: 0 0 1.5rem 0;
color: #f1f3f5;
border-bottom: 2px solid #3b82f6;
padding-bottom: 0.75rem;
}
.help-section h3 {
font-size: 1.2rem;
margin: 2rem 0 1rem 0;
color: #60a5fa;
font-weight: 600;
}
.help-section p {
color: #a8b1c3;
line-height: 1.7;
margin: 0 0 1rem 0;
}
.help-section ul {
color: #a8b1c3;
margin: 1rem 0 1rem 2rem;
line-height: 1.8;
}
.help-section li {
margin: 0.5rem 0;
}
.table-explanation {
background: rgba(59, 130, 246, 0.08);
border-left: 3px solid #3b82f6;
padding: 1.5rem;
border-radius: 8px;
margin: 1.5rem 0;
}
.table-column {
margin: 1.5rem 0;
background: rgba(255, 255, 255, 0.03);
padding: 1.2rem;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.05);
}
.table-column h4 {
margin: 0 0 0.5rem 0;
color: #60a5fa;
font-size: 1rem;
font-weight: 600;
font-family: monospace;
}
.table-column .column-desc {
color: #7d8599;
font-size: 0.9rem;
margin: 0.5rem 0;
}
.table-column .column-detail {
color: #a8b1c3;
font-size: 0.95rem;
line-height: 1.6;
margin-top: 0.5rem;
}
.score-formula {
background: rgba(16, 185, 129, 0.08);
border-left: 3px solid #10b981;
padding: 1.5rem;
border-radius: 8px;
margin: 1.5rem 0;
font-family: monospace;
font-size: 0.95rem;
color: #10b981;
line-height: 1.8;
overflow-x: auto;
}
.warning-box {
background: rgba(245, 158, 11, 0.08);
border-left: 3px solid #f59e0b;
padding: 1.5rem;
border-radius: 8px;
margin: 1.5rem 0;
}
.warning-box strong {
color: #f59e0b;
}
.warning-box p {
color: #a8b1c3;
margin: 0.5rem 0;
}
.info-box {
background: rgba(59, 130, 246, 0.08);
border-left: 3px solid #3b82f6;
padding: 1.5rem;
border-radius: 8px;
margin: 1.5rem 0;
}
.info-box p {
color: #a8b1c3;
margin: 0.5rem 0;
}
.config-impact {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.5rem;
margin: 1.5rem 0;
}
.config-item {
background: rgba(255, 255, 255, 0.03);
padding: 1.2rem;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.05);
}
.config-item h5 {
margin: 0 0 0.5rem 0;
color: #60a5fa;
font-size: 0.95rem;
font-weight: 600;
}
.config-item p {
color: #a8b1c3;
font-size: 0.9rem;
margin: 0;
line-height: 1.6;
}
code {
background: rgba(0, 0, 0, 0.3);
padding: 0.2rem 0.5rem;
border-radius: 4px;
font-family: monospace;
color: #60a5fa;
font-size: 0.9em;
}
.back-to-top {
display: inline-block;
margin-top: 2rem;
padding: 0.75rem 1.5rem;
background: #3b82f6;
color: #fff;
text-decoration: none;
border-radius: 8px;
transition: all 250ms ease;
font-size: 0.9rem;
}
.back-to-top:hover {
background: #60a5fa;
}
@media (max-width: 1024px) {
.help-container {
flex-direction: column;
}
.help-sidebar {
position: relative;
width: 100%;
top: auto;
margin-bottom: 2rem;
}
.config-impact {
grid-template-columns: 1fr;
}
}
</style>
</head>
<body>
<div class="layout">
<!-- Navigation Component -->
<!-- NAVIGATION_PLACEHOLDER -->
<!-- Main Content -->
<main class="content">
<header>
<h1>Help & Documentation</h1>
<p>Learn how to interpret packet data, understand scoring, and optimize your configuration</p>
</header>
<div class="help-container">
<!-- Table of Contents Sidebar -->
<aside class="help-sidebar">
<div class="help-toc">
<h3>Contents</h3>
<ul>
<li><a href="#packet-table" class="toc-link">Packet Table</a></li>
<li><a href="#column-details" class="toc-link">Column Details</a></li>
<li><a href="#scoring-system" class="toc-link">Scoring System</a></li>
<li><a href="#score-factors" class="toc-link">Score Factors</a></li>
<li><a href="#reactive-scoring" class="toc-link">Reactive Scoring</a></li>
<li><a href="#configuration" class="toc-link">Configuration Effects</a></li>
<li><a href="#config-settings" class="toc-link">Config Settings</a></li>
</ul>
</div>
</aside>
<!-- Main Content -->
<div class="help-content">
<!-- Packet Table Section -->
<section id="packet-table" class="help-section">
<h2>Packet Table Overview</h2>
<p>The packet table displays real-time information about every packet your repeater receives and processes. Each row represents a single packet event, showing transmission details, signal quality metrics, and repeater processing information.</p>
<div class="info-box">
<p><strong>Purpose:</strong> The packet table helps you monitor network traffic, diagnose signal issues, and understand how your repeater is handling different types of packets.</p>
</div>
</section>
<!-- Column Details Section -->
<section id="column-details" class="help-section">
<h2>Column Details</h2>
<div class="table-column">
<h4>Time</h4>
<div class="column-desc">Format: HH:MM:SS</div>
<div class="column-detail">
The exact time the packet was received by the radio module. Displayed in 24-hour format. Useful for correlating events with logs and identifying traffic patterns throughout the day.
</div>
</div>
<div class="table-column">
<h4>Type</h4>
<div class="column-desc">Packet payload type identifier</div>
<div class="column-detail">
<strong>ADVERT:</strong> Node advertisement/discovery packets (usually broadcasts)<br>
<strong>ACK:</strong> Acknowledgment responses<br>
<strong>TXT:</strong> Text messages<br>
<strong>GRP:</strong> Group messages<br>
<strong>PATH:</strong> Path information packets<br>
<strong>RESP:</strong> Response packets<br>
<strong>TRACE:</strong> Trace/debug packets<br>
</div>
</div>
<div class="table-column">
<h4>Route</h4>
<div class="column-desc">Routing mode indicator</div>
<div class="column-detail">
<strong>DIRECT:</strong> Packet explicitly routed to this repeater (contains its address in the path)<br>
<strong>FLOOD:</strong> Broadcast packet intended for all nodes in range<br>
DIRECT packets have higher priority since they're specifically addressed to your repeater. FLOOD packets are retransmitted if bandwidth allows.
</div>
</div>
<div class="table-column">
<h4>Length</h4>
<div class="column-desc">Payload size in bytes</div>
<div class="column-detail">
The actual payload data size (not including LoRa overhead). Affects airtime consumption and score calculation. Larger packets take longer to transmit, consuming more airtime budget. Typical range: 20-250 bytes.
</div>
</div>
<div class="table-column">
<h4>RSSI</h4>
<div class="column-desc">Received Signal Strength Indicator (dBm)</div>
<div class="column-detail">
Measures signal power. <strong>More negative = weaker signal</strong><br>
<strong>Excellent:</strong> -80 to -100 dBm (strong)<br>
<strong>Good:</strong> -100 to -120 dBm (acceptable)<br>
<strong>Poor:</strong> -120 to -140 dBm (weak, may be unreliable)<br>
Affects score calculation - better RSSI yields higher scores. Distance and obstacles reduce RSSI.
</div>
</div>
<div class="table-column">
<h4>SNR</h4>
<div class="column-desc">Signal-to-Noise Ratio (dB)</div>
<div class="column-detail">
Measures signal clarity vs. background noise. <strong>Higher = cleaner signal</strong><br>
<strong>Excellent:</strong> SNR > 10 dB (very clean)<br>
<strong>Good:</strong> SNR 5-10 dB (normal operation)<br>
<strong>Poor:</strong> SNR < 5 dB (noisy environment)<br>
Even with weak RSSI, high SNR indicates reliable reception. Critical for score calculation.
</div>
</div>
<div class="table-column">
<h4>Score</h4>
<div class="column-desc">Composite quality metric (0.0 - 1.0)</div>
<div class="column-detail">
A single number representing overall packet quality based on SNR and packet length. This matches the C++ MeshCore algorithm exactly. Higher scores (closer to 1.0) indicate better quality packets with good SNR relative to the spreading factor threshold. Used internally for optional reactive delay optimization (when use_score_for_tx is enabled). See Scoring System section for detailed calculation method.
</div>
</div>
<div class="table-column">
<h4>TX Delay</h4>
<div class="column-desc">Time in milliseconds</div>
<div class="column-detail">
How long the repeater waited before retransmitting. Delay factors include:<br>
• Airtime budget checking<br>
• Random collision avoidance (0-5ms factor)<br>
• Current channel utilization<br>
• Optional quality-based prioritization (when enabled)<br>
Longer delays may indicate congestion or airtime throttling to comply with duty cycle limits.
</div>
</div>
<div class="table-column">
<h4>Status</h4>
<div class="column-desc">Packet processing outcome</div>
<div class="column-detail">
<strong>FORWARDED:</strong> Packet has been successfully retransmitted to other nodes. The repeater forwarded this packet over the air.<br>
<strong>DROPPED:</strong> Packet was rejected and not forwarded.<br>
<br>
<strong>Drop Reasons:</strong>
<ul style="margin: 8px 0; padding-left: 20px; font-size: 0.9em; line-height: 1.6;">
<li><strong>Duplicate:</strong> Packet hash already in cache. Prevents redundant retransmission.</li>
<li><strong>Empty payload:</strong> Packet has no payload data. Cannot be processed.</li>
<li><strong>Path at max size:</strong> Path field has reached maximum length. Cannot add repeater identifier.</li>
<li><strong>Duty cycle limit:</strong> Airtime budget exhausted. Cannot transmit (EU 1% duty cycle or configured limit).</li>
<li><strong>Direct: no path:</strong> Direct-mode packet lacks routing path.</li>
<li><strong>Direct: not our hop:</strong> Direct-mode packet is not addressed to this repeater node.</li>
</ul>
</div>
</div>
</section>
<!-- Scoring System Section -->
<section id="scoring-system" class="help-section">
<h2>Scoring System</h2>
<p>The packet score is calculated using the exact same algorithm as the C++ MeshCore implementation. It combines SNR (relative to spreading factor threshold) and packet length to produce a single quality indicator (0.0 to 1.0). This score can optionally be used for reactive delay optimization when use_score_for_tx is enabled.</p>
<h3>The Scoring Formula</h3>
<div class="score-formula">
<div style="background: var(--color-bg-secondary); border: 1px solid var(--color-border); padding: 20px; border-radius: 8px; margin-bottom: 20px;">
<div style="font-size: 1.2em; font-weight: bold; margin-bottom: 15px; text-align: center; color: var(--color-text-primary);">
Score = SNR Factor × Length Factor
</div>
<table style="width: 100%; border-collapse: collapse; background: var(--color-bg-tertiary); border-radius: 6px; overflow: hidden;">
<tr>
<td style="padding: 12px; border-right: 1px solid var(--color-border); width: 50%;">
<div style="font-size: 0.9em; color: var(--color-text-secondary); margin-bottom: 6px;">SNR Factor</div>
<div style="font-size: 1.1em; font-family: monospace; color: var(--color-accent-primary);">
(SNR - SF<sub>threshold</sub>) / 10
</div>
</td>
<td style="padding: 12px; width: 50%;">
<div style="font-size: 0.9em; color: var(--color-text-secondary); margin-bottom: 6px;">Length Factor</div>
<div style="font-size: 1.1em; font-family: monospace; color: var(--color-accent-primary);">
(1 - length / 256)
</div>
</td>
</tr>
</table>
</div>
<h4 style="margin-top: 20px; margin-bottom: 15px; color: var(--color-text-primary);">Spreading Factor Thresholds</h4>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; margin-bottom: 20px;">
<div style="background: var(--color-bg-tertiary); padding: 12px; border-radius: 6px; border-left: 4px solid var(--color-accent-primary); color: var(--color-text-secondary);">
<strong style="color: var(--color-text-primary);">SF7</strong> → -7.5 dB
</div>
<div style="background: var(--color-bg-tertiary); padding: 12px; border-radius: 6px; border-left: 4px solid var(--color-accent-primary); color: var(--color-text-secondary);">
<strong style="color: var(--color-text-primary);">SF8</strong> → -10.0 dB
</div>
<div style="background: var(--color-bg-tertiary); padding: 12px; border-radius: 6px; border-left: 4px solid var(--color-accent-primary); color: var(--color-text-secondary);">
<strong style="color: var(--color-text-primary);">SF9</strong> → -12.5 dB
</div>
<div style="background: var(--color-bg-tertiary); padding: 12px; border-radius: 6px; border-left: 4px solid var(--color-accent-primary); color: var(--color-text-secondary);">
<strong style="color: var(--color-text-primary);">SF10</strong> → -15.0 dB
</div>
<div style="background: var(--color-bg-tertiary); padding: 12px; border-radius: 6px; border-left: 4px solid var(--color-accent-primary); color: var(--color-text-secondary);">
<strong style="color: var(--color-text-primary);">SF11</strong> → -17.5 dB
</div>
<div style="background: var(--color-bg-tertiary); padding: 12px; border-radius: 6px; border-left: 4px solid var(--color-accent-primary); color: var(--color-text-secondary);">
<strong style="color: var(--color-text-primary);">SF12</strong> → -20.0 dB
</div>
</div>
<h4 style="margin-bottom: 15px; color: var(--color-text-primary);">Real-World Example</h4>
<div style="background: var(--color-bg-tertiary); border-left: 4px solid var(--color-info); padding: 15px; border-radius: 6px;">
<p style="color: var(--color-text-primary);"><strong>Packet Details:</strong></p>
<ul style="margin: 8px 0; padding-left: 20px; color: var(--color-text-secondary);">
<li>SNR: <code style="background: var(--color-bg-secondary); padding: 2px 6px; border-radius: 3px; color: var(--color-accent-primary);">12 dB</code></li>
<li>Spreading Factor: <code style="background: var(--color-bg-secondary); padding: 2px 6px; border-radius: 3px; color: var(--color-accent-primary);">SF8</code></li>
<li>Payload Length: <code style="background: var(--color-bg-secondary); padding: 2px 6px; border-radius: 3px; color: var(--color-accent-primary);">100 bytes</code></li>
</ul>
<hr style="border: none; border-top: 1px solid var(--color-border); margin: 12px 0;">
<p style="margin: 8px 0; color: var(--color-text-primary);"><strong>Calculation:</strong></p>
<div style="background: var(--color-bg-secondary); padding: 12px; border-radius: 4px; font-family: monospace; font-size: 0.95em; color: var(--color-text-secondary);">
SNR Factor = (12 - (-10)) / 10 = 22 / 10 = <strong style="color: var(--color-accent-primary);">2.2</strong> (clamped to 1.0)<br>
Length Factor = (1 - 100/256) = 0.609<br>
<strong style="color: var(--color-accent-primary);">Score = 1.0 × 0.609 = 0.61</strong> (FAIR quality)
</div>
</div>
</div>
<p>This formula ensures that:</p>
<ul>
<li><strong>Signal quality matters:</strong> Higher SNR produces higher scores, with SF-specific thresholds</li>
<li><strong>Smaller packets score higher:</strong> They consume less airtime due to shorter transmission time</li>
<li><strong>Poor SNR packets may score zero:</strong> If SNR falls below SF threshold, score = 0.0</li>
</ul>
<h3>Score Interpretation</h3>
<div style="margin-top: 20px;">
<!-- Visual score scale -->
<div style="margin-bottom: 20px;">
<div style="font-weight: bold; margin-bottom: 8px; color: var(--color-text-primary);">Quality Scale</div>
<div style="background: linear-gradient(90deg, #ef4444 0%, #f97316 25%, #eab308 50%, #84cc16 75%, #22c55e 100%); height: 30px; border-radius: 6px; margin-bottom: 8px; border: 2px solid var(--color-border);"></div>
<div style="display: flex; justify-content: space-between; font-size: 0.85em; color: var(--color-text-secondary);">
<span>0.0</span>
<span>0.25</span>
<span>0.5</span>
<span>0.75</span>
<span>1.0</span>
</div>
</div>
<!-- Score ratings -->
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 12px;">
<div style="background: linear-gradient(135deg, rgba(34, 197, 94, 0.1), rgba(34, 197, 94, 0.05)); border-left: 4px solid #22c55e; padding: 15px; border-radius: 6px;">
<div style="font-weight: bold; color: #22c55e; font-size: 1.1em; margin-bottom: 6px;">0.9 - 1.0 Excellent</div>
<div style="color: #555; font-size: 0.9em;">Perfect conditions, high SNR, small payload</div>
</div>
<div style="background: linear-gradient(135deg, rgba(132, 204, 22, 0.1), rgba(132, 204, 22, 0.05)); border-left: 4px solid #84cc16; padding: 15px; border-radius: 6px;">
<div style="font-weight: bold; color: #84cc16; font-size: 1.1em; margin-bottom: 6px;">0.7 - 0.9 Good</div>
<div style="color: #555; font-size: 0.9em;">Normal operation, acceptable signal</div>
</div>
<div style="background: linear-gradient(135deg, rgba(234, 179, 8, 0.1), rgba(234, 179, 8, 0.05)); border-left: 4px solid #eab308; padding: 15px; border-radius: 6px;">
<div style="font-weight: bold; color: #ca8a04; font-size: 1.1em; margin-bottom: 6px;">0.5 - 0.7 Fair</div>
<div style="color: #555; font-size: 0.9em;">Degraded conditions, lower SNR</div>
</div>
<div style="background: linear-gradient(135deg, rgba(249, 115, 22, 0.1), rgba(249, 115, 22, 0.05)); border-left: 4px solid #f97316; padding: 15px; border-radius: 6px;">
<div style="font-weight: bold; color: #ea580c; font-size: 1.1em; margin-bottom: 6px;">0.3 - 0.5 Poor</div>
<div style="color: #555; font-size: 0.9em;">Marginal conditions, weak signal</div>
</div>
<div style="background: linear-gradient(135deg, rgba(239, 68, 68, 0.1), rgba(239, 68, 68, 0.05)); border-left: 4px solid #ef4444; padding: 15px; border-radius: 6px;">
<div style="font-weight: bold; color: #dc2626; font-size: 1.1em; margin-bottom: 6px;">< 0.3 Very Poor</div>
<div style="color: #555; font-size: 0.9em;">Barely usable, may be dropped</div>
</div>
</div>
</div>
</section>
<!-- Score Factors Section -->
<section id="score-factors" class="help-section">
<h2>What Affects Your Score?</h2>
<h3>Primary Factors</h3>
<div class="table-column">
<h4>Signal-to-Noise Ratio (SNR)</h4>
<div class="column-detail">
<strong>Impact: HIGHEST</strong><br>
Each 1 dB improvement in SNR can increase score by ~0.05. High interference environments significantly reduce scores. The repeater benefits from placement with clear LoS (line of sight) to minimize multipath and fading.
</div>
</div>
<div class="table-column">
<h4>Packet Payload Length</h4>
<div class="column-detail">
<strong>Impact: HIGH</strong><br>
Larger packets consume more airtime due to longer transmission times. A 100-byte packet scores lower than a 50-byte packet with identical SNR.
</div>
<div class="table-column">
<h4>RSSI (Signal Strength)</h4>
<div class="column-detail">
<strong>Impact: NOT USED IN SCORING</strong><br>
RSSI is displayed for monitoring purposes but does NOT affect the score calculation. The C++ MeshCore algorithm uses only SNR and packet length. However, RSSI correlates with SNR - better RSSI typically means better SNR, which indirectly results in higher scores.
</div>
</div>
<h3>Environmental Factors</h3>
<ul>
<li><strong>Weather:</strong> Rain and fog reduce signal strength and increase noise</li>
<li><strong>Time of Day:</strong> Atmospheric conditions change, especially during dawn/dusk</li>
<li><strong>Frequency Congestion:</strong> More devices on 869 MHz = higher noise floor</li>
<li><strong>Physical Obstructions:</strong> Buildings and trees block signals, increase fading</li>
<li><strong>Antenna Orientation:</strong> Poor antenna alignment reduces SNR significantly</li>
</ul>
<div class="warning-box">
<p><strong>Environmental Issues:</strong> If you see consistently low scores across many packets, check your antenna placement, orientation, and surroundings. Poor environmental conditions are often the limiting factor, not the repeater itself.</p>
</div>
</section>
<!-- Reactive Scoring Section -->
<section id="reactive-scoring" class="help-section">
<h2>Reactive Score-Based Delay Optimization</h2>
<p>The repeater includes an optional reactive scoring system that dynamically prioritizes packets based on signal quality during network congestion. This feature matches the C++ MeshCore behavior for intelligent packet prioritization.</p>
<h3>How It Works</h3>
<div class="info-box">
<p><strong>Key Principle:</strong> When the repeater detects congestion (calculated TX delay ≥ 50ms), it automatically applies a quality-based delay multiplier to high-quality packets, giving them priority while gracefully backing off low-quality packets.</p>
<p><strong>Default Behavior:</strong> This feature is <strong>disabled by default</strong> (use_score_for_tx: false). When disabled, all packets follow standard C++ MeshCore delay calculation with pure randomization.</p>
</div>
<h3>Delay Multiplier Formula</h3>
<div class="score-formula">
<div style="background: var(--color-bg-secondary); border: 1px solid var(--color-border); padding: 20px; border-radius: 8px;">
<div style="font-size: 1.1em; font-weight: bold; margin-bottom: 15px; text-align: center; color: var(--color-text-primary);">
Applied Only When: delay ≥ 50ms AND use_score_for_tx = true
</div>
<div style="background: var(--color-bg-tertiary); padding: 15px; border-radius: 6px; font-family: monospace; color: var(--color-accent-primary); line-height: 1.8;">
<strong style="color: var(--color-text-primary);">Delay Multiplier = max(0.2, 1.0 - score)</strong>
</div>
<div style="margin-top: 15px; color: var(--color-text-secondary); font-size: 0.9em;">
<p><strong>What this means:</strong></p>
<ul style="margin: 10px 0; padding-left: 20px;">
<li><strong>Perfect packet (score 1.0):</strong> Multiplier = max(0.2, 0.0) = 0.2 → Gets 20% of base delay (fast priority)</li>
<li><strong>Good packet (score 0.7):</strong> Multiplier = max(0.2, 0.3) = 0.3 → Gets 30% of base delay</li>
<li><strong>Fair packet (score 0.5):</strong> Multiplier = max(0.2, 0.5) = 0.5 → Gets 50% of base delay</li>
<li><strong>Poor packet (score 0.2):</strong> Multiplier = max(0.2, 0.8) = 0.8 → Gets 80% of base delay (slower, backoff)</li>
<li><strong>Minimum floor:</strong> No packet gets less than 20% multiplier (prevents starvation)</li>
</ul>
</div>
</div>
</div>
<h3>Example: Reactive Scoring in Action</h3>
<div class="info-box">
<p><strong>Scenario:</strong> Two packets arrive during congestion (base delay 100ms), tx_delay_factor=1.0</p>
<ul>
<li><strong>Packet X:</strong> Excellent signal, score = 0.9</li>
<li><strong>Packet Y:</strong> Weak signal, score = 0.4</li>
</ul>
<p><strong>Without Reactive Scoring (disabled):</strong></p>
<ul>
<li>Packet X: TX Delay = 0-500ms (pure random collision avoidance)</li>
<li>Packet Y: TX Delay = 0-500ms (pure random collision avoidance)</li>
<li>Result: Both may transmit at same time, causing collision</li>
</ul>
<p><strong>With Reactive Scoring (enabled, congestion detected):</strong></p>
<ul>
<li>Packet X: Multiplier = 0.1 → TX Delay = 0-50ms (high priority, transmits first)</li>
<li>Packet Y: Multiplier = 0.6 → TX Delay = 0-300ms (lower priority, waits longer)</li>
<li>Result: High-quality packets forward with minimal delay; marginal packets gracefully back off</li>
</ul>
</div>
<h3>Configuration</h3>
<div class="table-column">
<h4>use_score_for_tx</h4>
<div class="column-desc">Enable/disable reactive score-based delay optimization</div>
<div class="column-detail">
<strong>Default:</strong> false (disabled)<br>
<strong>Options:</strong> true or false<br>
<strong>When true:</strong> Activates quality-based delay multiplier when congestion detected (delay ≥ 50ms)<br>
<strong>When false:</strong> Standard C++ MeshCore behavior, pure random delays, no score influence on timing<br>
<strong>Location in config.yaml:</strong>
<div style="background: var(--color-bg-secondary); padding: 12px; border-radius: 4px; margin-top: 8px; font-family: monospace; font-size: 0.9em;">
repeater:<br>
&nbsp;&nbsp;use_score_for_tx: false
</div>
</div>
</div>
<div class="table-column">
<h4>score_threshold</h4>
<div class="column-desc">Reserved for future enhancement / statistics monitoring</div>
<div class="column-detail">
<strong>Default:</strong> 0.3<br>
<strong>Range:</strong> 0.0 - 1.0<br>
<strong>Current Status:</strong> This value is read from config but <strong>not currently used</strong> in packet processing. It is reserved for future features.<br>
<strong>Future Potential Uses:</strong>
<ul style="margin: 8px 0; padding-left: 20px;">
<li>Dashboard quality alerts when average packet score drops below threshold</li>
<li>Proactive packet filtering - dropping very poor quality packets upfront (below threshold)</li>
<li>Quality monitoring and trend statistics in web UI</li>
<li>Logging alerts for poor signal conditions</li>
</ul>
<strong>Recommendation:</strong> Leave at default (0.3). Changing it currently has <strong>no effect on packet processing</strong>. This setting will become active once future quality monitoring features are implemented.
</div>
</div>
<h3>When to Enable Reactive Scoring</h3>
<div class="config-impact">
<div class="config-item">
<h5>Enable (use_score_for_tx: true)</h5>
<p>
• High-traffic networks where collisions are frequent<br>
• Noisy environments with poor average signal quality<br>
• You want to prioritize high-quality packets during congestion<br>
• Testing adaptive network behavior<br>
• Duty-cycle constrained regions (EU) with limited bandwidth
</p>
</div>
<div class="config-item">
<h5>Disable (use_score_for_tx: false)</h5>
<p>
• Low-traffic networks where congestion is rare<br>
• You want pure C++ MeshCore compatibility<br>
• Consistent delay behavior is more important than efficiency<br>
• New deployments - start simple and tune later<br>
</p>
</div>
</div>
<div class="warning-box">
<p><strong>Important:</strong> Reactive scoring only affects TX delay timing, not packet forwarding decisions. All packets still get forwarded (unless dropped for other reasons like duplicates or duty cycle). The system gracefully prioritizes quality during congestion without dropping packets, matching MeshCore's intelligent backpressure strategy.</p>
</div>
</section>
<h2>Configuration Impact on Scoring</h2>
<p>Your repeater's configuration settings directly affect packet scoring and processing behavior.</p>
<h3>Radio Configuration Parameters</h3>
<div class="config-impact">
<div class="config-item">
<h5>Spreading Factor (SF)</h5>
<p><strong>Current setting:</strong> SF 8<br>
<strong>Higher SF (9-12):</strong> Better range and SNR, but slower transmission, more airtime consumed<br>
<strong>Lower SF (7):</strong> Faster transmission, less airtime, but worse sensitivity and range<br>
<strong>Score impact:</strong> Higher SF generally improves SNR = higher scores, but increases payload duration penalty</p>
</div>
<div class="config-item">
<h5>Bandwidth (BW)</h5>
<p><strong>Current setting:</strong> 62.5 kHz<br>
<strong>Wider BW (125 kHz):</strong> Faster data rate, less airtime per byte, but worse sensitivity<br>
<strong>Narrower BW (31.25 kHz):</strong> Better sensitivity, but slower transmission<br>
<strong>Score impact:</strong> BW affects SNR - narrower = potentially better SNR but longer TX times</p>
</div>
<div class="config-item">
<h5>TX Power</h5>
<p><strong>Current setting:</strong> 14 dBm<br>
<strong>Higher power:</strong> Better outbound range, but may increase noise at nearby receivers<br>
<strong>Lower power:</strong> Reduces interference, saves energy, but limits outbound range<br>
<strong>Score impact:</strong> TX power only affects outgoing transmissions, not received score</p>
</div>
<div class="config-item">
<h5>Coding Rate (CR)</h5>
<p><strong>Current setting:</strong> 4/8<br>
<strong>Higher CR (4/7):</strong> Less error correction, faster transmission, more airtime efficient<br>
<strong>Lower CR (4/8):</strong> More error correction, better resilience to interference<br>
<strong>Score impact:</strong> Higher CR can improve SNR in clean environments, reduce it in noisy ones</p>
</div>
</div>
<h3>Duty Cycle Configuration</h3>
<div class="table-explanation">
<p><strong>Current Duty Cycle Limit:</strong> 6% max airtime per hour</p>
<p>This means your repeater can spend at most 3.6 minutes (21.6 seconds per minute) transmitting per hour. How this affects packet handling:</p>
<ul>
<li><strong>When below limit:</strong> All packets retransmitted if they pass validation</li>
<li><strong>When approaching limit:</strong> Incoming packets may be dropped if airtime budget is exhausted</li>
<li><strong>When limit reached:</strong> All new transmissions are dropped until the duty cycle budget resets (each minute)</li>
</ul>
<p><strong>Important:</strong> The repeater does NOT queue packets for later transmission. When duty cycle limit is reached, packets are immediately dropped. This is by design - a repeater must forward immediately or drop the packet. Note: Packet score does not affect duty cycle enforcement - all packets are treated equally when duty cycle limit is reached.</p>
</div>
<h3>Airtime Consumption Example</h3>
<div class="info-box">
<p><strong>Scenario:</strong> 100-byte packet at SF8, BW 62.5 kHz, CR 4/8<br>
<strong>Airtime:</strong> ~512 ms<br>
<strong>At 6% duty cycle:</strong> Can transmit ~420 packets/hour maximum<br>
<strong>Effect on score:</strong> High volume of large packets will consume budget quickly, causing lower-scored packets to be dropped
</p>
</div>
</section>
<!-- Configuration Settings Section -->
<section id="config-settings" class="help-section">
<h2>Configuration Settings Reference</h2>
<p>The repeater is configured via <code>config.yaml</code>. This section explains key settings and how they affect packet performance.</p>
<div class="info-box">
<p><strong>Important:</strong> Packet <strong>Score</strong> (signal quality) and <strong>TX Delay</strong> (collision avoidance timing) are independent systems. Score is calculated from SNR and packet length. Delays are configured via tx_delay_factor and direct_tx_delay_factor and are based on airtime, not signal quality.</p>
</div>
<h3>Delay Settings</h3>
<div class="table-column">
<h4>tx_delay_factor</h4>
<div class="column-desc">Flood mode transmission delay multiplier</div>
<div class="column-detail">
<strong>Default:</strong> 1.0<br>
<strong>Purpose:</strong> Scales the base collision-avoidance delay for flood packets.<br>
<strong>Formula:</strong> delay = random(0-5) × (airtime × 52/50 ÷ 2) × tx_delay_factor<br>
<strong>Effect:</strong> Higher values = longer delays between flood packet retransmissions, reducing collisions but increasing latency. Lower values speed up propagation in low-traffic areas.<br>
<strong>Typical range:</strong> 0.5 - 2.0 (0.5 = faster, 2.0 = collision-resistant)
</div>
</div>
<div class="table-column">
<h4>direct_tx_delay_factor</h4>
<div class="column-desc">Direct mode transmission delay (in seconds)</div>
<div class="column-detail">
<strong>Default:</strong> 0.5 seconds<br>
<strong>Purpose:</strong> Fixed delay for direct-routed packets (packets specifically addressed to this repeater).<br>
<strong>Effect:</strong> Direct packets wait this many seconds before retransmission. Direct packets bypass the collision-avoidance algorithm and use a fixed delay instead.<br>
<strong>Note:</strong> Typically lower than flood delays to prioritize DIRECT packets. 0 = immediate forwarding.<br>
<strong>Typical range:</strong> 0 - 2.0 seconds
</div>
</div>
<h3>How TX Delay is Calculated</h3>
<p>The TX Delay shown in the packet table follows the MeshCore C++ implementation for collision avoidance:</p>
<div class="info-box">
<p><strong>For FLOOD packets (broadcast):</strong><br>
TX Delay = random(0 to 5) × (airtime_ms × 52/50 ÷ 2) × tx_delay_factor ÷ 1000<br><br>
<strong>For DIRECT packets (addressed to this repeater):</strong><br>
TX Delay = direct_tx_delay_factor (fixed, in seconds)<br><br>
<strong>Optional Reactive Scoring:</strong><br>
If use_score_for_tx is enabled AND delay ≥ 50ms:<br>
TX Delay = base_delay × max(0.2, 1.0 - packet_score)<br>
This applies a quality-based multiplier during congestion: high-score packets get shorter delays (priority), low-score packets get longer delays (backoff).<br><br>
<strong>Example:</strong> FLOOD packet with 100ms airtime, tx_delay_factor=1.0, score=0.8:<br>
• Base delay = (100 × 52/50 ÷ 2) = 52 ms<br>
• With random(0-5) multiplier: 0-260 ms (before score adjustment)<br>
• If ≥50ms AND score adjustment active: 0-260ms × max(0.2, 1.0-0.8) = 0-260ms × 0.2 = <strong>0-52ms</strong> (prioritized)<br><br>
<strong>Tuning:</strong> Increase tx_delay_factor in high-traffic areas to reduce collisions. Decrease in low-traffic areas for faster propagation. Enable use_score_for_tx for intelligent priority during congestion. Direct packets bypass randomization and use fixed delays.
</p>
</div>
<h3>Duty Cycle Constraints</h3>
<div class="table-column">
<h4>max_airtime_per_minute</h4>
<div class="column-desc">Maximum transmission time per minute in milliseconds</div>
<div class="column-detail">
<strong>Common values:</strong><br>
<code>3600 ms/min</code> = 100% duty cycle (US/AU FCC, no restriction)<br>
<code>36 ms/min</code> = 1% duty cycle (EU ETSI standard)<br>
<code>360 ms/min</code> = 10% duty cycle (compromise for EU testing)<br><br>
<strong>Effect on packet handling:</strong> Duty cycle enforcement is <strong>independent of packet score</strong>. When duty cycle limit is reached, ALL packets are dropped equally - regardless of signal quality. The system does not prioritize high-score packets; it simply refuses to transmit until the budget resets.<br>
<strong>TX Delay impact:</strong> TX Delay shown in the packet table is unaffected by duty cycle limits. However, packets may be completely blocked (dropped) when airtime budget is exhausted. There is no queuing or delay-until-later mechanism - dropped packets are lost immediately.<br>
<strong>Packet distribution during high traffic:</strong> When approaching or exceeding duty cycle limits (>80%), incoming packets are dropped indiscriminately based on airtime availability. The mean packet score will fluctuate based on random traffic mix, not because the system prefers high-score packets. All packets have equal probability of being dropped when budget is exhausted.
</div>
</div>
<h3>How These Work Together</h3>
<div class="info-box">
<p><strong>Example Scenario - Packet Forwarding with Delay:</strong></p>
<p>You receive 3 packets with different routes and sizes (tx_delay_factor=1.0, direct_tx_delay_factor=0.5s):</p>
<ul>
<li><strong>Packet A:</strong> Route DIRECT, 50 bytes → TX Delay = 0.5 seconds (fixed)</li>
<li><strong>Packet B:</strong> Route FLOOD, 100 bytes → TX Delay = random(0-5) × 52ms × 1.0 = 0-260 ms</li>
<li><strong>Packet C:</strong> Route FLOOD, 150 bytes → TX Delay = random(0-5) × 78ms × 1.0 = 0-390 ms</li>
</ul>
<p><strong>Processing order (without duty cycle limits):</strong></p>
<ul>
<li>Packet A: Waits 0.5s, then forwards (direct packets get fixed priority)</li>
<li>Packets B & C: Random delays prevent collision, lower packet transmitted first if random lucky</li>
</ul>
<p><strong>If duty cycle ~95% full:</strong> Still forwards all three, but with increased TX delays. If insufficient airtime remains for a packet, it is dropped immediately (not queued)</p>
</div>
<h3>Optimization Tips</h3>
<ul>
<li><strong>For high-traffic/interference:</strong> Increase <code>tx_delay_factor</code> to 1.5-2.0 to reduce collisions with more randomization</li>
<li><strong>For low-traffic areas:</strong> Decrease <code>tx_delay_factor</code> to 0.5 for faster propagation</li>
<li><strong>For priority direct packets:</strong> Lower <code>direct_tx_delay_factor</code> below 0.5s for faster handling</li>
<li><strong>For duty-cycle constrained regions (EU):</strong> Keep default settings; airtime budget enforces fairness</li>
<li><strong>Monitor TX Delay column:</strong> Increasing delays indicate network congestion or approaching duty cycle limits</li>
</ul>
</section>
<a href="#packet-table" class="back-to-top">↑ Back to Top</a>
</div>
</div>
</main>
</div>
<script>
// Table of contents active link highlighting
const tocLinks = document.querySelectorAll('.toc-link');
const sections = document.querySelectorAll('.help-section');
function updateActiveTocLink() {
let current = '';
sections.forEach(section => {
const sectionTop = section.offsetTop;
const sectionHeight = section.clientHeight;
const scrollPosition = window.scrollY + 150;
if (scrollPosition >= sectionTop && scrollPosition < sectionTop + sectionHeight) {
current = section.getAttribute('id');
}
});
tocLinks.forEach(link => {
link.classList.remove('active');
if (link.getAttribute('href') === `#${current}`) {
link.classList.add('active');
}
});
}
window.addEventListener('scroll', updateActiveTocLink);
updateActiveTocLink();
// Smooth scroll for anchor links
tocLinks.forEach(link => {
link.addEventListener('click', (e) => {
e.preventDefault();
const targetId = link.getAttribute('href');
const targetSection = document.querySelector(targetId);
if (targetSection) {
targetSection.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
});
});
</script>
</body>
</html>

View File

@@ -0,0 +1,238 @@
<!DOCTYPE html>
<html>
<head>
<title>pyMC Repeater - Logs</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<div class="layout">
<!-- Navigation Component -->
<!-- NAVIGATION_PLACEHOLDER -->
<!-- Main Content -->
<main class="content">
<header>
<h1>System Logs</h1>
<p>Real-time system events and diagnostics</p>
</header>
<!-- Filter Controls -->
<div style="margin-bottom: 20px; padding: 15px; background: var(--color-bg-secondary); border-radius: 8px; border: 1px solid var(--color-border);">
<div style="display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin-bottom: 15px;">
<label style="font-weight: bold; margin: 0; width: 100%;">Logger Filters:</label>
<button id="selectAllBtn" style="flex: 1; min-width: 100px; padding: 8px 12px; background: var(--color-accent-primary); color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 0.9em;">Select All</button>
<button id="clearAllBtn" style="flex: 1; min-width: 100px; padding: 8px 12px; background: var(--color-accent-secondary); color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 0.9em;">Clear All</button>
</div>
<div id="filterContainer" style="display: flex; flex-wrap: wrap; gap: 8px;">
<!-- Filters will be added here dynamically -->
</div>
</div>
<!-- Mock log data - will be replaced with real logs via API -->
<div class="log-container" id="logs">
<div class="log-line">
<span class="log-time">[Loading...]</span>
<span class="log-level info">INFO</span>
<span class="log-msg">Fetching system logs...</span>
</div>
</div>
</main>
</div>
<script>
// Track filter state and all logs
let allLogs = [];
let enabledLoggers = new Set();
let allLoggers = new Set();
// Fetch logs from API on page load
async function loadLogs() {
try {
const response = await fetch('/api/logs');
const data = await response.json();
if (data.logs && data.logs.length > 0) {
allLogs = data.logs;
// Extract all unique logger names
const newLoggers = new Set();
allLogs.forEach(log => {
const loggerName = extractLoggerName(log.message);
newLoggers.add(loggerName);
});
// On first load, enable all detected loggers
if (enabledLoggers.size === 0) {
enabledLoggers = new Set(newLoggers);
}
// Check if logger set has changed
const loggersChanged = !setsEqual(allLoggers, newLoggers);
// Update allLoggers with currently active loggers only
allLoggers = newLoggers;
// Only update filter UI if the set of loggers changed
if (loggersChanged) {
updateFilterUI();
}
// Always display filtered logs (but don't rebuild filters)
displayLogs();
}
} catch (error) {
console.error('Error loading logs:', error);
const logsContainer = document.getElementById('logs');
logsContainer.innerHTML = `
<div class="log-line">
<span class="log-time">[Error]</span>
<span class="log-level error">ERROR</span>
<span class="log-msg">Failed to load logs: ${escapeHtml(error.message)}</span>
</div>
`;
}
}
// Helper to compare two sets
function setsEqual(set1, set2) {
if (set1.size !== set2.size) return false;
for (let item of set1) {
if (!set2.has(item)) return false;
}
return true;
}
// Extract logger name from log message (e.g., "RepeaterDaemon", "HTTPServer", etc.)
function extractLoggerName(message) {
// Format: "2025-10-22 12:47:30,270 - LoggerName - LEVEL - message"
const match = message.match(/- (\w+) -/);
return match ? match[1] : 'Unknown';
}
// Update filter UI with detected loggers
function updateFilterUI() {
const filterContainer = document.getElementById('filterContainer');
const sortedLoggers = Array.from(allLoggers).sort();
// Clear existing buttons
filterContainer.innerHTML = '';
sortedLoggers.forEach(logger => {
const button = document.createElement('button');
button.className = 'filter-btn';
button.dataset.logger = logger;
button.textContent = logger;
button.style.cssText = `
padding: 8px 14px;
border: 2px solid var(--color-border);
background: var(--color-bg-tertiary);
color: var(--color-text-primary);
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
font-size: 0.9em;
`;
// Set active state based on enabledLoggers
if (enabledLoggers.has(logger)) {
button.style.background = 'var(--color-accent-primary)';
button.style.color = 'white';
button.style.borderColor = 'var(--color-accent-primary)';
}
button.addEventListener('click', () => {
if (enabledLoggers.has(logger)) {
enabledLoggers.delete(logger);
} else {
enabledLoggers.add(logger);
}
updateFilterUI();
displayLogs();
});
filterContainer.appendChild(button);
});
}
// Display logs filtered by enabled loggers
function displayLogs() {
const logsContainer = document.getElementById('logs');
logsContainer.innerHTML = '';
allLogs.forEach(log => {
const loggerName = extractLoggerName(log.message);
// Skip if logger is not enabled
if (!enabledLoggers.has(loggerName)) {
return;
}
const logLine = document.createElement('div');
logLine.className = 'log-line';
// Try to parse timestamp
const timestamp = new Date(log.timestamp);
const timeStr = timestamp.toLocaleTimeString('en-US', {
hour12: false,
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
// Get log level from API response or parse from message
let level = log.level || 'INFO';
let levelClass = level.toLowerCase();
logLine.innerHTML = `
<span class="log-time">[${timeStr}]</span>
<span class="log-level ${levelClass}">${level}</span>
<span class="log-msg">${escapeHtml(log.message || '')}</span>
`;
logsContainer.appendChild(logLine);
});
// Auto-scroll to bottom
logsContainer.scrollTop = logsContainer.scrollHeight;
}
// Helper function to escape HTML
function escapeHtml(text) {
const map = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;'
};
return text.replace(/[&<>"']/g, m => map[m]);
}
// Setup button event listeners
function setupButtons() {
document.getElementById('selectAllBtn').addEventListener('click', () => {
enabledLoggers = new Set(allLoggers);
updateFilterUI();
displayLogs();
});
document.getElementById('clearAllBtn').addEventListener('click', () => {
enabledLoggers.clear();
updateFilterUI();
displayLogs();
});
}
// Load logs on page load
document.addEventListener('DOMContentLoaded', () => {
setupButtons();
loadLogs();
});
// Refresh logs every 5 seconds
setInterval(loadLogs, 5000);
</script>
</body>
</html>

432
repeater/templates/nav.html Normal file
View File

@@ -0,0 +1,432 @@
<!-- Shared Navigation Component -->
<aside class="sidebar" id="sidebar">
<div class="sidebar-header">
<h1>pyMC Repeater</h1>
<button class="menu-toggle" id="menu-toggle" aria-label="Toggle menu">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="3" y1="6" x2="21" y2="6"></line>
<line x1="3" y1="12" x2="21" y2="12"></line>
<line x1="3" y1="18" x2="21" y2="18"></line>
</svg>
</button>
<div class="node-name">Node: {{ node_name }}</div>
<div class="node-pubkey">&lt;{{ pub_key }}&gt;</div>
</div>
<div class="sidebar-content-wrapper">
<nav class="sidebar-nav">
<div class="nav-section">
<div class="nav-section-title">Actions</div>
<button id="send-advert-btn" class="nav-item nav-action">
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"></circle>
<polyline points="12 6 12 12 16 14"></polyline>
</svg>
Send Advert
</button>
</div>
<div class="nav-section">
<div class="nav-section-title">Monitoring</div>
<a href="/" class="nav-item{{ ' active' if page == 'dashboard' else '' }}">
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="7" height="7"></rect>
<rect x="14" y="3" width="7" height="7"></rect>
<rect x="14" y="14" width="7" height="7"></rect>
<rect x="3" y="14" width="7" height="7"></rect>
</svg>
Dashboard
</a>
<a href="/neighbors" class="nav-item{{ ' active' if page == 'neighbors' else '' }}">
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path>
<circle cx="9" cy="7" r="4"></circle>
<path d="M23 21v-2a4 4 0 0 0-3-3.87"></path>
<path d="M16 3.13a4 4 0 0 1 0 7.75"></path>
</svg>
Neighbors
</a>
<a href="/statistics" class="nav-item{{ ' active' if page == 'statistics' else '' }}">
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<line x1="12" y1="2" x2="12" y2="22"></line>
<path d="M17 8v12"></path>
<path d="M7 14v6"></path>
</svg>
Statistics
</a>
</div>
<div class="nav-section">
<div class="nav-section-title">System</div>
<a href="/configuration" class="nav-item{{ ' active' if page == 'configuration' else '' }}">
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 1 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"></path>
</svg>
Configuration
</a>
<a href="/logs" class="nav-item{{ ' active' if page == 'logs' else '' }}">
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
<polyline points="14 2 14 8 20 8"></polyline>
<line x1="12" y1="13" x2="16" y2="13"></line>
<line x1="12" y1="17" x2="16" y2="17"></line>
</svg>
Logs
</a>
<a href="/help" class="nav-item{{ ' active' if page == 'help' else '' }}">
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"></circle>
<path d="M12 16v-4"></path>
<path d="M12 8h.01"></path>
</svg>
Help
</a>
</div>
</nav>
<div class="sidebar-footer">
<div style="display: flex; gap: 8px; align-items: center; margin-bottom: var(--spacing-md);">
<div class="status-badge" id="status-badge" title="System operational status">Online</div>
<div class="version-badge" id="version-badge" title="Software version">v1.0.0</div>
</div>
<!-- Mode Toggle Buttons -->
<div class="control-buttons">
<button class="control-btn" id="mode-toggle-btn" title="Toggle between Forward and Monitor modes">
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="17 1 21 5 17 9"></polyline>
<path d="M3 11V9a4 4 0 0 1 4-4h14"></path>
<polyline points="7 23 3 19 7 15"></polyline>
<path d="M21 13v2a4 4 0 0 1-4 4H3"></path>
</svg>
<span class="control-label">
<span class="control-title">Mode</span>
<span class="control-value" id="mode-status">Forward</span>
</span>
</button>
<button class="control-btn" id="duty-cycle-toggle-btn" title="Toggle duty cycle enforcement">
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 12h-4l-3 9L9 3l-3 9H2"></path>
</svg>
<span class="control-label">
<span class="control-title">Duty Cycle</span>
<span class="control-value" id="duty-cycle-status">Enabled</span>
</span>
</button>
</div>
<div class="duty-cycle-stats">
<div class="duty-cycle-bar-container">
<div class="duty-cycle-bar" id="duty-cycle-bar"></div>
</div>
<small class="duty-cycle-text">
Duty Cycle: <strong id="duty-utilization">0.0%</strong> / <span id="duty-max">10.0%</span>
</small>
</div>
<div style="display: flex; justify-content: space-between; align-items: center; margin-top: 8px;">
<small>Last updated: <span id="footer-update-time">{{ last_updated }}</span></small>
<a href="https://github.com/rightup" target="_blank" class="github-link" title="GitHub">
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
</svg>
</a>
</div>
</div>
</div> <!-- Close sidebar-content-wrapper -->
</aside>
<style>
/* GitHub link styling */
.github-link {
display: inline-flex;
align-items: center;
color: #d4d4d4;
text-decoration: none;
transition: color 0.2s, transform 0.2s;
}
.github-link:hover {
color: #4ec9b0;
transform: scale(1.1);
}
.github-link svg {
display: block;
}
</style>
<script>
// Mobile menu toggle
const menuToggle = document.getElementById('menu-toggle');
const sidebar = document.getElementById('sidebar');
if (menuToggle) {
menuToggle.addEventListener('click', () => {
sidebar.classList.toggle('menu-open');
// Prevent body scroll when menu is open on mobile
if (sidebar.classList.contains('menu-open')) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = '';
}
});
// Close menu when clicking nav items
const navItems = sidebar.querySelectorAll('.nav-item, .nav-action');
navItems.forEach(item => {
item.addEventListener('click', () => {
sidebar.classList.remove('menu-open');
document.body.style.overflow = '';
});
});
}
// Update footer stats periodically
function updateFooterStats() {
fetch('/api/stats')
.then(r => r.json())
.then(data => {
// Update version badge
if (data.version) {
document.getElementById('version-badge').textContent = 'v' + data.version;
}
// Update duty cycle
const utilization = data.utilization_percent || 0;
const maxPercent = data.config?.duty_cycle?.max_airtime_percent || 10;
document.getElementById('duty-utilization').textContent = utilization.toFixed(1) + '%';
document.getElementById('duty-max').textContent = maxPercent.toFixed(1) + '%';
// Update progress bar
const percentage = Math.min((utilization / maxPercent) * 100, 100);
const bar = document.getElementById('duty-cycle-bar');
bar.style.width = percentage + '%';
// Set minimum width so it's always visible
if (percentage === 0) {
bar.style.width = '100%';
bar.style.backgroundColor = '#4ade80'; // Green - plenty of capacity
} else {
// Color code the bar based on usage
if (percentage > 90) {
bar.style.backgroundColor = '#f48771'; // Red - critical
} else if (percentage > 70) {
bar.style.backgroundColor = '#dcdcaa'; // Yellow - warning
} else {
bar.style.backgroundColor = '#4ade80'; // Green - good
}
}
// Update control button states from config
const mode = data.config?.repeater?.mode || 'forward';
const dutyCycleEnabled = data.config?.duty_cycle?.enforcement_enabled !== false;
// Update status badge based on mode and duty cycle
const statusBadge = document.getElementById('status-badge');
if (mode === 'monitor') {
statusBadge.textContent = 'Monitor Mode';
statusBadge.style.backgroundColor = '#d97706'; // Orange for monitor
statusBadge.style.color = '#ffffff'; // White text
statusBadge.title = 'Monitoring only - not forwarding packets';
} else if (!dutyCycleEnabled) {
statusBadge.textContent = 'No Limits';
statusBadge.style.backgroundColor = '#dc2626'; // Red for unlimited
statusBadge.style.color = '#ffffff'; // White text
statusBadge.title = 'Forwarding without duty cycle enforcement';
} else {
statusBadge.textContent = 'Active';
statusBadge.style.backgroundColor = '#10b981'; // Green for normal
statusBadge.style.color = '#ffffff'; // White text
statusBadge.title = 'Forwarding with duty cycle enforcement';
}
document.getElementById('mode-status').textContent =
mode.charAt(0).toUpperCase() + mode.slice(1);
document.getElementById('duty-cycle-status').textContent =
dutyCycleEnabled ? 'Enabled' : 'Disabled';
// Update button states
const modeBtn = document.getElementById('mode-toggle-btn');
const dutyBtn = document.getElementById('duty-cycle-toggle-btn');
if (mode === 'monitor') {
modeBtn.classList.add('control-btn-warning');
modeBtn.classList.remove('control-btn-active');
} else {
modeBtn.classList.add('control-btn-active');
modeBtn.classList.remove('control-btn-warning');
}
if (!dutyCycleEnabled) {
dutyBtn.classList.add('control-btn-warning');
dutyBtn.classList.remove('control-btn-active');
} else {
dutyBtn.classList.add('control-btn-active');
dutyBtn.classList.remove('control-btn-warning');
}
// Update timestamp
document.getElementById('footer-update-time').textContent = new Date().toLocaleTimeString();
})
.catch(e => console.error('Error updating footer stats:', e));
}
// Handle Send Advert button - works on all pages
function sendAdvert() {
const btn = document.getElementById('send-advert-btn');
if (!btn) return;
const icon = btn.querySelector('.icon');
const iconHTML = icon ? icon.outerHTML : '';
btn.disabled = true;
btn.innerHTML = iconHTML + 'Sending...';
fetch('/api/send_advert', {
method: 'POST'
})
.then(r => r.json())
.then(data => {
if (data.success) {
btn.innerHTML = iconHTML + 'Sent!';
setTimeout(() => {
btn.innerHTML = iconHTML + 'Send Advert';
btn.disabled = false;
}, 2000);
} else {
btn.innerHTML = iconHTML + 'Error';
console.error('Failed to send advert:', data.error);
setTimeout(() => {
btn.innerHTML = iconHTML + 'Send Advert';
btn.disabled = false;
}, 2000);
}
})
.catch(e => {
console.error('Error sending advert:', e);
btn.innerHTML = iconHTML + 'Error';
setTimeout(() => {
btn.innerHTML = iconHTML + 'Send Advert';
btn.disabled = false;
}, 2000);
});
}
// Mode toggle handler
function toggleMode() {
const btn = document.getElementById('mode-toggle-btn');
const statusText = document.getElementById('mode-status');
const currentMode = statusText.textContent.toLowerCase();
const newMode = currentMode === 'forward' ? 'monitor' : 'forward';
btn.disabled = true;
statusText.textContent = 'Changing...';
fetch('/api/set_mode', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mode: newMode })
})
.then(r => r.json())
.then(data => {
if (data.success) {
statusText.textContent = newMode.charAt(0).toUpperCase() + newMode.slice(1);
updateFooterStats(); // Refresh to get updated state
} else {
statusText.textContent = currentMode.charAt(0).toUpperCase() + currentMode.slice(1);
alert('Failed to change mode: ' + (data.error || 'Unknown error'));
}
})
.catch(e => {
console.error('Error toggling mode:', e);
statusText.textContent = currentMode.charAt(0).toUpperCase() + currentMode.slice(1);
alert('Failed to change mode');
})
.finally(() => {
btn.disabled = false;
});
}
// Duty cycle toggle handler
function toggleDutyCycle() {
const btn = document.getElementById('duty-cycle-toggle-btn');
const statusText = document.getElementById('duty-cycle-status');
const currentEnabled = statusText.textContent === 'Enabled';
const newEnabled = !currentEnabled;
btn.disabled = true;
statusText.textContent = 'Changing...';
fetch('/api/set_duty_cycle', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: newEnabled })
})
.then(r => r.json())
.then(data => {
if (data.success) {
statusText.textContent = newEnabled ? 'Enabled' : 'Disabled';
updateFooterStats(); // Refresh to get updated state
} else {
statusText.textContent = currentEnabled ? 'Enabled' : 'Disabled';
alert('Failed to change duty cycle: ' + (data.error || 'Unknown error'));
}
})
.catch(e => {
console.error('Error toggling duty cycle:', e);
statusText.textContent = currentEnabled ? 'Enabled' : 'Disabled';
alert('Failed to change duty cycle');
})
.finally(() => {
btn.disabled = false;
});
}
// Update immediately and then every 5 seconds
document.addEventListener('DOMContentLoaded', () => {
updateFooterStats();
setInterval(updateFooterStats, 5000);
// Attach toggle button handlers
const modeBtn = document.getElementById('mode-toggle-btn');
const dutyBtn = document.getElementById('duty-cycle-toggle-btn');
const sendAdvertBtn = document.getElementById('send-advert-btn');
if (modeBtn) {
modeBtn.addEventListener('click', toggleMode);
}
if (dutyBtn) {
dutyBtn.addEventListener('click', toggleDutyCycle);
}
// Attach send advert button handler - works on all pages
if (sendAdvertBtn) {
sendAdvertBtn.addEventListener('click', sendAdvert);
}
});
// Add data-label attributes to table cells for mobile display
function initMobileTableLabels() {
const tables = document.querySelectorAll('table');
tables.forEach(table => {
const headers = [];
// Get all header text
table.querySelectorAll('thead th').forEach(th => {
headers.push(th.textContent.trim());
});
// Add data-label to each cell
table.querySelectorAll('tbody td').forEach((td, index) => {
const headerIndex = index % headers.length;
if (headers[headerIndex]) {
td.setAttribute('data-label', headers[headerIndex]);
}
});
});
}
document.addEventListener('DOMContentLoaded', initMobileTableLabels);
</script>

View File

@@ -0,0 +1,395 @@
<!DOCTYPE html>
<html>
<head>
<title>pyMC Repeater - Neighbors</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<div class="layout">
<!-- Navigation Component -->
<!-- NAVIGATION_PLACEHOLDER -->
<!-- Main Content -->
<main class="content">
<header>
<h1>Neighbor Repeaters</h1>
<div class="header-info">
<span>Tracking: <strong id="neighbor-count">0</strong> repeaters</span>
<span>Updated: <strong id="update-time">{{ last_updated }}</strong></span>
</div>
</header>
<!-- Neighbors Table -->
<div class="table-card">
<h2>Discovered Repeaters</h2>
<div class="table-container">
<table class="data-table">
<thead>
<tr>
<th>Node Name</th>
<th>Public Key</th>
<th>Contact Type</th>
<th>Location</th>
<th>RSSI</th>
<th>SNR</th>
<th>Last Seen</th>
<th>First Seen</th>
<th>Advert Count</th>
</tr>
</thead>
<tbody id="neighbors-table">
<tr>
<td colspan="9" class="empty-message">
No repeaters discovered yet - waiting for adverts...
</td>
</tr>
</tbody>
</table>
</div>
</div>
</main>
</div>
<script>
let updateInterval;
// Handle Send Advert button
function sendAdvert() {
const btn = document.getElementById('send-advert-btn');
if (!btn) return;
const icon = btn.querySelector('.icon');
const iconHTML = icon ? icon.outerHTML : '';
btn.disabled = true;
btn.innerHTML = iconHTML + 'Sending...';
fetch('/api/send_advert', {
method: 'POST'
})
.then(r => r.json())
.then(data => {
if (data.success) {
btn.innerHTML = iconHTML + 'Sent!';
setTimeout(() => {
btn.innerHTML = iconHTML + 'Send Advert';
btn.disabled = false;
}, 2000);
} else {
btn.innerHTML = iconHTML + 'Error';
console.error('Failed to send advert:', data.error);
setTimeout(() => {
btn.innerHTML = iconHTML + 'Send Advert';
btn.disabled = false;
}, 2000);
}
})
.catch(e => {
console.error('Error sending advert:', e);
btn.innerHTML = iconHTML + 'Error';
setTimeout(() => {
btn.innerHTML = iconHTML + 'Send Advert';
btn.disabled = false;
}, 2000);
});
}
function updateNeighbors() {
fetch('/api/stats')
.then(r => r.json())
.then(data => {
const neighbors = data.neighbors || {};
const neighborCount = Object.keys(neighbors).length;
document.getElementById('neighbor-count').textContent = neighborCount;
document.getElementById('update-time').textContent = new Date().toLocaleTimeString();
updateNeighborsTable(neighbors);
})
.catch(e => console.error('Error fetching neighbors:', e));
}
function updateNeighborsTable(neighbors) {
const tbody = document.getElementById('neighbors-table');
if (!neighbors || Object.keys(neighbors).length === 0) {
tbody.innerHTML = `
<tr>
<td colspan="9" class="empty-message">
No repeaters discovered yet - waiting for adverts...
</td>
</tr>
`;
return;
}
// Sort by last_seen (most recent first)
const sortedNeighbors = Object.entries(neighbors).sort((a, b) => {
return b[1].last_seen - a[1].last_seen;
});
tbody.innerHTML = sortedNeighbors.map(([pubkey, neighbor]) => {
const name = neighbor.node_name || 'Unknown';
// Format pubkey properly - it's a 64-char hex string
const pubkeyShort = pubkey.length >= 16
? `&lt;${pubkey.substring(0, 8)}...${pubkey.substring(pubkey.length - 8)}&gt;`
: `&lt;${pubkey}&gt;`;
const contactType = neighbor.contact_type || 'Repeater';
const location = neighbor.latitude && neighbor.longitude && (neighbor.latitude !== 0.0 || neighbor.longitude !== 0.0)
? `${neighbor.latitude.toFixed(6)}, ${neighbor.longitude.toFixed(6)}`
: 'N/A';
const rssi = neighbor.rssi || 'N/A';
const snr = neighbor.snr !== undefined ? neighbor.snr.toFixed(1) + ' dB' : 'N/A';
const lastSeen = new Date(neighbor.last_seen * 1000).toLocaleString();
const firstSeen = new Date(neighbor.first_seen * 1000).toLocaleString();
const advertCount = neighbor.advert_count || 0;
// Color code RSSI
let rssiClass = 'rssi-poor';
if (rssi !== 'N/A') {
if (rssi > -80) rssiClass = 'rssi-excellent';
else if (rssi > -90) rssiClass = 'rssi-good';
else if (rssi > -100) rssiClass = 'rssi-fair';
}
return `
<tr>
<td data-label="Node Name"><strong>${name}</strong></td>
<td data-label="Public Key"><code class="pubkey">${pubkeyShort}</code></td>
<td data-label="Contact Type"><span class="contact-type-badge">${contactType}</span></td>
<td data-label="Location">${location}</td>
<td data-label="RSSI"><span class="${rssiClass}">${rssi}</span></td>
<td data-label="SNR">${snr}</td>
<td data-label="Last Seen">${lastSeen}</td>
<td data-label="First Seen">${firstSeen}</td>
<td data-label="Advert Count">${advertCount}</td>
</tr>
`;
}).join('');
}
// Initialize on load
document.addEventListener('DOMContentLoaded', () => {
updateNeighbors();
// Auto-update every 10 seconds
updateInterval = setInterval(updateNeighbors, 10000);
// Attach send advert button handler
const sendAdvertBtn = document.getElementById('send-advert-btn');
if (sendAdvertBtn) {
sendAdvertBtn.addEventListener('click', sendAdvert);
}
});
</script>
<style>
.pubkey {
font-family: 'Courier New', monospace;
font-size: 0.85em;
color: #4ec9b0;
}
.contact-type-badge {
display: inline-block;
padding: 4px 8px;
background-color: rgba(59, 130, 246, 0.2);
border: 1px solid rgba(59, 130, 246, 0.4);
border-radius: 4px;
font-size: 0.85em;
color: #60a5fa;
font-weight: 500;
}
.rssi-excellent {
color: #4ade80;
font-weight: 500;
}
.rssi-good {
color: #4ec9b0;
font-weight: 500;
}
.rssi-fair {
color: #dcdcaa;
font-weight: 500;
}
.rssi-poor {
color: #f48771;
font-weight: 500;
}
/* Mobile responsive table styling */
@media (max-width: 768px) {
.data-table {
display: block;
width: 100%;
}
.data-table thead {
display: none;
}
.data-table tbody {
display: block;
width: 100%;
}
.data-table tbody tr {
display: block;
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
margin-bottom: var(--spacing-md);
padding: var(--spacing-md);
overflow: hidden;
}
.data-table tbody tr:hover {
background: var(--color-bg-tertiary);
border-color: var(--color-accent-primary);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
.data-table td {
display: inline-flex;
align-items: center;
gap: var(--spacing-sm);
margin-bottom: var(--spacing-sm);
margin-right: var(--spacing-lg);
padding: 0;
border: none;
text-align: left;
font-size: 0.75rem;
flex-wrap: wrap;
}
.data-table td::before {
content: attr(data-label);
font-weight: var(--font-weight-bold);
color: var(--color-text-tertiary);
text-transform: uppercase;
font-size: 0.6rem;
letter-spacing: 0.5px;
min-width: fit-content;
padding: 2px 6px;
background: var(--color-bg-tertiary);
border-radius: 4px;
}
/* Node Name and Public Key get full width */
.data-table td:nth-child(1),
.data-table td:nth-child(2) {
display: block;
width: 100%;
margin-right: 0;
margin-bottom: var(--spacing-md);
}
.data-table td:nth-child(1)::before { content: "Node Name"; }
.data-table td:nth-child(2)::before { content: "Public Key"; }
.data-table td:nth-child(3)::before { content: "Contact Type"; }
.data-table td:nth-child(4)::before { content: "Location"; }
.data-table td:nth-child(5)::before { content: "RSSI"; }
.data-table td:nth-child(6)::before { content: "SNR"; }
.data-table td:nth-child(7)::before { content: "Last Seen"; }
.data-table td:nth-child(8)::before { content: "First Seen"; }
.data-table td:nth-child(9)::before { content: "Advert Count"; }
/* Location and timestamps wrap to next line */
.data-table td:nth-child(4),
.data-table td:nth-child(7),
.data-table td:nth-child(8) {
display: block;
width: 100%;
margin-right: 0;
margin-bottom: var(--spacing-sm);
}
}
@media (max-width: 600px) {
.data-table tbody tr {
padding: var(--spacing-md);
}
.data-table td {
display: inline-flex;
align-items: center;
gap: var(--spacing-xs);
margin-bottom: var(--spacing-xs);
margin-right: var(--spacing-md);
font-size: 0.7rem;
}
.data-table td::before {
font-size: 0.55rem;
padding: 1px 4px;
}
/* Full width items */
.data-table td:nth-child(1),
.data-table td:nth-child(2),
.data-table td:nth-child(4),
.data-table td:nth-child(7),
.data-table td:nth-child(8) {
display: block;
width: 100%;
margin-right: 0;
margin-bottom: var(--spacing-sm);
}
.pubkey {
font-size: 0.7rem;
word-break: break-all;
}
}
@media (max-width: 480px) {
.data-table tbody tr {
padding: var(--spacing-sm);
}
.data-table td {
display: inline-flex;
align-items: center;
gap: var(--spacing-xs);
margin-bottom: var(--spacing-xs);
margin-right: var(--spacing-sm);
font-size: 0.65rem;
}
.data-table td::before {
font-size: 0.5rem;
padding: 1px 3px;
}
/* Full width items */
.data-table td:nth-child(1),
.data-table td:nth-child(2),
.data-table td:nth-child(4),
.data-table td:nth-child(7),
.data-table td:nth-child(8) {
display: block;
width: 100%;
margin-right: 0;
margin-bottom: var(--spacing-xs);
}
.pubkey {
font-size: 0.6rem;
word-break: break-all;
white-space: normal;
}
.contact-type-badge {
font-size: 0.7rem;
padding: 2px 4px;
}
}
</style>
</body>
</html>

View File

@@ -0,0 +1,335 @@
<!DOCTYPE html>
<html>
<head>
<title>pyMC Repeater - Statistics</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="https://cdn.jsdelivr.net/npm/chart.js@3.9.1"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<div class="layout">
<!-- Navigation Component -->
<!-- NAVIGATION_PLACEHOLDER -->
<!-- Main Content -->
<main class="content">
<header>
<h1>Statistics</h1>
<p>Detailed performance analytics and metrics</p>
</header>
<!-- Summary Stats -->
<h2>Summary</h2>
<div class="stats-grid">
<div class="stat-card success">
<div class="stat-label">Total RX</div>
<div class="stat-value" id="total-rx">0<span class="stat-unit">packets</span></div>
</div>
<div class="stat-card success">
<div class="stat-label">Total TX</div>
<div class="stat-value" id="total-tx">0<span class="stat-unit">packets</span></div>
</div>
<div class="stat-card">
<div class="stat-label">Success Rate</div>
<div class="stat-value" id="success-rate">0<span class="stat-unit">%</span></div>
</div>
</div>
<!-- Charts -->
<h2>Performance Charts</h2>
<div class="charts-grid">
<div class="chart-card">
<h3>RX vs TX Over Time</h3>
<div class="chart-container">
<canvas id="rxtxChart"></canvas>
</div>
</div>
<div class="chart-card">
<h3>Packet Type Distribution</h3>
<div class="chart-container">
<canvas id="packetTypeChart"></canvas>
</div>
</div>
<div class="chart-card">
<h3>Signal Metrics Over Time</h3>
<div class="chart-container">
<canvas id="signalMetricsChart"></canvas>
</div>
</div>
<div class="chart-card">
<h3>Route Type Distribution</h3>
<div class="chart-container">
<canvas id="routeTypeChart"></canvas>
</div>
</div>
</div>
</main>
</div>
<script>
let rxtxChart = null;
let packetTypeChart = null;
let signalMetricsChart = null;
let routeTypeChart = null;
const chartOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
labels: {
color: '#d4d4d4'
}
},
filler: {
propagate: true
}
},
scales: {
x: {
ticks: { color: '#999' },
grid: { color: '#333' }
},
y: {
ticks: { color: '#999' },
grid: { color: '#333' }
}
}
};
function initCharts() {
// RX vs TX chart
let rxtxCtx = document.getElementById('rxtxChart').getContext('2d');
rxtxChart = new Chart(rxtxCtx, {
type: 'line',
data: {
labels: ['00:00', '05:00', '10:00', '15:00', '20:00'],
datasets: [
{
label: 'RX',
data: [0, 0, 0, 0, 0],
borderColor: '#4ec9b0',
backgroundColor: 'rgba(78, 201, 176, 0.1)',
borderWidth: 2,
fill: true,
tension: 0.4
},
{
label: 'TX',
data: [0, 0, 0, 0, 0],
borderColor: '#6a9955',
backgroundColor: 'rgba(106, 153, 85, 0.1)',
borderWidth: 2,
fill: true,
tension: 0.4
}
]
},
options: chartOptions
});
// Packet type chart
let typeCtx = document.getElementById('packetTypeChart').getContext('2d');
packetTypeChart = new Chart(typeCtx, {
type: 'doughnut',
data: {
labels: ['REQ', 'RESPONSE', 'TXT', 'ACK', 'ADVERT', 'GRP_TXT', 'GRP_DATA', 'PATH', 'OTHER'],
datasets: [{
data: [0, 0, 0, 0, 0, 0, 0, 0, 0],
backgroundColor: ['#ce9178', '#f48771', '#dcdcaa', '#6a9955', '#4ec9b0', '#c586c0', '#9cdcfe', '#569cd6', '#808080']
}]
},
options: chartOptions
});
// Signal metrics chart (RSSI, SNR, Noise Floor)
let metricsCtx = document.getElementById('signalMetricsChart').getContext('2d');
signalMetricsChart = new Chart(metricsCtx, {
type: 'line',
data: {
labels: ['00:00', '05:00', '10:00', '15:00', '20:00'],
datasets: [
{
label: 'RSSI (dBm)',
data: [0, 0, 0, 0, 0],
borderColor: '#ce9178',
backgroundColor: 'rgba(206, 145, 120, 0.1)',
borderWidth: 2,
fill: true,
tension: 0.4,
yAxisID: 'y1'
},
{
label: 'SNR (dB)',
data: [0, 0, 0, 0, 0],
borderColor: '#4ec9b0',
backgroundColor: 'rgba(78, 201, 176, 0.1)',
borderWidth: 2,
fill: false,
tension: 0.4,
yAxisID: 'y2'
},
{
label: 'Noise Floor (dBm)',
data: [0, 0, 0, 0, 0],
borderColor: '#f48771',
backgroundColor: 'rgba(244, 135, 113, 0.1)',
borderWidth: 2,
fill: false,
tension: 0.4,
borderDash: [5, 5],
yAxisID: 'y1'
}
]
},
options: {
...chartOptions,
scales: {
x: {
ticks: { color: '#999' },
grid: { color: '#333' }
},
y1: {
type: 'linear',
position: 'left',
ticks: { color: '#999' },
grid: { color: '#333' },
title: {
display: true,
text: 'RSSI / Noise (dBm)',
color: '#ce9178'
}
},
y2: {
type: 'linear',
position: 'right',
ticks: { color: '#999' },
grid: { drawOnChartArea: false }
}
}
}
});
// Route type chart
let routeCtx = document.getElementById('routeTypeChart').getContext('2d');
routeTypeChart = new Chart(routeCtx, {
type: 'doughnut',
data: {
labels: ['FLOOD', 'DIRECT'],
datasets: [{
data: [0, 0],
backgroundColor: ['#dcdcaa', '#6a9955']
}]
},
options: chartOptions
});
}
function updateStats() {
fetch('/api/stats')
.then(r => r.json())
.then(data => {
// Update summary
const rx = data.rx_count || 0;
const tx = data.forwarded_count || 0;
const successRate = rx > 0 ? Math.round((tx / rx) * 100) : 0;
document.getElementById('total-rx').textContent = rx;
document.getElementById('total-tx').textContent = tx;
document.getElementById('success-rate').textContent = successRate;
// Update charts with data trends
const packets = data.recent_packets || [];
// Calculate packet type distribution
// Types: 0x00=REQ, 0x01=RESPONSE, 0x02=TXT, 0x03=ACK, 0x04=ADVERT,
// 0x05=GRP_TXT, 0x06=GRP_DATA, 0x08=PATH, other
const types = { 0: 0, 1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0, 8: 0, other: 0 };
const routes = { flood: 0, direct: 0 };
let rssiSum = 0, snrSum = 0, rssiMin = 0;
let packetCount = 0;
packets.forEach(p => {
// Count packet types
if (p.type === 0 || p.type === 1 || p.type === 2 || p.type === 3 ||
p.type === 4 || p.type === 5 || p.type === 6 || p.type === 8) {
types[p.type] = (types[p.type] || 0) + 1;
} else {
types.other++;
}
if (p.route === 1) routes.flood++; else routes.direct++;
rssiSum += p.rssi || 0;
snrSum += p.snr || 0;
if (rssiMin === 0 || p.rssi < rssiMin) rssiMin = p.rssi;
packetCount++;
});
// Update packet type chart
packetTypeChart.data.datasets[0].data = [
types[0] || 0, // REQ
types[1] || 0, // RESPONSE
types[2] || 0, // TXT
types[3] || 0, // ACK
types[4] || 0, // ADVERT
types[5] || 0, // GRP_TXT (channel messages)
types[6] || 0, // GRP_DATA
types[8] || 0, // PATH
types.other || 0 // OTHER
];
packetTypeChart.update();
// Update RX vs TX chart (add current counts to timeline)
const now = new Date();
const timeLabel = now.getHours().toString().padStart(2, '0') + ':' +
now.getMinutes().toString().padStart(2, '0');
rxtxChart.data.labels.push(timeLabel);
rxtxChart.data.datasets[0].data.push(rx); // RX count
rxtxChart.data.datasets[1].data.push(tx); // TX count
// Keep only last 20 data points
if (rxtxChart.data.labels.length > 20) {
rxtxChart.data.labels.shift();
rxtxChart.data.datasets[0].data.shift();
rxtxChart.data.datasets[1].data.shift();
}
rxtxChart.update();
// Update route type chart
routeTypeChart.data.datasets[0].data = [routes.flood, routes.direct];
routeTypeChart.update();
// Update signal metrics chart
const avgRssi = packetCount > 0 ? Math.round(rssiSum / packetCount) : 0;
const avgSnr = packetCount > 0 ? Math.round(snrSum / packetCount) : 0;
const noiseFloor = avgRssi - avgSnr; // Noise Floor = RSSI - SNR
signalMetricsChart.data.datasets[0].data.push(avgRssi);
signalMetricsChart.data.datasets[1].data.push(avgSnr);
signalMetricsChart.data.datasets[2].data.push(noiseFloor);
if (signalMetricsChart.data.datasets[0].data.length > 5) {
signalMetricsChart.data.datasets[0].data.shift();
signalMetricsChart.data.datasets[1].data.shift();
signalMetricsChart.data.datasets[2].data.shift();
}
signalMetricsChart.update();
})
.catch(e => console.error('Error fetching stats:', e));
}
document.addEventListener('DOMContentLoaded', () => {
initCharts();
updateStats();
setInterval(updateStats, 5000);
});
</script>
</body>
</html>

1848
repeater/templates/style.css Normal file

File diff suppressed because it is too large Load Diff

268
setup-radio-config.sh Normal file
View File

@@ -0,0 +1,268 @@
#!/bin/bash
# Radio configuration setup script for pyMC Repeater
CONFIG_DIR="${1:-.}"
CONFIG_FILE="$CONFIG_DIR/config.yaml"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
HARDWARE_CONFIG="$SCRIPT_DIR/radio-settings.json"
# Detect OS and set appropriate sed parameters
if [[ "$OSTYPE" == "darwin"* ]]; then
# macOS
SED_OPTS=(-i '')
else
# Linux
SED_OPTS=(-i)
fi
echo "=== pyMC Repeater Radio Configuration ==="
echo ""
# Step 0: Repeater Name
echo "=== Step 0: Set Repeater Name ==="
echo ""
# Read existing repeater name from config if it exists
existing_name=""
if [ -f "$CONFIG_FILE" ]; then
existing_name=$(grep "^\s*node_name:" "$CONFIG_FILE" | sed 's/.*node_name:\s*"\?\([^"]*\)"\?$/\1/' | head -1)
fi
# Generate random name with format pyRptXXXX (where X is random digit)
if [ -n "$existing_name" ]; then
default_name="$existing_name"
prompt_text="Enter repeater name [$default_name] (press Enter to keep)"
else
random_num=$((RANDOM % 10000))
default_name=$(printf "pyRpt%04d" $random_num)
prompt_text="Enter repeater name [$default_name]"
fi
read -p "$prompt_text: " repeater_name
repeater_name=${repeater_name:-$default_name}
echo "Repeater name: $repeater_name"
echo ""
echo "=== Step 1: Select Hardware ==="
echo ""
if [ ! -f "$HARDWARE_CONFIG" ]; then
echo "Error: Hardware configuration file not found at $HARDWARE_CONFIG"
exit 1
fi
# Parse hardware options from radio-settings.json
hw_index=0
declare -a hw_keys
declare -a hw_names
# Extract hardware keys and names using grep and sed
hw_data=$(grep -o '"[^"]*":\s*{' "$HARDWARE_CONFIG" | grep -v hardware | sed 's/"\([^"]*\)".*/\1/' | while read hw_key; do
hw_name=$(grep -A 1 "\"$hw_key\"" "$HARDWARE_CONFIG" | grep "\"name\"" | sed 's/.*"name":\s*"\([^"]*\)".*/\1/')
if [ -n "$hw_name" ]; then
echo "$hw_key|$hw_name"
fi
done)
while IFS='|' read -r hw_key hw_name; do
if [ -n "$hw_key" ] && [ -n "$hw_name" ]; then
echo " $((hw_index + 1))) $hw_name ($hw_key)"
hw_keys[$hw_index]="$hw_key"
hw_names[$hw_index]="$hw_name"
((hw_index++))
fi
done <<< "$hw_data"
if [ "$hw_index" -eq 0 ]; then
echo "Error: No hardware configurations found"
exit 1
fi
echo ""
read -p "Select hardware (1-$hw_index): " hw_selection
if ! [ "$hw_selection" -ge 1 ] 2>/dev/null || [ "$hw_selection" -gt "$hw_index" ]; then
echo "Error: Invalid selection"
exit 1
fi
selected_hw=$((hw_selection - 1))
hw_key="${hw_keys[$selected_hw]}"
hw_name="${hw_names[$selected_hw]}"
echo "Selected: $hw_name"
echo ""
# Step 2: Radio Settings Selection
echo "=== Step 2: Select Radio Settings ==="
echo ""
# Fetch config from API
echo "Fetching radio settings from API..."
API_RESPONSE=$(curl -s https://api.meshcore.nz/api/v1/config)
if [ -z "$API_RESPONSE" ]; then
echo "Error: Failed to fetch configuration from API"
exit 1
fi
# Parse JSON entries - one per line, extracting each field
SETTINGS=$(echo "$API_RESPONSE" | grep -o '{[^{}]*"title"[^{}]*"coding_rate"[^{}]*}' | sed 's/.*"title":"\([^"]*\)".*/\1/' | while read title; do
entry=$(echo "$API_RESPONSE" | grep -o "{[^{}]*\"title\":\"$title\"[^{}]*\"coding_rate\"[^{}]*}")
desc=$(echo "$entry" | sed 's/.*"description":"\([^"]*\)".*/\1/')
freq=$(echo "$entry" | sed 's/.*"frequency":"\([^"]*\)".*/\1/')
sf=$(echo "$entry" | sed 's/.*"spreading_factor":"\([^"]*\)".*/\1/')
bw=$(echo "$entry" | sed 's/.*"bandwidth":"\([^"]*\)".*/\1/')
cr=$(echo "$entry" | sed 's/.*"coding_rate":"\([^"]*\)".*/\1/')
echo "$title|$desc|$freq|$sf|$bw|$cr"
done)
if [ -z "$SETTINGS" ]; then
echo "Error: Could not parse radio settings from API response"
exit 1
fi
# Display menu
echo "Available Radio Settings:"
echo ""
index=0
while IFS='|' read -r title desc freq sf bw cr; do
printf " %2d) %-35s ----> %7.3fMHz / SF%s / BW%s / CR%s\n" $((index + 1)) "$title" "$freq" "$sf" "$bw" "$cr"
# Store values in files to avoid subshell issues
echo "$title" > /tmp/radio_title_$index
echo "$freq" > /tmp/radio_freq_$index
echo "$sf" > /tmp/radio_sf_$index
echo "$bw" > /tmp/radio_bw_$index
echo "$cr" > /tmp/radio_cr_$index
((index++))
done <<< "$SETTINGS"
echo ""
read -p "Select a radio setting (1-$index): " selection
# Validate selection
if ! [ "$selection" -ge 1 ] 2>/dev/null || [ "$selection" -gt "$index" ]; then
echo "Error: Invalid selection"
exit 1
fi
selected=$((selection - 1))
freq=$(cat /tmp/radio_freq_$selected 2>/dev/null)
sf=$(cat /tmp/radio_sf_$selected 2>/dev/null)
bw=$(cat /tmp/radio_bw_$selected 2>/dev/null)
cr=$(cat /tmp/radio_cr_$selected 2>/dev/null)
title=$(cat /tmp/radio_title_$selected 2>/dev/null)
# Convert frequency from MHz to Hz (handle decimal values)
freq_hz=$(echo "$freq * 1000000" | bc -l | cut -d. -f1)
bw_hz=$(echo "$bw * 1000" | bc -l | cut -d. -f1)
echo ""
echo "Selected: $title"
echo "Frequency: ${freq}MHz, SF: $sf, BW: $bw, CR: $cr"
echo ""
# Update config.yaml
if [ ! -f "$CONFIG_FILE" ]; then
echo "Error: Config file not found at $CONFIG_FILE"
exit 1
fi
echo "Updating configuration..."
# Repeater name
sed "${SED_OPTS[@]}" "s/^ node_name:.*/ node_name: \"$repeater_name\"/" "$CONFIG_FILE"
# Radio settings - using converted Hz values
sed "${SED_OPTS[@]}" "s/^ frequency:.*/ frequency: $freq_hz/" "$CONFIG_FILE"
sed "${SED_OPTS[@]}" "s/^ spreading_factor:.*/ spreading_factor: $sf/" "$CONFIG_FILE"
sed "${SED_OPTS[@]}" "s/^ bandwidth:.*/ bandwidth: $bw_hz/" "$CONFIG_FILE"
sed "${SED_OPTS[@]}" "s/^ coding_rate:.*/ coding_rate: $cr/" "$CONFIG_FILE"
# Extract hardware-specific settings from radio-settings.json
echo "Extracting hardware configuration from $HARDWARE_CONFIG..."
# Use jq to extract all fields from the selected hardware
hw_config=$(jq ".hardware.\"$hw_key\"" "$HARDWARE_CONFIG" 2>/dev/null)
if [ -z "$hw_config" ] || [ "$hw_config" == "null" ]; then
echo "Warning: Could not extract hardware config from JSON, using defaults"
else
# Extract each field and update config.yaml
bus_id=$(echo "$hw_config" | jq -r '.bus_id // empty')
cs_id=$(echo "$hw_config" | jq -r '.cs_id // empty')
cs_pin=$(echo "$hw_config" | jq -r '.cs_pin // empty')
reset_pin=$(echo "$hw_config" | jq -r '.reset_pin // empty')
busy_pin=$(echo "$hw_config" | jq -r '.busy_pin // empty')
irq_pin=$(echo "$hw_config" | jq -r '.irq_pin // empty')
txen_pin=$(echo "$hw_config" | jq -r '.txen_pin // empty')
rxen_pin=$(echo "$hw_config" | jq -r '.rxen_pin // empty')
tx_power=$(echo "$hw_config" | jq -r '.tx_power // empty')
preamble_length=$(echo "$hw_config" | jq -r '.preamble_length // empty')
is_waveshare=$(echo "$hw_config" | jq -r '.is_waveshare // empty')
# Update sx1262 section in config.yaml (2-space indentation)
[ -n "$bus_id" ] && sed "${SED_OPTS[@]}" "s/^ bus_id:.*/ bus_id: $bus_id/" "$CONFIG_FILE"
[ -n "$cs_id" ] && sed "${SED_OPTS[@]}" "s/^ cs_id:.*/ cs_id: $cs_id/" "$CONFIG_FILE"
[ -n "$cs_pin" ] && sed "${SED_OPTS[@]}" "s/^ cs_pin:.*/ cs_pin: $cs_pin/" "$CONFIG_FILE"
[ -n "$reset_pin" ] && sed "${SED_OPTS[@]}" "s/^ reset_pin:.*/ reset_pin: $reset_pin/" "$CONFIG_FILE"
[ -n "$busy_pin" ] && sed "${SED_OPTS[@]}" "s/^ busy_pin:.*/ busy_pin: $busy_pin/" "$CONFIG_FILE"
[ -n "$irq_pin" ] && sed "${SED_OPTS[@]}" "s/^ irq_pin:.*/ irq_pin: $irq_pin/" "$CONFIG_FILE"
[ -n "$txen_pin" ] && sed "${SED_OPTS[@]}" "s/^ txen_pin:.*/ txen_pin: $txen_pin/" "$CONFIG_FILE"
[ -n "$rxen_pin" ] && sed "${SED_OPTS[@]}" "s/^ rxen_pin:.*/ rxen_pin: $rxen_pin/" "$CONFIG_FILE"
[ -n "$tx_power" ] && sed "${SED_OPTS[@]}" "s/^ tx_power:.*/ tx_power: $tx_power/" "$CONFIG_FILE"
[ -n "$preamble_length" ] && sed "${SED_OPTS[@]}" "s/^ preamble_length:.*/ preamble_length: $preamble_length/" "$CONFIG_FILE"
# Update is_waveshare flag
if [ "$is_waveshare" == "true" ]; then
sed "${SED_OPTS[@]}" "s/^ is_waveshare:.*/ is_waveshare: true/" "$CONFIG_FILE"
else
sed "${SED_OPTS[@]}" "s/^ is_waveshare:.*/ is_waveshare: false/" "$CONFIG_FILE"
fi
fi
# Cleanup
rm -f /tmp/radio_*_* "$CONFIG_FILE.bak"
echo "Configuration updated successfully!"
echo ""
echo "Applied Configuration:"
echo " Repeater Name: $repeater_name"
echo " Hardware: $hw_name ($hw_key)"
echo " Frequency: ${freq}MHz (${freq_hz}Hz)"
echo " Spreading Factor: $sf"
echo " Bandwidth: ${bw}kHz (${bw_hz}Hz)"
echo " Coding Rate: $cr"
echo ""
echo "Hardware GPIO Configuration:"
if [ -n "$bus_id" ]; then
echo " Bus ID: $bus_id"
echo " Chip Select: $cs_id (pin $cs_pin)"
echo " Reset Pin: $reset_pin"
echo " Busy Pin: $busy_pin"
echo " IRQ Pin: $irq_pin"
[ "$txen_pin" != "-1" ] && echo " TX Enable Pin: $txen_pin"
[ "$rxen_pin" != "-1" ] && echo " RX Enable Pin: $rxen_pin"
echo " TX Power: $tx_power dBm"
echo " Preamble Length: $preamble_length"
[ -n "$is_waveshare" ] && echo " Waveshare: $is_waveshare"
fi
# Enable and start the service
SERVICE_NAME="pymc-repeater"
if systemctl list-unit-files | grep -q "^$SERVICE_NAME\.service"; then
echo ""
echo "Enabling and starting the $SERVICE_NAME service..."
sudo systemctl enable "$SERVICE_NAME"
sudo systemctl start "$SERVICE_NAME"
else
echo ""
echo "Service $SERVICE_NAME not found, skipping service management"
fi
echo "Setup complete. Please check the service status with 'systemctl status $SERVICE_NAME'."

99
uninstall.sh Normal file
View File

@@ -0,0 +1,99 @@
#!/bin/bash
# Uninstall script for pyMC Repeater
set -e
INSTALL_DIR="/opt/pymc_repeater"
CONFIG_DIR="/etc/pymc_repeater"
LOG_DIR="/var/log/pymc_repeater"
SERVICE_USER="repeater"
SERVICE_FILE="/etc/systemd/system/pymc-repeater.service"
echo "=== pyMC Repeater Uninstall ==="
# Check if running as root
if [ "$EUID" -ne 0 ]; then
echo "Error: This script must be run as root"
exit 1
fi
# Stop and disable service
if systemctl is-active --quiet pymc-repeater; then
echo "Stopping service..."
systemctl stop pymc-repeater
fi
if systemctl is-enabled --quiet pymc-repeater 2>/dev/null; then
echo "Disabling service..."
systemctl disable pymc-repeater
fi
# Remove systemd service file
if [ -f "$SERVICE_FILE" ]; then
echo "Removing systemd service..."
rm -f "$SERVICE_FILE"
systemctl daemon-reload
fi
# Uninstall Python package
if [ -d "$INSTALL_DIR" ]; then
echo "Uninstalling Python package..."
cd "$INSTALL_DIR"
pip uninstall -y pymc_repeater 2>/dev/null || true
fi
# Remove installation directory
if [ -d "$INSTALL_DIR" ]; then
echo "Removing installation directory..."
rm -rf "$INSTALL_DIR"
fi
# Ask before removing config and logs
echo ""
read -p "Remove configuration files in $CONFIG_DIR? [y/N] " -n 1 -r
echo
if [[ $REPLY =~ ^[Yy]$ ]]; then
echo "Removing configuration directory..."
rm -rf "$CONFIG_DIR"
else
echo "Keeping configuration files in $CONFIG_DIR"
fi
echo ""
read -p "Remove log files in $LOG_DIR? [y/N] " -n 1 -r
echo
if [[ $REPLY =~ ^[Yy]$ ]]; then
echo "Removing log directory..."
rm -rf "$LOG_DIR"
else
echo "Keeping log files in $LOG_DIR"
fi
echo ""
read -p "Remove user data in /var/lib/pymc_repeater? [y/N] " -n 1 -r
echo
if [[ $REPLY =~ ^[Yy]$ ]]; then
echo "Removing user data directory..."
rm -rf /var/lib/pymc_repeater
else
echo "Keeping user data in /var/lib/pymc_repeater"
fi
# Ask before removing service user
echo ""
read -p "Remove service user '$SERVICE_USER'? [y/N] " -n 1 -r
echo
if [[ $REPLY =~ ^[Yy]$ ]]; then
if id "$SERVICE_USER" &>/dev/null; then
echo "Removing service user..."
userdel "$SERVICE_USER" 2>/dev/null || true
fi
else
echo "Keeping service user '$SERVICE_USER'"
fi
echo ""
echo "=== Uninstall Complete ==="
echo ""
echo "The pyMC Repeater has been removed from your system."
echo "----------------------------------"