diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..b3e56bc --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +buy_me_a_coffee: rightup diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a01bc69 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..2f2968c --- /dev/null +++ b/.pre-commit-config.yaml @@ -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" + ] diff --git a/README.md b/README.md new file mode 100644 index 0000000..2a5ceee --- /dev/null +++ b/README.md @@ -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 +![Dashboard](docs/dashboard.png) +*Real-time monitoring dashboard showing packet statistics, neighbor discovery, and system status* + +### Statistics +![Statistics](docs/stats.png) +*statistics and performance metrics* + +## Installation + +**Clone the Repository:** +```bash +git clone https://github.com/rightup/pyMC_Repeater.git +cd pyMC_Repeater +``` + +**Quick Install:** +```bash +sudo bash deploy.sh +``` + +This script will: +- Create a dedicated `repeater` service user with hardware access +- Install files to `/opt/pymc_repeater` +- Create configuration directory at `/etc/pymc_repeater` +- Setup log directory at `/var/log/pymc_repeater` +- **Launch interactive radio & hardware configuration wizard** +- Install and enable systemd service + +**After Installation:** +```bash +# View live logs +sudo journalctl -u pymc-repeater -f + +# Access web dashboard +http://: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. diff --git a/config.yaml.example b/config.yaml.example new file mode 100644 index 0000000..f160724 --- /dev/null +++ b/config.yaml.example @@ -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" diff --git a/deploy.sh b/deploy.sh new file mode 100644 index 0000000..2484912 --- /dev/null +++ b/deploy.sh @@ -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 "----------------------------------" diff --git a/docs/dashboard.png b/docs/dashboard.png new file mode 100644 index 0000000..772b6bb Binary files /dev/null and b/docs/dashboard.png differ diff --git a/docs/stats.png b/docs/stats.png new file mode 100644 index 0000000..94ce76e Binary files /dev/null and b/docs/stats.png differ diff --git a/pymc-repeater.service b/pymc-repeater.service new file mode 100644 index 0000000..264f4f2 --- /dev/null +++ b/pymc-repeater.service @@ -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 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..d2626ac --- /dev/null +++ b/pyproject.toml @@ -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 diff --git a/radio-settings.json b/radio-settings.json new file mode 100644 index 0000000..aee9b0d --- /dev/null +++ b/radio-settings.json @@ -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 + } + } +} diff --git a/repeater/__init__.py b/repeater/__init__.py new file mode 100644 index 0000000..5becc17 --- /dev/null +++ b/repeater/__init__.py @@ -0,0 +1 @@ +__version__ = "1.0.0" diff --git a/repeater/airtime.py b/repeater/airtime.py new file mode 100644 index 0000000..f456613 --- /dev/null +++ b/repeater/airtime.py @@ -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, + } diff --git a/repeater/config.py b/repeater/config.py new file mode 100644 index 0000000..ffad780 --- /dev/null +++ b/repeater/config.py @@ -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") diff --git a/repeater/engine.py b/repeater/engine.py new file mode 100644 index 0000000..dd7b4d6 --- /dev/null +++ b/repeater/engine.py @@ -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 diff --git a/repeater/http_server.py b/repeater/http_server.py new file mode 100644 index 0000000..ee519c4 --- /dev/null +++ b/repeater/http_server.py @@ -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 "

Error

Template directory not configured

" + + if not self.dashboard_template: + return "

Error

Template not loaded

