Skip to content

Commit 8c354db

Browse files
authored
Merge pull request #253 from pyMC-dev/wip/null-radio-before-pr250
Wip/null radio before pr250
2 parents f4d8948 + e0dbecd commit 8c354db

59 files changed

Lines changed: 487 additions & 71 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

config.yaml.example

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,3 @@
1-
# Default Repeater Configuration
2-
# radio_type: sx1262 | kiss (use kiss for serial KISS TNC modem)
3-
radio_type: sx1262
4-
51
repeater:
62
# Node name for logging and identification
73
node_name: "mesh-repeater-01"
@@ -317,7 +313,8 @@ identities:
317313
# - kiss (KISS-modem over a serial port; alias: kiss-modem)
318314
# - pymc_tcp (pymc_usb firmware modem over Wi-Fi/TCP)
319315
# - pymc_usb (pymc_usb firmware modem over USB-CDC)
320-
radio_type: sx1262
316+
# - null/none (disable radio hardware; daemon starts without RF I/O)
317+
radio_type: null
321318

322319
# CH341 USB-to-SPI adapter settings (only used when radio_type: sx1262_ch341)
323320
# NOTE: VID/PID are integers. Hex is also accepted in YAML, e.g. 0x1A86.

radio-settings.json

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,7 @@
186186
"ch341-usb-sx1262": {
187187
"name": "CH341 USB-SPI + SX1262 (example)",
188188
"description": "SX1262 via CH341 USB-to-SPI adapter. NOTE: pin numbers are CH341 GPIO 0-7, not BCM.",
189+
"connection_type": "usb",
189190
"radio_type": "sx1262_ch341",
190191
"vid": 6790,
191192
"pid": 21778,
@@ -322,6 +323,30 @@
322323
"use_dio2_rf": false,
323324
"use_dio3_tcxo": true,
324325
"preamble_length": 17
326+
},
327+
"pymc_usb": {
328+
"name": "pymc_usb modem (USB-CDC)",
329+
"description": "ESP32-S3 / nRF52 board running pymc_usb firmware as a USB-CDC LoRa modem. Pick this if the modem is plugged into the host's USB port (e.g. /dev/ttyACM0). Edit the 'pymc_usb' section after first-run to point at the right serial device.",
330+
"connection_type": "usb",
331+
"radio_type": "pymc_usb",
332+
"tx_power": 22,
333+
"preamble_length": 16
334+
},
335+
"pymc_tcp": {
336+
"name": "pymc_tcp modem (Wi-Fi / Ethernet)",
337+
"description": "ESP32 board running pymc_usb firmware exposed as a TCP server over Wi-Fi or Ethernet. After first-run, set 'pymc_tcp.host' to the modem's LAN address or mDNS name (e.g. pymc-3e2834.local).",
338+
"connection_type": "network",
339+
"radio_type": "pymc_tcp",
340+
"tx_power": 22,
341+
"preamble_length": 16
342+
},
343+
"kiss": {
344+
"name": "KISS modem (serial)",
345+
"description": "MeshCore KISS modem over serial - requires pyMC_core with KISS support.",
346+
"connection_type": "usb",
347+
"radio_type": "kiss",
348+
"tx_power": 14,
349+
"preamble_length": 17
325350
}
326351
}
327352
}

repeater/config.py

Lines changed: 54 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,47 @@
22
import logging
33
import os
44
from pathlib import Path
5-
from typing import Any, Dict, Optional
5+
from typing import Any, Dict, Optional, overload
66

77
import yaml
88

99
logger = logging.getLogger("Config")
1010

1111

