diff --git a/.gitignore b/.gitignore index f045404..4eed89b 100644 --- a/.gitignore +++ b/.gitignore @@ -34,6 +34,7 @@ debian/pymc-repeater.substvars # Virtual environments .venv/ +.venv_new/ env/ ENV/ diff --git a/README.md b/README.md index e341724..8f2a5a4 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,16 @@ Frequency Labs meshadv TX Power: Up to 22dBm SPI Bus: SPI0 GPIO Pins: CS=21, Reset=18, Busy=20, IRQ=16, TXEN=13, RXEN=12, use_dio3_tcxo=True + +HT-RA62 module + + Hardware: Heltec HT-RA62 LoRa module + Platform: Raspberry Pi (or compatible single-board computer) + Frequency: 868MHz (EU) or 915MHz (US) + TX Power: Up to 22dBm + SPI Bus: SPI0 + GPIO Pins: CS=21, Reset=18, Busy=20, IRQ=16, use_dio3_tcxo=True, use_dio2_rf=True + ... ## Screenshots diff --git a/config.yaml.example b/config.yaml.example index c711fa7..cd5d626 100644 --- a/config.yaml.example +++ b/config.yaml.example @@ -89,7 +89,6 @@ 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" @@ -117,6 +116,18 @@ identities: # node_name: "meshcore-bot" # tcp_port: 5001 +# Radio hardware type +# Supported: +# - sx1262 (Linux spidev + system GPIO) +# - sx1262_ch341 (CH341 USB-to-SPI + CH341 GPIO 0-7) +radio_type: sx1262 + +# CH341 USB-to-SPI adapter settings (only used when radio_type: sx1262_ch341) +# NOTE: VID/PID are integers. Hex is also accepted in YAML, e.g. 0x1A86. +ch341: + vid: 6790 # 0x1A86 + pid: 21778 # 0x5512 + radio: # Frequency in Hz (869.618 MHz for EU) frequency: 869618000 @@ -151,12 +162,16 @@ radio: # baud_rate: 9600 # SX1262 Hardware Configuration +# NOTE: +# - When radio_type: sx1262, these pins are BCM GPIO numbers. +# - When radio_type: sx1262_ch341, these pins are CH341 GPIO numbers (0-7). sx1262: # SPI bus and chip select + # NOTE: For CH341 these are not used but are still required parameters. bus_id: 0 cs_id: 0 - # GPIO pins (BCM numbering) + # GPIO pins cs_pin: 21 reset_pin: 18 busy_pin: 20 @@ -171,6 +186,8 @@ sx1262: rxled_pin: -1 use_dio3_tcxo: false + dio3_tcxo_voltage: 1.8 + use_dio2_rf: false # Waveshare hardware flag is_waveshare: false diff --git a/manage.sh b/manage.sh index 6e40487..e5f9ac1 100755 --- a/manage.sh +++ b/manage.sh @@ -70,6 +70,11 @@ is_running() { systemctl is-active "$SERVICE_NAME" >/dev/null 2>&1 } +# Function to check if service is enabled +is_enabled() { + systemctl is-enabled "$SERVICE_NAME" >/dev/null 2>&1 +} + # Function to get current version get_version() { # Try to read from _version.py first (generated by setuptools_scm) @@ -178,6 +183,7 @@ install_repeater() { $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 + SPI_MISSING=0 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 CONFIG_FILE="" @@ -194,16 +200,28 @@ install_repeater() { show_info "SPI Enabled" "\nSPI has been enabled in $CONFIG_FILE\n\nSystem will reboot now. Please run this script again after reboot." reboot else - show_error "SPI is required for LoRa radio operation.\n\nPlease enable SPI manually and run this script again." - return + if ask_yes_no "Continue Without SPI?" "\nSPI is required for LoRa radio operation and is not enabled.\n\nYou can continue the installation, but the radio will not work until SPI is enabled.\n\nContinue anyway?"; then + SPI_MISSING=1 + else + show_error "SPI is required for LoRa radio operation.\n\nPlease enable SPI manually and run this script again." + return + fi fi else # Not a Raspberry Pi - provide generic instructions - show_error "SPI interface is required but not detected (/dev/spidev* not found).\n\nPlease enable SPI in your system's configuration and ensure the SPI kernel module is loaded.\n\nFor Raspberry Pi: sudo raspi-config -> Interfacing Options -> SPI -> Enable" - return + if ask_yes_no "SPI Not Detected" "\nSPI interface is required but not detected (/dev/spidev* not found).\n\nPlease enable SPI in your system's configuration and ensure the SPI kernel module is loaded.\n\nFor Raspberry Pi: sudo raspi-config -> Interfacing Options -> SPI -> Enable\n\nContinue installation anyway?"; then + SPI_MISSING=1 + else + show_error "SPI interface is required but not detected (/dev/spidev* not found).\n\nPlease enable SPI in your system's configuration and ensure the SPI kernel module is loaded.\n\nFor Raspberry Pi: sudo raspi-config -> Interfacing Options -> SPI -> Enable" + return + fi fi fi + if [ "$SPI_MISSING" -eq 1 ]; then + show_info "Warning" "\nContinuing without SPI enabled.\n\nLoRa radio will not work until SPI is enabled and /dev/spidev* is available." + fi + # Get script directory for file copying during installation SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" @@ -719,6 +737,9 @@ manage_service() { case $action in "start") + if ! is_enabled; then + systemctl enable "$SERVICE_NAME" + fi systemctl start "$SERVICE_NAME" if is_running; then show_info "Service Started" "\n✓ pyMC Repeater service has been started successfully." diff --git a/radio-presets.json b/radio-presets.json index 9123f44..99090fd 100644 --- a/radio-presets.json +++ b/radio-presets.json @@ -1 +1,151 @@ -{"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"}]}}} +{ + "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 (Narrow)", + "description": "916.575MHz / SF7 / BW62.5 / CR8", + "frequency": "916.575", + "spreading_factor": "7", + "bandwidth": "62.5", + "coding_rate": "8" + }, + { + "title": "Australia: SA, WA, QLD", + "description": "923.125MHz / SF8 / BW62.5 / CR8", + "frequency": "923.125", + "spreading_factor": "8", + "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 diff --git a/radio-settings.json b/radio-settings.json index b4f35dc..390ab8d 100644 --- a/radio-settings.json +++ b/radio-settings.json @@ -16,8 +16,8 @@ "preamble_length": 17, "is_waveshare": true }, - "uconsole": { - "name": "uConsole LoRa Module", + "uconsole_aiov1": { + "name": "uConsole LoRa Module aio v1", "bus_id": 1, "cs_id": 0, "cs_pin": -1, @@ -30,6 +30,23 @@ "rxled_pin": -1, "tx_power": 22, "preamble_length": 17 + }, + "uconsole_aio_v2": { + "name": "uConsole LoRa Module aio v2", + "bus_id": 1, + "cs_id": 0, + "cs_pin": -1, + "reset_pin": 25, + "busy_pin": 24, + "irq_pin": 26, + "txen_pin": -1, + "rxen_pin": -1, + "txled_pin": -1, + "rxled_pin": -1, + "tx_power": 22, + "preamble_length": 17, + "use_dio3_tcxo": true, + "use_dio2_rf": true }, "pimesh-1w-usa": { "name": "PiMesh-1W (USA)", @@ -111,6 +128,24 @@ "use_dio2_rf": true, "preamble_length": 17 }, + "femtofox-1W-SX": { + "name": "FemtoFox SX1262 (1W)", + "bus_id": 0, + "cs_id": 0, + "cs_pin": 16, + "gpio_chip": 1, + "use_gpiod_backend": true, + "reset_pin": 25, + "busy_pin": 22, + "irq_pin": 23, + "txen_pin": -1, + "rxen_pin": 24, + "txled_pin": -1, + "rxled_pin": -1, + "tx_power": 30, + "use_dio3_tcxo": true, + "preamble_length": 17 + }, "nebrahat": { "name": "NebraHat-2W", "bus_id": 0, @@ -127,6 +162,29 @@ "use_dio3_tcxo": true, "use_dio2_rf": true, "preamble_length": 17 + }, + "ch341-usb-sx1262": { + "name": "CH341 USB-SPI + SX1262 (example)", + "description": "SX1262 via CH341 USB-to-SPI adapter. NOTE: pin numbers are CH341 GPIO 0-7, not BCM.", + "radio_type": "sx1262_ch341", + "vid": 6790, + "pid": 21778, + "bus_id": 0, + "cs_id": 0, + "cs_pin": 0, + "reset_pin": 2, + "busy_pin": 4, + "irq_pin": 6, + "txen_pin": -1, + "rxen_pin": 1, + "txled_pin": -1, + "rxled_pin": -1, + "tx_power": 22, + "use_dio2_rf": true, + "use_dio3_tcxo": true, + "dio3_tcxo_voltage": 1.8, + "preamble_length": 17, + "is_waveshare": false } } } diff --git a/repeater/airtime.py b/repeater/airtime.py index d823974..d529d0c 100644 --- a/repeater/airtime.py +++ b/repeater/airtime.py @@ -53,17 +53,17 @@ class AirtimeManager: Airtime in milliseconds """ sf = spreading_factor or self.spreading_factor - bw_khz = (bandwidth_hz or self.bandwidth) / 1000 + bw_hz = (bandwidth_hz or self.bandwidth) cr = coding_rate or self.coding_rate 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 + de = 1 if (sf >= 11 and bw_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_hz / 1000) # Preamble time: T_preamble = (n_preamble + 4.25) * T_sym t_preamble = (preamble_len + 4.25) * t_sym diff --git a/repeater/config.py b/repeater/config.py index cb6b98b..78dcb67 100644 --- a/repeater/config.py +++ b/repeater/config.py @@ -197,11 +197,20 @@ def _load_or_create_identity_key(path: Optional[str] = None) -> bytes: def get_radio_for_board(board_config: dict): + def _parse_int(value, *, default=None) -> int: + if value is None: + return default + if isinstance(value, int): + return value + if isinstance(value, str): + return int(value.strip().rstrip(','), 0) + raise ValueError(f"Invalid int value type: {type(value)}") + radio_type = board_config.get("radio_type", "sx1262").lower().strip() if radio_type == "kiss-modem": radio_type = "kiss" - if radio_type == "sx1262": + if radio_type in ("sx1262", "sx1262_ch341"): from pymc_core.hardware.sx1262_wrapper import SX1262Radio # Get radio and SPI configuration - all settings must be in config file @@ -213,19 +222,35 @@ def get_radio_for_board(board_config: dict): if not radio_config: raise ValueError("Missing 'radio' section in configuration file") - # Build config with required fields - no defaults + # CH341 integration: swap SPI transport + GPIO backend to CH341 + if radio_type == "sx1262_ch341": + ch341_cfg = board_config.get("ch341") + if not ch341_cfg: + raise ValueError("Missing 'ch341' section in configuration file") + + from pymc_core.hardware.lora.LoRaRF.SX126x import set_spi_transport + from pymc_core.hardware.transports.ch341_spi_transport import CH341SPITransport + + vid = _parse_int(ch341_cfg.get("vid"), default=0x1A86) + pid = _parse_int(ch341_cfg.get("pid"), default=0x5512) + + # Create CH341 transport (also configures CH341 GPIO manager globally) + ch341_spi = CH341SPITransport(vid=vid, pid=pid, auto_setup_gpio=True) + set_spi_transport(ch341_spi) + combined_config = { - "bus_id": spi_config["bus_id"], - "cs_id": spi_config["cs_id"], - "cs_pin": spi_config["cs_pin"], - "reset_pin": spi_config["reset_pin"], - "busy_pin": spi_config["busy_pin"], - "irq_pin": spi_config["irq_pin"], - "txen_pin": spi_config["txen_pin"], - "rxen_pin": spi_config["rxen_pin"], - "txled_pin": spi_config.get("txled_pin", -1), - "rxled_pin": spi_config.get("rxled_pin", -1), + "bus_id": _parse_int(spi_config["bus_id"]), + "cs_id": _parse_int(spi_config["cs_id"]), + "cs_pin": _parse_int(spi_config["cs_pin"]), + "reset_pin": _parse_int(spi_config["reset_pin"]), + "busy_pin": _parse_int(spi_config["busy_pin"]), + "irq_pin": _parse_int(spi_config["irq_pin"]), + "txen_pin": _parse_int(spi_config["txen_pin"]), + "rxen_pin": _parse_int(spi_config["rxen_pin"]), + "txled_pin": _parse_int(spi_config.get("txled_pin", -1), default=-1), + "rxled_pin": _parse_int(spi_config.get("rxled_pin", -1), default=-1), "use_dio3_tcxo": spi_config.get("use_dio3_tcxo", False), + "dio3_tcxo_voltage": float(spi_config.get("dio3_tcxo_voltage", 1.8)), "use_dio2_rf": spi_config.get("use_dio2_rf", False), "is_waveshare": spi_config.get("is_waveshare", False), "frequency": int(radio_config["frequency"]), @@ -237,6 +262,13 @@ def get_radio_for_board(board_config: dict): "sync_word": radio_config["sync_word"], } + # Add optional GPIO parameters if specified in config + # These wont be supported by older versions of pymc_core + if "gpio_chip" in spi_config: + combined_config["gpio_chip"] = _parse_int(spi_config["gpio_chip"], default=0) + if "use_gpiod_backend" in spi_config: + combined_config["use_gpiod_backend"] = spi_config["use_gpiod_backend"] + radio = SX1262Radio.get_instance(**combined_config) if hasattr(radio, "_initialized") and not radio._initialized: @@ -293,7 +325,6 @@ def get_radio_for_board(board_config: dict): return radio - else: - raise RuntimeError( - f"Unknown radio type: {radio_type}. Supported: sx1262, kiss (or kiss-modem)" - ) + raise RuntimeError( + f"Unknown radio type: {radio_type}. Supported: sx1262, sx1262_ch341, kiss (or kiss-modem)" + ) diff --git a/repeater/data_acquisition/letsmesh_handler.py b/repeater/data_acquisition/letsmesh_handler.py index 452a4ed..9ac9979 100644 --- a/repeater/data_acquisition/letsmesh_handler.py +++ b/repeater/data_acquisition/letsmesh_handler.py @@ -3,13 +3,20 @@ import binascii import json import logging import threading -from datetime import UTC, datetime, timedelta +from datetime import datetime, timedelta from typing import Callable, Dict, List, Optional import paho.mqtt.client as mqtt from nacl.signing import SigningKey -from .. import __version__ +# Try to import datetime.UTC (Python 3.11+) otherwise fallback to timezone.utc +try: + from datetime import UTC +except Exception: + from datetime import timezone + UTC = timezone.utc + +from repeater import __version__ # Try to import paho-mqtt error code mappings try: @@ -601,32 +608,86 @@ def get_mqtt_error_message(rc: int, is_disconnect: bool = False) -> str: """ if HAS_REASON_CODES: try: - reason = ReasonCode(rc) - return f"{reason.name}: {reason.value}" - except (ValueError, AttributeError): - pass + # ReasonCode object has getName() method and value property + reason = ReasonCode(mqtt.CONNACK if not is_disconnect else mqtt.DISCONNECT, identifier=rc) + name = reason.getName() if hasattr(reason, 'getName') else str(reason) + return f"{name} (code {rc})" + except Exception as e: + # Log the exception for debugging + logger.debug(f"Could not decode reason code {rc}: {e}") - # Fallback to manual mappings + # Fallback to manual mappings - Extended with MQTT v5 codes connect_errors = { - 0: "connection accepted", - 1: "incorrect protocol version", - 2: "invalid client identifier", - 3: "server unavailable", - 4: "bad username or password (JWT invalid)", - 5: "not authorized (JWT signature/format invalid)", - 6: "reserved error code", + 0: "Connection accepted", + 1: "Incorrect protocol version", + 2: "Invalid client identifier", + 3: "Server unavailable", + 4: "Bad username or password (JWT invalid)", + 5: "Not authorized (JWT signature/format invalid)", + # MQTT v5 codes + 128: "Unspecified error", + 129: "Malformed packet", + 130: "Protocol error", + 131: "Implementation specific error", + 132: "Unsupported protocol version", + 133: "Client identifier not valid", + 134: "Bad username or password", + 135: "Not authorized", + 136: "Server unavailable", + 137: "Server busy", + 138: "Banned", + 140: "Bad authentication method", + 144: "Topic name invalid", + 149: "Packet too large", + 151: "Quota exceeded", + 153: "Payload format invalid", + 154: "Retain not supported", + 155: "QoS not supported", + 156: "Use another server", + 157: "Server moved", + 159: "Connection rate exceeded", } disconnect_errors = { - 0: "normal disconnect", - 1: "unacceptable protocol version", - 2: "identifier rejected", - 3: "server unavailable", - 4: "bad username or password", - 5: "not authorized", - 16: "connection lost / protocol error", - 17: "client timeout", + 0: "Normal disconnect", + 1: "Unacceptable protocol version", + 2: "Identifier rejected", + 3: "Server unavailable", + 4: "Bad username or password", + 5: "Not authorized", + 7: "Connection lost / network error", + 16: "Connection lost / protocol error", + 17: "Client timeout", + # MQTT v5 codes + 4: "Disconnect with Will message", + 128: "Unspecified error", + 129: "Malformed packet", + 130: "Protocol error", + 131: "Implementation specific error", + 135: "Not authorized", + 137: "Server busy", + 139: "Server shutting down", + 141: "Keep alive timeout", + 142: "Session taken over", + 143: "Topic filter invalid", + 144: "Topic name invalid", + 147: "Receive maximum exceeded", + 148: "Topic alias invalid", + 149: "Packet too large", + 150: "Message rate too high", + 151: "Quota exceeded", + 152: "Administrative action", + 153: "Payload format invalid", + 154: "Retain not supported", + 155: "QoS not supported", + 156: "Use another server", + 157: "Server moved", + 158: "Shared subscriptions not supported", + 159: "Connection rate exceeded", + 160: "Maximum connect time", + 161: "Subscription identifiers not supported", + 162: "Wildcard subscriptions not supported", } error_dict = disconnect_errors if is_disconnect else connect_errors - return error_dict.get(rc, f"unknown error code {rc}") + return error_dict.get(rc, f"Unknown error code {rc}") diff --git a/repeater/data_acquisition/storage_collector.py b/repeater/data_acquisition/storage_collector.py index 1226a3b..750b0e6 100644 --- a/repeater/data_acquisition/storage_collector.py +++ b/repeater/data_acquisition/storage_collector.py @@ -18,8 +18,13 @@ class StorageCollector: def __init__(self, config: dict, local_identity=None, repeater_handler=None): self.config = config self.repeater_handler = repeater_handler - storage_cfg = config.get("storage", {}) - self.storage_dir = Path(storage_cfg.get("storage_dir", "/var/lib/pymc_repeater")) + + storage_dir_cfg = ( + config.get("storage", {}).get("storage_dir") + or config.get("storage_dir") + or "/var/lib/pymc_repeater" + ) + self.storage_dir = Path(storage_dir_cfg) self.storage_dir.mkdir(parents=True, exist_ok=True) node_name = config.get("repeater", {}).get("node_name", "unknown") diff --git a/repeater/main.py b/repeater/main.py index 800cb63..a4a87d7 100644 --- a/repeater/main.py +++ b/repeater/main.py @@ -63,7 +63,8 @@ class RepeaterDaemon: logger.info(f"Initializing repeater: {self.config['repeater']['node_name']}") if self.radio is None: - logger.info("Initializing radio hardware...") + radio_type = self.config.get("radio_type", "sx1262") + logger.info(f"Initializing radio hardware... (radio_type={radio_type})") try: self.radio = get_radio_for_board(self.config) @@ -747,69 +748,104 @@ class RepeaterDaemon: logger.error(f"Failed to send advert: {e}", exc_info=True) return False + async def _shutdown(self): + """Best-effort shutdown: stop background services and release hardware.""" + # Stop router + if self.router: + try: + await self.router.stop() + except Exception as e: + logger.warning(f"Error stopping router: {e}") + + # Stop HTTP server + if self.http_server: + try: + self.http_server.stop() + except Exception as e: + logger.warning(f"Error stopping HTTP server: {e}") + + # Release radio resources + if self.radio and hasattr(self.radio, "cleanup"): + try: + self.radio.cleanup() + except Exception as e: + logger.warning(f"Error cleaning up radio: {e}") + + # Release CH341 USB device if in use + try: + if self.config.get("radio_type", "sx1262").lower() == "sx1262_ch341": + from pymc_core.hardware.ch341.ch341_async import CH341Async + + CH341Async.reset_instance() + except Exception as e: + logger.debug(f"CH341 reset skipped/failed: {e}") + async def run(self): logger.info("Repeater daemon started") - await self.initialize() - - # Start HTTP stats server - http_port = self.config.get("http", {}).get("port", 8000) - http_host = self.config.get("http", {}).get("host", "0.0.0.0") - - node_name = self.config.get("repeater", {}).get("node_name", "Repeater") - - # Format public key for display - pub_key_formatted = "" - if self.local_identity: - pub_key_hex = self.local_identity.get_public_key().hex() - # Format as - if len(pub_key_hex) >= 16: - pub_key_formatted = f"{pub_key_hex[:8]}...{pub_key_hex[-8:]}" - else: - pub_key_formatted = pub_key_hex - - current_loop = asyncio.get_event_loop() - - self.http_server = HTTPStatsServer( - host=http_host, - port=http_port, - stats_getter=self.get_stats, - 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"), - ) - try: - self.http_server.start() - except Exception as e: - logger.error(f"Failed to start HTTP server: {e}") + await self.initialize() - # Run dispatcher (handles RX/TX via pymc_core) - try: - await self.dispatcher.run_forever() - except KeyboardInterrupt: - logger.info("Shutting down...") - for frame_server in getattr(self, "companion_frame_servers", []): - try: - await frame_server.stop() - except Exception as e: - logger.debug(f"Companion frame server stop: {e}") - if hasattr(self, "companion_bridges"): - for bridge in self.companion_bridges.values(): - if hasattr(bridge, "stop"): - try: - await bridge.stop() - except Exception as e: - logger.debug(f"Companion bridge stop: {e}") - if self.router: - await self.router.stop() - if self.http_server: - self.http_server.stop() + # Start HTTP stats server + http_port = self.config.get("http", {}).get("port", 8000) + http_host = self.config.get("http", {}).get("host", "0.0.0.0") + + node_name = self.config.get("repeater", {}).get("node_name", "Repeater") + + # Format public key for display + pub_key_formatted = "" + if self.local_identity: + pub_key_hex = self.local_identity.get_public_key().hex() + # Format as + if len(pub_key_hex) >= 16: + pub_key_formatted = f"{pub_key_hex[:8]}...{pub_key_hex[-8:]}" + else: + pub_key_formatted = pub_key_hex + + current_loop = asyncio.get_event_loop() + + self.http_server = HTTPStatsServer( + host=http_host, + port=http_port, + stats_getter=self.get_stats, + 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"), + ) + + try: + self.http_server.start() + except Exception as e: + logger.error(f"Failed to start HTTP server: {e}") + + # Run dispatcher (handles RX/TX via pymc_core) + try: + await self.dispatcher.run_forever() + except KeyboardInterrupt: + logger.info("Shutting down...") + for frame_server in getattr(self, "companion_frame_servers", []): + try: + await frame_server.stop() + except Exception as e: + logger.debug(f"Companion frame server stop: {e}") + if hasattr(self, "companion_bridges"): + for bridge in self.companion_bridges.values(): + if hasattr(bridge, "stop"): + try: + await bridge.stop() + except Exception as e: + logger.debug(f"Companion bridge stop: {e}") + if self.router: + await self.router.stop() + if self.http_server: + self.http_server.stop() + finally: + await self._shutdown() def main(): diff --git a/repeater/web/api_endpoints.py b/repeater/web/api_endpoints.py index 6a464dd..0149b43 100644 --- a/repeater/web/api_endpoints.py +++ b/repeater/web/api_endpoints.py @@ -287,8 +287,12 @@ class APIEndpoints: 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")) + storage_dir_cfg = ( + self.config.get("storage", {}).get("storage_dir") + or self.config.get("storage_dir") + or "/var/lib/pymc_repeater" + ) + config_dir = Path(storage_dir_cfg) installed_path = config_dir / "radio-settings.json" dev_path = os.path.join(os.path.dirname(__file__), "..", "..", "radio-settings.json") @@ -333,8 +337,12 @@ class APIEndpoints: 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")) + storage_dir_cfg = ( + self.config.get("storage", {}).get("storage_dir") + or self.config.get("storage_dir") + or "/var/lib/pymc_repeater" + ) + config_dir = Path(storage_dir_cfg) installed_path = config_dir / "radio-presets.json" dev_path = os.path.join(os.path.dirname(__file__), "..", "..", "radio-presets.json") @@ -390,6 +398,29 @@ class APIEndpoints: import json + storage_dir_cfg = ( + self.config.get("storage", {}).get("storage_dir") + or self.config.get("storage_dir") + or "/var/lib/pymc_repeater" + ) + config_dir = Path(storage_dir_cfg) + 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 hardware_key != "kiss": + if not os.path.exists(hardware_file): + logger.error(f"Hardware file not found. Tried: {installed_path}, {dev_path}") + 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", {}) + hw_config = hardware_configs.get(hardware_key, {}) + if not hw_config: + return {"success": False, "error": f"Hardware configuration not found: {hardware_key}"} + else: + hw_config = {} + import yaml # Read current config first so we can update it @@ -425,27 +456,23 @@ class APIEndpoints: 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" - ) - 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: - hardware_data = json.load(f) - 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}", - } + # SX1262 / sx1262_ch341: radio_type and optional CH341 from hw_config + if "radio_type" in hw_config: + config_yaml["radio_type"] = hw_config.get("radio_type") + else: + config_yaml["radio_type"] = "sx1262" + + ch341_cfg = hw_config.get("ch341") if isinstance(hw_config.get("ch341"), dict) else None + vid = (ch341_cfg or {}).get("vid", hw_config.get("vid")) + pid = (ch341_cfg or {}).get("pid", hw_config.get("pid")) + if vid is not None or pid is not None: + if "ch341" not in config_yaml: + config_yaml["ch341"] = {} + if vid is not None: + config_yaml["ch341"]["vid"] = vid + if pid is not None: + config_yaml["ch341"]["pid"] = pid - 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: @@ -475,11 +502,12 @@ class APIEndpoints: 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 "dio3_tcxo_voltage" in hw_config: + config_yaml["sx1262"]["dio3_tcxo_voltage"] = hw_config.get("dio3_tcxo_voltage", 1.8) 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: yaml.dump(config_yaml, f, default_flow_style=False, sort_keys=False) diff --git a/setup-radio-config.sh b/setup-radio-config.sh index 2f5f3a8..dfd5be5 100644 --- a/setup-radio-config.sh +++ b/setup-radio-config.sh @@ -267,6 +267,10 @@ if [ -z "$hw_config" ] || [ "$hw_config" == "null" ]; then echo "Warning: Could not extract hardware config from JSON, using defaults" else # Extract each field and update config.yaml + radio_type=$(echo "$hw_config" | jq -r '.radio_type // empty') + vid=$(echo "$hw_config" | jq -r '.vid // empty') + pid=$(echo "$hw_config" | jq -r '.pid // empty') + bus_id=$(echo "$hw_config" | jq -r '.bus_id // empty') cs_id=$(echo "$hw_config" | jq -r '.cs_id // empty') cs_pin=$(echo "$hw_config" | jq -r '.cs_pin // empty') @@ -281,6 +285,29 @@ else preamble_length=$(echo "$hw_config" | jq -r '.preamble_length // empty') is_waveshare=$(echo "$hw_config" | jq -r '.is_waveshare // empty') use_dio3_tcxo=$(echo "$hw_config" | jq -r '.use_dio3_tcxo // empty') + dio3_tcxo_voltage=$(echo "$hw_config" | jq -r '.dio3_tcxo_voltage // empty') + use_dio2_rf=$(echo "$hw_config" | jq -r '.use_dio2_rf // empty') + + # Update radio_type + optional CH341 section + if [ -n "$radio_type" ]; then + if grep -q "^radio_type:" "$CONFIG_FILE"; then + sed "${SED_OPTS[@]}" "s/^radio_type:.*/radio_type: $radio_type/" "$CONFIG_FILE" + else + # Append if missing (config.yaml.example includes it, so this is a fallback) + printf "\nradio_type: %s\n" "$radio_type" >> "$CONFIG_FILE" + fi + fi + + if [ -n "$vid" ] || [ -n "$pid" ]; then + # Ensure ch341 section exists + if ! grep -q "^ch341:" "$CONFIG_FILE"; then + # Append if missing (fallback) + printf "\nch341:\n vid: %s\n pid: %s\n" "${vid:-6790}" "${pid:-21778}" >> "$CONFIG_FILE" + fi + + [ -n "$vid" ] && sed "${SED_OPTS[@]}" "s/^ vid:.*/ vid: $vid/" "$CONFIG_FILE" + [ -n "$pid" ] && sed "${SED_OPTS[@]}" "s/^ pid:.*/ pid: $pid/" "$CONFIG_FILE" + fi # Update sx1262 section in config.yaml (2-space indentation) [ -n "$bus_id" ] && sed "${SED_OPTS[@]}" "s/^ bus_id:.*/ bus_id: $bus_id/" "$CONFIG_FILE" @@ -345,6 +372,30 @@ else fi fi fi + + # Update dio3_tcxo_voltage (only meaningful if use_dio3_tcxo is true) + if [ -n "$dio3_tcxo_voltage" ]; then + if grep -q "^ dio3_tcxo_voltage:" "$CONFIG_FILE"; then + sed "${SED_OPTS[@]}" "s/^ dio3_tcxo_voltage:.*/ dio3_tcxo_voltage: $dio3_tcxo_voltage/" "$CONFIG_FILE" + else + # Add after use_dio3_tcxo + sed "${SED_OPTS[@]}" "/^ use_dio3_tcxo:.*/a\\ dio3_tcxo_voltage: $dio3_tcxo_voltage" "$CONFIG_FILE" + fi + fi + + # Update use_dio2_rf flag + if [ "$use_dio2_rf" == "true" ] || [ "$use_dio2_rf" == "false" ]; then + if grep -q "^ use_dio2_rf:" "$CONFIG_FILE"; then + sed "${SED_OPTS[@]}" "s/^ use_dio2_rf:.*/ use_dio2_rf: $use_dio2_rf/" "$CONFIG_FILE" + else + # Add after dio3_tcxo_voltage if present, otherwise after use_dio3_tcxo + if grep -q "^ dio3_tcxo_voltage:" "$CONFIG_FILE"; then + sed "${SED_OPTS[@]}" "/^ dio3_tcxo_voltage:.*/a\\ use_dio2_rf: $use_dio2_rf" "$CONFIG_FILE" + else + sed "${SED_OPTS[@]}" "/^ use_dio3_tcxo:.*/a\\ use_dio2_rf: $use_dio2_rf" "$CONFIG_FILE" + fi + fi + fi fi fi