Merge upstream feat/newRadios (radios, MQTT, SPI)

- Take upstream radio-presets.json and radio-settings.json (Femtofox, AIO v2, CH341).
- config.py: upstream get_radio_for_board (_parse_int, sx1262_ch341, GPIO) + re-add KISS branch.
- config.yaml.example: upstream radio/ch341 + our identities.companions.
- manage.sh: upstream SPI warning prompt.
- main.py: single try/finally with companion shutdown and _shutdown().
- letsmesh_handler: upstream UTC fallback and MQTT v5 reason code handling.
- storage_collector, api_endpoints: upstream storage_dir_cfg; apply_setup_wizard supports KISS and sx1262_ch341/ch341.
- airtime.py: upstream bw_hz fix for symbol time.
- pyproject.toml: keep pymc_core dependency (no git pin).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
agessaman
2026-02-21 15:59:01 -08:00
13 changed files with 605 additions and 136 deletions

1
.gitignore vendored
View File

@@ -34,6 +34,7 @@ debian/pymc-repeater.substvars
# Virtual environments
.venv/
.venv_new/
env/
ENV/

View File

@@ -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

View File

@@ -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

View File

@@ -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."

View File

@@ -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"
}
]
}
}
}

View File

@@ -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
}
}
}

View File

@@ -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

View File

@@ -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)"
)

View File

@@ -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}")

View File

@@ -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")

View File

@@ -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 <first8...last8>
if len(pub_key_hex) >= 16:
pub_key_formatted = f"{pub_key_hex[:8]}...{pub_key_hex[-8:]}"
else:
pub_key_formatted = pub_key_hex
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 <first8...last8>
if len(pub_key_hex) >= 16:
pub_key_formatted = f"{pub_key_hex[:8]}...{pub_key_hex[-8:]}"
else:
pub_key_formatted = pub_key_hex
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():

View File

@@ -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)

View File

@@ -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