12+
class NullRadio:
13+
"""No-op radio used when radio_type disables hardware initialization."""
14+
15+
def __init__(self):
16+
self._rx_callback = None
17+
18+
def begin(self):
19+
return True
20+
21+
async def send(self, data: bytes):
22+
raise RuntimeError("Radio is disabled (radio_type is null/none)")
23+
24+
async def wait_for_rx(self) -> bytes:
25+
import asyncio
26+
27+
while True:
28+
await asyncio.sleep(3600)
29+
30+
def sleep(self):
31+
return None
32+
33+
def get_last_rssi(self) -> int:
34+
return 0
35+
36+
def get_last_snr(self) -> float:
37+
return 0.0
38+
39+
def set_rx_callback(self, callback):
40+
self._rx_callback = callback
41+
42+
def check_radio_health(self):
43+
return True
44+
45+
1246
def resolve_storage_dir(
1347
config: Dict[str, Any],
1448
*,
@@ -299,7 +333,13 @@ def _load_or_create_identity_key(path: Optional[str] = None) -> bytes:
299333

300334
def get_radio_for_board(board_config: dict):
301335

302-
def _parse_int(value, *, default=None) -> int:
336+
@overload
337+
def _parse_int(value, *, default: None = None) -> Optional[int]: ...
338+
339+
@overload
340+
def _parse_int(value, *, default: int) -> int: ...
341+
342+
def _parse_int(value, *, default=None):
303343
if value is None:
304344
return default
305345
if isinstance(value, int):
@@ -322,7 +362,16 @@ def _parse_int_list(value):
322362
return [_parse_int(item) for item in stripped.split(",") if item.strip()]
323363
raise ValueError(f"Invalid int list value type: {type(value)}")
324364

325-
radio_type = board_config.get("radio_type", "sx1262").lower().strip()
365+
radio_type_raw = board_config.get("radio_type")
366+
if radio_type_raw is None:
367+
radio_type = "none"
368+
else:
369+
radio_type = str(radio_type_raw).lower().strip()
370+
371+
if radio_type in ("", "none", "null", "disabled", "off", "no_radio"):
372+
logger.warning("Radio disabled by configuration (radio_type=%r)", radio_type_raw)
373+
return NullRadio()
374+
326375
if radio_type == "kiss-modem":
327376
radio_type = "kiss"
328377

@@ -483,7 +532,7 @@ def _parse_int_list(value):
483532
spreading_factor=int(radio_cfg.get("spreading_factor", 8)),
484533
coding_rate=int(radio_cfg.get("coding_rate", 8)),
485534
tx_power=int(radio_cfg.get("tx_power", 22)),
486-
sync_word=_parse_int(radio_cfg.get("sync_word", 0x12)),
535+
sync_word=_parse_int(radio_cfg.get("sync_word", 0x12), default=0x12),
487536
preamble_length=int(radio_cfg.get("preamble_length", 16)),
488537
lbt_enabled=bool(tcp_cfg.get("lbt_enabled", True)),
489538
lbt_max_attempts=int(tcp_cfg.get("lbt_max_attempts", 5)),
@@ -527,7 +576,7 @@ def _parse_int_list(value):
527576
spreading_factor=int(radio_cfg.get("spreading_factor", 8)),
528577
coding_rate=int(radio_cfg.get("coding_rate", 8)),
529578
tx_power=int(radio_cfg.get("tx_power", 22)),
530-
sync_word=_parse_int(radio_cfg.get("sync_word", 0x12)),
579+
sync_word=_parse_int(radio_cfg.get("sync_word", 0x12), default=0x12),
531580
preamble_length=int(radio_cfg.get("preamble_length", 16)),
532581
lbt_enabled=bool(usb_cfg.get("lbt_enabled", True)),
533582
lbt_max_attempts=int(usb_cfg.get("lbt_max_attempts", 5)),

repeater/main.py

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
import time
99

1010
from repeater.companion.utils import validate_companion_node_name, normalize_companion_identity_key
11-
from repeater.config import get_radio_for_board, load_config, save_config
11+
from repeater.config import NullRadio, get_radio_for_board, load_config, save_config
1212
from repeater.config_manager import ConfigManager
1313
from repeater.data_acquisition.glass_handler import GlassHandler
1414
from repeater.data_acquisition.gps_service import GPSService
@@ -59,6 +59,8 @@ def __init__(self, config: dict, radio=None):
5959
self.companion_frame_servers: list = []
6060
self._shutdown_started = False
6161
self._main_task = None
62+
self.radio_status = "unknown"
63+
self.radio_error = None
6264

6365
log_level = config.get("logging", {}).get("level", "INFO")
6466
logging.basicConfig(
@@ -97,11 +99,34 @@ async def initialize(self):
9799
#-----------------------------------------------
98100

99101
if self.radio is None:
100-
radio_type = self.config.get("radio_type", "sx1262")
102+
radio_type_raw = self.config.get("radio_type")
103+
radio_type = "none" if radio_type_raw is None else str(radio_type_raw)
104+
radio_type_lower = radio_type.lower().strip()
105+
radio_explicitly_disabled = radio_type_lower in (
106+
"",
107+
"none",
108+
"null",
109+
"disabled",
110+
"off",
111+
"no_radio",
112+
)
101113
logger.info(f"Initializing radio hardware... (radio_type={radio_type})")
102114
try:
103115
self.radio = get_radio_for_board(self.config)
104116

117+
if isinstance(self.radio, NullRadio):
118+
self.radio_status = "disabled" if radio_explicitly_disabled else "degraded"
119+
if self.radio_status == "disabled":
120+
self.radio_error = None
121+
else:
122+
self.radio_error = (
123+
self.radio_error
124+
or f"Radio type '{radio_type}' unavailable; running in no-radio mode"
125+
)
126+
else:
127+
self.radio_status = "ok"
128+
self.radio_error = None
129+
105130
# KISS modem: schedule RX callbacks on the event loop for thread safety
106131
if hasattr(self.radio, "set_event_loop"):
107132
self.radio.set_event_loop(asyncio.get_running_loop())
@@ -133,7 +158,14 @@ async def initialize(self):
133158
logger.info("Radio hardware initialized")
134159
except Exception as e:
135160
logger.error(f"Failed to initialize radio hardware: {e}")
136-
raise RuntimeError("Repeater requires real LoRa hardware") from e
161+
self.radio_status = "degraded"
162+
self.radio_error = str(e)
163+
logger.warning(
164+
"Radio type '%s' unavailable; starting in no-radio mode to keep service alive. "
165+
"Check radio configuration and hardware mapping.",
166+
radio_type,
167+
)
168+
self.radio = NullRadio()
137169

138170
try:
139171
from pymc_core import LocalIdentity
@@ -944,6 +976,10 @@ def get_stats(self) -> dict:
944976
if self.sensor_manager:
945977
stats["sensors"] = self.sensor_manager.get_summary()
946978

979+
stats["radio_status"] = self.radio_status
980+
if self.radio_error:
981+
stats["radio_error"] = self.radio_error
982+
947983
return stats
948984

949985
async def _get_companion_stats(self, stats_type: int) -> dict:
@@ -1200,7 +1236,9 @@ async def _shutdown(self):
12001236

12011237
# Release CH341 USB device if in use
12021238
try:
1203-
if self.config.get("radio_type", "sx1262").lower() == "sx1262_ch341":
1239+
radio_type_raw = self.config.get("radio_type")
1240+
radio_type = "" if radio_type_raw is None else str(radio_type_raw).lower()
1241+
if radio_type == "sx1262_ch341":
12041242
from pymc_core.hardware.ch341.ch341_async import CH341Async
12051243

12061244
CH341Async.reset_instance()

repeater/web/api_endpoints.py

Lines changed: 59 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -317,13 +317,18 @@ def needs_setup(self):
317317
)
318318
has_default_password = admin_password in ["admin123", ""]
319319

320-
needs_setup = has_default_name or has_default_password
320+
radio_type_raw = config.get("radio_type")
321+
radio_type = "" if radio_type_raw is None else str(radio_type_raw).lower().strip()
322+
radio_not_configured = radio_type in ("", "none", "null", "disabled", "off", "no_radio")
323+
324+
needs_setup = has_default_name or has_default_password or radio_not_configured
321325

322326
return {
323327
"needs_setup": needs_setup,
324328
"reasons": {
325329
"default_name": has_default_name,
326330
"default_password": has_default_password,
331+
"radio_not_configured": radio_not_configured,
327332
},
328333
}
329334
except Exception as e:
@@ -360,16 +365,6 @@ def hardware_options(self):
360365
}
361366
)
362367