" + + 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 "" 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("", 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 += ( + "" + f"{time_str}" + f'{pkt_type}' + f'{route}' + f"{pkt.get('length', 0)}" + f"{pkt.get('rssi', 0)}" + f"{snr_val: .1f}" + f'{score_val: .2f}' + f"{delay_val: .0f}" + f"{status}" + "" + ) + else: + packets_table = """ + + + No packets received yet - waiting for traffic... + + + """ + + # 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_replacement = f'\n{packets_table}\n' + 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"

Error

{str(e)}

" + + +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}") diff --git a/repeater/main.py b/repeater/main.py new file mode 100644 index 0000000..8e88715 --- /dev/null +++ b/repeater/main.py @@ -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 + 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() diff --git a/repeater/templates/configuration.html b/repeater/templates/configuration.html new file mode 100644 index 0000000..79e053d --- /dev/null +++ b/repeater/templates/configuration.html @@ -0,0 +1,216 @@ + + + + pyMC Repeater - Configuration + + + + + +
+ + + + +
+
+

Configuration

+

System configuration and settings

+
+ +
+ Configuration is read-only. To modify settings, edit the config file and restart the daemon. +
+ + +

Radio Settings

+
+
+
Frequency
+
Loading...
+
+
+
Spreading Factor
+
Loading...
+
+
+
Bandwidth
+
Loading...
+
+
+
TX Power
+
Loading...
+
+
+
Coding Rate
+
Loading...
+
+
+
Preamble Length
+
Loading...
+
+
+ + +

Repeater Settings

+
+
+
Node Name
+
Loading...
+
+
+
Local Hash
+
Loading...
+
+
+
Public Key
+
Loading...
+
+
+
Latitude
+
Loading...
+
+
+
Longitude
+
Loading...
+
+
+
Mode
+
Loading...
+
+
+
Periodic Advertisement Interval
+
Loading...
+
How often the repeater sends an advertisement packet (0 = disabled)
+
+
+ + +

Duty Cycle

+
+
+
Max Airtime %
+
Loading...
+
+
+
Enforcement
+
Loading...
+
+
+ + +

Transmission Delays

+
+
+
Flood TX Delay Factor
+
Loading...
+
Multiplier for flood packet transmission delays (collision avoidance)
+
+
+
Direct TX Delay Factor
+
Loading...
+
Base delay for direct-routed packet transmission (seconds)
+
+
+
+
+ + + + diff --git a/repeater/templates/dashboard.html b/repeater/templates/dashboard.html new file mode 100644 index 0000000..4c65777 --- /dev/null +++ b/repeater/templates/dashboard.html @@ -0,0 +1,712 @@ + + + + pyMC Repeater Stats + + + + + + +
+ + + + +
+
+

Repeater Dashboard

+
+ System Status: Active + Updated: {{ last_updated }} +
+
+ + +
+
+
RX Packets
+
{{ rx_count }}total
+
+ +
+
Forwarded
+
{{ forwarded_count }}packets
+
+ +
+
Uptime
+
{{ uptime_hours }}h
+
+ +
+
Dropped
+
{{ dropped_count }}packets
+
+
+ + +

Performance Metrics

+
+
+

Packet Rate (RX/TX per hour)

+
+ +
+
+ +
+

Signal Quality Distribution

+
+ +
+
+
+ + +

Recent Packets

+
+ + + + + + + + + + + + + + + + + + + + +
TimeTypeRouteLenPath / HashesRSSISNRScoreTX DelayStatus
+ No packets received yet - waiting for traffic... +
+
+ +
+ Real-time updates enabled +
+
+
+ + + + + + diff --git a/repeater/templates/help.html b/repeater/templates/help.html new file mode 100644 index 0000000..7b91ef4 --- /dev/null +++ b/repeater/templates/help.html @@ -0,0 +1,934 @@ + + + + pyMC Repeater - Help + + + + + + +
+ + + + +
+
+

Help & Documentation

+

Learn how to interpret packet data, understand scoring, and optimize your configuration

+
+ +
+ + + + +
+ +
+

Packet Table Overview

+ +

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.

+ +
+

Purpose: The packet table helps you monitor network traffic, diagnose signal issues, and understand how your repeater is handling different types of packets.

+
+
+ + +
+

Column Details

+ +
+

Time

+
Format: HH:MM:SS
+
+ 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. +
+
+ +
+

Type

+
Packet payload type identifier
+
+ ADVERT: Node advertisement/discovery packets (usually broadcasts)
+ ACK: Acknowledgment responses
+ TXT: Text messages
+ GRP: Group messages
+ PATH: Path information packets
+ RESP: Response packets
+ TRACE: Trace/debug packets
+ +
+
+ +
+

Route

+
Routing mode indicator
+
+ DIRECT: Packet explicitly routed to this repeater (contains its address in the path)
+ FLOOD: Broadcast packet intended for all nodes in range
+ DIRECT packets have higher priority since they're specifically addressed to your repeater. FLOOD packets are retransmitted if bandwidth allows. +
+
+ +
+

Length

+
Payload size in bytes
+
+ 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. +
+
+ +
+

RSSI

+
Received Signal Strength Indicator (dBm)
+
+ Measures signal power. More negative = weaker signal
+ Excellent: -80 to -100 dBm (strong)
+ Good: -100 to -120 dBm (acceptable)
+ Poor: -120 to -140 dBm (weak, may be unreliable)
+ Affects score calculation - better RSSI yields higher scores. Distance and obstacles reduce RSSI. +
+
+ +
+

SNR

+
Signal-to-Noise Ratio (dB)
+
+ Measures signal clarity vs. background noise. Higher = cleaner signal
+ Excellent: SNR > 10 dB (very clean)
+ Good: SNR 5-10 dB (normal operation)
+ Poor: SNR < 5 dB (noisy environment)
+ Even with weak RSSI, high SNR indicates reliable reception. Critical for score calculation. +
+
+ +
+

Score

+
Composite quality metric (0.0 - 1.0)
+
+ 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. +
+
+ +
+

TX Delay

+
Time in milliseconds
+
+ How long the repeater waited before retransmitting. Delay factors include:
+ • Airtime budget checking
+ • Random collision avoidance (0-5ms factor)
+ • Current channel utilization
+ • Optional quality-based prioritization (when enabled)
+ Longer delays may indicate congestion or airtime throttling to comply with duty cycle limits. +
+
+ +
+

Status

+
Packet processing outcome
+
+ FORWARDED: Packet has been successfully retransmitted to other nodes. The repeater forwarded this packet over the air.
+ DROPPED: Packet was rejected and not forwarded.
+
+ Drop Reasons: +
    +
  • Duplicate: Packet hash already in cache. Prevents redundant retransmission.
  • +
  • Empty payload: Packet has no payload data. Cannot be processed.
  • +
  • Path at max size: Path field has reached maximum length. Cannot add repeater identifier.
  • +
  • Duty cycle limit: Airtime budget exhausted. Cannot transmit (EU 1% duty cycle or configured limit).
  • +
  • Direct: no path: Direct-mode packet lacks routing path.
  • +
  • Direct: not our hop: Direct-mode packet is not addressed to this repeater node.
  • +
+
+
+
+ + +
+

Scoring System

+ +

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.

+ +

The Scoring Formula

+ +
+
+
+ Score = SNR Factor × Length Factor +
+ + + + + + +
+
SNR Factor
+
+ (SNR - SFthreshold) / 10 +
+
+
Length Factor
+
+ (1 - length / 256) +
+
+
+ +

Spreading Factor Thresholds

+
+
+ SF7 → -7.5 dB +
+
+ SF8 → -10.0 dB +
+
+ SF9 → -12.5 dB +
+
+ SF10 → -15.0 dB +
+
+ SF11 → -17.5 dB +
+
+ SF12 → -20.0 dB +
+
+ +

Real-World Example

+
+

Packet Details:

+
    +
  • SNR: 12 dB
  • +
  • Spreading Factor: SF8
  • +
  • Payload Length: 100 bytes
  • +
+
+

Calculation:

+
+ SNR Factor = (12 - (-10)) / 10 = 22 / 10 = 2.2 (clamped to 1.0)
+ Length Factor = (1 - 100/256) = 0.609
+ Score = 1.0 × 0.609 = 0.61 (FAIR quality) +
+
+
+ +

This formula ensures that:

+
    +
  • Signal quality matters: Higher SNR produces higher scores, with SF-specific thresholds
  • +
  • Smaller packets score higher: They consume less airtime due to shorter transmission time
  • +
  • Poor SNR packets may score zero: If SNR falls below SF threshold, score = 0.0
  • +
+ +

Score Interpretation

+ +
+ +
+
Quality Scale
+
+
+ 0.0 + 0.25 + 0.5 + 0.75 + 1.0 +
+
+ + +
+
+
0.9 - 1.0 Excellent
+
Perfect conditions, high SNR, small payload
+
+ +
+
0.7 - 0.9 Good
+
Normal operation, acceptable signal
+
+ +
+
0.5 - 0.7 Fair
+
Degraded conditions, lower SNR
+
+ +
+
0.3 - 0.5 Poor
+
Marginal conditions, weak signal
+
+ +
+
< 0.3 Very Poor
+
Barely usable, may be dropped
+
+
+
+
+ + +
+

What Affects Your Score?

+ +

Primary Factors

+ +
+

Signal-to-Noise Ratio (SNR)

+
+ Impact: HIGHEST
+ 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. +
+
+ +
+

Packet Payload Length

+
+ Impact: HIGH
+ Larger packets consume more airtime due to longer transmission times. A 100-byte packet scores lower than a 50-byte packet with identical SNR. +
+ +
+

RSSI (Signal Strength)

+
+ Impact: NOT USED IN SCORING
+ 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. +
+
+ +

Environmental Factors

+ +
    +
  • Weather: Rain and fog reduce signal strength and increase noise
  • +
  • Time of Day: Atmospheric conditions change, especially during dawn/dusk
  • +
  • Frequency Congestion: More devices on 869 MHz = higher noise floor
  • +
  • Physical Obstructions: Buildings and trees block signals, increase fading
  • +
  • Antenna Orientation: Poor antenna alignment reduces SNR significantly
  • +
+ +
+

Environmental Issues: 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.

+
+
+ + +
+

Reactive Score-Based Delay Optimization

+ +

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.

+ +

How It Works

+ +
+

Key Principle: 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.

+

Default Behavior: This feature is disabled by default (use_score_for_tx: false). When disabled, all packets follow standard C++ MeshCore delay calculation with pure randomization.

+
+ +

Delay Multiplier Formula

+ +
+
+
+ Applied Only When: delay ≥ 50ms AND use_score_for_tx = true +
+ +
+ Delay Multiplier = max(0.2, 1.0 - score) +
+ +
+

What this means:

+
    +
  • Perfect packet (score 1.0): Multiplier = max(0.2, 0.0) = 0.2 → Gets 20% of base delay (fast priority)
  • +
  • Good packet (score 0.7): Multiplier = max(0.2, 0.3) = 0.3 → Gets 30% of base delay
  • +
  • Fair packet (score 0.5): Multiplier = max(0.2, 0.5) = 0.5 → Gets 50% of base delay
  • +
  • Poor packet (score 0.2): Multiplier = max(0.2, 0.8) = 0.8 → Gets 80% of base delay (slower, backoff)
  • +
  • Minimum floor: No packet gets less than 20% multiplier (prevents starvation)
  • +
+
+
+
+ +

Example: Reactive Scoring in Action

+ +
+

Scenario: Two packets arrive during congestion (base delay 100ms), tx_delay_factor=1.0

+
    +
  • Packet X: Excellent signal, score = 0.9
  • +
  • Packet Y: Weak signal, score = 0.4
  • +
+

Without Reactive Scoring (disabled):

+
    +
  • Packet X: TX Delay = 0-500ms (pure random collision avoidance)
  • +
  • Packet Y: TX Delay = 0-500ms (pure random collision avoidance)
  • +
  • Result: Both may transmit at same time, causing collision
  • +
+

With Reactive Scoring (enabled, congestion detected):

+
    +
  • Packet X: Multiplier = 0.1 → TX Delay = 0-50ms (high priority, transmits first)
  • +
  • Packet Y: Multiplier = 0.6 → TX Delay = 0-300ms (lower priority, waits longer)
  • +
  • Result: High-quality packets forward with minimal delay; marginal packets gracefully back off
  • +
+
+ +

Configuration

+ +
+

use_score_for_tx

+
Enable/disable reactive score-based delay optimization
+
+ Default: false (disabled)
+ Options: true or false
+ When true: Activates quality-based delay multiplier when congestion detected (delay ≥ 50ms)
+ When false: Standard C++ MeshCore behavior, pure random delays, no score influence on timing
+ Location in config.yaml: +
+ repeater:
+   use_score_for_tx: false +
+
+
+ +
+

score_threshold

+
Reserved for future enhancement / statistics monitoring
+
+ Default: 0.3
+ Range: 0.0 - 1.0
+ Current Status: This value is read from config but not currently used in packet processing. It is reserved for future features.
+ Future Potential Uses: +
    +
  • Dashboard quality alerts when average packet score drops below threshold
  • +
  • Proactive packet filtering - dropping very poor quality packets upfront (below threshold)
  • +
  • Quality monitoring and trend statistics in web UI
  • +
  • Logging alerts for poor signal conditions
  • +
+ Recommendation: Leave at default (0.3). Changing it currently has no effect on packet processing. This setting will become active once future quality monitoring features are implemented. +
+
+ +

When to Enable Reactive Scoring

+ +
+
+
Enable (use_score_for_tx: true)
+

+ • High-traffic networks where collisions are frequent
+ • Noisy environments with poor average signal quality
+ • You want to prioritize high-quality packets during congestion
+ • Testing adaptive network behavior
+ • Duty-cycle constrained regions (EU) with limited bandwidth +

+
+ +
+
Disable (use_score_for_tx: false)
+

+ • Low-traffic networks where congestion is rare
+ • You want pure C++ MeshCore compatibility
+ • Consistent delay behavior is more important than efficiency
+ • New deployments - start simple and tune later
+ +

+
+
+ +
+

Important: 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.

+
+
+

Configuration Impact on Scoring

+ +

Your repeater's configuration settings directly affect packet scoring and processing behavior.

+ +

Radio Configuration Parameters

+ +
+
+
Spreading Factor (SF)
+

Current setting: SF 8
+ Higher SF (9-12): Better range and SNR, but slower transmission, more airtime consumed
+ Lower SF (7): Faster transmission, less airtime, but worse sensitivity and range
+ Score impact: Higher SF generally improves SNR = higher scores, but increases payload duration penalty

+
+ +
+
Bandwidth (BW)
+

Current setting: 62.5 kHz
+ Wider BW (125 kHz): Faster data rate, less airtime per byte, but worse sensitivity
+ Narrower BW (31.25 kHz): Better sensitivity, but slower transmission
+ Score impact: BW affects SNR - narrower = potentially better SNR but longer TX times

+
+ +
+
TX Power
+

Current setting: 14 dBm
+ Higher power: Better outbound range, but may increase noise at nearby receivers
+ Lower power: Reduces interference, saves energy, but limits outbound range
+ Score impact: TX power only affects outgoing transmissions, not received score

+
+ +
+
Coding Rate (CR)
+

Current setting: 4/8
+ Higher CR (4/7): Less error correction, faster transmission, more airtime efficient
+ Lower CR (4/8): More error correction, better resilience to interference
+ Score impact: Higher CR can improve SNR in clean environments, reduce it in noisy ones

+
+
+ +

Duty Cycle Configuration

+ +
+

Current Duty Cycle Limit: 6% max airtime per hour

+

This means your repeater can spend at most 3.6 minutes (21.6 seconds per minute) transmitting per hour. How this affects packet handling:

+
    +
  • When below limit: All packets retransmitted if they pass validation
  • +
  • When approaching limit: Incoming packets may be dropped if airtime budget is exhausted
  • +
  • When limit reached: All new transmissions are dropped until the duty cycle budget resets (each minute)
  • +
+

Important: 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.

+
+ +

Airtime Consumption Example

+ +
+

Scenario: 100-byte packet at SF8, BW 62.5 kHz, CR 4/8
+ Airtime: ~512 ms
+ At 6% duty cycle: Can transmit ~420 packets/hour maximum
+ Effect on score: High volume of large packets will consume budget quickly, causing lower-scored packets to be dropped +

+
+ + + +
+

Configuration Settings Reference

+ +

The repeater is configured via config.yaml. This section explains key settings and how they affect packet performance.

+ +
+

Important: Packet Score (signal quality) and TX Delay (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.

+
+ +

Delay Settings

+ +
+

tx_delay_factor

+
Flood mode transmission delay multiplier
+
+ Default: 1.0
+ Purpose: Scales the base collision-avoidance delay for flood packets.
+ Formula: delay = random(0-5) × (airtime × 52/50 ÷ 2) × tx_delay_factor
+ Effect: Higher values = longer delays between flood packet retransmissions, reducing collisions but increasing latency. Lower values speed up propagation in low-traffic areas.
+ Typical range: 0.5 - 2.0 (0.5 = faster, 2.0 = collision-resistant) +
+
+ +
+

direct_tx_delay_factor

+
Direct mode transmission delay (in seconds)
+
+ Default: 0.5 seconds
+ Purpose: Fixed delay for direct-routed packets (packets specifically addressed to this repeater).
+ Effect: Direct packets wait this many seconds before retransmission. Direct packets bypass the collision-avoidance algorithm and use a fixed delay instead.
+ Note: Typically lower than flood delays to prioritize DIRECT packets. 0 = immediate forwarding.
+ Typical range: 0 - 2.0 seconds +
+
+ +

How TX Delay is Calculated

+ +

The TX Delay shown in the packet table follows the MeshCore C++ implementation for collision avoidance:

+ +
+

For FLOOD packets (broadcast):
+ TX Delay = random(0 to 5) × (airtime_ms × 52/50 ÷ 2) × tx_delay_factor ÷ 1000

+ For DIRECT packets (addressed to this repeater):
+ TX Delay = direct_tx_delay_factor (fixed, in seconds)

+ Optional Reactive Scoring:
+ If use_score_for_tx is enabled AND delay ≥ 50ms:
+ TX Delay = base_delay × max(0.2, 1.0 - packet_score)
+ This applies a quality-based multiplier during congestion: high-score packets get shorter delays (priority), low-score packets get longer delays (backoff).

+ Example: FLOOD packet with 100ms airtime, tx_delay_factor=1.0, score=0.8:
+ • Base delay = (100 × 52/50 ÷ 2) = 52 ms
+ • With random(0-5) multiplier: 0-260 ms (before score adjustment)
+ • If ≥50ms AND score adjustment active: 0-260ms × max(0.2, 1.0-0.8) = 0-260ms × 0.2 = 0-52ms (prioritized)

+ Tuning: 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. +

+
+ +

Duty Cycle Constraints

+ +
+

max_airtime_per_minute

+
Maximum transmission time per minute in milliseconds
+
+ Common values:
+ • 3600 ms/min = 100% duty cycle (US/AU FCC, no restriction)
+ • 36 ms/min = 1% duty cycle (EU ETSI standard)
+ • 360 ms/min = 10% duty cycle (compromise for EU testing)

+ Effect on packet handling: Duty cycle enforcement is independent of packet score. 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.
+ TX Delay impact: 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.
+ Packet distribution during high traffic: 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. +
+
+ +

How These Work Together

+ +
+

Example Scenario - Packet Forwarding with Delay:

+

You receive 3 packets with different routes and sizes (tx_delay_factor=1.0, direct_tx_delay_factor=0.5s):

+
    +
  • Packet A: Route DIRECT, 50 bytes → TX Delay = 0.5 seconds (fixed)
  • +
  • Packet B: Route FLOOD, 100 bytes → TX Delay = random(0-5) × 52ms × 1.0 = 0-260 ms
  • +
  • Packet C: Route FLOOD, 150 bytes → TX Delay = random(0-5) × 78ms × 1.0 = 0-390 ms
  • +
+

Processing order (without duty cycle limits):

+
    +
  • Packet A: Waits 0.5s, then forwards (direct packets get fixed priority)
  • +
  • Packets B & C: Random delays prevent collision, lower packet transmitted first if random lucky
  • +
+

If duty cycle ~95% full: Still forwards all three, but with increased TX delays. If insufficient airtime remains for a packet, it is dropped immediately (not queued)

+
+ +

Optimization Tips

+ +
    +
  • For high-traffic/interference: Increase tx_delay_factor to 1.5-2.0 to reduce collisions with more randomization
  • +
  • For low-traffic areas: Decrease tx_delay_factor to 0.5 for faster propagation
  • +
  • For priority direct packets: Lower direct_tx_delay_factor below 0.5s for faster handling
  • +
  • For duty-cycle constrained regions (EU): Keep default settings; airtime budget enforces fairness
  • +
  • Monitor TX Delay column: Increasing delays indicate network congestion or approaching duty cycle limits
  • +
+
+ + ↑ Back to Top +
+
+
+
+ + + + diff --git a/repeater/templates/logs.html b/repeater/templates/logs.html new file mode 100644 index 0000000..22317e0 --- /dev/null +++ b/repeater/templates/logs.html @@ -0,0 +1,238 @@ + + + + pyMC Repeater - Logs + + + + + +
+ + + + +
+
+

System Logs

+

Real-time system events and diagnostics

+
+ + +
+
+ + + +
+
+ +
+
+ + +
+
+ [Loading...] + INFO + Fetching system logs... +
+
+
+
+ + + + diff --git a/repeater/templates/nav.html b/repeater/templates/nav.html new file mode 100644 index 0000000..95f9175 --- /dev/null +++ b/repeater/templates/nav.html @@ -0,0 +1,432 @@ + + + + + + diff --git a/repeater/templates/neighbors.html b/repeater/templates/neighbors.html new file mode 100644 index 0000000..117d6d9 --- /dev/null +++ b/repeater/templates/neighbors.html @@ -0,0 +1,395 @@ + + + + pyMC Repeater - Neighbors + + + + + +
+ + + + +
+
+

Neighbor Repeaters

+
+ Tracking: 0 repeaters + Updated: {{ last_updated }} +
+
+ + +
+

Discovered Repeaters

+
+ + + + + + + + + + + + + + + + + + + +
Node NamePublic KeyContact TypeLocationRSSISNRLast SeenFirst SeenAdvert Count
+ No repeaters discovered yet - waiting for adverts... +
+
+
+
+
+ + + + + + diff --git a/repeater/templates/statistics.html b/repeater/templates/statistics.html new file mode 100644 index 0000000..64e1009 --- /dev/null +++ b/repeater/templates/statistics.html @@ -0,0 +1,335 @@ + + + + pyMC Repeater - Statistics + + + + + + + +
+ + + + +
+
+

Statistics

+

Detailed performance analytics and metrics

+
+ + +

Summary

+
+
+
Total RX
+
0packets
+
+ +
+
Total TX
+
0packets
+
+ +
+
Success Rate
+
0%
+
+
+ + +

Performance Charts

+
+
+

RX vs TX Over Time

+
+ +
+
+ +
+

Packet Type Distribution

+
+ +
+
+ +
+

Signal Metrics Over Time

+
+ +
+
+ +
+

Route Type Distribution

+
+ +
+
+
+
+
+ + + + diff --git a/repeater/templates/style.css b/repeater/templates/style.css new file mode 100644 index 0000000..ed16ed9 --- /dev/null +++ b/repeater/templates/style.css @@ -0,0 +1,1848 @@ +/* ============================================================================ + LoRa Repeater Dashboard - Professional Design System + + Design Philosophy: + - Modern, minimal aesthetic inspired by contemporary design systems + - Consistent spacing, subtle depth, and refined typography + - Accessible colour palette with WCAG AA+ contrast ratios + - Responsive, mobile-first approach + ============================================================================ */ + +/* ============================================================================ + COLOUR PALETTE & DESIGN TOKENS + ============================================================================ */ + +:root { + /* Colour Palette */ + --color-bg-primary: #0f1419; + --color-bg-secondary: #1a1f2e; + --color-bg-tertiary: #252d3d; + --color-bg-hover: #2d3547; + + --color-text-primary: #f1f3f5; + --color-text-secondary: #a8b1c3; + --color-text-tertiary: #7d8599; + + --color-border: #3a4454; + --color-border-light: #2d3547; + + /* Brand Accent */ + --color-accent-primary: #00d4ff; + --color-accent-primary-hover: #00e5ff; + --color-accent-primary-dim: rgba(0, 212, 255, 0.15); + + /* Status Colours (accessible) */ + --color-success: #10b981; + --color-success-dim: rgba(16, 185, 129, 0.15); + --color-warning: #f59e0b; + --color-warning-dim: rgba(245, 158, 11, 0.15); + --color-error: #ef4444; + --color-error-dim: rgba(239, 68, 68, 0.15); + --color-info: #3b82f6; + --color-info-dim: rgba(59, 130, 246, 0.15); + + /* Spacing Scale (8px base) */ + --spacing-xs: 0.25rem; /* 4px */ + --spacing-sm: 0.5rem; /* 8px */ + --spacing-md: 1rem; /* 16px */ + --spacing-lg: 1.5rem; /* 24px */ + --spacing-xl: 2rem; /* 32px */ + --spacing-2xl: 3rem; /* 48px */ + + /* Sizing */ + --size-sidebar: 280px; + --size-header-height: 64px; + + /* Shadows */ + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05); + --shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1); + --shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.1); + --shadow-xl: 0 20px 25px rgba(0, 0, 0, 0.15); + + /* Border Radius */ + --radius-sm: 0.375rem; /* 6px */ + --radius-md: 0.5rem; /* 8px */ + --radius-lg: 0.75rem; /* 12px */ + + /* Transitions */ + --transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1); + --transition-base: 250ms cubic-bezier(0.4, 0, 0.2, 1); + --transition-slow: 350ms cubic-bezier(0.4, 0, 0.2, 1); + + /* Typography */ + --font-family-base: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Helvetica Neue", sans-serif; + --font-family-mono: "SFMono-Regular", "Consolas", "Liberation Mono", "Menlo", monospace; + + --font-size-xs: 0.75rem; /* 12px */ + --font-size-sm: 0.875rem; /* 14px */ + --font-size-base: 1rem; /* 16px */ + --font-size-lg: 1.125rem; /* 18px */ + --font-size-xl: 1.25rem; /* 20px */ + --font-size-2xl: 1.5rem; /* 24px */ + --font-size-3xl: 2rem; /* 32px */ + + --font-weight-regular: 400; + --font-weight-medium: 500; + --font-weight-semibold: 600; + --font-weight-bold: 700; +} + +/* ============================================================================ + RESET & GLOBAL STYLES + ============================================================================ */ + +*, +*::before, +*::after { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +html { + scroll-behavior: smooth; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +body { + font-family: var(--font-family-base); + font-size: var(--font-size-base); + font-weight: var(--font-weight-regular); + line-height: 1.5; + color: var(--color-text-primary); + background: var(--color-bg-primary); + height: 100vh; +} + +/* Reset link styling globally */ +a { + color: inherit; + text-decoration: none; +} + +a:visited { + color: inherit; +} + +/* ============================================================================ + LAYOUT: SIDEBAR + CONTENT + ============================================================================ */ + +.layout { + display: flex; + height: 100vh; + overflow: hidden; +} + +/* Desktop layout - no extra padding */ +@media (min-width: 769px) { + .layout { + padding-top: 0; + } +} + +/* SIDEBAR */ + +.sidebar { + width: var(--size-sidebar); + background: var(--color-bg-secondary); + border-right: 1px solid var(--color-border); + display: flex; + flex-direction: column; + overflow-y: auto; + overflow-x: hidden; +} + +.sidebar::-webkit-scrollbar { + width: 6px; +} + +.sidebar::-webkit-scrollbar-track { + background: var(--color-bg-secondary); +} + +.sidebar::-webkit-scrollbar-thumb { + background: var(--color-border); + border-radius: var(--radius-sm); +} + +.sidebar::-webkit-scrollbar-thumb:hover { + background: var(--color-border-light); +} + +/* SIDEBAR HEADER */ + +.sidebar-header { + padding: var(--spacing-lg); + border-bottom: 1px solid var(--color-border); + background: linear-gradient( + 135deg, + rgba(0, 212, 255, 0.1) 0%, + rgba(59, 130, 246, 0.05) 100% + ); +} + +.sidebar-header h1 { + font-size: var(--font-size-xl); + font-weight: var(--font-weight-bold); + color: var(--color-accent-primary); + margin-bottom: var(--spacing-xs); + letter-spacing: -0.5px; +} + +.sidebar-header .node-name { + font-size: var(--font-size-sm); + color: var(--color-text-secondary); + font-weight: var(--font-weight-regular); +} + +.sidebar-header .node-pubkey { + font-size: 0.7rem; + color: var(--color-text-tertiary); + font-family: 'Courier New', monospace; + margin-top: 0.25rem; + word-break: break-all; +} + +/* Hide menu toggle on desktop */ +.menu-toggle { + display: none !important; +} + +/* ============================================================================ + MODERN SIDEBAR NAVIGATION + ============================================================================ */ + +.sidebar-nav { + flex-grow: 1; + display: flex; + flex-direction: column; + gap: 2rem; + padding: 1rem; +} + +.nav-section { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.nav-section-title { + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.08em; + font-weight: 600; + color: var(--color-text-tertiary); + margin-bottom: 0.5rem; + padding: 0; +} + +.nav-item { + display: flex; + align-items: center; + gap: 0.75rem; + color: var(--color-text-secondary); + text-decoration: none; + padding: 0.85rem 1rem; + border-radius: 12px; + transition: background 250ms ease, color 250ms ease; + font-size: 0.95rem; + font-weight: 500; + cursor: pointer; + user-select: none; +} + +.nav-item:hover { + background: rgba(255, 255, 255, 0.08); + color: #60a5fa; +} + +.nav-item.active { + background: #3b82f6; + color: #ffffff; + font-weight: 600; + box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.3); +} + +.nav-item .icon { + width: 1.5rem; + height: 1.5rem; + flex-shrink: 0; + stroke: currentColor; + stroke-width: 1.5; + opacity: 0.9; + transition: opacity 250ms ease; +} + +.nav-item:hover .icon, +.nav-item.active .icon { + opacity: 1; +} + +.nav-action { + background: rgba(16, 185, 129, 0.15) !important; + border: 1px solid rgba(16, 185, 129, 0.4); + cursor: pointer; + color: #ffffff; +} + +.nav-action:hover { + background: rgba(16, 185, 129, 0.25) !important; + color: #ffffff; + border-color: rgba(16, 185, 129, 0.6); +} + +.nav-action:active { + background: rgba(16, 185, 129, 0.35) !important; + color: #ffffff; +} + +.nav-action:disabled { + opacity: 0.6; + cursor: not-allowed; + background: rgba(16, 185, 129, 0.1) !important; + color: rgba(255, 255, 255, 0.6); +} + +.nav-button { + display: flex; + align-items: center; + gap: 0.75rem; + color: #ffffff; + background: linear-gradient(135deg, #10b981 0%, #059669 100%); + border: none; + padding: 0.85rem 1rem; + border-radius: 12px; + transition: all 250ms ease; + font-size: 0.95rem; + font-weight: 600; + cursor: pointer; + user-select: none; + width: 100%; + text-align: left; + box-shadow: 0 2px 8px rgba(16, 185, 129, 0.3); +} + +.nav-button:hover { + background: linear-gradient(135deg, #059669 0%, #047857 100%); + box-shadow: 0 4px 12px rgba(16, 185, 129, 0.4); + transform: translateY(-1px); +} + +.nav-button:active { + transform: translateY(0); + box-shadow: 0 2px 6px rgba(16, 185, 129, 0.3); +} + +.nav-button .icon { + width: 1.5rem; + height: 1.5rem; + flex-shrink: 0; + stroke: currentColor; + stroke-width: 1.5; +} + +svg.icon { + display: inline-block; + width: 1.5rem !important; + height: 1.5rem !important; + min-width: 1.5rem; + min-height: 1.5rem; + vertical-align: middle; + flex-shrink: 0; +} + +/* SIDEBAR CONTENT WRAPPER - Desktop: transparent wrapper, doesn't affect layout */ + +.sidebar-content-wrapper { + display: contents; /* Makes wrapper invisible on desktop, children act as if wrapper doesn't exist */ +} + +/* SIDEBAR FOOTER */ + +.sidebar-footer { + margin-top: auto; /* Push footer to bottom of sidebar */ + padding: var(--spacing-lg); + border-top: 1px solid var(--color-border); + background: var(--color-bg-tertiary); +} + +.status-badge { + display: inline-block; + background: var(--color-success-dim); + color: var(--color-success); + padding: var(--spacing-xs) var(--spacing-sm); + border-radius: var(--radius-sm); + font-size: var(--font-size-xs); + font-weight: var(--font-weight-semibold); +} + +.version-badge { + display: inline-block; + background: rgba(100, 116, 139, 0.15); + color: var(--color-text-secondary); + padding: var(--spacing-xs) var(--spacing-sm); + border-radius: var(--radius-sm); + font-size: var(--font-size-xs); + font-weight: var(--font-weight-semibold); + font-family: monospace; +} + +/* Control Buttons */ +.control-buttons { + display: flex; + flex-direction: column; + gap: var(--spacing-sm); + margin-bottom: var(--spacing-md); +} + +.control-btn { + display: flex; + align-items: center; + gap: var(--spacing-sm); + padding: var(--spacing-sm) var(--spacing-md); + background: var(--color-bg-secondary); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + color: var(--color-text-secondary); + cursor: pointer; + transition: all var(--transition-base); + font-family: var(--font-family-base); + font-size: var(--font-size-sm); + width: 100%; + text-align: left; +} + +.control-btn:hover:not(.control-btn-active):not(.control-btn-warning) { + background: var(--color-bg-hover); + border-color: var(--color-accent-primary); + transform: translateY(-1px); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); +} + +.control-btn.control-btn-active:hover, +.control-btn.control-btn-warning:hover { + transform: translateY(-1px); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); + filter: brightness(1.1); +} + +.control-btn:active { + transform: translateY(0); +} + +.control-btn:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; +} + +.control-btn .icon { + width: 1.25rem; + height: 1.25rem; + flex-shrink: 0; + stroke: currentColor; +} + +.control-label { + display: flex; + flex-direction: column; + gap: 2px; + flex: 1; +} + +.control-title { + font-size: var(--font-size-xs); + color: var(--color-text-tertiary); + text-transform: uppercase; + letter-spacing: 0.5px; + font-weight: var(--font-weight-semibold); +} + +.control-value { + font-size: var(--font-size-sm); + color: var(--color-text-primary); + font-weight: var(--font-weight-semibold); +} + +.control-btn.control-btn-active { + border-color: var(--color-success) !important; + background: var(--color-success-dim) !important; +} + +.control-btn.control-btn-active .icon { + stroke: var(--color-success) !important; +} + +.control-btn.control-btn-active .control-title { + color: var(--color-text-secondary) !important; +} + +.control-btn.control-btn-active .control-value { + color: var(--color-success) !important; +} + +.control-btn.control-btn-warning { + border-color: var(--color-warning) !important; + background: var(--color-warning-dim) !important; +} + +.control-btn.control-btn-warning .icon { + stroke: var(--color-warning) !important; +} + +.control-btn.control-btn-warning .control-title { + color: var(--color-text-secondary) !important; +} + +.control-btn.control-btn-warning .control-value { + color: var(--color-warning) !important; +} + +.duty-cycle-stats { + margin: var(--spacing-md) 0; +} + +.duty-cycle-bar-container { + width: 100%; + height: 6px; + background: var(--color-bg-secondary); + border-radius: 3px; + overflow: hidden; + margin-bottom: var(--spacing-xs); +} + +.duty-cycle-bar { + height: 100%; + background: var(--color-success); + transition: width 0.3s ease, background-color 0.3s ease; + border-radius: 3px; +} + +.duty-cycle-text { + display: block; + color: var(--color-text-secondary); + font-size: var(--font-size-xs); + line-height: 1.4; +} + +.duty-cycle-text strong { + color: var(--color-text-primary); + font-weight: var(--font-weight-semibold); +} + +.sidebar-footer small { + display: block; + color: var(--color-text-tertiary); + font-size: var(--font-size-xs); + line-height: 1.6; +} + +/* CONTENT AREA */ + +.content { + flex: 1; + overflow-y: auto; + overflow-x: hidden; + padding: var(--spacing-2xl); + background: var(--color-bg-primary); +} + +.content::-webkit-scrollbar { + width: 8px; +} + +.content::-webkit-scrollbar-track { + background: var(--color-bg-primary); +} + +.content::-webkit-scrollbar-thumb { + background: var(--color-border); + border-radius: var(--radius-sm); +} + +.content::-webkit-scrollbar-thumb:hover { + background: var(--color-text-tertiary); +} + + +/* ============================================================================ + HEADER & TYPOGRAPHY + ============================================================================ */ + +header { + margin-bottom: var(--spacing-2xl); + border-bottom: 1px solid var(--color-border); + padding-bottom: var(--spacing-lg); +} + +h1 { + font-size: var(--font-size-3xl); + font-weight: var(--font-weight-bold); + color: var(--color-text-primary); + margin-bottom: var(--spacing-md); + letter-spacing: -0.5px; +} + +h2 { + font-size: var(--font-size-2xl); + font-weight: var(--font-weight-bold); + color: var(--color-text-primary); + margin: var(--spacing-2xl) 0 var(--spacing-lg) 0; + letter-spacing: -0.25px; +} + +h3 { + font-size: var(--font-size-lg); + font-weight: var(--font-weight-semibold); + color: var(--color-text-primary); +} + +.header-info { + display: flex; + justify-content: space-between; + align-items: center; + font-size: var(--font-size-sm); + color: var(--color-text-secondary); +} + + +/* ============================================================================ + STAT CARDS + ============================================================================ */ + +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: var(--spacing-lg); + margin-bottom: var(--spacing-2xl); +} + +.stat-card { + background: var(--color-bg-secondary); + padding: var(--spacing-lg); + border-radius: var(--radius-lg); + border: 1px solid var(--color-border); + box-shadow: var(--shadow-sm); + transition: all var(--transition-base); + border-left: 3px solid var(--color-accent-primary); +} + +.stat-card:hover { + border-color: var(--color-border-light); + box-shadow: var(--shadow-md); + transform: translateY(-2px); +} + +.stat-card.success { + border-left-color: var(--color-success); +} + +.stat-card.warning { + border-left-color: var(--color-warning); +} + +.stat-card.error { + border-left-color: var(--color-error); +} + +.stat-label { + display: block; + font-size: var(--font-size-xs); + font-weight: var(--font-weight-semibold); + color: var(--color-text-secondary); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: var(--spacing-md); +} + +.stat-value { + font-size: var(--font-size-3xl); + font-weight: var(--font-weight-bold); + color: var(--color-accent-primary); + line-height: 1; + margin-bottom: var(--spacing-sm); +} + +.stat-unit { + font-size: var(--font-size-sm); + color: var(--color-text-tertiary); + margin-left: var(--spacing-xs); +} + + +/* ============================================================================ + CHARTS + ============================================================================ */ + +.charts-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(480px, 1fr)); + gap: var(--spacing-lg); + margin-bottom: var(--spacing-2xl); +} + +.chart-card { + background: var(--color-bg-secondary); + padding: var(--spacing-lg); + border-radius: var(--radius-lg); + border: 1px solid var(--color-border); + box-shadow: var(--shadow-sm); + transition: all var(--transition-base); +} + +.chart-card:hover { + border-color: var(--color-border-light); + box-shadow: var(--shadow-md); +} + +.chart-card h3 { + font-size: var(--font-size-sm); + font-weight: var(--font-weight-semibold); + color: var(--color-text-primary); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: var(--spacing-lg); +} + +.chart-container { + position: relative; + height: 240px; +} + +/* Chart.js customization */ +.chart-container canvas { + max-height: 240px; +} + + +/* ============================================================================ + TABLES + ============================================================================ */ + +.table-container { + overflow-x: auto; + border-radius: var(--radius-lg); + box-shadow: var(--shadow-sm); +} + +table { + width: 100%; + border-collapse: collapse; + background: var(--color-bg-secondary); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + overflow: hidden; +} + +thead { + background: linear-gradient( + 90deg, + rgba(0, 212, 255, 0.15) 0%, + rgba(59, 130, 246, 0.1) 100% + ); + border-bottom: 1px solid var(--color-border); +} + +th { + padding: var(--spacing-md); + text-align: left; + font-weight: var(--font-weight-semibold); + font-size: var(--font-size-xs); + color: var(--color-text-primary); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +td { + padding: var(--spacing-md); + border-bottom: 1px solid var(--color-border-light); + font-size: var(--font-size-sm); + color: var(--color-text-secondary); +} + +tbody tr { + transition: background-color var(--transition-fast); +} + +tbody tr:hover { + background: var(--color-bg-tertiary); +} + +tbody tr:last-child td { + border-bottom: none; +} + +/* Table cell styles */ +.packet-type { + color: var(--color-accent-primary); + font-weight: var(--font-weight-medium); + font-size: var(--font-size-xs); +} + +.route-flood { + color: var(--color-warning); + background: var(--color-warning-dim); + padding: var(--spacing-xs) var(--spacing-sm); + border-radius: var(--radius-sm); + font-weight: var(--font-weight-medium); + font-size: var(--font-size-xs); + display: inline-block; +} + +.route-direct { + color: var(--color-success); + background: var(--color-success-dim); + padding: var(--spacing-xs) var(--spacing-sm); + border-radius: var(--radius-sm); + font-weight: var(--font-weight-medium); + font-size: var(--font-size-xs); + display: inline-block; +} + +.score { + color: var(--color-accent-primary); + font-weight: var(--font-weight-semibold); +} + +.status-tx { + color: var(--color-success); + font-weight: var(--font-weight-medium); +} + +.status-wait { + color: var(--color-warning); + font-weight: var(--font-weight-medium); +} + +.status-drop { + color: var(--color-error); + font-weight: var(--font-weight-medium); +} + +/* Path and hash styling */ +.path-hash, +.src-dst-hash { + font-family: 'Courier New', monospace; + font-size: 0.85rem; + letter-spacing: 0.3px; + word-break: break-all; + line-height: 1.5; + display: block; + margin: var(--spacing-sm) 0; +} + +.path-hash { + color: #e0e0e0; + background: #2a2a2a; + padding: var(--spacing-sm) var(--spacing-md); + border-radius: 4px; + overflow-wrap: break-word; + border: 1px solid #404040; +} + +.src-dst-hash { + color: #ffffff; + background: #1e3a8a; + padding: var(--spacing-sm) var(--spacing-md); + border-radius: 4px; + font-weight: var(--font-weight-bold); + border: 1px solid #3b82f6; +} + +.my-hash { + background: #dc2626; + color: #ffffff; + padding: 2px 6px; + border-radius: 3px; + font-weight: var(--font-weight-bold); + display: inline-block; + font-size: 0.8rem; + border: 1px solid #ef4444; +} + +.path-transform { + color: #999999; + font-size: 0.75rem; + display: inline; +} + +.dupe-badge { + background: #991b1b; + color: #fca5a5; + padding: 3px 8px; + border-radius: 3px; + font-size: 0.7rem; + font-weight: var(--font-weight-bold); + display: inline-block; + margin-left: var(--spacing-sm); + border: 1px solid #dc2626; +} + +.drop-reason { + color: #fca5a5; + font-size: 0.75rem; + display: block; + margin-top: 3px; +} + +.na { + color: var(--color-text-tertiary); + font-style: italic; +} + +.empty-message { + text-align: center; + color: var(--color-text-tertiary); + padding: var(--spacing-2xl) var(--spacing-lg); + font-style: italic; + font-size: var(--font-size-sm); +} + + +/* ============================================================================ + CONFIGURATION SECTION + ============================================================================ */ + +.config-section { + background: var(--color-bg-secondary); + padding: var(--spacing-lg); + border-radius: var(--radius-lg); + border: 1px solid var(--color-border); + margin-bottom: var(--spacing-lg); + box-shadow: var(--shadow-sm); +} + +.config-section h3 { + margin-top: 0; + margin-bottom: var(--spacing-lg); + padding-bottom: var(--spacing-md); + border-bottom: 1px solid var(--color-border); +} + +.config-item { + display: grid; + grid-template-columns: 180px 1fr; + gap: var(--spacing-lg); + margin-bottom: var(--spacing-lg); + align-items: flex-start; +} + +.config-item:last-child { + margin-bottom: 0; +} + +.config-label { + font-size: var(--font-size-sm); + font-weight: var(--font-weight-semibold); + color: var(--color-text-primary); +} + +.config-value { + background: var(--color-bg-tertiary); + padding: var(--spacing-md); + border-radius: var(--radius-md); + border: 1px solid var(--color-border); + color: var(--color-accent-primary); + font-family: var(--font-family-mono); + font-size: var(--font-size-sm); + word-break: break-all; + line-height: 1.6; +} + +.config-help { + grid-column: 2; + font-size: var(--font-size-xs); + color: var(--color-text-secondary); + margin-top: var(--spacing-xs); + font-style: italic; + line-height: 1.4; +} + +.info-box { + background: var(--color-accent-primary-dim); + border: 1px solid rgba(0, 212, 255, 0.3); + border-left: 3px solid var(--color-accent-primary); + padding: var(--spacing-lg); + border-radius: var(--radius-lg); + margin-bottom: var(--spacing-lg); + color: var(--color-text-secondary); + font-size: var(--font-size-sm); + line-height: 1.6; +} + +.score-formula { + margin-bottom: var(--spacing-xl); +} + +.score-formula code { + background: var(--color-bg-tertiary); + border: 1px solid var(--color-border); + padding: 2px 6px; + border-radius: var(--radius-sm); + font-size: 0.9em; + color: var(--color-accent-primary); + font-family: var(--font-family-mono); +} + +.score-formula table { + width: 100%; + border-collapse: collapse; +} + +.score-formula ul { + margin-left: var(--spacing-lg); + margin-top: var(--spacing-sm); + margin-bottom: var(--spacing-md); +} + +.score-formula li { + margin-bottom: var(--spacing-sm); + color: var(--color-text-secondary); +} + + +/* ============================================================================ + LOG VIEWER + ============================================================================ */ + +.log-container { + background: var(--color-bg-tertiary); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + padding: var(--spacing-lg); + font-family: var(--font-family-mono); + font-size: var(--font-size-sm); + max-height: 500px; + overflow-y: auto; + box-shadow: var(--shadow-sm); + line-height: 1.7; +} + +.log-container::-webkit-scrollbar { + width: 6px; +} + +.log-container::-webkit-scrollbar-track { + background: var(--color-bg-tertiary); +} + +.log-container::-webkit-scrollbar-thumb { + background: var(--color-border); + border-radius: var(--radius-sm); +} + +.log-line { + display: flex; + gap: var(--spacing-md); + margin-bottom: var(--spacing-sm); + padding: var(--spacing-sm) 0; + border-bottom: 1px solid var(--color-border-light); +} + +.log-line:last-child { + border-bottom: none; + margin-bottom: 0; +} + +.log-time { + color: var(--color-text-tertiary); + min-width: 100px; + flex-shrink: 0; +} + +.log-level { + display: inline-block; + min-width: 70px; + padding: var(--spacing-xs) var(--spacing-sm); + border-radius: var(--radius-sm); + font-weight: var(--font-weight-semibold); + font-size: var(--font-size-xs); + text-transform: uppercase; + text-align: center; + flex-shrink: 0; +} + +.log-level.info { + background: var(--color-info-dim); + color: var(--color-info); +} + +.log-level.warning { + background: var(--color-warning-dim); + color: var(--color-warning); +} + +.log-level.error { + background: var(--color-error-dim); + color: var(--color-error); +} + +.log-level.debug { + background: var(--color-accent-primary-dim); + color: var(--color-accent-primary); +} + +.log-msg { + color: var(--color-text-secondary); + word-break: break-word; + flex: 1; +} + + +/* ============================================================================ + UTILITY CLASSES + ============================================================================ */ + +.refresh-info { + text-align: right; + margin-top: var(--spacing-lg); + font-size: var(--font-size-xs); + color: var(--color-text-tertiary); +} + +/* ============================================================================ + RESPONSIVE DESIGN + ============================================================================ */ + +/* Tablet: 768px and below */ +@media (max-width: 768px) { + :root { + --size-sidebar: 100%; + } + + .layout { + flex-direction: column; + padding-top: 60px; + } + + .sidebar { + width: 100%; + height: 60px; + flex-direction: row; + border-right: none; + border-bottom: 1px solid var(--color-border); + overflow: hidden; + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 1000; + transition: none; + } + + .sidebar.menu-open { + height: 100vh; + height: 100dvh; /* Use dynamic viewport height for better mobile support */ + max-height: none; + overflow: hidden; /* Changed from auto - we'll scroll the content instead */ + position: fixed; + flex-direction: column; + display: flex; + top: 0; + left: 0; + right: 0; + bottom: 0; + } + + .layout { + padding-top: 60px; + } + + .content { + margin-top: 0; + } + + .sidebar-header { + border-right: none; + border-bottom: 1px solid var(--color-border); + padding: 0 var(--spacing-md); + display: flex; + align-items: center; + min-width: auto; + width: 100%; + height: 60px; + background: var(--color-bg-secondary); + position: relative; + flex-shrink: 0; /* Prevent header from shrinking */ + } + + .sidebar.menu-open .sidebar-header { + border-right: none; + border-bottom: 1px solid var(--color-border); + background: var(--color-bg-secondary); + position: sticky; /* Make header sticky at top */ + top: 0; + z-index: 10; + } + + /* Wrapper for scrollable content on mobile */ + .sidebar-content-wrapper { + display: contents; /* On mobile closed state, act transparent like desktop */ + } + + .sidebar.menu-open .sidebar-content-wrapper { + display: flex; + flex-direction: column; + flex: 1; + overflow-y: auto; + overflow-x: hidden; + -webkit-overflow-scrolling: touch; + min-height: 0; + position: relative; + } + + .sidebar-header h1 { + font-size: var(--font-size-lg); + margin-bottom: 0; + color: var(--color-accent-primary); + padding-right: var(--spacing-2xl); + } + + .sidebar-header .node-name { + display: none; + } + + .sidebar-header .node-pubkey { + display: none; + } + + /* Show menu toggle on mobile */ + .menu-toggle { + display: flex !important; + align-items: center; + justify-content: center; + width: 44px; + height: 44px; + background: none; + border: none; + color: var(--color-text-secondary); + cursor: pointer; + padding: 0; + margin: 0; + flex-shrink: 0; + position: absolute; + right: 8px; + top: 50%; + transform: translateY(-50%); + } + + .menu-toggle svg { + width: 24px; + height: 24px; + } + + .sidebar-nav { + display: none; + padding: 0; + gap: 0; + width: 100%; + margin: 0; + flex-direction: column; + } + + .sidebar.menu-open .sidebar-nav { + display: flex; + flex-direction: column; + flex-shrink: 0; /* Don't let nav shrink, let wrapper handle scroll */ + } + + .nav-section { + border-bottom: 1px solid var(--color-border-light); + padding: var(--spacing-md) var(--spacing-lg) 0; + } + + .nav-section:first-child { + padding-top: var(--spacing-lg); + } + + .nav-section:last-child { + padding-bottom: var(--spacing-lg); + } + + .nav-section-title { + font-size: 0.7rem; + } + + .nav-item { + border-radius: 8px; + margin-bottom: var(--spacing-sm); + font-size: 0.95rem; + padding: 1rem var(--spacing-lg); + } + + /* Hide footer on mobile by default */ + .sidebar-footer { + display: none; + } + + /* Show footer only when menu is open */ + .sidebar.menu-open .sidebar-footer { + display: flex !important; /* Force display as flex when menu is open */ + flex-direction: column; + gap: var(--spacing-md); + margin-top: 0 !important; /* Remove auto margin on mobile */ + flex-shrink: 0; /* Don't shrink the footer */ + padding: var(--spacing-md) var(--spacing-lg); + border-top: 1px solid var(--color-border); + background: var(--color-bg-secondary); + } + + .content { + padding: var(--spacing-lg); + overflow-y: auto; + width: 100%; + } + + h1 { + font-size: var(--font-size-2xl); + } + + h2 { + font-size: var(--font-size-xl); + margin: var(--spacing-xl) 0 var(--spacing-lg) 0; + } + + .stats-grid { + grid-template-columns: 1fr; + gap: var(--spacing-md); + margin-bottom: var(--spacing-xl); + } + + /* Improve stat card readability on tablets */ + .stat-card { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--spacing-md); + padding: var(--spacing-md) var(--spacing-lg); + } + + .stat-label { + font-size: var(--font-size-sm); + margin-bottom: 0; + flex: 1; + text-align: left; + } + + .stat-value { + font-size: var(--font-size-2xl); + margin-bottom: 0; + flex-shrink: 0; + } + + .charts-grid { + grid-template-columns: 1fr; + gap: var(--spacing-md); + margin-bottom: var(--spacing-xl); + } + + .config-item { + grid-template-columns: 1fr; + gap: var(--spacing-sm); + align-items: stretch; + } + + /* Mobile table: card-based layout - structured design */ + .table-container { + overflow-x: visible; + } + + table { + display: block; + width: 100%; + } + + thead { + display: none; + } + + tbody { + display: block; + width: 100%; + } + + + tbody tr { + display: block; + background: var(--color-bg-secondary); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + margin-bottom: var(--spacing-lg); + padding: 0; + overflow: hidden; + } + + 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); + } + + /* Card header with time and type */ + td:nth-child(1), /* Time */ + td:nth-child(2) /* Type */ { + display: inline-block; + padding: var(--spacing-md); + margin: 0; + border: none; + background: transparent; + } + + td:nth-child(1) { + font-size: var(--font-size-lg); + font-weight: var(--font-weight-semibold); + color: var(--color-text-primary); + width: auto; + margin-right: var(--spacing-md); + } + + td:nth-child(1)::before { + content: attr(data-label); + font-size: 0.65rem; + color: var(--color-text-tertiary); + text-transform: uppercase; + letter-spacing: 0.5px; + display: block; + margin-bottom: 4px; + font-weight: var(--font-weight-semibold); + } + + td:nth-child(2) { + float: right; + padding: var(--spacing-md); + } + + td:nth-child(2)::before { + content: attr(data-label); + font-size: 0.65rem; + color: var(--color-text-tertiary); + text-transform: uppercase; + letter-spacing: 0.5px; + display: block; + margin-bottom: 4px; + text-align: right; + font-weight: var(--font-weight-semibold); + } + + /* Path/Hashes section - full width with label */ + td:nth-child(5) { + display: block; + width: 100%; + padding: var(--spacing-md); + margin: 0; + border-top: 1px solid var(--color-border); + background: rgba(0, 0, 0, 0.2); + clear: both; + } + + td:nth-child(5)::before { + content: attr(data-label); + font-size: 0.65rem; + color: var(--color-text-tertiary); + text-transform: uppercase; + letter-spacing: 0.5px; + display: block; + margin-bottom: 8px; + font-weight: var(--font-weight-semibold); + } + + /* Route badge on right side */ + td:nth-child(3) { + position: absolute; + top: var(--spacing-md); + right: var(--spacing-md); + margin: 0; + padding: 0; + } + + td:nth-child(3)::before { + display: none; + } + + /* Metrics row - RSSI, SNR, SCORE, TX DELAY */ + td:nth-child(6), /* RSSI */ + td:nth-child(7), /* SNR */ + td:nth-child(8), /* SCORE */ + td:nth-child(9) /* TX DELAY */ { + display: inline-block; + width: calc(50% - 8px); + padding: var(--spacing-sm) var(--spacing-md); + margin: 0; + margin-right: 8px; + border: none; + text-align: left; + } + + td:nth-child(6)::before, + td:nth-child(7)::before, + td:nth-child(8)::before, + td:nth-child(9)::before { + content: attr(data-label); + font-size: 0.6rem; + color: var(--color-text-tertiary); + text-transform: uppercase; + letter-spacing: 0.5px; + display: block; + margin-bottom: 2px; + font-weight: var(--font-weight-semibold); + } + + td:nth-child(6), + td:nth-child(8) { + margin-right: 8px; + } + + td:nth-child(7), + td:nth-child(9) { + margin-right: 0; + } + + /* Status section - full width */ + td:nth-child(10) { + display: block; + width: 100%; + padding: var(--spacing-md); + margin: 0; + border-top: 1px solid var(--color-border); + } + + td:nth-child(10)::before { + content: attr(data-label); + font-size: 0.65rem; + color: var(--color-text-tertiary); + text-transform: uppercase; + letter-spacing: 0.5px; + display: block; + margin-bottom: 6px; + font-weight: var(--font-weight-semibold); + } + + /* Hide LEN - not critical for mobile */ + td:nth-child(4) { + display: none; + } + + /* Better card positioning with relative parent */ + tbody tr { + position: relative; + } + + /* Path hash styling on mobile */ + .path-hash, + .src-dst-hash { + font-size: 0.85rem; + padding: 4px 8px; + line-height: 1.4; + display: inline-block; + margin: 2px; + } + + .path-hash { + font-family: 'Courier New', monospace; + letter-spacing: 0.2px; + } + + .my-hash { + padding: 4px 8px; + font-size: 0.85rem; + display: inline-block; + } + + .path-arrow { + font-size: 1em; + padding: 0 4px; + } + + .stat-value { + font-size: var(--font-size-2xl); + } + + header { + margin-bottom: var(--spacing-xl); + } +} + +/* Tablet: 480px - 768px range */ +@media (max-width: 600px) { + /* Compact stat cards for tablet - horizontal layout */ + .stat-card { + padding: var(--spacing-md) var(--spacing-lg); + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--spacing-md); + } + + .stat-label { + font-size: var(--font-size-sm); + margin-bottom: 0; + flex: 1; + text-align: left; + } + + .stat-value { + font-size: var(--font-size-2xl); + margin-bottom: 0; + flex-shrink: 0; + } + + .stat-unit { + font-size: var(--font-size-sm); + } + + /* Show all details compactly - inline with labels */ + tbody tr { + padding: var(--spacing-md); + } + + 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; + } + + td::before { + font-size: 0.55rem; + padding: 1px 4px; + } + + /* Path/Hashes wraps to next line */ + td:nth-child(5) { + display: block; + width: 100%; + margin-right: 0; + margin-bottom: var(--spacing-sm); + } + + /* Optimize path hash display for tablet */ + .path-hash, + .src-dst-hash { + font-size: 0.78rem; + word-break: break-all; + padding: var(--spacing-sm) var(--spacing-md); + } + + .my-hash { + padding: 2px 5px; + font-size: 0.72rem; + } + + /* Status on its own line */ + td:nth-child(10) { + display: block; + width: 100%; + margin-right: 0; + padding-top: var(--spacing-xs); + border-top: 1px solid var(--color-border-light); + } +} + +/* Mobile: 480px and below */ +@media (max-width: 480px) { + .content { + padding: var(--spacing-md); + } + + h1 { + font-size: var(--font-size-xl); + } + + /* Compact stat cards for mobile - horizontal layout */ + .stat-card { + padding: var(--spacing-md); + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--spacing-md); + } + + .stat-label { + font-size: var(--font-size-sm); + margin-bottom: 0; + flex: 1; + text-align: left; + } + + .stat-value { + font-size: var(--font-size-xl); + margin-bottom: 0; + flex-shrink: 0; + } + + .stat-unit { + font-size: var(--font-size-xs); + } + + .chart-card { + padding: var(--spacing-md); + } + + .chart-container { + height: 200px; + } + + .log-container { + max-height: 300px; + font-size: var(--font-size-xs); + } + + .log-line { + flex-direction: column; + gap: var(--spacing-sm); + } + + .log-time { + min-width: auto; + } + + .log-level { + min-width: auto; + width: fit-content; + } + + /* Very compact table with all details */ + tbody tr { + padding: var(--spacing-sm); + } + + 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; + } + + td::before { + font-size: 0.5rem; + padding: 1px 3px; + } + + /* Full width items */ + td:nth-child(5), + td:nth-child(10) { + display: block; + width: 100%; + margin-right: 0; + margin-bottom: var(--spacing-xs); + } + + td:nth-child(10) { + padding-top: var(--spacing-xs); + border-top: 1px solid var(--color-border-light); + } + + td::before { + margin-bottom: 2px; + } + + /* Ultra-compact path hash for tiny screens */ + .path-hash, + .src-dst-hash { + font-size: 0.75rem; + padding: var(--spacing-sm) var(--spacing-md); + line-height: 1.4; + word-break: break-all; + white-space: normal; + } + + .path-hash { + font-family: 'Courier New', monospace; + letter-spacing: 0.1px; + } + + .my-hash { + padding: 2px 4px; + font-size: 0.7rem; + display: inline-block; + } + + .src-dst-hash { + font-size: 0.75rem; + } + + .path-transform { + font-size: 0.65rem; + display: block; + } + + .dupe-badge { + font-size: 0.65rem; + padding: 2px 6px; + } + + .drop-reason { + font-size: 0.7rem; + } + + /* Mobile filter buttons */ + #filterContainer { + flex-direction: column !important; + } + + .filter-btn { + width: 100%; + justify-content: center; + } + + #selectAllBtn, + #clearAllBtn { + width: 100%; + } +} + +/* ============================================================================ + ANIMATION & TRANSITIONS + ============================================================================ */ + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } +} + +/* Smooth transitions for interactive elements */ +button, +a, +input, +select, +textarea { + transition: all var(--transition-fast); +} + +/* ============================================================================ + ACCESSIBILITY + ============================================================================ */ + +/* Focus states for keyboard navigation */ +a:focus-visible, +button:focus-visible, +input:focus-visible, +select:focus-visible, +textarea:focus-visible { + outline: 2px solid var(--color-accent-primary); + outline-offset: 2px; +} + +/* High contrast mode support */ +@media (prefers-contrast: more) { + --color-border: #4a5568; + --color-text-secondary: #cbd5e0; + --color-accent-primary: #00e5ff; +} + +/* Dark mode preference (already applied by default) */ +@media (prefers-color-scheme: dark) { + /* Already dark - no changes needed */ +} diff --git a/setup-radio-config.sh b/setup-radio-config.sh new file mode 100644 index 0000000..ba23757 --- /dev/null +++ b/setup-radio-config.sh @@ -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'." diff --git a/uninstall.sh b/uninstall.sh new file mode 100644 index 0000000..b7c33bd --- /dev/null +++ b/uninstall.sh @@ -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 "----------------------------------"