diff --git a/.gitignore b/.gitignore index 012cf78..f045404 100644 --- a/.gitignore +++ b/.gitignore @@ -59,4 +59,4 @@ data/ # Logs *.log .DS_Store -syncpi.sh \ No newline at end of file +syncpi.sh diff --git a/README.md b/README.md index da5f638..e341724 100644 --- a/README.md +++ b/README.md @@ -286,7 +286,3 @@ This software is intended for educational and experimental purposes. Always test ## License This project is licensed under the MIT License - see the LICENSE file for details. - - - - diff --git a/config.yaml.example b/config.yaml.example index 39b7130..c711fa7 100644 --- a/config.yaml.example +++ b/config.yaml.example @@ -45,20 +45,20 @@ repeater: security: # Maximum number of authenticated clients (across all identities) max_clients: 1 - + # Admin password for full access admin_password: "admin123" - + # Guest password for limited access guest_password: "guest123" - + # Allow read-only access for clients without password/not in ACL allow_read_only: false - + # JWT secret key for signing tokens (auto-generated if not provided) # Generate with: python -c "import secrets; print(secrets.token_hex(32))" jwt_secret: "" - + # JWT token expiry time in minutes (default: 60 minutes / 1 hour) # Controls how long users stay logged in before needing to re-authenticate jwt_expiry_minutes: 60 @@ -81,7 +81,7 @@ identities: # - name: "TestBBS" # identity_key: "your_room_identity_key_hex_here" # type: "room_server" - # + # # # Room-specific settings # settings: # node_name: "Test BBS Room" @@ -89,7 +89,7 @@ identities: # longitude: 0.0 # admin_password: "room_admin_password" # guest_password: "room_guest_password" - + # Add more room servers as needed # - name: "SocialHub" # identity_key: "another_identity_key_hex_here" @@ -195,39 +195,39 @@ duty_cycle: mqtt: # Enable/disable MQTT publishing enabled: false - + # MQTT broker settings broker: "localhost" port: 1883 # Use 8883 for TLS/SSL, 80/443/9001 for WebSockets - + # Use WebSocket transport instead of standard TCP # Typically uses ports: 80 (ws://), 443 (wss://), or 9001 use_websockets: false - + # Authentication (optional) username: null password: null - + # TLS/SSL configuration (optional) # For public brokers with trusted certificates, just enable TLS: # tls: # enabled: true tls: enabled: false - + # Advanced TLS options (usually not needed for public brokers): - + # Custom CA certificate for server verification # Leave null to use system default CA certificates (recommended) ca_cert: null # e.g., "/etc/ssl/certs/ca-certificates.crt" - + # Client certificate and key for mutual TLS (rarely needed) client_cert: null # e.g., "/etc/pymc/client.crt" client_key: null # e.g., "/etc/pymc/client.key" - + # Skip certificate verification (insecure, not recommended) insecure: false - + # Base topic for publishing # Messages will be published to: {base_topic}/{node_name}/{packet|advert} base_topic: "meshcore/repeater" @@ -243,29 +243,29 @@ storage: retention: # Clean up SQLite records older than this many days sqlite_cleanup_days: 31 - + # RRD archives are managed automatically: # - 1 minute resolution for 1 week - # - 5 minute resolution for 1 month + # - 5 minute resolution for 1 month # - 1 hour resolution for 1 year letsmesh: enabled: false iata_code: "Test" # e.g., "SFO", "LHR", "Test" - + # ============================================================ # BROKER SELECTION MODE - Choose how to connect to brokers # ============================================================ - # + # # EXAMPLE 1: Single built-in broker (default, most common) # Connect to Europe only - simple, low bandwidth broker_index: 0 # 0 = Europe, 1 = US West - + # EXAMPLE 2: All built-in brokers for maximum redundancy # Survives single broker failure, best uptime # broker_index: -1 # or null - connects to both EU and US - + # EXAMPLE 3: Only custom brokers (private/self-hosted) # Ignores built-in LetsMesh brokers completely # broker_index: -2 @@ -274,7 +274,7 @@ letsmesh: # host: "mqtt.myserver.com" # port: 443 # audience: "mqtt.myserver.com" - + # EXAMPLE 4: Single built-in + custom backup # Use EU primary with your own backup # broker_index: 0 @@ -283,7 +283,7 @@ letsmesh: # host: "mqtt-backup.mydomain.com" # port: 8883 # audience: "mqtt-backup.mydomain.com" - + # EXAMPLE 5: All built-in + multiple custom (maximum redundancy) # EU + US + your own servers - best for critical deployments # broker_index: -1 @@ -297,14 +297,14 @@ letsmesh: # port: 443 # audience: "mqtt-2.mydomain.com" # ============================================================ - + status_interval: 300 owner: "" email: "" - + # Block specific packet types from being published to LetsMesh # If not specified or empty list, all types are published - # Available types: REQ, RESPONSE, TXT_MSG, ACK, ADVERT, GRP_TXT, + # Available types: REQ, RESPONSE, TXT_MSG, ACK, ADVERT, GRP_TXT, # GRP_DATA, ANON_REQ, PATH, TRACE, RAW_CUSTOM disallowed_packet_types: [] # - REQ # Don't publish requests diff --git a/convert_firmware_key.sh b/convert_firmware_key.sh index bd918e6..b5660d0 100755 --- a/convert_firmware_key.sh +++ b/convert_firmware_key.sh @@ -79,17 +79,17 @@ key_bytes = bytes.fromhex(key_hex) # Verify with pyMC if available try: from nacl.bindings import crypto_scalarmult_ed25519_base_noclamp - + scalar = key_bytes[:32] pubkey = crypto_scalarmult_ed25519_base_noclamp(scalar) - + print(f"Derived public key: {pubkey.hex()}") - + # Calculate address (MeshCore uses first byte of pubkey directly, not SHA256) address = pubkey[0] print(f"Node address: 0x{address:02x}") print() - + except ImportError: print("Warning: PyNaCl not available, skipping verification") print() diff --git a/debian/pymc-repeater.postinst b/debian/pymc-repeater.postinst index dc4f967..d2d57d7 100755 --- a/debian/pymc-repeater.postinst +++ b/debian/pymc-repeater.postinst @@ -38,13 +38,13 @@ case "$1" in echo "Installing pymc_core[hardware] from PyPI..." python3 -m pip install --break-system-packages 'pymc_core[hardware]>=1.0.7' || true fi - + # Install packages not available in Debian repos if ! python3 -c "import cherrypy_cors" 2>/dev/null; then echo "Installing cherrypy-cors from PyPI..." python3 -m pip install --break-system-packages 'cherrypy-cors==1.7.0' || true fi - + if ! python3 -c "import ws4py" 2>/dev/null; then echo "Installing ws4py from PyPI..." python3 -m pip install --break-system-packages 'ws4py>=0.5.1' || true diff --git a/manage.sh b/manage.sh index 57087d7..6e40487 100755 --- a/manage.sh +++ b/manage.sh @@ -96,7 +96,7 @@ get_status_display() { # Main menu show_main_menu() { local status=$(get_status_display) - + CHOICE=$($DIALOG --backtitle "pyMC Repeater Management" --title "pyMC Repeater Management" --menu "\nCurrent Status: $status\n\nChoose an action:" 18 70 9 \ "install" "Install pyMC Repeater" \ "upgrade" "Upgrade existing installation" \ @@ -109,7 +109,7 @@ show_main_menu() { "logs" "View live logs" \ "status" "Show detailed status" \ "exit" "Exit" 3>&1 1>&2 2>&3) - + case $CHOICE in "install") if is_installed; then @@ -173,10 +173,10 @@ install_repeater() { show_error "Installation requires root privileges.\n\nPlease run: sudo $0" return fi - + # Welcome screen $DIALOG --backtitle "pyMC Repeater Management" --title "Welcome" --msgbox "\nWelcome to pyMC Repeater Setup\n\nThis installer will configure your Linux system as a LoRa mesh network repeater.\n\nPress OK to continue..." 12 70 - + # SPI Check - Universal approach that works on all boards if ! ls /dev/spidev* >/dev/null 2>&1; then # SPI devices not found, check if we're on a Raspberry Pi and can enable it @@ -186,7 +186,7 @@ install_repeater() { elif [ -f "/boot/config.txt" ]; then CONFIG_FILE="/boot/config.txt" fi - + if [ -n "$CONFIG_FILE" ]; then # Raspberry Pi detected - offer to enable SPI if ask_yes_no "SPI Not Enabled" "\nSPI interface is required but not detected (/dev/spidev* not found)!\n\nWould you like to enable it now?\n(This will require a reboot)"; then @@ -203,29 +203,29 @@ install_repeater() { return fi fi - + # Get script directory for file copying during installation SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" - + # Installation progress ( echo "0"; echo "# Creating service user..." if ! id "$SERVICE_USER" &>/dev/null; then useradd --system --home /var/lib/pymc_repeater --shell /sbin/nologin "$SERVICE_USER" fi - + echo "10"; echo "# Adding 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 - + echo "20"; echo "# Creating directories..." mkdir -p "$INSTALL_DIR" "$CONFIG_DIR" "$LOG_DIR" /var/lib/pymc_repeater - + echo "25"; echo "# Installing system dependencies..." apt-get update -qq apt-get install -y libffi-dev jq pip python3-rrdtool wget swig build-essential python3-dev pip install --break-system-packages setuptools_scm >/dev/null 2>&1 || true - + # Install mikefarah yq v4 if not already installed if ! command -v yq &> /dev/null || [[ "$(yq --version 2>&1)" != *"mikefarah/yq"* ]]; then YQ_VERSION="v4.40.5" @@ -237,7 +237,7 @@ install_repeater() { fi wget -qO /usr/local/bin/yq "https://github.com/mikefarah/yq/releases/download/${YQ_VERSION}/${YQ_BINARY}" && chmod +x /usr/local/bin/yq fi - + echo "28"; echo "# Generating version file..." cd "$SCRIPT_DIR" # Generate version file using setuptools_scm before copying @@ -248,18 +248,18 @@ install_repeater() { python3 -c "from setuptools_scm import get_version; get_version(write_to='repeater/_version.py')" 2>&1 || echo " Warning: Could not generate _version.py file" echo " Generated version: $GENERATED_VERSION" fi - + # Clean up stale bytecode in source directory before copying find "$SCRIPT_DIR/repeater" -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true find "$SCRIPT_DIR/repeater" -type f -name '*.pyc' -delete 2>/dev/null || true - + echo "29"; echo "# Cleaning old installation files..." # Remove old repeater directory to ensure clean install rm -rf "$INSTALL_DIR/repeater" 2>/dev/null || true # Clean up old Python bytecode find "$INSTALL_DIR" -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true find "$INSTALL_DIR" -type f -name '*.pyc' -delete 2>/dev/null || true - + echo "30"; echo "# Installing files..." cp -r "$SCRIPT_DIR/repeater" "$INSTALL_DIR/" cp "$SCRIPT_DIR/pyproject.toml" "$INSTALL_DIR/" @@ -268,17 +268,17 @@ install_repeater() { cp "$SCRIPT_DIR/pymc-repeater.service" "$INSTALL_DIR/" 2>/dev/null || true cp "$SCRIPT_DIR/radio-settings.json" /var/lib/pymc_repeater/ 2>/dev/null || true cp "$SCRIPT_DIR/radio-presets.json" /var/lib/pymc_repeater/ 2>/dev/null || true - + echo "45"; echo "# Installing configuration..." cp "$SCRIPT_DIR/config.yaml.example" "$CONFIG_DIR/config.yaml.example" if [ ! -f "$CONFIG_DIR/config.yaml" ]; then cp "$SCRIPT_DIR/config.yaml.example" "$CONFIG_DIR/config.yaml" fi - + echo "55"; echo "# Installing systemd service..." cp "$SCRIPT_DIR/pymc-repeater.service" /etc/systemd/system/ systemctl daemon-reload - + echo "65"; 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" /var/lib/pymc_repeater @@ -287,7 +287,7 @@ install_repeater() { # Pre-create the .config directory that the service will need mkdir -p /var/lib/pymc_repeater/.config/pymc_repeater chown -R "$SERVICE_USER:$SERVICE_USER" /var/lib/pymc_repeater/.config - + # Configure polkit for passwordless service restart mkdir -p /etc/polkit-1/rules.d cat > /etc/polkit-1/rules.d/10-pymc-repeater.rules <<'EOF' @@ -300,13 +300,13 @@ polkit.addRule(function(action, subject) { }); EOF chmod 0644 /etc/polkit-1/rules.d/10-pymc-repeater.rules - + echo "75"; echo "# Starting service..." systemctl enable "$SERVICE_NAME" - + echo "90"; echo "# Installation files complete..." ) | $DIALOG --backtitle "pyMC Repeater Management" --title "Installing" --gauge "Setting up pyMC Repeater..." 8 70 0 - + # Install Python package outside of progress gauge for better error handling clear echo "=== Installing Python Dependencies ===" @@ -314,13 +314,13 @@ EOF echo "Installing pymc_repeater and dependencies (including pymc_core from GitHub)..." echo "This may take a few minutes..." echo "" - + SCRIPT_DIR="$(dirname "$0")" cd "$SCRIPT_DIR" - + # Suppress pip root user warnings export PIP_ROOT_USER_ACTION=ignore - + # Calculate version from git for setuptools_scm if [ -d .git ]; then git fetch --tags 2>/dev/null || true @@ -330,16 +330,16 @@ EOF else export SETUPTOOLS_SCM_PRETEND_VERSION="1.0.5" fi - + # Force binary wheels for slow-to-compile packages (much faster on Raspberry Pi) export PIP_ONLY_BINARY=pycryptodome,cffi,PyNaCl,psutil echo "Note: Using optimized binary wheels for faster installation" echo "" - + if pip install --break-system-packages --no-cache-dir .[hardware]; then echo "" echo "✓ Python package installation completed successfully!" - + # Reload systemd and start the service systemctl daemon-reload systemctl start "$SERVICE_NAME" @@ -349,7 +349,7 @@ EOF echo "Please check the error messages above and try again." read -p "Press Enter to continue..." || true fi - + # Show final results sleep 2 local ip_address=$(hostname -I | awk '{print $1}') @@ -393,18 +393,18 @@ reset_repeater() { show_error "Upgrade requires root privileges.\n\nPlease run: sudo $0" return fi - + local current_version=$(get_version) - + if ask_yes_no "Confirm Reset of pyMC Repeater restoring to default configuration.\n\nContinue?"; then - + # Show info that upgrade is starting show_info "Reseting" "Starting reset process...\n\nProgress will be shown in the terminal." - + echo "=== Reset Progress ===" echo "[1/4] Stopping service..." systemctl stop "$SERVICE_NAME" 2>/dev/null || true - + echo "[2/4] Backing up configuration..." if [ -d "$CONFIG_DIR" ]; then cp -r "$CONFIG_DIR" "$CONFIG_DIR.backup.$(date +%Y%m%d_%H%M%S)" 2>/dev/null || true @@ -451,37 +451,37 @@ reset_repeater() { fi fi } - + # Upgrade function upgrade_repeater() { if [ "$EUID" -ne 0 ]; then show_error "Upgrade requires root privileges.\n\nPlease run: sudo $0" return fi - + local current_version=$(get_version) - + if ask_yes_no "Confirm Upgrade" "Current version: $current_version\n\nThis will upgrade pyMC Repeater while preserving your configuration.\n\nContinue?"; then - + # Show info that upgrade is starting show_info "Upgrading" "Starting upgrade process...\n\nThis may take a few minutes.\nProgress will be shown in the terminal." - + echo "=== Upgrade Progress ===" echo "[1/9] Stopping service..." systemctl stop "$SERVICE_NAME" 2>/dev/null || true - + echo "[2/9] Backing up configuration..." if [ -d "$CONFIG_DIR" ]; then cp -r "$CONFIG_DIR" "$CONFIG_DIR.backup.$(date +%Y%m%d_%H%M%S)" 2>/dev/null || true echo " ✓ Configuration backed up" fi - + echo "[3/9] Updating system dependencies..." apt-get update -qq apt-get install -y libffi-dev jq pip python3-rrdtool wget swig build-essential python3-dev pip install --break-system-packages setuptools_scm >/dev/null 2>&1 || true - + # Install mikefarah yq v4 if not already installed if ! command -v yq &> /dev/null || [[ "$(yq --version 2>&1)" != *"mikefarah/yq"* ]]; then YQ_VERSION="v4.40.5" @@ -494,7 +494,7 @@ upgrade_repeater() { wget -qO /usr/local/bin/yq "https://github.com/mikefarah/yq/releases/download/${YQ_VERSION}/${YQ_BINARY}" && chmod +x /usr/local/bin/yq fi echo " ✓ Dependencies updated" - + echo "[3.5/9] Generating version file..." SCRIPT_DIR="$(dirname "$0")" cd "$SCRIPT_DIR" @@ -510,7 +510,7 @@ upgrade_repeater() { find "$SCRIPT_DIR/repeater" -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true find "$SCRIPT_DIR/repeater" -type f -name '*.pyc' -delete 2>/dev/null || true echo " ✓ Version file generated and bytecode cleaned" - + echo "[3.8/9] Cleaning old installation files..." # Remove old repeater directory to ensure clean upgrade rm -rf "$INSTALL_DIR/repeater" 2>/dev/null || true @@ -518,7 +518,7 @@ upgrade_repeater() { find "$INSTALL_DIR" -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true find "$INSTALL_DIR" -type f -name '*.pyc' -delete 2>/dev/null || true echo " ✓ Old files cleaned" - + echo "[4/9] Installing new files..." cp -r repeater "$INSTALL_DIR/" 2>/dev/null || true cp pyproject.toml "$INSTALL_DIR/" 2>/dev/null || true @@ -527,14 +527,14 @@ upgrade_repeater() { cp radio-settings.json /var/lib/pymc_repeater/ 2>/dev/null || true cp radio-presets.json /var/lib/pymc_repeater/ 2>/dev/null || true echo " ✓ Files updated" - + echo "[5/9] Validating and updating configuration..." if validate_and_update_config; then echo " ✓ Configuration validated and updated" else echo " ⚠ Configuration validation failed, keeping existing config" fi - + echo "[6/9] Fixing permissions..." chown -R "$SERVICE_USER:$SERVICE_USER" "$INSTALL_DIR" "$CONFIG_DIR" "$LOG_DIR" /var/lib/pymc_repeater 2>/dev/null || true chmod 750 "$CONFIG_DIR" "$LOG_DIR" 2>/dev/null || true @@ -555,24 +555,24 @@ polkit.addRule(function(action, subject) { EOF chmod 0644 /etc/polkit-1/rules.d/10-pymc-repeater.rules echo " ✓ Permissions updated" - + echo "[7/9] Reloading systemd..." systemctl daemon-reload echo " ✓ Systemd reloaded" - + echo "=== Installing Python Dependencies ===" echo "" echo "Updating pymc_repeater and dependencies (including pymc_core from GitHub)..." echo "This may take a few minutes..." echo "" - + # Install from source directory to properly resolve Git dependencies SCRIPT_DIR="$(dirname "$0")" cd "$SCRIPT_DIR" - + # Suppress pip root user warnings export PIP_ROOT_USER_ACTION=ignore - + # Calculate version from git for setuptools_scm if [ -d .git ]; then git fetch --tags 2>/dev/null || true @@ -582,12 +582,12 @@ EOF else export SETUPTOOLS_SCM_PRETEND_VERSION="1.0.5" fi - + # Force binary wheels for slow-to-compile packages (much faster on Raspberry Pi) export PIP_ONLY_BINARY=pycryptodome,cffi,PyNaCl,psutil echo "Note: Using optimized binary wheels and cached packages for faster installation" echo "" - + # Upgrade packages (uses cache for unchanged dependencies - much faster) if python3 -m pip install --break-system-packages --upgrade --upgrade-strategy eager .[hardware]; then echo "" @@ -596,22 +596,22 @@ EOF echo "" echo "⚠ Package update failed, but continuing..." fi - + echo "" echo "✓ All packages including pymc_core reinstalled successfully" - + echo "[8/9] Starting service..." systemctl daemon-reload systemctl start "$SERVICE_NAME" echo " ✓ Service started" - + echo "[9/9] Verifying installation..." sleep 3 # Give service time to start - + local new_version=$(get_version) - + if is_running; then echo " ✓ Service is running" show_info "Upgrade Complete" "Upgrade completed successfully!\n\nVersion: $current_version → $new_version\n\n✓ Service is running\n✓ Configuration preserved" @@ -630,10 +630,10 @@ configure_radio() { show_error "Service is not running!\n\nPlease start the service first from the main menu." return fi - + # Get IP address local ip_address=$(hostname -I | awk '{print $1}') - + # Show info about web-based configuration if ask_yes_no "Configure Radio Settings" "Radio configuration is now done through the web interface.\n\nThe web-based setup wizard provides an easy way to:\n\n• Change repeater name\n• Select hardware board\n• Configure radio frequency and settings\n• Update admin password\n\nWeb Dashboard: http://$ip_address:8000/setup\n\nWould you like to open this information?"; then clear @@ -669,36 +669,36 @@ uninstall_repeater() { show_error "Uninstall requires root privileges.\n\nPlease run: sudo $0" return fi - + if ask_yes_no "Confirm Uninstall" "This will completely remove pyMC Repeater including:\n\n- Service and files\n- Configuration (backup will be created)\n- Logs and data\n\nThis action cannot be undone!\n\nContinue?"; then ( echo "0"; echo "# Stopping and disabling service..." systemctl stop "$SERVICE_NAME" 2>/dev/null || true systemctl disable "$SERVICE_NAME" 2>/dev/null || true - + echo "20"; echo "# Backing up configuration..." if [ -d "$CONFIG_DIR" ]; then cp -r "$CONFIG_DIR" "/tmp/pymc_repeater_config_backup_$(date +%Y%m%d_%H%M%S)" 2>/dev/null || true fi - + echo "40"; echo "# Removing service files..." rm -f /etc/systemd/system/pymc-repeater.service systemctl daemon-reload - + echo "60"; echo "# Removing installation..." rm -rf "$INSTALL_DIR" rm -rf "$CONFIG_DIR" rm -rf "$LOG_DIR" rm -rf /var/lib/pymc_repeater - + echo "80"; echo "# Removing service user..." if id "$SERVICE_USER" &>/dev/null; then userdel "$SERVICE_USER" 2>/dev/null || true fi - + echo "100"; echo "# Uninstall complete!" ) | $DIALOG --backtitle "pyMC Repeater Management" --title "Uninstalling" --gauge "Removing pyMC Repeater..." 8 70 0 - + show_info "Uninstall Complete" "\npyMC Repeater has been completely removed.\n\nConfiguration backup saved to /tmp/\n\nThank you for using pyMC Repeater!" fi } @@ -706,17 +706,17 @@ uninstall_repeater() { # Service management manage_service() { local action=$1 - + if [ "$EUID" -ne 0 ]; then show_error "Service management requires root privileges.\n\nPlease run: sudo $0" return fi - + if ! service_exists; then show_error "Service is not installed." return fi - + case $action in "start") systemctl start "$SERVICE_NAME" @@ -746,14 +746,14 @@ show_detailed_status() { local status_info="" local version=$(get_version) local ip_address=$(hostname -I | awk '{print $1}') - + status_info="Installation Status: " if is_installed; then status_info="${status_info}Installed\n" status_info="${status_info}Version: $version\n" status_info="${status_info}Install Directory: $INSTALL_DIR\n" status_info="${status_info}Config Directory: $CONFIG_DIR\n\n" - + status_info="${status_info}Service Status: " if is_running; then status_info="${status_info}Running ✓\n" @@ -761,7 +761,7 @@ show_detailed_status() { else status_info="${status_info}Stopped ✗\n\n" fi - + # Add system info status_info="${status_info}System Info:\n" status_info="${status_info}- SPI: " @@ -770,14 +770,14 @@ show_detailed_status() { else status_info="${status_info}Disabled ✗\n" fi - + status_info="${status_info}- IP Address: $ip_address\n" status_info="${status_info}- Hostname: $(hostname)\n" - + else status_info="${status_info}Not Installed" fi - + show_info "System Status" "$status_info" } @@ -786,7 +786,7 @@ validate_and_update_config() { local config_file="$CONFIG_DIR/config.yaml" local example_file="config.yaml.example" local updated_example="$CONFIG_DIR/config.yaml.example" - + # Copy the new example file if [ -f "$example_file" ]; then cp "$example_file" "$updated_example" @@ -794,40 +794,40 @@ validate_and_update_config() { echo " ⚠ config.yaml.example not found in source directory" return 1 fi - + # Check if user config exists if [ ! -f "$config_file" ]; then echo " ⚠ No existing config.yaml found, copying example" cp "$updated_example" "$config_file" return 0 fi - + # Check if yq is available YQ_CMD="/usr/local/bin/yq" if ! command -v "$YQ_CMD" &> /dev/null; then echo " ⚠ mikefarah yq not found at $YQ_CMD, skipping config merge" return 0 fi - + # Verify it's the correct yq version if [[ "$($YQ_CMD --version 2>&1)" != *"mikefarah/yq"* ]]; then echo " ⚠ Wrong yq version detected at $YQ_CMD, skipping config merge" return 0 fi - + echo " Merging configuration..." - + # Create backup of user config local backup_file="${config_file}.backup.$(date +%Y%m%d_%H%M%S)" cp "$config_file" "$backup_file" echo " ✓ Backup created: $backup_file" - + # Merge strategy: user config takes precedence, add missing keys from example # This uses yq's multiply merge operator (*) which: # - Keeps all values from the right operand (user config) # - Adds missing keys from the left operand (example config) local temp_merged="${config_file}.merged" - + if "$YQ_CMD" eval-all '. as $item ireduce ({}; . * $item)' "$updated_example" "$config_file" > "$temp_merged" 2>/dev/null; then # Verify the merged file is valid YAML if "$YQ_CMD" eval '.' "$temp_merged" > /dev/null 2>&1; then diff --git a/pyproject.toml b/pyproject.toml index 4d52e7c..767502b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -86,4 +86,3 @@ line_length = 100 [tool.setuptools_scm] version_scheme = "guess-next-dev" local_scheme = "no-local-version" - diff --git a/radio-presets.json b/radio-presets.json index 40b4b7a..9123f44 100644 --- a/radio-presets.json +++ b/radio-presets.json @@ -1 +1 @@ -{"config":{"connect_screen":{"info_message":"The default pin for devices without a screen is 123456. Trouble pairing? Forget the bluetooth device in system settings."},"remote_management":{"repeaters":{"guest_login_enabled":true,"guest_login_disabled_message":"Guest login has been temporarily disabled. Please try again later.","guest_login_passwords":[""],"flood_routed_guest_login_enabled":true,"flood_routed_guest_login_disabled_message":"To avoid overwhelming the mesh with flood packets, please set a path to log in to a repeater as a guest."}},"suggested_radio_settings":{"info_message":"These radio settings have been suggested by the community.","entries":[{"title":"Australia","description":"915.800MHz / SF10 / BW250 / CR5","frequency":"915.800","spreading_factor":"10","bandwidth":"250","coding_rate":"5"},{"title":"Australia: Victoria","description":"916.575MHz / SF7 / BW62.5 / CR8","frequency":"916.575","spreading_factor":"7","bandwidth":"62.5","coding_rate":"8"},{"title":"EU/UK (Narrow)","description":"869.618MHz / SF8 / BW62.5 / CR8","frequency":"869.618","spreading_factor":"8","bandwidth":"62.5","coding_rate":"8"},{"title":"EU/UK (Long Range)","description":"869.525MHz / SF11 / BW250 / CR5","frequency":"869.525","spreading_factor":"11","bandwidth":"250","coding_rate":"5"},{"title":"EU/UK (Medium Range)","description":"869.525MHz / SF10 / BW250 / CR5","frequency":"869.525","spreading_factor":"10","bandwidth":"250","coding_rate":"5"},{"title":"Czech Republic (Narrow)","description":"869.525MHz / SF7 / BW62.5 / CR5","frequency":"869.525","spreading_factor":"7","bandwidth":"62.5","coding_rate":"5"},{"title":"EU 433MHz (Long Range)","description":"433.650MHz / SF11 / BW250 / CR5","frequency":"433.650","spreading_factor":"11","bandwidth":"250","coding_rate":"5"},{"title":"New Zealand","description":"917.375MHz / SF11 / BW250 / CR5","frequency":"917.375","spreading_factor":"11","bandwidth":"250","coding_rate":"5"},{"title":"New Zealand (Narrow)","description":"917.375MHz / SF7 / BW62.5 / CR5","frequency":"917.375","spreading_factor":"7","bandwidth":"62.5","coding_rate":"5"},{"title":"Portugal 433","description":"433.375MHz / SF9 / BW62.5 / CR6","frequency":"433.375","spreading_factor":"9","bandwidth":"62.5","coding_rate":"6"},{"title":"Portugal 868","description":"869.618MHz / SF7 / BW62.5 / CR6","frequency":"869.618","spreading_factor":"7","bandwidth":"62.5","coding_rate":"6"},{"title":"Switzerland","description":"869.618MHz / SF8 / BW62.5 / CR8","frequency":"869.618","spreading_factor":"8","bandwidth":"62.5","coding_rate":"8"},{"title":"USA/Canada (Recommended)","description":"910.525MHz / SF7 / BW62.5 / CR5","frequency":"910.525","spreading_factor":"7","bandwidth":"62.5","coding_rate":"5"},{"title":"USA/Canada (Alternate)","description":"910.525MHz / SF11 / BW250 / CR5","frequency":"910.525","spreading_factor":"11","bandwidth":"250","coding_rate":"5"},{"title":"Vietnam","description":"920.250MHz / SF11 / BW250 / CR5","frequency":"920.250","spreading_factor":"11","bandwidth":"250","coding_rate":"5"}]}}} \ No newline at end of file +{"config":{"connect_screen":{"info_message":"The default pin for devices without a screen is 123456. Trouble pairing? Forget the bluetooth device in system settings."},"remote_management":{"repeaters":{"guest_login_enabled":true,"guest_login_disabled_message":"Guest login has been temporarily disabled. Please try again later.","guest_login_passwords":[""],"flood_routed_guest_login_enabled":true,"flood_routed_guest_login_disabled_message":"To avoid overwhelming the mesh with flood packets, please set a path to log in to a repeater as a guest."}},"suggested_radio_settings":{"info_message":"These radio settings have been suggested by the community.","entries":[{"title":"Australia","description":"915.800MHz / SF10 / BW250 / CR5","frequency":"915.800","spreading_factor":"10","bandwidth":"250","coding_rate":"5"},{"title":"Australia: Victoria","description":"916.575MHz / SF7 / BW62.5 / CR8","frequency":"916.575","spreading_factor":"7","bandwidth":"62.5","coding_rate":"8"},{"title":"EU/UK (Narrow)","description":"869.618MHz / SF8 / BW62.5 / CR8","frequency":"869.618","spreading_factor":"8","bandwidth":"62.5","coding_rate":"8"},{"title":"EU/UK (Long Range)","description":"869.525MHz / SF11 / BW250 / CR5","frequency":"869.525","spreading_factor":"11","bandwidth":"250","coding_rate":"5"},{"title":"EU/UK (Medium Range)","description":"869.525MHz / SF10 / BW250 / CR5","frequency":"869.525","spreading_factor":"10","bandwidth":"250","coding_rate":"5"},{"title":"Czech Republic (Narrow)","description":"869.525MHz / SF7 / BW62.5 / CR5","frequency":"869.525","spreading_factor":"7","bandwidth":"62.5","coding_rate":"5"},{"title":"EU 433MHz (Long Range)","description":"433.650MHz / SF11 / BW250 / CR5","frequency":"433.650","spreading_factor":"11","bandwidth":"250","coding_rate":"5"},{"title":"New Zealand","description":"917.375MHz / SF11 / BW250 / CR5","frequency":"917.375","spreading_factor":"11","bandwidth":"250","coding_rate":"5"},{"title":"New Zealand (Narrow)","description":"917.375MHz / SF7 / BW62.5 / CR5","frequency":"917.375","spreading_factor":"7","bandwidth":"62.5","coding_rate":"5"},{"title":"Portugal 433","description":"433.375MHz / SF9 / BW62.5 / CR6","frequency":"433.375","spreading_factor":"9","bandwidth":"62.5","coding_rate":"6"},{"title":"Portugal 868","description":"869.618MHz / SF7 / BW62.5 / CR6","frequency":"869.618","spreading_factor":"7","bandwidth":"62.5","coding_rate":"6"},{"title":"Switzerland","description":"869.618MHz / SF8 / BW62.5 / CR8","frequency":"869.618","spreading_factor":"8","bandwidth":"62.5","coding_rate":"8"},{"title":"USA/Canada (Recommended)","description":"910.525MHz / SF7 / BW62.5 / CR5","frequency":"910.525","spreading_factor":"7","bandwidth":"62.5","coding_rate":"5"},{"title":"USA/Canada (Alternate)","description":"910.525MHz / SF11 / BW250 / CR5","frequency":"910.525","spreading_factor":"11","bandwidth":"250","coding_rate":"5"},{"title":"Vietnam","description":"920.250MHz / SF11 / BW250 / CR5","frequency":"920.250","spreading_factor":"11","bandwidth":"250","coding_rate":"5"}]}}} diff --git a/repeater/__init__.py b/repeater/__init__.py index df25aa5..19b87fa 100644 --- a/repeater/__init__.py +++ b/repeater/__init__.py @@ -3,6 +3,7 @@ try: except ImportError: try: from importlib.metadata import version + __version__ = version("pymc_repeater") except Exception: __version__ = "unknown" diff --git a/repeater/airtime.py b/repeater/airtime.py index bd8ae26..d823974 100644 --- a/repeater/airtime.py +++ b/repeater/airtime.py @@ -37,9 +37,9 @@ class AirtimeManager: ) -> float: """ Calculate LoRa packet airtime using the Semtech reference formula. - + Reference: https://www.semtech.com/design-support/lora-calculator - + Args: payload_len: Payload length in bytes spreading_factor: SF7-SF12 (uses config value if None) @@ -48,7 +48,7 @@ class AirtimeManager: preamble_len: Preamble symbols (uses config value if None) crc_enabled: Whether CRC is enabled (default: True) explicit_header: Whether explicit header mode is used (default: True) - + Returns: Airtime in milliseconds """ @@ -58,25 +58,25 @@ class AirtimeManager: preamble_len = preamble_len or self.preamble_length crc = 1 if crc_enabled else 0 h = 0 if explicit_header else 1 # H=0 for explicit, H=1 for implicit - + # Low data rate optimization: required for SF11/SF12 at 125kHz de = 1 if (sf >= 11 and bandwidth_hz <= 125000) else 0 - + # Symbol time in milliseconds: T_sym = 2^SF / BW_kHz - t_sym = (2 ** sf) / bw_khz - + t_sym = (2**sf) / bw_khz + # Preamble time: T_preamble = (n_preamble + 4.25) * T_sym t_preamble = (preamble_len + 4.25) * t_sym - + # Payload symbol calculation (Semtech formula): # n_payload = 8 + ceil(max(8*PL - 4*SF + 28 + 16*CRC - 20*H, 0) / (4*(SF - 2*DE))) * CR numerator = max(8 * payload_len - 4 * sf + 28 + 16 * crc - 20 * h, 0) denominator = 4 * (sf - 2 * de) n_payload = 8 + math.ceil(numerator / denominator) * cr - + # Payload time t_payload = n_payload * t_sym - + # Total packet airtime return t_preamble + t_payload diff --git a/repeater/companion/__init__.py b/repeater/companion/__init__.py index ecfa313..f64af20 100644 --- a/repeater/companion/__init__.py +++ b/repeater/companion/__init__.py @@ -3,17 +3,17 @@ Exposes the MeshCore companion frame protocol over TCP for standard clients. """ -from .frame_server import CompanionFrameServer from .constants import ( CMD_APP_START, CMD_GET_CONTACTS, + CMD_SEND_LOGIN, CMD_SEND_TXT_MSG, CMD_SYNC_NEXT_MESSAGE, - CMD_SEND_LOGIN, - RESP_CODE_OK, - RESP_CODE_ERR, PUSH_CODE_MSG_WAITING, + RESP_CODE_ERR, + RESP_CODE_OK, ) +from .frame_server import CompanionFrameServer __all__ = [ "CompanionFrameServer", diff --git a/repeater/companion/constants.py b/repeater/companion/constants.py index 4fdbd7c..2fa3b16 100644 --- a/repeater/companion/constants.py +++ b/repeater/companion/constants.py @@ -1,138 +1,150 @@ -"""Companion frame protocol constants (MeshCore Companion Radio Protocol).""" +"""Companion frame protocol constants — re-exported from pyMC_core. -import base64 +All protocol constants now live in :mod:`pymc_core.companion.constants`. +This module re-exports them so existing repeater imports continue to work. +""" -# Commands (app -> radio) -CMD_APP_START = 1 -CMD_SEND_TXT_MSG = 2 -CMD_SEND_CHANNEL_TXT_MSG = 3 -CMD_GET_CONTACTS = 4 -CMD_GET_DEVICE_TIME = 5 -CMD_SET_DEVICE_TIME = 6 -CMD_SEND_SELF_ADVERT = 7 -CMD_SET_ADVERT_NAME = 8 -CMD_ADD_UPDATE_CONTACT = 9 -CMD_SYNC_NEXT_MESSAGE = 10 -CMD_SET_RADIO_PARAMS = 11 -CMD_SET_RADIO_TX_POWER = 12 -CMD_RESET_PATH = 13 -CMD_SET_ADVERT_LATLON = 14 -CMD_REMOVE_CONTACT = 15 -CMD_SHARE_CONTACT = 16 -CMD_EXPORT_CONTACT = 17 -CMD_IMPORT_CONTACT = 18 -CMD_REBOOT = 19 -CMD_GET_BATT_AND_STORAGE = 20 -CMD_SET_TUNING_PARAMS = 21 -CMD_DEVICE_QUERY = 22 -CMD_EXPORT_PRIVATE_KEY = 23 -CMD_IMPORT_PRIVATE_KEY = 24 -CMD_SEND_RAW_DATA = 25 -CMD_SEND_LOGIN = 26 -CMD_SEND_STATUS_REQ = 27 -CMD_HAS_CONNECTION = 28 -CMD_LOGOUT = 29 -CMD_GET_CONTACT_BY_KEY = 30 -CMD_GET_CHANNEL = 31 -CMD_SET_CHANNEL = 32 -CMD_SIGN_START = 33 -CMD_SIGN_DATA = 34 -CMD_SIGN_FINISH = 35 -CMD_SEND_TRACE_PATH = 36 -CMD_SET_DEVICE_PIN = 37 -CMD_SET_OTHER_PARAMS = 38 -CMD_SEND_TELEMETRY_REQ = 39 -CMD_GET_CUSTOM_VARS = 40 -CMD_SET_CUSTOM_VAR = 41 -CMD_GET_ADVERT_PATH = 42 -CMD_GET_TUNING_PARAMS = 43 -CMD_SEND_BINARY_REQ = 50 -CMD_FACTORY_RESET = 51 -CMD_SEND_PATH_DISCOVERY_REQ = 52 -CMD_SET_FLOOD_SCOPE = 54 -CMD_SEND_CONTROL_DATA = 55 -CMD_GET_STATS = 56 -CMD_SEND_ANON_REQ = 57 -CMD_SET_AUTOADD_CONFIG = 58 -CMD_GET_AUTOADD_CONFIG = 59 - -# Response codes (radio -> app) -RESP_CODE_OK = 0 -RESP_CODE_ERR = 1 -RESP_CODE_CONTACTS_START = 2 -RESP_CODE_CONTACT = 3 -RESP_CODE_END_OF_CONTACTS = 4 -RESP_CODE_SELF_INFO = 5 -RESP_CODE_SENT = 6 -RESP_CODE_CONTACT_MSG_RECV = 7 -RESP_CODE_CHANNEL_MSG_RECV = 8 -RESP_CODE_CURR_TIME = 9 -RESP_CODE_NO_MORE_MESSAGES = 10 -RESP_CODE_EXPORT_CONTACT = 11 -RESP_CODE_BATT_AND_STORAGE = 12 -RESP_CODE_DEVICE_INFO = 13 # CMD_DEVICE_QUERY response -RESP_CODE_PRIVATE_KEY = 14 -RESP_CODE_DISABLED = 15 -RESP_CODE_CONTACT_MSG_RECV_V3 = 16 -RESP_CODE_CHANNEL_MSG_RECV_V3 = 17 -RESP_CODE_CHANNEL_INFO = 18 -RESP_CODE_SIGN_START = 19 -RESP_CODE_SIGNATURE = 20 -RESP_CODE_CUSTOM_VARS = 21 -RESP_CODE_ADVERT_PATH = 22 -RESP_CODE_TUNING_PARAMS = 23 -RESP_CODE_STATS = 24 -RESP_CODE_AUTOADD_CONFIG = 25 - -# Push codes (radio -> app, unsolicited) -PUSH_CODE_ADVERT = 0x80 -PUSH_CODE_PATH_UPDATED = 0x81 -PUSH_CODE_SEND_CONFIRMED = 0x82 -PUSH_CODE_MSG_WAITING = 0x83 -PUSH_CODE_RAW_DATA = 0x84 -PUSH_CODE_LOGIN_SUCCESS = 0x85 -PUSH_CODE_LOGIN_FAIL = 0x86 -PUSH_CODE_STATUS_RESPONSE = 0x87 -PUSH_CODE_LOG_RX_DATA = 0x88 -PUSH_CODE_TRACE_DATA = 0x89 -PUSH_CODE_NEW_ADVERT = 0x8A -PUSH_CODE_TELEMETRY_RESPONSE = 0x8B -PUSH_CODE_BINARY_RESPONSE = 0x8C -PUSH_CODE_PATH_DISCOVERY_RESPONSE = 0x8D -PUSH_CODE_CONTROL_DATA = 0x8E -PUSH_CODE_CONTACT_DELETED = 0x8F -PUSH_CODE_CONTACTS_FULL = 0x90 - -# Error codes -ERR_CODE_UNSUPPORTED_CMD = 1 -ERR_CODE_NOT_FOUND = 2 -ERR_CODE_TABLE_FULL = 3 -ERR_CODE_BAD_STATE = 4 -ERR_CODE_FILE_IO_ERROR = 5 -ERR_CODE_ILLEGAL_ARG = 6 - -# Stats sub-types -STATS_TYPE_CORE = 0 -STATS_TYPE_RADIO = 1 -STATS_TYPE_PACKETS = 2 - -# Frame delimiters (USB/TCP: > = outbound, < = inbound) -FRAME_OUTBOUND_PREFIX = 0x3E # '>' -FRAME_INBOUND_PREFIX = 0x3C # '<' -MAX_FRAME_SIZE = 512 -PUB_KEY_SIZE = 32 -MAX_PATH_SIZE = 64 - -# ADV types -ADV_TYPE_CHAT = 1 -ADV_TYPE_REPEATER = 2 -ADV_TYPE_ROOM = 3 -ADV_TYPE_SENSOR = 4 - -# Default Public channel PSK (from firmware MeshCore/examples/companion_radio/MyMesh.cpp) -# Base64-encoded; decode to get the 16-byte secret used for MAC/AES -PUBLIC_GROUP_PSK = b"izOH6cXN6mrJ5e26oRXNcg==" - -# Default public channel secret: base64-decode PUBLIC_GROUP_PSK so we match firmware -# (firmware uses decode_base64(psk) -> 16 bytes; HMAC key is that + 16 zero bytes) -DEFAULT_PUBLIC_CHANNEL_SECRET = base64.b64decode(PUBLIC_GROUP_PSK) +# Re-exports; F401 ignored for re-exported names. +from pymc_core.companion.constants import ( # noqa: F401 + ADV_TYPE_CHAT, + ADV_TYPE_REPEATER, + ADV_TYPE_ROOM, + ADV_TYPE_SENSOR, + ADVERT_LOC_NONE, + ADVERT_LOC_SHARE, + AUTOADD_CHAT, + AUTOADD_OVERWRITE_OLDEST, + AUTOADD_REPEATER, + AUTOADD_ROOM, + AUTOADD_SENSOR, + CMD_ADD_UPDATE_CONTACT, + CMD_APP_START, + CMD_DEVICE_QUERY, + CMD_EXPORT_CONTACT, + CMD_EXPORT_PRIVATE_KEY, + CMD_FACTORY_RESET, + CMD_GET_ADVERT_PATH, + CMD_GET_AUTOADD_CONFIG, + CMD_GET_BATT_AND_STORAGE, + CMD_GET_CHANNEL, + CMD_GET_CONTACT_BY_KEY, + CMD_GET_CONTACTS, + CMD_GET_CUSTOM_VARS, + CMD_GET_DEVICE_TIME, + CMD_GET_STATS, + CMD_GET_TUNING_PARAMS, + CMD_HAS_CONNECTION, + CMD_IMPORT_CONTACT, + CMD_IMPORT_PRIVATE_KEY, + CMD_LOGOUT, + CMD_REBOOT, + CMD_REMOVE_CONTACT, + CMD_RESET_PATH, + CMD_SEND_ANON_REQ, + CMD_SEND_BINARY_REQ, + CMD_SEND_CHANNEL_TXT_MSG, + CMD_SEND_CONTROL_DATA, + CMD_SEND_LOGIN, + CMD_SEND_PATH_DISCOVERY_REQ, + CMD_SEND_RAW_DATA, + CMD_SEND_SELF_ADVERT, + CMD_SEND_STATUS_REQ, + CMD_SEND_TELEMETRY_REQ, + CMD_SEND_TRACE_PATH, + CMD_SEND_TXT_MSG, + CMD_SET_ADVERT_LATLON, + CMD_SET_ADVERT_NAME, + CMD_SET_AUTOADD_CONFIG, + CMD_SET_CHANNEL, + CMD_SET_CUSTOM_VAR, + CMD_SET_DEVICE_PIN, + CMD_SET_DEVICE_TIME, + CMD_SET_FLOOD_SCOPE, + CMD_SET_OTHER_PARAMS, + CMD_SET_RADIO_PARAMS, + CMD_SET_RADIO_TX_POWER, + CMD_SET_TUNING_PARAMS, + CMD_SHARE_CONTACT, + CMD_SIGN_DATA, + CMD_SIGN_FINISH, + CMD_SIGN_START, + CMD_SYNC_NEXT_MESSAGE, + CONTACT_NAME_SIZE, + DEFAULT_MAX_CHANNELS, + DEFAULT_MAX_CONTACTS, + DEFAULT_OFFLINE_QUEUE_SIZE, + DEFAULT_PUBLIC_CHANNEL_SECRET, + DEFAULT_RESPONSE_TIMEOUT_MS, + ERR_CODE_BAD_STATE, + ERR_CODE_FILE_IO_ERROR, + ERR_CODE_ILLEGAL_ARG, + ERR_CODE_NOT_FOUND, + ERR_CODE_TABLE_FULL, + ERR_CODE_UNSUPPORTED_CMD, + FRAME_INBOUND_PREFIX, + FRAME_OUTBOUND_PREFIX, + MAX_FRAME_SIZE, + MAX_PATH_SIZE, + MAX_SIGN_DATA_SIZE, + MSG_SEND_FAILED, + MSG_SEND_SENT_DIRECT, + MSG_SEND_SENT_FLOOD, + PROTOCOL_CODE_ANON_REQ, + PROTOCOL_CODE_BINARY_REQ, + PROTOCOL_CODE_RAW_DATA, + PUB_KEY_SIZE, + PUBLIC_GROUP_PSK, + PUSH_CODE_ADVERT, + PUSH_CODE_BINARY_RESPONSE, + PUSH_CODE_CONTACT_DELETED, + PUSH_CODE_CONTACTS_FULL, + PUSH_CODE_CONTROL_DATA, + PUSH_CODE_LOG_RX_DATA, + PUSH_CODE_LOGIN_FAIL, + PUSH_CODE_LOGIN_SUCCESS, + PUSH_CODE_MSG_WAITING, + PUSH_CODE_NEW_ADVERT, + PUSH_CODE_PATH_DISCOVERY_RESPONSE, + PUSH_CODE_PATH_UPDATED, + PUSH_CODE_RAW_DATA, + PUSH_CODE_SEND_CONFIRMED, + PUSH_CODE_STATUS_RESPONSE, + PUSH_CODE_TELEMETRY_RESPONSE, + PUSH_CODE_TRACE_DATA, + RESP_CODE_ADVERT_PATH, + RESP_CODE_AUTOADD_CONFIG, + RESP_CODE_BATT_AND_STORAGE, + RESP_CODE_CHANNEL_INFO, + RESP_CODE_CHANNEL_MSG_RECV, + RESP_CODE_CHANNEL_MSG_RECV_V3, + RESP_CODE_CONTACT, + RESP_CODE_CONTACT_MSG_RECV, + RESP_CODE_CONTACT_MSG_RECV_V3, + RESP_CODE_CONTACTS_START, + RESP_CODE_CURR_TIME, + RESP_CODE_CUSTOM_VARS, + RESP_CODE_DEVICE_INFO, + RESP_CODE_DISABLED, + RESP_CODE_END_OF_CONTACTS, + RESP_CODE_ERR, + RESP_CODE_EXPORT_CONTACT, + RESP_CODE_NO_MORE_MESSAGES, + RESP_CODE_OK, + RESP_CODE_PRIVATE_KEY, + RESP_CODE_SELF_INFO, + RESP_CODE_SENT, + RESP_CODE_SIGN_START, + RESP_CODE_SIGNATURE, + RESP_CODE_STATS, + RESP_CODE_TUNING_PARAMS, + STATS_TYPE_CORE, + STATS_TYPE_PACKETS, + STATS_TYPE_RADIO, + TELEM_MODE_ALLOW_ALL, + TELEM_MODE_ALLOW_FLAGS, + TELEM_MODE_DENY, + TXT_TYPE_CLI_DATA, + TXT_TYPE_PLAIN, + TXT_TYPE_SIGNED_PLAIN, + BinaryReqType, +) diff --git a/repeater/companion/frame_server.py b/repeater/companion/frame_server.py index 1a9062a..ba051f1 100644 --- a/repeater/companion/frame_server.py +++ b/repeater/companion/frame_server.py @@ -1,129 +1,29 @@ """ -Companion frame protocol TCP server. +Repeater-specific CompanionFrameServer with SQLite persistence. -Implements the MeshCore Companion Radio Protocol over TCP for standard clients. -Frame format: outbound '>' + 2-byte len (LE) + data; inbound '<' + 2-byte len + data. +Thin subclass of :class:`pymc_core.companion.frame_server.CompanionFrameServer` +that adds SQLite-backed message, contact, and channel persistence via a +``sqlite_handler`` dependency. """ +from __future__ import annotations + import asyncio -import base64 import logging -import struct -import time from typing import Optional -from pymc_core.companion.constants import ADV_TYPE_CHAT -from pymc_core.companion.models import Contact, QueuedMessage - -from .constants import ( - RESP_CODE_DEVICE_INFO, - CMD_ADD_UPDATE_CONTACT, - CMD_GET_CHANNEL, - CMD_GET_CONTACT_BY_KEY, - CMD_SET_CHANNEL, - CMD_SET_FLOOD_SCOPE, - CMD_APP_START, - CMD_DEVICE_QUERY, - CMD_GET_ADVERT_PATH, - CMD_GET_BATT_AND_STORAGE, - CMD_GET_CONTACTS, - CMD_GET_STATS, - CMD_IMPORT_CONTACT, - CMD_REMOVE_CONTACT, - CMD_RESET_PATH, - CMD_SEND_BINARY_REQ, - CMD_SEND_PATH_DISCOVERY_REQ, - CMD_SEND_CONTROL_DATA, - CMD_SEND_CHANNEL_TXT_MSG, - CMD_SEND_LOGIN, - CMD_SEND_SELF_ADVERT, - CMD_SEND_STATUS_REQ, - CMD_SEND_TELEMETRY_REQ, - CMD_SEND_TRACE_PATH, - CMD_SEND_TXT_MSG, - CMD_SET_ADVERT_LATLON, - CMD_SET_ADVERT_NAME, - CMD_SYNC_NEXT_MESSAGE, - ERR_CODE_BAD_STATE, - ERR_CODE_ILLEGAL_ARG, - ERR_CODE_NOT_FOUND, - ERR_CODE_TABLE_FULL, - ERR_CODE_UNSUPPORTED_CMD, - FRAME_INBOUND_PREFIX, - FRAME_OUTBOUND_PREFIX, - MAX_FRAME_SIZE, - MAX_PATH_SIZE, - PUB_KEY_SIZE, - PUSH_CODE_ADVERT, - PUSH_CODE_BINARY_RESPONSE, - PUSH_CODE_LOGIN_FAIL, - PUSH_CODE_LOGIN_SUCCESS, - PUSH_CODE_LOG_RX_DATA, - PUSH_CODE_NEW_ADVERT, - PUSH_CODE_TRACE_DATA, - PUSH_CODE_MSG_WAITING, - PUSH_CODE_PATH_UPDATED, - PUSH_CODE_SEND_CONFIRMED, - PUSH_CODE_STATUS_RESPONSE, - PUSH_CODE_TELEMETRY_RESPONSE, - RESP_CODE_ADVERT_PATH, - RESP_CODE_BATT_AND_STORAGE, - RESP_CODE_CHANNEL_INFO, - RESP_CODE_CHANNEL_MSG_RECV, - RESP_CODE_CHANNEL_MSG_RECV_V3, - RESP_CODE_CONTACT, - RESP_CODE_CONTACT_MSG_RECV_V3, - RESP_CODE_CONTACT_MSG_RECV, - RESP_CODE_CONTACTS_START, - RESP_CODE_END_OF_CONTACTS, - RESP_CODE_ERR, - RESP_CODE_NO_MORE_MESSAGES, - RESP_CODE_OK, - RESP_CODE_SELF_INFO, - RESP_CODE_SENT, - RESP_CODE_STATS, - STATS_TYPE_CORE, - STATS_TYPE_PACKETS, - STATS_TYPE_RADIO, - PUSH_CODE_PATH_DISCOVERY_RESPONSE, - PUSH_CODE_CONTROL_DATA, -) +from pymc_core.companion.frame_server import CompanionFrameServer as _BaseFrameServer +from pymc_core.companion.models import QueuedMessage logger = logging.getLogger("CompanionFrameServer") -def _build_advert_push_frames(data: dict) -> tuple[bytes, Optional[bytes]]: - """Build PUSH_CODE_ADVERT short frame and optional PUSH_CODE_NEW_ADVERT full frame from extracted data. Thread-safe for asyncio.to_thread.""" - pubkey_b = data.get("pubkey_b", b"") - if isinstance(pubkey_b, bytes): - pubkey_b = pubkey_b[:32].ljust(32, b"\x00") - else: - pubkey_b = b"\x00" * 32 - short = bytes([PUSH_CODE_ADVERT]) + pubkey_b - if not data.get("include_full"): - return (short, None) - op = data.get("out_path", b"") - op = (op if isinstance(op, bytes) else bytes(op or []))[:MAX_PATH_SIZE].ljust(MAX_PATH_SIZE, b"\x00") - nb = data.get("name_b", b"") - nb = (nb if isinstance(nb, bytes) else (nb.encode("utf-8", errors="replace") if isinstance(nb, str) else b""))[:32].ljust(32, b"\x00") - full = ( - bytes([PUSH_CODE_NEW_ADVERT]) - + pubkey_b - + bytes([data.get("adv_type", 0), data.get("flags", 0), data.get("opl_byte", 0xFF)]) - + op - + nb - + struct.pack(" dict for companion stats - self._control_handler = control_handler # Optional; used to register/clear discovery callbacks so "No callback waiting" is not logged - self._server: Optional[asyncio.Server] = None - self._client_writer: Optional[asyncio.StreamWriter] = None - self._client_reader: Optional[asyncio.StreamReader] = None - self._app_target_ver = 0 - - async def start(self) -> None: - """Start the TCP server.""" - self._server = await asyncio.start_server( - self._handle_client, - self.bind_address, - self.port, + super().__init__( + bridge=bridge, + companion_hash=companion_hash, + port=port, + bind_address=bind_address, + device_model="pyMC-Repeater-Companion", + device_version="1.0.0", + build_date="13 Feb 2026", + local_hash=local_hash, + stats_getter=stats_getter, + control_handler=control_handler, ) - addr = self._server.sockets[0].getsockname() if self._server.sockets else (self.bind_address, self.port) - logger.info(f"Companion frame server listening on {addr[0]}:{addr[1]} (hash=0x{int(self.companion_hash):02x})") + self.sqlite_handler = sqlite_handler - async def stop(self) -> None: - """Stop the TCP server and disconnect any client.""" - if self._client_writer: - try: - self._client_writer.close() - await self._client_writer.wait_closed() - except Exception: - pass - self._client_writer = None - self._client_reader = None - if self._server: - self._server.close() - await self._server.wait_closed() - self._server = None - logger.info(f"Companion frame server stopped (port={self.port})") + # ----------------------------------------------------------------- + # Persistence hook overrides + # ----------------------------------------------------------------- async def _persist_companion_message(self, msg_dict: dict) -> None: - """Persist a message to SQLite (deduplicated) and remove it from the bridge queue so it is delivered once from SQLite. - SQLite I/O runs in a thread so the event loop stays responsive and the client does not time out.""" + """Persist message to SQLite and pop from bridge queue.""" if not self.sqlite_handler: return await asyncio.to_thread( - self.sqlite_handler.companion_push_message, self.companion_hash, msg_dict + self.sqlite_handler.companion_push_message, + self.companion_hash, + msg_dict, ) self.bridge.message_queue.pop_last() - def _setup_push_callbacks(self) -> None: - """Subscribe to bridge events and send PUSH frames to connected client.""" - - def _write_push(data: bytes) -> None: - if self._client_writer and not self._client_writer.is_closing(): - try: - frame = bytes([FRAME_OUTBOUND_PREFIX]) + struct.pack("= 32: - _write_push(bytes([PUSH_CODE_PATH_UPDATED]) + pub_key[:32]) - - async def on_channel_message_received( - channel_name, sender_name, message_text, timestamp, path_len=0, channel_idx=0, packet_hash=None - ): - msg_dict = { - "sender_key": b"", - "text": message_text, - "timestamp": timestamp, - "txt_type": 0, - "is_channel": True, - "channel_idx": channel_idx, - "path_len": path_len, - "packet_hash": packet_hash, - } - await self._persist_companion_message(msg_dict) - _write_push(bytes([PUSH_CODE_MSG_WAITING])) - - async def on_binary_response(tag_bytes, response_data, parsed=None, request_type=None): - # PUSH_CODE_BINARY_RESPONSE: 0x8C + reserved(1) + tag(4) + response_payload - frame = ( - bytes([PUSH_CODE_BINARY_RESPONSE, 0]) - + (tag_bytes if isinstance(tag_bytes, bytes) else struct.pack(" None: - """Push PUSH_CODE_TRACE_DATA (0x89) to client. Matches firmware onTraceRecv() frame format.""" - if not self._client_writer or self._client_writer.is_closing(): - return - # Firmware: code(1) + reserved(1) + path_len(1) + flags(1) + tag(4) + auth(4) + path_hashes + path_snrs + final_snr(1) - path_sz = flags & 0x03 - expected_snr_len = path_len >> path_sz - if len(path_snrs) != expected_snr_len: - logger.debug("push_trace_data: path_snrs len %s != expected %s", len(path_snrs), expected_snr_len) - return - data = ( - bytes([PUSH_CODE_TRACE_DATA, 0, path_len, flags]) - + struct.pack(" Optional[QueuedMessage]: + """Retrieve next message from SQLite when bridge queue is empty.""" + if not self.sqlite_handler: + return None + msg_dict = self.sqlite_handler.companion_pop_message(self.companion_hash) + if not msg_dict: + return None + return QueuedMessage( + sender_key=msg_dict.get("sender_key", b""), + txt_type=msg_dict.get("txt_type", 0), + timestamp=msg_dict.get("timestamp", 0), + text=msg_dict.get("text", ""), + is_channel=bool(msg_dict.get("is_channel", False)), + channel_idx=msg_dict.get("channel_idx", 0), + path_len=msg_dict.get("path_len", 0), ) - try: - frame = bytes([FRAME_OUTBOUND_PREFIX]) + struct.pack(" None: - """Push raw RX packet to client (PUSH_CODE_LOG_RX_DATA 0x88). Matches firmware logRxRaw() so client can track repeats by packet hash.""" - if not self._client_writer or self._client_writer.is_closing(): - logger.debug("push_rx_raw: no client connected (companion %s)", self.companion_hash) - return - # Firmware: code(1) + snr(1) + rssi(1) + raw; snr = (int8)(snr*4), rssi = (int8)rssi - snr_byte = max(-128, min(127, int(round(snr * 4)))) - rssi_byte = max(-128, min(127, int(rssi))) - if snr_byte < 0: - snr_byte += 256 - if rssi_byte < 0: - rssi_byte += 256 - payload_len = min(len(raw), MAX_FRAME_SIZE - 3) - data = bytes([PUSH_CODE_LOG_RX_DATA, snr_byte & 0xFF, rssi_byte & 0xFF]) + raw[:payload_len] - try: - frame = bytes([FRAME_OUTBOUND_PREFIX]) + struct.pack(" None: - """Push CONTROL packet to client (PUSH_CODE_CONTROL_DATA 0x8E). Spec: code, SNR*4, RSSI (signed), path_len, payload (no path bytes). Frame layout matches meshcore_py reader (PacketType.CONTROL_DATA) and firmware MyMesh::onControlDataRecv. See docs/companion-discovery.md for discovery payload layout.""" - if not self._client_writer or self._client_writer.is_closing(): - logger.warning("Push control data skipped: no client connection") - return - # Discovery response (0x90): clear the no-op callback we registered for this tag - if self._control_handler and len(payload) >= 6 and (payload[0] & 0xF0) == 0x90: - tag = struct.unpack(" None: - if self._client_writer: - try: - await self._client_writer.drain() - except Exception: - pass - - def _write_frame(self, data: bytes) -> None: - """Send a frame to the connected client (outbound format).""" - if self._client_writer and not self._client_writer.is_closing(): - frame = bytes([FRAME_OUTBOUND_PREFIX]) + struct.pack(" None: - self._write_frame(bytes([RESP_CODE_OK])) - - def _write_err(self, err_code: int) -> None: - self._write_frame(bytes([RESP_CODE_ERR, err_code])) def _save_contacts(self) -> None: """Persist contacts to SQLite.""" @@ -460,859 +91,41 @@ class CompanionFrameServer: dicts = [] for c in contacts: pk = c.public_key if isinstance(c.public_key, bytes) else bytes.fromhex(c.public_key) - dicts.append({ - "pubkey": pk, - "name": c.name, - "adv_type": c.adv_type, - "flags": c.flags, - "out_path_len": c.out_path_len, - "out_path": c.out_path if isinstance(c.out_path, bytes) else (bytes.fromhex(c.out_path) if c.out_path else b""), - "last_advert_timestamp": c.last_advert_timestamp, - "lastmod": c.lastmod, - "gps_lat": c.gps_lat, - "gps_lon": c.gps_lon, - "sync_since": c.sync_since, - }) + dicts.append( + { + "pubkey": pk, + "name": c.name, + "adv_type": c.adv_type, + "flags": c.flags, + "out_path_len": c.out_path_len, + "out_path": ( + c.out_path + if isinstance(c.out_path, bytes) + else (bytes.fromhex(c.out_path) if c.out_path else b"") + ), + "last_advert_timestamp": c.last_advert_timestamp, + "lastmod": c.lastmod, + "gps_lat": c.gps_lat, + "gps_lon": c.gps_lon, + "sync_since": c.sync_since, + } + ) self.sqlite_handler.companion_save_contacts(self.companion_hash, dicts) - async def _handle_client( - self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter - ) -> None: - """Handle a new client connection. One client at a time.""" - if self._client_writer: - logger.warning("Companion already has a client; rejecting new connection") - writer.close() - await writer.wait_closed() - return - - self._client_reader = reader - self._client_writer = writer - self._setup_push_callbacks() - logger.info(f"Companion client connected (port={self.port})") - - try: - while True: - prefix = await reader.read(1) - if not prefix: - break - if prefix[0] != FRAME_INBOUND_PREFIX: - logger.warning(f"Invalid frame prefix: 0x{prefix[0]:02x}") - continue - len_bytes = await reader.readexactly(2) - frame_len = struct.unpack(" MAX_FRAME_SIZE: - logger.warning(f"Frame too long: {frame_len}") - break - payload = await reader.readexactly(frame_len) - await self._handle_cmd(payload) - except asyncio.IncompleteReadError: - pass - except (ConnectionResetError, BrokenPipeError): - pass - except Exception as e: - logger.error(f"Client handler error: {e}", exc_info=True) - finally: - self._client_writer = None - self._client_reader = None - logger.info(f"Companion client disconnected (port={self.port})") - - async def _handle_cmd(self, payload: bytes) -> None: - """Dispatch command to handler.""" - if not payload: - return - cmd = payload[0] - data = payload[1:] - # Log every command at INFO so discovery (52) and unsupported are visible in logs - logger.info("Companion cmd 0x%02x (%s) len=%s", cmd, cmd, len(payload)) - if cmd in (CMD_GET_CHANNEL, CMD_SET_CHANNEL): - logger.debug(f"Companion cmd 0x{cmd:02x} ({'GET_CHANNEL' if cmd == CMD_GET_CHANNEL else 'SET_CHANNEL'}), payload_len={len(payload)}") - - try: - if cmd == CMD_APP_START: - await self._cmd_app_start(data) - elif cmd == CMD_DEVICE_QUERY: - await self._cmd_device_query(data) - elif cmd == CMD_GET_CONTACTS: - await self._cmd_get_contacts(data) - elif cmd == CMD_GET_CONTACT_BY_KEY: - await self._cmd_get_contact_by_key(data) - elif cmd == CMD_SEND_TXT_MSG: - await self._cmd_send_txt_msg(data) - elif cmd == CMD_SEND_CHANNEL_TXT_MSG: - await self._cmd_send_channel_txt_msg(data) - elif cmd == CMD_SYNC_NEXT_MESSAGE: - await self._cmd_sync_next_message(data) - elif cmd == CMD_SEND_LOGIN: - await self._cmd_send_login(data) - elif cmd == CMD_SEND_STATUS_REQ: - await self._cmd_send_status_req(data) - elif cmd == CMD_SEND_TELEMETRY_REQ: - await self._cmd_send_telemetry_req(data) - elif cmd == CMD_SEND_SELF_ADVERT: - await self._cmd_send_self_advert(data) - elif cmd == CMD_SET_ADVERT_NAME: - await self._cmd_set_advert_name(data) - elif cmd == CMD_SET_ADVERT_LATLON: - await self._cmd_set_advert_latlon(data) - elif cmd == CMD_ADD_UPDATE_CONTACT: - await self._cmd_add_update_contact(data) - elif cmd == CMD_REMOVE_CONTACT: - await self._cmd_remove_contact(data) - elif cmd == CMD_RESET_PATH: - await self._cmd_reset_path(data) - elif cmd == CMD_GET_BATT_AND_STORAGE: - await self._cmd_get_batt_and_storage(data) - elif cmd == CMD_GET_STATS: - await self._cmd_get_stats(data) - elif cmd == CMD_GET_ADVERT_PATH: - await self._cmd_get_advert_path(data) - elif cmd == CMD_IMPORT_CONTACT: - await self._cmd_import_contact(data) - elif cmd == CMD_GET_CHANNEL: - await self._cmd_get_channel(data) - elif cmd == CMD_SET_CHANNEL: - await self._cmd_set_channel(data) - elif cmd == CMD_SEND_BINARY_REQ: - await self._cmd_send_binary_req(data) - elif cmd == CMD_SEND_PATH_DISCOVERY_REQ: - await self._cmd_send_path_discovery_req(data) - elif cmd == CMD_SEND_CONTROL_DATA: - await self._cmd_send_control_data(data) - elif cmd == CMD_SEND_TRACE_PATH: - await self._cmd_send_trace_path(data) - elif cmd == CMD_SET_FLOOD_SCOPE: - # App sends this on connect; no-op for repeater companion (no radio scope) - self._write_ok() - else: - logger.warning( - "Companion unsupported cmd 0x%02x (%s) len=%s (expected 52 for path discovery)", - cmd, cmd, len(payload), - ) - self._write_err(ERR_CODE_UNSUPPORTED_CMD) - except Exception as e: - logger.error(f"Cmd 0x{cmd:02x} error: {e}", exc_info=True) - self._write_err(ERR_CODE_ILLEGAL_ARG) - - async def _cmd_app_start(self, data: bytes) -> None: - if len(data) >= 1: - self._app_target_ver = data[0] - # RESP_CODE_SELF_INFO - name is varchar (remainder of frame) - # Send name without null terminator; client displays remainder of frame as-is - prefs = self.bridge.get_self_info() - pubkey = self.bridge.get_public_key() - name = prefs.node_name.encode("utf-8", errors="replace") - lat = int(getattr(prefs, "latitude", 0) * 1e6) - lon = int(getattr(prefs, "longitude", 0) * 1e6) - frame = ( - bytes([RESP_CODE_SELF_INFO, ADV_TYPE_CHAT, prefs.tx_power_dbm, 22]) - + pubkey - + struct.pack(" None: - if len(data) >= 1: - self._app_target_ver = data[0] - firmware_ver = 8 - # Protocol: max_contacts_div_2 and max_channels are bytes (ver 3+) - max_contacts = getattr( - getattr(self.bridge, "contacts", None), "max_contacts", 1000 - ) - max_channels_val = getattr( - getattr(self.bridge, "channels", None), "max_channels", 40 - ) - max_contacts_div_2 = min(max_contacts // 2, 255) - max_channels = min(max_channels_val, 255) - ble_pin = 0 - build_date = b"13 Feb 2026\x00"[:12].ljust(12, b"\x00") - model = b"pyMC-Repeater-Companion\x00"[:40].ljust(40, b"\x00") - version = b"1.0.0\x00"[:20].ljust(20, b"\x00") - frame = ( - bytes([RESP_CODE_DEVICE_INFO, firmware_ver, max_contacts_div_2, max_channels]) - + struct.pack(" None: - since = struct.unpack("= 4 else 0 - contacts = self.bridge.get_contacts(since=since) - self._write_frame(bytes([RESP_CODE_CONTACTS_START]) + struct.pack(" 0xFF, else 0-255 - opl = c.out_path_len if hasattr(c, "out_path_len") else -1 - opl_byte = 0xFF if opl < 0 else min(opl, 255) - frame = ( - bytes([RESP_CODE_CONTACT]) - + pubkey - + bytes([c.adv_type if hasattr(c, "adv_type") else 0, c.flags if hasattr(c, "flags") else 0]) - + bytes([opl_byte]) - + (c.out_path[:MAX_PATH_SIZE] if hasattr(c, "out_path") and c.out_path else b"").ljust(MAX_PATH_SIZE, b"\x00") - + name - + struct.pack(" None: - """Handle CMD_GET_CONTACT_BY_KEY (0x1e): lookup by 32-byte pubkey, respond with RESP_CODE_CONTACT or ERR.""" - if len(data) < PUB_KEY_SIZE: - self._write_err(ERR_CODE_ILLEGAL_ARG) - return - pubkey = data[:PUB_KEY_SIZE] - contact = self.bridge.contacts.get_by_key(pubkey) if hasattr(self.bridge.contacts, "get_by_key") else None - if not contact: - self._write_err(ERR_CODE_NOT_FOUND) - return - c = contact - pubkey_b = c.public_key if isinstance(c.public_key, bytes) else bytes.fromhex(c.public_key) - name = (c.name.encode("utf-8")[:32] if isinstance(c.name, str) else c.name[:32]).ljust(32, b"\x00") - opl = c.out_path_len if hasattr(c, "out_path_len") else -1 - opl_byte = 0xFF if opl < 0 else min(opl, 255) - frame = ( - bytes([RESP_CODE_CONTACT]) - + pubkey_b - + bytes([c.adv_type if hasattr(c, "adv_type") else 0, c.flags if hasattr(c, "flags") else 0]) - + bytes([opl_byte]) - + (c.out_path[:MAX_PATH_SIZE] if hasattr(c, "out_path") and c.out_path else b"").ljust(MAX_PATH_SIZE, b"\x00") - + name - + struct.pack(" None: - if len(data) < 12: - self._write_err(ERR_CODE_ILLEGAL_ARG) - return - txt_type = data[0] - attempt = data[1] - sender_ts = struct.unpack_from(" None: - if len(data) < 6: - self._write_err(ERR_CODE_ILLEGAL_ARG) - return - # Protocol: txt_type(1) + channel_idx(1) + sender_timestamp(4) + text (matches firmware/meshcore_py) - txt_type = data[0] - channel_idx = data[1] - sender_ts = struct.unpack_from(" None: - # CMD_SEND_BINARY_REQ: pubkey(32) + req_data (request_type(1) + optional payload) - if len(data) < 33: - self._write_err(ERR_CODE_ILLEGAL_ARG) - return - pubkey = data[:32] - req_data = data[32:] - send_binary_req = getattr(self.bridge, "send_binary_req", None) - if not send_binary_req: - self._write_err(ERR_CODE_UNSUPPORTED_CMD) - return - try: - result = await send_binary_req(pubkey, req_data) - except Exception as e: - logger.error(f"send_binary_req error: {e}", exc_info=True) - self._write_err(ERR_CODE_ILLEGAL_ARG) - return - if not result.success: - self._write_err(ERR_CODE_NOT_FOUND) - return - # RESP_CODE_SENT: 0x06 + flood(1) + tag(4 LE) + timeout(4 LE) - tag = result.expected_ack if result.expected_ack is not None else 0 - timeout_ms = result.timeout_ms if result.timeout_ms is not None else 10000 - frame = bytes([RESP_CODE_SENT, 1 if result.is_flood else 0]) + struct.pack(" None: - # CMD_SEND_CONTROL_DATA (55): first byte is flags/type (0x80 = DISCOVER_REQ). Firmware: (cmd_frame[1] & 0x80) != 0. - if len(data) < 2: - self._write_err(ERR_CODE_ILLEGAL_ARG) - return - if (data[0] & 0x80) == 0: - self._write_err(ERR_CODE_ILLEGAL_ARG) - return - # Discovery request: register a no-op response callback so ControlHandler won't log "No callback waiting" - if self._control_handler and len(data) >= 6 and (data[0] & 0xF0) == 0x80: - tag = struct.unpack(" None: - # CMD_SEND_PATH_DISCOVERY_REQ (52): reserved(1) + pub_key(32). Firmware: cmd_frame[1]==0, cmd_frame[2:34]=pub_key. - logger.info("Path discovery request received (cmd 52), data_len=%s", len(data)) - if len(data) < 33: - self._write_err(ERR_CODE_ILLEGAL_ARG) - return - pub_key = data[1:33] - send_req = getattr(self.bridge, "send_path_discovery_req", None) - if not send_req: - self._write_err(ERR_CODE_UNSUPPORTED_CMD) - return - try: - result = await send_req(pub_key) - except Exception as e: - logger.error("send_path_discovery_req error: %s", e, exc_info=True) - self._write_err(ERR_CODE_ILLEGAL_ARG) - return - if not result.success: - self._write_err(ERR_CODE_NOT_FOUND) - return - tag = result.expected_ack if result.expected_ack is not None else 0 - timeout_ms = result.timeout_ms if result.timeout_ms is not None else 10000 - frame = bytes([RESP_CODE_SENT, 1 if result.is_flood else 0]) + struct.pack(" None: - # CMD_SEND_TRACE_PATH: tag(4) + auth(4) + flags(1) + path_bytes (firmware MyMesh.cpp) - if len(data) < 10: - self._write_err(ERR_CODE_ILLEGAL_ARG) - return - tag = struct.unpack_from("> path_sz) <= MAX_PATH_SIZE and path_len % (1 << path_sz) == 0 - if (path_len >> path_sz) > MAX_PATH_SIZE or (path_len % (1 << path_sz)) != 0: - self._write_err(ERR_CODE_ILLEGAL_ARG) - return - send_raw = getattr(self.bridge, "send_trace_path_raw", None) - if not send_raw: - self._write_err(ERR_CODE_UNSUPPORTED_CMD) - return - try: - ok = await send_raw(tag, auth_code, flags, path_bytes) - except Exception as e: - logger.error(f"send_trace_path error: {e}", exc_info=True) - self._write_err(ERR_CODE_ILLEGAL_ARG) - return - if not ok: - self._write_err(ERR_CODE_TABLE_FULL) - return - # RESP_CODE_SENT + 0 (not flood) + tag(4) + est_timeout(4) = 10 bytes (firmware) - est_timeout_ms = 5000 + (path_len * 200) - frame = bytes([RESP_CODE_SENT, 0]) + struct.pack("> path_sz - path_snrs = bytes(snr_len) # no RX SNR when we're the sender - final_snr_byte = 0 - self.push_trace_data( - path_len, flags, tag, auth_code, path_bytes, path_snrs, final_snr_byte - ) - - async def _cmd_sync_next_message(self, data: bytes) -> None: - msg = self.bridge.sync_next_message() - if msg is None and self.sqlite_handler: - msg_dict = self.sqlite_handler.companion_pop_message(self.companion_hash) - if msg_dict: - msg = QueuedMessage( - sender_key=msg_dict.get("sender_key", b""), - txt_type=msg_dict.get("txt_type", 0), - timestamp=msg_dict.get("timestamp", 0), - text=msg_dict.get("text", ""), - is_channel=bool(msg_dict.get("is_channel", False)), - channel_idx=msg_dict.get("channel_idx", 0), - path_len=msg_dict.get("path_len", 0), - ) - if msg is None: - self._write_frame(bytes([RESP_CODE_NO_MORE_MESSAGES])) - return - if msg.is_channel: - # Layout must match meshcore_py reader.py (PacketType.CHANNEL_MSG_RECV and type 17) - # so client can group repeats by (channel_idx, sender_timestamp, text); path_len differs per repeat. - path_len_byte = msg.path_len if msg.path_len < 256 else 0xFF - txt_type = 0 # TXT_TYPE_PLAIN - text_bytes = (msg.text or "").rstrip("\x00").encode("utf-8", errors="replace") - if self._app_target_ver >= 3: - # V3: code(1) + snr(1) + reserved(2) + channel_idx(1) + path_len(1) + txt_type(1) + timestamp(4) + text - frame = bytes([ - RESP_CODE_CHANNEL_MSG_RECV_V3, - 0, 0, 0, # snr + reserved - msg.channel_idx, - path_len_byte, - txt_type, - ]) + struct.pack("= 6 else msg.sender_key.ljust(6, b"\x00") - path_len_byte = msg.path_len if msg.path_len < 256 else 0xFF - text_bytes = msg.text.encode("utf-8", errors="replace") - if self._app_target_ver >= 3: - frame = bytes([ - RESP_CODE_CONTACT_MSG_RECV_V3, - 0, 0, 0, # snr + reserved - ]) + prefix + bytes([path_len_byte, msg.txt_type]) + struct.pack(" None: - if len(data) < 32: - self._write_err(ERR_CODE_ILLEGAL_ARG) - return - pubkey = data[:32] - password = data[32:].decode("utf-8", errors="replace").rstrip("\x00") if len(data) > 32 else "" - self._write_frame(bytes([RESP_CODE_SENT, 1]) + struct.pack(" None: - # CMD_SEND_STATUS_REQ (27): pub_key(32). - # Firmware: cmd_frame[0]=CMD, pub_key = &cmd_frame[1]; len >= 1+PUB_KEY_SIZE - # data here is payload[1:] so data = pub_key(32). No reserved byte. - if len(data) < 32: - self._write_err(ERR_CODE_ILLEGAL_ARG) - return - pubkey = data[0:32] - # Immediate RESP_CODE_SENT so client knows the request was dispatched - self._write_frame(bytes([RESP_CODE_SENT, 0]) + struct.pack("= 16 else raw_bytes.hex()}" - ) - self._write_frame( - bytes([PUSH_CODE_STATUS_RESPONSE, 0]) - + pubkey[:6] - + raw_bytes - ) - - async def _cmd_send_telemetry_req(self, data: bytes) -> None: - # CMD_SEND_TELEMETRY_REQ (39): reserved(3) + pub_key(32) + optional flags(1). - # Firmware: cmd_frame[0]=CMD, reserved(3), pub_key = &cmd_frame[4]; len >= 4+PUB_KEY_SIZE - # data here is payload[1:] so data = reserved(3) + pub_key(32) + optional flags. - if len(data) < 35: - self._write_err(ERR_CODE_ILLEGAL_ARG) - return - pubkey = data[3:35] - # The 3 reserved bytes (data[0..2]) are unused by firmware. - # Default to requesting all telemetry categories. - flags = 0x07 # request all: base + location + environment - want_base = bool(flags & 0x01) - want_location = bool(flags & 0x02) - want_environment = bool(flags & 0x04) - # Immediate RESP_CODE_SENT so client knows the request was dispatched - self._write_frame(bytes([RESP_CODE_SENT, 0]) + struct.pack(" None: - flood = len(data) >= 1 and data[0] == 1 - ok = await self.bridge.advertise(flood=flood) - self._write_ok() if ok else self._write_err(ERR_CODE_BAD_STATE) - - async def _cmd_set_advert_name(self, data: bytes) -> None: - name = data.decode("utf-8", errors="replace").rstrip("\x00") - self.bridge.set_advert_name(name) - self._write_ok() - - async def _cmd_set_advert_latlon(self, data: bytes) -> None: - if len(data) < 8: - self._write_err(ERR_CODE_ILLEGAL_ARG) - return - lat, lon = struct.unpack_from(" None: - # Match meshcore minimum: 36 bytes (pubkey 32 + adv_type 1 + flags 1 + out_path_len 1). - if len(data) < 36: - self._write_err(ERR_CODE_ILLEGAL_ARG) - await self._drain_writer() - return - pubkey = data[0:32] - adv_type = data[32] - flags = data[33] - out_path_len = struct.unpack_from("= out_path_end: - out_path = data[35:out_path_end].rstrip(b"\x00") - else: - out_path = data[35:len(data)].rstrip(b"\x00") if len(data) > 35 else b"" - name_start = 35 + MAX_PATH_SIZE - name_end = name_start + 32 - if len(data) >= name_end: - name_raw = data[name_start:name_end] - elif len(data) > name_start: - name_raw = data[name_start:len(data)].ljust(32, b"\x00") - else: - name_raw = b"\x00" * 32 - name = name_raw.split(b"\x00")[0].decode("utf-8", errors="replace") - last_advert = 0 - if len(data) >= name_end + 4: - last_advert = struct.unpack_from("= name_end + 4 + 8: - gps_lat = struct.unpack_from("= name_end + 4 + 12: - lastmod = struct.unpack_from(" 255 else out_path_len - out_path_padded = (out_path[:MAX_PATH_SIZE] if out_path else b"").ljust(MAX_PATH_SIZE, b"\x00") - name_padded = (name.encode("utf-8")[:32] if isinstance(name, str) else name[:32]).ljust(32, b"\x00") - contact_frame = ( - bytes([RESP_CODE_CONTACT]) - + pubkey - + bytes([adv_type, flags, opl_byte]) - + out_path_padded - + name_padded - + struct.pack(" None: - if len(data) < 32: - self._write_err(ERR_CODE_ILLEGAL_ARG) - await self._drain_writer() - return - pubkey = data[:32] - ok = self.bridge.remove_contact(pubkey) - if ok and self.sqlite_handler: - self._save_contacts() - self._write_ok() if ok else self._write_err(ERR_CODE_NOT_FOUND) - await self._drain_writer() - - async def _cmd_reset_path(self, data: bytes) -> None: - if len(data) < 32: - self._write_err(ERR_CODE_ILLEGAL_ARG) - return - pubkey = data[:32] - ok = self.bridge.reset_path(pubkey) - self._write_ok() if ok else self._write_err(ERR_CODE_NOT_FOUND) - - async def _cmd_get_batt_and_storage(self, data: bytes) -> None: - millivolts = 0 - used_kb = 0 - total_kb = 0 - frame = bytes([RESP_CODE_BATT_AND_STORAGE]) + struct.pack(" None: - # CMD_GET_STATS (56): data[0] = stats_type (0=core, 1=radio, 2=packets). Firmware MyMesh.cpp + meshcore_py reader. - stats_type = data[0] if len(data) >= 1 else STATS_TYPE_PACKETS - if stats_type not in (STATS_TYPE_CORE, STATS_TYPE_RADIO, STATS_TYPE_PACKETS): - self._write_err(ERR_CODE_ILLEGAL_ARG) - return - stats = (self.stats_getter(stats_type) if self.stats_getter else None) or self.bridge.get_stats(stats_type) - frame = bytes([RESP_CODE_STATS, stats_type]) - if stats_type == STATS_TYPE_CORE: - # Format: battery_mv(H) + uptime_secs(I) + errors(H) + queue_len(B) = 9 bytes (meshcore_py ) - battery_mv = int(stats.get("battery_mv", 0)) - uptime_secs = int(stats.get("uptime_secs", 0)) - errors = int(stats.get("errors", 0)) - queue_len = min(255, max(0, int(stats.get("queue_len", 0)))) - frame += struct.pack(" None: - # CMD_GET_ADVERT_PATH (42): reserved(1) + pub_key(32). Return inbound path from advert_paths (path_cache). - # Firmware: RESP_CODE_ADVERT_PATH(1) + recv_timestamp(4 LE) + path_len(1) + path(path_len) - if len(data) < 1 + PUB_KEY_SIZE: - self._write_err(ERR_CODE_ILLEGAL_ARG) - return - pub_key = data[1 : 1 + PUB_KEY_SIZE] - prefix = pub_key[:7] - found = self.bridge.get_advert_path(prefix) if getattr(self.bridge, "get_advert_path", None) else None - if not found: - self._write_err(ERR_CODE_NOT_FOUND) - return - path_bytes = getattr(found, "path", None) or b"" - if not isinstance(path_bytes, bytes): - path_bytes = bytes(path_bytes) - path_len = min(len(path_bytes), MAX_PATH_SIZE) - recv_ts = getattr(found, "recv_timestamp", 0) - frame = bytes([RESP_CODE_ADVERT_PATH]) + struct.pack(" None: - ok = self.bridge.import_contact(data) - self._write_ok() if ok else self._write_err(ERR_CODE_ILLEGAL_ARG) - - async def _cmd_get_channel(self, data: bytes) -> None: - # Payload: channel index (1 byte), or empty for "get full list" (some apps send - # one request with no payload to receive all channels in one go). - channel_idx = data[0] if len(data) >= 1 else 0 - get_full_list = len(data) == 0 - max_channels_val = getattr( - getattr(self.bridge, "channels", None), "max_channels", 40 - ) - logger.debug( - f"CMD_GET_CHANNEL: idx={channel_idx}, data_len={len(data)}, get_full_list={get_full_list}" - ) - - # Frame format per firmware & meshcore_py: code(1) + channel_idx(1) + name(32) + secret(16) - def _channel_info_frame(idx: int, ch) -> bytes: - if ch is None: - name = b"\x00" * 32 - secret = b"\x00" * 16 - else: - name = ch.name.encode("utf-8", errors="replace")[:32].ljust( - 32, b"\x00" - ) - # Firmware and meshcore_py use 16-byte (128-bit) secret in the frame - secret = (ch.secret[:16] if ch.secret else b"\x00" * 16).ljust(16, b"\x00") - return bytes([RESP_CODE_CHANNEL_INFO, idx]) + name + secret - - if get_full_list: - for idx in range(max_channels_val): - ch = self.bridge.get_channel(idx) - frame = _channel_info_frame(idx, ch) - self._write_frame(frame) - logger.debug(f"CMD_GET_CHANNEL: sent full list ({max_channels_val} slots)") - return - - if channel_idx < 0 or channel_idx >= max_channels_val: - logger.debug(f"CMD_GET_CHANNEL: channel {channel_idx} out of range") - self._write_err(ERR_CODE_NOT_FOUND) - return - ch = self.bridge.get_channel(channel_idx) - if ch is None: - logger.debug(f"CMD_GET_CHANNEL: returning empty slot {channel_idx}") - else: - logger.debug(f"CMD_GET_CHANNEL: returning {ch.name!r}, secret_len=16") - frame = _channel_info_frame(channel_idx, ch) - self._write_frame(frame) - - async def _cmd_set_channel(self, data: bytes) -> None: - # MeshCore format: channel_idx(1) + name(32) + secret(32) or secret_hex(64) - logger.debug(f"CMD_SET_CHANNEL: data_len={len(data)}, data_hex={data[:50].hex()}...") - if len(data) < 34: # minimum: idx + name(32) + at least 1 byte secret - logger.debug(f"CMD_SET_CHANNEL: rejected (len {len(data)} < 34)") - self._write_err(ERR_CODE_ILLEGAL_ARG) - return - channel_idx = data[0] - name_raw = data[1:33] - name = name_raw.split(b"\x00")[0].decode("utf-8", errors="replace").strip() - if len(data) >= 97: - # Hex secret: 64 hex chars = 32 bytes - try: - secret = bytes.fromhex(data[33:97].decode("ascii")) - logger.debug(f"CMD_SET_CHANNEL: parsed hex secret, len={len(secret)}") - except (ValueError, UnicodeDecodeError) as e: - logger.debug(f"CMD_SET_CHANNEL: hex secret parse failed: {e}") - self._write_err(ERR_CODE_ILLEGAL_ARG) - return - elif len(data) >= 65: - # Binary secret: 32 bytes (MeshCore DataStore format) - secret = data[33:65] - logger.debug(f"CMD_SET_CHANNEL: parsed 32-byte binary secret") - elif len(data) >= 49: - # Legacy: 16-byte binary secret - secret = data[33:49] - logger.debug(f"CMD_SET_CHANNEL: parsed 16-byte binary secret") - else: - logger.debug(f"CMD_SET_CHANNEL: rejected (len {len(data)} not in 49/65/97)") - self._write_err(ERR_CODE_ILLEGAL_ARG) - return - logger.debug(f"CMD_SET_CHANNEL: idx={channel_idx}, name={name!r}, secret_len={len(secret)}") - ok = self.bridge.set_channel(channel_idx, name, secret) - if ok and self.sqlite_handler: - self._save_channels() - logger.debug(f"CMD_SET_CHANNEL: set_channel ok={ok}") - - self._write_ok() if ok else self._write_err(ERR_CODE_NOT_FOUND) - def _save_channels(self) -> None: """Persist channels to SQLite.""" if not self.sqlite_handler: return channels = [] - max_ch = getattr( - getattr(self.bridge, "channels", None), "max_channels", 40 - ) + max_ch = getattr(getattr(self.bridge, "channels", None), "max_channels", 40) for idx in range(max_ch): ch = self.bridge.get_channel(idx) if ch is not None: - channels.append({ - "channel_idx": idx, - "name": ch.name, - "secret": ch.secret, - }) + channels.append( + { + "channel_idx": idx, + "name": ch.name, + "secret": ch.secret, + } + ) self.sqlite_handler.companion_save_channels(self.companion_hash, channels) diff --git a/repeater/config.py b/repeater/config.py index 5bd9ffa..cb6b98b 100644 --- a/repeater/config.py +++ b/repeater/config.py @@ -49,7 +49,7 @@ def get_node_info(config: Dict[str, Any]) -> Dict[str, Any]: "model": letsmesh_config.get("model", "PyMC-Repeater"), "disallowed_packet_types": disallowed_hex, "email": letsmesh_config.get("email", ""), - "owner": letsmesh_config.get("owner", "") + "owner": letsmesh_config.get("owner", ""), } @@ -107,14 +107,14 @@ def save_config(config_data: Dict[str, Any], config_path: Optional[str] = None) # Create backup of existing config config_file = Path(config_path) if config_file.exists(): - backup_path = config_file.with_suffix('.yaml.backup') + backup_path = config_file.with_suffix(".yaml.backup") config_file.rename(backup_path) logger.info(f"Created backup at {backup_path}") - + # Save new config - with open(config_path, 'w') as f: + with open(config_path, "w") as f: yaml.safe_dump(config_data, f, default_flow_style=False, sort_keys=False) - + logger.info(f"Saved configuration to {config_path}") return True @@ -252,7 +252,9 @@ def get_radio_for_board(board_config: dict): from pymc_core.hardware.kiss_modem_wrapper import KissModemWrapper except ImportError: try: - from pymc_core.hardware.kiss_serial_wrapper import KissSerialWrapper as KissModemWrapper + from pymc_core.hardware.kiss_serial_wrapper import ( + KissSerialWrapper as KissModemWrapper, + ) except ImportError: raise RuntimeError( "KISS modem support requires pyMC_core with KISS support. " diff --git a/repeater/config_manager.py b/repeater/config_manager.py index e2457a3..a30003c 100644 --- a/repeater/config_manager.py +++ b/repeater/config_manager.py @@ -2,19 +2,20 @@ from __future__ import annotations import logging import os -import yaml from typing import Any, Dict, List, Optional +import yaml + logger = logging.getLogger("ConfigManager") class ConfigManager: """Manages configuration persistence and live updates to the daemon.""" - + def __init__(self, config_path: str, config: dict, daemon_instance=None): """ Initialize ConfigManager. - + Args: config_path: Path to the YAML config file config: Reference to the config dictionary @@ -23,7 +24,7 @@ class ConfigManager: self.config_path = config_path self.config = config self.daemon = daemon_instance - + def save_to_file(self) -> tuple[bool, str]: """ Save current config to YAML file. @@ -35,7 +36,7 @@ class ConfigManager: dirpath = os.path.dirname(self.config_path) if dirpath: os.makedirs(dirpath, exist_ok=True) - with open(self.config_path, 'w') as f: + with open(self.config_path, "w") as f: # Use safe_dump with explicit width to prevent line wrapping # Setting width to a very large number prevents truncation of long strings like identity keys yaml.safe_dump( @@ -45,7 +46,7 @@ class ConfigManager: indent=2, width=1000000, # Very large width to prevent any line wrapping sort_keys=False, - allow_unicode=True + allow_unicode=True, ) logger.info(f"Configuration saved to {self.config_path}") return True, "" @@ -53,73 +54,75 @@ class ConfigManager: msg = f"Failed to save config to {self.config_path}: {e}" logger.error(msg, exc_info=True) return False, str(e) - + def live_update_daemon(self, sections: Optional[List[str]] = None) -> bool: """ Apply configuration changes to the running daemon's in-memory config. - + Args: sections: List of config sections to update (e.g., ['repeater', 'delays']). If None, updates all common sections. - + Returns: True if live update was successful, False otherwise """ - if not self.daemon or not hasattr(self.daemon, 'config'): + if not self.daemon or not hasattr(self.daemon, "config"): logger.warning("Daemon not available for live update") return False - + try: daemon_config = self.daemon.config - + # Default sections to update if not specified if sections is None: - sections = ['repeater', 'delays', 'radio', 'acl', 'identities'] - + sections = ["repeater", "delays", "radio", "acl", "identities"] + # Update each section for section in sections: if section in self.config: if section not in daemon_config: daemon_config[section] = {} - + # Deep copy the section to avoid reference issues if isinstance(self.config[section], dict): daemon_config[section].update(self.config[section]) else: daemon_config[section] = self.config[section] - + logger.debug(f"Live updated daemon config section: {section}") - + logger.info(f"Live updated daemon config sections: {', '.join(sections)}") - + # Also reload runtime config in RepeaterHandler if delays or repeater sections changed - if self.daemon and hasattr(self.daemon, 'repeater_handler'): - if any(s in ['delays', 'repeater'] for s in sections): - if hasattr(self.daemon.repeater_handler, 'reload_runtime_config'): + if self.daemon and hasattr(self.daemon, "repeater_handler"): + if any(s in ["delays", "repeater"] for s in sections): + if hasattr(self.daemon.repeater_handler, "reload_runtime_config"): self.daemon.repeater_handler.reload_runtime_config() logger.info("Reloaded RepeaterHandler runtime config") - + return True - + except Exception as e: logger.error(f"Failed to live update daemon config: {e}", exc_info=True) return False - - def update_and_save(self, - updates: Dict[str, Any], - live_update: bool = True, - live_update_sections: Optional[List[str]] = None) -> Dict[str, Any]: + + def update_and_save( + self, + updates: Dict[str, Any], + live_update: bool = True, + live_update_sections: Optional[List[str]] = None, + ) -> Dict[str, Any]: """ Apply updates to config, save to file, and optionally live update daemon. - + This is the main method that should be used by both mesh_cli and api_endpoints. - + Args: updates: Dictionary of config updates in nested format. Example: {"repeater": {"node_name": "NewName"}, "delays": {"tx_delay_factor": 1.5}} live_update: Whether to apply changes to running daemon immediately live_update_sections: Specific sections to live update. If None, auto-detects from updates. - + Returns: Dict with keys: - success: bool - Whether operation succeeded @@ -127,23 +130,19 @@ class ConfigManager: - live_updated: bool - Whether daemon was live updated - error: str (optional) - Error message if failed """ - result = { - "success": False, - "saved": False, - "live_updated": False - } - + result = {"success": False, "saved": False, "live_updated": False} + try: # Apply updates to config for section, values in updates.items(): if section not in self.config: self.config[section] = {} - + if isinstance(values, dict): self.config[section].update(values) else: self.config[section] = values - + # Save to file saved, err = self.save_to_file() result["saved"] = saved @@ -151,39 +150,39 @@ class ConfigManager: if not result["saved"]: result["error"] = err or "Failed to save config to file" return result - + # Live update daemon if requested if live_update: # Auto-detect sections if not specified if live_update_sections is None: live_update_sections = list(updates.keys()) - + result["live_updated"] = self.live_update_daemon(live_update_sections) - + result["success"] = result["saved"] return result - + except Exception as e: logger.error(f"Error in update_and_save: {e}", exc_info=True) result["error"] = str(e) return result - + def update_nested(self, path: str, value: Any, live_update: bool = True) -> Dict[str, Any]: """ Update a nested config value using dot notation. - + Convenience method for simple updates like "repeater.node_name" = "NewName" - + Args: path: Dot-separated path to config value (e.g., "repeater.node_name") value: Value to set live_update: Whether to apply changes to running daemon - + Returns: Result dict from update_and_save """ - parts = path.split('.') - + parts = path.split(".") + if len(parts) == 1: # Top-level key updates = {parts[0]: value} @@ -202,26 +201,26 @@ class ConfigManager: current[part] = {} current = current[part] current[parts[-1]] = value - + # Determine which section to live update section = parts[0] - + return self.update_and_save( updates=updates, live_update=live_update, - live_update_sections=[section] if live_update else None + live_update_sections=[section] if live_update else None, ) - + def get_status(self) -> Dict[str, Any]: """ Get status information about the ConfigManager. - + Returns: Dict with config file path, existence, daemon availability """ return { "config_path": self.config_path, "config_exists": os.path.exists(self.config_path), - "daemon_available": self.daemon is not None and hasattr(self.daemon, 'config'), - "config_sections": list(self.config.keys()) if self.config else [] + "daemon_available": self.daemon is not None and hasattr(self.daemon, "config"), + "config_sections": list(self.config.keys()) if self.config else [], } diff --git a/repeater/data_acquisition/__init__.py b/repeater/data_acquisition/__init__.py index 5df598e..14a0e13 100644 --- a/repeater/data_acquisition/__init__.py +++ b/repeater/data_acquisition/__init__.py @@ -1,6 +1,6 @@ -from .sqlite_handler import SQLiteHandler -from .rrdtool_handler import RRDToolHandler from .mqtt_handler import MQTTHandler +from .rrdtool_handler import RRDToolHandler +from .sqlite_handler import SQLiteHandler from .storage_collector import StorageCollector -__all__ = ['SQLiteHandler', 'RRDToolHandler', 'MQTTHandler', 'StorageCollector'] \ No newline at end of file +__all__ = ["SQLiteHandler", "RRDToolHandler", "MQTTHandler", "StorageCollector"] diff --git a/repeater/data_acquisition/hardware_stats.py b/repeater/data_acquisition/hardware_stats.py index a19ffc0..465478e 100644 --- a/repeater/data_acquisition/hardware_stats.py +++ b/repeater/data_acquisition/hardware_stats.py @@ -5,13 +5,14 @@ KISS - Keep It Simple Stupid approach. try: import psutil + PSUTIL_AVAILABLE = True except ImportError: PSUTIL_AVAILABLE = False psutil = None -import time import logging +import time logger = logging.getLogger("HardwareStats") @@ -26,10 +27,8 @@ class HardwareStatsCollector: if not PSUTIL_AVAILABLE: logger.error("psutil not available - cannot collect hardware stats") - return { - "error": "psutil library not available - cannot collect hardware statistics" - } - + return {"error": "psutil library not available - cannot collect hardware statistics"} + try: # Get current timestamp now = time.time() @@ -42,10 +41,10 @@ class HardwareStatsCollector: # Memory stats memory = psutil.virtual_memory() - + # Disk stats - disk = psutil.disk_usage('/') - + disk = psutil.disk_usage("/") + # Network stats (total across all interfaces) net_io = psutil.net_io_counters() @@ -79,48 +78,39 @@ class HardwareStatsCollector: "usage_percent": cpu_percent, "count": cpu_count, "frequency": cpu_freq.current if cpu_freq else 0, - "load_avg": { - "1min": load_avg[0], - "5min": load_avg[1], - "15min": load_avg[2] - } + "load_avg": {"1min": load_avg[0], "5min": load_avg[1], "15min": load_avg[2]}, }, "memory": { "total": memory.total, "available": memory.available, "used": memory.used, - "usage_percent": memory.percent + "usage_percent": memory.percent, }, "disk": { "total": disk.total, "used": disk.used, "free": disk.free, - "usage_percent": round((disk.used / disk.total) * 100, 1) + "usage_percent": round((disk.used / disk.total) * 100, 1), }, "network": { "bytes_sent": net_io.bytes_sent, "bytes_recv": net_io.bytes_recv, "packets_sent": net_io.packets_sent, - "packets_recv": net_io.packets_recv + "packets_recv": net_io.packets_recv, }, - "system": { - "uptime": system_uptime, - "boot_time": boot_time - } + "system": {"uptime": system_uptime, "boot_time": boot_time}, } - + # Add temperatures if available if temperatures: stats["temperatures"] = temperatures return stats - + except Exception as e: logger.error(f"Error collecting hardware stats: {e}") - return { - "error": str(e) - } - + return {"error": str(e)} + def get_processes_summary(self, limit=10): """ Get top processes by CPU and memory usage. @@ -131,44 +121,39 @@ class HardwareStatsCollector: return { "processes": [], "total_processes": 0, - "error": "psutil library not available - cannot collect process statistics" + "error": "psutil library not available - cannot collect process statistics", } - + try: processes = [] - + # Get all processes - for proc in psutil.process_iter(['pid', 'name', 'cpu_percent', 'memory_percent', 'memory_info']): + for proc in psutil.process_iter( + ["pid", "name", "cpu_percent", "memory_percent", "memory_info"] + ): try: pinfo = proc.info # Calculate memory in MB memory_mb = 0 - if pinfo['memory_info']: - memory_mb = pinfo['memory_info'].rss / 1024 / 1024 # RSS in MB - + if pinfo["memory_info"]: + memory_mb = pinfo["memory_info"].rss / 1024 / 1024 # RSS in MB + process_data = { - "pid": pinfo['pid'], - "name": pinfo['name'] or 'Unknown', - "cpu_percent": pinfo['cpu_percent'] or 0.0, - "memory_percent": pinfo['memory_percent'] or 0.0, - "memory_mb": round(memory_mb, 1) + "pid": pinfo["pid"], + "name": pinfo["name"] or "Unknown", + "cpu_percent": pinfo["cpu_percent"] or 0.0, + "memory_percent": pinfo["memory_percent"] or 0.0, + "memory_mb": round(memory_mb, 1), } processes.append(process_data) except (psutil.NoSuchProcess, psutil.AccessDenied): pass - + # Sort by CPU usage and get top processes - top_processes = sorted(processes, key=lambda x: x['cpu_percent'], reverse=True)[:limit] - - return { - "processes": top_processes, - "total_processes": len(processes) - } - + top_processes = sorted(processes, key=lambda x: x["cpu_percent"], reverse=True)[:limit] + + return {"processes": top_processes, "total_processes": len(processes)} + except Exception as e: logger.error(f"Error collecting process stats: {e}") - return { - "processes": [], - "total_processes": 0, - "error": str(e) - } \ No newline at end of file + return {"processes": [], "total_processes": 0, "error": str(e)} diff --git a/repeater/data_acquisition/letsmesh_handler.py b/repeater/data_acquisition/letsmesh_handler.py index 872640c..452a4ed 100644 --- a/repeater/data_acquisition/letsmesh_handler.py +++ b/repeater/data_acquisition/letsmesh_handler.py @@ -1,24 +1,27 @@ +import base64 +import binascii import json import logging -import binascii -import base64 -import paho.mqtt.client as mqtt import threading +from datetime import UTC, datetime, timedelta +from typing import Callable, Dict, List, Optional -from datetime import datetime, timedelta, UTC +import paho.mqtt.client as mqtt from nacl.signing import SigningKey -from typing import Callable, Optional, List, Dict -from .. import __version__ +from .. import __version__ # Try to import paho-mqtt error code mappings try: from paho.mqtt.reasoncodes import ReasonCode + HAS_REASON_CODES = True except ImportError: HAS_REASON_CODES = False logger = logging.getLogger("LetsMeshHandler") + + # -------------------------------------------------------------------- # Helper: Base64URL without padding # -------------------------------------------------------------------- @@ -117,7 +120,7 @@ class _BrokerConnection: payload_b64 = b64url(json.dumps(payload, separators=(",", ":")).encode()) signing_input = f"{header_b64}.{payload_b64}".encode() - + # Sign using LocalIdentity (supports both standard and firmware keys) try: signature = self.local_identity.sign(signing_input) @@ -126,10 +129,10 @@ class _BrokerConnection: logging.error(f" - public_key: {self.public_key}") logging.error(f" - signing_input length: {len(signing_input)}") raise - + signature_hex = binascii.hexlify(signature).decode() token = f"{header_b64}.{payload_b64}.{signature_hex}" - + logging.debug(f"JWT token generated for {self.broker['name']}: {token[:50]}...") return token @@ -152,7 +155,7 @@ class _BrokerConnection: """MQTT disconnection callback""" was_running = self._running self._running = False - + if rc != 0: # Unexpected disconnect error_msg = get_mqtt_error_message(rc, is_disconnect=True) logging.warning(f"Disconnected from {self.broker['name']} (rc={rc}): {error_msg}") @@ -160,7 +163,7 @@ class _BrokerConnection: self._schedule_reconnect(reason=error_msg) else: logging.info(f"Clean disconnect from {self.broker['name']}") - + if self._on_disconnect_callback: self._on_disconnect_callback(self.broker["name"]) @@ -168,29 +171,31 @@ class _BrokerConnection: """Schedule reconnection with exponential backoff""" if self._reconnect_timer: self._reconnect_timer.cancel() - + # Exponential backoff: 5s, 10s, 20s, 40s, 80s, up to max - delay = min(5 * (2 ** self._reconnect_attempts), self._max_reconnect_delay) + delay = min(5 * (2**self._reconnect_attempts), self._max_reconnect_delay) self._reconnect_attempts += 1 - - logging.info(f"Scheduling reconnect to {self.broker['name']} in {delay}s (attempt {self._reconnect_attempts}, reason: {reason})") + + logging.info( + f"Scheduling reconnect to {self.broker['name']} in {delay}s (attempt {self._reconnect_attempts}, reason: {reason})" + ) self._reconnect_timer = threading.Timer(delay, lambda: self._attempt_reconnect(reason)) self._reconnect_timer.daemon = True self._reconnect_timer.start() - + def _attempt_reconnect(self, reason: str = "connection lost"): """Attempt to reconnect to broker with fresh JWT""" try: logging.info(f"Attempting reconnection to {self.broker['name']} (reason: {reason})...") - + # Stop the loop if it's still running (websocket mode requires clean restart) try: self.client.loop_stop() except: pass - + self._set_jwt_credentials() - + # Reconnect and restart loop self.client.connect(self.broker["host"], self.broker["port"], keepalive=60) self.client.loop_start() @@ -198,7 +203,7 @@ class _BrokerConnection: except Exception as e: logging.error(f"Reconnection failed for {self.broker['name']}: {e}") self._schedule_reconnect() # Try again later - + def _set_jwt_credentials(self): """Set JWT token credentials before connecting (CONNECT handshake only)""" try: @@ -242,7 +247,7 @@ class _BrokerConnection: """Disconnect from broker""" self._running = False self._loop_running = False - + # Cancel any pending timers if self._reconnect_timer: self._reconnect_timer.cancel() @@ -250,7 +255,7 @@ class _BrokerConnection: if self._jwt_refresh_timer: self._jwt_refresh_timer.cancel() self._jwt_refresh_timer = None - + self.client.loop_stop() self.client.disconnect() logging.info(f"Disconnected from {self.broker['name']}") @@ -265,7 +270,7 @@ class _BrokerConnection: def is_connected(self) -> bool: """Check if connection is active""" return self._running - + def has_pending_reconnect(self) -> bool: """Check if a reconnection is scheduled""" return self._reconnect_timer is not None and self._reconnect_timer.is_alive() @@ -281,19 +286,19 @@ class _BrokerConnection: stagger_offset = self.broker_index * 0.05 refresh_threshold = 0.80 + stagger_offset return elapsed >= expiry_seconds * refresh_threshold - + def _schedule_jwt_refresh(self): """Schedule proactive JWT refresh before token expires""" if self._jwt_refresh_timer: self._jwt_refresh_timer.cancel() - + expiry_seconds = self.jwt_expiry_minutes * 60 # Stagger refresh by 5% per broker to prevent simultaneous disconnects # Broker 0: 80%, Broker 1: 85%, Broker 2: 90%, etc. stagger_offset = self.broker_index * 0.05 refresh_threshold = 0.80 + stagger_offset refresh_delay = expiry_seconds * refresh_threshold - + logging.info( f"JWT refresh scheduled for {self.broker['name']} in {refresh_delay:.0f}s " f"({refresh_threshold*100:.0f}% of {self.jwt_expiry_minutes}min token lifetime)" @@ -301,12 +306,12 @@ class _BrokerConnection: self._jwt_refresh_timer = threading.Timer(refresh_delay, self.reconnect_for_token_expiry) self._jwt_refresh_timer.daemon = True self._jwt_refresh_timer.start() - + def reconnect_for_token_expiry(self): """Proactively reconnect with new JWT before current one expires""" if not self._running: return - + logging.info(f"JWT token expiring soon for {self.broker['name']}, refreshing...") self._running = False self._jwt_refresh_timer = None @@ -330,7 +335,7 @@ class MeshCoreToMqttJwtPusher: # Store local identity and get public key self.local_identity = local_identity public_key = local_identity.get_public_key().hex().upper() - + # Extract values from config from ..config import get_node_info @@ -356,9 +361,11 @@ class MeshCoreToMqttJwtPusher: elif broker_index is None or broker_index == -1: # Connect to all built-in brokers + additional ones self.brokers = LETSMESH_BROKERS.copy() - logging.info(f"Multi-broker mode: connecting to all {len(LETSMESH_BROKERS)} built-in brokers") + logging.info( + f"Multi-broker mode: connecting to all {len(LETSMESH_BROKERS)} built-in brokers" + ) else: - + if broker_index >= len(LETSMESH_BROKERS): raise ValueError(f"Invalid broker_index {broker_index}") self.brokers = [LETSMESH_BROKERS[broker_index]] @@ -372,7 +379,7 @@ class MeshCoreToMqttJwtPusher: logging.info(f"Added custom broker: {broker_config['name']}") else: logging.warning(f"Skipping invalid broker config: {broker_config}") - + # Validate that we have at least one broker if not self.brokers: raise ValueError( @@ -432,7 +439,7 @@ class MeshCoreToMqttJwtPusher: # Check if all connections are down AND none have pending reconnects all_down = all(not conn.is_connected() for conn in self.connections) any_reconnecting = any(conn.has_pending_reconnect() for conn in self.connections) - + if all_down and not any_reconnecting: logging.warning("All broker connections lost with no pending reconnects") elif all_down: @@ -454,7 +461,7 @@ class MeshCoreToMqttJwtPusher: timer.start() except Exception as e: logging.error(f"Failed to connect to {conn.broker['name']}: {e}") - + def _delayed_connect(self, conn): """Connect a broker after a delay (called by timer)""" try: @@ -471,6 +478,7 @@ class MeshCoreToMqttJwtPusher: self.publish_status(state="offline", origin=self.node_name, radio_config=self.radio_config) import time + time.sleep(0.5) # Give time for messages to be sent # Disconnect all brokers @@ -493,7 +501,7 @@ class MeshCoreToMqttJwtPusher: state="online", origin=self.node_name, radio_config=self.radio_config ) logging.debug(f"Status heartbeat sent (next in {self.status_interval}s)") - + time.sleep(self.status_interval) except Exception as e: logging.error(f"Status heartbeat error: {e}") @@ -579,14 +587,15 @@ class MeshCoreToMqttJwtPusher: # Helper Functions # ==================================================================== + def get_mqtt_error_message(rc: int, is_disconnect: bool = False) -> str: """ Get human-readable MQTT error message. - + Args: rc: Return code from paho-mqtt is_disconnect: True if from on_disconnect, False if from on_connect - + Returns: Human-readable error message """ @@ -596,7 +605,7 @@ def get_mqtt_error_message(rc: int, is_disconnect: bool = False) -> str: return f"{reason.name}: {reason.value}" except (ValueError, AttributeError): pass - + # Fallback to manual mappings connect_errors = { 0: "connection accepted", @@ -607,7 +616,7 @@ def get_mqtt_error_message(rc: int, is_disconnect: bool = False) -> str: 5: "not authorized (JWT signature/format invalid)", 6: "reserved error code", } - + disconnect_errors = { 0: "normal disconnect", 1: "unacceptable protocol version", @@ -618,7 +627,6 @@ def get_mqtt_error_message(rc: int, is_disconnect: bool = False) -> str: 16: "connection lost / protocol error", 17: "client timeout", } - + error_dict = disconnect_errors if is_disconnect else connect_errors return error_dict.get(rc, f"unknown error code {rc}") - diff --git a/repeater/data_acquisition/mqtt_handler.py b/repeater/data_acquisition/mqtt_handler.py index 1baf9e7..c08810d 100644 --- a/repeater/data_acquisition/mqtt_handler.py +++ b/repeater/data_acquisition/mqtt_handler.py @@ -1,10 +1,11 @@ import json import logging import ssl -from typing import Dict, Any, Optional +from typing import Any, Dict, Optional try: import paho.mqtt.client as mqtt + MQTT_AVAILABLE = True except ImportError: MQTT_AVAILABLE = False @@ -102,17 +103,17 @@ class MQTTHandler: try: base_topic = self.mqtt_config.get("base_topic", "meshcore/repeater") topic = f"{base_topic}/{self.node_name}/{record_type}" - + if record_type == "packet": packet_record = PacketRecord.from_packet_record( - record, - origin=self.node_name, - origin_id=self.node_id + record, origin=self.node_name, origin_id=self.node_id ) if not packet_record: - logger.debug("Skipping MQTT publish: packet missing required data for PacketRecord") + logger.debug( + "Skipping MQTT publish: packet missing required data for PacketRecord" + ) return - + payload = packet_record.to_dict() logger.debug("Publishing packet using PacketRecord format") else: diff --git a/repeater/data_acquisition/rrdtool_handler.py b/repeater/data_acquisition/rrdtool_handler.py index 8a3a442..e89b4ed 100644 --- a/repeater/data_acquisition/rrdtool_handler.py +++ b/repeater/data_acquisition/rrdtool_handler.py @@ -1,10 +1,11 @@ import logging import time from pathlib import Path -from typing import Optional, Dict, Any +from typing import Any, Dict, Optional try: import rrdtool + RRDTOOL_AVAILABLE = True except ImportError: RRDTOOL_AVAILABLE = False @@ -23,17 +24,18 @@ class RRDToolHandler: if not self.available: logger.warning("RRDTool not available - skipping RRD initialization") return - + if self.rrd_path.exists(): logger.info(f"RRD database exists: {self.rrd_path}") return - + try: rrdtool.create( str(self.rrd_path), - "--step", "60", - "--start", str(int(time.time() - 60)), - + "--step", + "60", + "--start", + str(int(time.time() - 60)), "DS:rx_count:COUNTER:120:0:U", "DS:tx_count:COUNTER:120:0:U", "DS:drop_count:COUNTER:120:0:U", @@ -42,7 +44,6 @@ class RRDToolHandler: "DS:avg_length:GAUGE:120:0:256", "DS:avg_score:GAUGE:120:0:1", "DS:neighbor_count:GAUGE:120:0:U", - "DS:type_0:COUNTER:120:0:U", "DS:type_1:COUNTER:120:0:U", "DS:type_2:COUNTER:120:0:U", @@ -60,25 +61,24 @@ class RRDToolHandler: "DS:type_14:COUNTER:120:0:U", "DS:type_15:COUNTER:120:0:U", "DS:type_other:COUNTER:120:0:U", - "RRA:AVERAGE:0.5:1:10080", "RRA:AVERAGE:0.5:5:8640", "RRA:AVERAGE:0.5:60:8760", "RRA:MAX:0.5:1:10080", - "RRA:MIN:0.5:1:10080" + "RRA:MIN:0.5:1:10080", ) logger.info(f"RRD database created: {self.rrd_path}") - + except Exception as e: logger.error(f"Failed to create RRD database: {e}") def update_packet_metrics(self, record: dict, cumulative_counts: dict): if not self.available or not self.rrd_path.exists(): return - + try: timestamp = int(record.get("timestamp", time.time())) - + try: info = rrdtool.info(str(self.rrd_path)) last_update = int(info.get("last_update", timestamp - 60)) @@ -86,104 +86,114 @@ class RRDToolHandler: return except Exception as e: logger.debug(f"Failed to get RRD info for packet update: {e}") - + rx_total = cumulative_counts.get("rx_total", 0) tx_total = cumulative_counts.get("tx_total", 0) drop_total = cumulative_counts.get("drop_total", 0) type_counts = cumulative_counts.get("type_counts", {}) - + type_values = [] for i in range(16): type_values.append(str(type_counts.get(f"type_{i}", 0))) type_values.append(str(type_counts.get("type_other", 0))) - + # Handle None values for TX packets - use 'U' (unknown) for RRD - rssi = record.get('rssi') - snr = record.get('snr') - score = record.get('score') - - rssi_val = 'U' if rssi is None else str(rssi) - snr_val = 'U' if snr is None else str(snr) - score_val = 'U' if score is None else str(score) - length_val = str(record.get('length', 0)) - - basic_values = f"{timestamp}:{rx_total}:{tx_total}:{drop_total}:" \ - f"{rssi_val}:{snr_val}:{length_val}:{score_val}:" \ - f"U" - + rssi = record.get("rssi") + snr = record.get("snr") + score = record.get("score") + + rssi_val = "U" if rssi is None else str(rssi) + snr_val = "U" if snr is None else str(snr) + score_val = "U" if score is None else str(score) + length_val = str(record.get("length", 0)) + + basic_values = ( + f"{timestamp}:{rx_total}:{tx_total}:{drop_total}:" + f"{rssi_val}:{snr_val}:{length_val}:{score_val}:" + f"U" + ) + type_values_str = ":".join(type_values) values = f"{basic_values}:{type_values_str}" - + rrdtool.update(str(self.rrd_path), values) - + except Exception as e: logger.error(f"Failed to update RRD packet metrics: {e}") logger.debug(f"RRD packet update failed - record: {record}") - def get_data(self, start_time: Optional[int] = None, end_time: Optional[int] = None, - resolution: str = "average") -> Optional[dict]: + def get_data( + self, + start_time: Optional[int] = None, + end_time: Optional[int] = None, + resolution: str = "average", + ) -> Optional[dict]: if not self.available or not self.rrd_path.exists(): - logger.error(f"RRD not available: available={self.available}, rrd_path exists={self.rrd_path.exists()}") + logger.error( + f"RRD not available: available={self.available}, rrd_path exists={self.rrd_path.exists()}" + ) return None - + try: if end_time is None: end_time = int(time.time()) if start_time is None: start_time = end_time - (24 * 3600) - + fetch_result = rrdtool.fetch( str(self.rrd_path), resolution.upper(), - "--start", str(start_time), - "--end", str(end_time) + "--start", + str(start_time), + "--end", + str(end_time), ) - + if not fetch_result: logger.error("RRD fetch returned None") return None - + (start, end, step), data_sources, data_points = fetch_result - + if not data_points: logger.warning("No data points returned from RRD fetch") - + result = { "start_time": start, "end_time": end, "step": step, "data_sources": data_sources, "packet_types": {}, - "metrics": {} + "metrics": {}, } - + timestamps = [] current_time = start - + for ds in data_sources: - if ds.startswith('type_'): - if 'packet_types' not in result: - result['packet_types'] = {} - result['packet_types'][ds] = [] + if ds.startswith("type_"): + if "packet_types" not in result: + result["packet_types"] = {} + result["packet_types"][ds] = [] else: - result['metrics'][ds] = [] - + result["metrics"][ds] = [] + for point in data_points: timestamps.append(current_time) - + for i, value in enumerate(point): ds_name = data_sources[i] - if ds_name.startswith('type_'): - result['packet_types'][ds_name].append(value) + if ds_name.startswith("type_"): + result["packet_types"][ds_name].append(value) else: - result['metrics'][ds_name].append(value) - + result["metrics"][ds_name].append(value) + current_time += step - - result['timestamps'] = timestamps - + + result["timestamps"] = timestamps + return result - + except Exception as e: logger.error(f"Failed to get RRD data: {e}") return None @@ -192,65 +202,65 @@ class RRDToolHandler: try: end_time = int(time.time()) start_time = end_time - (hours * 3600) - + rrd_data = self.get_data(start_time, end_time) - if not rrd_data or 'packet_types' not in rrd_data: + if not rrd_data or "packet_types" not in rrd_data: logger.warning(f"No RRD data available") return None - + type_totals = {} packet_type_names = { - 'type_0': 'Request (REQ)', - 'type_1': 'Response (RESPONSE)', - 'type_2': 'Plain Text Message (TXT_MSG)', - 'type_3': 'Acknowledgment (ACK)', - 'type_4': 'Node Advertisement (ADVERT)', - 'type_5': 'Group Text Message (GRP_TXT)', - 'type_6': 'Group Datagram (GRP_DATA)', - 'type_7': 'Anonymous Request (ANON_REQ)', - 'type_8': 'Returned Path (PATH)', - 'type_9': 'Trace (TRACE)', - 'type_10': 'Multi-part Packet', - 'type_11': 'Control Packet Data', - 'type_12': 'Reserved Type 12', - 'type_13': 'Reserved Type 13', - 'type_14': 'Reserved Type 14', - 'type_15': 'Custom Packet (RAW_CUSTOM)', - 'type_other': 'Other Types (>15)' + "type_0": "Request (REQ)", + "type_1": "Response (RESPONSE)", + "type_2": "Plain Text Message (TXT_MSG)", + "type_3": "Acknowledgment (ACK)", + "type_4": "Node Advertisement (ADVERT)", + "type_5": "Group Text Message (GRP_TXT)", + "type_6": "Group Datagram (GRP_DATA)", + "type_7": "Anonymous Request (ANON_REQ)", + "type_8": "Returned Path (PATH)", + "type_9": "Trace (TRACE)", + "type_10": "Multi-part Packet", + "type_11": "Control Packet Data", + "type_12": "Reserved Type 12", + "type_13": "Reserved Type 13", + "type_14": "Reserved Type 14", + "type_15": "Custom Packet (RAW_CUSTOM)", + "type_other": "Other Types (>15)", } - + total_valid_points = 0 - for type_key, data_points in rrd_data['packet_types'].items(): + for type_key, data_points in rrd_data["packet_types"].items(): valid_points = [p for p in data_points if p is not None] total_valid_points += len(valid_points) - + if total_valid_points < 10: logger.warning(f"RRD data too sparse ({total_valid_points} valid points)") return None - - for type_key, data_points in rrd_data['packet_types'].items(): + + for type_key, data_points in rrd_data["packet_types"].items(): valid_points = [p for p in data_points if p is not None] - + if len(valid_points) >= 2: total = max(valid_points) - min(valid_points) elif len(valid_points) == 1: total = valid_points[0] else: total = 0 - + type_name = packet_type_names.get(type_key, type_key) type_totals[type_name] = max(0, total or 0) - + result = { "hours": hours, "packet_type_totals": type_totals, "total_packets": sum(type_totals.values()), "period": f"{hours} hours", - "data_source": "rrd" + "data_source": "rrd", } - + return result - + except Exception as e: logger.error(f"Failed to get packet type stats from RRD: {e}") - return None \ No newline at end of file + return None diff --git a/repeater/data_acquisition/sqlite_handler.py b/repeater/data_acquisition/sqlite_handler.py index 883017b..812dbf6 100644 --- a/repeater/data_acquisition/sqlite_handler.py +++ b/repeater/data_acquisition/sqlite_handler.py @@ -1,14 +1,15 @@ +import base64 import json import logging +import secrets import sqlite3 import time -import secrets -import base64 from pathlib import Path -from typing import Optional, List, Dict, Any +from typing import Any, Dict, List, Optional logger = logging.getLogger("SQLiteHandler") + class SQLiteHandler: def __init__(self, storage_dir: Path): self.storage_dir = storage_dir @@ -19,7 +20,8 @@ class SQLiteHandler: def _init_database(self): try: with sqlite3.connect(self.sqlite_path) as conn: - conn.execute(""" + conn.execute( + """ CREATE TABLE IF NOT EXISTS packets ( id INTEGER PRIMARY KEY AUTOINCREMENT, timestamp REAL NOT NULL, @@ -45,9 +47,11 @@ class SQLiteHandler: forwarded_path TEXT, raw_packet TEXT ) - """) - - conn.execute(""" + """ + ) + + conn.execute( + """ CREATE TABLE IF NOT EXISTS adverts ( id INTEGER PRIMARY KEY AUTOINCREMENT, timestamp REAL NOT NULL, @@ -66,17 +70,21 @@ class SQLiteHandler: is_new_neighbor BOOLEAN NOT NULL, zero_hop BOOLEAN NOT NULL DEFAULT FALSE ) - """) - - conn.execute(""" + """ + ) + + conn.execute( + """ CREATE TABLE IF NOT EXISTS noise_floor ( id INTEGER PRIMARY KEY AUTOINCREMENT, timestamp REAL NOT NULL, noise_floor_dbm REAL NOT NULL ) - """) - - conn.execute(""" + """ + ) + + conn.execute( + """ CREATE TABLE IF NOT EXISTS transport_keys ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, @@ -88,9 +96,11 @@ class SQLiteHandler: updated_at REAL NOT NULL, FOREIGN KEY (parent_id) REFERENCES transport_keys(id) ) - """) - - conn.execute(""" + """ + ) + + conn.execute( + """ CREATE TABLE IF NOT EXISTS api_tokens ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, @@ -98,20 +108,34 @@ class SQLiteHandler: created_at REAL NOT NULL, last_used REAL ) - """) - - conn.execute("CREATE INDEX IF NOT EXISTS idx_packets_timestamp ON packets(timestamp)") + """ + ) + + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_packets_timestamp ON packets(timestamp)" + ) conn.execute("CREATE INDEX IF NOT EXISTS idx_packets_type ON packets(type)") conn.execute("CREATE INDEX IF NOT EXISTS idx_packets_hash ON packets(packet_hash)") - conn.execute("CREATE INDEX IF NOT EXISTS idx_packets_transmitted ON packets(transmitted)") - conn.execute("CREATE INDEX IF NOT EXISTS idx_adverts_timestamp ON adverts(timestamp)") + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_packets_transmitted ON packets(transmitted)" + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_adverts_timestamp ON adverts(timestamp)" + ) conn.execute("CREATE INDEX IF NOT EXISTS idx_adverts_pubkey ON adverts(pubkey)") - conn.execute("CREATE INDEX IF NOT EXISTS idx_noise_timestamp ON noise_floor(timestamp)") - conn.execute("CREATE INDEX IF NOT EXISTS idx_transport_keys_name ON transport_keys(name)") - conn.execute("CREATE INDEX IF NOT EXISTS idx_transport_keys_parent ON transport_keys(parent_id)") - + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_noise_timestamp ON noise_floor(timestamp)" + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_transport_keys_name ON transport_keys(name)" + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_transport_keys_parent ON transport_keys(parent_id)" + ) + # Room server tables - conn.execute(""" + conn.execute( + """ CREATE TABLE IF NOT EXISTS room_messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, room_hash TEXT NOT NULL, @@ -122,9 +146,11 @@ class SQLiteHandler: txt_type INTEGER NOT NULL, created_at REAL NOT NULL ) - """) - - conn.execute(""" + """ + ) + + conn.execute( + """ CREATE TABLE IF NOT EXISTS room_client_sync ( id INTEGER PRIMARY KEY AUTOINCREMENT, room_hash TEXT NOT NULL, @@ -138,16 +164,25 @@ class SQLiteHandler: updated_at REAL NOT NULL, UNIQUE(room_hash, client_pubkey) ) - """) - - conn.execute("CREATE INDEX IF NOT EXISTS idx_room_messages_room ON room_messages(room_hash, post_timestamp)") - conn.execute("CREATE INDEX IF NOT EXISTS idx_room_messages_author ON room_messages(author_pubkey)") - conn.execute("CREATE INDEX IF NOT EXISTS idx_room_client_sync_room ON room_client_sync(room_hash, client_pubkey)") - conn.execute("CREATE INDEX IF NOT EXISTS idx_room_client_sync_pending ON room_client_sync(pending_ack_crc)") - + """ + ) + + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_room_messages_room ON room_messages(room_hash, post_timestamp)" + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_room_messages_author ON room_messages(author_pubkey)" + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_room_client_sync_room ON room_client_sync(room_hash, client_pubkey)" + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_room_client_sync_pending ON room_client_sync(pending_ack_crc)" + ) + conn.commit() logger.info(f"SQLite database initialized: {self.sqlite_path}") - + except Exception as e: logger.error(f"Failed to initialize SQLite: {e}") @@ -156,83 +191,92 @@ class SQLiteHandler: try: with sqlite3.connect(self.sqlite_path) as conn: # Create migrations table if it doesn't exist - conn.execute(""" + conn.execute( + """ CREATE TABLE IF NOT EXISTS migrations ( id INTEGER PRIMARY KEY AUTOINCREMENT, migration_name TEXT NOT NULL UNIQUE, applied_at REAL NOT NULL ) - """) - + """ + ) + # Migration 1: Add zero_hop column to adverts table migration_name = "add_zero_hop_to_adverts" existing = conn.execute( "SELECT migration_name FROM migrations WHERE migration_name = ?", - (migration_name,) + (migration_name,), ).fetchone() - + if not existing: # Check if zero_hop column already exists cursor = conn.execute("PRAGMA table_info(adverts)") columns = [column[1] for column in cursor.fetchall()] - + if "zero_hop" not in columns: - conn.execute("ALTER TABLE adverts ADD COLUMN zero_hop BOOLEAN NOT NULL DEFAULT FALSE") + conn.execute( + "ALTER TABLE adverts ADD COLUMN zero_hop BOOLEAN NOT NULL DEFAULT FALSE" + ) logger.info("Added zero_hop column to adverts table") - + # Mark migration as applied conn.execute( "INSERT INTO migrations (migration_name, applied_at) VALUES (?, ?)", - (migration_name, time.time()) + (migration_name, time.time()), ) logger.info(f"Migration '{migration_name}' applied successfully") - + # Migration 2: Add LBT metrics columns to packets table migration_name = "add_lbt_metrics_to_packets" existing = conn.execute( "SELECT migration_name FROM migrations WHERE migration_name = ?", - (migration_name,) + (migration_name,), ).fetchone() - + if not existing: # Check if columns already exist cursor = conn.execute("PRAGMA table_info(packets)") columns = [column[1] for column in cursor.fetchall()] - + if "lbt_attempts" not in columns: - conn.execute("ALTER TABLE packets ADD COLUMN lbt_attempts INTEGER DEFAULT 0") + conn.execute( + "ALTER TABLE packets ADD COLUMN lbt_attempts INTEGER DEFAULT 0" + ) logger.info("Added lbt_attempts column to packets table") - + if "lbt_backoff_delays_ms" not in columns: conn.execute("ALTER TABLE packets ADD COLUMN lbt_backoff_delays_ms TEXT") logger.info("Added lbt_backoff_delays_ms column to packets table") - + if "lbt_channel_busy" not in columns: - conn.execute("ALTER TABLE packets ADD COLUMN lbt_channel_busy BOOLEAN DEFAULT FALSE") + conn.execute( + "ALTER TABLE packets ADD COLUMN lbt_channel_busy BOOLEAN DEFAULT FALSE" + ) logger.info("Added lbt_channel_busy column to packets table") - + # Mark migration as applied conn.execute( "INSERT INTO migrations (migration_name, applied_at) VALUES (?, ?)", - (migration_name, time.time()) + (migration_name, time.time()), ) logger.info(f"Migration '{migration_name}' applied successfully") - + # Migration 3: Add api_tokens table migration_name = "add_api_tokens_table" existing = conn.execute( "SELECT migration_name FROM migrations WHERE migration_name = ?", - (migration_name,) + (migration_name,), ).fetchone() - + if not existing: # Check if api_tokens table already exists cursor = conn.execute( "SELECT name FROM sqlite_master WHERE type='table' AND name='api_tokens'" ) - + if not cursor.fetchone(): - conn.execute(""" + conn.execute( + """ CREATE TABLE api_tokens ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, @@ -240,13 +284,14 @@ class SQLiteHandler: created_at REAL NOT NULL, last_used REAL ) - """) + """ + ) logger.info("Created api_tokens table") - + # Mark migration as applied conn.execute( "INSERT INTO migrations (migration_name, applied_at) VALUES (?, ?)", - (migration_name, time.time()) + (migration_name, time.time()), ) logger.info(f"Migration '{migration_name}' applied successfully") @@ -254,7 +299,7 @@ class SQLiteHandler: migration_name = "add_companion_tables" existing = conn.execute( "SELECT migration_name FROM migrations WHERE migration_name = ?", - (migration_name,) + (migration_name,), ).fetchone() if not existing: @@ -262,7 +307,8 @@ class SQLiteHandler: "SELECT name FROM sqlite_master WHERE type='table' AND name='companion_contacts'" ) if not cursor.fetchone(): - conn.execute(""" + conn.execute( + """ CREATE TABLE companion_contacts ( id INTEGER PRIMARY KEY AUTOINCREMENT, companion_hash TEXT NOT NULL, @@ -279,8 +325,10 @@ class SQLiteHandler: sync_since INTEGER NOT NULL DEFAULT 0, updated_at REAL NOT NULL ) - """) - conn.execute(""" + """ + ) + conn.execute( + """ CREATE TABLE companion_channels ( id INTEGER PRIMARY KEY AUTOINCREMENT, companion_hash TEXT NOT NULL, @@ -289,8 +337,10 @@ class SQLiteHandler: secret BLOB NOT NULL, updated_at REAL NOT NULL ) - """) - conn.execute(""" + """ + ) + conn.execute( + """ CREATE TABLE companion_messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, companion_hash TEXT NOT NULL, @@ -304,19 +354,30 @@ class SQLiteHandler: packet_hash TEXT, created_at REAL NOT NULL ) - """) - conn.execute("CREATE INDEX IF NOT EXISTS idx_companion_contacts_hash ON companion_contacts(companion_hash)") - conn.execute("CREATE INDEX IF NOT EXISTS idx_companion_contacts_pubkey ON companion_contacts(companion_hash, pubkey)") - conn.execute("CREATE INDEX IF NOT EXISTS idx_companion_channels_hash ON companion_channels(companion_hash)") - conn.execute("CREATE INDEX IF NOT EXISTS idx_companion_messages_hash ON companion_messages(companion_hash)") + """ + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_companion_contacts_hash ON companion_contacts(companion_hash)" + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_companion_contacts_pubkey ON companion_contacts(companion_hash, pubkey)" + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_companion_channels_hash ON companion_channels(companion_hash)" + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_companion_messages_hash ON companion_messages(companion_hash)" + ) conn.execute( "CREATE INDEX IF NOT EXISTS idx_companion_messages_hash_packet ON companion_messages(companion_hash, packet_hash)" ) - logger.info("Created companion_contacts, companion_channels, companion_messages tables") + logger.info( + "Created companion_contacts, companion_channels, companion_messages tables" + ) conn.execute( "INSERT INTO migrations (migration_name, applied_at) VALUES (?, ?)", - (migration_name, time.time()) + (migration_name, time.time()), ) logger.info(f"Migration '{migration_name}' applied successfully") @@ -332,43 +393,38 @@ class SQLiteHandler: with sqlite3.connect(self.sqlite_path) as conn: cursor = conn.execute( "INSERT INTO api_tokens (name, token_hash, created_at) VALUES (?, ?, ?)", - (name, token_hash, time.time()) + (name, token_hash, time.time()), ) return cursor.lastrowid except Exception as e: logger.error(f"Failed to create API token: {e}") raise - + def verify_api_token(self, token_hash: str) -> Optional[Dict[str, Any]]: """Verify API token and update last_used timestamp""" try: with sqlite3.connect(self.sqlite_path) as conn: cursor = conn.execute( "SELECT id, name, created_at FROM api_tokens WHERE token_hash = ?", - (token_hash,) + (token_hash,), ) row = cursor.fetchone() - + if row: token_id, name, created_at = row - + # Update last_used timestamp conn.execute( - "UPDATE api_tokens SET last_used = ? WHERE id = ?", - (time.time(), token_id) + "UPDATE api_tokens SET last_used = ? WHERE id = ?", (time.time(), token_id) ) conn.commit() - - return { - 'id': token_id, - 'name': name, - 'created_at': created_at - } + + return {"id": token_id, "name": name, "created_at": created_at} return None except Exception as e: logger.error(f"Failed to verify API token: {e}") return None - + def revoke_api_token(self, token_id: int) -> bool: """Revoke (delete) an API token""" try: @@ -378,7 +434,7 @@ class SQLiteHandler: except Exception as e: logger.error(f"Failed to revoke API token: {e}") return False - + def list_api_tokens(self) -> List[Dict[str, Any]]: """List all API tokens (without sensitive data)""" try: @@ -386,15 +442,12 @@ class SQLiteHandler: cursor = conn.execute( "SELECT id, name, created_at, last_used FROM api_tokens ORDER BY created_at DESC" ) - + tokens = [] for row in cursor.fetchall(): - tokens.append({ - 'id': row[0], - 'name': row[1], - 'created_at': row[2], - 'last_used': row[3] - }) + tokens.append( + {"id": row[0], "name": row[1], "created_at": row[2], "last_used": row[3]} + ) return tokens except Exception as e: logger.error(f"Failed to list API tokens: {e}") @@ -414,42 +467,49 @@ class SQLiteHandler: except Exception: fwd_path_val = str(fwd_path) - conn.execute(""" + conn.execute( + """ INSERT INTO packets ( timestamp, type, route, length, rssi, snr, score, transmitted, is_duplicate, drop_reason, src_hash, dst_hash, path_hash, - header, transport_codes, payload, payload_length, + header, transport_codes, payload, payload_length, tx_delay_ms, packet_hash, original_path, forwarded_path, raw_packet, lbt_attempts, lbt_backoff_delays_ms, lbt_channel_busy ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - """, ( - record.get("timestamp", time.time()), - record.get("type", 0), - record.get("route", 0), - record.get("length", 0), - record.get("rssi"), - record.get("snr"), - record.get("score"), - int(bool(record.get("transmitted", False))), - int(bool(record.get("is_duplicate", False))), - record.get("drop_reason"), - record.get("src_hash"), - record.get("dst_hash"), - record.get("path_hash"), - record.get("header"), - record.get("transport_codes"), - record.get("payload"), - record.get("payload_length"), - record.get("tx_delay_ms"), - record.get("packet_hash"), - orig_path_val, - fwd_path_val, - record.get("raw_packet"), - record.get("lbt_attempts", 0), - json.dumps(record.get("lbt_backoff_delays_ms")) if record.get("lbt_backoff_delays_ms") else None, - int(bool(record.get("lbt_channel_busy", False))) - )) - + """, + ( + record.get("timestamp", time.time()), + record.get("type", 0), + record.get("route", 0), + record.get("length", 0), + record.get("rssi"), + record.get("snr"), + record.get("score"), + int(bool(record.get("transmitted", False))), + int(bool(record.get("is_duplicate", False))), + record.get("drop_reason"), + record.get("src_hash"), + record.get("dst_hash"), + record.get("path_hash"), + record.get("header"), + record.get("transport_codes"), + record.get("payload"), + record.get("payload_length"), + record.get("tx_delay_ms"), + record.get("packet_hash"), + orig_path_val, + fwd_path_val, + record.get("raw_packet"), + record.get("lbt_attempts", 0), + ( + json.dumps(record.get("lbt_backoff_delays_ms")) + if record.get("lbt_backoff_delays_ms") + else None + ), + int(bool(record.get("lbt_channel_busy", False))), + ), + ) + except Exception as e: logger.error(f"Failed to store packet in SQLite: {e}") @@ -459,16 +519,16 @@ class SQLiteHandler: conn.row_factory = sqlite3.Row existing = conn.execute( "SELECT pubkey, first_seen, advert_count, zero_hop, rssi, snr FROM adverts WHERE pubkey = ? ORDER BY last_seen DESC LIMIT 1", - (record.get("pubkey", ""),) + (record.get("pubkey", ""),), ).fetchone() - + current_time = record.get("timestamp", time.time()) - - if existing: + + if existing: # Use incoming zero_hop value (already calculated from route_type + path_len) incoming_zero_hop = record.get("zero_hop", False) existing_zero_hop = bool(existing["zero_hop"]) - + # Signal measurement logic: # - If incoming is zero-hop: ALWAYS store incoming rssi/snr (most recent zero-hop measurement) # - If incoming is multi-hop and existing was zero-hop: preserve existing (don't overwrite zero-hop with multi-hop) @@ -485,78 +545,85 @@ class SQLiteHandler: rssi_to_store = None snr_to_store = None zero_hop_to_store = False - - conn.execute(""" - UPDATE adverts + + conn.execute( + """ + UPDATE adverts SET timestamp = ?, node_name = ?, is_repeater = ?, route_type = ?, contact_type = ?, latitude = ?, longitude = ?, last_seen = ?, rssi = ?, snr = ?, advert_count = advert_count + 1, is_new_neighbor = 0, zero_hop = ? WHERE pubkey = ? - """, ( - current_time, - record.get("node_name"), - record.get("is_repeater", False), - record.get("route_type"), - record.get("contact_type"), - record.get("latitude"), - record.get("longitude"), - current_time, - rssi_to_store, - snr_to_store, - zero_hop_to_store, - record.get("pubkey", "") - )) + """, + ( + current_time, + record.get("node_name"), + record.get("is_repeater", False), + record.get("route_type"), + record.get("contact_type"), + record.get("latitude"), + record.get("longitude"), + current_time, + rssi_to_store, + snr_to_store, + zero_hop_to_store, + record.get("pubkey", ""), + ), + ) else: - conn.execute(""" + conn.execute( + """ INSERT INTO adverts ( - timestamp, pubkey, node_name, is_repeater, route_type, contact_type, - latitude, longitude, first_seen, last_seen, rssi, snr, advert_count, + timestamp, pubkey, node_name, is_repeater, route_type, contact_type, + latitude, longitude, first_seen, last_seen, rssi, snr, advert_count, is_new_neighbor, zero_hop ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - """, ( - current_time, - record.get("pubkey", ""), - record.get("node_name"), - record.get("is_repeater", False), - record.get("route_type"), - record.get("contact_type"), - record.get("latitude"), - record.get("longitude"), - current_time, - current_time, - record.get("rssi"), - record.get("snr"), - 1, - True, - record.get("zero_hop", False) - )) - + """, + ( + current_time, + record.get("pubkey", ""), + record.get("node_name"), + record.get("is_repeater", False), + record.get("route_type"), + record.get("contact_type"), + record.get("latitude"), + record.get("longitude"), + current_time, + current_time, + record.get("rssi"), + record.get("snr"), + 1, + True, + record.get("zero_hop", False), + ), + ) + except Exception as e: logger.error(f"Failed to store advert in SQLite: {e}") def store_noise_floor(self, record: dict): try: with sqlite3.connect(self.sqlite_path) as conn: - conn.execute(""" + conn.execute( + """ INSERT INTO noise_floor (timestamp, noise_floor_dbm) VALUES (?, ?) - """, ( - record.get("timestamp", time.time()), - record.get("noise_floor_dbm") - )) + """, + (record.get("timestamp", time.time()), record.get("noise_floor_dbm")), + ) except Exception as e: logger.error(f"Failed to store noise floor in SQLite: {e}") def get_packet_stats(self, hours: int = 24) -> dict: try: cutoff = time.time() - (hours * 3600) - + with sqlite3.connect(self.sqlite_path) as conn: conn.row_factory = sqlite3.Row - - stats = conn.execute(""" - SELECT + + stats = conn.execute( + """ + SELECT COUNT(*) as total_packets, SUM(transmitted) as transmitted_packets, SUM(CASE WHEN transmitted = 0 THEN 1 ELSE 0 END) as dropped_packets, @@ -565,26 +632,34 @@ class SQLiteHandler: AVG(score) as avg_score, AVG(payload_length) as avg_payload_length, AVG(tx_delay_ms) as avg_tx_delay - FROM packets + FROM packets WHERE timestamp > ? - """, (cutoff,)).fetchone() - - types = conn.execute(""" + """, + (cutoff,), + ).fetchone() + + types = conn.execute( + """ SELECT type, COUNT(*) as count - FROM packets + FROM packets WHERE timestamp > ? GROUP BY type ORDER BY count DESC - """, (cutoff,)).fetchall() - - drop_reasons = conn.execute(""" + """, + (cutoff,), + ).fetchall() + + drop_reasons = conn.execute( + """ SELECT drop_reason, COUNT(*) as count - FROM packets + FROM packets WHERE timestamp > ? AND transmitted = 0 AND drop_reason IS NOT NULL GROUP BY drop_reason ORDER BY count DESC - """, (cutoff,)).fetchall() - + """, + (cutoff,), + ).fetchall() + return { "total_packets": stats["total_packets"], "transmitted_packets": stats["transmitted_packets"], @@ -595,9 +670,12 @@ class SQLiteHandler: "avg_payload_length": round(stats["avg_payload_length"] or 0, 1), "avg_tx_delay": round(stats["avg_tx_delay"] or 0, 1), "packet_types": [{"type": row["type"], "count": row["count"]} for row in types], - "drop_reasons": [{"reason": row["drop_reason"], "count": row["count"]} for row in drop_reasons] + "drop_reasons": [ + {"reason": row["drop_reason"], "count": row["count"]} + for row in drop_reasons + ], } - + except Exception as e: logger.error(f"Failed to get packet stats: {e}") return {} @@ -606,78 +684,83 @@ class SQLiteHandler: try: with sqlite3.connect(self.sqlite_path) as conn: conn.row_factory = sqlite3.Row - - packets = conn.execute(""" - SELECT + + packets = conn.execute( + """ + SELECT timestamp, type, route, length, rssi, snr, score, transmitted, is_duplicate, drop_reason, src_hash, dst_hash, path_hash, - header, transport_codes, payload, payload_length, + header, transport_codes, payload, payload_length, tx_delay_ms, packet_hash, original_path, forwarded_path, raw_packet, lbt_attempts, lbt_backoff_delays_ms, lbt_channel_busy - FROM packets + FROM packets ORDER BY timestamp DESC LIMIT ? - """, (limit,)).fetchall() - + """, + (limit,), + ).fetchall() + return [dict(row) for row in packets] - + except Exception as e: logger.error(f"Failed to get recent packets: {e}") return [] - def get_filtered_packets(self, - packet_type: Optional[int] = None, - route: Optional[int] = None, - start_timestamp: Optional[float] = None, - end_timestamp: Optional[float] = None, - limit: int = 1000, - offset: int = 0) -> list: + def get_filtered_packets( + self, + packet_type: Optional[int] = None, + route: Optional[int] = None, + start_timestamp: Optional[float] = None, + end_timestamp: Optional[float] = None, + limit: int = 1000, + offset: int = 0, + ) -> list: try: with sqlite3.connect(self.sqlite_path) as conn: conn.row_factory = sqlite3.Row - + where_clauses = [] params = [] - + if packet_type is not None: where_clauses.append("type = ?") params.append(packet_type) - + if route is not None: where_clauses.append("route = ?") params.append(route) - + if start_timestamp is not None: where_clauses.append("timestamp >= ?") params.append(start_timestamp) - + if end_timestamp is not None: where_clauses.append("timestamp <= ?") params.append(end_timestamp) - + base_query = """ - SELECT + SELECT timestamp, type, route, length, rssi, snr, score, transmitted, is_duplicate, drop_reason, src_hash, dst_hash, path_hash, - header, transport_codes, payload, payload_length, + header, transport_codes, payload, payload_length, tx_delay_ms, packet_hash, original_path, forwarded_path, raw_packet, lbt_attempts, lbt_backoff_delays_ms, lbt_channel_busy FROM packets """ - + if where_clauses: query = f"{base_query} WHERE {' AND '.join(where_clauses)}" else: query = base_query - + query += " ORDER BY timestamp DESC LIMIT ? OFFSET ?" params.append(limit) params.append(offset) - + packets = conn.execute(query, params).fetchall() - + return [dict(row) for row in packets] - + except Exception as e: logger.error(f"Failed to get filtered packets: {e}") return [] @@ -686,20 +769,23 @@ class SQLiteHandler: try: with sqlite3.connect(self.sqlite_path) as conn: conn.row_factory = sqlite3.Row - - packet = conn.execute(""" - SELECT + + packet = conn.execute( + """ + SELECT timestamp, type, route, length, rssi, snr, score, transmitted, is_duplicate, drop_reason, src_hash, dst_hash, path_hash, - header, transport_codes, payload, payload_length, + header, transport_codes, payload, payload_length, tx_delay_ms, packet_hash, original_path, forwarded_path, raw_packet, lbt_attempts, lbt_backoff_delays_ms, lbt_channel_busy - FROM packets + FROM packets WHERE packet_hash = ? - """, (packet_hash,)).fetchone() - + """, + (packet_hash,), + ).fetchone() + return dict(packet) if packet else None - + except Exception as e: logger.error(f"Failed to get packet by hash: {e}") return None @@ -707,47 +793,54 @@ class SQLiteHandler: def get_packet_type_stats(self, hours: int = 24) -> dict: try: cutoff = time.time() - (hours * 3600) - + with sqlite3.connect(self.sqlite_path) as conn: conn.row_factory = sqlite3.Row - + type_counts = {} packet_type_names = { - 0: 'Request (REQ)', 1: 'Response (RESPONSE)', - 2: 'Plain Text Message (TXT_MSG)', 3: 'Acknowledgment (ACK)', - 4: 'Node Advertisement (ADVERT)', 5: 'Group Text Message (GRP_TXT)', - 6: 'Group Datagram (GRP_DATA)', 7: 'Anonymous Request (ANON_REQ)', - 8: 'Returned Path (PATH)', 9: 'Trace (TRACE)', - 10: 'Multi-part Packet', 11: 'Reserved Type 11', - 12: 'Reserved Type 12', 13: 'Reserved Type 13', - 14: 'Reserved Type 14', 15: 'Custom Packet (RAW_CUSTOM)' + 0: "Request (REQ)", + 1: "Response (RESPONSE)", + 2: "Plain Text Message (TXT_MSG)", + 3: "Acknowledgment (ACK)", + 4: "Node Advertisement (ADVERT)", + 5: "Group Text Message (GRP_TXT)", + 6: "Group Datagram (GRP_DATA)", + 7: "Anonymous Request (ANON_REQ)", + 8: "Returned Path (PATH)", + 9: "Trace (TRACE)", + 10: "Multi-part Packet", + 11: "Reserved Type 11", + 12: "Reserved Type 12", + 13: "Reserved Type 13", + 14: "Reserved Type 14", + 15: "Custom Packet (RAW_CUSTOM)", } - + for packet_type in range(16): count = conn.execute( - "SELECT COUNT(*) FROM packets WHERE type = ? AND timestamp > ?", - (packet_type, cutoff) + "SELECT COUNT(*) FROM packets WHERE type = ? AND timestamp > ?", + (packet_type, cutoff), ).fetchone()[0] - - type_name = packet_type_names.get(packet_type, f'Type {packet_type}') + + type_name = packet_type_names.get(packet_type, f"Type {packet_type}") if count > 0: type_counts[type_name] = count - + other_count = conn.execute( - "SELECT COUNT(*) FROM packets WHERE type > 15 AND timestamp > ?", - (cutoff,) + "SELECT COUNT(*) FROM packets WHERE type > 15 AND timestamp > ?", (cutoff,) ).fetchone()[0] if other_count > 0: - type_counts['Other Types (>15)'] = other_count - + type_counts["Other Types (>15)"] = other_count + return { "hours": hours, "packet_type_totals": type_counts, "total_packets": sum(type_counts.values()), "period": f"{hours} hours", - "data_source": "sqlite" + "data_source": "sqlite", } - + except Exception as e: logger.error(f"Failed to get packet type stats from SQLite: {e}") return {"error": str(e), "data_source": "error"} @@ -756,44 +849,38 @@ class SQLiteHandler: try: cutoff = time.time() - (hours * 3600) - + with sqlite3.connect(self.sqlite_path) as conn: conn.row_factory = sqlite3.Row - + route_counts = {} - route_names = { - 0: 'Transport Flood', - 1: 'Flood', - 2: 'Direct', - 3: 'Transport Direct' - } + route_names = {0: "Transport Flood", 1: "Flood", 2: "Direct", 3: "Transport Direct"} for route_type in range(4): count = conn.execute( - "SELECT COUNT(*) FROM packets WHERE route = ? AND timestamp > ?", - (route_type, cutoff) + "SELECT COUNT(*) FROM packets WHERE route = ? AND timestamp > ?", + (route_type, cutoff), ).fetchone()[0] - - route_name = route_names.get(route_type, f'Route {route_type}') + + route_name = route_names.get(route_type, f"Route {route_type}") if count > 0: route_counts[route_name] = count - + # Count any other route types > 3 other_count = conn.execute( - "SELECT COUNT(*) FROM packets WHERE route > 3 AND timestamp > ?", - (cutoff,) + "SELECT COUNT(*) FROM packets WHERE route > 3 AND timestamp > ?", (cutoff,) ).fetchone()[0] if other_count > 0: - route_counts['Other Routes (>3)'] = other_count - + route_counts["Other Routes (>3)"] = other_count + return { "hours": hours, "route_totals": route_counts, "total_packets": sum(route_counts.values()), "period": f"{hours} hours", - "data_source": "sqlite" + "data_source": "sqlite", } - + except Exception as e: logger.error(f"Failed to get route stats from SQLite: {e}") return {"error": str(e), "data_source": "error"} @@ -802,19 +889,21 @@ class SQLiteHandler: try: with sqlite3.connect(self.sqlite_path) as conn: conn.row_factory = sqlite3.Row - - neighbors = conn.execute(""" + + neighbors = conn.execute( + """ SELECT pubkey, node_name, is_repeater, route_type, contact_type, latitude, longitude, first_seen, last_seen, rssi, snr, advert_count, zero_hop FROM adverts a1 WHERE last_seen = ( - SELECT MAX(last_seen) - FROM adverts a2 + SELECT MAX(last_seen) + FROM adverts a2 WHERE a2.pubkey = a1.pubkey ) ORDER BY last_seen DESC - """).fetchall() - + """ + ).fetchall() + result = {} for row in neighbors: result[row["pubkey"]] = { @@ -831,9 +920,9 @@ class SQLiteHandler: "advert_count": row["advert_count"], "zero_hop": bool(row["zero_hop"]), } - + return result - + except Exception as e: logger.error(f"Failed to get neighbors: {e}") return {} @@ -841,29 +930,31 @@ class SQLiteHandler: def get_noise_floor_history(self, hours: int = 24, limit: int = None) -> list: try: cutoff = time.time() - (hours * 3600) - + with sqlite3.connect(self.sqlite_path) as conn: conn.row_factory = sqlite3.Row - + # Build query with optional limit query = """ SELECT timestamp, noise_floor_dbm - FROM noise_floor + FROM noise_floor WHERE timestamp > ? ORDER BY timestamp DESC """ - + if limit: query += f" LIMIT {int(limit)}" - + measurements = conn.execute(query, (cutoff,)).fetchall() - + # Reverse to get chronological order (oldest to newest) - result = [{"timestamp": row["timestamp"], "noise_floor_dbm": row["noise_floor_dbm"]} - for row in reversed(measurements)] - + result = [ + {"timestamp": row["timestamp"], "noise_floor_dbm": row["noise_floor_dbm"]} + for row in reversed(measurements) + ] + return result - + except Exception as e: logger.error(f"Failed to get noise floor history: {e}") return [] @@ -871,28 +962,31 @@ class SQLiteHandler: def get_noise_floor_stats(self, hours: int = 24) -> dict: try: cutoff = time.time() - (hours * 3600) - + with sqlite3.connect(self.sqlite_path) as conn: conn.row_factory = sqlite3.Row - - stats = conn.execute(""" - SELECT + + stats = conn.execute( + """ + SELECT COUNT(*) as measurement_count, AVG(noise_floor_dbm) as avg_noise_floor, MIN(noise_floor_dbm) as min_noise_floor, MAX(noise_floor_dbm) as max_noise_floor - FROM noise_floor + FROM noise_floor WHERE timestamp > ? - """, (cutoff,)).fetchone() - + """, + (cutoff,), + ).fetchone() + return { "measurement_count": stats["measurement_count"], "avg_noise_floor": round(stats["avg_noise_floor"] or 0, 1), "min_noise_floor": round(stats["min_noise_floor"] or 0, 1), "max_noise_floor": round(stats["max_noise_floor"] or 0, 1), - "hours": hours + "hours": hours, } - + except Exception as e: logger.error(f"Failed to get noise floor stats: {e}") return {} @@ -900,22 +994,24 @@ class SQLiteHandler: def cleanup_old_data(self, days: int = 7): try: cutoff = time.time() - (days * 24 * 3600) - + with sqlite3.connect(self.sqlite_path) as conn: result = conn.execute("DELETE FROM packets WHERE timestamp < ?", (cutoff,)) packets_deleted = result.rowcount - + result = conn.execute("DELETE FROM adverts WHERE timestamp < ?", (cutoff,)) adverts_deleted = result.rowcount - + result = conn.execute("DELETE FROM noise_floor WHERE timestamp < ?", (cutoff,)) noise_deleted = result.rowcount - + conn.commit() - + if packets_deleted > 0 or adverts_deleted > 0 or noise_deleted > 0: - logger.info(f"Cleaned up {packets_deleted} old packets, {adverts_deleted} old adverts, {noise_deleted} old noise measurements") - + logger.info( + f"Cleaned up {packets_deleted} old packets, {adverts_deleted} old adverts, {noise_deleted} old noise measurements" + ) + except Exception as e: logger.error(f"Failed to cleanup old data: {e}") @@ -924,60 +1020,65 @@ class SQLiteHandler: with sqlite3.connect(self.sqlite_path) as conn: type_counts = {} for i in range(16): - count = conn.execute("SELECT COUNT(*) FROM packets WHERE type = ?", (i,)).fetchone()[0] + count = conn.execute( + "SELECT COUNT(*) FROM packets WHERE type = ?", (i,) + ).fetchone()[0] type_counts[f"type_{i}"] = count - - other_count = conn.execute("SELECT COUNT(*) FROM packets WHERE type > 15").fetchone()[0] + + other_count = conn.execute( + "SELECT COUNT(*) FROM packets WHERE type > 15" + ).fetchone()[0] type_counts["type_other"] = other_count - + rx_total = conn.execute("SELECT COUNT(*) FROM packets").fetchone()[0] - tx_total = conn.execute("SELECT COUNT(*) FROM packets WHERE transmitted = 1").fetchone()[0] - drop_total = conn.execute("SELECT COUNT(*) FROM packets WHERE transmitted = 0").fetchone()[0] - + tx_total = conn.execute( + "SELECT COUNT(*) FROM packets WHERE transmitted = 1" + ).fetchone()[0] + drop_total = conn.execute( + "SELECT COUNT(*) FROM packets WHERE transmitted = 0" + ).fetchone()[0] + return { "rx_total": rx_total, "tx_total": tx_total, "drop_total": drop_total, - "type_counts": type_counts + "type_counts": type_counts, } - + except Exception as e: logger.error(f"Failed to get cumulative counts: {e}") - return { - "rx_total": 0, - "tx_total": 0, - "drop_total": 0, - "type_counts": {} - } + return {"rx_total": 0, "tx_total": 0, "drop_total": 0, "type_counts": {}} + + def get_adverts_by_contact_type( + self, contact_type: str, limit: Optional[int] = None, hours: Optional[int] = None + ) -> List[dict]: - def get_adverts_by_contact_type(self, contact_type: str, limit: Optional[int] = None, hours: Optional[int] = None) -> List[dict]: - try: with sqlite3.connect(self.sqlite_path) as conn: conn.row_factory = sqlite3.Row - + query = """ - SELECT id, timestamp, pubkey, node_name, is_repeater, route_type, - contact_type, latitude, longitude, first_seen, last_seen, + SELECT id, timestamp, pubkey, node_name, is_repeater, route_type, + contact_type, latitude, longitude, first_seen, last_seen, rssi, snr, advert_count, is_new_neighbor, zero_hop - FROM adverts + FROM adverts WHERE contact_type = ? """ params = [contact_type] - + if hours is not None: cutoff = time.time() - (hours * 3600) query += " AND timestamp > ?" params.append(cutoff) - + query += " ORDER BY timestamp DESC" - + if limit is not None: query += " LIMIT ?" params.append(limit) - + rows = conn.execute(query, params).fetchall() - + adverts = [] for row in rows: advert = { @@ -996,12 +1097,12 @@ class SQLiteHandler: "snr": row["snr"], "advert_count": row["advert_count"], "is_new_neighbor": bool(row["is_new_neighbor"]), - "zero_hop": bool(row["zero_hop"]) + "zero_hop": bool(row["zero_hop"]), } adverts.append(advert) - + return adverts - + except Exception as e: logger.error(f"Failed to get adverts by contact_type '{contact_type}': {e}") return [] @@ -1009,50 +1110,70 @@ class SQLiteHandler: def generate_transport_key(self, name: str, key_length_bytes: int = 32) -> str: """ Generate a transport key using the proper MeshCore key derivation. - + Args: name: The key name to derive the key from key_length_bytes: Length of the key in bytes (default: 32 bytes = 256 bits) - + Returns: A base64-encoded transport key derived from the name """ try: from pymc_core.protocol.transport_keys import get_auto_key_for - + # Use the proper MeshCore key derivation function key_bytes = get_auto_key_for(name) - + # Encode to base64 for safe storage and transmission - key = base64.b64encode(key_bytes).decode('utf-8') - - logger.debug(f"Generated transport key for '{name}' with {len(key_bytes)} bytes ({len(key)} base64 chars)") + key = base64.b64encode(key_bytes).decode("utf-8") + + logger.debug( + f"Generated transport key for '{name}' with {len(key_bytes)} bytes ({len(key)} base64 chars)" + ) return key - + except Exception as e: logger.error(f"Failed to generate transport key using get_auto_key_for: {e}") # Fallback to secure random if MeshCore function fails try: random_bytes = secrets.token_bytes(key_length_bytes) - key = base64.b64encode(random_bytes).decode('utf-8') + key = base64.b64encode(random_bytes).decode("utf-8") logger.warning(f"Using fallback random key generation for '{name}'") return key except Exception as fallback_e: logger.error(f"Fallback key generation also failed: {fallback_e}") raise - def create_transport_key(self, name: str, flood_policy: str, transport_key: Optional[str] = None, parent_id: Optional[int] = None, last_used: Optional[float] = None) -> Optional[int]: + def create_transport_key( + self, + name: str, + flood_policy: str, + transport_key: Optional[str] = None, + parent_id: Optional[int] = None, + last_used: Optional[float] = None, + ) -> Optional[int]: try: # Generate key if not provided if transport_key is None: transport_key = self.generate_transport_key(name) - + current_time = time.time() with sqlite3.connect(self.sqlite_path) as conn: - cursor = conn.execute(""" + cursor = conn.execute( + """ INSERT INTO transport_keys (name, flood_policy, transport_key, parent_id, last_used, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?) - """, (name, flood_policy, transport_key, parent_id, last_used, current_time, current_time)) + """, + ( + name, + flood_policy, + transport_key, + parent_id, + last_used, + current_time, + current_time, + ), + ) return cursor.lastrowid except Exception as e: logger.error(f"Failed to create transport key: {e}") @@ -1062,22 +1183,27 @@ class SQLiteHandler: try: with sqlite3.connect(self.sqlite_path) as conn: conn.row_factory = sqlite3.Row - rows = conn.execute(""" + rows = conn.execute( + """ SELECT id, name, flood_policy, transport_key, parent_id, last_used, created_at, updated_at FROM transport_keys ORDER BY created_at ASC - """).fetchall() - - return [{ - "id": row["id"], - "name": row["name"], - "flood_policy": row["flood_policy"], - "transport_key": row["transport_key"], - "parent_id": row["parent_id"], - "last_used": row["last_used"], - "created_at": row["created_at"], - "updated_at": row["updated_at"] - } for row in rows] + """ + ).fetchall() + + return [ + { + "id": row["id"], + "name": row["name"], + "flood_policy": row["flood_policy"], + "transport_key": row["transport_key"], + "parent_id": row["parent_id"], + "last_used": row["last_used"], + "created_at": row["created_at"], + "updated_at": row["updated_at"], + } + for row in rows + ] except Exception as e: logger.error(f"Failed to get transport keys: {e}") return [] @@ -1086,11 +1212,14 @@ class SQLiteHandler: try: with sqlite3.connect(self.sqlite_path) as conn: conn.row_factory = sqlite3.Row - row = conn.execute(""" + row = conn.execute( + """ SELECT id, name, flood_policy, transport_key, parent_id, last_used, created_at, updated_at FROM transport_keys WHERE id = ? - """, (key_id,)).fetchone() - + """, + (key_id,), + ).fetchone() + if row: return { "id": row["id"], @@ -1100,18 +1229,26 @@ class SQLiteHandler: "parent_id": row["parent_id"], "last_used": row["last_used"], "created_at": row["created_at"], - "updated_at": row["updated_at"] + "updated_at": row["updated_at"], } return None except Exception as e: logger.error(f"Failed to get transport key by id: {e}") return None - def update_transport_key(self, key_id: int, name: Optional[str] = None, flood_policy: Optional[str] = None, transport_key: Optional[str] = None, parent_id: Optional[int] = None, last_used: Optional[float] = None) -> bool: + def update_transport_key( + self, + key_id: int, + name: Optional[str] = None, + flood_policy: Optional[str] = None, + transport_key: Optional[str] = None, + parent_id: Optional[int] = None, + last_used: Optional[float] = None, + ) -> bool: try: updates = [] params = [] - + if name is not None: updates.append("name = ?") params.append(name) @@ -1127,19 +1264,22 @@ class SQLiteHandler: if last_used is not None: updates.append("last_used = ?") params.append(last_used) - + if not updates: return False - + updates.append("updated_at = ?") params.append(time.time()) params.append(key_id) - + with sqlite3.connect(self.sqlite_path) as conn: - cursor = conn.execute(f""" + cursor = conn.execute( + f""" UPDATE transport_keys SET {', '.join(updates)} WHERE id = ? - """, params) + """, + params, + ) return cursor.rowcount > 0 except Exception as e: logger.error(f"Failed to update transport key: {e}") @@ -1167,40 +1307,58 @@ class SQLiteHandler: # Room Server Methods # ------------------------------------------------------------------ - def insert_room_message(self, room_hash: str, author_pubkey: str, message_text: str, - post_timestamp: float, sender_timestamp: float = None, - txt_type: int = 0) -> Optional[int]: + def insert_room_message( + self, + room_hash: str, + author_pubkey: str, + message_text: str, + post_timestamp: float, + sender_timestamp: float = None, + txt_type: int = 0, + ) -> Optional[int]: """Insert a new room message and return its ID.""" try: with sqlite3.connect(self.sqlite_path) as conn: - cursor = conn.execute(""" + cursor = conn.execute( + """ INSERT INTO room_messages ( room_hash, author_pubkey, post_timestamp, sender_timestamp, message_text, txt_type, created_at ) VALUES (?, ?, ?, ?, ?, ?, ?) - """, ( - room_hash, author_pubkey, post_timestamp, sender_timestamp, - message_text, txt_type, time.time() - )) + """, + ( + room_hash, + author_pubkey, + post_timestamp, + sender_timestamp, + message_text, + txt_type, + time.time(), + ), + ) return cursor.lastrowid except Exception as e: logger.error(f"Failed to insert room message: {e}") return None - def get_unsynced_messages(self, room_hash: str, client_pubkey: str, - sync_since: float, limit: int = 100) -> List[Dict]: + def get_unsynced_messages( + self, room_hash: str, client_pubkey: str, sync_since: float, limit: int = 100 + ) -> List[Dict]: """Get messages for a room that client hasn't synced yet.""" try: with sqlite3.connect(self.sqlite_path) as conn: conn.row_factory = sqlite3.Row - cursor = conn.execute(""" + cursor = conn.execute( + """ SELECT * FROM room_messages - WHERE room_hash = ? + WHERE room_hash = ? AND post_timestamp > ? AND author_pubkey != ? ORDER BY post_timestamp ASC LIMIT ? - """, (room_hash, sync_since, client_pubkey, limit)) + """, + (room_hash, sync_since, client_pubkey, limit), + ) return [dict(row) for row in cursor.fetchall()] except Exception as e: logger.error(f"Failed to get unsynced messages: {e}") @@ -1210,12 +1368,15 @@ class SQLiteHandler: """Count unsynced messages for a client.""" try: with sqlite3.connect(self.sqlite_path) as conn: - cursor = conn.execute(""" + cursor = conn.execute( + """ SELECT COUNT(*) FROM room_messages - WHERE room_hash = ? + WHERE room_hash = ? AND post_timestamp > ? AND author_pubkey != ? - """, (room_hash, sync_since, client_pubkey)) + """, + (room_hash, sync_since, client_pubkey), + ) return cursor.fetchone()[0] except Exception as e: logger.error(f"Failed to count unsynced messages: {e}") @@ -1226,14 +1387,17 @@ class SQLiteHandler: try: with sqlite3.connect(self.sqlite_path) as conn: # Check if exists - cursor = conn.execute(""" - SELECT id FROM room_client_sync + cursor = conn.execute( + """ + SELECT id FROM room_client_sync WHERE room_hash = ? AND client_pubkey = ? - """, (room_hash, client_pubkey)) + """, + (room_hash, client_pubkey), + ) existing = cursor.fetchone() - - kwargs['updated_at'] = time.time() - + + kwargs["updated_at"] = time.time() + if existing: # Update set_clauses = [] @@ -1242,30 +1406,36 @@ class SQLiteHandler: set_clauses.append(f"{key} = ?") values.append(value) values.extend([room_hash, client_pubkey]) - - conn.execute(f""" - UPDATE room_client_sync + + conn.execute( + f""" + UPDATE room_client_sync SET {', '.join(set_clauses)} WHERE room_hash = ? AND client_pubkey = ? - """, values) + """, + values, + ) else: # Insert with defaults - kwargs.setdefault('sync_since', 0) - kwargs.setdefault('pending_ack_crc', 0) - kwargs.setdefault('push_post_timestamp', 0) - kwargs.setdefault('ack_timeout_time', 0) - kwargs.setdefault('push_failures', 0) - kwargs.setdefault('last_activity', time.time()) - - columns = ['room_hash', 'client_pubkey'] + list(kwargs.keys()) - placeholders = ['?'] * len(columns) + kwargs.setdefault("sync_since", 0) + kwargs.setdefault("pending_ack_crc", 0) + kwargs.setdefault("push_post_timestamp", 0) + kwargs.setdefault("ack_timeout_time", 0) + kwargs.setdefault("push_failures", 0) + kwargs.setdefault("last_activity", time.time()) + + columns = ["room_hash", "client_pubkey"] + list(kwargs.keys()) + placeholders = ["?"] * len(columns) values = [room_hash, client_pubkey] + list(kwargs.values()) - - conn.execute(f""" + + conn.execute( + f""" INSERT INTO room_client_sync ({', '.join(columns)}) VALUES ({', '.join(placeholders)}) - """, values) - + """, + values, + ) + conn.commit() return True except Exception as e: @@ -1277,10 +1447,13 @@ class SQLiteHandler: try: with sqlite3.connect(self.sqlite_path) as conn: conn.row_factory = sqlite3.Row - cursor = conn.execute(""" + cursor = conn.execute( + """ SELECT * FROM room_client_sync WHERE room_hash = ? AND client_pubkey = ? - """, (room_hash, client_pubkey)) + """, + (room_hash, client_pubkey), + ) row = cursor.fetchone() return dict(row) if row else None except Exception as e: @@ -1292,11 +1465,14 @@ class SQLiteHandler: try: with sqlite3.connect(self.sqlite_path) as conn: conn.row_factory = sqlite3.Row - cursor = conn.execute(""" + cursor = conn.execute( + """ SELECT * FROM room_client_sync WHERE room_hash = ? ORDER BY last_activity DESC - """, (room_hash,)) + """, + (room_hash,), + ) return [dict(row) for row in cursor.fetchall()] except Exception as e: logger.error(f"Failed to get room clients: {e}") @@ -1306,9 +1482,12 @@ class SQLiteHandler: """Get total number of messages in a room.""" try: with sqlite3.connect(self.sqlite_path) as conn: - cursor = conn.execute(""" + cursor = conn.execute( + """ SELECT COUNT(*) FROM room_messages WHERE room_hash = ? - """, (room_hash,)) + """, + (room_hash,), + ) return cursor.fetchone()[0] except Exception as e: logger.error(f"Failed to get room message count: {e}") @@ -1319,28 +1498,36 @@ class SQLiteHandler: try: with sqlite3.connect(self.sqlite_path) as conn: conn.row_factory = sqlite3.Row - cursor = conn.execute(""" + cursor = conn.execute( + """ SELECT * FROM room_messages WHERE room_hash = ? ORDER BY post_timestamp DESC LIMIT ? OFFSET ? - """, (room_hash, limit, offset)) + """, + (room_hash, limit, offset), + ) return [dict(row) for row in cursor.fetchall()] except Exception as e: logger.error(f"Failed to get room messages: {e}") return [] - def get_messages_since(self, room_hash: str, since_timestamp: float, limit: int = 50) -> List[Dict]: + def get_messages_since( + self, room_hash: str, since_timestamp: float, limit: int = 50 + ) -> List[Dict]: """Get messages posted after a specific timestamp.""" try: with sqlite3.connect(self.sqlite_path) as conn: conn.row_factory = sqlite3.Row - cursor = conn.execute(""" + cursor = conn.execute( + """ SELECT * FROM room_messages WHERE room_hash = ? AND post_timestamp > ? ORDER BY post_timestamp DESC LIMIT ? - """, (room_hash, since_timestamp, limit)) + """, + (room_hash, since_timestamp, limit), + ) return [dict(row) for row in cursor.fetchall()] except Exception as e: logger.error(f"Failed to get messages since timestamp: {e}") @@ -1350,12 +1537,15 @@ class SQLiteHandler: """Get count of unsynced messages for a client.""" try: with sqlite3.connect(self.sqlite_path) as conn: - cursor = conn.execute(""" + cursor = conn.execute( + """ SELECT COUNT(*) FROM room_messages - WHERE room_hash = ? + WHERE room_hash = ? AND author_pubkey != ? AND post_timestamp > ? - """, (room_hash, client_pubkey, sync_since)) + """, + (room_hash, client_pubkey, sync_since), + ) return cursor.fetchone()[0] except Exception as e: logger.error(f"Failed to get unsynced count: {e}") @@ -1365,10 +1555,13 @@ class SQLiteHandler: """Delete a specific message by ID.""" try: with sqlite3.connect(self.sqlite_path) as conn: - cursor = conn.execute(""" + cursor = conn.execute( + """ DELETE FROM room_messages WHERE room_hash = ? AND id = ? - """, (room_hash, message_id)) + """, + (room_hash, message_id), + ) return cursor.rowcount > 0 except Exception as e: logger.error(f"Failed to delete message: {e}") @@ -1378,9 +1571,12 @@ class SQLiteHandler: """Clear all messages from a room.""" try: with sqlite3.connect(self.sqlite_path) as conn: - cursor = conn.execute(""" + cursor = conn.execute( + """ DELETE FROM room_messages WHERE room_hash = ? - """, (room_hash,)) + """, + (room_hash,), + ) return cursor.rowcount except Exception as e: logger.error(f"Failed to clear room messages: {e}") @@ -1391,16 +1587,20 @@ class SQLiteHandler: try: with sqlite3.connect(self.sqlite_path) as conn: # First check if cleanup is needed - cursor = conn.execute(""" + cursor = conn.execute( + """ SELECT COUNT(*) FROM room_messages WHERE room_hash = ? - """, (room_hash,)) + """, + (room_hash,), + ) total_count = cursor.fetchone()[0] - + if total_count <= keep_count: return 0 # No cleanup needed - + # Delete old messages - cursor = conn.execute(""" + cursor = conn.execute( + """ DELETE FROM room_messages WHERE room_hash = ? AND id NOT IN ( @@ -1409,7 +1609,9 @@ class SQLiteHandler: ORDER BY post_timestamp DESC LIMIT ? ) - """, (room_hash, room_hash, keep_count)) + """, + (room_hash, room_hash, keep_count), + ) return cursor.rowcount except Exception as e: logger.error(f"Failed to cleanup old messages: {e}") @@ -1421,11 +1623,14 @@ class SQLiteHandler: try: with sqlite3.connect(self.sqlite_path) as conn: conn.row_factory = sqlite3.Row - cursor = conn.execute(""" + cursor = conn.execute( + """ SELECT pubkey, name, adv_type, flags, out_path_len, out_path, last_advert_timestamp, lastmod, gps_lat, gps_lon, sync_since FROM companion_contacts WHERE companion_hash = ? - """, (companion_hash,)) + """, + (companion_hash,), + ) return [dict(row) for row in cursor.fetchall()] except Exception as e: logger.error(f"Failed to load companion contacts: {e}") @@ -1435,29 +1640,34 @@ class SQLiteHandler: """Replace all contacts for a companion in storage.""" try: with sqlite3.connect(self.sqlite_path) as conn: - conn.execute("DELETE FROM companion_contacts WHERE companion_hash = ?", (companion_hash,)) + conn.execute( + "DELETE FROM companion_contacts WHERE companion_hash = ?", (companion_hash,) + ) now = time.time() for c in contacts: - conn.execute(""" + conn.execute( + """ INSERT INTO companion_contacts (companion_hash, pubkey, name, adv_type, flags, out_path_len, out_path, last_advert_timestamp, lastmod, gps_lat, gps_lon, sync_since, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - """, ( - companion_hash, - c.get("pubkey", b""), - c.get("name", ""), - c.get("adv_type", 0), - c.get("flags", 0), - c.get("out_path_len", -1), - c.get("out_path", b""), - c.get("last_advert_timestamp", 0), - c.get("lastmod", 0), - c.get("gps_lat", 0.0), - c.get("gps_lon", 0.0), - c.get("sync_since", 0), - now, - )) + """, + ( + companion_hash, + c.get("pubkey", b""), + c.get("name", ""), + c.get("adv_type", 0), + c.get("flags", 0), + c.get("out_path_len", -1), + c.get("out_path", b""), + c.get("last_advert_timestamp", 0), + c.get("lastmod", 0), + c.get("gps_lat", 0.0), + c.get("gps_lon", 0.0), + c.get("sync_since", 0), + now, + ), + ) conn.commit() return True except Exception as e: @@ -1469,10 +1679,13 @@ class SQLiteHandler: try: with sqlite3.connect(self.sqlite_path) as conn: conn.row_factory = sqlite3.Row - cursor = conn.execute(""" + cursor = conn.execute( + """ SELECT channel_idx, name, secret FROM companion_channels WHERE companion_hash = ? ORDER BY channel_idx - """, (companion_hash,)) + """, + (companion_hash,), + ) return [dict(row) for row in cursor.fetchall()] except Exception as e: logger.error(f"Failed to load companion channels: {e}") @@ -1482,20 +1695,25 @@ class SQLiteHandler: """Replace all channels for a companion in storage.""" try: with sqlite3.connect(self.sqlite_path) as conn: - conn.execute("DELETE FROM companion_channels WHERE companion_hash = ?", (companion_hash,)) + conn.execute( + "DELETE FROM companion_channels WHERE companion_hash = ?", (companion_hash,) + ) now = time.time() for ch in channels: - conn.execute(""" + conn.execute( + """ INSERT INTO companion_channels (companion_hash, channel_idx, name, secret, updated_at) VALUES (?, ?, ?, ?, ?) - """, ( - companion_hash, - ch.get("channel_idx", 0), - ch.get("name", ""), - ch.get("secret", b""), - now, - )) + """, + ( + companion_hash, + ch.get("channel_idx", 0), + ch.get("name", ""), + ch.get("secret", b""), + now, + ), + ) conn.commit() return True except Exception as e: @@ -1507,11 +1725,14 @@ class SQLiteHandler: try: with sqlite3.connect(self.sqlite_path) as conn: conn.row_factory = sqlite3.Row - cursor = conn.execute(""" + cursor = conn.execute( + """ SELECT sender_key, txt_type, timestamp, text, is_channel, channel_idx, path_len FROM companion_messages WHERE companion_hash = ? ORDER BY created_at ASC LIMIT ? - """, (companion_hash, limit)) + """, + (companion_hash, limit), + ) return [dict(row) for row in cursor.fetchall()] except Exception as e: logger.error(f"Failed to load companion messages: {e}") @@ -1526,29 +1747,35 @@ class SQLiteHandler: sender_key = msg.get("sender_key", b"") with sqlite3.connect(self.sqlite_path) as conn: if packet_hash: - cursor = conn.execute(""" + cursor = conn.execute( + """ SELECT id FROM companion_messages WHERE companion_hash = ? AND packet_hash = ? LIMIT 1 - """, (companion_hash, packet_hash)) + """, + (companion_hash, packet_hash), + ) if cursor.fetchone(): return False - conn.execute(""" + conn.execute( + """ INSERT INTO companion_messages (companion_hash, sender_key, txt_type, timestamp, text, is_channel, channel_idx, path_len, packet_hash, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - """, ( - companion_hash, - sender_key, - msg.get("txt_type", 0), - msg.get("timestamp", 0), - msg.get("text", ""), - int(msg.get("is_channel", False)), - msg.get("channel_idx", 0), - msg.get("path_len", 0), - packet_hash, - time.time(), - )) + """, + ( + companion_hash, + sender_key, + msg.get("txt_type", 0), + msg.get("timestamp", 0), + msg.get("text", ""), + int(msg.get("is_channel", False)), + msg.get("channel_idx", 0), + msg.get("path_len", 0), + packet_hash, + time.time(), + ), + ) conn.commit() return True except Exception as e: @@ -1560,11 +1787,14 @@ class SQLiteHandler: try: with sqlite3.connect(self.sqlite_path) as conn: conn.row_factory = sqlite3.Row - cursor = conn.execute(""" + cursor = conn.execute( + """ SELECT id, sender_key, txt_type, timestamp, text, is_channel, channel_idx, path_len FROM companion_messages WHERE companion_hash = ? ORDER BY created_at ASC LIMIT 1 - """, (companion_hash,)) + """, + (companion_hash,), + ) row = cursor.fetchone() if not row: return None diff --git a/repeater/data_acquisition/storage_collector.py b/repeater/data_acquisition/storage_collector.py index f139151..1226a3b 100644 --- a/repeater/data_acquisition/storage_collector.py +++ b/repeater/data_acquisition/storage_collector.py @@ -3,15 +3,14 @@ import logging import time from datetime import datetime from pathlib import Path -from typing import Optional, Dict, Any +from typing import Any, Dict, Optional -from .sqlite_handler import SQLiteHandler -from .rrdtool_handler import RRDToolHandler -from .mqtt_handler import MQTTHandler from .letsmesh_handler import MeshCoreToMqttJwtPusher +from .mqtt_handler import MQTTHandler +from .rrdtool_handler import RRDToolHandler +from .sqlite_handler import SQLiteHandler from .storage_utils import PacketRecord - logger = logging.getLogger("StorageCollector") @@ -62,16 +61,18 @@ class StorageCollector: self.disallowed_packet_types = set() else: self.disallowed_packet_types = set() - + # Initialize hardware stats collector from .hardware_stats import HardwareStatsCollector + self.hardware_stats = HardwareStatsCollector() logger.info("Hardware stats collector initialized") - + # Initialize WebSocket handler for real-time updates self.websocket_available = False try: from .websocket_handler import broadcast_packet, broadcast_stats + self.websocket_broadcast_packet = broadcast_packet self.websocket_broadcast_stats = broadcast_stats self.websocket_available = True @@ -87,23 +88,23 @@ class StorageCollector: "packets_sent": 0, "packets_received": 0, "errors": 0, - "queue_len": 0 + "queue_len": 0, } uptime_secs = int(time.time() - self.repeater_handler.start_time) - + # Get airtime stats airtime_stats = self.repeater_handler.airtime_mgr.get_stats() - + # Get latest noise floor from database noise_floor = None try: recent_noise = self.sqlite_handler.get_noise_floor_history(hours=0.5, limit=1) if recent_noise and len(recent_noise) > 0: - noise_floor = recent_noise[-1].get('noise_floor_dbm') + noise_floor = recent_noise[-1].get("noise_floor_dbm") except Exception as e: logger.debug(f"Could not fetch noise floor: {e}") - + stats = { "uptime_secs": uptime_secs, "packets_sent": self.repeater_handler.forwarded_count, @@ -111,22 +112,22 @@ class StorageCollector: "errors": 0, "queue_len": 0, # N/A for Python repeater } - + # Add airtime stats if airtime_stats: stats["tx_air_secs"] = airtime_stats["total_airtime_ms"] / 1000 stats["current_airtime_ms"] = airtime_stats["current_airtime_ms"] stats["utilization_percent"] = airtime_stats["utilization_percent"] - + # Add noise floor if available if noise_floor is not None: stats["noise_floor"] = noise_floor - + return stats def record_packet(self, packet_record: dict, skip_letsmesh_if_invalid: bool = True): """Record packet to storage and publish to MQTT/LetsMesh - + Args: packet_record: Dictionary containing packet information skip_letsmesh_if_invalid: If True, don't publish packets with drop_reason to LetsMesh @@ -141,28 +142,34 @@ class StorageCollector: cumulative_counts = self.sqlite_handler.get_cumulative_counts() self.rrd_handler.update_packet_metrics(packet_record, cumulative_counts) self.mqtt_handler.publish(packet_record, "packet") - + # Broadcast to WebSocket clients for real-time updates if self.websocket_available: try: self.websocket_broadcast_packet(packet_record) - + # Broadcast 24-hour packet stats (same as /api/packet_stats?hours=24) packet_stats_24h = self.sqlite_handler.get_packet_stats(hours=24) - uptime_seconds = time.time() - self.repeater_handler.start_time if self.repeater_handler else 0 - - self.websocket_broadcast_stats({ - "packet_stats": packet_stats_24h, - "system_stats": { - "uptime_seconds": uptime_seconds, + uptime_seconds = ( + time.time() - self.repeater_handler.start_time if self.repeater_handler else 0 + ) + + self.websocket_broadcast_stats( + { + "packet_stats": packet_stats_24h, + "system_stats": { + "uptime_seconds": uptime_seconds, + }, } - }) + ) except Exception as e: logger.debug(f"WebSocket broadcast failed: {e}") # Publish to LetsMesh if enabled (skip invalid packets if requested) - if skip_letsmesh_if_invalid and packet_record.get('drop_reason'): - logger.debug(f"Skipping LetsMesh publish for packet with drop_reason: {packet_record.get('drop_reason')}") + if skip_letsmesh_if_invalid and packet_record.get("drop_reason"): + logger.debug( + f"Skipping LetsMesh publish for packet with drop_reason: {packet_record.get('drop_reason')}" + ) else: self._publish_to_letsmesh(packet_record) @@ -247,23 +254,24 @@ class StorageCollector: def get_neighbors(self) -> dict: return self.sqlite_handler.get_neighbors() - + def get_node_name_by_pubkey(self, pubkey: str) -> Optional[str]: """ Lookup node name from adverts table by public key. - + Args: pubkey: Public key in hex string format - + Returns: Node name if found, None otherwise """ try: import sqlite3 + with sqlite3.connect(self.sqlite_handler.sqlite_path) as conn: result = conn.execute( "SELECT node_name FROM adverts WHERE pubkey = ? AND node_name IS NOT NULL ORDER BY last_seen DESC LIMIT 1", - (pubkey,) + (pubkey,), ).fetchone() return result[0] if result else None except Exception as e: diff --git a/repeater/data_acquisition/storage_utils.py b/repeater/data_acquisition/storage_utils.py index bd930a5..bde938e 100644 --- a/repeater/data_acquisition/storage_utils.py +++ b/repeater/data_acquisition/storage_utils.py @@ -1,6 +1,6 @@ """Storage utility classes and functions for data acquisition.""" -from dataclasses import dataclass, asdict +from dataclasses import asdict, dataclass from datetime import datetime from typing import Optional diff --git a/repeater/data_acquisition/websocket_handler.py b/repeater/data_acquisition/websocket_handler.py index 67d008a..2e87ebc 100644 --- a/repeater/data_acquisition/websocket_handler.py +++ b/repeater/data_acquisition/websocket_handler.py @@ -1,19 +1,21 @@ """ WebSocket handler for real-time packet updates - simple ws4py implementation """ + import json import logging import threading import time -import cherrypy from urllib.parse import parse_qs -from ws4py.websocket import WebSocket + +import cherrypy from ws4py.server.cherrypyserver import WebSocketPlugin, WebSocketTool +from ws4py.websocket import WebSocket logger = logging.getLogger("WebSocket") # Suppress noisy ws4py error logs for normal disconnections (ConnectionResetError, etc.) -logging.getLogger('ws4py').setLevel(logging.CRITICAL) +logging.getLogger("ws4py").setLevel(logging.CRITICAL) # Global set of connected clients _connected_clients = set() @@ -69,14 +71,18 @@ class PacketWebSocket(WebSocket): # Auth success - store user and add to connected clients self.user = payload.get("sub") # type: ignore[attr-defined] _connected_clients.add(self) - logger.info(f"WebSocket connected ({self.user or 'unknown user'}). Total clients: {len(_connected_clients)}") - + logger.info( + f"WebSocket connected ({self.user or 'unknown user'}). Total clients: {len(_connected_clients)}" + ) + def closed(self, code, reason=None): """Called when a WebSocket connection is closed""" _connected_clients.discard(self) - user = getattr(self, 'user', 'unknown') - logger.info(f"WebSocket disconnected (user: {user}, code: {code}, reason: {reason}). Total clients: {len(_connected_clients)}") - + user = getattr(self, "user", "unknown") + logger.info( + f"WebSocket disconnected (user: {user}, code: {code}, reason: {reason}). Total clients: {len(_connected_clients)}" + ) + def received_message(self, message): """Handle messages from client""" try: diff --git a/repeater/engine.py b/repeater/engine.py index 3a34e69..08cade9 100644 --- a/repeater/engine.py +++ b/repeater/engine.py @@ -16,9 +16,8 @@ from pymc_core.protocol.constants import ( PH_TYPE_SHIFT, ROUTE_TYPE_DIRECT, ROUTE_TYPE_FLOOD, - ROUTE_TYPE_TRANSPORT_FLOOD, ROUTE_TYPE_TRANSPORT_DIRECT, - + ROUTE_TYPE_TRANSPORT_FLOOD, ) from pymc_core.protocol.packet_utils import PacketHeaderUtils, PacketTimingUtils @@ -45,7 +44,9 @@ class RepeaterHandler(BaseHandler): self.send_advert_func = send_advert_func self.airtime_mgr = AirtimeManager(config) self.seen_packets = OrderedDict() - self.cache_ttl = max(300, config.get("repeater", {}).get("cache_ttl", 3600)) # Min 5 min, default 1 hour + self.cache_ttl = max( + 300, config.get("repeater", {}).get("cache_ttl", 3600) + ) # Min 5 min, default 1 hour self.max_cache_size = 1000 self.tx_delay_factor = config.get("delays", {}).get("tx_delay_factor", 1.0) self.direct_tx_delay_factor = config.get("delays", {}).get("direct_tx_delay_factor", 0.5) @@ -100,10 +101,12 @@ class RepeaterHandler(BaseHandler): self._transport_keys_cache = None self._transport_keys_cache_time = 0 self._transport_keys_cache_ttl = 60 # Cache for 60 seconds - + self._start_background_tasks() - async def __call__(self, packet: Packet, metadata: Optional[dict] = None, local_transmission: bool = False) -> None: + async def __call__( + self, packet: Packet, metadata: Optional[dict] = None, local_transmission: bool = False + ) -> None: if metadata is None: metadata = {} @@ -132,9 +135,13 @@ class RepeaterHandler(BaseHandler): original_path = list(packet.path) if packet.path else [] # Process for forwarding (skip if in monitor mode or if this is a local transmission) - result = None if (monitor_mode or local_transmission) else self.process_packet(processed_packet, snr) + result = ( + None + if (monitor_mode or local_transmission) + else self.process_packet(processed_packet, snr) + ) forwarded_path = None - + # For local transmissions, create a direct transmission result if local_transmission and not monitor_mode: # Mark local packet as seen to prevent duplicate processing when received back @@ -172,18 +179,18 @@ class RepeaterHandler(BaseHandler): # Wait for transmission to complete to get LBT metadata await tx_task - + # Extract LBT metadata after transmission - tx_metadata = getattr(fwd_pkt, '_tx_metadata', None) + tx_metadata = getattr(fwd_pkt, "_tx_metadata", None) lbt_attempts = 0 lbt_backoff_delays_ms = None lbt_channel_busy = False - + if tx_metadata: - lbt_attempts = tx_metadata.get('lbt_attempts', 0) - lbt_backoff_delays_ms = tx_metadata.get('lbt_backoff_delays_ms', []) - lbt_channel_busy = tx_metadata.get('lbt_channel_busy', False) - + lbt_attempts = tx_metadata.get("lbt_attempts", 0) + lbt_backoff_delays_ms = tx_metadata.get("lbt_backoff_delays_ms", []) + lbt_channel_busy = tx_metadata.get("lbt_channel_busy", False) + if lbt_attempts > 0: total_lbt_delay = sum(lbt_backoff_delays_ms) logger.info( @@ -197,7 +204,9 @@ class RepeaterHandler(BaseHandler): drop_reason = "Monitor mode" else: # Check if packet has a specific drop reason set by handlers - drop_reason = processed_packet.drop_reason or self._get_drop_reason(processed_packet) + drop_reason = processed_packet.drop_reason or self._get_drop_reason( + processed_packet + ) logger.debug(f"Packet not forwarded: {drop_reason}") # Extract packet type and route from header @@ -282,7 +291,9 @@ class RepeaterHandler(BaseHandler): ), "raw_packet": packet.write_to().hex() if hasattr(packet, "write_to") else None, "lbt_attempts": lbt_attempts if transmitted else 0, - "lbt_backoff_delays_ms": lbt_backoff_delays_ms if transmitted and lbt_backoff_delays_ms else None, + "lbt_backoff_delays_ms": ( + lbt_backoff_delays_ms if transmitted and lbt_backoff_delays_ms else None + ), "lbt_channel_busy": lbt_channel_busy if transmitted else False, } @@ -396,7 +407,10 @@ class RepeaterHandler(BaseHandler): return False, "Empty payload" if len(packet.path or []) >= MAX_PATH_SIZE: - return False, f"Path length {len(packet.path or [])} exceeds MAX_PATH_SIZE ({MAX_PATH_SIZE})" + return ( + False, + f"Path length {len(packet.path or [])} exceeds MAX_PATH_SIZE ({MAX_PATH_SIZE})", + ) return True, "" @@ -408,11 +422,13 @@ class RepeaterHandler(BaseHandler): try: from pymc_core.protocol.transport_keys import calc_transport_code - + # Check cache validity current_time = time.time() - if (self._transport_keys_cache is None or - current_time - self._transport_keys_cache_time > self._transport_keys_cache_ttl): + if ( + self._transport_keys_cache is None + or current_time - self._transport_keys_cache_time > self._transport_keys_cache_ttl + ): # Refresh cache self._transport_keys_cache = self.storage.get_transport_keys() self._transport_keys_cache_time = current_time @@ -425,14 +441,16 @@ class RepeaterHandler(BaseHandler): # Check if packet has transport codes if not packet.has_transport_codes(): return False, "No transport codes present" - transport_code_0 = packet.transport_codes[0] # First transport code - payload = packet.get_payload() - payload_type = packet.get_payload_type() if hasattr(packet, 'get_payload_type') else ((packet.header & 0x3C) >> 2) - + payload_type = ( + packet.get_payload_type() + if hasattr(packet, "get_payload_type") + else ((packet.header & 0x3C) >> 2) + ) + # Check packet against each transport key for key_record in transport_keys: transport_key_encoded = key_record.get("transport_key") @@ -441,41 +459,48 @@ class RepeaterHandler(BaseHandler): if not transport_key_encoded: continue - + try: import base64 + transport_key = base64.b64decode(transport_key_encoded) expected_code = calc_transport_code(transport_key, packet) if transport_code_0 == expected_code: - logger.debug(f"Transport code validated for key '{key_name}' with policy '{flood_policy}'") - + logger.debug( + f"Transport code validated for key '{key_name}' with policy '{flood_policy}'" + ) + # Update last_used timestamp for this key try: key_id = key_record.get("id") if key_id: self.storage.update_transport_key( - key_id=key_id, - last_used=time.time() + key_id=key_id, last_used=time.time() + ) + logger.debug( + f"Updated last_used timestamp for transport key '{key_name}'" ) - logger.debug(f"Updated last_used timestamp for transport key '{key_name}'") except Exception as e: - logger.warning(f"Failed to update last_used for transport key '{key_name}': {e}") - + logger.warning( + f"Failed to update last_used for transport key '{key_name}': {e}" + ) + # Check flood policy for this key if flood_policy == "allow": return True, "" else: return False, f"Transport key '{key_name}' flood policy denied" - except Exception as e: logger.warning(f"Error checking transport key '{key_name}': {e}") continue - + # No matching transport code found - logger.debug(f"Transport code 0x{transport_code_0:04X} denied (checked {len(transport_keys)} keys)") + logger.debug( + f"Transport code 0x{transport_code_0:04X} denied (checked {len(transport_keys)} keys)" + ) return False, "No matching transport code" - + except Exception as e: logger.error(f"Transport code validation error: {e}") return False, f"Transport code validation error: {e}" @@ -649,6 +674,7 @@ class RepeaterHandler(BaseHandler): async def schedule_retransmit(self, fwd_pkt: Packet, delay: float, airtime_ms: float = 0.0): """Schedule a packet retransmission with delay and return the task.""" + async def delayed_send(): await asyncio.sleep(delay) try: @@ -720,13 +746,17 @@ class RepeaterHandler(BaseHandler): "mode": repeater_config.get("mode", "forward"), "use_score_for_tx": repeater_config.get("use_score_for_tx", False), "score_threshold": repeater_config.get("score_threshold", 0.3), - "send_advert_interval_hours": repeater_config.get("send_advert_interval_hours", 10), + "send_advert_interval_hours": repeater_config.get( + "send_advert_interval_hours", 10 + ), "latitude": repeater_config.get("latitude", 0.0), "longitude": repeater_config.get("longitude", 0.0), "max_flood_hops": repeater_config.get("max_flood_hops", 3), "advert_interval_minutes": repeater_config.get("advert_interval_minutes", 120), }, - "radio": self.config.get("radio", {}), # Read from live config, not cached radio_config + "radio": self.config.get( + "radio", {} + ), # Read from live config, not cached radio_config "duty_cycle": { "max_airtime_percent": max_duty_cycle_percent, "enforcement_enabled": duty_cycle_config.get("enforcement_enabled", True), @@ -813,8 +843,10 @@ class RepeaterHandler(BaseHandler): try: # Refresh delay factors self.tx_delay_factor = self.config.get("delays", {}).get("tx_delay_factor", 1.0) - self.direct_tx_delay_factor = self.config.get("delays", {}).get("direct_tx_delay_factor", 0.5) - + self.direct_tx_delay_factor = self.config.get("delays", {}).get( + "direct_tx_delay_factor", 0.5 + ) + # Refresh repeater settings repeater_config = self.config.get("repeater", {}) self.use_score_for_tx = repeater_config.get("use_score_for_tx", False) diff --git a/repeater/handler_helpers/__init__.py b/repeater/handler_helpers/__init__.py index 51da12b..3518ca2 100644 --- a/repeater/handler_helpers/__init__.py +++ b/repeater/handler_helpers/__init__.py @@ -1,11 +1,19 @@ """Handler helper modules for pyMC Repeater.""" -from .trace import TraceHelper -from .discovery import DiscoveryHelper from .advert import AdvertHelper +from .discovery import DiscoveryHelper from .login import LoginHelper -from .text import TextHelper from .path import PathHelper from .protocol_request import ProtocolRequestHelper +from .text import TextHelper +from .trace import TraceHelper -__all__ = ["TraceHelper", "DiscoveryHelper", "AdvertHelper", "LoginHelper", "TextHelper", "PathHelper", "ProtocolRequestHelper"] +__all__ = [ + "TraceHelper", + "DiscoveryHelper", + "AdvertHelper", + "LoginHelper", + "TextHelper", + "PathHelper", + "ProtocolRequestHelper", +] diff --git a/repeater/handler_helpers/acl.py b/repeater/handler_helpers/acl.py index 47f65a8..3351999 100644 --- a/repeater/handler_helpers/acl.py +++ b/repeater/handler_helpers/acl.py @@ -58,7 +58,7 @@ class ACL: sync_since: int = None, target_identity_hash: int = None, target_identity_name: str = None, - target_identity_config: dict = None + target_identity_config: dict = None, ) -> tuple[bool, int]: target_identity_config = target_identity_config or {} @@ -79,9 +79,11 @@ class ACL: # Empty strings are treated as "not set" admin_pwd = identity_settings.get("admin_password") or None guest_pwd = identity_settings.get("guest_password") or None - + if not admin_pwd and not guest_pwd: - logger.error(f"Room server '{target_identity_name}' has no passwords configured! Set admin_password and/or guest_password in settings.") + logger.error( + f"Room server '{target_identity_name}' has no passwords configured! Set admin_password and/or guest_password in settings." + ) return False, 0 else: # Repeater uses global passwords from its own security section @@ -91,10 +93,12 @@ class ACL: f"Repeater passwords - admin: {'SET' if admin_pwd else 'NONE'}, " f"guest: {'SET' if guest_pwd else 'NONE'}" ) - + if target_identity_name: - logger.debug(f"Authenticating for identity '{target_identity_name}' (room_server={is_room_server})") - + logger.debug( + f"Authenticating for identity '{target_identity_name}' (room_server={is_room_server})" + ) + pub_key = client_identity.get_public_key()[:PUB_KEY_SIZE] if not password: @@ -111,8 +115,12 @@ class ACL: permissions = 0 logger.debug(f"Comparing password (len={len(password)}) against admin/guest") - logger.debug(f"Admin pwd len={len(admin_pwd) if admin_pwd else 0}, Guest pwd len={len(guest_pwd) if guest_pwd else 0}") - logger.debug(f"Password comparison: '{password}' vs admin='{admin_pwd[:4]}...' ({len(admin_pwd)} chars)") + logger.debug( + f"Admin pwd len={len(admin_pwd) if admin_pwd else 0}, Guest pwd len={len(guest_pwd) if guest_pwd else 0}" + ) + logger.debug( + f"Password comparison: '{password}' vs admin='{admin_pwd[:4]}...' ({len(admin_pwd)} chars)" + ) if admin_pwd and password == admin_pwd: permissions = PERM_ACL_ADMIN logger.info(f"Admin password validated for '{target_identity_name or 'unknown'}'") diff --git a/repeater/handler_helpers/advert.py b/repeater/handler_helpers/advert.py index 4b0fa62..74354d5 100644 --- a/repeater/handler_helpers/advert.py +++ b/repeater/handler_helpers/advert.py @@ -70,11 +70,12 @@ class AdvertHelper: if pubkey == local_pubkey: logger.debug("Ignoring own advert in neighbor tracking") return - + # Get route type from packet header from pymc_core.protocol.constants import PH_ROUTE_MASK + route_type = packet.header & PH_ROUTE_MASK - + # Check if this is a new neighbor current_time = time.time() if pubkey not in self._known_neighbors: diff --git a/repeater/handler_helpers/discovery.py b/repeater/handler_helpers/discovery.py index e48d50d..4836161 100644 --- a/repeater/handler_helpers/discovery.py +++ b/repeater/handler_helpers/discovery.py @@ -7,6 +7,7 @@ allowing other nodes to discover repeaters on the mesh network. import asyncio import logging + from pymc_core.node.handlers.control import ControlHandler logger = logging.getLogger("DiscoveryHelper") diff --git a/repeater/handler_helpers/login.py b/repeater/handler_helpers/login.py index cedf611..5912866 100644 --- a/repeater/handler_helpers/login.py +++ b/repeater/handler_helpers/login.py @@ -22,9 +22,11 @@ class LoginHelper: self.handlers = {} self.acls = {} # Per-identity ACLs keyed by hash_byte - def register_identity(self, name: str, identity, identity_type: str = "room_server", config: dict = None): + def register_identity( + self, name: str, identity, identity_type: str = "room_server", config: dict = None + ): config = config or {} - + hash_byte = identity.get_public_key()[0] # Create ACL for this identity @@ -79,9 +81,11 @@ class LoginHelper: self.acls[hash_byte] = identity_acl logger.info(f"Created ACL for {identity_type} '{name}': hash=0x{hash_byte:02X}") - + # Create auth callback that uses this identity's ACL - def auth_callback_with_context(client_identity, shared_secret, password, timestamp, sync_since=None): + def auth_callback_with_context( + client_identity, shared_secret, password, timestamp, sync_since=None + ): return identity_acl.authenticate_client( client_identity=client_identity, shared_secret=shared_secret, @@ -90,9 +94,9 @@ class LoginHelper: sync_since=sync_since, target_identity_hash=hash_byte, target_identity_name=name, - target_identity_config=config + target_identity_config=config, ) - + handler = LoginServerHandler( local_identity=identity, log_fn=self.log_fn, @@ -103,11 +107,9 @@ class LoginHelper: handler.set_send_packet_callback(self._send_packet_with_delay) self.handlers[hash_byte] = handler - + logger.info(f"Registered {identity_type} '{name}' login handler: hash=0x{hash_byte:02X}") - - async def process_login_packet(self, packet): try: @@ -123,9 +125,11 @@ class LoginHelper: packet.mark_do_not_retransmit() return True else: - logger.debug(f"No login handler registered for hash 0x{dest_hash:02X}, allowing forward") + logger.debug( + f"No login handler registered for hash 0x{dest_hash:02X}, allowing forward" + ) return False - + except Exception as e: logger.error(f"Error processing login packet: {e}") return False diff --git a/repeater/handler_helpers/mesh_cli.py b/repeater/handler_helpers/mesh_cli.py index 2f7dd12..a05bf81 100644 --- a/repeater/handler_helpers/mesh_cli.py +++ b/repeater/handler_helpers/mesh_cli.py @@ -1,8 +1,9 @@ import logging -from typing import Optional, Dict, Any, Callable -import yaml -from pathlib import Path import time +from pathlib import Path +from typing import Any, Callable, Dict, Optional + +import yaml logger = logging.getLogger(__name__) @@ -10,15 +11,15 @@ logger = logging.getLogger(__name__) class MeshCLI: def __init__( - self, - config_path: str, - config: Dict[str, Any], + self, + config_path: str, + config: Dict[str, Any], config_manager, # ConfigManager instance for save & live updates identity_type: str = "repeater", enable_regions: bool = True, send_advert_callback: Optional[Callable] = None, - identity = None, - storage_handler = None + identity=None, + storage_handler=None, ): self.config_path = Path(config_path) @@ -29,39 +30,39 @@ class MeshCLI: self.send_advert_callback = send_advert_callback self.identity = identity self.storage_handler = storage_handler - + # Get repeater config shortcut - self.repeater_config = config.get('repeater', {}) - + self.repeater_config = config.get("repeater", {}) + def handle_command(self, sender_pubkey: bytes, command: str, is_admin: bool) -> str: # Check admin permission first if not is_admin: return "Error: Admin permission required" - + logger.debug(f"handle_command received: '{command}' (len={len(command)})") - + # Extract optional sequence prefix (XX|) prefix = "" - if len(command) > 4 and command[2] == '|': + if len(command) > 4 and command[2] == "|": prefix = command[:3] command = command[3:] logger.debug(f"Extracted prefix: '{prefix}', remaining command: '{command}'") - + # Strip leading/trailing whitespace command = command.strip() logger.debug(f"After strip: '{command}'") - + # Route to appropriate handler reply = self._route_command(command) - + # Add prefix back to reply if present if prefix: return prefix + reply return reply - + def _route_command(self, command: str) -> str: - + # System commands if command == "reboot": return self._cmd_reboot() @@ -79,97 +80,98 @@ class MeshCLI: return self._cmd_clear_stats() elif command == "ver": return self._cmd_version() - + # Get commands elif command.startswith("get "): return self._cmd_get(command[4:]) - + # Set commands elif command.startswith("set "): return self._cmd_set(command[4:]) - + # ACL commands elif command.startswith("setperm "): return self._cmd_setperm(command) elif command == "get acl": return "Error: Use 'get acl' via serial console only" - + # Region commands (repeaters only) elif command.startswith("region"): if self.enable_regions: return self._cmd_region(command) else: return "Error: Region commands not available for room servers" - + # Neighbor commands elif command == "neighbors": return self._cmd_neighbors() elif command.startswith("neighbor.remove "): return self._cmd_neighbor_remove(command) - + # Temporary radio params elif command.startswith("tempradio "): return self._cmd_tempradio(command) - + # Sensor commands elif command.startswith("sensor "): return "Error: Sensor commands not implemented in Python repeater" - + # GPS commands elif command.startswith("gps"): return "Error: GPS commands not implemented in Python repeater" - + # Logging commands elif command.startswith("log "): return self._cmd_log(command) - + # Statistics commands elif command.startswith("stats-"): return "Error: Stats commands not fully implemented yet" - + else: return "Unknown command" - + # ==================== System Commands ==================== - + def _cmd_reboot(self) -> str: """Reboot the repeater process.""" from repeater.service_utils import restart_service - + logger.warning("Reboot command received via mesh CLI") success, message = restart_service() - + if success: return f"OK - {message}" else: return f"Error: {message}" - + def _cmd_advert(self) -> str: """Send self advertisement.""" if not self.send_advert_callback: logger.warning("Advert command received but no callback configured") return "Error: Advert functionality not configured" - + try: import asyncio - + async def delayed_advert(): """Delay advert to let CLI response send first (matches C++ 1500ms delay).""" await asyncio.sleep(1.5) await self.send_advert_callback() - + asyncio.create_task(delayed_advert()) logger.info("Advert scheduled for sending (1.5s delay)") return "OK - Advert sent" except Exception as e: logger.error(f"Failed to schedule advert: {e}", exc_info=True) return f"Error: {e}" - + def _cmd_clock(self, command: str) -> str: """Handle clock commands.""" if command == "clock": # Display current time import datetime + dt = datetime.datetime.utcnow() return f"{dt.hour:02d}:{dt.minute:02d} - {dt.day}/{dt.month}/{dt.year} UTC" elif command == "clock sync": @@ -177,94 +179,94 @@ class MeshCLI: return "OK - clock sync not needed (system time used)" else: return "Unknown clock command" - + def _cmd_time(self, command: str) -> str: """Set time - not supported in Python (use system time).""" return "Error: Time setting not supported (system time is used)" - + def _cmd_password(self, command: str) -> str: """Change admin password.""" new_password = command[9:].strip() - + if not new_password: return "Error: Password cannot be empty" - + # Update security config - if 'security' not in self.config: - self.config['security'] = {} - - self.config['security']['password'] = new_password - + if "security" not in self.config: + self.config["security"] = {} + + self.config["security"]["password"] = new_password + # Save config and live update try: saved, err = self.config_manager.save_to_file() if not saved: logger.error(f"Failed to save password: {err}") return f"Error: Failed to save config: {err}" - self.config_manager.live_update_daemon(['security']) + self.config_manager.live_update_daemon(["security"]) return f"password now: {new_password}" except Exception as e: logger.error(f"Failed to save password: {e}") return "Error: Failed to save password" - + def _cmd_clear_stats(self) -> str: """Clear statistics.""" # TODO: Implement stats clearing return "Error: Not yet implemented" - + def _cmd_version(self) -> str: """Get version information.""" role = "room_server" if self.identity_type == "room_server" else "repeater" - version = self.config.get('version', '1.0.0') + version = self.config.get("version", "1.0.0") return f"pyMC_{role} v{version}" - + # ==================== Get Commands ==================== - + def _cmd_get(self, param: str) -> str: """Handle get commands.""" param = param.strip() logger.debug(f"_cmd_get called with param: '{param}' (len={len(param)})") - + if param == "af": - af = self.repeater_config.get('airtime_factor', 1.0) + af = self.repeater_config.get("airtime_factor", 1.0) return f"> {af}" - + elif param == "name": - name = self.repeater_config.get('name', 'Unknown') + name = self.repeater_config.get("name", "Unknown") return f"> {name}" - + elif param == "repeat": - disabled = self.repeater_config.get('disable_forward', False) + disabled = self.repeater_config.get("disable_forward", False) return f"> {'off' if disabled else 'on'}" - + elif param == "lat": - lat = self.repeater_config.get('latitude', 0.0) + lat = self.repeater_config.get("latitude", 0.0) return f"> {lat}" - + elif param == "lon": - lon = self.repeater_config.get('longitude', 0.0) + lon = self.repeater_config.get("longitude", 0.0) return f"> {lon}" - + elif param == "radio": - radio = self.config.get('radio', {}) - freq_hz = radio.get('frequency', 915000000) - bw_hz = radio.get('bandwidth', 125000) - sf = radio.get('spreading_factor', 7) - cr = radio.get('coding_rate', 5) + radio = self.config.get("radio", {}) + freq_hz = radio.get("frequency", 915000000) + bw_hz = radio.get("bandwidth", 125000) + sf = radio.get("spreading_factor", 7) + cr = radio.get("coding_rate", 5) # Convert Hz to MHz for freq, Hz to kHz for bandwidth (match C++ ftoa output) freq_mhz = freq_hz / 1_000_000.0 bw_khz = bw_hz / 1_000.0 return f"> {freq_mhz},{bw_khz},{sf},{cr}" - + elif param == "freq": - freq_hz = self.config.get('radio', {}).get('frequency', 915000000) + freq_hz = self.config.get("radio", {}).get("frequency", 915000000) freq_mhz = freq_hz / 1_000_000.0 return f"> {freq_mhz}" - + elif param == "tx": - power = self.config.get('radio', {}).get('tx_power', 20) + power = self.config.get("radio", {}).get("tx_power", 20) return f"> {power}" - + elif param == "public.key": if not self.identity: return "Error: Identity not available" @@ -275,263 +277,263 @@ class MeshCLI: except Exception as e: logger.error(f"Failed to get public key: {e}") return f"Error: {e}" - + elif param == "role": role = "room_server" if self.identity_type == "room_server" else "repeater" return f"> {role}" - + elif param == "guest.password": - guest_pw = self.config.get('security', {}).get('guest_password', '') + guest_pw = self.config.get("security", {}).get("guest_password", "") return f"> {guest_pw}" - + elif param == "allow.read.only": - allow = self.config.get('security', {}).get('allow_read_only', False) + allow = self.config.get("security", {}).get("allow_read_only", False) return f"> {'on' if allow else 'off'}" - + elif param == "advert.interval": - interval = self.repeater_config.get('advert_interval_minutes', 120) + interval = self.repeater_config.get("advert_interval_minutes", 120) return f"> {interval}" - + elif param == "flood.advert.interval": - interval = self.repeater_config.get('flood_advert_interval_hours', 24) + interval = self.repeater_config.get("flood_advert_interval_hours", 24) return f"> {interval}" - + elif param == "flood.max": - max_flood = self.repeater_config.get('max_flood_hops', 3) + max_flood = self.repeater_config.get("max_flood_hops", 3) return f"> {max_flood}" - + elif param == "rxdelay": - delay = self.repeater_config.get('rx_delay_base', 0.0) + delay = self.repeater_config.get("rx_delay_base", 0.0) return f"> {delay}" - + elif param == "txdelay": - delay = self.repeater_config.get('tx_delay_factor', 1.0) + delay = self.repeater_config.get("tx_delay_factor", 1.0) return f"> {delay}" - + elif param == "direct.txdelay": - delay = self.repeater_config.get('direct_tx_delay_factor', 0.5) + delay = self.repeater_config.get("direct_tx_delay_factor", 0.5) return f"> {delay}" - + elif param == "multi.acks": - acks = self.repeater_config.get('multi_acks', 0) + acks = self.repeater_config.get("multi_acks", 0) return f"> {acks}" - + elif param == "int.thresh": - thresh = self.repeater_config.get('interference_threshold', -120) + thresh = self.repeater_config.get("interference_threshold", -120) return f"> {thresh}" - + elif param == "agc.reset.interval": - interval = self.repeater_config.get('agc_reset_interval', 0) + interval = self.repeater_config.get("agc_reset_interval", 0) return f"> {interval}" - + else: return f"??: {param}" - + # ==================== Set Commands ==================== - + def _cmd_set(self, param: str) -> str: """Handle set commands.""" parts = param.split(None, 1) if len(parts) < 2: return "Error: Missing value" - + key, value = parts[0], parts[1] - + try: if key == "af": - self.repeater_config['airtime_factor'] = float(value) + self.repeater_config["airtime_factor"] = float(value) saved, _ = self.config_manager.save_to_file() - self.config_manager.live_update_daemon(['repeater']) + self.config_manager.live_update_daemon(["repeater"]) return "OK" - + elif key == "name": - self.repeater_config['node_name'] = value + self.repeater_config["node_name"] = value saved, _ = self.config_manager.save_to_file() - self.config_manager.live_update_daemon(['repeater']) + self.config_manager.live_update_daemon(["repeater"]) return "OK" - + elif key == "repeat": disabled = value.lower() == "off" - self.repeater_config['disable_forward'] = disabled + self.repeater_config["disable_forward"] = disabled saved, _ = self.config_manager.save_to_file() - self.config_manager.live_update_daemon(['repeater']) + self.config_manager.live_update_daemon(["repeater"]) return f"OK - repeat is now {'OFF' if disabled else 'ON'}" - + elif key == "lat": - self.repeater_config['latitude'] = float(value) + self.repeater_config["latitude"] = float(value) saved, _ = self.config_manager.save_to_file() - self.config_manager.live_update_daemon(['repeater']) + self.config_manager.live_update_daemon(["repeater"]) return "OK" - + elif key == "lon": - self.repeater_config['longitude'] = float(value) + self.repeater_config["longitude"] = float(value) saved, _ = self.config_manager.save_to_file() - self.config_manager.live_update_daemon(['repeater']) + self.config_manager.live_update_daemon(["repeater"]) return "OK" - + elif key == "radio": # Format: freq bw sf cr radio_parts = value.split() if len(radio_parts) != 4: return "Error: Expected freq bw sf cr" - - if 'radio' not in self.config: - self.config['radio'] = {} - - self.config['radio']['frequency'] = float(radio_parts[0]) - self.config['radio']['bandwidth'] = float(radio_parts[1]) - self.config['radio']['spreading_factor'] = int(radio_parts[2]) - self.config['radio']['coding_rate'] = int(radio_parts[3]) + + if "radio" not in self.config: + self.config["radio"] = {} + + self.config["radio"]["frequency"] = float(radio_parts[0]) + self.config["radio"]["bandwidth"] = float(radio_parts[1]) + self.config["radio"]["spreading_factor"] = int(radio_parts[2]) + self.config["radio"]["coding_rate"] = int(radio_parts[3]) saved, _ = self.config_manager.save_to_file() - self.config_manager.live_update_daemon(['radio']) + self.config_manager.live_update_daemon(["radio"]) return "OK - restart repeater to apply" - + elif key == "freq": - if 'radio' not in self.config: - self.config['radio'] = {} - self.config['radio']['frequency'] = float(value) + if "radio" not in self.config: + self.config["radio"] = {} + self.config["radio"]["frequency"] = float(value) saved, _ = self.config_manager.save_to_file() - self.config_manager.live_update_daemon(['radio']) + self.config_manager.live_update_daemon(["radio"]) return "OK - restart repeater to apply" - + elif key == "tx": - if 'radio' not in self.config: - self.config['radio'] = {} - self.config['radio']['tx_power'] = int(value) + if "radio" not in self.config: + self.config["radio"] = {} + self.config["radio"]["tx_power"] = int(value) saved, _ = self.config_manager.save_to_file() - self.config_manager.live_update_daemon(['radio']) + self.config_manager.live_update_daemon(["radio"]) return "OK" - + elif key == "guest.password": - if 'security' not in self.config: - self.config['security'] = {} - self.config['security']['guest_password'] = value + if "security" not in self.config: + self.config["security"] = {} + self.config["security"]["guest_password"] = value saved, _ = self.config_manager.save_to_file() - self.config_manager.live_update_daemon(['security']) + self.config_manager.live_update_daemon(["security"]) return "OK" - + elif key == "allow.read.only": - if 'security' not in self.config: - self.config['security'] = {} - self.config['security']['allow_read_only'] = value.lower() == "on" + if "security" not in self.config: + self.config["security"] = {} + self.config["security"]["allow_read_only"] = value.lower() == "on" saved, _ = self.config_manager.save_to_file() - self.config_manager.live_update_daemon(['security']) + self.config_manager.live_update_daemon(["security"]) return "OK" - + elif key == "advert.interval": mins = int(value) if mins > 0 and (mins < 60 or mins > 240): return "Error: interval range is 60-240 minutes" - self.repeater_config['advert_interval_minutes'] = mins + self.repeater_config["advert_interval_minutes"] = mins saved, _ = self.config_manager.save_to_file() - self.config_manager.live_update_daemon(['repeater']) + self.config_manager.live_update_daemon(["repeater"]) return "OK" - + elif key == "flood.advert.interval": hours = int(value) if (hours > 0 and hours < 3) or hours > 48: return "Error: interval range is 3-48 hours" - self.repeater_config['flood_advert_interval_hours'] = hours + self.repeater_config["flood_advert_interval_hours"] = hours saved, _ = self.config_manager.save_to_file() - self.config_manager.live_update_daemon(['repeater']) + self.config_manager.live_update_daemon(["repeater"]) return "OK" - + elif key == "flood.max": max_val = int(value) if max_val > 64: return "Error: max 64" - self.repeater_config['max_flood_hops'] = max_val + self.repeater_config["max_flood_hops"] = max_val saved, _ = self.config_manager.save_to_file() - self.config_manager.live_update_daemon(['repeater']) + self.config_manager.live_update_daemon(["repeater"]) return "OK" - + elif key == "rxdelay": delay = float(value) if delay < 0: return "Error: cannot be negative" - self.repeater_config['rx_delay_base'] = delay + self.repeater_config["rx_delay_base"] = delay saved, _ = self.config_manager.save_to_file() - self.config_manager.live_update_daemon(['repeater', 'delays']) + self.config_manager.live_update_daemon(["repeater", "delays"]) return "OK" - + elif key == "txdelay": delay = float(value) if delay < 0: return "Error: cannot be negative" - self.repeater_config['tx_delay_factor'] = delay + self.repeater_config["tx_delay_factor"] = delay saved, _ = self.config_manager.save_to_file() - self.config_manager.live_update_daemon(['repeater', 'delays']) + self.config_manager.live_update_daemon(["repeater", "delays"]) return "OK" - + elif key == "direct.txdelay": delay = float(value) if delay < 0: return "Error: cannot be negative" - self.repeater_config['direct_tx_delay_factor'] = delay + self.repeater_config["direct_tx_delay_factor"] = delay saved, _ = self.config_manager.save_to_file() - self.config_manager.live_update_daemon(['repeater', 'delays']) + self.config_manager.live_update_daemon(["repeater", "delays"]) return "OK" - + elif key == "multi.acks": - self.repeater_config['multi_acks'] = int(value) + self.repeater_config["multi_acks"] = int(value) saved, _ = self.config_manager.save_to_file() - self.config_manager.live_update_daemon(['repeater']) + self.config_manager.live_update_daemon(["repeater"]) return "OK" - + elif key == "int.thresh": - self.repeater_config['interference_threshold'] = int(value) + self.repeater_config["interference_threshold"] = int(value) saved, _ = self.config_manager.save_to_file() - self.config_manager.live_update_daemon(['repeater']) + self.config_manager.live_update_daemon(["repeater"]) return "OK" - + elif key == "agc.reset.interval": interval = int(value) # Round to nearest multiple of 4 rounded = (interval // 4) * 4 - self.repeater_config['agc_reset_interval'] = rounded + self.repeater_config["agc_reset_interval"] = rounded saved, _ = self.config_manager.save_to_file() - self.config_manager.live_update_daemon(['repeater']) + self.config_manager.live_update_daemon(["repeater"]) return f"OK - interval rounded to {rounded}" - + else: return f"unknown config: {key}" - + except ValueError as e: return f"Error: invalid value - {e}" except Exception as e: logger.error(f"Set command error: {e}") return f"Error: {e}" - + # ==================== ACL Commands ==================== - + def _cmd_setperm(self, command: str) -> str: """Set permissions for a public key.""" # Format: setperm {pubkey-hex} {permissions-int} parts = command[8:].split() if len(parts) < 2: return "Err - bad params" - + pubkey_hex = parts[0] try: permissions = int(parts[1]) except ValueError: return "Err - invalid permissions" - + # TODO: Apply permissions via ACL logger.info(f"setperm command: {pubkey_hex} -> {permissions}") return "Error: Not yet implemented - use config file" - + # ==================== Region Commands ==================== - + def _cmd_region(self, command: str) -> str: """Handle region commands.""" parts = command.split() - + if len(parts) == 1: return "Error: Region commands not implemented in Python repeater" - + subcommand = parts[1] - + if subcommand == "load": return "Error: Region commands not implemented" elif subcommand == "save": @@ -540,80 +542,82 @@ class MeshCLI: return "Error: Region commands not implemented" else: return "Err - ??" - + # ==================== Neighbor Commands ==================== - + def _cmd_neighbors(self) -> str: """List neighbors.""" if not self.storage_handler: return "Error: Storage not available" - + try: neighbors = self.storage_handler.get_neighbors() - + if not neighbors: return "No neighbors discovered yet" - + # Filter to only show repeaters and zero hop nodes filtered_neighbors = { - pubkey: info for pubkey, info in neighbors.items() - if info.get('is_repeater', False) or info.get('zero_hop', False) + pubkey: info + for pubkey, info in neighbors.items() + if info.get("is_repeater", False) or info.get("zero_hop", False) } - + if not filtered_neighbors: return "No repeaters or zero hop neighbors discovered yet" - + # Format output similar to C++ version # Format: " heard Xs ago" import time + current_time = int(time.time()) - + lines = [] for pubkey, info in filtered_neighbors.items(): - last_seen = info.get('last_seen', 0) + last_seen = info.get("last_seen", 0) seconds_ago = int(current_time - last_seen) - + # Get first 4 bytes of pubkey as hex (match C++ format) pubkey_short = pubkey[:8] if len(pubkey) >= 8 else pubkey - snr = info.get('snr', 0) or 0 - + snr = info.get("snr", 0) or 0 + # Format: <4byte_hex>:: (matches C++ format) lines.append(f"{pubkey_short}:{seconds_ago}:{int(snr)}") - + return "\n".join(lines) - + except Exception as e: logger.error(f"Failed to list neighbors: {e}", exc_info=True) return f"Error: {e}" - + def _cmd_neighbor_remove(self, command: str) -> str: """Remove a neighbor.""" pubkey_hex = command[16:].strip() - + if not pubkey_hex: return "ERR: Missing pubkey" - + # TODO: Remove neighbor from routing table logger.info(f"neighbor.remove: {pubkey_hex}") return "Error: Not yet implemented" - + # ==================== Temporary Radio Commands ==================== - + def _cmd_tempradio(self, command: str) -> str: """Apply temporary radio parameters.""" # Format: tempradio {freq} {bw} {sf} {cr} {timeout_mins} parts = command[10:].split() - + if len(parts) < 5: return "Error: Expected freq bw sf cr timeout_mins" - + try: freq = float(parts[0]) bw = float(parts[1]) sf = int(parts[2]) cr = int(parts[3]) timeout_mins = int(parts[4]) - + # Validate if not (300.0 <= freq <= 2500.0): return "Error: invalid frequency" @@ -625,16 +629,16 @@ class MeshCLI: return "Error: invalid coding rate" if timeout_mins <= 0: return "Error: invalid timeout" - + # TODO: Apply temporary radio parameters logger.info(f"tempradio: {freq}MHz {bw}kHz SF{sf} CR4/{cr} for {timeout_mins}min") return "Error: Not yet implemented" - + except ValueError: return "Error, invalid params" - + # ==================== Logging Commands ==================== - + def _cmd_log(self, command: str) -> str: """Handle log commands.""" if command == "log start": diff --git a/repeater/handler_helpers/path.py b/repeater/handler_helpers/path.py index fecacd1..d482118 100644 --- a/repeater/handler_helpers/path.py +++ b/repeater/handler_helpers/path.py @@ -13,20 +13,20 @@ class PathHelper: async def process_path_packet(self, packet): from pymc_core.protocol.crypto import CryptoUtils - + try: if len(packet.payload) < 2: return False - + dest_hash = packet.payload[0] src_hash = packet.payload[1] - + # Get the ACL for this destination identity identity_acl = self.acl_dict.get(dest_hash) if not identity_acl: logger.debug(f"No ACL for dest 0x{dest_hash:02X}, allowing forward") return False - + # Find the client by source hash client = None for client_info in identity_acl.get_all_clients(): @@ -34,57 +34,59 @@ class PathHelper: if pubkey[0] == src_hash: client = client_info break - + if not client: logger.debug(f"PATH packet from unknown client 0x{src_hash:02X}, allowing forward") return False - + # Get shared secret for decryption shared_secret = client.shared_secret if not shared_secret or len(shared_secret) == 0: logger.debug(f"No shared secret for client 0x{src_hash:02X}, cannot decrypt PATH") return False - + # Decrypt the PATH packet payload # Payload format: dest_hash(1) + src_hash(1) + mac(2) + encrypted_data if len(packet.payload) < 4: logger.debug(f"PATH packet too short: {len(packet.payload)} bytes") return False - + mac_and_data = packet.payload[2:] # Skip dest_hash and src_hash aes_key = shared_secret[:16] decrypted = CryptoUtils.mac_then_decrypt(aes_key, shared_secret, mac_and_data) - + if not decrypted: logger.debug(f"Failed to decrypt PATH packet from 0x{src_hash:02X}") return False - + # Parse decrypted PATH data # Format: path_len(1) + path[path_len] + extra_type(1) + extra[...] if len(decrypted) < 1: logger.debug(f"Decrypted PATH data too short") return False - + path_len = decrypted[0] if len(decrypted) < 1 + path_len: - logger.debug(f"PATH data truncated: need {1 + path_len} bytes, got {len(decrypted)}") + logger.debug( + f"PATH data truncated: need {1 + path_len} bytes, got {len(decrypted)}" + ) return False - - path_data = decrypted[1:1 + path_len] - + + path_data = decrypted[1 : 1 + path_len] + # Update client's out_path (same as C++ memcpy) client.out_path = bytearray(path_data) client.out_path_len = path_len client.last_activity = int(time.time()) - + logger.info( f"Updated out_path for client 0x{src_hash:02X} -> 0x{dest_hash:02X}: " f"path_len={path_len}, path={[hex(b) for b in path_data]}" ) - + # Don't mark as do_not_retransmit - let it forward normally return False - + except Exception as e: logger.error(f"Error processing PATH packet: {e}", exc_info=True) return False diff --git a/repeater/handler_helpers/protocol_request.py b/repeater/handler_helpers/protocol_request.py index 376117b..5490a65 100644 --- a/repeater/handler_helpers/protocol_request.py +++ b/repeater/handler_helpers/protocol_request.py @@ -10,12 +10,12 @@ import struct import time from pymc_core.node.handlers.protocol_request import ( - ProtocolRequestHandler, - REQ_TYPE_GET_STATUS, - REQ_TYPE_GET_TELEMETRY_DATA, REQ_TYPE_GET_ACCESS_LIST, REQ_TYPE_GET_NEIGHBOURS, - SERVER_RESPONSE_DELAY_MS + REQ_TYPE_GET_STATUS, + REQ_TYPE_GET_TELEMETRY_DATA, + SERVER_RESPONSE_DELAY_MS, + ProtocolRequestHandler, ) logger = logging.getLogger("ProtocolRequestHelper") @@ -23,8 +23,16 @@ logger = logging.getLogger("ProtocolRequestHelper") class ProtocolRequestHelper: """Provides repeater-specific protocol request handlers.""" - - def __init__(self, identity_manager, packet_injector=None, acl_dict=None, radio=None, engine=None, neighbor_tracker=None): + + def __init__( + self, + identity_manager, + packet_injector=None, + acl_dict=None, + radio=None, + engine=None, + neighbor_tracker=None, + ): self.identity_manager = identity_manager self.packet_injector = packet_injector @@ -71,9 +79,10 @@ class ProtocolRequestHelper: } logger.info(f"Registered protocol request handler for '{name}': hash=0x{hash_byte:02X}") - + def _create_acl_contacts_wrapper(self, acl): """Create contacts wrapper from ACL.""" + class ACLContactsWrapper: def __init__(self, identity_acl): self._acl = identity_acl @@ -138,22 +147,32 @@ class ProtocolRequestHelper: # uint32_t n_direct_dups; # uint32_t n_flood_dups; # uint32_t total_rx_air_time_secs; - + # Get stats from radio/engine noise_floor = int(self.radio.get_noise_floor() * 1.0) if self.radio else -120 - last_rssi = int(self.radio.last_rssi) if self.radio and hasattr(self.radio, 'last_rssi') else -120 - last_snr = int((self.radio.last_snr * 4.0) if self.radio and hasattr(self.radio, 'last_snr') else 0) - + last_rssi = ( + int(self.radio.last_rssi) if self.radio and hasattr(self.radio, "last_rssi") else -120 + ) + last_snr = int( + (self.radio.last_snr * 4.0) if self.radio and hasattr(self.radio, "last_snr") else 0 + ) + # Get packet counts - n_packets_recv = self.radio.packets_received if self.radio and hasattr(self.radio, 'packets_received') else 0 - n_packets_sent = self.radio.packets_sent if self.radio and hasattr(self.radio, 'packets_sent') else 0 - + n_packets_recv = ( + self.radio.packets_received + if self.radio and hasattr(self.radio, "packets_received") + else 0 + ) + n_packets_sent = ( + self.radio.packets_sent if self.radio and hasattr(self.radio, "packets_sent") else 0 + ) + # Get airtime stats total_air_time_secs = 0 total_rx_air_time_secs = 0 - if self.engine and hasattr(self.engine, 'airtime_manager'): + if self.engine and hasattr(self.engine, "airtime_manager"): total_air_time_secs = int(self.engine.airtime_manager.total_tx_airtime_ms / 1000) - + # Get routing stats n_sent_flood = 0 n_sent_direct = 0 @@ -161,18 +180,18 @@ class ProtocolRequestHelper: n_recv_direct = 0 n_direct_dups = 0 n_flood_dups = 0 - + if self.engine: - n_sent_flood = getattr(self.engine, 'sent_flood_count', 0) - n_sent_direct = getattr(self.engine, 'sent_direct_count', 0) - n_recv_flood = getattr(self.engine, 'recv_flood_count', 0) - n_recv_direct = getattr(self.engine, 'recv_direct_count', 0) - n_direct_dups = getattr(self.engine, 'direct_dup_count', 0) - n_flood_dups = getattr(self.engine, 'flood_dup_count', 0) - + n_sent_flood = getattr(self.engine, "sent_flood_count", 0) + n_sent_direct = getattr(self.engine, "sent_direct_count", 0) + n_recv_flood = getattr(self.engine, "recv_flood_count", 0) + n_recv_direct = getattr(self.engine, "recv_direct_count", 0) + n_direct_dups = getattr(self.engine, "direct_dup_count", 0) + n_flood_dups = getattr(self.engine, "flood_dup_count", 0) + # Pack struct (little-endian) stats = struct.pack( - ' str: """ Handle an incoming command from a client. @@ -64,10 +65,10 @@ class MeshCLI: return "Error: Admin permission required" logger.debug(f"handle_command received: '{command}' (len={len(command)})") - + # Extract optional sequence prefix (XX|) prefix = "" - if len(command) > 4 and command[2] == '|': + if len(command) > 4 and command[2] == "|": prefix = command[:3] command = command[3:] logger.debug(f"Extracted prefix: '{prefix}', remaining command: '{command}'") @@ -180,6 +181,7 @@ class MeshCLI: if command == "clock": # Display current time import datetime + dt = datetime.datetime.utcnow() return f"{dt.hour:02d}:{dt.minute:02d} - {dt.day}/{dt.month}/{dt.year} UTC" elif command == "clock sync": @@ -198,13 +200,13 @@ class MeshCLI: if not new_password: return "Error: Password cannot be empty" - + # Update security config - if 'security' not in self.config: - self.config['security'] = {} - - self.config['security']['password'] = new_password - + if "security" not in self.config: + self.config["security"] = {} + + self.config["security"]["password"] = new_password + # Save config try: self.save_config() @@ -221,56 +223,56 @@ class MeshCLI: def _cmd_version(self) -> str: """Get version information.""" role = "room_server" if self.identity_type == "room_server" else "repeater" - version = self.config.get('version', '1.0.0') + version = self.config.get("version", "1.0.0") return f"pyMC_{role} v{version}" - + # ==================== Get Commands ==================== def _cmd_get(self, param: str) -> str: """Handle get commands.""" param = param.strip() logger.debug(f"_cmd_get called with param: '{param}' (len={len(param)})") - + if param == "af": - af = self.repeater_config.get('airtime_factor', 1.0) + af = self.repeater_config.get("airtime_factor", 1.0) return f"> {af}" - + elif param == "name": - name = self.repeater_config.get('name', 'Unknown') + name = self.repeater_config.get("name", "Unknown") return f"> {name}" - + elif param == "repeat": - disabled = self.repeater_config.get('disable_forward', False) + disabled = self.repeater_config.get("disable_forward", False) return f"> {'off' if disabled else 'on'}" - + elif param == "lat": - lat = self.repeater_config.get('latitude', 0.0) + lat = self.repeater_config.get("latitude", 0.0) return f"> {lat}" - + elif param == "lon": - lon = self.repeater_config.get('longitude', 0.0) + lon = self.repeater_config.get("longitude", 0.0) return f"> {lon}" - + elif param == "radio": - radio = self.config.get('radio', {}) - freq_hz = radio.get('frequency', 915000000) - bw_hz = radio.get('bandwidth', 125000) - sf = radio.get('spreading_factor', 7) - cr = radio.get('coding_rate', 5) + radio = self.config.get("radio", {}) + freq_hz = radio.get("frequency", 915000000) + bw_hz = radio.get("bandwidth", 125000) + sf = radio.get("spreading_factor", 7) + cr = radio.get("coding_rate", 5) # Convert Hz to MHz for freq, Hz to kHz for bandwidth (match C++ ftoa output) freq_mhz = freq_hz / 1_000_000.0 bw_khz = bw_hz / 1_000.0 return f"> {freq_mhz},{bw_khz},{sf},{cr}" - + elif param == "freq": - freq_hz = self.config.get('radio', {}).get('frequency', 915000000) + freq_hz = self.config.get("radio", {}).get("frequency", 915000000) freq_mhz = freq_hz / 1_000_000.0 return f"> {freq_mhz}" - + elif param == "tx": - power = self.config.get('radio', {}).get('tx_power', 20) + power = self.config.get("radio", {}).get("tx_power", 20) return f"> {power}" - + elif param == "public.key": # TODO: Get from identity return "Error: Not yet implemented" @@ -278,51 +280,51 @@ class MeshCLI: elif param == "role": role = "room_server" if self.identity_type == "room_server" else "repeater" return f"> {role}" - + elif param == "guest.password": - guest_pw = self.config.get('security', {}).get('guest_password', '') + guest_pw = self.config.get("security", {}).get("guest_password", "") return f"> {guest_pw}" - + elif param == "allow.read.only": - allow = self.config.get('security', {}).get('allow_read_only', False) + allow = self.config.get("security", {}).get("allow_read_only", False) return f"> {'on' if allow else 'off'}" - + elif param == "advert.interval": - interval = self.repeater_config.get('advert_interval_minutes', 120) + interval = self.repeater_config.get("advert_interval_minutes", 120) return f"> {interval}" - + elif param == "flood.advert.interval": - interval = self.repeater_config.get('flood_advert_interval_hours', 24) + interval = self.repeater_config.get("flood_advert_interval_hours", 24) return f"> {interval}" - + elif param == "flood.max": - max_flood = self.repeater_config.get('max_flood_hops', 3) + max_flood = self.repeater_config.get("max_flood_hops", 3) return f"> {max_flood}" - + elif param == "rxdelay": - delay = self.repeater_config.get('rx_delay_base', 0.0) + delay = self.repeater_config.get("rx_delay_base", 0.0) return f"> {delay}" - + elif param == "txdelay": - delay = self.repeater_config.get('tx_delay_factor', 1.0) + delay = self.repeater_config.get("tx_delay_factor", 1.0) return f"> {delay}" - + elif param == "direct.txdelay": - delay = self.repeater_config.get('direct_tx_delay_factor', 0.5) + delay = self.repeater_config.get("direct_tx_delay_factor", 0.5) return f"> {delay}" - + elif param == "multi.acks": - acks = self.repeater_config.get('multi_acks', 0) + acks = self.repeater_config.get("multi_acks", 0) return f"> {acks}" - + elif param == "int.thresh": - thresh = self.repeater_config.get('interference_threshold', -120) + thresh = self.repeater_config.get("interference_threshold", -120) return f"> {thresh}" - + elif param == "agc.reset.interval": - interval = self.repeater_config.get('agc_reset_interval', 0) + interval = self.repeater_config.get("agc_reset_interval", 0) return f"> {interval}" - + else: return f"??: {param}" @@ -335,144 +337,144 @@ class MeshCLI: return "Error: Missing value" key, value = parts[0], parts[1] - + try: if key == "af": - self.repeater_config['airtime_factor'] = float(value) + self.repeater_config["airtime_factor"] = float(value) self.save_config() return "OK" - + elif key == "name": - self.repeater_config['name'] = value + self.repeater_config["name"] = value self.save_config() return "OK" - + elif key == "repeat": disabled = value.lower() == "off" - self.repeater_config['disable_forward'] = disabled + self.repeater_config["disable_forward"] = disabled self.save_config() return f"OK - repeat is now {'OFF' if disabled else 'ON'}" - + elif key == "lat": - self.repeater_config['latitude'] = float(value) + self.repeater_config["latitude"] = float(value) self.save_config() return "OK" - + elif key == "lon": - self.repeater_config['longitude'] = float(value) + self.repeater_config["longitude"] = float(value) self.save_config() return "OK" - + elif key == "radio": # Format: freq bw sf cr radio_parts = value.split() if len(radio_parts) != 4: return "Error: Expected freq bw sf cr" - - if 'radio' not in self.config: - self.config['radio'] = {} - - self.config['radio']['frequency'] = float(radio_parts[0]) - self.config['radio']['bandwidth'] = float(radio_parts[1]) - self.config['radio']['spreading_factor'] = int(radio_parts[2]) - self.config['radio']['coding_rate'] = int(radio_parts[3]) + + if "radio" not in self.config: + self.config["radio"] = {} + + self.config["radio"]["frequency"] = float(radio_parts[0]) + self.config["radio"]["bandwidth"] = float(radio_parts[1]) + self.config["radio"]["spreading_factor"] = int(radio_parts[2]) + self.config["radio"]["coding_rate"] = int(radio_parts[3]) self.save_config() return "OK - restart repeater to apply" - + elif key == "freq": - if 'radio' not in self.config: - self.config['radio'] = {} - self.config['radio']['frequency'] = float(value) + if "radio" not in self.config: + self.config["radio"] = {} + self.config["radio"]["frequency"] = float(value) self.save_config() return "OK - restart repeater to apply" - + elif key == "tx": - if 'radio' not in self.config: - self.config['radio'] = {} - self.config['radio']['tx_power'] = int(value) + if "radio" not in self.config: + self.config["radio"] = {} + self.config["radio"]["tx_power"] = int(value) self.save_config() return "OK" - + elif key == "guest.password": - if 'security' not in self.config: - self.config['security'] = {} - self.config['security']['guest_password'] = value + if "security" not in self.config: + self.config["security"] = {} + self.config["security"]["guest_password"] = value self.save_config() return "OK" - + elif key == "allow.read.only": - if 'security' not in self.config: - self.config['security'] = {} - self.config['security']['allow_read_only'] = value.lower() == "on" + if "security" not in self.config: + self.config["security"] = {} + self.config["security"]["allow_read_only"] = value.lower() == "on" self.save_config() return "OK" - + elif key == "advert.interval": mins = int(value) if mins > 0 and (mins < 60 or mins > 240): return "Error: interval range is 60-240 minutes" - self.repeater_config['advert_interval_minutes'] = mins + self.repeater_config["advert_interval_minutes"] = mins self.save_config() return "OK" - + elif key == "flood.advert.interval": hours = int(value) if (hours > 0 and hours < 3) or hours > 48: return "Error: interval range is 3-48 hours" - self.repeater_config['flood_advert_interval_hours'] = hours + self.repeater_config["flood_advert_interval_hours"] = hours self.save_config() return "OK" - + elif key == "flood.max": max_val = int(value) if max_val > 64: return "Error: max 64" - self.repeater_config['max_flood_hops'] = max_val + self.repeater_config["max_flood_hops"] = max_val self.save_config() return "OK" - + elif key == "rxdelay": delay = float(value) if delay < 0: return "Error: cannot be negative" - self.repeater_config['rx_delay_base'] = delay + self.repeater_config["rx_delay_base"] = delay self.save_config() return "OK" - + elif key == "txdelay": delay = float(value) if delay < 0: return "Error: cannot be negative" - self.repeater_config['tx_delay_factor'] = delay + self.repeater_config["tx_delay_factor"] = delay self.save_config() return "OK" - + elif key == "direct.txdelay": delay = float(value) if delay < 0: return "Error: cannot be negative" - self.repeater_config['direct_tx_delay_factor'] = delay + self.repeater_config["direct_tx_delay_factor"] = delay self.save_config() return "OK" - + elif key == "multi.acks": - self.repeater_config['multi_acks'] = int(value) + self.repeater_config["multi_acks"] = int(value) self.save_config() return "OK" - + elif key == "int.thresh": - self.repeater_config['interference_threshold'] = int(value) + self.repeater_config["interference_threshold"] = int(value) self.save_config() return "OK" - + elif key == "agc.reset.interval": interval = int(value) # Round to nearest multiple of 4 rounded = (interval // 4) * 4 - self.repeater_config['agc_reset_interval'] = rounded + self.repeater_config["agc_reset_interval"] = rounded self.save_config() return f"OK - interval rounded to {rounded}" - + else: return f"unknown config: {key}" diff --git a/repeater/handler_helpers/room_server.py b/repeater/handler_helpers/room_server.py index 01e0ebd..f65b0c6 100644 --- a/repeater/handler_helpers/room_server.py +++ b/repeater/handler_helpers/room_server.py @@ -1,9 +1,9 @@ import asyncio import logging import time -from typing import Optional, Dict +from typing import Dict, Optional -from pymc_core.protocol import PacketBuilder, CryptoUtils +from pymc_core.protocol import CryptoUtils, PacketBuilder from pymc_core.protocol.constants import PAYLOAD_TYPE_TXT_MSG logger = logging.getLogger("RoomServer") @@ -51,7 +51,7 @@ class GlobalRateLimiter: self.min_gap = min_gap_seconds # Minimum gap between consecutive messages self.lock = asyncio.Lock() # Only one transmission at a time self.last_release_time = 0 - + async def acquire(self): async with self.lock: @@ -64,7 +64,7 @@ class GlobalRateLimiter: await asyncio.sleep(wait_time) # Lock is now held - caller can transmit # Will be released when context exits - + def release(self): self.last_release_time = time.time() @@ -82,43 +82,48 @@ class RoomServer: max_posts: int = 32, config_path: str = None, config: dict = None, - config_manager = None, - send_advert_callback = None + config_manager=None, + send_advert_callback=None, ): - + self.room_hash = room_hash self.room_name = room_name self.local_identity = local_identity self.db = sqlite_handler self.packet_injector = packet_injector self.acl = acl - + # Create send_advert callback for this room server async def send_room_advert(): """Send advertisement for this specific room server.""" if not packet_injector or not local_identity: - logger.error(f"Room '{room_name}': Cannot send advert - missing injector or identity") + logger.error( + f"Room '{room_name}': Cannot send advert - missing injector or identity" + ) return False - + try: from pymc_core.protocol import PacketBuilder - from pymc_core.protocol.constants import ADVERT_FLAG_HAS_NAME, ADVERT_FLAG_IS_ROOM_SERVER - + from pymc_core.protocol.constants import ( + ADVERT_FLAG_HAS_NAME, + ADVERT_FLAG_IS_ROOM_SERVER, + ) + # Get room config - room_config = config.get('identities', {}).get('room_servers', []) + room_config = config.get("identities", {}).get("room_servers", []) room_settings = {} for rs in room_config: - if rs.get('name') == room_name: - room_settings = rs.get('settings', {}) + if rs.get("name") == room_name: + room_settings = rs.get("settings", {}) break - + # Use room-specific name and location - node_name = room_settings.get('room_name', room_name) - latitude = room_settings.get('latitude', 0.0) - longitude = room_settings.get('longitude', 0.0) - + node_name = room_settings.get("room_name", room_name) + latitude = room_settings.get("latitude", 0.0) + longitude = room_settings.get("longitude", 0.0) + flags = ADVERT_FLAG_IS_ROOM_SERVER | ADVERT_FLAG_HAS_NAME - + packet = PacketBuilder.create_advert( local_identity=local_identity, name=node_name, @@ -129,21 +134,24 @@ class RoomServer: flags=flags, route_type="flood", ) - + # Send via packet injector await packet_injector(packet, wait_for_ack=False) - - logger.info(f"Room '{room_name}': Sent flood advert '{node_name}' at ({latitude:.6f}, {longitude:.6f})") + + logger.info( + f"Room '{room_name}': Sent flood advert '{node_name}' at ({latitude:.6f}, {longitude:.6f})" + ) return True - + except Exception as e: logger.error(f"Room '{room_name}': Failed to send advert: {e}", exc_info=True) return False - + # Initialize CLI handler for room server commands self.cli = None if config_path and config and config_manager: from .mesh_cli import MeshCLI + self.cli = MeshCLI( config_path, config, @@ -152,10 +160,10 @@ class RoomServer: enable_regions=False, # Room servers don't support region commands send_advert_callback=send_room_advert, identity=local_identity, - storage_handler=sqlite_handler + storage_handler=sqlite_handler, ) logger.info(f"Room '{room_name}': Initialized CLI handler with identity and storage") - + # Enforce hard limit (match C++ MAX_UNSYNCED_POSTS) if max_posts > MAX_UNSYNCED_POSTS: logger.warning( @@ -164,45 +172,45 @@ class RoomServer: ) max_posts = MAX_UNSYNCED_POSTS self.max_posts = max_posts - + # Round-robin state self.next_client_idx = 0 self.next_push_time = 0 - + # Cleanup tracking self.last_cleanup_time = time.time() self.cleanup_interval = 600 # Cleanup every 10 minutes - + # Safety and monitoring self.client_post_times = {} # Track last N post times per client for rate limiting self.consecutive_sync_errors = 0 # Circuit breaker counter self.last_eviction_check = time.time() self.eviction_check_interval = 300 # Check every 5 minutes - + # Initialize global rate limiter (singleton) global _global_push_limiter if _global_push_limiter is None: _global_push_limiter = GlobalRateLimiter(GLOBAL_MIN_GAP_BETWEEN_MESSAGES) self.global_limiter = _global_push_limiter - + # Background task handle self._sync_task = None self._running = False - + logger.info( f"RoomServer initialized: name='{room_name}', " f"hash=0x{room_hash:02X}, max_posts={max_posts}" ) - + async def start(self): if self._running: logger.warning(f"Room '{self.room_name}' sync loop already running") return - + self._running = True self._sync_task = asyncio.create_task(self._sync_loop()) logger.info(f"Room '{self.room_name}' sync loop started") - + async def stop(self): self._running = False if self._sync_task: @@ -212,14 +220,14 @@ class RoomServer: except asyncio.CancelledError: pass logger.info(f"Room '{self.room_name}' sync loop stopped") - + async def add_post( self, client_pubkey: bytes, message_text: str, sender_timestamp: int, txt_type: int = TXT_TYPE_PLAIN, - allow_server_author: bool = False + allow_server_author: bool = False, ) -> bool: try: @@ -230,20 +238,19 @@ class RoomServer: f"exceeds max length ({len(message_text)} > {MAX_MESSAGE_LENGTH}), truncating" ) message_text = message_text[:MAX_MESSAGE_LENGTH] - + # SAFETY: Rate limit per client client_key = client_pubkey.hex() now = time.time() - + if client_key not in self.client_post_times: self.client_post_times[client_key] = [] - + # Remove timestamps older than 1 minute self.client_post_times[client_key] = [ - t for t in self.client_post_times[client_key] - if now - t < 60 + t for t in self.client_post_times[client_key] if now - t < 60 ] - + # Check rate limit if len(self.client_post_times[client_key]) >= MAX_POSTS_PER_CLIENT_PER_MINUTE: logger.warning( @@ -251,13 +258,13 @@ class RoomServer: f"exceeded rate limit ({MAX_POSTS_PER_CLIENT_PER_MINUTE} posts/min), dropping message" ) return False - + # Record this post time self.client_post_times[client_key].append(now) - + # Use our RTC time for post_timestamp post_timestamp = time.time() - + # Store to database msg_id = self.db.insert_room_message( room_hash=f"0x{self.room_hash:02X}", @@ -265,22 +272,22 @@ class RoomServer: message_text=message_text, post_timestamp=post_timestamp, sender_timestamp=sender_timestamp, - txt_type=txt_type + txt_type=txt_type, ) - + if msg_id: logger.info( f"Room '{self.room_name}': New post #{msg_id} from " f"{client_pubkey[:4].hex()}: {message_text[:50]}" ) - + # Log authenticated clients count for debugging distribution all_clients = self.acl.get_all_clients() logger.info( f"Room '{self.room_name}': Message stored, will distribute to " f"{len(all_clients)} authenticated client(s)" ) - + # Update client's sync_since to this message's timestamp # This prevents the author from receiving their own message back # Also update activity timestamp (they're clearly active if posting) @@ -292,43 +299,43 @@ class RoomServer: room_hash=f"0x{self.room_hash:02X}", client_pubkey=client_pubkey.hex(), sync_since=post_timestamp, # Don't send this message back to author - last_activity=time.time() + last_activity=time.time(), ) - + # Trigger push notification self.next_push_time = time.time() + (PUSH_NOTIFY_DELAY_MS / 1000.0) - + return True else: logger.error(f"Failed to store message to database") return False - + except Exception as e: logger.error(f"Error adding post: {e}", exc_info=True) return False - + async def push_post_to_client(self, client_info, post: Dict) -> bool: - + try: # SAFETY: Global transmission lock - only ONE message on radio at a time # This is critical because LoRa is serial (0.5-9s airtime per message) await self.global_limiter.acquire() - + # SAFETY: Check client failure backoff sync_state = self.db.get_client_sync( room_hash=f"0x{self.room_hash:02X}", - client_pubkey=client_info.id.get_public_key().hex() + client_pubkey=client_info.id.get_public_key().hex(), ) - + if sync_state: - failures = sync_state.get('push_failures', 0) + failures = sync_state.get("push_failures", 0) if failures > 0: # Apply exponential backoff backoff_idx = min(failures, len(RETRY_BACKOFF_SCHEDULE) - 1) backoff_delay = RETRY_BACKOFF_SCHEDULE[backoff_idx] - last_failure_time = sync_state.get('updated_at', 0) + last_failure_time = sync_state.get("updated_at", 0) time_since_failure = time.time() - last_failure_time - + if time_since_failure < backoff_delay: wait_time = backoff_delay - time_since_failure logger.debug( @@ -336,33 +343,30 @@ class RoomServer: f"in backoff (failure {failures}), waiting {wait_time:.0f}s" ) return False # Skip this client for now - + # Build message payload timestamp = int(time.time()) - flags = (TXT_TYPE_SIGNED_PLAIN << 2) # Include author prefix - + flags = TXT_TYPE_SIGNED_PLAIN << 2 # Include author prefix + # Author prefix (first 4 bytes of pubkey) - author_pubkey = bytes.fromhex(post['author_pubkey']) + author_pubkey = bytes.fromhex(post["author_pubkey"]) author_prefix = author_pubkey[:4] - + # Plaintext: timestamp(4) + flags(1) + author_prefix(4) + text - message_bytes = post['message_text'].encode('utf-8') + message_bytes = post["message_text"].encode("utf-8") plaintext = ( - timestamp.to_bytes(4, 'little') + - bytes([flags]) + - author_prefix + - message_bytes + timestamp.to_bytes(4, "little") + bytes([flags]) + author_prefix + message_bytes ) - + # Calculate expected ACK (same algorithm as pymc_core) attempt = 0 pack_data = PacketBuilder._pack_timestamp_data(timestamp, attempt, message_bytes) ack_hash = CryptoUtils.sha256(pack_data + client_info.id.get_public_key())[:4] - expected_ack_crc = int.from_bytes(ack_hash, 'little') - + expected_ack_crc = int.from_bytes(ack_hash, "little") + # Determine routing based on stored out_path route_type = "flood" if client_info.out_path_len < 0 else "direct" - + # Create datagram packet = PacketBuilder.create_datagram( ptype=PAYLOAD_TYPE_TXT_MSG, @@ -370,41 +374,42 @@ class RoomServer: local_identity=self.local_identity, secret=client_info.shared_secret, plaintext=plaintext, - route_type=route_type + route_type=route_type, ) - + # Add stored path for direct routing if route_type == "direct" and len(client_info.out_path) > 0: - packet.path = bytearray(client_info.out_path[:client_info.out_path_len]) + packet.path = bytearray(client_info.out_path[: client_info.out_path_len]) packet.path_len = client_info.out_path_len - + # Calculate ACK timeout if route_type == "flood": ack_timeout = PUSH_ACK_TIMEOUT_FLOOD_MS / 1000.0 else: path_len = client_info.out_path_len if client_info.out_path_len >= 0 else 0 - ack_timeout = (PUSH_TIMEOUT_BASE_MS + PUSH_ACK_TIMEOUT_FACTOR_MS * (path_len + 1)) / 1000.0 - + ack_timeout = ( + PUSH_TIMEOUT_BASE_MS + PUSH_ACK_TIMEOUT_FACTOR_MS * (path_len + 1) + ) / 1000.0 + # Update client sync state with pending ACK self.db.upsert_client_sync( room_hash=f"0x{self.room_hash:02X}", client_pubkey=client_info.id.get_public_key().hex(), pending_ack_crc=expected_ack_crc, - push_post_timestamp=post['post_timestamp'], - ack_timeout_time=time.time() + ack_timeout + push_post_timestamp=post["post_timestamp"], + ack_timeout_time=time.time() + ack_timeout, ) # Send packet (dispatcher will track ACK automatically) # This blocks for the entire transmission duration (0.5-9 seconds) success = await self.packet_injector(packet, wait_for_ack=True) - + # SAFETY: Release transmission lock AFTER send completes self.global_limiter.release() - + if success: # ACK received! Update sync state await self._handle_ack_received( - client_info.id.get_public_key(), - post['post_timestamp'] + client_info.id.get_public_key(), post["post_timestamp"] ) logger.info( f"Room '{self.room_name}': Pushed post to " @@ -417,13 +422,13 @@ class RoomServer: f"Room '{self.room_name}': Push to " f"0x{client_info.id.get_public_key()[0]:02X} timed out" ) - + return success - + except Exception as e: logger.error(f"Error pushing post to client: {e}", exc_info=True) return False - + async def _handle_ack_received(self, client_pubkey: bytes, post_timestamp: float): try: @@ -434,29 +439,28 @@ class RoomServer: sync_since=post_timestamp, pending_ack_crc=0, push_failures=0, - last_activity=time.time() + last_activity=time.time(), ) except Exception as e: logger.error(f"Error handling ACK received: {e}") - + async def _handle_ack_timeout(self, client_pubkey: bytes): try: # Get current sync state sync_state = self.db.get_client_sync( - room_hash=f"0x{self.room_hash:02X}", - client_pubkey=client_pubkey.hex() + room_hash=f"0x{self.room_hash:02X}", client_pubkey=client_pubkey.hex() ) - + if sync_state: # Increment failure counter, clear pending_ack - failures = sync_state.get('push_failures', 0) + 1 + failures = sync_state.get("push_failures", 0) + 1 self.db.upsert_client_sync( room_hash=f"0x{self.room_hash:02X}", client_pubkey=client_pubkey.hex(), push_failures=failures, - pending_ack_crc=0 + pending_ack_crc=0, ) - + if failures >= 3: logger.warning( f"Room '{self.room_name}': Client 0x{client_pubkey[0]:02X} " @@ -464,86 +468,86 @@ class RoomServer: ) except Exception as e: logger.error(f"Error handling ACK timeout: {e}") - + def get_unsynced_count(self, client_pubkey: bytes) -> int: try: # Get client's sync state sync_state = self.db.get_client_sync( - room_hash=f"0x{self.room_hash:02X}", - client_pubkey=client_pubkey.hex() + room_hash=f"0x{self.room_hash:02X}", client_pubkey=client_pubkey.hex() ) - - sync_since = sync_state['sync_since'] if sync_state else 0 - + + sync_since = sync_state["sync_since"] if sync_state else 0 + return self.db.get_unsynced_count( room_hash=f"0x{self.room_hash:02X}", client_pubkey=client_pubkey.hex(), - sync_since=sync_since + sync_since=sync_since, ) except Exception as e: logger.error(f"Error getting unsynced count: {e}") return 0 - + async def _evict_failed_clients(self): try: now = time.time() all_sync_states = self.db.get_all_room_clients(f"0x{self.room_hash:02X}") - + for sync_state in all_sync_states: - client_pubkey_hex = sync_state['client_pubkey'] - push_failures = sync_state.get('push_failures', 0) - last_activity = sync_state.get('last_activity', 0) - + client_pubkey_hex = sync_state["client_pubkey"] + push_failures = sync_state.get("push_failures", 0) + last_activity = sync_state.get("last_activity", 0) + # Skip already-evicted clients (marked with last_activity=0) if last_activity == 0: continue - + evict = False reason = "" - + # Check max failures if push_failures >= MAX_PUSH_FAILURES: evict = True reason = f"max failures ({push_failures})" - + # Check inactivity timeout elif now - last_activity > INACTIVE_CLIENT_TIMEOUT: evict = True reason = f"inactive for {(now - last_activity) / 60:.0f} minutes" - + if evict: # Remove from database self.db.upsert_client_sync( room_hash=f"0x{self.room_hash:02X}", client_pubkey=client_pubkey_hex, - last_activity=0 # Mark as evicted + last_activity=0, # Mark as evicted ) - + # Remove from ACL client_pubkey = bytes.fromhex(client_pubkey_hex) self.acl.remove_client(client_pubkey) - + logger.info( f"Room '{self.room_name}': Evicted client " f"0x{client_pubkey[0]:02X} ({reason})" ) - + except Exception as e: logger.error(f"Error evicting failed clients: {e}", exc_info=True) - + async def _sync_loop(self): # SAFETY: Stagger room startup to prevent thundering herd import random + startup_delay = random.uniform(0, 5) # 0-5 second random delay await asyncio.sleep(startup_delay) - + logger.info(f"Room '{self.room_name}' sync loop starting (delayed {startup_delay:.1f}s)") - + while self._running: try: await asyncio.sleep(SYNC_PUSH_INTERVAL_MS / 1000.0) - + # SAFETY: Circuit breaker - stop if too many consecutive errors if self.consecutive_sync_errors >= MAX_CONSECUTIVE_SYNC_ERRORS: logger.error( @@ -553,21 +557,21 @@ class RoomServer: await asyncio.sleep(DB_ERROR_RETRY_DELAY) self.consecutive_sync_errors = 0 # Reset after pause continue - + # SAFETY: Periodic eviction check (every 5 minutes) if time.time() - self.last_eviction_check > self.eviction_check_interval: await self._evict_failed_clients() self.last_eviction_check = time.time() - + # Periodic cleanup check (every 10 minutes) if time.time() - self.last_cleanup_time > self.cleanup_interval: await self._cleanup_old_messages() self.last_cleanup_time = time.time() - + # Check if it's time to push if time.time() < self.next_push_time: continue - + # Get all clients for this room all_clients = self.acl.get_all_clients() if not all_clients: @@ -575,60 +579,66 @@ class RoomServer: # to avoid log spam when room is idle self.next_push_time = time.time() + 1.0 # Check again in 1 second continue - + # SAFETY: Limit number of clients if len(all_clients) > MAX_CLIENTS_PER_ROOM: logger.warning( f"Room '{self.room_name}': Too many clients ({len(all_clients)} > {MAX_CLIENTS_PER_ROOM})" ) all_clients = all_clients[:MAX_CLIENTS_PER_ROOM] - + # Check for ACK timeouts first await self._check_ack_timeouts() - + # Track how many clients we've checked in this iteration clients_checked = 0 max_checks = len(all_clients) - + # Round-robin: find next active client while clients_checked < max_checks: # Get next client if self.next_client_idx >= len(all_clients): self.next_client_idx = 0 - + client = all_clients[self.next_client_idx] self.next_client_idx = (self.next_client_idx + 1) % len(all_clients) clients_checked += 1 - + # Get client sync state sync_state = self.db.get_client_sync( room_hash=f"0x{self.room_hash:02X}", - client_pubkey=client.id.get_public_key().hex() + client_pubkey=client.id.get_public_key().hex(), ) - + # Skip if already waiting for ACK, evicted, or max failures if sync_state: - pending_ack = sync_state.get('pending_ack_crc', 0) - last_activity = sync_state.get('last_activity', 0) - push_failures = sync_state.get('push_failures', 0) - + pending_ack = sync_state.get("pending_ack_crc", 0) + last_activity = sync_state.get("last_activity", 0) + push_failures = sync_state.get("push_failures", 0) + if pending_ack != 0: - logger.debug(f"Skipping client 0x{client.id.get_public_key()[0]:02X} (waiting for ACK)") + logger.debug( + f"Skipping client 0x{client.id.get_public_key()[0]:02X} (waiting for ACK)" + ) continue - + if last_activity == 0: - logger.debug(f"Skipping client 0x{client.id.get_public_key()[0]:02X} (evicted)") + logger.debug( + f"Skipping client 0x{client.id.get_public_key()[0]:02X} (evicted)" + ) continue - + if push_failures >= 3: - logger.debug(f"Skipping client 0x{client.id.get_public_key()[0]:02X} (max failures)") + logger.debug( + f"Skipping client 0x{client.id.get_public_key()[0]:02X} (max failures)" + ) continue - - sync_since = sync_state.get('sync_since', 0) + + sync_since = sync_state.get("sync_since", 0) else: # Initialize sync state for new client # Use sync_since from ACL client (sent during login) if available - sync_since = client.sync_since if hasattr(client, 'sync_since') else 0 + sync_since = client.sync_since if hasattr(client, "sync_since") else 0 logger.info( f"Room '{self.room_name}': Initializing client " f"0x{client.id.get_public_key()[0]:02X} with sync_since={sync_since}" @@ -637,17 +647,17 @@ class RoomServer: room_hash=f"0x{self.room_hash:02X}", client_pubkey=client.id.get_public_key().hex(), sync_since=sync_since, - last_activity=time.time() + last_activity=time.time(), ) - + # Find next unsynced message for this client unsynced = self.db.get_unsynced_messages( room_hash=f"0x{self.room_hash:02X}", client_pubkey=client.id.get_public_key().hex(), sync_since=sync_since, - limit=1 + limit=1, ) - + if unsynced: post = unsynced[0] logger.debug( @@ -656,7 +666,7 @@ class RoomServer: ) # Check if enough time has passed since post creation now = time.time() - if now >= post['post_timestamp'] + POST_SYNC_DELAY_SECS: + if now >= post["post_timestamp"] + POST_SYNC_DELAY_SECS: # Push this post await self.push_post_to_client(client, post) self.next_push_time = time.time() + (SYNC_PUSH_INTERVAL_MS / 1000.0) @@ -668,15 +678,15 @@ class RoomServer: else: # No unsynced posts for this client, try next client continue - + # If we checked all clients and none were active/ready if clients_checked >= max_checks: # All clients skipped or no messages - wait longer before next check self.next_push_time = time.time() + 5.0 # Wait 5 seconds - + # SAFETY: Reset error counter on successful iteration self.consecutive_sync_errors = 0 - + except asyncio.CancelledError: break except Exception as e: @@ -684,35 +694,34 @@ class RoomServer: self.consecutive_sync_errors += 1 logger.error( f"Room '{self.room_name}': Sync loop error #{self.consecutive_sync_errors}: {e}", - exc_info=True + exc_info=True, ) - + # SAFETY: Back off on errors backoff = min(self.consecutive_sync_errors, 10) # Cap at 10 seconds await asyncio.sleep(backoff) - + logger.info(f"Room '{self.room_name}' sync loop stopped") - + async def _check_ack_timeouts(self): try: now = time.time() all_sync_states = self.db.get_all_room_clients(f"0x{self.room_hash:02X}") - + for sync_state in all_sync_states: - if sync_state['pending_ack_crc'] != 0: - timeout_time = sync_state.get('ack_timeout_time', 0) + if sync_state["pending_ack_crc"] != 0: + timeout_time = sync_state.get("ack_timeout_time", 0) if now >= timeout_time: # ACK timeout - client_pubkey = bytes.fromhex(sync_state['client_pubkey']) + client_pubkey = bytes.fromhex(sync_state["client_pubkey"]) await self._handle_ack_timeout(client_pubkey) except Exception as e: logger.error(f"Error checking ACK timeouts: {e}") - + async def _cleanup_old_messages(self): try: deleted = self.db.cleanup_old_messages( - room_hash=f"0x{self.room_hash:02X}", - keep_count=self.max_posts + room_hash=f"0x{self.room_hash:02X}", keep_count=self.max_posts ) if deleted > 0: logger.info(f"Room '{self.room_name}': Cleaned up {deleted} old messages") diff --git a/repeater/handler_helpers/text.py b/repeater/handler_helpers/text.py index d854817..9d6f59d 100644 --- a/repeater/handler_helpers/text.py +++ b/repeater/handler_helpers/text.py @@ -12,6 +12,7 @@ import struct import time from pymc_core.node.handlers.text import TextMessageHandler + from .mesh_cli import MeshCLI from .room_server import RoomServer @@ -24,9 +25,18 @@ TXT_TYPE_CLI_DATA = 0x01 class TextHelper: - def __init__(self, identity_manager, packet_injector=None, acl_dict=None, log_fn=None, - config_path: str = None, config: dict = None, config_manager=None, - sqlite_handler=None, send_advert_callback=None): + def __init__( + self, + identity_manager, + packet_injector=None, + acl_dict=None, + log_fn=None, + config_path: str = None, + config: dict = None, + config_manager=None, + sqlite_handler=None, + send_advert_callback=None, + ): self.identity_manager = identity_manager self.packet_injector = packet_injector @@ -34,47 +44,43 @@ class TextHelper: self.acl_dict = acl_dict or {} # Per-identity ACLs keyed by hash_byte self.sqlite_handler = sqlite_handler # For room server database operations self.send_advert_callback = send_advert_callback # Callback to send repeater advert - + # Dictionary of handlers keyed by dest_hash self.handlers = {} - + # Dictionary of room servers keyed by dest_hash self.room_servers = {} - + # Track repeater identity for CLI commands self.repeater_hash = None - + # Store config for later use self.config_path = config_path self.config = config self.config_manager = config_manager - + # Store for later CLI initialization (needs identity and storage) self.config_path = config_path self.config = config - + # Initialize CLI handler later when repeater identity is registered self.cli = None def register_identity( - self, - name: str, - identity, - identity_type: str = "room_server", - radio_config=None + self, name: str, identity, identity_type: str = "room_server", radio_config=None ): hash_byte = identity.get_public_key()[0] - + # Get ACL for this identity identity_acl = self.acl_dict.get(hash_byte) if not identity_acl: logger.warning(f"Cannot register identity '{name}': no ACL for hash 0x{hash_byte:02X}") return - + # Create a contacts wrapper from this identity's ACL acl_contacts = self._create_acl_contacts_wrapper(identity_acl) - + # Create TextMessageHandler for this identity handler = TextMessageHandler( local_identity=identity, @@ -83,7 +89,7 @@ class TextHelper: send_packet_fn=self._send_packet, radio_config=radio_config, ) - + # Register by dest hash hash_byte = identity.get_public_key()[0] self.handlers[hash_byte] = { @@ -92,12 +98,12 @@ class TextHelper: "name": name, "type": identity_type, } - + # Track repeater identity for CLI commands if identity_type == "repeater": self.repeater_hash = hash_byte logger.info(f"Set repeater hash for CLI: 0x{hash_byte:02X}") - + # Initialize CLI handler now that we have the repeater identity if self.config_path and self.config and self.config_manager: self.cli = MeshCLI( @@ -108,18 +114,20 @@ class TextHelper: enable_regions=True, send_advert_callback=self.send_advert_callback, identity=identity, - storage_handler=self.sqlite_handler + storage_handler=self.sqlite_handler, ) - logger.info("Initialized CLI handler for repeater commands with identity and storage") - + logger.info( + "Initialized CLI handler for repeater commands with identity and storage" + ) + # Create RoomServer instance for room_server identities if identity_type == "room_server" and self.sqlite_handler: try: from .room_server import MAX_UNSYNCED_POSTS - + room_config = radio_config or {} - max_posts = room_config.get('max_posts', MAX_UNSYNCED_POSTS) - + max_posts = room_config.get("max_posts", MAX_UNSYNCED_POSTS) + # Enforce hard limit if max_posts > MAX_UNSYNCED_POSTS: logger.warning( @@ -127,7 +135,7 @@ class TextHelper: f"of {MAX_UNSYNCED_POSTS}, capping to {MAX_UNSYNCED_POSTS}" ) max_posts = MAX_UNSYNCED_POSTS - + room_server = RoomServer( room_hash=hash_byte, room_name=name, @@ -138,31 +146,29 @@ class TextHelper: max_posts=max_posts, config_path=self.config_path, config=self.config, - config_manager=self.config_manager + config_manager=self.config_manager, ) - + self.room_servers[hash_byte] = room_server - + # Start sync loop asyncio.create_task(room_server.start()) - + logger.info( f"Registered room server '{name}': hash=0x{hash_byte:02X}, " f"max_posts={max_posts}" ) except Exception as e: logger.error(f"Failed to create room server '{name}': {e}", exc_info=True) - - logger.info( - f"Registered {identity_type} '{name}' text handler: hash=0x{hash_byte:02X}" - ) - + + logger.info(f"Registered {identity_type} '{name}' text handler: hash=0x{hash_byte:02X}") + def _create_acl_contacts_wrapper(self, acl): class ACLContactsWrapper: def __init__(self, identity_acl): self._acl = identity_acl - + @property def contacts(self): contact_list = [] @@ -172,10 +178,10 @@ class TextHelper: def __init__(self, client): self.public_key = client.id.get_public_key().hex() self.name = f"client_{self.public_key[:8]}" - + contact_list.append(ContactProxy(client_info)) return contact_list - + return ACLContactsWrapper(acl) async def process_text_packet(self, packet): @@ -183,20 +189,20 @@ class TextHelper: try: if len(packet.payload) < 2: return False - + dest_hash = packet.payload[0] src_hash = packet.payload[1] - + handler_info = self.handlers.get(dest_hash) if handler_info: logger.debug( f"Routing text message to '{handler_info['name']}': " f"dest=0x{dest_hash:02X}, src=0x{src_hash:02X}" ) - + # Let handler decrypt the message first await handler_info["handler"](packet) - + # Call placeholder for custom processing await self._on_message_received( identity_name=handler_info["name"], @@ -205,16 +211,14 @@ class TextHelper: dest_hash=dest_hash, src_hash=src_hash, ) - + # Mark packet as handled packet.mark_do_not_retransmit() return True else: - logger.debug( - f"No text handler for hash 0x{dest_hash:02X}, allowing forward" - ) + logger.debug(f"No text handler for hash 0x{dest_hash:02X}, allowing forward") return False - + except Exception as e: logger.error(f"Error processing text packet: {e}") return False @@ -230,128 +234,137 @@ class TextHelper: # Placeholder - can be overridden or callback can be added logger.debug( - f"Message received for {identity_type} '{identity_name}' " - f"from 0x{src_hash:02X}" + f"Message received for {identity_type} '{identity_name}' " f"from 0x{src_hash:02X}" ) - + # Extract decrypted message if available if hasattr(packet, "decrypted") and packet.decrypted: message_text = packet.decrypted.get("text", "") - + # Clean message text - remove null bytes and trailing whitespace - message_text = message_text.rstrip('\x00').rstrip() - - logger.info( - f"[{identity_type}:{identity_name}] Message: {message_text}" - ) - + message_text = message_text.rstrip("\x00").rstrip() + + logger.info(f"[{identity_type}:{identity_name}] Message: {message_text}") + # Handle room server messages if identity_type == "room_server" and dest_hash in self.room_servers: room_server = self.room_servers[dest_hash] - + # Check if this is a CLI command FIRST (before storing as post) if self._is_cli_command(message_text): # Handle CLI command - do NOT store as post if room_server and room_server.cli: try: # Check admin permission - is_admin = self._check_admin_permission_for_identity(src_hash, dest_hash) - + is_admin = self._check_admin_permission_for_identity( + src_hash, dest_hash + ) + if not is_admin: - logger.warning(f"Room '{identity_name}': CLI command denied from 0x{src_hash:02X} (not admin)") + logger.warning( + f"Room '{identity_name}': CLI command denied from 0x{src_hash:02X} (not admin)" + ) return - + # Get sender's full pubkey identity_acl = self.acl_dict.get(dest_hash) - sender_pubkey = bytes([src_hash]) + b'\x00' * 31 # Default + sender_pubkey = bytes([src_hash]) + b"\x00" * 31 # Default if identity_acl: for client_info in identity_acl.get_all_clients(): if client_info.id.get_public_key()[0] == src_hash: sender_pubkey = client_info.id.get_public_key() break - + # Handle CLI command reply = room_server.cli.handle_command( - sender_pubkey=sender_pubkey, - command=message_text, - is_admin=is_admin + sender_pubkey=sender_pubkey, command=message_text, is_admin=is_admin ) - - logger.info(f"Room '{identity_name}': CLI command from 0x{src_hash:02X}: {message_text[:50]} -> {reply[:100]}") - + + logger.info( + f"Room '{identity_name}': CLI command from 0x{src_hash:02X}: {message_text[:50]} -> {reply[:100]}" + ) + # Send reply back to sender handler_info = self.handlers.get(dest_hash) if handler_info: await self._send_cli_reply(packet, reply, handler_info) - + except Exception as e: - logger.error(f"Error processing room server CLI command: {e}", exc_info=True) - + logger.error( + f"Error processing room server CLI command: {e}", exc_info=True + ) + # CLI command handled, don't store as post return - + # NOT a CLI command - store as regular room post try: # Get sender's full pubkey identity_acl = self.acl_dict.get(dest_hash) - sender_pubkey = bytes([src_hash]) + b'\x00' * 31 # Default + sender_pubkey = bytes([src_hash]) + b"\x00" * 31 # Default if identity_acl: for client_info in identity_acl.get_all_clients(): if client_info.id.get_public_key()[0] == src_hash: sender_pubkey = client_info.id.get_public_key() break - + # Store message as post sender_timestamp = int(time.time()) success = await room_server.add_post( client_pubkey=sender_pubkey, message_text=message_text, sender_timestamp=sender_timestamp, - txt_type=TXT_TYPE_PLAIN + txt_type=TXT_TYPE_PLAIN, ) - + if success: - logger.info(f"Room '{identity_name}': New post from {sender_pubkey[:4].hex()}: {message_text[:50]}") - + logger.info( + f"Room '{identity_name}': New post from {sender_pubkey[:4].hex()}: {message_text[:50]}" + ) + except Exception as e: logger.error(f"Error storing room post: {e}", exc_info=True) - + return - + # Check if this is a CLI command to the repeater (AFTER decryption) if dest_hash == self.repeater_hash and self.cli and self._is_cli_command(message_text): try: # Check admin permission - is_admin = self._check_admin_permission_for_identity(src_hash, self.repeater_hash) - + is_admin = self._check_admin_permission_for_identity( + src_hash, self.repeater_hash + ) + # If not admin, log and return without sending reply if not is_admin: - logger.warning(f"CLI command denied from 0x{src_hash:02X} (not admin): {message_text[:50]}") + logger.warning( + f"CLI command denied from 0x{src_hash:02X} (not admin): {message_text[:50]}" + ) return - + # Get client for full public key repeater_acl = self.acl_dict.get(self.repeater_hash) - sender_pubkey = bytes([src_hash]) + b'\x00' * 31 # Default + sender_pubkey = bytes([src_hash]) + b"\x00" * 31 # Default if repeater_acl: for client_info in repeater_acl.get_all_clients(): if client_info.id.get_public_key()[0] == src_hash: sender_pubkey = client_info.id.get_public_key() break - + # Handle CLI command reply = self.cli.handle_command( - sender_pubkey=sender_pubkey, - command=message_text, - is_admin=is_admin + sender_pubkey=sender_pubkey, command=message_text, is_admin=is_admin ) - - logger.info(f"CLI command from 0x{src_hash:02X}: {message_text[:50]} -> {reply[:100]}") - + + logger.info( + f"CLI command from 0x{src_hash:02X}: {message_text[:50]} -> {reply[:100]}" + ) + # Send reply back to sender handler_info = self.handlers.get(dest_hash) if handler_info: await self._send_cli_reply(packet, reply, handler_info) - + except Exception as e: logger.error(f"Error processing CLI command: {e}", exc_info=True) @@ -381,7 +394,7 @@ class TextHelper: } for hash_byte, info in self.handlers.items() ] - + async def cleanup(self): """Cleanup room servers and handlers.""" # Stop all room server sync loops @@ -390,52 +403,68 @@ class TextHelper: await room_server.stop() except Exception as e: logger.error(f"Error stopping room server: {e}") - + logger.info("TextHelper cleanup complete") - + def _is_cli_command(self, message: str) -> bool: """Check if message looks like a CLI command.""" # Strip optional sequence prefix (XX|) - if len(message) > 4 and message[2] == '|': + if len(message) > 4 and message[2] == "|": message = message[3:].strip() - + # Check for known command prefixes command_prefixes = [ - "get ", "set ", "reboot", "advert", "clock", "time ", - "password ", "clear ", "ver", "board", "neighbors", "neighbor.", - "tempradio ", "setperm ", "region", "sensor ", "gps", "log ", - "stats-", "start ota" + "get ", + "set ", + "reboot", + "advert", + "clock", + "time ", + "password ", + "clear ", + "ver", + "board", + "neighbors", + "neighbor.", + "tempradio ", + "setperm ", + "region", + "sensor ", + "gps", + "log ", + "stats-", + "start ota", ] - + return any(message.startswith(prefix) for prefix in command_prefixes) - + def _check_admin_permission(self, src_hash: int) -> bool: """Check if sender has admin permissions for repeater (legacy method).""" return self._check_admin_permission_for_identity(src_hash, self.repeater_hash) - + def _check_admin_permission_for_identity(self, src_hash: int, identity_hash: int) -> bool: """Check if sender has admin permissions (bit 0x02) for a specific identity.""" # Get the identity's ACL identity_acl = self.acl_dict.get(identity_hash) if not identity_acl: return False - + # Get client by hash byte clients = identity_acl.get_all_clients() for client_info in clients: pubkey = client_info.id.get_public_key() if pubkey[0] == src_hash: # Check admin bit (0x02 = PERM_ACL_ADMIN) - permissions = getattr(client_info, 'permissions', 0) + permissions = getattr(client_info, "permissions", 0) PERM_ACL_ADMIN = 0x02 return (permissions & 0x02) == PERM_ACL_ADMIN - + return False - + async def _send_cli_reply(self, original_packet, reply_text: str, handler_info: dict): """ Send CLI reply back to sender using TXT_MSG datagram. - + Follows the C++ pattern (lines 603-609 in MyMesh.cpp): - Creates TXT_MSG datagram with TXT_TYPE_CLI_DATA flag - Encrypts with shared secret from ACL client @@ -443,77 +472,87 @@ class TextHelper: * if out_path_len < 0: sendFlood() * else: sendDirect() with stored out_path """ - from pymc_core.protocol import PacketBuilder, Identity - from pymc_core.protocol.constants import PAYLOAD_TYPE_TXT_MSG import time - + + from pymc_core.protocol import Identity, PacketBuilder + from pymc_core.protocol.constants import PAYLOAD_TYPE_TXT_MSG + try: src_hash = original_packet.payload[1] dest_hash = original_packet.payload[0] - + incoming_route = original_packet.get_route_type() - logger.debug(f"CLI reply: original packet dest=0x{dest_hash:02X}, src=0x{src_hash:02X}, incoming_route={incoming_route}") - + logger.debug( + f"CLI reply: original packet dest=0x{dest_hash:02X}, src=0x{src_hash:02X}, incoming_route={incoming_route}" + ) + # Find the client in the DESTINATION identity's ACL (not always repeater!) # dest_hash is the identity that received the command (repeater OR room server) identity_acl = self.acl_dict.get(dest_hash) if not identity_acl: logger.error(f"No ACL found for identity 0x{dest_hash:02X} for CLI reply") return - + client = None for client_info in identity_acl.get_all_clients(): pubkey = client_info.id.get_public_key() if pubkey[0] == src_hash: client = client_info break - + if not client: - logger.error(f"Client 0x{src_hash:02X} not found in identity 0x{dest_hash:02X} ACL for CLI reply") + logger.error( + f"Client 0x{src_hash:02X} not found in identity 0x{dest_hash:02X} ACL for CLI reply" + ) return - + # Get shared secret from client shared_secret = client.shared_secret if not shared_secret or len(shared_secret) == 0: logger.error(f"No shared secret for client 0x{src_hash:02X}") return - + # Build reply packet payload # Format: timestamp(4) + flags(1) + reply_text timestamp = int(time.time()) TXT_TYPE_CLI_DATA = 0x01 - flags = (TXT_TYPE_CLI_DATA << 2) # Upper 6 bits are txt_type - - reply_bytes = reply_text.encode('utf-8') - plaintext = timestamp.to_bytes(4, 'little') + bytes([flags]) + reply_bytes - + flags = TXT_TYPE_CLI_DATA << 2 # Upper 6 bits are txt_type + + reply_bytes = reply_text.encode("utf-8") + plaintext = timestamp.to_bytes(4, "little") + bytes([flags]) + reply_bytes + # Decide routing based on client->out_path_len (C++ pattern) # out_path is populated by PATH packets, NOT from incoming text message route route_type = "flood" if client.out_path_len < 0 else "direct" - logger.debug(f"CLI reply: client.out_path_len={client.out_path_len}, using route_type={route_type}") - + logger.debug( + f"CLI reply: client.out_path_len={client.out_path_len}, using route_type={route_type}" + ) + reply_packet = PacketBuilder.create_datagram( ptype=PAYLOAD_TYPE_TXT_MSG, dest=client.id, local_identity=handler_info["identity"], secret=shared_secret, plaintext=plaintext, - route_type=route_type + route_type=route_type, ) - - + # Add path for direct routing if available from PATH packets if client.out_path_len >= 0 and len(client.out_path) > 0: - reply_packet.path = bytearray(client.out_path[:client.out_path_len]) + reply_packet.path = bytearray(client.out_path[: client.out_path_len]) reply_packet.path_len = client.out_path_len - logger.debug(f"CLI reply: Added stored out_path - path_len={reply_packet.path_len}, path={[hex(b) for b in reply_packet.path]}") - + logger.debug( + f"CLI reply: Added stored out_path - path_len={reply_packet.path_len}, path={[hex(b) for b in reply_packet.path]}" + ) + # Send with delay (CLI_REPLY_DELAY_MILLIS = 600ms in C++) CLI_REPLY_DELAY_MS = 600 await asyncio.sleep(CLI_REPLY_DELAY_MS / 1000.0) - + await self._send_packet(reply_packet, wait_for_ack=False) - logger.info(f"CLI reply sent to 0x{src_hash:02X} via {route_type.upper()}: {reply_text[:50]}") - + logger.info( + f"CLI reply sent to 0x{src_hash:02X} via {route_type.upper()}: {reply_text[:50]}" + ) + except Exception as e: logger.error(f"Error sending CLI reply: {e}", exc_info=True) diff --git a/repeater/handler_helpers/trace.py b/repeater/handler_helpers/trace.py index 6b38658..9224d46 100644 --- a/repeater/handler_helpers/trace.py +++ b/repeater/handler_helpers/trace.py @@ -9,7 +9,7 @@ of packets through the mesh network. import asyncio import logging import time -from typing import Dict, Any +from typing import Any, Dict from pymc_core.hardware.signal_utils import snr_register_to_db from pymc_core.node.handlers.trace import TraceHandler @@ -34,10 +34,12 @@ class TraceHelper: self.local_hash = local_hash self.repeater_handler = repeater_handler self.packet_injector = packet_injector # Function to inject packets into router - + # Ping callback system - track pending ping requests by tag - self.pending_pings = {} # {tag: {'event': asyncio.Event(), 'result': dict, 'target': int, 'sent_at': float}} - + self.pending_pings = ( + {} + ) # {tag: {'event': asyncio.Event(), 'result': dict, 'target': int, 'sent_at': float}} + # Optional: when trace reaches final node, call this (packet, parsed_data) to push 0x89 to companions self.on_trace_complete = None # async (packet, parsed_data) -> None @@ -63,9 +65,7 @@ class TraceHelper: parsed_data = self.trace_handler._parse_trace_payload(packet.payload) if not parsed_data.get("valid", False): - logger.warning( - f"Invalid trace packet: {parsed_data.get('error', 'Unknown error')}" - ) + logger.warning(f"Invalid trace packet: {parsed_data.get('error', 'Unknown error')}") return trace_path = parsed_data["trace_path"] @@ -76,14 +76,14 @@ class TraceHelper: if trace_tag in self.pending_pings: ping_info = self.pending_pings[trace_tag] # Store response data - ping_info['result'] = { - 'path': trace_path, - 'snr': packet.get_snr(), - 'rssi': getattr(packet, "rssi", 0), - 'received_at': time.time() + ping_info["result"] = { + "path": trace_path, + "snr": packet.get_snr(), + "rssi": getattr(packet, "rssi", 0), + "received_at": time.time(), } # Signal the waiting coroutine - ping_info['event'].set() + ping_info["event"].set() logger.info(f"Ping response received for tag {trace_tag}") # Record the trace packet for dashboard/statistics @@ -149,27 +149,37 @@ class TraceHelper: # Add detailed SNR info if we have the corresponding hash if i < len(trace_path): - path_snr_details.append({ - "hash": f"{trace_path[i]:02X}", - "snr_raw": snr_val, - "snr_db": snr_db - }) + path_snr_details.append( + {"hash": f"{trace_path[i]:02X}", "snr_raw": snr_val, "snr_db": snr_db} + ) return { "timestamp": time.time(), - "header": f"0x{packet.header:02X}" if hasattr(packet, "header") and packet.header is not None else None, - "payload": packet.payload.hex() if hasattr(packet, "payload") and packet.payload else None, - "payload_length": len(packet.payload) if hasattr(packet, "payload") and packet.payload else 0, + "header": ( + f"0x{packet.header:02X}" + if hasattr(packet, "header") and packet.header is not None + else None + ), + "payload": ( + packet.payload.hex() if hasattr(packet, "payload") and packet.payload else None + ), + "payload_length": ( + len(packet.payload) if hasattr(packet, "payload") and packet.payload else 0 + ), "type": packet.get_payload_type(), # 0x09 for trace - "route": packet.get_route_type(), # Should be direct (1) + "route": packet.get_route_type(), # Should be direct (1) "length": len(packet.payload or b""), "rssi": getattr(packet, "rssi", 0), "snr": getattr(packet, "snr", 0.0), - "score": self.repeater_handler.calculate_packet_score( - getattr(packet, "snr", 0.0), - len(packet.payload or b""), - self.repeater_handler.radio_config.get("spreading_factor", 8) - ) if self.repeater_handler else 0.0, + "score": ( + self.repeater_handler.calculate_packet_score( + getattr(packet, "snr", 0.0), + len(packet.payload or b""), + self.repeater_handler.radio_config.get("spreading_factor", 8), + ) + if self.repeater_handler + else 0.0 + ), "tx_delay_ms": 0, "transmitted": False, "is_duplicate": False, @@ -226,21 +236,24 @@ class TraceHelper: True if the packet should be forwarded, False otherwise """ # Use the exact logic from the original working code - return (packet.path_len < trace_path_len and - len(trace_path) > packet.path_len and - trace_path[packet.path_len] == self.local_hash and - self.repeater_handler and not self.repeater_handler.is_duplicate(packet)) + return ( + packet.path_len < trace_path_len + and len(trace_path) > packet.path_len + and trace_path[packet.path_len] == self.local_hash + and self.repeater_handler + and not self.repeater_handler.is_duplicate(packet) + ) async def _forward_trace_packet(self, packet, trace_path_len: int) -> None: """ Forward a trace packet by appending SNR and sending via injection. - + Args: packet: The trace packet to forward trace_path_len: The length of the trace path """ # Update the packet record to show it will be transmitted - if self.repeater_handler and hasattr(self.repeater_handler, 'recent_packets'): + if self.repeater_handler and hasattr(self.repeater_handler, "recent_packets"): packet_hash = packet.calculate_packet_hash().hex().upper()[:16] for record in reversed(self.repeater_handler.recent_packets): if record.get("packet_hash") == packet_hash: @@ -293,41 +306,44 @@ class TraceHelper: elif len(trace_path) <= packet.path_len: logger.info("Path index out of bounds") elif trace_path[packet.path_len] != self.local_hash: - expected_hash = trace_path[packet.path_len] if packet.path_len < len(trace_path) else None + expected_hash = ( + trace_path[packet.path_len] if packet.path_len < len(trace_path) else None + ) logger.info(f"Not our turn (next hop: 0x{expected_hash:02x})") elif self.repeater_handler and self.repeater_handler.is_duplicate(packet): logger.info("Duplicate packet, ignoring") def register_ping(self, tag: int, target_hash: int) -> asyncio.Event: """Register a ping request and return an event to wait on. - + Args: tag: The unique trace tag for this ping target_hash: The hash of the target node - + Returns: asyncio.Event that will be set when response is received """ event = asyncio.Event() self.pending_pings[tag] = { - 'event': event, - 'result': None, - 'target': target_hash, - 'sent_at': time.time() + "event": event, + "result": None, + "target": target_hash, + "sent_at": time.time(), } logger.debug(f"Registered ping with tag {tag} for target 0x{target_hash:02x}") return event def cleanup_stale_pings(self, max_age_seconds: int = 30): """Remove pending pings older than max_age_seconds. - + Args: max_age_seconds: Maximum age in seconds before a ping is considered stale """ current_time = time.time() stale_tags = [ - tag for tag, info in self.pending_pings.items() - if current_time - info['sent_at'] > max_age_seconds + tag + for tag, info in self.pending_pings.items() + if current_time - info["sent_at"] > max_age_seconds ] for tag in stale_tags: self.pending_pings.pop(tag) diff --git a/repeater/identity_manager.py b/repeater/identity_manager.py index d626adf..d5dfe6c 100644 --- a/repeater/identity_manager.py +++ b/repeater/identity_manager.py @@ -1,20 +1,20 @@ import logging -from typing import Dict, Optional, Tuple, Any +from typing import Any, Dict, Optional, Tuple logger = logging.getLogger("IdentityManager") class IdentityManager: - + def __init__(self, config: dict): self.config = config self.identities: Dict[int, Tuple[Any, dict, str]] = {} self.named_identities: Dict[str, Tuple[Any, dict, str]] = {} self.registered_hashes: Dict[int, str] = {} - + def register_identity(self, name: str, identity, config: dict, identity_type: str): hash_byte = identity.get_public_key()[0] - + if hash_byte in self.identities: existing_name = self.registered_hashes.get(hash_byte, "unknown") logger.error( @@ -22,40 +22,42 @@ class IdentityManager: f"conflicts with existing identity '{existing_name}'" ) return False - + self.identities[hash_byte] = (identity, config, identity_type) self.named_identities[name] = (identity, config, identity_type) self.registered_hashes[hash_byte] = f"{identity_type}:{name}" - + logger.info( f"Identity registered: name={name}, hash=0x{hash_byte:02X}, type={identity_type}" ) return True - + def get_identity_by_hash(self, hash_byte: int) -> Optional[Tuple[Any, dict, str]]: return self.identities.get(hash_byte) - + def get_identity_by_name(self, name: str) -> Optional[Tuple[Any, dict, str]]: return self.named_identities.get(name) - + def has_identity(self, hash_byte: int) -> bool: return hash_byte in self.identities - + def list_identities(self) -> list: identities = [] for hash_byte, (identity, config, id_type) in self.identities.items(): name = self.registered_hashes.get(hash_byte, "unknown") - identities.append({ - "hash": f"0x{hash_byte:02X}", - "name": name, - "type": id_type, - "address": identity.get_address_bytes().hex() if identity else "N/A" - }) + identities.append( + { + "hash": f"0x{hash_byte:02X}", + "name": name, + "type": id_type, + "address": identity.get_address_bytes().hex() if identity else "N/A", + } + ) return identities - + def has_identity_type(self, identity_type: str) -> bool: return any(id_type == identity_type for _, _, id_type in self.identities.values()) - + def get_identities_by_type(self, identity_type: str) -> list: results = [] for name, (identity, config, id_type) in self.named_identities.items(): diff --git a/repeater/main.py b/repeater/main.py index 5e05575..00538ae 100644 --- a/repeater/main.py +++ b/repeater/main.py @@ -7,10 +7,18 @@ import time from repeater.config import get_radio_for_board, load_config from repeater.config_manager import ConfigManager from repeater.engine import RepeaterHandler -from repeater.web.http_server import HTTPStatsServer, _log_buffer -from repeater.handler_helpers import TraceHelper, DiscoveryHelper, AdvertHelper, LoginHelper, TextHelper, PathHelper, ProtocolRequestHelper -from repeater.packet_router import PacketRouter +from repeater.handler_helpers import ( + AdvertHelper, + DiscoveryHelper, + LoginHelper, + PathHelper, + ProtocolRequestHelper, + TextHelper, + TraceHelper, +) from repeater.identity_manager import IdentityManager +from repeater.packet_router import PacketRouter +from repeater.web.http_server import HTTPStatsServer, _log_buffer logger = logging.getLogger("RepeaterDaemon") @@ -40,7 +48,6 @@ class RepeaterDaemon: self.companion_bridges: dict[int, object] = {} self.companion_frame_servers: list = [] - log_level = config.get("logging", {}).get("level", "INFO") logging.basicConfig( level=getattr(logging, log_level), @@ -64,35 +71,35 @@ class RepeaterDaemon: if hasattr(self.radio, "set_event_loop"): self.radio.set_event_loop(asyncio.get_running_loop()) - if hasattr(self.radio, 'set_custom_cad_thresholds'): + if hasattr(self.radio, "set_custom_cad_thresholds"): # Load CAD settings from config, with defaults cad_config = self.config.get("radio", {}).get("cad", {}) peak_threshold = cad_config.get("peak_threshold", 23) min_threshold = cad_config.get("min_threshold", 11) - + self.radio.set_custom_cad_thresholds(peak=peak_threshold, min_val=min_threshold) - logger.info(f"CAD thresholds set from config: peak={peak_threshold}, min={min_threshold}") + logger.info( + f"CAD thresholds set from config: peak={peak_threshold}, min={min_threshold}" + ) else: logger.warning("Radio does not support CAD configuration") - - if hasattr(self.radio, 'get_frequency'): + if hasattr(self.radio, "get_frequency"): logger.info(f"Radio config - Freq: {self.radio.get_frequency():.1f}MHz") - if hasattr(self.radio, 'get_spreading_factor'): + if hasattr(self.radio, "get_spreading_factor"): logger.info(f"Radio config - SF: {self.radio.get_spreading_factor()}") - if hasattr(self.radio, 'get_bandwidth'): + if hasattr(self.radio, "get_bandwidth"): logger.info(f"Radio config - BW: {self.radio.get_bandwidth()}kHz") - if hasattr(self.radio, 'get_coding_rate'): + if hasattr(self.radio, "get_coding_rate"): logger.info(f"Radio config - CR: {self.radio.get_coding_rate()}") - if hasattr(self.radio, 'get_tx_power'): + if hasattr(self.radio, "get_tx_power"): logger.info(f"Radio config - TX Power: {self.radio.get_tx_power()}dBm") - + 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 - try: from pymc_core import LocalIdentity from pymc_core.node.dispatcher import Dispatcher @@ -116,7 +123,7 @@ class RepeaterDaemon: 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}") @@ -133,7 +140,7 @@ class RepeaterDaemon: # Create router self.router = PacketRouter(self) await self.router.start() - + # Register router as entry point for ALL packets via fallback handler # All received packets flow through router → helpers → repeater engine self.dispatcher.register_fallback_handler(self._router_callback) @@ -147,7 +154,7 @@ class RepeaterDaemon: log_fn=logger.info, ) logger.info("Trace processing helper initialized") - + # Create advert helper for neighbor tracking self.advert_helper = AdvertHelper( local_identity=self.local_identity, @@ -176,73 +183,81 @@ class RepeaterDaemon: packet_injector=self.router.inject_packet, log_fn=logger.info, ) - + # Register default repeater identity self.login_helper.register_identity( name="repeater", identity=self.local_identity, identity_type="repeater", - config=self.config # Pass full config so repeater can access top-level security section + config=self.config, # Pass full config so repeater can access top-level security section ) - + # Register room server identities with their configs - for name, identity, config in self.identity_manager.get_identities_by_type("room_server"): + for name, identity, config in self.identity_manager.get_identities_by_type( + "room_server" + ): self.login_helper.register_identity( - name=name, - identity=identity, + name=name, + identity=identity, identity_type="room_server", - config=config # Pass room-specific config + config=config, # Pass room-specific config ) - + logger.info("Login processing helper initialized") - + # Initialize ConfigManager for centralized config management self.config_manager = ConfigManager( - config_path=getattr(self, 'config_path', '/etc/pymc_repeater/config.yaml'), + config_path=getattr(self, "config_path", "/etc/pymc_repeater/config.yaml"), config=self.config, - daemon_instance=self + daemon_instance=self, ) logger.info("Config manager initialized") - + # Initialize text message helper with per-identity ACLs self.text_helper = TextHelper( identity_manager=self.identity_manager, packet_injector=self.router.inject_packet, acl_dict=self.login_helper.get_acl_dict(), # Per-identity ACLs log_fn=logger.info, - config_path=getattr(self, 'config_path', None), # For CLI to save changes + config_path=getattr(self, "config_path", None), # For CLI to save changes config=self.config, # For CLI to read/modify settings config_manager=self.config_manager, # New centralized config manager - sqlite_handler=self.repeater_handler.storage.sqlite_handler if self.repeater_handler and self.repeater_handler.storage else None, # For room server database + sqlite_handler=( + self.repeater_handler.storage.sqlite_handler + if self.repeater_handler and self.repeater_handler.storage + else None + ), # For room server database send_advert_callback=self.send_advert, # For CLI advert command ) - + # Register default repeater identity for text messages self.text_helper.register_identity( name="repeater", identity=self.local_identity, identity_type="repeater", - radio_config=self.config.get("radio", {}) + radio_config=self.config.get("radio", {}), ) - + # Register room server identities for text messages - for name, identity, config in self.identity_manager.get_identities_by_type("room_server"): + for name, identity, config in self.identity_manager.get_identities_by_type( + "room_server" + ): self.text_helper.register_identity( name=name, identity=identity, identity_type="room_server", - radio_config=config # Pass room-specific config (includes max_posts, etc.) + radio_config=config, # Pass room-specific config (includes max_posts, etc.) ) - + logger.info("Text message processing helper initialized") - + # Initialize PATH packet helper for updating client out_path self.path_helper = PathHelper( acl_dict=self.login_helper.get_acl_dict(), # Per-identity ACLs log_fn=logger.info, ) logger.info("PATH packet processing helper initialized") - + # Initialize protocol request handler for status/telemetry requests self.protocol_request_helper = ProtocolRequestHelper( identity_manager=self.identity_manager, @@ -254,9 +269,7 @@ class RepeaterDaemon: ) # Register repeater identity for protocol requests self.protocol_request_helper.register_identity( - name="repeater", - identity=self.local_identity, - identity_type="repeater" + name="repeater", identity=self.local_identity, identity_type="repeater" ) logger.info("Protocol request handler initialized") @@ -280,22 +293,20 @@ class RepeaterDaemon: async def _load_additional_identities(self): from pymc_core import LocalIdentity - + identities_config = self.config.get("identities", {}) - + # Load room server identities room_servers = identities_config.get("room_servers") or [] for room_config in room_servers: try: name = room_config.get("name") identity_key = room_config.get("identity_key") - + if not name or not identity_key: - logger.warning( - f"Skipping room server config: missing name or identity_key" - ) + logger.warning(f"Skipping room server config: missing name or identity_key") continue - + # Convert identity_key to bytes if it's a hex string if isinstance(identity_key, bytes): identity_key_bytes = identity_key @@ -303,36 +314,40 @@ class RepeaterDaemon: try: identity_key_bytes = bytes.fromhex(identity_key) if len(identity_key_bytes) != 32: - logger.error(f"Identity key for '{name}' is invalid length: {len(identity_key_bytes)} bytes (expected 32)") + logger.error( + f"Identity key for '{name}' is invalid length: {len(identity_key_bytes)} bytes (expected 32)" + ) continue except ValueError as e: logger.error(f"Identity key for '{name}' is not valid hex: {e}") continue else: - logger.error(f"Identity key for '{name}' has unknown type: {type(identity_key)}") + logger.error( + f"Identity key for '{name}' has unknown type: {type(identity_key)}" + ) continue - + # Create the identity room_identity = LocalIdentity(seed=identity_key_bytes) - + # Register with the manager and all helpers success = self._register_identity_everywhere( name=name, identity=room_identity, config=room_config, - identity_type="room_server" + identity_type="room_server", ) - + if success: room_hash = room_identity.get_public_key()[0] logger.info( f"Loaded room server '{name}': hash=0x{room_hash:02x}, " f"address={room_identity.get_address_bytes().hex()}" ) - + except Exception as e: logger.error(f"Failed to load room server identity '{name}': {e}") - + # Summary logging total_identities = len(self.identity_manager.list_identities()) logger.info(f"Identity manager loaded {total_identities} total identities") @@ -341,7 +356,7 @@ class RepeaterDaemon: """Load companion identities from config and create CompanionBridge + frame server for each.""" from pymc_core import LocalIdentity from pymc_core.companion import CompanionBridge - from pymc_core.companion.models import Contact, Channel + from pymc_core.companion.models import Channel, Contact from repeater.companion import CompanionFrameServer @@ -353,7 +368,11 @@ class RepeaterDaemon: if self.repeater_handler and self.repeater_handler.storage: sqlite_handler = self.repeater_handler.storage.sqlite_handler - radio_config = self.repeater_handler.radio_config if self.repeater_handler else self.config.get("radio", {}) + radio_config = ( + self.repeater_handler.radio_config + if self.repeater_handler + else self.config.get("radio", {}) + ) for comp_config in companions_config: try: @@ -415,28 +434,40 @@ class RepeaterDaemon: for row in channel_rows: ch = Channel( name=row.get("name", ""), - secret=row.get("secret", b"") if isinstance(row.get("secret"), bytes) else (bytes.fromhex(row.get("secret", "")) if row.get("secret") else b""), + secret=( + row.get("secret", b"") + if isinstance(row.get("secret"), bytes) + else ( + bytes.fromhex(row.get("secret", "")) + if row.get("secret") + else b"" + ) + ), ) bridge.channels.set(row.get("channel_idx", 0), ch) # Preload queued messages from SQLite into bridge for msg_dict in sqlite_handler.companion_load_messages(companion_hash_str): from pymc_core.companion.models import QueuedMessage + sk = msg_dict.get("sender_key", b"") if isinstance(sk, str): sk = bytes.fromhex(sk) - bridge.message_queue.push(QueuedMessage( - sender_key=sk, - txt_type=msg_dict.get("txt_type", 0), - timestamp=msg_dict.get("timestamp", 0), - text=msg_dict.get("text", ""), - is_channel=bool(msg_dict.get("is_channel", False)), - channel_idx=msg_dict.get("channel_idx", 0), - path_len=msg_dict.get("path_len", 0), - )) + bridge.message_queue.push( + QueuedMessage( + sender_key=sk, + txt_type=msg_dict.get("txt_type", 0), + timestamp=msg_dict.get("timestamp", 0), + text=msg_dict.get("text", ""), + is_channel=bool(msg_dict.get("is_channel", False)), + channel_idx=msg_dict.get("channel_idx", 0), + path_len=msg_dict.get("path_len", 0), + ) + ) # Ensure public channel (0) exists with default key for new companions from repeater.companion.constants import DEFAULT_PUBLIC_CHANNEL_SECRET + if bridge.get_channel(0) is None: bridge.set_channel(0, "Public", DEFAULT_PUBLIC_CHANNEL_SECRET) @@ -450,7 +481,9 @@ class RepeaterDaemon: sqlite_handler=sqlite_handler, local_hash=self.local_hash, stats_getter=self._get_companion_stats, - control_handler=self.discovery_helper.control_handler if self.discovery_helper else None, + control_handler=( + self.discovery_helper.control_handler if self.discovery_helper else None + ), ) await frame_server.start() self.companion_frame_servers.append(frame_server) @@ -500,7 +533,9 @@ class RepeaterDaemon: tag = int.from_bytes(payload_bytes[2:6], "little") if len(payload_bytes) >= 6 else 0 logger.debug( "Delivering discovery response to %s companion(s): tag=0x%08X, len=%s", - len(servers), tag, len(payload_bytes), + len(servers), + tag, + len(payload_bytes), ) for fs in servers: try: @@ -530,11 +565,7 @@ class RepeaterDaemon: logger.debug("Push trace data to companion: %s", e) def _register_identity_everywhere( - self, - name: str, - identity, - config: dict, - identity_type: str + self, name: str, identity, config: dict, identity_type: str ) -> bool: """ Register an identity with the manager and all helpers in one place. @@ -542,39 +573,31 @@ class RepeaterDaemon: """ # Register with identity manager success = self.identity_manager.register_identity( - name=name, - identity=identity, - config=config, - identity_type=identity_type + name=name, identity=identity, config=config, identity_type=identity_type ) - + if not success: return False - + # Register with all helpers if self.login_helper: self.login_helper.register_identity( - name=name, - identity=identity, - identity_type=identity_type, - config=config + name=name, identity=identity, identity_type=identity_type, config=config ) - + if self.text_helper: self.text_helper.register_identity( name=name, identity=identity, identity_type=identity_type, - radio_config=self.config.get("radio", {}) + radio_config=self.config.get("radio", {}), ) - + if self.protocol_request_helper: self.protocol_request_helper.register_identity( - name=name, - identity=identity, - identity_type=identity_type + name=name, identity=identity, identity_type=identity_type ) - + return True async def _router_callback(self, packet): @@ -587,19 +610,15 @@ class RepeaterDaemon: await self.router.enqueue(packet) except Exception as e: logger.error(f"Error enqueuing packet in router: {e}", exc_info=True) - + def register_text_handler_for_identity( - self, - name: str, - identity, - identity_type: str = "room_server", - radio_config: dict = None + self, name: str, identity, identity_type: str = "room_server", radio_config: dict = None ): if not self.text_helper: logger.warning("Text helper not initialized, cannot register identity") return False - + try: self.text_helper.register_identity( name=name, @@ -612,10 +631,10 @@ class RepeaterDaemon: except Exception as e: logger.error(f"Failed to register text handler for '{name}': {e}") return False - + def get_stats(self) -> dict: stats = {} - + if self.repeater_handler: stats = self.repeater_handler.get_stats() # Add public key if available @@ -625,12 +644,17 @@ class RepeaterDaemon: stats["public_key"] = pubkey.hex() except Exception: stats["public_key"] = None - + return stats def _get_companion_stats(self, stats_type: int) -> dict: """Return stats dict for companion CMD_GET_STATS (format expected by frame_server + meshcore_py).""" - from repeater.companion.constants import STATS_TYPE_CORE, STATS_TYPE_RADIO, STATS_TYPE_PACKETS + from repeater.companion.constants import ( + STATS_TYPE_CORE, + STATS_TYPE_PACKETS, + STATS_TYPE_RADIO, + ) + if not self.repeater_handler: return {} engine = self.repeater_handler @@ -751,10 +775,10 @@ class RepeaterDaemon: node_name=node_name, pub_key=pub_key_formatted, send_advert_func=self.send_advert, - config=self.config, - event_loop=current_loop, - daemon_instance=self, - config_path=getattr(self, 'config_path', '/etc/pymc_repeater/config.yaml'), + config=self.config, + event_loop=current_loop, + daemon_instance=self, + config_path=getattr(self, "config_path", "/etc/pymc_repeater/config.yaml"), ) try: @@ -804,14 +828,13 @@ def main(): # Load configuration config = load_config(args.config) - config_path = args.config if args.config else '/etc/pymc_repeater/config.yaml' + config_path = args.config if args.config else "/etc/pymc_repeater/config.yaml" if args.log_level: if "logging" not in config: config["logging"] = {} 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) daemon.config_path = config_path diff --git a/repeater/packet_router.py b/repeater/packet_router.py index a1007a4..8a8e878 100644 --- a/repeater/packet_router.py +++ b/repeater/packet_router.py @@ -1,19 +1,21 @@ import asyncio import logging -from pymc_core.node.handlers.trace import TraceHandler -from pymc_core.node.handlers.control import ControlHandler -from pymc_core.node.handlers.advert import AdvertHandler from pymc_core.node.handlers.ack import AckHandler +from pymc_core.node.handlers.advert import AdvertHandler +from pymc_core.node.handlers.control import ControlHandler +from pymc_core.node.handlers.group_text import GroupTextHandler +from pymc_core.node.handlers.login_response import LoginResponseHandler from pymc_core.node.handlers.login_server import LoginServerHandler -from pymc_core.node.handlers.text import TextMessageHandler from pymc_core.node.handlers.path import PathHandler from pymc_core.node.handlers.protocol_request import ProtocolRequestHandler -from pymc_core.node.handlers.group_text import GroupTextHandler from pymc_core.node.handlers.protocol_response import ProtocolResponseHandler -from pymc_core.node.handlers.login_response import LoginResponseHandler +from pymc_core.node.handlers.text import TextMessageHandler +from pymc_core.node.handlers.trace import TraceHandler + logger = logging.getLogger("PacketRouter") + class PacketRouter: def __init__(self, daemon_instance): @@ -56,7 +58,9 @@ class PacketRouter: await self.enqueue(packet) packet_len = len(packet.payload) if packet.payload else 0 - logger.debug(f"Injected packet processed by engine as local transmission ({packet_len} bytes)") + logger.debug( + f"Injected packet processed by engine as local transmission ({packet_len} bytes)" + ) return True except Exception as e: @@ -72,8 +76,7 @@ class PacketRouter: continue except Exception as e: logger.error(f"Router error: {e}", exc_info=True) - - + async def _route_packet(self, packet): payload_type = packet.get_payload_type() @@ -98,7 +101,11 @@ class PacketRouter: snr = getattr(packet, "_snr", None) or getattr(packet, "snr", 0.0) rssi = getattr(packet, "_rssi", None) or getattr(packet, "rssi", 0) path_len = getattr(packet, "path_len", 0) or 0 - path_bytes = (bytes(getattr(packet, "path", [])) if getattr(packet, "path", None) is not None else b"")[:path_len] + path_bytes = ( + bytes(getattr(packet, "path", [])) + if getattr(packet, "path", None) is not None + else b"" + )[:path_len] payload_bytes = bytes(packet.payload) if packet.payload else b"" await deliver(snr, rssi, path_len, path_bytes, payload_bytes) diff --git a/repeater/service_utils.py b/repeater/service_utils.py index 78ba7e8..5e465ee 100644 --- a/repeater/service_utils.py +++ b/repeater/service_utils.py @@ -2,6 +2,7 @@ Service management utilities for pyMC Repeater. Provides functions for service control operations like restart. """ + import logging import subprocess from typing import Tuple @@ -21,12 +22,9 @@ def restart_service() -> Tuple[bool, str]: """ try: result = subprocess.run( - ['systemctl', 'restart', 'pymc-repeater'], - capture_output=True, - text=True, - timeout=5 + ["systemctl", "restart", "pymc-repeater"], capture_output=True, text=True, timeout=5 ) - + if result.returncode == 0: logger.info("Service restart command executed successfully") return True, "Service restart initiated" diff --git a/repeater/web/__init__.py b/repeater/web/__init__.py index 77ae3f9..7ec398e 100644 --- a/repeater/web/__init__.py +++ b/repeater/web/__init__.py @@ -1,12 +1,12 @@ -from .http_server import HTTPStatsServer, StatsApp, LogBuffer, _log_buffer from .api_endpoints import APIEndpoints from .cad_calibration_engine import CADCalibrationEngine +from .http_server import HTTPStatsServer, LogBuffer, StatsApp, _log_buffer __all__ = [ - 'HTTPStatsServer', - 'StatsApp', - 'LogBuffer', - 'APIEndpoints', - 'CADCalibrationEngine', - '_log_buffer' -] \ No newline at end of file + "HTTPStatsServer", + "StatsApp", + "LogBuffer", + "APIEndpoints", + "CADCalibrationEngine", + "_log_buffer", +] diff --git a/repeater/web/api_endpoints.py b/repeater/web/api_endpoints.py index 23ccde9..6a464dd 100644 --- a/repeater/web/api_endpoints.py +++ b/repeater/web/api_endpoints.py @@ -5,14 +5,17 @@ import time from datetime import datetime from pathlib import Path from typing import Callable, Optional + import cherrypy +from pymc_core.protocol import CryptoUtils + from repeater import __version__ from repeater.config import update_global_flood_policy -from .cad_calibration_engine import CADCalibrationEngine + from .auth.middleware import require_auth from .auth_endpoints import AuthAPIEndpoints +from .cad_calibration_engine import CADCalibrationEngine from .companion_endpoints import CompanionAPIEndpoints -from pymc_core.protocol import CryptoUtils logger = logging.getLogger("HTTPServer") @@ -47,7 +50,7 @@ logger = logging.getLogger("HTTPServer") # Packets # GET /api/packet_stats?hours=24 - Get packet statistics -# GET /api/packet_type_stats?hours=24 - Get packet type statistics +# GET /api/packet_type_stats?hours=24 - Get packet type statistics # GET /api/route_stats?hours=24 - Get route statistics # GET /api/recent_packets?limit=100 - Get recent packets # GET /api/filtered_packets?type=4&route=1&start_timestamp=X&end_timestamp=Y&limit=1000 - Get filtered packets @@ -124,26 +127,32 @@ logger = logging.getLogger("HTTPServer") # ============================================================================ - class APIEndpoints: - - def __init__(self, stats_getter: Optional[Callable] = None, send_advert_func: Optional[Callable] = None, config: Optional[dict] = None, event_loop=None, daemon_instance=None, config_path=None): + + def __init__( + self, + stats_getter: Optional[Callable] = None, + send_advert_func: Optional[Callable] = None, + config: Optional[dict] = None, + event_loop=None, + daemon_instance=None, + config_path=None, + ): self.stats_getter = stats_getter self.send_advert_func = send_advert_func self.config = config or {} self.event_loop = event_loop self.daemon_instance = daemon_instance - self._config_path = config_path or '/etc/pymc_repeater/config.yaml' + self._config_path = config_path or "/etc/pymc_repeater/config.yaml" self.cad_calibration = CADCalibrationEngine(daemon_instance, event_loop) - + # Initialize ConfigManager for centralized config management from repeater.config_manager import ConfigManager + self.config_manager = ConfigManager( - config_path=self._config_path, - config=self.config, - daemon_instance=daemon_instance + config_path=self._config_path, config=self.config, daemon_instance=daemon_instance ) - + # Create nested auth object for /api/auth/* routes self.auth = AuthAPIEndpoints() @@ -155,28 +164,38 @@ class APIEndpoints: def _set_cors_headers(self): if self._is_cors_enabled(): - cherrypy.response.headers['Access-Control-Allow-Origin'] = '*' - cherrypy.response.headers['Access-Control-Allow-Methods'] = 'GET, POST, PUT, DELETE, OPTIONS' - cherrypy.response.headers['Access-Control-Allow-Headers'] = 'Content-Type, Authorization' + cherrypy.response.headers["Access-Control-Allow-Origin"] = "*" + cherrypy.response.headers["Access-Control-Allow-Methods"] = ( + "GET, POST, PUT, DELETE, OPTIONS" + ) + cherrypy.response.headers["Access-Control-Allow-Headers"] = ( + "Content-Type, Authorization" + ) @cherrypy.expose def default(self, *args, **kwargs): """Handle default requests""" if cherrypy.request.method == "OPTIONS": return "" - + raise cherrypy.HTTPError(404) def _get_storage(self): if not self.daemon_instance: raise Exception("Daemon not available") - - if not hasattr(self.daemon_instance, 'repeater_handler') or not self.daemon_instance.repeater_handler: + + if ( + not hasattr(self.daemon_instance, "repeater_handler") + or not self.daemon_instance.repeater_handler + ): raise Exception("Repeater handler not initialized") - - if not hasattr(self.daemon_instance.repeater_handler, 'storage') or not self.daemon_instance.repeater_handler.storage: + + if ( + not hasattr(self.daemon_instance.repeater_handler, "storage") + or not self.daemon_instance.repeater_handler.storage + ): raise Exception("Storage not initialized in repeater handler") - + return self.daemon_instance.repeater_handler.storage def _success(self, data, **kwargs): @@ -203,7 +222,7 @@ class APIEndpoints: def _require_post(self): if cherrypy.request.method != "POST": cherrypy.response.status = 405 # Method Not Allowed - cherrypy.response.headers['Allow'] = 'POST' + cherrypy.response.headers["Allow"] = "POST" raise cherrypy.HTTPError(405, "Method not allowed. This endpoint requires POST.") def _get_time_range(self, hours): @@ -239,21 +258,26 @@ class APIEndpoints: config = self.config # Check for default values that indicate first-time setup - node_name = config.get('repeater', {}).get('node_name', '') - has_default_name = node_name in ['mesh-repeater-01', ''] + node_name = config.get("repeater", {}).get("node_name", "") + has_default_name = node_name in ["mesh-repeater-01", ""] - admin_password = config.get('repeater', {}).get('security', {}).get('admin_password', '') - has_default_password = admin_password in ['admin123', ''] + admin_password = ( + config.get("repeater", {}).get("security", {}).get("admin_password", "") + ) + has_default_password = admin_password in ["admin123", ""] needs_setup = has_default_name or has_default_password - return {'needs_setup': needs_setup, 'reasons': { - 'default_name': has_default_name, - 'default_password': has_default_password - }} + return { + "needs_setup": needs_setup, + "reasons": { + "default_name": has_default_name, + "default_password": has_default_password, + }, + } except Exception as e: logger.error(f"Error checking setup status: {e}") - return {'needs_setup': False, 'error': str(e)} + return {"needs_setup": False, "error": str(e)} @cherrypy.expose @cherrypy.tools.json_out() @@ -265,37 +289,41 @@ class APIEndpoints: # Check config-based location first, then development location storage_cfg = self.config.get("storage", {}) config_dir = Path(storage_cfg.get("storage_dir", "/var/lib/pymc_repeater")) - installed_path = config_dir / 'radio-settings.json' - dev_path = os.path.join(os.path.dirname(__file__), '..', '..', 'radio-settings.json') - + installed_path = config_dir / "radio-settings.json" + dev_path = os.path.join(os.path.dirname(__file__), "..", "..", "radio-settings.json") + hardware_file = str(installed_path) if installed_path.exists() else dev_path hardware_list = [] if os.path.exists(hardware_file): - with open(hardware_file, 'r') as f: + with open(hardware_file, "r") as f: hardware_data = json.load(f) - hardware_configs = hardware_data.get('hardware', {}) + hardware_configs = hardware_data.get("hardware", {}) for hw_key, hw_config in hardware_configs.items(): if isinstance(hw_config, dict): - hardware_list.append({ - 'key': hw_key, - 'name': hw_config.get('name', hw_key), - 'description': hw_config.get('description', ''), - 'config': hw_config - }) + hardware_list.append( + { + "key": hw_key, + "name": hw_config.get("name", hw_key), + "description": hw_config.get("description", ""), + "config": hw_config, + } + ) # Add MeshCore KISS modem option (serial TNC) - hardware_list.append({ - 'key': 'kiss', - 'name': 'KISS modem (serial)', - 'description': 'MeshCore KISS modem over serial – requires pyMC_core with KISS support', - 'config': {} - }) + hardware_list.append( + { + "key": "kiss", + "name": "KISS modem (serial)", + "description": "MeshCore KISS modem over serial – requires pyMC_core with KISS support", + "config": {}, + } + ) - return {'hardware': hardware_list} + return {"hardware": hardware_list} except Exception as e: logger.error(f"Error loading hardware options: {e}") - return {'error': str(e)} + return {"error": str(e)} @cherrypy.expose @cherrypy.tools.json_out() @@ -303,29 +331,33 @@ class APIEndpoints: """Get radio preset configurations from local file""" try: import json - + # Check config-based location first, then development location storage_cfg = self.config.get("storage", {}) config_dir = Path(storage_cfg.get("storage_dir", "/var/lib/pymc_repeater")) - installed_path = config_dir / 'radio-presets.json' - dev_path = os.path.join(os.path.dirname(__file__), '..', '..', 'radio-presets.json') - + installed_path = config_dir / "radio-presets.json" + dev_path = os.path.join(os.path.dirname(__file__), "..", "..", "radio-presets.json") + presets_file = str(installed_path) if installed_path.exists() else dev_path - + if not os.path.exists(presets_file): logger.error(f"Presets file not found. Tried: {installed_path}, {dev_path}") - return {'error': 'Radio presets file not found'} - - with open(presets_file, 'r') as f: + return {"error": "Radio presets file not found"} + + with open(presets_file, "r") as f: presets_data = json.load(f) - + # Extract entries from local file - entries = presets_data.get('config', {}).get('suggested_radio_settings', {}).get('entries', []) - return {'presets': entries, 'source': 'local'} - + entries = ( + presets_data.get("config", {}) + .get("suggested_radio_settings", {}) + .get("entries", []) + ) + return {"presets": entries, "source": "local"} + except Exception as e: logger.error(f"Error loading radio presets: {e}") - return {'error': str(e)} + return {"error": str(e)} @cherrypy.expose @cherrypy.tools.json_out() @@ -335,117 +367,123 @@ class APIEndpoints: try: self._require_post() data = cherrypy.request.json - + # Validate required fields - node_name = data.get('node_name', '').strip() + node_name = data.get("node_name", "").strip() if not node_name: - return {'success': False, 'error': 'Node name is required'} + return {"success": False, "error": "Node name is required"} # Validate UTF-8 byte length (31 bytes max + 1 null terminator = 32 bytes total) - if len(node_name.encode('utf-8')) > 31: - return {'success': False, 'error': 'Node name too long (max 31 bytes in UTF-8)'} - - hardware_key = data.get('hardware_key', '').strip() + if len(node_name.encode("utf-8")) > 31: + return {"success": False, "error": "Node name too long (max 31 bytes in UTF-8)"} + + hardware_key = data.get("hardware_key", "").strip() if not hardware_key: - return {'success': False, 'error': 'Hardware selection is required'} - - radio_preset = data.get('radio_preset', {}) + return {"success": False, "error": "Hardware selection is required"} + + radio_preset = data.get("radio_preset", {}) if not radio_preset: - return {'success': False, 'error': 'Radio preset selection is required'} - - admin_password = data.get('admin_password', '').strip() + return {"success": False, "error": "Radio preset selection is required"} + + admin_password = data.get("admin_password", "").strip() if not admin_password or len(admin_password) < 6: - return {'success': False, 'error': 'Admin password must be at least 6 characters'} - + return {"success": False, "error": "Admin password must be at least 6 characters"} + import json + import yaml # Read current config first so we can update it - with open(self._config_path, 'r') as f: + with open(self._config_path, "r") as f: config_yaml = yaml.safe_load(f) # Update repeater settings - if 'repeater' not in config_yaml: - config_yaml['repeater'] = {} - config_yaml['repeater']['node_name'] = node_name + if "repeater" not in config_yaml: + config_yaml["repeater"] = {} + config_yaml["repeater"]["node_name"] = node_name - if 'security' not in config_yaml['repeater']: - config_yaml['repeater']['security'] = {} - config_yaml['repeater']['security']['admin_password'] = admin_password + if "security" not in config_yaml["repeater"]: + config_yaml["repeater"]["security"] = {} + config_yaml["repeater"]["security"]["admin_password"] = admin_password # Update radio settings - convert MHz/kHz to Hz (used for both SX1262 and KISS modem) - if 'radio' not in config_yaml: - config_yaml['radio'] = {} - freq_mhz = float(radio_preset.get('frequency', 0)) - bw_khz = float(radio_preset.get('bandwidth', 0)) - config_yaml['radio']['frequency'] = int(freq_mhz * 1000000) - config_yaml['radio']['spreading_factor'] = int(radio_preset.get('spreading_factor', 7)) - config_yaml['radio']['bandwidth'] = int(bw_khz * 1000) - config_yaml['radio']['coding_rate'] = int(radio_preset.get('coding_rate', 5)) + if "radio" not in config_yaml: + config_yaml["radio"] = {} + freq_mhz = float(radio_preset.get("frequency", 0)) + bw_khz = float(radio_preset.get("bandwidth", 0)) + config_yaml["radio"]["frequency"] = int(freq_mhz * 1000000) + config_yaml["radio"]["spreading_factor"] = int(radio_preset.get("spreading_factor", 7)) + config_yaml["radio"]["bandwidth"] = int(bw_khz * 1000) + config_yaml["radio"]["coding_rate"] = int(radio_preset.get("coding_rate", 5)) - if hardware_key == 'kiss': + if hardware_key == "kiss": # KISS modem: set radio_type and kiss section (port/baud from request or defaults) - config_yaml['radio_type'] = 'kiss' - kiss_port = (data.get('kiss_port') or '').strip() or '/dev/ttyUSB0' - kiss_baud = int(data.get('kiss_baud_rate', data.get('kiss_baud', 115200))) - config_yaml['kiss'] = {'port': kiss_port, 'baud_rate': kiss_baud} - config_yaml['radio']['tx_power'] = int(radio_preset.get('tx_power', 14)) - if 'preamble_length' not in config_yaml['radio']: - config_yaml['radio']['preamble_length'] = 17 + config_yaml["radio_type"] = "kiss" + kiss_port = (data.get("kiss_port") or "").strip() or "/dev/ttyUSB0" + kiss_baud = int(data.get("kiss_baud_rate", data.get("kiss_baud", 115200))) + config_yaml["kiss"] = {"port": kiss_port, "baud_rate": kiss_baud} + config_yaml["radio"]["tx_power"] = int(radio_preset.get("tx_power", 14)) + if "preamble_length" not in config_yaml["radio"]: + config_yaml["radio"]["preamble_length"] = 17 else: # SX1262: load hardware config from radio-settings.json storage_cfg = self.config.get("storage", {}) config_dir = Path(storage_cfg.get("storage_dir", "/var/lib/pymc_repeater")) - installed_path = config_dir / 'radio-settings.json' - dev_path = os.path.join(os.path.dirname(__file__), '..', '..', 'radio-settings.json') + installed_path = config_dir / "radio-settings.json" + dev_path = os.path.join( + os.path.dirname(__file__), "..", "..", "radio-settings.json" + ) hardware_file = str(installed_path) if installed_path.exists() else dev_path if not os.path.exists(hardware_file): - return {'success': False, 'error': 'Hardware configuration file not found'} - with open(hardware_file, 'r') as f: + return {"success": False, "error": "Hardware configuration file not found"} + with open(hardware_file, "r") as f: hardware_data = json.load(f) - hardware_configs = hardware_data.get('hardware', {}) + hardware_configs = hardware_data.get("hardware", {}) hw_config = hardware_configs.get(hardware_key, {}) if not hw_config: - return {'success': False, 'error': f'Hardware configuration not found: {hardware_key}'} + return { + "success": False, + "error": f"Hardware configuration not found: {hardware_key}", + } - config_yaml['radio_type'] = 'sx1262' - if 'tx_power' in hw_config: - config_yaml['radio']['tx_power'] = hw_config.get('tx_power', 22) - if 'preamble_length' in hw_config: - config_yaml['radio']['preamble_length'] = hw_config.get('preamble_length', 17) + config_yaml["radio_type"] = "sx1262" + if "tx_power" in hw_config: + config_yaml["radio"]["tx_power"] = hw_config.get("tx_power", 22) + if "preamble_length" in hw_config: + config_yaml["radio"]["preamble_length"] = hw_config.get("preamble_length", 17) - if 'sx1262' not in config_yaml: - config_yaml['sx1262'] = {} - if 'bus_id' in hw_config: - config_yaml['sx1262']['bus_id'] = hw_config.get('bus_id', 0) - if 'cs_id' in hw_config: - config_yaml['sx1262']['cs_id'] = hw_config.get('cs_id', 0) - if 'reset_pin' in hw_config: - config_yaml['sx1262']['reset_pin'] = hw_config.get('reset_pin', 22) - if 'busy_pin' in hw_config: - config_yaml['sx1262']['busy_pin'] = hw_config.get('busy_pin', 17) - if 'irq_pin' in hw_config: - config_yaml['sx1262']['irq_pin'] = hw_config.get('irq_pin', 16) - if 'txen_pin' in hw_config: - config_yaml['sx1262']['txen_pin'] = hw_config.get('txen_pin', -1) - if 'rxen_pin' in hw_config: - config_yaml['sx1262']['rxen_pin'] = hw_config.get('rxen_pin', -1) - if 'cs_pin' in hw_config: - config_yaml['sx1262']['cs_pin'] = hw_config.get('cs_pin', -1) - if 'txled_pin' in hw_config: - config_yaml['sx1262']['txled_pin'] = hw_config.get('txled_pin', -1) - if 'rxled_pin' in hw_config: - config_yaml['sx1262']['rxled_pin'] = hw_config.get('rxled_pin', -1) - if 'use_dio3_tcxo' in hw_config: - config_yaml['sx1262']['use_dio3_tcxo'] = hw_config.get('use_dio3_tcxo', False) - if 'use_dio2_rf' in hw_config: - config_yaml['sx1262']['use_dio2_rf'] = hw_config.get('use_dio2_rf', False) - if 'is_waveshare' in hw_config: - config_yaml['sx1262']['is_waveshare'] = hw_config.get('is_waveshare', False) + if "sx1262" not in config_yaml: + config_yaml["sx1262"] = {} + if "bus_id" in hw_config: + config_yaml["sx1262"]["bus_id"] = hw_config.get("bus_id", 0) + if "cs_id" in hw_config: + config_yaml["sx1262"]["cs_id"] = hw_config.get("cs_id", 0) + if "reset_pin" in hw_config: + config_yaml["sx1262"]["reset_pin"] = hw_config.get("reset_pin", 22) + if "busy_pin" in hw_config: + config_yaml["sx1262"]["busy_pin"] = hw_config.get("busy_pin", 17) + if "irq_pin" in hw_config: + config_yaml["sx1262"]["irq_pin"] = hw_config.get("irq_pin", 16) + if "txen_pin" in hw_config: + config_yaml["sx1262"]["txen_pin"] = hw_config.get("txen_pin", -1) + if "rxen_pin" in hw_config: + config_yaml["sx1262"]["rxen_pin"] = hw_config.get("rxen_pin", -1) + if "cs_pin" in hw_config: + config_yaml["sx1262"]["cs_pin"] = hw_config.get("cs_pin", -1) + if "txled_pin" in hw_config: + config_yaml["sx1262"]["txled_pin"] = hw_config.get("txled_pin", -1) + if "rxled_pin" in hw_config: + config_yaml["sx1262"]["rxled_pin"] = hw_config.get("rxled_pin", -1) + if "use_dio3_tcxo" in hw_config: + config_yaml["sx1262"]["use_dio3_tcxo"] = hw_config.get("use_dio3_tcxo", False) + if "use_dio2_rf" in hw_config: + config_yaml["sx1262"]["use_dio2_rf"] = hw_config.get("use_dio2_rf", False) + if "is_waveshare" in hw_config: + config_yaml["sx1262"]["is_waveshare"] = hw_config.get("is_waveshare", False) # Write updated config - with open(self._config_path, 'w') as f: + with open(self._config_path, "w") as f: yaml.dump(config_yaml, f, default_flow_style=False, sort_keys=False) - + logger.info( f"Setup wizard completed: node_name={node_name}, hardware={hardware_key}, freq={freq_mhz}MHz" ) @@ -453,39 +491,44 @@ class APIEndpoints: # Trigger service restart after setup import subprocess import threading - + def delayed_restart(): import time + time.sleep(2) # Give time for response to be sent try: # Use systemctl without sudo - polkit rules allow the repeater user to restart the service - subprocess.run(['systemctl', 'restart', 'pymc-repeater'], check=False) + subprocess.run(["systemctl", "restart", "pymc-repeater"], check=False) except Exception as e: logger.error(f"Failed to restart service: {e}") - + # Start restart in background thread restart_thread = threading.Thread(target=delayed_restart, daemon=True) restart_thread.start() - + result_config = { - 'node_name': node_name, - 'hardware': hardware_key, - 'radio_type': config_yaml.get('radio_type', 'sx1262'), - 'frequency': freq_mhz, - 'spreading_factor': radio_preset.get('spreading_factor'), - 'bandwidth': radio_preset.get('bandwidth'), - 'coding_rate': radio_preset.get('coding_rate') + "node_name": node_name, + "hardware": hardware_key, + "radio_type": config_yaml.get("radio_type", "sx1262"), + "frequency": freq_mhz, + "spreading_factor": radio_preset.get("spreading_factor"), + "bandwidth": radio_preset.get("bandwidth"), + "coding_rate": radio_preset.get("coding_rate"), } - if hardware_key == 'kiss': - result_config['kiss_port'] = config_yaml.get('kiss', {}).get('port') - result_config['kiss_baud_rate'] = config_yaml.get('kiss', {}).get('baud_rate') - return {'success': True, 'message': 'Setup completed successfully. Service is restarting...', 'config': result_config} - + if hardware_key == "kiss": + result_config["kiss_port"] = config_yaml.get("kiss", {}).get("port") + result_config["kiss_baud_rate"] = config_yaml.get("kiss", {}).get("baud_rate") + return { + "success": True, + "message": "Setup completed successfully. Service is restarting...", + "config": result_config, + } + except cherrypy.HTTPError: raise except Exception as e: logger.error(f"Error completing setup wizard: {e}", exc_info=True) - return {'success': False, 'error': str(e)} + return {"success": False, "error": str(e)} # ============================================================================ # SYSTEM ENDPOINTS @@ -499,6 +542,7 @@ class APIEndpoints: stats["version"] = __version__ try: import pymc_core + stats["core_version"] = pymc_core.__version__ except ImportError: stats["core_version"] = "unknown" @@ -512,10 +556,10 @@ class APIEndpoints: def send_advert(self): # Enable CORS for this endpoint self._set_cors_headers() - + if cherrypy.request.method == "OPTIONS": return "" - + try: self._require_post() if not self.send_advert_func: @@ -523,9 +567,14 @@ class APIEndpoints: if self.event_loop is None: return self._error("Event loop not available") import asyncio + future = asyncio.run_coroutine_threadsafe(self.send_advert_func(), self.event_loop) result = future.result(timeout=10) - return self._success("Advert sent successfully") if result else self._error("Failed to send advert") + return ( + self._success("Advert sent successfully") + if result + else self._error("Failed to send advert") + ) except cherrypy.HTTPError: # Re-raise HTTP errors (like 405 Method Not Allowed) without logging raise @@ -539,10 +588,10 @@ class APIEndpoints: def set_mode(self): # Enable CORS for this endpoint only if configured self._set_cors_headers() - + if cherrypy.request.method == "OPTIONS": return "" - + try: self._require_post() data = cherrypy.request.json @@ -567,10 +616,10 @@ class APIEndpoints: def set_duty_cycle(self): # Enable CORS for this endpoint only if configured self._set_cors_headers() - + if cherrypy.request.method == "OPTIONS": return "" - + try: self._require_post() data = cherrypy.request.json @@ -592,20 +641,20 @@ class APIEndpoints: @cherrypy.tools.json_in() def update_duty_cycle_config(self): self._set_cors_headers() - + if cherrypy.request.method == "OPTIONS": return "" - + try: self._require_post() data = cherrypy.request.json or {} - + applied = [] - + # Ensure config section exists if "duty_cycle" not in self.config: self.config["duty_cycle"] = {} - + # Update max airtime percentage if "max_airtime_percent" in data: percent = float(data["max_airtime_percent"]) @@ -615,21 +664,19 @@ class APIEndpoints: max_airtime_ms = int((percent / 100) * 60000) self.config["duty_cycle"]["max_airtime_per_minute"] = max_airtime_ms applied.append(f"max_airtime={percent}%") - + # Update enforcement enabled/disabled if "enforcement_enabled" in data: enabled = bool(data["enforcement_enabled"]) self.config["duty_cycle"]["enforcement_enabled"] = enabled applied.append(f"enforcement={'enabled' if enabled else 'disabled'}") - + if not applied: return self._error("No valid settings provided") - + # Save to config file and live update daemon result = self.config_manager.update_and_save( - updates={}, - live_update=True, - live_update_sections=['duty_cycle'] + updates={}, live_update=True, live_update_sections=["duty_cycle"] ) if not result.get("saved", False): @@ -637,14 +684,16 @@ class APIEndpoints: logger.info(f"Duty cycle config updated: {', '.join(applied)}") - return self._success({ - "applied": applied, - "persisted": True, - "live_update": result.get("live_updated", False), - "restart_required": False, - "message": "Duty cycle settings applied immediately." - }) - + return self._success( + { + "applied": applied, + "persisted": True, + "live_update": result.get("live_updated", False), + "restart_required": False, + "message": "Duty cycle settings applied immediately.", + } + ) + except cherrypy.HTTPError: raise except Exception as e: @@ -656,55 +705,51 @@ class APIEndpoints: def check_pymc_console(self): """Check if PyMC Console directory exists.""" self._set_cors_headers() - + if cherrypy.request.method == "OPTIONS": return "" - + try: - pymc_console_path = '/opt/pymc_console/web/html' + pymc_console_path = "/opt/pymc_console/web/html" exists = os.path.isdir(pymc_console_path) - - return self._success({ - "exists": exists, - "path": pymc_console_path - }) + + return self._success({"exists": exists, "path": pymc_console_path}) except Exception as e: logger.error(f"Error checking PyMC Console directory: {e}") return self._error(str(e)) - + @cherrypy.expose @cherrypy.tools.json_out() @cherrypy.tools.json_in() def update_web_config(self): """Update web configuration (CORS, frontend path) using ConfigManager.""" self._set_cors_headers() - + if cherrypy.request.method == "OPTIONS": return "" - + try: self._require_post() updates = cherrypy.request.json or {} - + if not updates: return self._error("No configuration updates provided") - + # Use ConfigManager to update and save configuration # Web changes (CORS, web_path) don't require live update - result = self.config_manager.update_and_save( - updates=updates, - live_update=False - ) - + result = self.config_manager.update_and_save(updates=updates, live_update=False) + if result.get("success"): logger.info(f"Web configuration updated: {list(updates.keys())}") - return self._success({ - "persisted": result.get("saved", False), - "message": "Web configuration saved successfully. Restart required for changes to take effect." - }) + return self._success( + { + "persisted": result.get("saved", False), + "message": "Web configuration saved successfully. Restart required for changes to take effect.", + } + ) else: return self._error(result.get("error", "Failed to update web configuration")) - + except cherrypy.HTTPError: raise except Exception as e: @@ -718,22 +763,22 @@ class APIEndpoints: """Restart the pymc-repeater service via systemctl.""" # Enable CORS for this endpoint only if configured self._set_cors_headers() - + if cherrypy.request.method == "OPTIONS": return "" - + try: self._require_post() from repeater.service_utils import restart_service as do_restart - + logger.warning("Service restart requested via API") success, message = do_restart() - + if success: return {"success": True, "message": message} else: return self._error(message) - + except cherrypy.HTTPError: raise except Exception as e: @@ -744,6 +789,7 @@ class APIEndpoints: @cherrypy.tools.json_out() def logs(self): from .http_server import _log_buffer + try: logs = list(_log_buffer.logs) return { @@ -794,7 +840,9 @@ class APIEndpoints: if processes: return self._success(processes) else: - return self._error("Process information not available (psutil may not be installed)") + return self._error( + "Process information not available (psutil may not be installed)" + ) else: return self._error("Storage collector not available") except Exception as e: @@ -856,7 +904,7 @@ class APIEndpoints: # Enforce reasonable limits limit = min(int(limit), 10000) offset = max(int(offset), 0) - + # Get packets from storage with TRUE DB-level pagination # Uses SQL "LIMIT ? OFFSET ?" - no Python slicing needed! storage = self._get_storage() @@ -866,32 +914,34 @@ class APIEndpoints: start_timestamp=float(start_timestamp) if start_timestamp else None, end_timestamp=float(end_timestamp) if end_timestamp else None, limit=limit, - offset=offset + offset=offset, ) - + response = { "success": True, "data": packets, "count": len(packets), "offset": offset, "limit": limit, - "compressed": True + "compressed": True, } - + return response - + except Exception as e: logger.error(f"Error getting bulk packets: {e}") return self._error(e) @cherrypy.expose @cherrypy.tools.json_out() - def filtered_packets(self, start_timestamp=None, end_timestamp=None, limit=1000, type=None, route=None): + def filtered_packets( + self, start_timestamp=None, end_timestamp=None, limit=1000, type=None, route=None + ): # Handle OPTIONS request for CORS preflight if cherrypy.request.method == "OPTIONS": self._set_cors_headers() return "" - + try: # Convert 'type' parameter to 'packet_type' for storage method packet_type = int(type) if type is not None else None @@ -899,21 +949,25 @@ class APIEndpoints: start_ts = float(start_timestamp) if start_timestamp is not None else None end_ts = float(end_timestamp) if end_timestamp is not None else None limit_int = int(limit) if limit is not None else 1000 - + packets = self._get_storage().get_filtered_packets( packet_type=packet_type, route=route_int, start_timestamp=start_ts, end_timestamp=end_ts, - limit=limit_int + limit=limit_int, + ) + return self._success( + packets, + count=len(packets), + filters={ + "type": packet_type, + "route": route_int, + "start_timestamp": start_ts, + "end_timestamp": end_ts, + "limit": limit_int, + }, ) - return self._success(packets, count=len(packets), filters={ - 'type': packet_type, - 'route': route_int, - 'start_timestamp': start_ts, - 'end_timestamp': end_ts, - 'limit': limit_int - }) except ValueError as e: return self._error(f"Invalid parameter format: {e}") except Exception as e: @@ -947,11 +1001,9 @@ class APIEndpoints: @cherrypy.tools.json_out() def rrd_data(self): try: - params = self._get_params({ - 'start_time': None, - 'end_time': None, - 'resolution': 'average' - }) + params = self._get_params( + {"start_time": None, "end_time": None, "resolution": "average"} + ) data = self._get_storage().get_rrd_data(**params) return self._success(data) if data else self._error("No RRD data available") except ValueError as e: @@ -962,33 +1014,40 @@ class APIEndpoints: @cherrypy.expose @cherrypy.tools.json_out() - def packet_type_graph_data(self, hours=24, resolution='average', types='all'): - + def packet_type_graph_data(self, hours=24, resolution="average", types="all"): + try: hours = int(hours) start_time, end_time = self._get_time_range(hours) - + storage = self._get_storage() - + stats = storage.sqlite_handler.get_packet_type_stats(hours) - if 'error' in stats: - return self._error(stats['error']) - - packet_type_totals = stats.get('packet_type_totals', {}) - + if "error" in stats: + return self._error(stats["error"]) + + packet_type_totals = stats.get("packet_type_totals", {}) + # Create simple bar chart data format for packet types series = [] for type_name, count in packet_type_totals.items(): if count > 0: # Only include types with actual data - series.append({ - "name": type_name, - "type": type_name.lower().replace(' ', '_').replace('(', '').replace(')', ''), - "data": [[end_time * 1000, count]] # Single data point with total count - }) - + series.append( + { + "name": type_name, + "type": type_name.lower() + .replace(" ", "_") + .replace("(", "") + .replace(")", ""), + "data": [ + [end_time * 1000, count] + ], # Single data point with total count + } + ) + # Sort series by count (descending) - series.sort(key=lambda x: x['data'][0][1], reverse=True) - + series.sort(key=lambda x: x["data"][0][1], reverse=True) + graph_data = { "start_time": start_time, "end_time": end_time, @@ -996,11 +1055,11 @@ class APIEndpoints: "timestamps": [start_time, end_time], "series": series, "data_source": "sqlite", - "chart_type": "bar" # Indicate this is bar chart data + "chart_type": "bar", # Indicate this is bar chart data } - + return self._success(graph_data) - + except ValueError as e: return self._error(f"Invalid parameter format: {e}") except Exception as e: @@ -1009,59 +1068,69 @@ class APIEndpoints: @cherrypy.expose @cherrypy.tools.json_out() - def metrics_graph_data(self, hours=24, resolution='average', metrics='all'): - + def metrics_graph_data(self, hours=24, resolution="average", metrics="all"): + try: hours = int(hours) start_time, end_time = self._get_time_range(hours) - + rrd_data = self._get_storage().get_rrd_data( start_time=start_time, end_time=end_time, resolution=resolution ) - - if not rrd_data or 'metrics' not in rrd_data: + + if not rrd_data or "metrics" not in rrd_data: return self._error("No RRD data available") - + metric_names = { - 'rx_count': 'Received Packets', 'tx_count': 'Transmitted Packets', - 'drop_count': 'Dropped Packets', 'avg_rssi': 'Average RSSI (dBm)', - 'avg_snr': 'Average SNR (dB)', 'avg_length': 'Average Packet Length', - 'avg_score': 'Average Score', 'neighbor_count': 'Neighbor Count' + "rx_count": "Received Packets", + "tx_count": "Transmitted Packets", + "drop_count": "Dropped Packets", + "avg_rssi": "Average RSSI (dBm)", + "avg_snr": "Average SNR (dB)", + "avg_length": "Average Packet Length", + "avg_score": "Average Score", + "neighbor_count": "Neighbor Count", } - - counter_metrics = ['rx_count', 'tx_count', 'drop_count'] - - if metrics != 'all': - requested_metrics = [m.strip() for m in metrics.split(',')] + + counter_metrics = ["rx_count", "tx_count", "drop_count"] + + if metrics != "all": + requested_metrics = [m.strip() for m in metrics.split(",")] else: - requested_metrics = list(rrd_data['metrics'].keys()) - - timestamps_ms = [ts * 1000 for ts in rrd_data['timestamps']] + requested_metrics = list(rrd_data["metrics"].keys()) + + timestamps_ms = [ts * 1000 for ts in rrd_data["timestamps"]] series = [] - + for metric_key in requested_metrics: - if metric_key in rrd_data['metrics']: + if metric_key in rrd_data["metrics"]: if metric_key in counter_metrics: - chart_data = self._process_counter_data(rrd_data['metrics'][metric_key], timestamps_ms) + chart_data = self._process_counter_data( + rrd_data["metrics"][metric_key], timestamps_ms + ) else: - chart_data = self._process_gauge_data(rrd_data['metrics'][metric_key], timestamps_ms) - - series.append({ - "name": metric_names.get(metric_key, metric_key), - "type": metric_key, - "data": chart_data - }) - + chart_data = self._process_gauge_data( + rrd_data["metrics"][metric_key], timestamps_ms + ) + + series.append( + { + "name": metric_names.get(metric_key, metric_key), + "type": metric_key, + "data": chart_data, + } + ) + graph_data = { - "start_time": rrd_data['start_time'], - "end_time": rrd_data['end_time'], - "step": rrd_data['step'], - "timestamps": rrd_data['timestamps'], - "series": series + "start_time": rrd_data["start_time"], + "end_time": rrd_data["end_time"], + "step": rrd_data["step"], + "timestamps": rrd_data["timestamps"], + "series": series, } - + return self._success(graph_data) - + except ValueError as e: return self._error(f"Invalid parameter format: {e}") except Exception as e: @@ -1069,10 +1138,10 @@ class APIEndpoints: return self._error(e) @cherrypy.expose - @cherrypy.tools.json_out() + @cherrypy.tools.json_out() @cherrypy.tools.json_in() def cad_calibration_start(self): - + try: self._require_post() data = cherrypy.request.json or {} @@ -1088,11 +1157,11 @@ class APIEndpoints: except Exception as e: logger.error(f"Error starting CAD calibration: {e}") return self._error(e) - + @cherrypy.expose @cherrypy.tools.json_out() def cad_calibration_stop(self): - + try: self._require_post() self.cad_calibration.stop_calibration() @@ -1103,45 +1172,51 @@ class APIEndpoints: except Exception as e: logger.error(f"Error stopping CAD calibration: {e}") return self._error(e) - + @cherrypy.expose @cherrypy.tools.json_out() @cherrypy.tools.json_in() def save_cad_settings(self): - + try: self._require_post() data = cherrypy.request.json or {} peak = data.get("peak") min_val = data.get("min_val") detection_rate = data.get("detection_rate", 0) - + if peak is None or min_val is None: return self._error("Missing peak or min_val parameters") - - if self.daemon_instance and hasattr(self.daemon_instance, 'radio') and self.daemon_instance.radio: - if hasattr(self.daemon_instance.radio, 'set_custom_cad_thresholds'): + + if ( + self.daemon_instance + and hasattr(self.daemon_instance, "radio") + and self.daemon_instance.radio + ): + if hasattr(self.daemon_instance.radio, "set_custom_cad_thresholds"): self.daemon_instance.radio.set_custom_cad_thresholds(peak=peak, min_val=min_val) logger.info(f"Applied CAD settings to radio: peak={peak}, min={min_val}") - + if "radio" not in self.config: self.config["radio"] = {} if "cad" not in self.config["radio"]: self.config["radio"]["cad"] = {} - + self.config["radio"]["cad"]["peak_threshold"] = peak self.config["radio"]["cad"]["min_threshold"] = min_val - - config_path = getattr(self, '_config_path', '/etc/pymc_repeater/config.yaml') + + config_path = getattr(self, "_config_path", "/etc/pymc_repeater/config.yaml") saved, err = self.config_manager.save_to_file() if not saved: return self._error(err or "Failed to save configuration to file") - logger.info(f"Saved CAD settings to config: peak={peak}, min={min_val}, rate={detection_rate:.1f}%") + logger.info( + f"Saved CAD settings to config: peak={peak}, min={min_val}, rate={detection_rate:.1f}%" + ) return { - "success": True, + "success": True, "message": f"CAD settings saved: peak={peak}, min={min_val}", - "settings": {"peak": peak, "min_val": min_val, "detection_rate": detection_rate} + "settings": {"peak": peak, "min_val": min_val, "detection_rate": detection_rate}, } except cherrypy.HTTPError: # Re-raise HTTP errors (like 405 Method Not Allowed) without logging @@ -1155,7 +1230,7 @@ class APIEndpoints: @cherrypy.tools.json_in() def update_radio_config(self): """Update radio and repeater configuration with live updates. - + POST /api/update_radio_config Body: { "tx_power": 22, # TX power in dBm (2-30) @@ -1173,23 +1248,23 @@ class APIEndpoints: "flood_advert_interval_hours": 10, # Flood advert interval (0 or 3-48) "advert_interval_minutes": 120 # Local advert interval (0 or 1-10080) } - + Note: Radio hardware changes (frequency, bandwidth, SF, CR) require restart to apply. - + Returns: {"success": true, "data": {"applied": [...], "live_update": true}} """ # Enable CORS for this endpoint only if configured self._set_cors_headers() - + if cherrypy.request.method == "OPTIONS": return "" - + try: self._require_post() data = cherrypy.request.json or {} - + applied = [] - + # Ensure config sections exist if "radio" not in self.config: self.config["radio"] = {} @@ -1197,7 +1272,7 @@ class APIEndpoints: self.config["delays"] = {} if "repeater" not in self.config: self.config["repeater"] = {} - + # Update TX power (up to 30 dBm for high-power radios) if "tx_power" in data: power = int(data["tx_power"]) @@ -1205,7 +1280,7 @@ class APIEndpoints: return self._error("TX power must be 2-30 dBm") self.config["radio"]["tx_power"] = power applied.append(f"power={power}dBm") - + # Update frequency (in Hz) if "frequency" in data: freq = float(data["frequency"]) @@ -1213,7 +1288,7 @@ class APIEndpoints: return self._error("Frequency must be 100-1000 MHz") self.config["radio"]["frequency"] = freq applied.append(f"freq={freq/1_000_000:.3f}MHz") - + # Update bandwidth (in Hz) if "bandwidth" in data: bw = int(float(data["bandwidth"])) @@ -1222,7 +1297,7 @@ class APIEndpoints: return self._error(f"Bandwidth must be one of {[b/1000 for b in valid_bw]} kHz") self.config["radio"]["bandwidth"] = bw applied.append(f"bw={bw/1000}kHz") - + # Update spreading factor if "spreading_factor" in data: sf = int(data["spreading_factor"]) @@ -1230,7 +1305,7 @@ class APIEndpoints: return self._error("Spreading factor must be 5-12") self.config["radio"]["spreading_factor"] = sf applied.append(f"sf={sf}") - + # Update coding rate if "coding_rate" in data: cr = int(data["coding_rate"]) @@ -1238,7 +1313,7 @@ class APIEndpoints: return self._error("Coding rate must be 5-8 (for 4/5 to 4/8)") self.config["radio"]["coding_rate"] = cr applied.append(f"cr=4/{cr}") - + # Update TX delay factor if "tx_delay_factor" in data: tdf = float(data["tx_delay_factor"]) @@ -1246,7 +1321,7 @@ class APIEndpoints: return self._error("TX delay factor must be 0.0-5.0") self.config["delays"]["tx_delay_factor"] = tdf applied.append(f"txdelay={tdf}") - + # Update direct TX delay factor if "direct_tx_delay_factor" in data: dtdf = float(data["direct_tx_delay_factor"]) @@ -1254,7 +1329,7 @@ class APIEndpoints: return self._error("Direct TX delay factor must be 0.0-5.0") self.config["delays"]["direct_tx_delay_factor"] = dtdf applied.append(f"direct.txdelay={dtdf}") - + # Update RX delay base if "rx_delay_base" in data: rxd = float(data["rx_delay_base"]) @@ -1262,18 +1337,18 @@ class APIEndpoints: return self._error("RX delay cannot be negative") self.config["delays"]["rx_delay_base"] = rxd applied.append(f"rxdelay={rxd}") - + # Update node name if "node_name" in data: name = str(data["node_name"]).strip() if not name: return self._error("Node name cannot be empty") # Validate UTF-8 byte length (31 bytes max + 1 null terminator = 32 bytes total) - if len(name.encode('utf-8')) > 31: + if len(name.encode("utf-8")) > 31: return self._error("Node name too long (max 31 bytes in UTF-8)") self.config["repeater"]["node_name"] = name applied.append(f"name={name}") - + # Update latitude if "latitude" in data: lat = float(data["latitude"]) @@ -1281,7 +1356,7 @@ class APIEndpoints: return self._error("Latitude must be -90 to 90") self.config["repeater"]["latitude"] = lat applied.append(f"lat={lat}") - + # Update longitude if "longitude" in data: lon = float(data["longitude"]) @@ -1289,7 +1364,7 @@ class APIEndpoints: return self._error("Longitude must be -180 to 180") self.config["repeater"]["longitude"] = lon applied.append(f"lon={lon}") - + # Update max flood hops if "max_flood_hops" in data: hops = int(data["max_flood_hops"]) @@ -1297,7 +1372,7 @@ class APIEndpoints: return self._error("Max flood hops must be 0-64") self.config["repeater"]["max_flood_hops"] = hops applied.append(f"flood.max={hops}") - + # Update flood advert interval (hours) if "flood_advert_interval_hours" in data: hours = int(data["flood_advert_interval_hours"]) @@ -1305,7 +1380,7 @@ class APIEndpoints: return self._error("Flood advert interval must be 0 (off) or 3-48 hours") self.config["repeater"]["send_advert_interval_hours"] = hours applied.append(f"flood.advert.interval={hours}h") - + # Update local advert interval (minutes) if "advert_interval_minutes" in data: mins = int(data["advert_interval_minutes"]) @@ -1326,18 +1401,18 @@ class APIEndpoints: if "kiss_baud_rate" in data: self.config["kiss"]["baud_rate"] = int(data["kiss_baud_rate"]) applied.append("kiss.baud_rate") - + if not applied: return self._error("No valid settings provided") - - live_sections = ['repeater', 'delays', 'radio'] + + live_sections = ["repeater", "delays", "radio"] if "kiss" in self.config: live_sections.append("kiss") # Save to config file and live update daemon in one operation result = self.config_manager.update_and_save( updates={}, # Updates already applied to self.config above live_update=True, - live_update_sections=live_sections + live_update_sections=live_sections, ) if not result.get("saved", False): @@ -1345,14 +1420,20 @@ class APIEndpoints: logger.info(f"Radio config updated: {', '.join(applied)}") - return self._success({ - "applied": applied, - "persisted": True, - "live_update": result.get("live_updated", False), - "restart_required": not result.get("live_updated", False), - "message": "Settings applied immediately." if result.get("live_updated") else "Settings saved. Restart service to apply changes." - }) - + return self._success( + { + "applied": applied, + "persisted": True, + "live_update": result.get("live_updated", False), + "restart_required": not result.get("live_updated", False), + "message": ( + "Settings applied immediately." + if result.get("live_updated") + else "Settings saved. Restart service to apply changes." + ), + } + ) + except cherrypy.HTTPError: raise except Exception as e: @@ -1362,79 +1443,69 @@ class APIEndpoints: @cherrypy.expose @cherrypy.tools.json_out() def noise_floor_history(self, hours: int = 24, limit: int = None): - + try: storage = self._get_storage() hours = int(hours) limit = int(limit) if limit else None history = storage.get_noise_floor_history(hours=hours, limit=limit) - - return self._success({ - "history": history, - "hours": hours, - "count": len(history) - }) + + return self._success({"history": history, "hours": hours, "count": len(history)}) except Exception as e: logger.error(f"Error fetching noise floor history: {e}") return self._error(e) - + @cherrypy.expose @cherrypy.tools.json_out() def noise_floor_stats(self, hours: int = 24): - + try: storage = self._get_storage() hours = int(hours) stats = storage.get_noise_floor_stats(hours=hours) - - return self._success({ - "stats": stats, - "hours": hours - }) + + return self._success({"stats": stats, "hours": hours}) except Exception as e: logger.error(f"Error fetching noise floor stats: {e}") return self._error(e) - + @cherrypy.expose @cherrypy.tools.json_out() def noise_floor_chart_data(self, hours: int = 24): - + try: storage = self._get_storage() hours = int(hours) chart_data = storage.get_noise_floor_rrd(hours=hours) - - return self._success({ - "chart_data": chart_data, - "hours": hours - }) + + return self._success({"chart_data": chart_data, "hours": hours}) except Exception as e: logger.error(f"Error fetching noise floor chart data: {e}") return self._error(e) @cherrypy.expose def cad_calibration_stream(self): - cherrypy.response.headers['Content-Type'] = 'text/event-stream' - cherrypy.response.headers['Cache-Control'] = 'no-cache' - cherrypy.response.headers['Connection'] = 'keep-alive' - - if not hasattr(self.cad_calibration, 'message_queue'): + cherrypy.response.headers["Content-Type"] = "text/event-stream" + cherrypy.response.headers["Cache-Control"] = "no-cache" + cherrypy.response.headers["Connection"] = "keep-alive" + + if not hasattr(self.cad_calibration, "message_queue"): self.cad_calibration.message_queue = [] - + def generate(): try: yield f"data: {json.dumps({'type': 'connected', 'message': 'Connected to CAD calibration stream'})}\n\n" - + if self.cad_calibration.running: - config = getattr(self.cad_calibration.daemon_instance, 'config', {}) + config = getattr(self.cad_calibration.daemon_instance, "config", {}) radio_config = config.get("radio", {}) sf = radio_config.get("spreading_factor", 8) - + peak_range, min_range = self.cad_calibration.get_test_ranges(sf) total_tests = len(peak_range) * len(min_range) - + status_message = { - "type": "status", + "type": "status", "message": f"Calibration in progress: SF{sf}, {total_tests} tests", "test_ranges": { "peak_min": min(peak_range), @@ -1442,13 +1513,13 @@ class APIEndpoints: "min_min": min(min_range), "min_max": max(min_range), "spreading_factor": sf, - "total_tests": total_tests - } + "total_tests": total_tests, + }, } yield f"data: {json.dumps(status_message)}\n\n" - + last_message_index = len(self.cad_calibration.message_queue) - + while True: current_queue_length = len(self.cad_calibration.message_queue) if current_queue_length > last_message_index: @@ -1458,43 +1529,39 @@ class APIEndpoints: last_message_index = current_queue_length else: yield f"data: {json.dumps({'type': 'keepalive'})}\n\n" - + time.sleep(0.5) - + except Exception as e: logger.error(f"SSE stream error: {e}") - + return generate() - cad_calibration_stream._cp_config = {'response.stream': True} + cad_calibration_stream._cp_config = {"response.stream": True} @cherrypy.expose @cherrypy.tools.json_out() def adverts_by_contact_type(self, contact_type=None, limit=None, hours=None): - + try: if not contact_type: return self._error("contact_type parameter is required") - + limit_int = int(limit) if limit is not None else None hours_int = int(hours) if hours is not None else None - + storage = self._get_storage() adverts = storage.sqlite_handler.get_adverts_by_contact_type( - contact_type=contact_type, - limit=limit_int, - hours=hours_int + contact_type=contact_type, limit=limit_int, hours=hours_int ) - - return self._success(adverts, - count=len(adverts), - contact_type=contact_type, - filters={ - "contact_type": contact_type, - "limit": limit_int, - "hours": hours_int - }) - + + return self._success( + adverts, + count=len(adverts), + contact_type=contact_type, + filters={"contact_type": contact_type, "limit": limit_int, "hours": hours_int}, + ) + except ValueError as e: return self._error(f"Invalid parameter format: {e}") except Exception as e: @@ -1505,7 +1572,7 @@ class APIEndpoints: @cherrypy.tools.json_out() @cherrypy.tools.json_in() def transport_keys(self): - + if cherrypy.request.method == "GET": try: storage = self._get_storage() @@ -1514,7 +1581,7 @@ class APIEndpoints: except Exception as e: logger.error(f"Error getting transport keys: {e}") return self._error(e) - + elif cherrypy.request.method == "POST": try: data = cherrypy.request.json or {} @@ -1523,30 +1590,35 @@ class APIEndpoints: transport_key = data.get("transport_key") # Optional now parent_id = data.get("parent_id") last_used = data.get("last_used") - + if not name or not flood_policy: return self._error("Missing required fields: name, flood_policy") - + if flood_policy not in ["allow", "deny"]: return self._error("flood_policy must be 'allow' or 'deny'") - + # Convert ISO timestamp string to float if provided if last_used: try: from datetime import datetime - dt = datetime.fromisoformat(last_used.replace('Z', '+00:00')) + + dt = datetime.fromisoformat(last_used.replace("Z", "+00:00")) last_used = dt.timestamp() except (ValueError, AttributeError): # If conversion fails, use current time last_used = time.time() else: last_used = time.time() - + storage = self._get_storage() - key_id = storage.create_transport_key(name, flood_policy, transport_key, parent_id, last_used) - + key_id = storage.create_transport_key( + name, flood_policy, transport_key, parent_id, last_used + ) + if key_id: - return self._success({"id": key_id}, message="Transport key created successfully") + return self._success( + {"id": key_id}, message="Transport key created successfully" + ) else: return self._error("Failed to create transport key") except Exception as e: @@ -1557,7 +1629,7 @@ class APIEndpoints: @cherrypy.tools.json_out() @cherrypy.tools.json_in() def transport_key(self, key_id): - + if cherrypy.request.method == "GET": try: key_id = int(key_id) @@ -1572,35 +1644,39 @@ class APIEndpoints: except Exception as e: logger.error(f"Error getting transport key: {e}") return self._error(e) - + elif cherrypy.request.method == "PUT": try: key_id = int(key_id) data = cherrypy.request.json or {} - + name = data.get("name") flood_policy = data.get("flood_policy") transport_key = data.get("transport_key") parent_id = data.get("parent_id") last_used = data.get("last_used") - + if flood_policy and flood_policy not in ["allow", "deny"]: return self._error("flood_policy must be 'allow' or 'deny'") - + # Convert ISO timestamp string to float if provided if last_used: try: - dt = datetime.fromisoformat(last_used.replace('Z', '+00:00')) + dt = datetime.fromisoformat(last_used.replace("Z", "+00:00")) last_used = dt.timestamp() except (ValueError, AttributeError): # If conversion fails, leave as None to not update last_used = None - + storage = self._get_storage() - success = storage.update_transport_key(key_id, name, flood_policy, transport_key, parent_id, last_used) - + success = storage.update_transport_key( + key_id, name, flood_policy, transport_key, parent_id, last_used + ) + if success: - return self._success({"id": key_id}, message="Transport key updated successfully") + return self._success( + {"id": key_id}, message="Transport key updated successfully" + ) else: return self._error("Failed to update transport key or key not found") except ValueError: @@ -1608,15 +1684,17 @@ class APIEndpoints: except Exception as e: logger.error(f"Error updating transport key: {e}") return self._error(e) - + elif cherrypy.request.method == "DELETE": try: key_id = int(key_id) storage = self._get_storage() success = storage.delete_transport_key(key_id) - + if success: - return self._success({"id": key_id}, message="Transport key deleted successfully") + return self._success( + {"id": key_id}, message="Transport key deleted successfully" + ) else: return self._error("Failed to delete transport key or key not found") except ValueError: @@ -1629,10 +1707,9 @@ class APIEndpoints: @cherrypy.tools.json_out() @cherrypy.tools.json_in() def global_flood_policy(self): - """ Update global flood policy configuration - + POST /global_flood_policy Body: {"global_flood_allow": true/false} """ @@ -1640,42 +1717,44 @@ class APIEndpoints: try: data = cherrypy.request.json or {} global_flood_allow = data.get("global_flood_allow") - + if global_flood_allow is None: return self._error("Missing required field: global_flood_allow") - + if not isinstance(global_flood_allow, bool): return self._error("global_flood_allow must be a boolean value") - + # Update the running configuration first (like CAD settings) if "mesh" not in self.config: self.config["mesh"] = {} self.config["mesh"]["global_flood_allow"] = global_flood_allow - + # Get the actual config path from daemon instance (same as CAD settings) - config_path = getattr(self, '_config_path', '/etc/pymc_repeater/config.yaml') - if self.daemon_instance and hasattr(self.daemon_instance, 'config_path'): + config_path = getattr(self, "_config_path", "/etc/pymc_repeater/config.yaml") + if self.daemon_instance and hasattr(self.daemon_instance, "config_path"): config_path = self.daemon_instance.config_path - + logger.info(f"Using config path for global flood policy: {config_path}") - + # Update the configuration file using ConfigManager try: saved, err = self.config_manager.save_to_file() if saved: - logger.info(f"Updated running config and saved global flood policy to file: {'allow' if global_flood_allow else 'deny'}") + logger.info( + f"Updated running config and saved global flood policy to file: {'allow' if global_flood_allow else 'deny'}" + ) else: logger.error(f"Failed to save global flood policy to file: {err}") return self._error(err or "Failed to save configuration to file") except Exception as e: logger.error(f"Failed to save global flood policy to file: {e}") return self._error(f"Failed to save configuration to file: {e}") - + return self._success( {"global_flood_allow": global_flood_allow}, - message=f"Global flood policy updated to {'allow' if global_flood_allow else 'deny'} (live and saved)" + message=f"Global flood policy updated to {'allow' if global_flood_allow else 'deny'} (live and saved)", ) - + except Exception as e: logger.error(f"Error updating global flood policy: {e}") return self._error(e) @@ -1688,7 +1767,7 @@ class APIEndpoints: def advert(self, advert_id): # Enable CORS for this endpoint only if configured self._set_cors_headers() - + if cherrypy.request.method == "OPTIONS": return "" elif cherrypy.request.method == "DELETE": @@ -1696,7 +1775,7 @@ class APIEndpoints: advert_id = int(advert_id) storage = self._get_storage() success = storage.delete_advert(advert_id) - + if success: return self._success({"id": advert_id}, message="Neighbor deleted successfully") else: @@ -1717,20 +1796,20 @@ class APIEndpoints: # Enable CORS for this endpoint only if configured self._set_cors_headers() - + # Handle OPTIONS request for CORS preflight if cherrypy.request.method == "OPTIONS": return "" - + try: self._require_post() data = cherrypy.request.json or {} target_id = data.get("target_id") timeout = int(data.get("timeout", 10)) - + if not target_id: return self._error("Missing target_id parameter") - + # Parse target hash (accepts hex string like "0xA5" or "a5") try: target_hash = int(target_id, 16) if isinstance(target_id, str) else int(target_id) @@ -1738,86 +1817,88 @@ class APIEndpoints: return self._error("target_id must be a valid byte (0x00-0xFF)") except ValueError: return self._error(f"Invalid target_id format: {target_id}") - + # Check if router and trace_helper are available - if not hasattr(self.daemon_instance, 'router'): + if not hasattr(self.daemon_instance, "router"): return self._error("Packet router not available") - + router = self.daemon_instance.router - if not hasattr(self.daemon_instance, 'trace_helper'): + if not hasattr(self.daemon_instance, "trace_helper"): return self._error("Trace helper not available") - + trace_helper = self.daemon_instance.trace_helper - + # Generate unique tag for this ping import random + trace_tag = random.randint(0, 0xFFFFFFFF) - + # Create trace packet from pymc_core.protocol import PacketBuilder + packet = PacketBuilder.create_trace( - tag=trace_tag, - auth_code=0x12345678, - flags=0x00, - path=[target_hash] + tag=trace_tag, auth_code=0x12345678, flags=0x00, path=[target_hash] ) - + # Wait for response with timeout import asyncio - + async def send_and_wait(): """Async helper to send ping and wait for response""" # Register ping with TraceHelper (must be done in async context) event = trace_helper.register_ping(trace_tag, target_hash) - + # Send packet via router await router.inject_packet(packet) logger.info(f"Ping sent to 0x{target_hash:02x} with tag {trace_tag}") - + try: await asyncio.wait_for(event.wait(), timeout=timeout) return True except asyncio.TimeoutError: return False - + # Run the async send and wait in the daemon's event loop try: if self.event_loop is None: return self._error("Event loop not available") - + future = asyncio.run_coroutine_threadsafe(send_and_wait(), self.event_loop) response_received = future.result(timeout=timeout + 1) except Exception as e: logger.error(f"Error waiting for ping response: {e}") trace_helper.pending_pings.pop(trace_tag, None) return self._error(f"Error waiting for response: {str(e)}") - + if response_received: # Get result ping_info = trace_helper.pending_pings.pop(trace_tag, None) if not ping_info: return self._error("Ping info not found after response") - - result = ping_info.get('result') + + result = ping_info.get("result") if result: # Calculate round-trip time - rtt_ms = (result['received_at'] - ping_info['sent_at']) * 1000 - - return self._success({ - "target_id": f"0x{target_hash:02x}", - "rtt_ms": round(rtt_ms, 2), - "snr_db": result['snr'], - "rssi": result['rssi'], - "path": [f"0x{h:02x}" for h in result['path']], - "tag": trace_tag - }, message="Ping successful") + rtt_ms = (result["received_at"] - ping_info["sent_at"]) * 1000 + + return self._success( + { + "target_id": f"0x{target_hash:02x}", + "rtt_ms": round(rtt_ms, 2), + "snr_db": result["snr"], + "rssi": result["rssi"], + "path": [f"0x{h:02x}" for h in result["path"]], + "tag": trace_tag, + }, + message="Ping successful", + ) else: return self._error("Received response but no data") else: # Timeout trace_helper.pending_pings.pop(trace_tag, None) return self._error(f"Ping timeout after {timeout}s") - + except cherrypy.HTTPError: raise except Exception as e: @@ -1825,68 +1906,73 @@ class APIEndpoints: return self._error(str(e)) # ========== Identity Management Endpoints ========== - + @cherrypy.expose @cherrypy.tools.json_out() def identities(self): """ GET /api/identities - List all registered identities - + Returns both the in-memory registered identities and the configured ones from YAML """ # Enable CORS for this endpoint only if configured self._set_cors_headers() - + if cherrypy.request.method == "OPTIONS": return "" - + try: - if not self.daemon_instance or not hasattr(self.daemon_instance, 'identity_manager'): + if not self.daemon_instance or not hasattr(self.daemon_instance, "identity_manager"): return self._error("Identity manager not available") - + # Get runtime registered identities identity_manager = self.daemon_instance.identity_manager registered_identities = identity_manager.list_identities() - + # Get configured identities from config identities_config = self.config.get("identities", {}) room_servers = identities_config.get("room_servers") or [] - + # Enhance with config data configured = [] for room_config in room_servers: name = room_config.get("name") identity_key = room_config.get("identity_key", "") settings = room_config.get("settings", {}) - + # Find matching registered identity for additional data matching = next( - (r for r in registered_identities if r["name"] == f"room_server:{name}"), - None + (r for r in registered_identities if r["name"] == f"room_server:{name}"), None ) - - configured.append({ - "name": name, - "type": "room_server", - "identity_key": identity_key[:16] + "..." if len(identity_key) > 16 else identity_key, - "identity_key_length": len(identity_key), - "settings": settings, - "hash": matching["hash"] if matching else None, - "address": matching["address"] if matching else None, - "registered": matching is not None - }) - - return self._success({ - "registered": registered_identities, - "configured": configured, - "total_registered": len(registered_identities), - "total_configured": len(configured) - }) - + + configured.append( + { + "name": name, + "type": "room_server", + "identity_key": ( + identity_key[:16] + "..." if len(identity_key) > 16 else identity_key + ), + "identity_key_length": len(identity_key), + "settings": settings, + "hash": matching["hash"] if matching else None, + "address": matching["address"] if matching else None, + "registered": matching is not None, + } + ) + + return self._success( + { + "registered": registered_identities, + "configured": configured, + "total_registered": len(registered_identities), + "total_configured": len(configured), + } + ) + except Exception as e: logger.error(f"Error listing identities: {e}") return self._error(e) - + @cherrypy.expose @cherrypy.tools.json_out() def identity(self, name=None): @@ -1895,55 +1981,52 @@ class APIEndpoints: """ # Enable CORS for this endpoint only if configured self._set_cors_headers() - + if cherrypy.request.method == "OPTIONS": return "" - + try: if not name: return self._error("Missing name parameter") - + identities_config = self.config.get("identities", {}) room_servers = identities_config.get("room_servers") or [] - + # Find the identity in config - identity_config = next( - (r for r in room_servers if r.get("name") == name), - None - ) - + identity_config = next((r for r in room_servers if r.get("name") == name), None) + if not identity_config: return self._error(f"Identity '{name}' not found") - + # Get runtime info if available - if self.daemon_instance and hasattr(self.daemon_instance, 'identity_manager'): + if self.daemon_instance and hasattr(self.daemon_instance, "identity_manager"): identity_manager = self.daemon_instance.identity_manager runtime_info = identity_manager.get_identity_by_name(name) - + if runtime_info: identity_obj, config, identity_type = runtime_info identity_config["runtime"] = { "hash": f"0x{identity_obj.get_public_key()[0]:02X}", "address": identity_obj.get_address_bytes().hex(), "type": identity_type, - "registered": True + "registered": True, } else: identity_config["runtime"] = {"registered": False} - + return self._success(identity_config) - + except Exception as e: logger.error(f"Error getting identity: {e}") return self._error(e) - + @cherrypy.expose @cherrypy.tools.json_out() @cherrypy.tools.json_in() def create_identity(self): """ POST /api/create_identity - Create a new identity - + Body: { "name": "MyRoomServer", "identity_key": "hex_key_string", # Optional - will be auto-generated if not provided @@ -1960,28 +2043,28 @@ class APIEndpoints: """ # Enable CORS for this endpoint only if configured self._set_cors_headers() - + if cherrypy.request.method == "OPTIONS": return "" - + try: self._require_post() data = cherrypy.request.json or {} - + name = data.get("name") identity_key = data.get("identity_key") identity_type = data.get("type", "room_server") settings = data.get("settings", {}) - + if not name: return self._error("Missing required field: name") - + # Validate passwords are different if both provided admin_pw = settings.get("admin_password") guest_pw = settings.get("guest_password") if admin_pw and guest_pw and admin_pw == guest_pw: return self._error("admin_password and guest_password must be different") - + # Auto-generate identity key if not provided key_was_generated = False if not identity_key: @@ -1994,46 +2077,50 @@ class APIEndpoints: except Exception as gen_error: logger.error(f"Failed to auto-generate identity key: {gen_error}") return self._error(f"Failed to auto-generate identity key: {gen_error}") - + # Validate identity type if identity_type not in ["room_server"]: - return self._error(f"Invalid identity type: {identity_type}. Only 'room_server' is supported.") - + return self._error( + f"Invalid identity type: {identity_type}. Only 'room_server' is supported." + ) + # Check if identity already exists identities_config = self.config.get("identities", {}) room_servers = identities_config.get("room_servers") or [] - + if any(r.get("name") == name for r in room_servers): return self._error(f"Identity with name '{name}' already exists") - + # Create new identity config new_identity = { "name": name, "identity_key": identity_key, "type": identity_type, - "settings": settings + "settings": settings, } - + # Add to config room_servers.append(new_identity) - + if "identities" not in self.config: self.config["identities"] = {} self.config["identities"]["room_servers"] = room_servers - + # Save to file saved, err = self.config_manager.save_to_file() if not saved: return self._error(err or "Failed to save configuration to file") - logger.info(f"Created new identity: {name} (type: {identity_type}){' with auto-generated key' if key_was_generated else ''}") - + logger.info( + f"Created new identity: {name} (type: {identity_type}){' with auto-generated key' if key_was_generated else ''}" + ) + # Hot reload - register identity immediately registration_success = False if self.daemon_instance: try: from pymc_core import LocalIdentity - + # Create LocalIdentity from the key (convert hex string to bytes) if isinstance(identity_key, bytes): identity_key_bytes = identity_key @@ -2042,51 +2129,60 @@ class APIEndpoints: identity_key_bytes = bytes.fromhex(identity_key) except ValueError as e: logger.error(f"Identity key for {name} is not valid hex string: {e}") - identity_key_bytes = identity_key.encode('latin-1') if len(identity_key) == 32 else identity_key.encode('utf-8') + identity_key_bytes = ( + identity_key.encode("latin-1") + if len(identity_key) == 32 + else identity_key.encode("utf-8") + ) else: logger.error(f"Unknown identity_key type: {type(identity_key)}") identity_key_bytes = bytes(identity_key) - + room_identity = LocalIdentity(seed=identity_key_bytes) - + # Use the consolidated registration method - if hasattr(self.daemon_instance, '_register_identity_everywhere'): + if hasattr(self.daemon_instance, "_register_identity_everywhere"): registration_success = self.daemon_instance._register_identity_everywhere( name=name, identity=room_identity, config=new_identity, - identity_type=identity_type + identity_type=identity_type, ) if registration_success: - logger.info(f"Hot reload: Registered identity '{name}' with all systems") + logger.info( + f"Hot reload: Registered identity '{name}' with all systems" + ) else: logger.warning(f"Hot reload: Failed to register identity '{name}'") - + except Exception as reg_error: - logger.error(f"Failed to hot reload identity {name}: {reg_error}", exc_info=True) - - message = f"Identity '{name}' created successfully and activated immediately!" if registration_success else f"Identity '{name}' created successfully. Restart required to activate." + logger.error( + f"Failed to hot reload identity {name}: {reg_error}", exc_info=True + ) + + message = ( + f"Identity '{name}' created successfully and activated immediately!" + if registration_success + else f"Identity '{name}' created successfully. Restart required to activate." + ) if key_was_generated: message += " Identity key was auto-generated." - - return self._success( - new_identity, - message=message - ) - + + return self._success(new_identity, message=message) + except cherrypy.HTTPError: raise except Exception as e: logger.error(f"Error creating identity: {e}") return self._error(e) - + @cherrypy.expose @cherrypy.tools.json_out() @cherrypy.tools.json_in() def update_identity(self): """ PUT /api/update_identity - Update an existing identity - + Body: { "name": "MyRoomServer", # Required - used to find identity "new_name": "RenamedRoom", # Optional - rename identity @@ -2102,44 +2198,47 @@ class APIEndpoints: """ # Enable CORS for this endpoint only if configured self._set_cors_headers() - + if cherrypy.request.method == "OPTIONS": return "" - + try: if cherrypy.request.method != "PUT": cherrypy.response.status = 405 - cherrypy.response.headers['Allow'] = 'PUT' + cherrypy.response.headers["Allow"] = "PUT" raise cherrypy.HTTPError(405, "Method not allowed. This endpoint requires PUT.") - + data = cherrypy.request.json or {} - + name = data.get("name") if not name: return self._error("Missing required field: name") - + identities_config = self.config.get("identities", {}) room_servers = identities_config.get("room_servers") or [] - + # Find the identity identity_index = next( - (i for i, r in enumerate(room_servers) if r.get("name") == name), - None + (i for i, r in enumerate(room_servers) if r.get("name") == name), None ) - + if identity_index is None: return self._error(f"Identity '{name}' not found") - + # Update fields identity = room_servers[identity_index] - + if "new_name" in data: new_name = data["new_name"] # Check if new name conflicts - if any(r.get("name") == new_name for i, r in enumerate(room_servers) if i != identity_index): + if any( + r.get("name") == new_name + for i, r in enumerate(room_servers) + if i != identity_index + ): return self._error(f"Identity with name '{new_name}' already exists") identity["name"] = new_name - + # Only update identity_key if a valid full key is provided # Silently reject truncated keys (containing "...") or invalid hex strings if "identity_key" in data and data["identity_key"]: @@ -2154,19 +2253,19 @@ class APIEndpoints: except ValueError: # Invalid hex, silently ignore pass - + if "settings" in data: # Merge settings if "settings" not in identity: identity["settings"] = {} identity["settings"].update(data["settings"]) - + # Validate passwords are different if both are now set admin_pw = identity["settings"].get("admin_password") guest_pw = identity["settings"].get("guest_password") if admin_pw and guest_pw and admin_pw == guest_pw: return self._error("admin_password and guest_password must be different") - + # Save to config room_servers[identity_index] = identity self.config["identities"]["room_servers"] = room_servers @@ -2176,19 +2275,19 @@ class APIEndpoints: return self._error(err or "Failed to save configuration to file") logger.info(f"Updated identity: {name}") - + # Hot reload - re-register identity if key changed or name changed registration_success = False # Only reload if identity_key was actually provided and not empty, or if name changed - needs_reload = (data.get("identity_key") or "new_name" in data) - + needs_reload = data.get("identity_key") or "new_name" in data + if needs_reload and self.daemon_instance: try: from pymc_core import LocalIdentity - + final_name = identity["name"] # Could be new_name identity_key = identity["identity_key"] - + # Create LocalIdentity from the key (convert hex string to bytes) if isinstance(identity_key, bytes): identity_key_bytes = identity_key @@ -2196,46 +2295,61 @@ class APIEndpoints: try: identity_key_bytes = bytes.fromhex(identity_key) except ValueError as e: - logger.error(f"Identity key for {final_name} is not valid hex string: {e}") - identity_key_bytes = identity_key.encode('latin-1') if len(identity_key) == 32 else identity_key.encode('utf-8') + logger.error( + f"Identity key for {final_name} is not valid hex string: {e}" + ) + identity_key_bytes = ( + identity_key.encode("latin-1") + if len(identity_key) == 32 + else identity_key.encode("utf-8") + ) else: logger.error(f"Unknown identity_key type: {type(identity_key)}") identity_key_bytes = bytes(identity_key) - + room_identity = LocalIdentity(seed=identity_key_bytes) - + # Use the consolidated registration method - if hasattr(self.daemon_instance, '_register_identity_everywhere'): + if hasattr(self.daemon_instance, "_register_identity_everywhere"): registration_success = self.daemon_instance._register_identity_everywhere( name=final_name, identity=room_identity, config=identity, - identity_type="room_server" + identity_type="room_server", ) if registration_success: - logger.info(f"Hot reload: Re-registered identity '{final_name}' with all systems") + logger.info( + f"Hot reload: Re-registered identity '{final_name}' with all systems" + ) else: - logger.warning(f"Hot reload: Failed to re-register identity '{final_name}'") - + logger.warning( + f"Hot reload: Failed to re-register identity '{final_name}'" + ) + except Exception as reg_error: - logger.error(f"Failed to hot reload identity {name}: {reg_error}", exc_info=True) - + logger.error( + f"Failed to hot reload identity {name}: {reg_error}", exc_info=True + ) + if needs_reload: - message = f"Identity '{name}' updated successfully and changes applied immediately!" if registration_success else f"Identity '{name}' updated successfully. Restart required to apply changes." + message = ( + f"Identity '{name}' updated successfully and changes applied immediately!" + if registration_success + else f"Identity '{name}' updated successfully. Restart required to apply changes." + ) else: - message = f"Identity '{name}' updated successfully (settings only, no reload needed)." - - return self._success( - identity, - message=message - ) - + message = ( + f"Identity '{name}' updated successfully (settings only, no reload needed)." + ) + + return self._success(identity, message=message) + except cherrypy.HTTPError: raise except Exception as e: logger.error(f"Error updating identity: {e}") return self._error(e) - + @cherrypy.expose @cherrypy.tools.json_out() def delete_identity(self, name=None): @@ -2244,29 +2358,29 @@ class APIEndpoints: """ # Enable CORS for this endpoint only if configured self._set_cors_headers() - + if cherrypy.request.method == "OPTIONS": return "" - + try: if cherrypy.request.method != "DELETE": cherrypy.response.status = 405 - cherrypy.response.headers['Allow'] = 'DELETE' + cherrypy.response.headers["Allow"] = "DELETE" raise cherrypy.HTTPError(405, "Method not allowed. This endpoint requires DELETE.") - + if not name: return self._error("Missing name parameter") - + identities_config = self.config.get("identities", {}) room_servers = identities_config.get("room_servers") or [] - + # Find and remove the identity initial_count = len(room_servers) room_servers = [r for r in room_servers if r.get("name") != name] - + if len(room_servers) == initial_count: return self._error(f"Identity '{name}' not found") - + # Update config self.config["identities"]["room_servers"] = room_servers @@ -2275,138 +2389,150 @@ class APIEndpoints: return self._error(err or "Failed to save configuration to file") logger.info(f"Deleted identity: {name}") - + unregister_success = False if self.daemon_instance: try: - if hasattr(self.daemon_instance, 'identity_manager'): + if hasattr(self.daemon_instance, "identity_manager"): identity_manager = self.daemon_instance.identity_manager - + # Remove from named_identities dict if name in identity_manager.named_identities: del identity_manager.named_identities[name] logger.info(f"Removed identity {name} from named_identities") unregister_success = True - + # Note: We don't remove from identities dict (keyed by hash) # because we'd need to look up the hash first, and there could # be multiple identities with the same hash # Full cleanup happens on restart - + except Exception as unreg_error: - logger.error(f"Failed to unregister identity {name}: {unreg_error}", exc_info=True) - - message = f"Identity '{name}' deleted successfully and deactivated immediately!" if unregister_success else f"Identity '{name}' deleted successfully. Restart required to fully remove." - - return self._success( - {"name": name}, - message=message + logger.error( + f"Failed to unregister identity {name}: {unreg_error}", exc_info=True + ) + + message = ( + f"Identity '{name}' deleted successfully and deactivated immediately!" + if unregister_success + else f"Identity '{name}' deleted successfully. Restart required to fully remove." ) - + + return self._success({"name": name}, message=message) + except cherrypy.HTTPError: raise except Exception as e: logger.error(f"Error deleting identity: {e}") return self._error(e) - + @cherrypy.expose @cherrypy.tools.json_out() @cherrypy.tools.json_in() def send_room_server_advert(self): """ POST /api/send_room_server_advert - Send advert for a room server - + Body: { "name": "MyRoomServer" } """ # Enable CORS for this endpoint only if configured self._set_cors_headers() - + if cherrypy.request.method == "OPTIONS": return "" - + try: self._require_post() - + if not self.daemon_instance: return self._error("Daemon not available") - + data = cherrypy.request.json or {} name = data.get("name") - + if not name: return self._error("Missing required field: name") - + # Get the identity from identity manager - if not hasattr(self.daemon_instance, 'identity_manager'): + if not hasattr(self.daemon_instance, "identity_manager"): return self._error("Identity manager not available") - + identity_manager = self.daemon_instance.identity_manager identity_info = identity_manager.get_identity_by_name(name) - + if not identity_info: return self._error(f"Room server '{name}' not found or not registered") - + identity, config, identity_type = identity_info - + if identity_type != "room_server": return self._error(f"Identity '{name}' is not a room server") - + # Get settings from config settings = config.get("settings", {}) node_name = settings.get("node_name", name) latitude = settings.get("latitude", 0.0) longitude = settings.get("longitude", 0.0) disable_fwd = settings.get("disable_fwd", False) - + # Send the advert asynchronously if self.event_loop is None: return self._error("Event loop not available") - + import asyncio + future = asyncio.run_coroutine_threadsafe( self._send_room_server_advert_async( identity=identity, node_name=node_name, latitude=latitude, longitude=longitude, - disable_fwd=disable_fwd + disable_fwd=disable_fwd, ), - self.event_loop + self.event_loop, ) - + result = future.result(timeout=10) - + if result: - return self._success({ - "name": name, - "node_name": node_name, - "latitude": latitude, - "longitude": longitude - }, message=f"Advert sent for room server '{node_name}'") + return self._success( + { + "name": name, + "node_name": node_name, + "latitude": latitude, + "longitude": longitude, + }, + message=f"Advert sent for room server '{node_name}'", + ) else: return self._error(f"Failed to send advert for room server '{name}'") - + except cherrypy.HTTPError: raise except Exception as e: logger.error(f"Error sending room server advert: {e}", exc_info=True) return self._error(e) - - async def _send_room_server_advert_async(self, identity, node_name, latitude, longitude, disable_fwd): + + async def _send_room_server_advert_async( + self, identity, node_name, latitude, longitude, disable_fwd + ): """Send advert for a room server identity""" try: from pymc_core.protocol import PacketBuilder - from pymc_core.protocol.constants import ADVERT_FLAG_HAS_NAME, ADVERT_FLAG_IS_ROOM_SERVER - + from pymc_core.protocol.constants import ( + ADVERT_FLAG_HAS_NAME, + ADVERT_FLAG_IS_ROOM_SERVER, + ) + if not self.daemon_instance or not self.daemon_instance.dispatcher: logger.error("Cannot send advert: dispatcher not initialized") return False - + # Build flags - just use HAS_NAME for room servers flags = ADVERT_FLAG_IS_ROOM_SERVER | ADVERT_FLAG_HAS_NAME - + packet = PacketBuilder.create_advert( local_identity=identity, name=node_name, @@ -2417,30 +2543,32 @@ class APIEndpoints: flags=flags, route_type="flood", ) - + # Send via dispatcher await self.daemon_instance.dispatcher.send_packet(packet, wait_for_ack=False) - + # Mark as seen to prevent re-forwarding if self.daemon_instance.repeater_handler: self.daemon_instance.repeater_handler.mark_seen(packet) logger.debug(f"Marked room server advert '{node_name}' as seen in duplicate cache") - - logger.info(f"Sent flood advert for room server '{node_name}' at ({latitude:.6f}, {longitude:.6f})") + + logger.info( + f"Sent flood advert for room server '{node_name}' at ({latitude:.6f}, {longitude:.6f})" + ) return True - + except Exception as e: logger.error(f"Failed to send room server advert: {e}", exc_info=True) return False # ========== ACL (Access Control List) Endpoints ========== - + @cherrypy.expose @cherrypy.tools.json_out() def acl_info(self): """ GET /api/acl_info - Get ACL configuration and statistics - + Returns ACL settings for all registered identities including: - Identity name, type, and hash - Max clients allowed @@ -2450,75 +2578,83 @@ class APIEndpoints: """ # Enable CORS for this endpoint only if configured self._set_cors_headers() - + if cherrypy.request.method == "OPTIONS": return "" - + try: - if not self.daemon_instance or not hasattr(self.daemon_instance, 'login_helper'): + if not self.daemon_instance or not hasattr(self.daemon_instance, "login_helper"): return self._error("Login helper not available") - + login_helper = self.daemon_instance.login_helper identity_manager = self.daemon_instance.identity_manager - + acl_dict = login_helper.get_acl_dict() - + acl_info_list = [] - + # Add repeater identity if self.daemon_instance.local_identity: repeater_hash = self.daemon_instance.local_identity.get_public_key()[0] repeater_acl = acl_dict.get(repeater_hash) - + if repeater_acl: - acl_info_list.append({ - "name": "repeater", - "type": "repeater", - "hash": f"0x{repeater_hash:02X}", - "max_clients": repeater_acl.max_clients, - "authenticated_clients": repeater_acl.get_num_clients(), - "has_admin_password": bool(repeater_acl.admin_password), - "has_guest_password": bool(repeater_acl.guest_password), - "allow_read_only": repeater_acl.allow_read_only - }) - + acl_info_list.append( + { + "name": "repeater", + "type": "repeater", + "hash": f"0x{repeater_hash:02X}", + "max_clients": repeater_acl.max_clients, + "authenticated_clients": repeater_acl.get_num_clients(), + "has_admin_password": bool(repeater_acl.admin_password), + "has_guest_password": bool(repeater_acl.guest_password), + "allow_read_only": repeater_acl.allow_read_only, + } + ) + # Add room server identities for name, identity, config in identity_manager.get_identities_by_type("room_server"): hash_byte = identity.get_public_key()[0] acl = acl_dict.get(hash_byte) - + if acl: - acl_info_list.append({ - "name": name, - "type": "room_server", - "hash": f"0x{hash_byte:02X}", - "max_clients": acl.max_clients, - "authenticated_clients": acl.get_num_clients(), - "has_admin_password": bool(acl.admin_password), - "has_guest_password": bool(acl.guest_password), - "allow_read_only": acl.allow_read_only - }) - - return self._success({ - "acls": acl_info_list, - "total_identities": len(acl_info_list), - "total_authenticated_clients": sum(a["authenticated_clients"] for a in acl_info_list) - }) - + acl_info_list.append( + { + "name": name, + "type": "room_server", + "hash": f"0x{hash_byte:02X}", + "max_clients": acl.max_clients, + "authenticated_clients": acl.get_num_clients(), + "has_admin_password": bool(acl.admin_password), + "has_guest_password": bool(acl.guest_password), + "allow_read_only": acl.allow_read_only, + } + ) + + return self._success( + { + "acls": acl_info_list, + "total_identities": len(acl_info_list), + "total_authenticated_clients": sum( + a["authenticated_clients"] for a in acl_info_list + ), + } + ) + except Exception as e: logger.error(f"Error getting ACL info: {e}") return self._error(e) - + @cherrypy.expose @cherrypy.tools.json_out() def acl_clients(self, identity_hash=None, identity_name=None): """ GET /api/acl_clients - Get authenticated clients - + Query parameters: - identity_hash: Filter by identity hash (e.g., "0x42") - identity_name: Filter by identity name (e.g., "repeater" or room server name) - + Returns list of authenticated clients with: - Public key (truncated) - Full address @@ -2529,45 +2665,49 @@ class APIEndpoints: """ # Enable CORS for this endpoint only if configured self._set_cors_headers() - + if cherrypy.request.method == "OPTIONS": return "" - + try: - if not self.daemon_instance or not hasattr(self.daemon_instance, 'login_helper'): + if not self.daemon_instance or not hasattr(self.daemon_instance, "login_helper"): return self._error("Login helper not available") - + login_helper = self.daemon_instance.login_helper identity_manager = self.daemon_instance.identity_manager acl_dict = login_helper.get_acl_dict() - + # Build a mapping of hash to identity info identity_map = {} - + # Add repeater if self.daemon_instance.local_identity: repeater_hash = self.daemon_instance.local_identity.get_public_key()[0] identity_map[repeater_hash] = { "name": "repeater", "type": "repeater", - "hash": f"0x{repeater_hash:02X}" + "hash": f"0x{repeater_hash:02X}", } - + # Add room servers for name, identity, config in identity_manager.get_identities_by_type("room_server"): hash_byte = identity.get_public_key()[0] identity_map[hash_byte] = { "name": name, "type": "room_server", - "hash": f"0x{hash_byte:02X}" + "hash": f"0x{hash_byte:02X}", } - + # Filter by identity if requested target_hash = None if identity_hash: # Convert "0x42" to int try: - target_hash = int(identity_hash, 16) if identity_hash.startswith("0x") else int(identity_hash) + target_hash = ( + int(identity_hash, 16) + if identity_hash.startswith("0x") + else int(identity_hash) + ) except ValueError: return self._error(f"Invalid identity_hash format: {identity_hash}") elif identity_name: @@ -2578,71 +2718,76 @@ class APIEndpoints: break if target_hash is None: return self._error(f"Identity '{identity_name}' not found") - + # Collect clients clients_list = [] - + logger.info(f"ACL dict has {len(acl_dict)} identities") - + for hash_byte, acl in acl_dict.items(): # Skip if filtering by specific identity if target_hash is not None and hash_byte != target_hash: continue - - identity_info = identity_map.get(hash_byte, { - "name": "unknown", - "type": "unknown", - "hash": f"0x{hash_byte:02X}" - }) - + + identity_info = identity_map.get( + hash_byte, {"name": "unknown", "type": "unknown", "hash": f"0x{hash_byte:02X}"} + ) + all_clients = acl.get_all_clients() - logger.info(f"Identity {identity_info['name']} (0x{hash_byte:02X}) has {len(all_clients)} clients") - + logger.info( + f"Identity {identity_info['name']} (0x{hash_byte:02X}) has {len(all_clients)} clients" + ) + for client in all_clients: try: pub_key = client.id.get_public_key() - + # Compute address from public key (first byte of SHA256) address_bytes = CryptoUtils.sha256(pub_key)[:1] - - clients_list.append({ - "public_key": pub_key[:8].hex() + "..." + pub_key[-4:].hex(), - "public_key_full": pub_key.hex(), - "address": address_bytes.hex(), - "permissions": "admin" if client.is_admin() else "guest", - "last_activity": client.last_activity, - "last_login_success": client.last_login_success, - "last_timestamp": client.last_timestamp, - "identity_name": identity_info["name"], - "identity_type": identity_info["type"], - "identity_hash": identity_info["hash"] - }) + + clients_list.append( + { + "public_key": pub_key[:8].hex() + "..." + pub_key[-4:].hex(), + "public_key_full": pub_key.hex(), + "address": address_bytes.hex(), + "permissions": "admin" if client.is_admin() else "guest", + "last_activity": client.last_activity, + "last_login_success": client.last_login_success, + "last_timestamp": client.last_timestamp, + "identity_name": identity_info["name"], + "identity_type": identity_info["type"], + "identity_hash": identity_info["hash"], + } + ) except Exception as client_error: logger.error(f"Error processing client: {client_error}", exc_info=True) continue - + logger.info(f"Returning {len(clients_list)} total clients") - - return self._success({ - "clients": clients_list, - "count": len(clients_list), - "filter": { - "identity_hash": identity_hash, - "identity_name": identity_name - } if (identity_hash or identity_name) else None - }) - + + return self._success( + { + "clients": clients_list, + "count": len(clients_list), + "filter": ( + {"identity_hash": identity_hash, "identity_name": identity_name} + if (identity_hash or identity_name) + else None + ), + } + ) + except Exception as e: logger.error(f"Error getting ACL clients: {e}") return self._error(e) - + @cherrypy.expose @cherrypy.tools.json_out() @cherrypy.tools.json_in() def acl_remove_client(self): """ POST /api/acl_remove_client - Remove an authenticated client from ACL - + Body: { "public_key": "full_hex_string", "identity_hash": "0x42" # Optional - if not provided, removes from all ACLs @@ -2650,74 +2795,78 @@ class APIEndpoints: """ # Enable CORS for this endpoint only if configured self._set_cors_headers() - + if cherrypy.request.method == "OPTIONS": return "" - + try: self._require_post() - - if not self.daemon_instance or not hasattr(self.daemon_instance, 'login_helper'): + + if not self.daemon_instance or not hasattr(self.daemon_instance, "login_helper"): return self._error("Login helper not available") - + data = cherrypy.request.json or {} public_key_hex = data.get("public_key") identity_hash_str = data.get("identity_hash") - + if not public_key_hex: return self._error("Missing required field: public_key") - + # Convert hex to bytes try: public_key = bytes.fromhex(public_key_hex) except ValueError: return self._error("Invalid public_key format (must be hex string)") - + login_helper = self.daemon_instance.login_helper acl_dict = login_helper.get_acl_dict() - + # Determine which ACLs to remove from target_hashes = [] if identity_hash_str: try: - target_hash = int(identity_hash_str, 16) if identity_hash_str.startswith("0x") else int(identity_hash_str) + target_hash = ( + int(identity_hash_str, 16) + if identity_hash_str.startswith("0x") + else int(identity_hash_str) + ) target_hashes = [target_hash] except ValueError: return self._error(f"Invalid identity_hash format: {identity_hash_str}") else: # Remove from all ACLs target_hashes = list(acl_dict.keys()) - + removed_count = 0 removed_from = [] - + for hash_byte in target_hashes: acl = acl_dict.get(hash_byte) if acl and acl.remove_client(public_key): removed_count += 1 removed_from.append(f"0x{hash_byte:02X}") - + if removed_count > 0: logger.info(f"Removed client {public_key[:6].hex()}... from {removed_count} ACL(s)") - return self._success({ - "removed_count": removed_count, - "removed_from": removed_from - }, message=f"Client removed from {removed_count} ACL(s)") + return self._success( + {"removed_count": removed_count, "removed_from": removed_from}, + message=f"Client removed from {removed_count} ACL(s)", + ) else: return self._error("Client not found in any ACL") - + except cherrypy.HTTPError: raise except Exception as e: logger.error(f"Error removing client from ACL: {e}") return self._error(e) - + @cherrypy.expose @cherrypy.tools.json_out() def acl_stats(self): """ GET /api/acl_stats - Get overall ACL statistics - + Returns: - Total identities with ACLs - Total authenticated clients across all identities @@ -2726,27 +2875,27 @@ class APIEndpoints: """ # Enable CORS for this endpoint only if configured self._set_cors_headers() - + if cherrypy.request.method == "OPTIONS": return "" - + try: - if not self.daemon_instance or not hasattr(self.daemon_instance, 'login_helper'): + if not self.daemon_instance or not hasattr(self.daemon_instance, "login_helper"): return self._error("Login helper not available") - + login_helper = self.daemon_instance.login_helper identity_manager = self.daemon_instance.identity_manager acl_dict = login_helper.get_acl_dict() - + total_clients = 0 admin_count = 0 guest_count = 0 - + identity_stats = { "repeater": {"count": 0, "clients": 0}, - "room_server": {"count": 0, "clients": 0} + "room_server": {"count": 0, "clients": 0}, } - + # Count repeater if self.daemon_instance.local_identity: repeater_hash = self.daemon_instance.local_identity.get_public_key()[0] @@ -2756,17 +2905,17 @@ class APIEndpoints: clients = repeater_acl.get_all_clients() identity_stats["repeater"]["clients"] = len(clients) total_clients += len(clients) - + for client in clients: if client.is_admin(): admin_count += 1 else: guest_count += 1 - + # Count room servers room_servers = identity_manager.get_identities_by_type("room_server") identity_stats["room_server"]["count"] = len(room_servers) - + for name, identity, config in room_servers: hash_byte = identity.get_public_key()[0] acl = acl_dict.get(hash_byte) @@ -2774,21 +2923,23 @@ class APIEndpoints: clients = acl.get_all_clients() identity_stats["room_server"]["clients"] += len(clients) total_clients += len(clients) - + for client in clients: if client.is_admin(): admin_count += 1 else: guest_count += 1 - - return self._success({ - "total_identities": len(acl_dict), - "total_clients": total_clients, - "admin_clients": admin_count, - "guest_clients": guest_count, - "by_identity_type": identity_stats - }) - + + return self._success( + { + "total_identities": len(acl_dict), + "total_clients": total_clients, + "admin_clients": admin_count, + "guest_clients": guest_count, + "by_identity_type": identity_stats, + } + ) + except Exception as e: logger.error(f"Error getting ACL stats: {e}") return self._error(e) @@ -2799,15 +2950,15 @@ class APIEndpoints: def _get_room_server_by_name_or_hash(self, room_name=None, room_hash=None): """Helper to get room server instance and metadata by name or hash.""" - if not self.daemon_instance or not hasattr(self.daemon_instance, 'text_helper'): + if not self.daemon_instance or not hasattr(self.daemon_instance, "text_helper"): raise Exception("Text helper not available") - + text_helper = self.daemon_instance.text_helper - if not text_helper or not hasattr(text_helper, 'room_servers'): + if not text_helper or not hasattr(text_helper, "room_servers"): raise Exception("Room servers not initialized") - + identity_manager = text_helper.identity_manager - + # Find by name first if room_name: identities = identity_manager.get_identities_by_type("room_server") @@ -2817,24 +2968,24 @@ class APIEndpoints: room_server = text_helper.room_servers.get(hash_byte) if room_server: return { - 'room_server': room_server, - 'name': name, - 'hash': hash_byte, - 'identity': identity, - 'config': config + "room_server": room_server, + "name": name, + "hash": hash_byte, + "identity": identity, + "config": config, } raise Exception(f"Room '{room_name}' not found") - + # Find by hash if room_hash: if isinstance(room_hash, str): - if room_hash.startswith('0x'): + if room_hash.startswith("0x"): hash_byte = int(room_hash, 16) else: hash_byte = int(room_hash) else: hash_byte = room_hash - + room_server = text_helper.room_servers.get(hash_byte) if room_server: # Find name @@ -2842,37 +2993,39 @@ class APIEndpoints: for name, identity, config in identities: if identity.get_public_key()[0] == hash_byte: return { - 'room_server': room_server, - 'name': name, - 'hash': hash_byte, - 'identity': identity, - 'config': config + "room_server": room_server, + "name": name, + "hash": hash_byte, + "identity": identity, + "config": config, } # Found server but no name match return { - 'room_server': room_server, - 'name': f"Room_0x{hash_byte:02X}", - 'hash': hash_byte, - 'identity': None, - 'config': {} + "room_server": room_server, + "name": f"Room_0x{hash_byte:02X}", + "hash": hash_byte, + "identity": None, + "config": {}, } raise Exception(f"Room with hash {room_hash} not found") - + raise Exception("Must provide room_name or room_hash") @cherrypy.expose @cherrypy.tools.json_out() - def room_messages(self, room_name=None, room_hash=None, limit=50, offset=0, since_timestamp=None): + def room_messages( + self, room_name=None, room_hash=None, limit=50, offset=0, since_timestamp=None + ): """ Get messages from a room server. - + Parameters: room_name: Name of the room room_hash: Hash of room identity (alternative to name) limit: Max messages to return (default 50) offset: Skip first N messages (default 0) since_timestamp: Only return messages after this timestamp - + Returns: { "success": true, @@ -2900,69 +3053,69 @@ class APIEndpoints: """ # Enable CORS for this endpoint only if configured self._set_cors_headers() - + if cherrypy.request.method == "OPTIONS": return "" - + try: room_info = self._get_room_server_by_name_or_hash(room_name, room_hash) - room_server = room_info['room_server'] - + room_server = room_info["room_server"] + # Get messages from database db = room_server.db room_hash_str = f"0x{room_info['hash']:02X}" - + # Get total count total_count = db.get_room_message_count(room_hash_str) - + # Get messages if since_timestamp: messages = db.get_messages_since( room_hash=room_hash_str, since_timestamp=float(since_timestamp), - limit=int(limit) + limit=int(limit), ) else: messages = db.get_room_messages( - room_hash=room_hash_str, - limit=int(limit), - offset=int(offset) + room_hash=room_hash_str, limit=int(limit), offset=int(offset) ) - + # Format messages with author prefix and lookup sender names storage = self._get_storage() formatted_messages = [] for msg in messages: - author_pubkey = msg['author_pubkey'] + author_pubkey = msg["author_pubkey"] formatted_msg = { - 'id': msg['id'], - 'author_pubkey': author_pubkey, - 'author_prefix': author_pubkey[:8] if author_pubkey else '', - 'post_timestamp': msg['post_timestamp'], - 'sender_timestamp': msg['sender_timestamp'], - 'message_text': msg['message_text'], - 'txt_type': msg['txt_type'], - 'created_at': msg.get('created_at', msg['post_timestamp']) + "id": msg["id"], + "author_pubkey": author_pubkey, + "author_prefix": author_pubkey[:8] if author_pubkey else "", + "post_timestamp": msg["post_timestamp"], + "sender_timestamp": msg["sender_timestamp"], + "message_text": msg["message_text"], + "txt_type": msg["txt_type"], + "created_at": msg.get("created_at", msg["post_timestamp"]), } - + # Lookup sender name from adverts table if author_pubkey: author_name = storage.get_node_name_by_pubkey(author_pubkey) if author_name: - formatted_msg['author_name'] = author_name - + formatted_msg["author_name"] = author_name + formatted_messages.append(formatted_msg) - - return self._success({ - 'room_name': room_info['name'], - 'room_hash': room_hash_str, - 'messages': formatted_messages, - 'count': len(formatted_messages), - 'total': total_count, - 'limit': int(limit), - 'offset': int(offset) - }) - + + return self._success( + { + "room_name": room_info["name"], + "room_hash": room_hash_str, + "messages": formatted_messages, + "count": len(formatted_messages), + "total": total_count, + "limit": int(limit), + "offset": int(offset), + } + ) + except Exception as e: logger.error(f"Error getting room messages: {e}", exc_info=True) return self._error(e) @@ -2973,7 +3126,7 @@ class APIEndpoints: def room_post_message(self): """ Post a message to a room server. - + POST Body: { "room_name": "General", // or "room_hash": "0x42" @@ -2981,43 +3134,43 @@ class APIEndpoints: "author_pubkey": "abc123...", // hex string, or "server" for system messages "txt_type": 0 // optional, default 0 } - + Special Values for author_pubkey: - "server" or "system": Uses SERVER_AUTHOR_PUBKEY (all zeros), message goes to ALL clients - Any other hex string: Normal behavior, message NOT sent to that client - + Returns: {"success": true, "data": {"message_id": 123}} """ # Enable CORS for this endpoint only if configured self._set_cors_headers() - + if cherrypy.request.method == "OPTIONS": return "" - + try: self._require_post() - + data = cherrypy.request.json - room_name = data.get('room_name') - room_hash = data.get('room_hash') - message = data.get('message') - author_pubkey = data.get('author_pubkey') - txt_type = data.get('txt_type', 0) - + room_name = data.get("room_name") + room_hash = data.get("room_hash") + message = data.get("message") + author_pubkey = data.get("author_pubkey") + txt_type = data.get("txt_type", 0) + if not message: return self._error("message is required") if not author_pubkey: return self._error("author_pubkey is required") - + # Convert author_pubkey to bytes try: # Special case: "server" or "system" = use room server's public key # This allows clients to identify which room server sent the message - if isinstance(author_pubkey, str) and author_pubkey.lower() in ('server', 'system'): + if isinstance(author_pubkey, str) and author_pubkey.lower() in ("server", "system"): # Get room server first to access its identity room_info = self._get_room_server_by_name_or_hash(room_name, room_hash) - room_server = room_info['room_server'] + room_server = room_info["room_server"] # Use the room server's actual public key author_bytes = room_server.local_identity.get_public_key() author_pubkey = author_bytes.hex() @@ -3030,14 +3183,18 @@ class APIEndpoints: is_server_message = False except Exception as e: return self._error(f"Invalid author_pubkey: {e}") - + # Get room server (if not already retrieved above) - if not isinstance(author_pubkey, str) or author_pubkey.lower() not in ('server', 'system'): + if not isinstance(author_pubkey, str) or author_pubkey.lower() not in ( + "server", + "system", + ): room_info = self._get_room_server_by_name_or_hash(room_name, room_hash) - room_server = room_info['room_server'] - + room_server = room_info["room_server"] + # Add post to room (will be distributed asynchronously) import asyncio + if self.event_loop: sender_timestamp = int(time.time()) # SECURITY: Server messages (using room server's key) go to ALL clients @@ -3048,32 +3205,38 @@ class APIEndpoints: message_text=message, sender_timestamp=sender_timestamp, txt_type=txt_type, - allow_server_author=is_server_message # Allow server key from API + allow_server_author=is_server_message, # Allow server key from API ), - self.event_loop + self.event_loop, ) success = future.result(timeout=5) - + if success: # Get the message ID (last inserted) db = room_server.db room_hash_str = f"0x{room_info['hash']:02X}" messages = db.get_room_messages(room_hash_str, limit=1, offset=0) - message_id = messages[0]['id'] if messages else None - - return self._success({ - 'message_id': message_id, - 'room_name': room_info['name'], - 'room_hash': room_hash_str, - 'queued_for_distribution': True, - 'is_server_message': is_server_message, - 'author_filter_note': 'Server messages go to ALL clients' if is_server_message else 'Message will NOT be sent to author' - }) + message_id = messages[0]["id"] if messages else None + + return self._success( + { + "message_id": message_id, + "room_name": room_info["name"], + "room_hash": room_hash_str, + "queued_for_distribution": True, + "is_server_message": is_server_message, + "author_filter_note": ( + "Server messages go to ALL clients" + if is_server_message + else "Message will NOT be sent to author" + ), + } + ) else: return self._error("Failed to add message (rate limit or validation error)") else: return self._error("Event loop not available") - + except cherrypy.HTTPError: raise except Exception as e: @@ -3085,13 +3248,13 @@ class APIEndpoints: def room_stats(self, room_name=None, room_hash=None): """ Get statistics for one or all room servers. - + Parameters: room_name: Name of specific room (optional) room_hash: Hash of specific room (optional) - + If no parameters, returns stats for all rooms. - + Returns: { "success": true, @@ -3119,16 +3282,16 @@ class APIEndpoints: """ # Enable CORS for this endpoint only if configured self._set_cors_headers() - + if cherrypy.request.method == "OPTIONS": return "" - + try: - if not self.daemon_instance or not hasattr(self.daemon_instance, 'text_helper'): + if not self.daemon_instance or not hasattr(self.daemon_instance, "text_helper"): return self._error("Text helper not available") - + text_helper = self.daemon_instance.text_helper - + # Get all rooms if no specific room requested if not room_name and not room_hash: all_rooms = [] @@ -3140,96 +3303,101 @@ class APIEndpoints: if identity.get_public_key()[0] == hash_byte: room_name_found = name break - + db = room_server.db room_hash_str = f"0x{hash_byte:02X}" - + # Get basic stats total_messages = db.get_room_message_count(room_hash_str) all_clients_sync = db.get_all_room_clients(room_hash_str) - active_clients = sum(1 for c in all_clients_sync if c.get('last_activity', 0) > 0) - - all_rooms.append({ - 'room_name': room_name_found, - 'room_hash': room_hash_str, - 'total_messages': total_messages, - 'total_clients': len(all_clients_sync), - 'active_clients': active_clients, - 'max_posts': room_server.max_posts, - 'sync_running': room_server._running - }) - - return self._success({ - 'rooms': all_rooms, - 'total_rooms': len(all_rooms) - }) - + active_clients = sum( + 1 for c in all_clients_sync if c.get("last_activity", 0) > 0 + ) + + all_rooms.append( + { + "room_name": room_name_found, + "room_hash": room_hash_str, + "total_messages": total_messages, + "total_clients": len(all_clients_sync), + "active_clients": active_clients, + "max_posts": room_server.max_posts, + "sync_running": room_server._running, + } + ) + + return self._success({"rooms": all_rooms, "total_rooms": len(all_rooms)}) + # Get specific room stats room_info = self._get_room_server_by_name_or_hash(room_name, room_hash) - room_server = room_info['room_server'] + room_server = room_info["room_server"] db = room_server.db room_hash_str = f"0x{room_info['hash']:02X}" - + # Get message count total_messages = db.get_room_message_count(room_hash_str) - + # Get client sync states all_clients_sync = db.get_all_room_clients(room_hash_str) - + # Get ACL for this room acl = None - if room_info['hash'] in text_helper.acl_dict: - acl = text_helper.acl_dict[room_info['hash']] - + if room_info["hash"] in text_helper.acl_dict: + acl = text_helper.acl_dict[room_info["hash"]] + # Format client info clients_info = [] active_count = 0 for client_sync in all_clients_sync: - pubkey_hex = client_sync['client_pubkey'] + pubkey_hex = client_sync["client_pubkey"] pubkey_bytes = bytes.fromhex(pubkey_hex) - + # Check if still in ACL in_acl = False if acl: acl_clients = acl.get_all_clients() in_acl = any(c.id.get_public_key() == pubkey_bytes for c in acl_clients) - + unsynced_count = db.get_unsynced_count( room_hash=room_hash_str, client_pubkey=pubkey_hex, - sync_since=client_sync.get('sync_since', 0) + sync_since=client_sync.get("sync_since", 0), ) - - is_active = client_sync.get('last_activity', 0) > 0 + + is_active = client_sync.get("last_activity", 0) > 0 if is_active: active_count += 1 - - clients_info.append({ - 'pubkey': pubkey_hex, - 'pubkey_prefix': pubkey_hex[:8], - 'sync_since': client_sync.get('sync_since', 0), - 'unsynced_count': unsynced_count, - 'pending_ack': client_sync.get('pending_ack_crc', 0) != 0, - 'pending_ack_crc': client_sync.get('pending_ack_crc', 0), - 'push_failures': client_sync.get('push_failures', 0), - 'last_activity': client_sync.get('last_activity', 0), - 'in_acl': in_acl, - 'is_active': is_active - }) - - return self._success({ - 'room_name': room_info['name'], - 'room_hash': room_hash_str, - 'total_messages': total_messages, - 'total_clients': len(all_clients_sync), - 'active_clients': active_count, - 'max_posts': room_server.max_posts, - 'sync_running': room_server._running, - 'next_push_time': room_server.next_push_time, - 'last_cleanup_time': room_server.last_cleanup_time, - 'clients': clients_info - }) - + + clients_info.append( + { + "pubkey": pubkey_hex, + "pubkey_prefix": pubkey_hex[:8], + "sync_since": client_sync.get("sync_since", 0), + "unsynced_count": unsynced_count, + "pending_ack": client_sync.get("pending_ack_crc", 0) != 0, + "pending_ack_crc": client_sync.get("pending_ack_crc", 0), + "push_failures": client_sync.get("push_failures", 0), + "last_activity": client_sync.get("last_activity", 0), + "in_acl": in_acl, + "is_active": is_active, + } + ) + + return self._success( + { + "room_name": room_info["name"], + "room_hash": room_hash_str, + "total_messages": total_messages, + "total_clients": len(all_clients_sync), + "active_clients": active_count, + "max_posts": room_server.max_posts, + "sync_running": room_server._running, + "next_push_time": room_server.next_push_time, + "last_cleanup_time": room_server.last_cleanup_time, + "clients": clients_info, + } + ) + except Exception as e: logger.error(f"Error getting room stats: {e}", exc_info=True) return self._error(e) @@ -3239,11 +3407,11 @@ class APIEndpoints: def room_clients(self, room_name=None, room_hash=None): """ Get list of clients synced to a room. - + Parameters: room_name: Name of the room room_hash: Hash of room identity - + Returns: { "success": true, @@ -3256,22 +3424,24 @@ class APIEndpoints: """ # Enable CORS for this endpoint only if configured self._set_cors_headers() - + if cherrypy.request.method == "OPTIONS": return "" - + try: # Reuse room_stats logic but return only clients stats = self.room_stats(room_name=room_name, room_hash=room_hash) - if stats.get('success') and 'clients' in stats.get('data', {}): - data = stats['data'] - return self._success({ - 'room_name': data['room_name'], - 'room_hash': data['room_hash'], - 'clients': data['clients'], - 'total': len(data['clients']), - 'active': data['active_clients'] - }) + if stats.get("success") and "clients" in stats.get("data", {}): + data = stats["data"] + return self._success( + { + "room_name": data["room_name"], + "room_hash": data["room_hash"], + "clients": data["clients"], + "total": len(data["clients"]), + "active": data["active_clients"], + } + ) else: return stats except Exception as e: @@ -3283,46 +3453,44 @@ class APIEndpoints: def room_message(self, room_name=None, room_hash=None, message_id=None): """ Delete a specific message from a room. - + Parameters: room_name: Name of the room room_hash: Hash of room identity message_id: ID of message to delete - + Returns: {"success": true} """ # Enable CORS for this endpoint only if configured self._set_cors_headers() - + if cherrypy.request.method == "OPTIONS": return "" - + try: if cherrypy.request.method != "DELETE": cherrypy.response.status = 405 return self._error("Method not allowed. Use DELETE.") - + if not message_id: return self._error("message_id is required") - + room_info = self._get_room_server_by_name_or_hash(room_name, room_hash) - room_server = room_info['room_server'] + room_server = room_info["room_server"] db = room_server.db room_hash_str = f"0x{room_info['hash']:02X}" - + # Delete message deleted = db.delete_room_message(room_hash_str, int(message_id)) - + if deleted: - return self._success({ - 'deleted': True, - 'message_id': int(message_id), - 'room_name': room_info['name'] - }) + return self._success( + {"deleted": True, "message_id": int(message_id), "room_name": room_info["name"]} + ) else: return self._error("Message not found or already deleted") - + except Exception as e: logger.error(f"Error deleting room message: {e}") return self._error(e) @@ -3332,42 +3500,44 @@ class APIEndpoints: def room_messages_clear(self, room_name=None, room_hash=None): """ Clear all messages from a room. - + Parameters: room_name: Name of the room room_hash: Hash of room identity - + Returns: {"success": true, "data": {"deleted_count": 123}} """ # Enable CORS for this endpoint only if configured self._set_cors_headers() - + if cherrypy.request.method == "OPTIONS": return "" - + try: if cherrypy.request.method != "DELETE": cherrypy.response.status = 405 return self._error("Method not allowed. Use DELETE.") - + room_info = self._get_room_server_by_name_or_hash(room_name, room_hash) - room_server = room_info['room_server'] + room_server = room_info["room_server"] db = room_server.db room_hash_str = f"0x{room_info['hash']:02X}" - + # Get count before deleting count_before = db.get_room_message_count(room_hash_str) - + # Clear all messages deleted = db.clear_room_messages(room_hash_str) - - return self._success({ - 'deleted_count': deleted or count_before, - 'room_name': room_info['name'], - 'room_hash': room_hash_str - }) - + + return self._success( + { + "deleted_count": deleted or count_before, + "room_name": room_info["name"], + "room_hash": room_hash_str, + } + ) + except Exception as e: logger.error(f"Error clearing room messages: {e}") return self._error(e) @@ -3380,18 +3550,19 @@ class APIEndpoints: def openapi(self): """Serve OpenAPI specification in YAML format.""" import os - spec_path = os.path.join(os.path.dirname(__file__), 'openapi.yaml') + + spec_path = os.path.join(os.path.dirname(__file__), "openapi.yaml") try: - with open(spec_path, 'r') as f: + with open(spec_path, "r") as f: spec_content = f.read() - cherrypy.response.headers['Content-Type'] = 'application/x-yaml' - return spec_content.encode('utf-8') + cherrypy.response.headers["Content-Type"] = "application/x-yaml" + return spec_content.encode("utf-8") except FileNotFoundError: cherrypy.response.status = 404 return b"OpenAPI spec not found" except Exception as e: cherrypy.response.status = 500 - return f"Error loading OpenAPI spec: {e}".encode('utf-8') + return f"Error loading OpenAPI spec: {e}".encode("utf-8") @cherrypy.expose def docs(self): @@ -3433,5 +3604,5 @@ class APIEndpoints: """ - cherrypy.response.headers['Content-Type'] = 'text/html' - return html.encode('utf-8') \ No newline at end of file + cherrypy.response.headers["Content-Type"] = "text/html" + return html.encode("utf-8") diff --git a/repeater/web/auth/__init__.py b/repeater/web/auth/__init__.py index a38c19f..2e0695e 100644 --- a/repeater/web/auth/__init__.py +++ b/repeater/web/auth/__init__.py @@ -1,9 +1,5 @@ -from .jwt_handler import JWTHandler from .api_tokens import APITokenManager +from .jwt_handler import JWTHandler from .middleware import require_auth -__all__ = [ - 'JWTHandler', - 'APITokenManager', - 'require_auth' -] +__all__ = ["JWTHandler", "APITokenManager", "require_auth"] diff --git a/repeater/web/auth/api_tokens.py b/repeater/web/auth/api_tokens.py index 55dcff7..5105e70 100644 --- a/repeater/web/auth/api_tokens.py +++ b/repeater/web/auth/api_tokens.py @@ -1,8 +1,8 @@ -import secrets -import hmac import hashlib -from typing import Optional, List, Dict +import hmac import logging +import secrets +from typing import Dict, List, Optional logger = logging.getLogger(__name__) @@ -11,18 +11,14 @@ class APITokenManager: def __init__(self, sqlite_handler, secret_key: str): self.db = sqlite_handler - self.secret_key = secret_key.encode('utf-8') - + self.secret_key = secret_key.encode("utf-8") + def generate_api_token(self) -> str: return secrets.token_hex(32) - + def hash_token(self, token: str) -> str: - return hmac.new( - self.secret_key, - token.encode('utf-8'), - hashlib.sha256 - ).hexdigest() - + return hmac.new(self.secret_key, token.encode("utf-8"), hashlib.sha256).hexdigest() + def create_token(self, name: str) -> tuple[int, str]: plaintext_token = self.generate_api_token() token_hash = self.hash_token(plaintext_token) @@ -43,7 +39,6 @@ class APITokenManager: logger.info(f"Revoked API token ID {token_id}") return deleted - + def list_tokens(self) -> List[Dict]: return self.db.list_api_tokens() - diff --git a/repeater/web/auth/cherrypy_tool.py b/repeater/web/auth/cherrypy_tool.py index c6894df..f107dc5 100644 --- a/repeater/web/auth/cherrypy_tool.py +++ b/repeater/web/auth/cherrypy_tool.py @@ -1,4 +1,5 @@ import logging + import cherrypy logger = logging.getLogger("HTTPServer") @@ -40,10 +41,10 @@ def check_auth(): cherrypy.request.user = { "username": payload.get("sub"), "client_id": payload.get("client_id"), - "auth_type": "jwt" + "auth_type": "jwt", } return - + # Check for JWT token in query parameter (for EventSource/SSE) # EventSource doesn't support custom headers, so we use query param query_token = cherrypy.request.params.get("token") @@ -54,7 +55,7 @@ def check_auth(): cherrypy.request.user = { "username": payload.get("sub"), "client_id": payload.get("client_id"), - "auth_type": "jwt_query" + "auth_type": "jwt_query", } # Remove token from params to avoid exposing it in logs del cherrypy.request.params["token"] @@ -69,15 +70,15 @@ def check_auth(): cherrypy.request.user = { "token_id": token_info["id"], "token_name": token_info["name"], - "auth_type": "api_token" + "auth_type": "api_token", } return - + # No valid authentication found logger.warning(f"Unauthorized access attempt to {cherrypy.request.path_info}") raise cherrypy.HTTPError(401, "Unauthorized - Valid JWT or API token required") # Register the tool -cherrypy.tools.require_auth = cherrypy.Tool('before_handler', check_auth) +cherrypy.tools.require_auth = cherrypy.Tool("before_handler", check_auth) logger.info("CherryPy require_auth tool registered") diff --git a/repeater/web/auth/jwt_handler.py b/repeater/web/auth/jwt_handler.py index a1dd22c..bc9d257 100644 --- a/repeater/web/auth/jwt_handler.py +++ b/repeater/web/auth/jwt_handler.py @@ -1,10 +1,12 @@ -import jwt +import logging import time from typing import Dict, Optional -import logging + +import jwt logger = logging.getLogger(__name__) + class JWTHandler: def __init__(self, secret: str, expiry_minutes: int = 15): self.secret = secret @@ -14,21 +16,16 @@ class JWTHandler: now = int(time.time()) expiry = now + (self.expiry_minutes * 60) - - payload = { - 'sub': username, - 'exp': expiry, - 'iat': now, - 'client_id': client_id - } - - token = jwt.encode(payload, self.secret, algorithm='HS256') + + payload = {"sub": username, "exp": expiry, "iat": now, "client_id": client_id} + + token = jwt.encode(payload, self.secret, algorithm="HS256") logger.info(f"Created JWT for user '{username}' with client_id '{client_id[:8]}...'") return token - + def verify_jwt(self, token: str) -> Optional[Dict]: try: - payload = jwt.decode(token, self.secret, algorithms=['HS256']) + payload = jwt.decode(token, self.secret, algorithms=["HS256"]) return payload except jwt.ExpiredSignatureError: logger.warning("JWT token expired") diff --git a/repeater/web/auth/middleware.py b/repeater/web/auth/middleware.py index c5cdb50..54ecc9b 100644 --- a/repeater/web/auth/middleware.py +++ b/repeater/web/auth/middleware.py @@ -1,6 +1,7 @@ -import cherrypy -from functools import wraps import logging +from functools import wraps + +import cherrypy logger = logging.getLogger(__name__) @@ -10,56 +11,56 @@ def require_auth(func): @wraps(func) def wrapper(*args, **kwargs): # Skip authentication for OPTIONS requests (CORS preflight) - if cherrypy.request.method == 'OPTIONS': + if cherrypy.request.method == "OPTIONS": return func(*args, **kwargs) - + # Get auth handlers from global cherrypy config (not app config) - jwt_handler = cherrypy.config.get('jwt_handler') - token_manager = cherrypy.config.get('token_manager') - + jwt_handler = cherrypy.config.get("jwt_handler") + token_manager = cherrypy.config.get("token_manager") + if not jwt_handler or not token_manager: logger.error("Auth handlers not configured") raise cherrypy.HTTPError(500, "Authentication not configured") - + # Try JWT authentication first - auth_header = cherrypy.request.headers.get('Authorization', '') - if auth_header.startswith('Bearer '): + auth_header = cherrypy.request.headers.get("Authorization", "") + if auth_header.startswith("Bearer "): token = auth_header[7:] # Remove 'Bearer ' prefix payload = jwt_handler.verify_jwt(token) - + if payload: # JWT is valid cherrypy.request.user = { - 'username': payload['sub'], - 'client_id': payload['client_id'], - 'auth_type': 'jwt' + "username": payload["sub"], + "client_id": payload["client_id"], + "auth_type": "jwt", } return func(*args, **kwargs) else: logger.warning("Invalid or expired JWT token") - + # Try API token authentication - api_key = cherrypy.request.headers.get('X-API-Key', '') + api_key = cherrypy.request.headers.get("X-API-Key", "") if api_key: token_info = token_manager.verify_token(api_key) - + if token_info: # API token is valid cherrypy.request.user = { - 'username': 'api_token', - 'token_name': token_info['name'], - 'token_id': token_info['id'], - 'auth_type': 'api_token' + "username": "api_token", + "token_name": token_info["name"], + "token_id": token_info["id"], + "auth_type": "api_token", } return func(*args, **kwargs) else: logger.warning("Invalid API token") - + # No valid authentication found logger.warning(f"Unauthorized access attempt to {cherrypy.request.path_info}") - + cherrypy.response.status = 401 - cherrypy.response.headers['Content-Type'] = 'application/json' - return {'success': False, 'error': 'Unauthorized - Valid JWT or API token required'} - - return wrapper \ No newline at end of file + cherrypy.response.headers["Content-Type"] = "application/json" + return {"success": False, "error": "Unauthorized - Valid JWT or API token required"} + + return wrapper diff --git a/repeater/web/auth_endpoints.py b/repeater/web/auth_endpoints.py index acf8f51..4f9e1ed 100644 --- a/repeater/web/auth_endpoints.py +++ b/repeater/web/auth_endpoints.py @@ -1,8 +1,11 @@ """ Authentication endpoints for login and token management """ -import cherrypy + import logging + +import cherrypy + from .auth.middleware import require_auth logger = logging.getLogger(__name__) @@ -24,123 +27,101 @@ class TokensAPIEndpoint: @require_auth def index(self): # Handle CORS preflight - if cherrypy.request.method == 'OPTIONS': + if cherrypy.request.method == "OPTIONS": return {} - + # Get token manager from cherrypy config - token_manager = cherrypy.config.get('token_manager') + token_manager = cherrypy.config.get("token_manager") if not token_manager: cherrypy.response.status = 500 - return {'success': False, 'error': 'Token manager not available'} - - if cherrypy.request.method == 'GET': + return {"success": False, "error": "Token manager not available"} + + if cherrypy.request.method == "GET": try: tokens = token_manager.list_tokens() - return { - 'success': True, - 'tokens': tokens - } + return {"success": True, "tokens": tokens} except Exception as e: logger.error(f"Token list error: {e}") cherrypy.response.status = 500 - return { - 'success': False, - 'error': 'Failed to list tokens' - } - - elif cherrypy.request.method == 'POST': + return {"success": False, "error": "Failed to list tokens"} + + elif cherrypy.request.method == "POST": try: import json - body = cherrypy.request.body.read().decode('utf-8') + + body = cherrypy.request.body.read().decode("utf-8") data = json.loads(body) if body else {} - name = data.get('name', '').strip() - + name = data.get("name", "").strip() + if not name: cherrypy.response.status = 400 - return { - 'success': False, - 'error': 'Token name is required' - } - + return {"success": False, "error": "Token name is required"} + # Create the token token_id, plaintext_token = token_manager.create_token(name) - - logger.info(f"Generated API token '{name}' (ID: {token_id}) by user {cherrypy.request.user['username']}") - + + logger.info( + f"Generated API token '{name}' (ID: {token_id}) by user {cherrypy.request.user['username']}" + ) + return { - 'success': True, - 'token': plaintext_token, - 'token_id': token_id, - 'name': name, - 'warning': 'Save this token securely - it will not be shown again' + "success": True, + "token": plaintext_token, + "token_id": token_id, + "name": name, + "warning": "Save this token securely - it will not be shown again", } - + except Exception as e: logger.error(f"Token generation error: {e}") cherrypy.response.status = 500 - return { - 'success': False, - 'error': 'Failed to generate token' - } + return {"success": False, "error": "Failed to generate token"} else: raise cherrypy.HTTPError(405, "Method not allowed") - + @cherrypy.expose @cherrypy.tools.json_out() @require_auth def default(self, token_id=None): # Handle CORS preflight - if cherrypy.request.method == 'OPTIONS': + if cherrypy.request.method == "OPTIONS": return {} - + # Get token manager from cherrypy config - token_manager = cherrypy.config.get('token_manager') + token_manager = cherrypy.config.get("token_manager") if not token_manager: cherrypy.response.status = 500 - return {'success': False, 'error': 'Token manager not available'} - - if cherrypy.request.method == 'DELETE': + return {"success": False, "error": "Token manager not available"} + + if cherrypy.request.method == "DELETE": try: if not token_id: cherrypy.response.status = 400 - return { - 'success': False, - 'error': 'Token ID is required' - } - + return {"success": False, "error": "Token ID is required"} + # Convert to int try: token_id_int = int(token_id) except ValueError: cherrypy.response.status = 400 - return { - 'success': False, - 'error': 'Invalid token ID' - } - + return {"success": False, "error": "Invalid token ID"} + # Revoke the token success = token_manager.revoke_token(token_id_int) - + if success: - logger.info(f"Revoked API token ID {token_id_int} by user {cherrypy.request.user['username']}") - return { - 'success': True, - 'message': 'Token revoked successfully' - } + logger.info( + f"Revoked API token ID {token_id_int} by user {cherrypy.request.user['username']}" + ) + return {"success": True, "message": "Token revoked successfully"} else: cherrypy.response.status = 404 - return { - 'success': False, - 'error': 'Token not found' - } - + return {"success": False, "error": "Token not found"} + except Exception as e: logger.error(f"Token revocation error: {e}") cherrypy.response.status = 500 - return { - 'success': False, - 'error': 'Failed to revoke token' - } + return {"success": False, "error": "Failed to revoke token"} else: raise cherrypy.HTTPError(405, "Method not allowed") @@ -156,309 +137,314 @@ class AuthEndpoints: @cherrypy.expose def login(self, **kwargs): - cherrypy.response.headers['Content-Type'] = 'application/json' - + cherrypy.response.headers["Content-Type"] = "application/json" + # Handle CORS preflight - if cherrypy.request.method == 'OPTIONS': - cherrypy.response.headers['Access-Control-Allow-Methods'] = 'POST, OPTIONS' - cherrypy.response.headers['Access-Control-Allow-Headers'] = 'Content-Type, Authorization, X-API-Key' - return b'' - - if cherrypy.request.method != 'POST': + if cherrypy.request.method == "OPTIONS": + cherrypy.response.headers["Access-Control-Allow-Methods"] = "POST, OPTIONS" + cherrypy.response.headers["Access-Control-Allow-Headers"] = ( + "Content-Type, Authorization, X-API-Key" + ) + return b"" + + if cherrypy.request.method != "POST": raise cherrypy.HTTPError(405, "Method not allowed") - + try: # Parse JSON body manually since we can't use json_in decorator with OPTIONS import json - body = cherrypy.request.body.read().decode('utf-8') + + body = cherrypy.request.body.read().decode("utf-8") data = json.loads(body) if body else {} - - username = data.get('username', '').strip() - password = data.get('password', '') - client_id = data.get('client_id', '').strip() - + + username = data.get("username", "").strip() + password = data.get("password", "") + client_id = data.get("client_id", "").strip() + if not username or not password or not client_id: - return json.dumps({ - 'success': False, - 'error': 'Missing required fields: username, password, client_id' - }).encode('utf-8') - + return json.dumps( + { + "success": False, + "error": "Missing required fields: username, password, client_id", + } + ).encode("utf-8") + # Validate credentials against config # Check if username is 'admin' and password matches config - repeater_config = self.config.get('repeater', {}) - security_config = repeater_config.get('security', {}) - config_password = security_config.get('admin_password', '') - + repeater_config = self.config.get("repeater", {}) + security_config = repeater_config.get("security", {}) + config_password = security_config.get("admin_password", "") + # Don't allow login with empty or unconfigured password if not config_password: logger.warning(f"Login attempt rejected - password not configured") - return json.dumps({ - 'success': False, - 'error': 'System not configured. Please complete setup wizard.' - }).encode('utf-8') - - if username == 'admin' and password == config_password: + return json.dumps( + { + "success": False, + "error": "System not configured. Please complete setup wizard.", + } + ).encode("utf-8") + + if username == "admin" and password == config_password: # Create JWT token token = self.jwt_handler.create_jwt(username, client_id) - - logger.info(f"Successful login for user '{username}' from client '{client_id[:8]}...'") - - return json.dumps({ - 'success': True, - 'token': token, - 'expires_in': self.jwt_handler.expiry_minutes * 60, - 'username': username - }).encode('utf-8') + + logger.info( + f"Successful login for user '{username}' from client '{client_id[:8]}...'" + ) + + return json.dumps( + { + "success": True, + "token": token, + "expires_in": self.jwt_handler.expiry_minutes * 60, + "username": username, + } + ).encode("utf-8") else: logger.warning(f"Failed login attempt for user '{username}'") - + # Don't reveal which part was wrong - return json.dumps({ - 'success': False, - 'error': 'Invalid username or password' - }).encode('utf-8') - + return json.dumps( + {"success": False, "error": "Invalid username or password"} + ).encode("utf-8") + except Exception as e: logger.error(f"Login error: {e}") - return json.dumps({ - 'success': False, - 'error': 'Internal server error' - }).encode('utf-8') - + return json.dumps({"success": False, "error": "Internal server error"}).encode("utf-8") + @cherrypy.expose @cherrypy.tools.json_out() @require_auth def verify(self): - if cherrypy.request.method != 'GET': + if cherrypy.request.method != "GET": raise cherrypy.HTTPError(405, "Method not allowed") - - return { - 'success': True, - 'authenticated': True, - 'user': cherrypy.request.user - } - + + return {"success": True, "authenticated": True, "user": cherrypy.request.user} + @cherrypy.expose def refresh(self, **kwargs): - cherrypy.response.headers['Content-Type'] = 'application/json' - + cherrypy.response.headers["Content-Type"] = "application/json" + # Handle CORS preflight - if cherrypy.request.method == 'OPTIONS': - cherrypy.response.headers['Access-Control-Allow-Methods'] = 'POST, OPTIONS' - cherrypy.response.headers['Access-Control-Allow-Headers'] = 'Content-Type, Authorization, X-API-Key' - return b'' - - if cherrypy.request.method != 'POST': + if cherrypy.request.method == "OPTIONS": + cherrypy.response.headers["Access-Control-Allow-Methods"] = "POST, OPTIONS" + cherrypy.response.headers["Access-Control-Allow-Headers"] = ( + "Content-Type, Authorization, X-API-Key" + ) + return b"" + + if cherrypy.request.method != "POST": raise cherrypy.HTTPError(405, "Method not allowed") - + try: import json - + # Manual authentication check (can't use @require_auth since we need to handle OPTIONS) - auth_header = cherrypy.request.headers.get('Authorization', '') - api_key = cherrypy.request.headers.get('X-API-Key', '') - - jwt_handler = cherrypy.config.get('jwt_handler') - token_manager = cherrypy.config.get('token_manager') - + auth_header = cherrypy.request.headers.get("Authorization", "") + api_key = cherrypy.request.headers.get("X-API-Key", "") + + jwt_handler = cherrypy.config.get("jwt_handler") + token_manager = cherrypy.config.get("token_manager") + user_info = None - + # Check JWT first - if auth_header.startswith('Bearer '): + if auth_header.startswith("Bearer "): token = auth_header[7:] payload = jwt_handler.verify_jwt(token) if payload: user_info = { - 'username': payload['sub'], - 'client_id': payload.get('client_id'), - 'auth_method': 'jwt' + "username": payload["sub"], + "client_id": payload.get("client_id"), + "auth_method": "jwt", } - + # Check API token if not user_info and api_key: token_data = token_manager.verify_token(api_key) if token_data: user_info = { - 'username': 'admin', - 'token_id': token_data['id'], - 'auth_method': 'api_token' + "username": "admin", + "token_id": token_data["id"], + "auth_method": "api_token", } - + if not user_info: - return json.dumps({ - 'success': False, - 'error': 'Unauthorized - Valid JWT or API token required' - }).encode('utf-8') - + return json.dumps( + {"success": False, "error": "Unauthorized - Valid JWT or API token required"} + ).encode("utf-8") + # Parse request body - body = cherrypy.request.body.read().decode('utf-8') + body = cherrypy.request.body.read().decode("utf-8") data = json.loads(body) if body else {} - - client_id = data.get('client_id', user_info.get('client_id', '')).strip() - + + client_id = data.get("client_id", user_info.get("client_id", "")).strip() + if not client_id: - return json.dumps({ - 'success': False, - 'error': 'Client ID is required' - }).encode('utf-8') - + return json.dumps({"success": False, "error": "Client ID is required"}).encode( + "utf-8" + ) + # Create new JWT token (refreshes expiry time) - new_token = self.jwt_handler.create_jwt(user_info['username'], client_id) - - logger.info(f"Token refreshed for user '{user_info['username']}' from client '{client_id[:8]}...'") - - return json.dumps({ - 'success': True, - 'token': new_token, - 'expires_in': self.jwt_handler.expiry_minutes * 60, - 'username': user_info['username'] - }).encode('utf-8') - + new_token = self.jwt_handler.create_jwt(user_info["username"], client_id) + + logger.info( + f"Token refreshed for user '{user_info['username']}' from client '{client_id[:8]}...'" + ) + + return json.dumps( + { + "success": True, + "token": new_token, + "expires_in": self.jwt_handler.expiry_minutes * 60, + "username": user_info["username"], + } + ).encode("utf-8") + except Exception as e: logger.error(f"Token refresh error: {e}") - return json.dumps({ - 'success': False, - 'error': 'Failed to refresh token' - }).encode('utf-8') - + return json.dumps({"success": False, "error": "Failed to refresh token"}).encode( + "utf-8" + ) + @cherrypy.expose def change_password(self): import json - - cherrypy.response.headers['Content-Type'] = 'application/json' - + + cherrypy.response.headers["Content-Type"] = "application/json" + # Handle CORS preflight - if cherrypy.request.method == 'OPTIONS': - cherrypy.response.headers['Access-Control-Allow-Methods'] = 'POST, OPTIONS' - cherrypy.response.headers['Access-Control-Allow-Headers'] = 'Content-Type, Authorization, X-API-Key' - return b'' - - if cherrypy.request.method != 'POST': + if cherrypy.request.method == "OPTIONS": + cherrypy.response.headers["Access-Control-Allow-Methods"] = "POST, OPTIONS" + cherrypy.response.headers["Access-Control-Allow-Headers"] = ( + "Content-Type, Authorization, X-API-Key" + ) + return b"" + + if cherrypy.request.method != "POST": raise cherrypy.HTTPError(405, "Method not allowed") - + # Require authentication for POST # Get auth handlers from global cherrypy config - jwt_handler = cherrypy.config.get('jwt_handler') - token_manager = cherrypy.config.get('token_manager') - + jwt_handler = cherrypy.config.get("jwt_handler") + token_manager = cherrypy.config.get("token_manager") + if not jwt_handler or not token_manager: logger.error("Auth handlers not configured") raise cherrypy.HTTPError(500, "Authentication not configured") - + # Try JWT authentication first - auth_header = cherrypy.request.headers.get('Authorization', '') + auth_header = cherrypy.request.headers.get("Authorization", "") user = None - - if auth_header.startswith('Bearer '): + + if auth_header.startswith("Bearer "): token = auth_header[7:] # Remove 'Bearer ' prefix payload = jwt_handler.verify_jwt(token) - + if payload: user = { - 'username': payload['sub'], - 'client_id': payload['client_id'], - 'auth_type': 'jwt' + "username": payload["sub"], + "client_id": payload["client_id"], + "auth_type": "jwt", } - + # Try API token authentication if JWT failed if not user: - api_key = cherrypy.request.headers.get('X-API-Key', '') + api_key = cherrypy.request.headers.get("X-API-Key", "") if api_key: token_info = token_manager.verify_token(api_key) - + if token_info: user = { - 'username': 'api_token', - 'token_name': token_info['name'], - 'token_id': token_info['id'], - 'auth_type': 'api_token' + "username": "api_token", + "token_name": token_info["name"], + "token_id": token_info["id"], + "auth_type": "api_token", } - + if not user: cherrypy.response.status = 401 - return json.dumps({ - 'success': False, - 'error': 'Unauthorized - Valid JWT or API token required' - }).encode('utf-8') - + return json.dumps( + {"success": False, "error": "Unauthorized - Valid JWT or API token required"} + ).encode("utf-8") + try: # Parse JSON body manually - body = cherrypy.request.body.read().decode('utf-8') + body = cherrypy.request.body.read().decode("utf-8") data = json.loads(body) if body else {} - - current_password = data.get('current_password', '') - new_password = data.get('new_password', '') - + + current_password = data.get("current_password", "") + new_password = data.get("new_password", "") + if not current_password or not new_password: cherrypy.response.status = 400 - return json.dumps({ - 'success': False, - 'error': 'Both current_password and new_password are required' - }).encode('utf-8') - + return json.dumps( + { + "success": False, + "error": "Both current_password and new_password are required", + } + ).encode("utf-8") + # Validate new password strength if len(new_password) < 8: cherrypy.response.status = 400 - return json.dumps({ - 'success': False, - 'error': 'New password must be at least 8 characters long' - }).encode('utf-8') - + return json.dumps( + {"success": False, "error": "New password must be at least 8 characters long"} + ).encode("utf-8") + # Verify current password - repeater_config = self.config.get('repeater', {}) - security_config = repeater_config.get('security', {}) - config_password = security_config.get('admin_password', '') - + repeater_config = self.config.get("repeater", {}) + security_config = repeater_config.get("security", {}) + config_password = security_config.get("admin_password", "") + if not config_password: cherrypy.response.status = 500 - return json.dumps({ - 'success': False, - 'error': 'System configuration error' - }).encode('utf-8') - + return json.dumps({"success": False, "error": "System configuration error"}).encode( + "utf-8" + ) + if current_password != config_password: cherrypy.response.status = 401 - return json.dumps({ - 'success': False, - 'error': 'Current password is incorrect' - }).encode('utf-8') - + return json.dumps( + {"success": False, "error": "Current password is incorrect"} + ).encode("utf-8") + # Update password in config - if 'repeater' not in self.config: - self.config['repeater'] = {} - if 'security' not in self.config['repeater']: - self.config['repeater']['security'] = {} - - self.config['repeater']['security']['admin_password'] = new_password - + if "repeater" not in self.config: + self.config["repeater"] = {} + if "security" not in self.config["repeater"]: + self.config["repeater"]["security"] = {} + + self.config["repeater"]["security"]["admin_password"] = new_password + # Save to config file using ConfigManager if self.config_manager: saved, _ = self.config_manager.save_to_file() if saved: logger.info(f"Admin password changed successfully by user {user['username']}") - return json.dumps({ - 'success': True, - 'message': 'Password changed successfully. Please log in again with your new password.' - }).encode('utf-8') + return json.dumps( + { + "success": True, + "message": "Password changed successfully. Please log in again with your new password.", + } + ).encode("utf-8") else: cherrypy.response.status = 500 - return json.dumps({ - 'success': False, - 'error': 'Failed to save password to config file' - }).encode('utf-8') + return json.dumps( + {"success": False, "error": "Failed to save password to config file"} + ).encode("utf-8") else: cherrypy.response.status = 500 - return json.dumps({ - 'success': False, - 'error': 'Config manager not available' - }).encode('utf-8') - + return json.dumps( + {"success": False, "error": "Config manager not available"} + ).encode("utf-8") + except Exception as e: logger.error(f"Password change error: {e}") cherrypy.response.status = 500 - return json.dumps({ - 'success': False, - 'error': 'Failed to change password' - }).encode('utf-8') \ No newline at end of file + return json.dumps({"success": False, "error": "Failed to change password"}).encode( + "utf-8" + ) diff --git a/repeater/web/cad_calibration_engine.py b/repeater/web/cad_calibration_engine.py index c124cfe..f43dbe0 100644 --- a/repeater/web/cad_calibration_engine.py +++ b/repeater/web/cad_calibration_engine.py @@ -3,13 +3,13 @@ import logging import random import threading import time -from typing import Dict, Any, Optional +from typing import Any, Dict, Optional logger = logging.getLogger("HTTPServer") class CADCalibrationEngine: - + def __init__(self, daemon_instance=None, event_loop=None): self.daemon_instance = daemon_instance self.event_loop = event_loop @@ -19,26 +19,28 @@ class CADCalibrationEngine: self.progress = {"current": 0, "total": 0} self.clients = set() # SSE clients self.calibration_thread = None - + def get_test_ranges(self, spreading_factor: int): """Get CAD test ranges""" # Higher values = less sensitive, lower values = more sensitive # Test from LESS sensitive to MORE sensitive to find the sweet spot sf_ranges = { - 7: (range(22, 30, 1), range(12, 20, 1)), - 8: (range(22, 30, 1), range(12, 20, 1)), - 9: (range(24, 32, 1), range(14, 22, 1)), - 10: (range(26, 34, 1), range(16, 24, 1)), - 11: (range(28, 36, 1), range(18, 26, 1)), - 12: (range(30, 38, 1), range(20, 28, 1)), + 7: (range(22, 30, 1), range(12, 20, 1)), + 8: (range(22, 30, 1), range(12, 20, 1)), + 9: (range(24, 32, 1), range(14, 22, 1)), + 10: (range(26, 34, 1), range(16, 24, 1)), + 11: (range(28, 36, 1), range(18, 26, 1)), + 12: (range(30, 38, 1), range(20, 28, 1)), } return sf_ranges.get(spreading_factor, sf_ranges[8]) - - async def test_cad_config(self, radio, det_peak: int, det_min: int, samples: int = 20) -> Dict[str, Any]: - + + async def test_cad_config( + self, radio, det_peak: int, det_min: int, samples: int = 20 + ) -> Dict[str, Any]: + detections = 0 baseline_detections = 0 - + # First, get baseline with very insensitive settings (should detect nothing) baseline_samples = 5 for _ in range(baseline_samples): @@ -50,10 +52,10 @@ class CADCalibrationEngine: except Exception: pass await asyncio.sleep(0.1) # 100ms between baseline samples - + # Wait before actual test await asyncio.sleep(0.5) - + # Now test the actual configuration for i in range(samples): try: @@ -62,226 +64,247 @@ class CADCalibrationEngine: detections += 1 except Exception: pass - + # Variable delay to avoid sampling artifacts delay = 0.05 + (i % 3) * 0.05 # 50ms, 100ms, 150ms rotation await asyncio.sleep(delay) - + # Calculate adjusted detection rate baseline_rate = (baseline_detections / baseline_samples) * 100 detection_rate = (detections / samples) * 100 - + # Subtract baseline noise adjusted_rate = max(0, detection_rate - baseline_rate) - + return { - 'det_peak': det_peak, - 'det_min': det_min, - 'samples': samples, - 'detections': detections, - 'detection_rate': detection_rate, - 'baseline_rate': baseline_rate, - 'adjusted_rate': adjusted_rate, # This is the useful metric - 'sensitivity_score': self._calculate_sensitivity_score(det_peak, det_min, adjusted_rate) + "det_peak": det_peak, + "det_min": det_min, + "samples": samples, + "detections": detections, + "detection_rate": detection_rate, + "baseline_rate": baseline_rate, + "adjusted_rate": adjusted_rate, # This is the useful metric + "sensitivity_score": self._calculate_sensitivity_score( + det_peak, det_min, adjusted_rate + ), } - - def _calculate_sensitivity_score(self, det_peak: int, det_min: int, adjusted_rate: float) -> float: - + + def _calculate_sensitivity_score( + self, det_peak: int, det_min: int, adjusted_rate: float + ) -> float: + # Ideal detection rate is around 10-30% for good sensitivity without false positives ideal_rate = 20.0 rate_penalty = abs(adjusted_rate - ideal_rate) / ideal_rate - + # Prefer moderate sensitivity settings (not too extreme) sensitivity_penalty = (abs(det_peak - 25) + abs(det_min - 15)) / 20.0 - + # Lower penalty = higher score score = max(0, 100 - (rate_penalty * 50) - (sensitivity_penalty * 20)) return score - + def broadcast_to_clients(self, data): # Store the message for clients to pick up self.last_message = data # Also store in a queue for clients to consume - if not hasattr(self, 'message_queue'): + if not hasattr(self, "message_queue"): self.message_queue = [] self.message_queue.append(data) - + def calibration_worker(self, samples: int, delay_ms: int): - + try: # Get radio from daemon instance if not self.daemon_instance: - self.broadcast_to_clients({"type": "error", "message": "No daemon instance available"}) + self.broadcast_to_clients( + {"type": "error", "message": "No daemon instance available"} + ) return - - radio = getattr(self.daemon_instance, 'radio', None) + + radio = getattr(self.daemon_instance, "radio", None) if not radio: - self.broadcast_to_clients({"type": "error", "message": "Radio instance not available"}) + self.broadcast_to_clients( + {"type": "error", "message": "Radio instance not available"} + ) return - if not hasattr(radio, 'perform_cad'): - self.broadcast_to_clients({"type": "error", "message": "Radio does not support CAD"}) + if not hasattr(radio, "perform_cad"): + self.broadcast_to_clients( + {"type": "error", "message": "Radio does not support CAD"} + ) return - + # Get spreading factor from daemon instance - config = getattr(self.daemon_instance, 'config', {}) + config = getattr(self.daemon_instance, "config", {}) radio_config = config.get("radio", {}) sf = radio_config.get("spreading_factor", 8) - + # Get test ranges peak_range, min_range = self.get_test_ranges(sf) - + total_tests = len(peak_range) * len(min_range) self.progress = {"current": 0, "total": total_tests} - - self.broadcast_to_clients({ - "type": "status", - "message": f"Starting calibration: SF{sf}, {total_tests} tests", - "test_ranges": { - "peak_min": min(peak_range), - "peak_max": max(peak_range), - "min_min": min(min_range), - "min_max": max(min_range), - "spreading_factor": sf, - "total_tests": total_tests + + self.broadcast_to_clients( + { + "type": "status", + "message": f"Starting calibration: SF{sf}, {total_tests} tests", + "test_ranges": { + "peak_min": min(peak_range), + "peak_max": max(peak_range), + "min_min": min(min_range), + "min_max": max(min_range), + "spreading_factor": sf, + "total_tests": total_tests, + }, } - }) - + ) + current = 0 - + peak_list = list(peak_range) min_list = list(min_range) - + # Create all test combinations test_combinations = [] for det_peak in peak_list: for det_min in min_list: test_combinations.append((det_peak, det_min)) - + # Sort by distance from center for center-out pattern peak_center = (max(peak_list) + min(peak_list)) / 2 min_center = (max(min_list) + min(min_list)) / 2 - + def distance_from_center(combo): peak, min_val = combo return ((peak - peak_center) ** 2 + (min_val - min_center) ** 2) ** 0.5 - + # Sort by distance from center test_combinations.sort(key=distance_from_center) - + # Randomize within bands for better coverage band_size = max(1, len(test_combinations) // 8) # Create 8 bands randomized_combinations = [] - + for i in range(0, len(test_combinations), band_size): - band = test_combinations[i:i + band_size] + band = test_combinations[i : i + band_size] random.shuffle(band) # Randomize within each band randomized_combinations.extend(band) - + # Run calibration in event loop with center-out randomized pattern if self.event_loop: for det_peak, det_min in randomized_combinations: if not self.running: break - + current += 1 self.progress["current"] = current - + # Update progress - self.broadcast_to_clients({ - "type": "progress", - "current": current, - "total": total_tests, - "peak": det_peak, - "min": det_min - }) - + self.broadcast_to_clients( + { + "type": "progress", + "current": current, + "total": total_tests, + "peak": det_peak, + "min": det_min, + } + ) + # Run the test future = asyncio.run_coroutine_threadsafe( - self.test_cad_config(radio, det_peak, det_min, samples), - self.event_loop + self.test_cad_config(radio, det_peak, det_min, samples), self.event_loop ) - + try: result = future.result(timeout=30) # 30 second timeout per test - + # Store result key = f"{det_peak}-{det_min}" self.results[key] = result - + # Send result to clients - self.broadcast_to_clients({ - "type": "result", - **result - }) + self.broadcast_to_clients({"type": "result", **result}) except Exception as e: logger.error(f"CAD test failed for peak={det_peak}, min={det_min}: {e}") - + # Delay between tests if self.running and delay_ms > 0: time.sleep(delay_ms / 1000.0) - + if self.running: # Find best result based on sensitivity score (not just detection rate) best_result = None recommended_result = None if self.results: # Find result with highest sensitivity score (best balance) - best_result = max(self.results.values(), key=lambda x: x.get('sensitivity_score', 0)) - + best_result = max( + self.results.values(), key=lambda x: x.get("sensitivity_score", 0) + ) + # Also find result with ideal adjusted detection rate (10-30%) - ideal_results = [r for r in self.results.values() if 10 <= r.get('adjusted_rate', 0) <= 30] + ideal_results = [ + r for r in self.results.values() if 10 <= r.get("adjusted_rate", 0) <= 30 + ] if ideal_results: # Among ideal results, pick the one with best sensitivity score - recommended_result = max(ideal_results, key=lambda x: x.get('sensitivity_score', 0)) + recommended_result = max( + ideal_results, key=lambda x: x.get("sensitivity_score", 0) + ) else: recommended_result = best_result - - self.broadcast_to_clients({ - "type": "completed", - "message": "Calibration completed", - "results": { - "best": best_result, - "recommended": recommended_result, - "total_tests": len(self.results) - } if best_result else None - }) + + self.broadcast_to_clients( + { + "type": "completed", + "message": "Calibration completed", + "results": ( + { + "best": best_result, + "recommended": recommended_result, + "total_tests": len(self.results), + } + if best_result + else None + ), + } + ) else: self.broadcast_to_clients({"type": "status", "message": "Calibration stopped"}) - + except Exception as e: logger.error(f"Calibration worker error: {e}") self.broadcast_to_clients({"type": "error", "message": str(e)}) finally: self.running = False - + def start_calibration(self, samples: int = 8, delay_ms: int = 100): - + if self.running: return False - + self.running = True self.results.clear() self.progress = {"current": 0, "total": 0} self.clear_message_queue() # Clear any old messages - + # Start calibration in separate thread self.calibration_thread = threading.Thread( - target=self.calibration_worker, - args=(samples, delay_ms) + target=self.calibration_worker, args=(samples, delay_ms) ) self.calibration_thread.daemon = True self.calibration_thread.start() - + return True - + def stop_calibration(self): - + self.running = False if self.calibration_thread: self.calibration_thread.join(timeout=2) - + def clear_message_queue(self): - - if hasattr(self, 'message_queue'): - self.message_queue.clear() \ No newline at end of file + + if hasattr(self, "message_queue"): + self.message_queue.clear() diff --git a/repeater/web/companion_endpoints.py b/repeater/web/companion_endpoints.py index 81ea9b3..24f60da 100644 --- a/repeater/web/companion_endpoints.py +++ b/repeater/web/companion_endpoints.py @@ -10,11 +10,12 @@ import asyncio import json import logging import queue -import time import threading +import time from typing import Optional import cherrypy + from .auth.middleware import require_auth logger = logging.getLogger("CompanionAPI") @@ -62,7 +63,9 @@ class CompanionAPIEndpoints: if name is not None: identity_manager = getattr(self.daemon_instance, "identity_manager", None) if identity_manager: - for reg_name, identity, _cfg in identity_manager.get_identities_by_type("companion"): + for reg_name, identity, _cfg in identity_manager.get_identities_by_type( + "companion" + ): if reg_name == name: hash_byte = identity.get_public_key()[0] bridge = bridges.get(hash_byte) @@ -74,7 +77,8 @@ class CompanionAPIEndpoints: if companion_hash is not None: bridge = bridges.get(companion_hash) if not bridge: - raise cherrypy.HTTPError(404, f"Companion 0x{companion_hash:02X} not found") + msg = f"Companion 0x{companion_hash:02X} not found" # noqa: E231 + raise cherrypy.HTTPError(404, msg) return bridge # --- default: first bridge --- @@ -154,9 +158,11 @@ class CompanionAPIEndpoints: def _make_cb(event_name): """Create a callback that serialises event data for SSE clients.""" + def _cb(*args, **kwargs): payload = self._serialise_event(event_name, args, kwargs) self._broadcast_sse(payload) + return _cb callback_names = [ @@ -218,15 +224,17 @@ class CompanionAPIEndpoints: items = [] for h, b in bridges.items(): - items.append({ - "companion_name": name_by_hash.get(h, ""), - "companion_hash": f"0x{h:02X}", - "node_name": b.prefs.node_name, - "public_key": b.get_public_key().hex(), - "is_running": b.is_running, - "contacts_count": b.contacts.get_count(), - "channels_count": b.channels.get_count(), - }) + items.append( + { + "companion_name": name_by_hash.get(h, ""), + "companion_hash": f"0x{h:02X}", # noqa: E231 + "node_name": b.prefs.node_name, + "public_key": b.get_public_key().hex(), + "is_running": b.is_running, + "contacts_count": b.contacts.get_count(), + "channels_count": b.channels.get_count(), + } + ) return self._success(items) # ----- Identity ----- @@ -238,18 +246,20 @@ class CompanionAPIEndpoints: """GET /api/companion/self_info — node identity and preferences.""" bridge = self._get_bridge(**self._resolve_bridge_params(kwargs)) prefs = bridge.get_self_info() - return self._success({ - "public_key": bridge.get_public_key().hex(), - "node_name": prefs.node_name, - "adv_type": prefs.adv_type, - "tx_power_dbm": prefs.tx_power_dbm, - "frequency_hz": prefs.frequency_hz, - "bandwidth_hz": prefs.bandwidth_hz, - "spreading_factor": prefs.spreading_factor, - "coding_rate": prefs.coding_rate, - "latitude": prefs.latitude, - "longitude": prefs.longitude, - }) + return self._success( + { + "public_key": bridge.get_public_key().hex(), + "node_name": prefs.node_name, + "adv_type": prefs.adv_type, + "tx_power_dbm": prefs.tx_power_dbm, + "frequency_hz": prefs.frequency_hz, + "bandwidth_hz": prefs.bandwidth_hz, + "spreading_factor": prefs.spreading_factor, + "coding_rate": prefs.coding_rate, + "latitude": prefs.latitude, + "longitude": prefs.longitude, + } + ) # ----- Contacts ----- @@ -263,17 +273,21 @@ class CompanionAPIEndpoints: contacts = bridge.get_contacts(since=since) items = [] for c in contacts: - items.append({ - "public_key": c.public_key.hex() if isinstance(c.public_key, bytes) else c.public_key, - "name": c.name, - "adv_type": c.adv_type, - "flags": c.flags, - "out_path_len": c.out_path_len, - "last_advert_timestamp": c.last_advert_timestamp, - "lastmod": c.lastmod, - "gps_lat": c.gps_lat, - "gps_lon": c.gps_lon, - }) + items.append( + { + "public_key": ( + c.public_key.hex() if isinstance(c.public_key, bytes) else c.public_key + ), + "name": c.name, + "adv_type": c.adv_type, + "flags": c.flags, + "out_path_len": c.out_path_len, + "last_advert_timestamp": c.last_advert_timestamp, + "lastmod": c.lastmod, + "gps_lat": c.gps_lat, + "gps_lon": c.gps_lon, + } + ) return self._success(items) @cherrypy.expose @@ -289,18 +303,22 @@ class CompanionAPIEndpoints: c = bridge.get_contact_by_key(pub_key) if not c: raise cherrypy.HTTPError(404, "Contact not found") - return self._success({ - "public_key": c.public_key.hex() if isinstance(c.public_key, bytes) else c.public_key, - "name": c.name, - "adv_type": c.adv_type, - "flags": c.flags, - "out_path_len": c.out_path_len, - "out_path": c.out_path.hex() if isinstance(c.out_path, bytes) else "", - "last_advert_timestamp": c.last_advert_timestamp, - "lastmod": c.lastmod, - "gps_lat": c.gps_lat, - "gps_lon": c.gps_lon, - }) + return self._success( + { + "public_key": ( + c.public_key.hex() if isinstance(c.public_key, bytes) else c.public_key + ), + "name": c.name, + "adv_type": c.adv_type, + "flags": c.flags, + "out_path_len": c.out_path_len, + "out_path": c.out_path.hex() if isinstance(c.out_path, bytes) else "", + "last_advert_timestamp": c.last_advert_timestamp, + "lastmod": c.lastmod, + "gps_lat": c.gps_lat, + "gps_lon": c.gps_lon, + } + ) # ----- Channels ----- @@ -315,11 +333,13 @@ class CompanionAPIEndpoints: for idx in range(bridge.channels.max_channels): ch = bridge.channels.get(idx) if ch: - items.append({ - "index": idx, - "name": ch.name, - # Don't expose the PSK secret over REST - }) + items.append( + { + "index": idx, + "name": ch.name, + # Don't expose the PSK secret over REST + } + ) return self._success(items) except cherrypy.HTTPError: raise @@ -354,14 +374,14 @@ class CompanionAPIEndpoints: if not text: raise cherrypy.HTTPError(400, "text required") txt_type = int(body.get("txt_type", 0)) - result = self._run_async( - bridge.send_text_message(pub_key, text, txt_type=txt_type) + result = self._run_async(bridge.send_text_message(pub_key, text, txt_type=txt_type)) + return self._success( + { + "sent": result.success, + "is_flood": result.is_flood, + "expected_ack": result.expected_ack, + } ) - return self._success({ - "sent": result.success, - "is_flood": result.is_flood, - "expected_ack": result.expected_ack, - }) @cherrypy.expose @cherrypy.tools.json_out() @@ -390,9 +410,7 @@ class CompanionAPIEndpoints: bridge = self._get_bridge(**self._resolve_bridge_params(body)) pub_key = self._pub_key_from_hex(body.get("pub_key", "")) password = body.get("password", "") - result = self._run_async( - bridge.send_login(pub_key, password), timeout=15.0 - ) + result = self._run_async(bridge.send_login(pub_key, password), timeout=15.0) return self._success(_to_json_safe(result)) # ----- Status / Telemetry Requests ----- @@ -417,7 +435,11 @@ class CompanionAPIEndpoints: @cherrypy.tools.json_out() @require_auth def request_telemetry(self, **kwargs): - """POST /api/companion/request_telemetry {pub_key, want_base?, want_location?, want_environment?, timeout?, companion_name?}""" + """POST /api/companion/request_telemetry. + + Body: pub_key, want_base?, want_location?, want_environment?, + timeout?, companion_name? + """ self._require_post() try: body = self._get_json_body() @@ -531,7 +553,8 @@ class CompanionAPIEndpoints: def generate(): try: - yield f"data: {json.dumps({'event': 'connected', 'timestamp': int(time.time())})}\n\n" + payload = {"event": "connected", "timestamp": int(time.time())} + yield f"data: {json.dumps(payload)}\n\n" while True: try: @@ -539,7 +562,8 @@ class CompanionAPIEndpoints: yield f"data: {json.dumps(item)}\n\n" except queue.Empty: # Keep-alive comment - yield f"data: {json.dumps({'event': 'keepalive', 'timestamp': int(time.time())})}\n\n" + payload = {"event": "keepalive", "timestamp": int(time.time())} + yield f"data: {json.dumps(payload)}\n\n" except GeneratorExit: pass except Exception as exc: @@ -558,6 +582,7 @@ class CompanionAPIEndpoints: # Utility: make arbitrary objects JSON-serialisable for SSE events # ====================================================================== + def _to_json_safe(obj): """Convert common companion objects to JSON-safe dicts/values.""" if obj is None or isinstance(obj, (bool, int, float, str)): diff --git a/repeater/web/html/assets/plotly.min-DO11Gp-n.js b/repeater/web/html/assets/plotly.min-DO11Gp-n.js index c8ae593..dcefca3 100644 --- a/repeater/web/html/assets/plotly.min-DO11Gp-n.js +++ b/repeater/web/html/assets/plotly.min-DO11Gp-n.js @@ -233,7 +233,7 @@ float cookTorranceSpecular( float G1 = (2.0 * NdotH * VdotN) / VdotH; float G2 = (2.0 * NdotH * LdotN) / LdotH; float G = min(1.0, min(G1, G2)); - + //Distribution term float D = beckmannDistribution(NdotH, roughness); @@ -245,7 +245,7 @@ float cookTorranceSpecular( } bool outOfRange(float a, float b, float p) { - return ((p > max(a, b)) || + return ((p > max(a, b)) || (p < min(a, b))); } @@ -393,7 +393,7 @@ void main() { #define GLSLIFY 1 bool outOfRange(float a, float b, float p) { - return ((p > max(a, b)) || + return ((p > max(a, b)) || (p < min(a, b))); } @@ -511,7 +511,7 @@ float cookTorranceSpecular( float G1 = (2.0 * NdotH * VdotN) / VdotH; float G2 = (2.0 * NdotH * LdotN) / LdotH; float G = min(1.0, min(G1, G2)); - + //Distribution term float D = beckmannDistribution(NdotH, roughness); @@ -525,7 +525,7 @@ float cookTorranceSpecular( //#pragma glslify: beckmann = require(glsl-specular-beckmann) // used in gl-surface3d bool outOfRange(float a, float b, float p) { - return ((p > max(a, b)) || + return ((p > max(a, b)) || (p < min(a, b))); } @@ -604,7 +604,7 @@ void main() { #define GLSLIFY 1 bool outOfRange(float a, float b, float p) { - return ((p > max(a, b)) || + return ((p > max(a, b)) || (p < min(a, b))); } @@ -639,7 +639,7 @@ void main() { #define GLSLIFY 1 bool outOfRange(float a, float b, float p) { - return ((p > max(a, b)) || + return ((p > max(a, b)) || (p < min(a, b))); } @@ -713,7 +713,7 @@ void main() { #define GLSLIFY 1 bool outOfRange(float a, float b, float p) { - return ((p > max(a, b)) || + return ((p > max(a, b)) || (p < min(a, b))); } @@ -746,7 +746,7 @@ void main() { #define GLSLIFY 1 bool outOfRange(float a, float b, float p) { - return ((p > max(a, b)) || + return ((p > max(a, b)) || (p < min(a, b))); } @@ -861,7 +861,7 @@ float beckmannSpecular( } bool outOfRange(float a, float b, float p) { - return ((p > max(a, b)) || + return ((p > max(a, b)) || (p < min(a, b))); } @@ -963,7 +963,7 @@ void main() { #define GLSLIFY 1 bool outOfRange(float a, float b, float p) { - return ((p > max(a, b)) || + return ((p > max(a, b)) || (p < min(a, b))); } @@ -1011,7 +1011,7 @@ void main() { #define GLSLIFY 1 bool outOfRange(float a, float b, float p) { - return ((p > max(a, b)) || + return ((p > max(a, b)) || (p < min(a, b))); } @@ -1068,7 +1068,7 @@ void main() { #define GLSLIFY 1 bool outOfRange(float a, float b, float p) { - return ((p > max(a, b)) || + return ((p > max(a, b)) || (p < min(a, b))); } @@ -1126,7 +1126,7 @@ void main() { #define GLSLIFY 1 bool outOfRange(float a, float b, float p) { - return ((p > max(a, b)) || + return ((p > max(a, b)) || (p < min(a, b))); } @@ -1186,7 +1186,7 @@ void main() { #define GLSLIFY 1 bool outOfRange(float a, float b, float p) { - return ((p > max(a, b)) || + return ((p > max(a, b)) || (p < min(a, b))); } @@ -1222,7 +1222,7 @@ void main() { #define GLSLIFY 1 bool outOfRange(float a, float b, float p) { - return ((p > max(a, b)) || + return ((p > max(a, b)) || (p < min(a, b))); } @@ -1526,7 +1526,7 @@ void main() { #define GLSLIFY 1 bool outOfRange(float a, float b, float p) { - return ((p > max(a, b)) || + return ((p > max(a, b)) || (p < min(a, b))); } @@ -1673,7 +1673,7 @@ float cookTorranceSpecular( float G1 = (2.0 * NdotH * VdotN) / VdotH; float G2 = (2.0 * NdotH * LdotN) / LdotH; float G = min(1.0, min(G1, G2)); - + //Distribution term float D = beckmannDistribution(NdotH, roughness); @@ -1685,7 +1685,7 @@ float cookTorranceSpecular( } bool outOfRange(float a, float b, float p) { - return ((p > max(a, b)) || + return ((p > max(a, b)) || (p < min(a, b))); } @@ -1796,7 +1796,7 @@ void main() { #define GLSLIFY 1 bool outOfRange(float a, float b, float p) { - return ((p > max(a, b)) || + return ((p > max(a, b)) || (p < min(a, b))); } @@ -1871,7 +1871,7 @@ void main() { #define GLSLIFY 1 bool outOfRange(float a, float b, float p) { - return ((p > max(a, b)) || + return ((p > max(a, b)) || (p < min(a, b))); } @@ -1957,7 +1957,7 @@ vec4 packFloat(float v) { } bool outOfRange(float a, float b, float p) { - return ((p > max(a, b)) || + return ((p > max(a, b)) || (p < min(a, b))); } diff --git a/repeater/web/http_server.py b/repeater/web/http_server.py index 2740e7f..84e21c4 100644 --- a/repeater/web/http_server.py +++ b/repeater/web/http_server.py @@ -14,15 +14,21 @@ from pymc_core.protocol.utils import PAYLOAD_TYPES, ROUTE_TYPES from repeater import __version__ from repeater.data_acquisition import SQLiteHandler + from .api_endpoints import APIEndpoints -from .auth_endpoints import AuthEndpoints -from .auth.jwt_handler import JWTHandler -from .auth.api_tokens import APITokenManager from .auth import cherrypy_tool # Import to register the tool +from .auth.api_tokens import APITokenManager +from .auth.jwt_handler import JWTHandler +from .auth_endpoints import AuthEndpoints # WebSocket support try: - from repeater.data_acquisition.websocket_handler import PacketWebSocket, init_websocket, broadcast_packet + from repeater.data_acquisition.websocket_handler import ( + PacketWebSocket, + broadcast_packet, + init_websocket, + ) + WEBSOCKET_AVAILABLE = True except ImportError: WEBSOCKET_AVAILABLE = False @@ -61,40 +67,41 @@ _log_buffer = LogBuffer(max_lines=100) class DocEndpoint: """Simple wrapper to serve API docs at /doc""" - + def __init__(self, api_endpoints): self.api_endpoints = api_endpoints - + @cherrypy.expose def index(self, **kwargs): """Serve Swagger UI at /doc""" return self.api_endpoints.docs() - + @cherrypy.expose def docs(self): """Serve Swagger UI at /doc/docs""" return self.api_endpoints.docs() - + @cherrypy.expose def openapi_json(self): """Serve OpenAPI spec in JSON format at /doc/openapi.json""" - import os - import yaml import json - - spec_path = os.path.join(os.path.dirname(__file__), 'openapi.yaml') + import os + + import yaml + + spec_path = os.path.join(os.path.dirname(__file__), "openapi.yaml") try: - with open(spec_path, 'r') as f: + with open(spec_path, "r") as f: spec_content = yaml.safe_load(f) - - cherrypy.response.headers['Content-Type'] = 'application/json' - return json.dumps(spec_content).encode('utf-8') + + cherrypy.response.headers["Content-Type"] = "application/json" + return json.dumps(spec_content).encode("utf-8") except FileNotFoundError: cherrypy.response.status = 404 - return json.dumps({"error": "OpenAPI spec not found"}).encode('utf-8') + return json.dumps({"error": "OpenAPI spec not found"}).encode("utf-8") except Exception as e: cherrypy.response.status = 500 - return json.dumps({"error": f"Error loading OpenAPI spec: {e}"}).encode('utf-8') + return json.dumps({"error": f"Error loading OpenAPI spec: {e}"}).encode("utf-8") class StatsApp: @@ -116,7 +123,7 @@ class StatsApp: self.pub_key = pub_key self.dashboard_template = None self.config = config or {} - + # Path to the compiled Vue.js application # Use web_path from config if provided, otherwise use default default_html_dir = os.path.join(os.path.dirname(__file__), "html") @@ -124,8 +131,10 @@ class StatsApp: self.html_dir = web_path if web_path is not None else default_html_dir # Create nested API object for routing - self.api = APIEndpoints(stats_getter, send_advert_func, self.config, event_loop, daemon_instance, config_path) - + self.api = APIEndpoints( + stats_getter, send_advert_func, self.config, event_loop, daemon_instance, config_path + ) + # Create doc endpoint for API documentation self.doc = DocEndpoint(self.api) @@ -134,7 +143,7 @@ class StatsApp: """Serve the Vue.js application index.html.""" index_path = os.path.join(self.html_dir, "index.html") try: - with open(index_path, 'r', encoding='utf-8') as f: + with open(index_path, "r", encoding="utf-8") as f: return f.read() except FileNotFoundError: raise cherrypy.HTTPError(404, "Application not found. Please build the frontend first.") @@ -148,19 +157,18 @@ class StatsApp: # Handle OPTIONS requests for any path if cherrypy.request.method == "OPTIONS": return "" - + # Let API routes pass through - if args and args[0] == 'api': + if args and args[0] == "api": raise cherrypy.NotFound() - + # Handle WebSocket routes - if args and len(args) >= 2 and args[0] == 'ws' and args[1] == 'packets': + if args and len(args) >= 2 and args[0] == "ws" and args[1] == "packets": # WebSocket tool will intercept this return "" - + # For all other routes, serve the Vue.js app (client-side routing) return self.index() - class HTTPStatsServer: @@ -183,20 +191,29 @@ class HTTPStatsServer: self.port = port self.config = config or {} self.config_path = config_path - + # Initialize authentication handlers self._init_auth_handlers() - + self.app = StatsApp( - stats_getter, node_name, pub_key, send_advert_func, config, event_loop, daemon_instance, config_path + stats_getter, + node_name, + pub_key, + send_advert_func, + config, + event_loop, + daemon_instance, + config_path, ) - + # Create auth endpoints (APIEndpoints has the config_manager) - self.auth_app = AuthEndpoints(self.config, self.jwt_handler, self.token_manager, self.app.api.config_manager) - + self.auth_app = AuthEndpoints( + self.config, self.jwt_handler, self.token_manager, self.app.api.config_manager + ) + # Create documentation endpoints as separate app self.doc_app = DocEndpoint(self.app.api) - + # Set up CORS at the server level if enabled self._cors_enabled = self.config.get("web", {}).get("cors_enabled", False) logger.info(f"CORS enabled: {self._cors_enabled}") @@ -207,43 +224,46 @@ class HTTPStatsServer: repeater_config = self.config.get("repeater", {}) security_config = repeater_config.get("security", {}) jwt_secret = security_config.get("jwt_secret", "") - + if not jwt_secret: # Auto-generate JWT secret jwt_secret = secrets.token_hex(32) - logger.warning("No JWT secret found in config, auto-generated one. Please save this to config.yaml:") - + logger.warning( + "No JWT secret found in config, auto-generated one. Please save this to config.yaml:" + ) + # Try to save to config if config_path is available if self.config_path: try: import yaml - with open(self.config_path, 'r') as f: + + with open(self.config_path, "r") as f: config_data = yaml.safe_load(f) or {} - - if 'repeater' not in config_data: - config_data['repeater'] = {} - if 'security' not in config_data['repeater']: - config_data['repeater']['security'] = {} - config_data['repeater']['security']['jwt_secret'] = jwt_secret - - with open(self.config_path, 'w') as f: + + if "repeater" not in config_data: + config_data["repeater"] = {} + if "security" not in config_data["repeater"]: + config_data["repeater"]["security"] = {} + config_data["repeater"]["security"]["jwt_secret"] = jwt_secret + + with open(self.config_path, "w") as f: yaml.dump(config_data, f, default_flow_style=False) - + logger.info(f"Saved auto-generated JWT secret to {self.config_path}") except Exception as e: logger.error(f"Failed to save JWT secret to config: {e}") - + # Initialize JWT handler with configurable expiry (default 1 hour) jwt_expiry_minutes = security_config.get("jwt_expiry_minutes", 60) self.jwt_handler = JWTHandler(jwt_secret, expiry_minutes=jwt_expiry_minutes) logger.info(f"JWT handler initialized (token expiry: {jwt_expiry_minutes} minutes)") - + # Initialize API token manager storage_dir = self.config.get("storage", {}).get("storage_dir", ".") - + # Ensure storage directory exists os.makedirs(storage_dir, exist_ok=True) - + # Initialize SQLiteHandler and APITokenManager self.sqlite_handler = SQLiteHandler(Path(storage_dir)) self.token_manager = APITokenManager(self.sqlite_handler, jwt_secret) @@ -254,29 +274,25 @@ class HTTPStatsServer: # Configure CORS to allow Authorization header # cherrypy-cors will handle preflight requests automatically cherrypy_cors.install() - + logger.info("CORS support enabled with Authorization header") - + def _json_error_handler(self, status, message, traceback, version): """Return JSON error responses instead of HTML for API endpoints""" cherrypy.response.headers["Content-Type"] = "application/json" - return json.dumps({ - "success": False, - "error": message - }) + return json.dumps({"success": False, "error": message}) def start(self): try: - + if self._cors_enabled: self._setup_server_cors() - default_html_dir = os.path.join(os.path.dirname(__file__), "html") web_path = self.config.get("web", {}).get("web_path") html_dir = web_path if web_path is not None else default_html_dir - + assets_dir = os.path.join(html_dir, "assets") next_dir = os.path.join(html_dir, "_next") @@ -288,11 +304,11 @@ class HTTPStatsServer: # "tools.gzip.mime_types": ["application/json", "text/html", "text/plain"], # Ensure proper content types for static files "tools.staticfile.content_types": { - 'js': 'application/javascript', - 'css': 'text/css', - 'html': 'text/html; charset=utf-8', - 'svg': 'image/svg+xml', - 'txt': 'text/plain' + "js": "application/javascript", + "css": "text/css", + "html": "text/html; charset=utf-8", + "svg": "image/svg+xml", + "txt": "text/plain", }, }, # Require authentication for all /api endpoints @@ -330,7 +346,7 @@ class HTTPStatsServer: "tools.staticfile.filename": os.path.join(html_dir, "favicon.ico"), }, } - + # Add WebSocket configuration to main config if available if WEBSOCKET_AVAILABLE: try: @@ -340,33 +356,34 @@ class HTTPStatsServer: "tools.websocket.handler_cls": PacketWebSocket, "tools.trailing_slash.on": False, "tools.require_auth.on": False, - "tools.gzip.on": False, + "tools.gzip.on": False, } logger.info("WebSocket endpoint configured at /ws/packets") except Exception as e: logger.error(f"Failed to initialize WebSocket: {e}") import traceback + logger.error(traceback.format_exc()) - + # Add CORS configuration if enabled if self._cors_enabled: cors_config = { "cors.expose.on": True, "tools.response_headers.on": True, "tools.response_headers.headers": [ - ('Access-Control-Allow-Origin', '*'), - ('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'), - ('Access-Control-Allow-Headers', 'Authorization, Content-Type, X-API-Key'), - ('Access-Control-Allow-Credentials', 'true'), + ("Access-Control-Allow-Origin", "*"), + ("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS"), + ("Access-Control-Allow-Headers", "Authorization, Content-Type, X-API-Key"), + ("Access-Control-Allow-Credentials", "true"), ], # Disable automatic trailing slash redirects to prevent CORS issues "tools.trailing_slash.on": False, } - + # Apply CORS to paths config["/"].update(cors_config) config["/api"].update(cors_config) - + # Add Vue.js assets support only if assets directory exists if os.path.isdir(assets_dir): config["/assets"] = { @@ -374,12 +391,12 @@ class HTTPStatsServer: "tools.staticdir.dir": assets_dir, # Set proper content types for assets "tools.staticdir.content_types": { - 'js': 'application/javascript', - 'css': 'text/css', - 'map': 'application/json' + "js": "application/javascript", + "css": "text/css", + "map": "application/json", }, } - + # Add Next.js support only if _next directory exists if os.path.isdir(next_dir): config["/_next"] = { @@ -387,9 +404,9 @@ class HTTPStatsServer: "tools.staticdir.dir": next_dir, # Set proper content types for Next.js assets "tools.staticdir.content_types": { - 'js': 'application/javascript', - 'css': 'text/css', - 'map': 'application/json' + "js": "application/javascript", + "css": "text/css", + "map": "application/json", }, } @@ -421,13 +438,13 @@ class HTTPStatsServer: # Mount main app cherrypy.tree.mount(self.app, "/", config) - + # Mount auth endpoints auth_config = { "/": { "tools.response_headers.on": True, "tools.response_headers.headers": [ - ('Content-Type', 'application/json'), + ("Content-Type", "application/json"), ], # Disable automatic trailing slash redirects "tools.trailing_slash.on": False, @@ -436,42 +453,48 @@ class HTTPStatsServer: if self._cors_enabled: auth_config["/"]["cors.expose.on"] = True # Add CORS headers for OPTIONS requests - auth_config["/"]["tools.response_headers.headers"].extend([ - ('Access-Control-Allow-Origin', '*'), - ('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'), - ('Access-Control-Allow-Headers', 'Authorization, Content-Type, X-API-Key'), - ('Access-Control-Allow-Credentials', 'true'), - ]) - + auth_config["/"]["tools.response_headers.headers"].extend( + [ + ("Access-Control-Allow-Origin", "*"), + ("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS"), + ("Access-Control-Allow-Headers", "Authorization, Content-Type, X-API-Key"), + ("Access-Control-Allow-Credentials", "true"), + ] + ) + cherrypy.tree.mount(self.auth_app, "/auth", auth_config) - + # Mount documentation endpoints as separate app (no auth required for docs) doc_config = { "/": { "tools.require_auth.on": False, # Docs are publicly accessible "tools.response_headers.on": True, "tools.response_headers.headers": [ - ('Content-Type', 'text/html; charset=utf-8'), + ("Content-Type", "text/html; charset=utf-8"), ], "tools.trailing_slash.on": False, } } if self._cors_enabled: doc_config["/"]["cors.expose.on"] = True - doc_config["/"]["tools.response_headers.headers"].extend([ - ('Access-Control-Allow-Origin', '*'), - ('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'), - ('Access-Control-Allow-Headers', 'Authorization, Content-Type, X-API-Key'), - ]) - + doc_config["/"]["tools.response_headers.headers"].extend( + [ + ("Access-Control-Allow-Origin", "*"), + ("Access-Control-Allow-Methods", "GET, POST, OPTIONS"), + ("Access-Control-Allow-Headers", "Authorization, Content-Type, X-API-Key"), + ] + ) + cherrypy.tree.mount(self.doc_app, "/doc", doc_config) - + # Store auth handlers in cherrypy config for middleware access - cherrypy.config.update({ - "jwt_handler": self.jwt_handler, - "token_manager": self.token_manager, - "security_config": self.config.get("security", {}), - }) + cherrypy.config.update( + { + "jwt_handler": self.jwt_handler, + "token_manager": self.token_manager, + "security_config": self.config.get("security", {}), + } + ) # Completely disable access logging cherrypy.log.access_log.propagate = False diff --git a/repeater/web/openapi.yaml b/repeater/web/openapi.yaml index 2f32422..9cfdc0e 100644 --- a/repeater/web/openapi.yaml +++ b/repeater/web/openapi.yaml @@ -3,7 +3,7 @@ info: title: pyMC Repeater API description: | REST API for pyMC Repeater - LoRa mesh network repeater with room server functionality. - + ## Features - System statistics and monitoring - Packet history and analysis @@ -726,7 +726,7 @@ paths: summary: Get RRD time-series data description: | Retrieve Round-Robin Database metrics for graphing. - + **Note:** This endpoint extracts parameters from the request internally. Parameters are handled automatically by the backend. responses: @@ -821,7 +821,7 @@ paths: summary: Get system metrics graph data description: | Returns time-series data for system metrics like packet counts, RSSI, SNR, etc. - + Available metrics: - rx_count: Received packets - tx_count: Transmitted packets @@ -1557,7 +1557,7 @@ paths: summary: Get ACL information for all identities description: | Get ACL configuration and statistics for all registered identities. - + Returns information including: - Identity name, type, and hash - Max clients allowed @@ -1741,7 +1741,7 @@ paths: summary: Get room messages description: | Retrieve messages from a room with pagination. - + **Max Messages Per Room**: 32 (hard limit) - Older messages auto-deleted every 10 minutes - Cannot be increased beyond 32 @@ -1847,15 +1847,15 @@ paths: summary: Post message to room description: | Add a new message to a room server. Message will be distributed to all synced clients. - + **Special author values:** - `"server"` or `"system"` - System message, goes to ALL clients (API only) - Any hex string - Normal message, NOT sent to that client - + **Security:** - Radio messages cannot use server key (blocked) - API messages can use server key (for announcements) - + **Rate Limits:** - 10 messages/minute per author_pubkey - 160 bytes max message length @@ -1992,7 +1992,7 @@ paths: summary: Get room statistics description: | Get detailed statistics for one or all room servers. - + **Room Limits:** - 32 messages maximum per room (hard limit) - Messages auto-expire every 10 minutes @@ -2101,7 +2101,7 @@ paths: summary: Get room clients description: | List all clients synced to a room with their status. - + **Client Filtering:** - Clients only receive messages where author_pubkey ≠ client_pubkey - unsynced_count shows pending messages for each client @@ -2335,7 +2335,7 @@ components: type: boolean description: Client is currently active (synced within timeout period) example: true - + Identity: type: object required: [name, type, hash, public_key] @@ -2385,7 +2385,7 @@ components: default: 32 description: Maximum messages to keep (room_server only, hard limit 32) example: 32 - + ACLClient: type: object required: [public_key, public_key_full, address, permissions] diff --git a/scripts/build-prod.sh b/scripts/build-prod.sh index 896b9ca..0a67fd3 100755 --- a/scripts/build-prod.sh +++ b/scripts/build-prod.sh @@ -152,7 +152,7 @@ if [ -n "$DEB_FILE" ]; then # Run lintian to check package quality log_step "Running lintian checks..." lintian "$DEB_FILE" || log_warn "Lintian found some issues (non-fatal)" - + log_info "" log_info "════════════════════════════════════════════════════════════" log_info "Production build complete!" diff --git a/scripts/setup-build-env.sh b/scripts/setup-build-env.sh index 168f8e7..5c41552 100755 --- a/scripts/setup-build-env.sh +++ b/scripts/setup-build-env.sh @@ -23,7 +23,7 @@ log_error() { } # Check if running as root or with sudo -if [ "$EUID" -ne 0 ]; then +if [ "$EUID" -ne 0 ]; then log_error "This script must be run with sudo or as root" exit 1 fi diff --git a/setup-radio-config.sh b/setup-radio-config.sh index f85d8bc..2f5f3a8 100644 --- a/setup-radio-config.sh +++ b/setup-radio-config.sh @@ -124,13 +124,13 @@ API_RESPONSE=$(curl -s --max-time 5 https://api.meshcore.nz/api/v1/config 2>/dev if [ -z "$API_RESPONSE" ]; then echo "Warning: Failed to fetch configuration from API (timeout or error)" echo "Using local radio presets file..." - + LOCAL_PRESETS="$SCRIPT_DIR/radio-presets.json" if [ ! -f "$LOCAL_PRESETS" ]; then echo "Error: Local radio presets file not found at $LOCAL_PRESETS" exit 1 fi - + API_RESPONSE=$(cat "$LOCAL_PRESETS") if [ -z "$API_RESPONSE" ]; then echo "Error: Failed to read local radio presets file" @@ -291,7 +291,7 @@ else [ -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" - + # Handle LED pins - add if missing, update if present if [ -n "$txled_pin" ]; then if grep -q "^ txled_pin:" "$CONFIG_FILE"; then @@ -301,7 +301,7 @@ else sed "${SED_OPTS[@]}" "/^ rxen_pin:.*/a\\ txled_pin: $txled_pin" "$CONFIG_FILE" fi fi - + if [ -n "$rxled_pin" ]; then if grep -q "^ rxled_pin:" "$CONFIG_FILE"; then sed "${SED_OPTS[@]}" "s/^ rxled_pin:.*/ rxled_pin: $rxled_pin/" "$CONFIG_FILE" @@ -310,7 +310,7 @@ else sed "${SED_OPTS[@]}" "/^ txled_pin:.*/a\\ rxled_pin: $rxled_pin" "$CONFIG_FILE" fi fi - + [ -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"