363-
# Add MeshCore KISS modem option (serial TNC)
364-
hardware_list.append(
365-
{
366-
"key": "kiss",
367-
"name": "KISS modem (serial)",
368-
"description": "MeshCore KISS modem over serial – requires pyMC_core with KISS support",
369-
"config": {},
370-
}
371-
)
372-
373368
return {"hardware": hardware_list}
374369
except Exception as e:
375370
logger.error(f"Error loading hardware options: {e}")
@@ -491,6 +486,49 @@ def setup_wizard(self):
491486
config_yaml["radio"]["tx_power"] = int(radio_preset.get("tx_power", 14))
492487
if "preamble_length" not in config_yaml["radio"]:
493488
config_yaml["radio"]["preamble_length"] = 17
489+
elif hardware_key == "pymc_usb":
490+
# pymc_usb modem: external SX1262 board over USB-CDC.
491+
# Accept pymc_usb_port / pymc_usb_baudrate from the request body
492+
# (mirrors the KISS pattern) so a future SPA can expose inputs;
493+
# fall back to /dev/ttyACM0 at 921600 baud, which matches the
494+
# firmware default and the typical USB-CDC modem device on Linux.
495+
config_yaml["radio_type"] = "pymc_usb"
496+
usb_port = (data.get("pymc_usb_port") or "").strip() or "/dev/ttyACM0"
497+
usb_baud = int(data.get("pymc_usb_baudrate", data.get("pymc_usb_baud", 921600)))
498+
pymc_usb_section = config_yaml.setdefault("pymc_usb", {})
499+
pymc_usb_section["port"] = usb_port
500+
pymc_usb_section["baudrate"] = usb_baud
501+
pymc_usb_section.setdefault("lbt_enabled", True)
502+
pymc_usb_section.setdefault("lbt_max_attempts", 5)
503+
if "tx_power" in hw_config:
504+
config_yaml["radio"]["tx_power"] = hw_config.get("tx_power", 22)
505+
if "preamble_length" in hw_config:
506+
config_yaml["radio"]["preamble_length"] = hw_config.get("preamble_length", 16)
507+
elif hardware_key == "pymc_tcp":
508+
# pymc_tcp modem: external SX1262 board exposed as TCP over Wi-Fi/Ethernet.
509+
# 'host' has no sensible default — must be the modem's LAN address or
510+
# mDNS name. Accept it from the request body if the SPA provides it,
511+
# otherwise write a clearly-placeholder hostname so the file is valid
512+
# YAML and the user gets a startup error pointing them at the right
513+
# section to edit (see config.py: ValueError 'Missing host …').
514+
config_yaml["radio_type"] = "pymc_tcp"
515+
tcp_host = (data.get("pymc_tcp_host") or "").strip() or "REPLACE_WITH_MODEM_HOST"
516+
tcp_port = int(data.get("pymc_tcp_port", 5055))
517+
pymc_tcp_section = config_yaml.setdefault("pymc_tcp", {})
518+
pymc_tcp_section["host"] = tcp_host
519+
pymc_tcp_section["port"] = tcp_port
520+
tcp_token = data.get("pymc_tcp_token")
521+
if tcp_token is not None:
522+
pymc_tcp_section["token"] = str(tcp_token)
523+
else:
524+
pymc_tcp_section.setdefault("token", "")
525+
pymc_tcp_section.setdefault("connect_timeout", 5.0)
526+
pymc_tcp_section.setdefault("lbt_enabled", True)
527+
pymc_tcp_section.setdefault("lbt_max_attempts", 5)
528+
if "tx_power" in hw_config:
529+
config_yaml["radio"]["tx_power"] = hw_config.get("tx_power", 22)
530+
if "preamble_length" in hw_config:
531+
config_yaml["radio"]["preamble_length"] = hw_config.get("preamble_length", 16)
494532
else:
495533
# SX1262 / sx1262_ch341: radio_type and optional CH341 from hw_config
496534
if "radio_type" in hw_config:
@@ -577,7 +615,7 @@ def delayed_restart():
577615
result_config = {
578616
"node_name": node_name,
579617
"hardware": hardware_key,
580-
"radio_type": config_yaml.get("radio_type", "sx1262"),
618+
"radio_type": config_yaml.get("radio_type"),
581619
"frequency": freq_mhz,
582620
"spreading_factor": radio_preset.get("spreading_factor"),
583621
"bandwidth": radio_preset.get("bandwidth"),
@@ -586,6 +624,15 @@ def delayed_restart():
586624
if hardware_key == "kiss":
587625
result_config["kiss_port"] = config_yaml.get("kiss", {}).get("port")
588626
result_config["kiss_baud_rate"] = config_yaml.get("kiss", {}).get("baud_rate")
627+
elif hardware_key == "pymc_usb":
628+
pymc_usb_cfg = config_yaml.get("pymc_usb", {})
629+
result_config["pymc_usb_port"] = pymc_usb_cfg.get("port")
630+
result_config["pymc_usb_baudrate"] = pymc_usb_cfg.get("baudrate")
631+
elif hardware_key == "pymc_tcp":
632+
pymc_tcp_cfg = config_yaml.get("pymc_tcp", {})
633+
result_config["pymc_tcp_host"] = pymc_tcp_cfg.get("host")
634+
result_config["pymc_tcp_port"] = pymc_tcp_cfg.get("port")
635+
# token deliberately omitted from response (sensitive)
589636
return {
590637
"success": True,
591638
"message": "Setup completed successfully. Service is restarting...",

0 commit comments

Comments
 (0)