mirror of
https://github.com/rightup/pyMC_Repeater.git
synced 2026-03-28 17:43:06 +01:00
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:
1
.github/FUNDING.yml
vendored
Normal file
1
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
buy_me_a_coffee: rightup
|
||||||
50
.gitignore
vendored
Normal file
50
.gitignore
vendored
Normal 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
40
.pre-commit-config.yaml
Normal 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
164
README.md
Normal 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 I’d 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 — it’s 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.
|
||||||
|
|
||||||
|
There’s still plenty of room for this project to grow and improve — but you’ve 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.
|
||||||
|
|
||||||
|
> **I’d 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
|
||||||
|

|
||||||
|
*Real-time monitoring dashboard showing packet statistics, neighbor discovery, and system status*
|
||||||
|
|
||||||
|
### Statistics
|
||||||
|

|
||||||
|
*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
102
config.yaml.example
Normal 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
110
deploy.sh
Normal 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
BIN
docs/dashboard.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 143 KiB |
BIN
docs/stats.png
Normal file
BIN
docs/stats.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 133 KiB |
39
pymc-repeater.service
Normal file
39
pymc-repeater.service
Normal 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
57
pyproject.toml
Normal 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
44
radio-settings.json
Normal 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
1
repeater/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
__version__ = "1.0.0"
|
||||||
78
repeater/airtime.py
Normal file
78
repeater/airtime.py
Normal 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
137
repeater/config.py
Normal 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
667
repeater/engine.py
Normal 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
459
repeater/http_server.py
Normal 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
271
repeater/main.py
Normal 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()
|
||||||
216
repeater/templates/configuration.html
Normal file
216
repeater/templates/configuration.html
Normal 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>
|
||||||
712
repeater/templates/dashboard.html
Normal file
712
repeater/templates/dashboard.html
Normal 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>
|
||||||
934
repeater/templates/help.html
Normal file
934
repeater/templates/help.html
Normal 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>
|
||||||
|
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>
|
||||||
238
repeater/templates/logs.html
Normal file
238
repeater/templates/logs.html
Normal 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 = {
|
||||||
|
'&': '&',
|
||||||
|
'<': '<',
|
||||||
|
'>': '>',
|
||||||
|
'"': '"',
|
||||||
|
"'": '''
|
||||||
|
};
|
||||||
|
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
432
repeater/templates/nav.html
Normal 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"><{{ pub_key }}></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>
|
||||||
395
repeater/templates/neighbors.html
Normal file
395
repeater/templates/neighbors.html
Normal 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
|
||||||
|
? `<${pubkey.substring(0, 8)}...${pubkey.substring(pubkey.length - 8)}>`
|
||||||
|
: `<${pubkey}>`;
|
||||||
|
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>
|
||||||
335
repeater/templates/statistics.html
Normal file
335
repeater/templates/statistics.html
Normal 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
1848
repeater/templates/style.css
Normal file
File diff suppressed because it is too large
Load Diff
268
setup-radio-config.sh
Normal file
268
setup-radio-config.sh
Normal 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
99
uninstall.sh
Normal 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 "----------------------------------"
|
||||||
Reference in New Issue
Block a user