From 541b25b47c23d2298cfe0234df832eedf429d7ee Mon Sep 17 00:00:00 2001 From: itk80 Date: Wed, 13 May 2026 17:30:09 +0200 Subject: [PATCH] config: add pymc_tcp / pymc_usb radio_type branches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the TCPLoRaRadio and USBLoRaRadio drivers that landed in pyMC_core on 2026-05-13 (PR pyMC-dev/pyMC_core#68) into get_radio_for_board() so they can be selected from a repeater config file without any code change in main.py / api_endpoints. Both branches follow the existing pattern: read host/port (TCP) or serial port (USB) plus auth/LBT options from their own config section, share the LoRa parameters from the common `radio` section, fall back to the firmware-default sync word 0x12, and surface ImportError as a clear RuntimeError if the installed pymc_core is too old to ship the drivers. config.yaml.example documents both sections and updates the radio_type header comment with the full supported list. Five new tests in tests/test_radio_config.py monkeypatch the radio classes and verify the section/parameter wiring + missing-required-field errors. No web UI / endpoint changes — the deployment this targets edits the config file directly. A GUI wizard for these radio types can land separately if there's appetite. --- config.yaml.example | 22 ++++++ repeater/config.py | 93 +++++++++++++++++++++++++- tests/test_radio_config.py | 133 ++++++++++++++++++++++++++++++++++++- 3 files changed, 246 insertions(+), 2 deletions(-) diff --git a/config.yaml.example b/config.yaml.example index cc54790..2f41f61 100644 --- a/config.yaml.example +++ b/config.yaml.example @@ -314,6 +314,9 @@ identities: # Supported: # - sx1262 (Linux spidev + system GPIO) # - sx1262_ch341 (CH341 USB-to-SPI + CH341 GPIO 0-7) +# - kiss (KISS-modem over a serial port; alias: kiss-modem) +# - pymc_tcp (pymc_usb firmware modem over Wi-Fi/TCP) +# - pymc_usb (pymc_usb firmware modem over USB-CDC) radio_type: sx1262 # CH341 USB-to-SPI adapter settings (only used when radio_type: sx1262_ch341) @@ -352,6 +355,25 @@ radio: # port: "/dev/ttyUSB0" # baud_rate: 9600 +# pymc_usb firmware modem over Wi-Fi/TCP (when radio_type: pymc_tcp). +# Requires pyMC_core with the TCPLoRaRadio driver (PR pyMC-dev/pyMC_core#68, +# merged into dev on 2026-05-13). +# pymc_tcp: +# host: "pymc-3e2834.local" # modem hostname / mDNS name / LAN IP +# port: 5055 # firmware default +# token: "" # empty = no auth; set to match firmware NVS token +# connect_timeout: 5.0 # seconds; deferred-connect retries with backoff on failure +# lbt_enabled: true # Listen-Before-Talk via firmware CAD +# lbt_max_attempts: 5 + +# pymc_usb firmware modem over USB-CDC (when radio_type: pymc_usb). +# Requires pyMC_core with the USBLoRaRadio driver (PR pyMC-dev/pyMC_core#68). +# pymc_usb: +# port: "/dev/ttyACM0" # USB-CDC device; udev rule may symlink to /dev/lora-modem +# baudrate: 921600 # must match firmware monitor_speed +# lbt_enabled: true +# lbt_max_attempts: 5 + # SX1262 Hardware Configuration # NOTE: # - When radio_type: sx1262, these pins are BCM GPIO numbers. diff --git a/repeater/config.py b/repeater/config.py index 97cae34..848d061 100644 --- a/repeater/config.py +++ b/repeater/config.py @@ -450,6 +450,97 @@ def get_radio_for_board(board_config: dict): return radio + elif radio_type == "pymc_tcp": + try: + from pymc_core.hardware.tcp_radio import TCPLoRaRadio + except ImportError: + raise RuntimeError( + "pymc_tcp radio requires pyMC_core >= the release that includes " + "PR pyMC-dev/pyMC_core#68 (merged 2026-05-13). " + "Reinstall the [hardware] extra to pick it up." + ) from None + + tcp_cfg = board_config.get("pymc_tcp") + if not tcp_cfg: + raise ValueError( + "Missing 'pymc_tcp' section in configuration file for radio_type: pymc_tcp" + ) + + host = tcp_cfg.get("host") + if not host: + raise ValueError( + "Missing 'host' in 'pymc_tcp' section (modem hostname or LAN IP)" + ) + + radio_cfg = board_config.get("radio") or {} + radio = TCPLoRaRadio( + host=host, + port=int(tcp_cfg.get("port", 5055)), + token=tcp_cfg.get("token", ""), + connect_timeout=float(tcp_cfg.get("connect_timeout", 5.0)), + frequency=int(radio_cfg.get("frequency", 869618000)), + bandwidth=int(radio_cfg.get("bandwidth", 62500)), + spreading_factor=int(radio_cfg.get("spreading_factor", 8)), + coding_rate=int(radio_cfg.get("coding_rate", 8)), + tx_power=int(radio_cfg.get("tx_power", 22)), + sync_word=_parse_int(radio_cfg.get("sync_word", 0x12)), + preamble_length=int(radio_cfg.get("preamble_length", 16)), + lbt_enabled=bool(tcp_cfg.get("lbt_enabled", True)), + lbt_max_attempts=int(tcp_cfg.get("lbt_max_attempts", 5)), + ) + + try: + radio.begin() + except Exception as e: + raise RuntimeError(f"Failed to initialize pymc_tcp radio: {e}") from e + + return radio + + elif radio_type == "pymc_usb": + try: + from pymc_core.hardware.usb_radio import USBLoRaRadio + except ImportError: + raise RuntimeError( + "pymc_usb radio requires pyMC_core >= the release that includes " + "PR pyMC-dev/pyMC_core#68 (merged 2026-05-13). " + "Reinstall the [hardware] extra to pick it up." + ) from None + + usb_cfg = board_config.get("pymc_usb") + if not usb_cfg: + raise ValueError( + "Missing 'pymc_usb' section in configuration file for radio_type: pymc_usb" + ) + + port = usb_cfg.get("port") + if not port: + raise ValueError( + "Missing 'port' in 'pymc_usb' section (e.g. /dev/ttyACM0)" + ) + + radio_cfg = board_config.get("radio") or {} + radio = USBLoRaRadio( + port=port, + baudrate=int(usb_cfg.get("baudrate", 921600)), + frequency=int(radio_cfg.get("frequency", 869618000)), + bandwidth=int(radio_cfg.get("bandwidth", 62500)), + spreading_factor=int(radio_cfg.get("spreading_factor", 8)), + coding_rate=int(radio_cfg.get("coding_rate", 8)), + tx_power=int(radio_cfg.get("tx_power", 22)), + sync_word=_parse_int(radio_cfg.get("sync_word", 0x12)), + preamble_length=int(radio_cfg.get("preamble_length", 16)), + lbt_enabled=bool(usb_cfg.get("lbt_enabled", True)), + lbt_max_attempts=int(usb_cfg.get("lbt_max_attempts", 5)), + ) + + try: + radio.begin() + except Exception as e: + raise RuntimeError(f"Failed to initialize pymc_usb radio: {e}") from e + + return radio + raise RuntimeError( - f"Unknown radio type: {radio_type}. Supported: sx1262, sx1262_ch341, kiss (or kiss-modem)" + f"Unknown radio type: {radio_type}. " + "Supported: sx1262, sx1262_ch341, kiss (or kiss-modem), pymc_tcp, pymc_usb" ) diff --git a/tests/test_radio_config.py b/tests/test_radio_config.py index 04a0a05..bbbe05d 100644 --- a/tests/test_radio_config.py +++ b/tests/test_radio_config.py @@ -1,9 +1,14 @@ +import pytest + from repeater.config import get_radio_for_board class _DummyRadio: _initialized = True + def begin(self): + return True + def test_get_radio_for_board_passes_en_pins(monkeypatch): captured_kwargs = {} @@ -46,4 +51,130 @@ def test_get_radio_for_board_passes_en_pins(monkeypatch): get_radio_for_board(board_config) assert captured_kwargs["en_pins"] == [26, 23] - assert "en_pin" not in captured_kwargs \ No newline at end of file + assert "en_pin" not in captured_kwargs + + +# ─── pymc_tcp / pymc_usb branches ──────────────────────────────────── + + +def _pymc_radio_cfg(): + """Common radio params for the pymc_* tests.""" + return { + "frequency": 869618000, + "tx_power": 22, + "spreading_factor": 8, + "bandwidth": 62500, + "coding_rate": 8, + "preamble_length": 16, + "sync_word": 0x12, + } + + +def test_get_radio_for_board_pymc_tcp(monkeypatch): + pytest.importorskip("pymc_core.hardware.tcp_radio") + captured = {} + + class _DummyTCPLoRaRadio(_DummyRadio): + def __init__(self, **kwargs): + captured.update(kwargs) + + monkeypatch.setattr( + "pymc_core.hardware.tcp_radio.TCPLoRaRadio", + _DummyTCPLoRaRadio, + ) + + board_config = { + "radio_type": "pymc_tcp", + "pymc_tcp": { + "host": "pymc-3e2834.local", + "port": 5055, + "token": "shared-secret", + "connect_timeout": 7.5, + "lbt_enabled": False, + "lbt_max_attempts": 3, + }, + "radio": _pymc_radio_cfg(), + } + + get_radio_for_board(board_config) + + assert captured["host"] == "pymc-3e2834.local" + assert captured["port"] == 5055 + assert captured["token"] == "shared-secret" + assert captured["connect_timeout"] == 7.5 + assert captured["frequency"] == 869618000 + assert captured["sync_word"] == 0x12 + assert captured["lbt_enabled"] is False + assert captured["lbt_max_attempts"] == 3 + + +def test_get_radio_for_board_pymc_tcp_requires_host(monkeypatch): + pytest.importorskip("pymc_core.hardware.tcp_radio") + + monkeypatch.setattr( + "pymc_core.hardware.tcp_radio.TCPLoRaRadio", + lambda **kwargs: _DummyRadio(), + ) + + board_config = { + "radio_type": "pymc_tcp", + "pymc_tcp": {"port": 5055}, + "radio": _pymc_radio_cfg(), + } + + with pytest.raises(ValueError, match="Missing 'host'"): + get_radio_for_board(board_config) + + +def test_get_radio_for_board_pymc_usb(monkeypatch): + pytest.importorskip("pymc_core.hardware.usb_radio") + captured = {} + + class _DummyUSBLoRaRadio(_DummyRadio): + def __init__(self, **kwargs): + captured.update(kwargs) + + monkeypatch.setattr( + "pymc_core.hardware.usb_radio.USBLoRaRadio", + _DummyUSBLoRaRadio, + ) + + board_config = { + "radio_type": "pymc_usb", + "pymc_usb": { + "port": "/dev/ttyACM0", + "baudrate": 921600, + }, + "radio": _pymc_radio_cfg(), + } + + get_radio_for_board(board_config) + + assert captured["port"] == "/dev/ttyACM0" + assert captured["baudrate"] == 921600 + assert captured["frequency"] == 869618000 + assert captured["sync_word"] == 0x12 + # LBT defaults preserved when omitted from pymc_usb section. + assert captured["lbt_enabled"] is True + assert captured["lbt_max_attempts"] == 5 + + +def test_get_radio_for_board_pymc_usb_requires_port(monkeypatch): + pytest.importorskip("pymc_core.hardware.usb_radio") + + monkeypatch.setattr( + "pymc_core.hardware.usb_radio.USBLoRaRadio", + lambda **kwargs: _DummyRadio(), + ) + + board_config = { + "radio_type": "pymc_usb", + # Section present (baudrate set) but `port` deliberately omitted to + # exercise the inner "Missing 'port'" guard rather than the outer + # "Missing 'pymc_usb' section" one. + "pymc_usb": {"baudrate": 921600}, + "radio": _pymc_radio_cfg(), + } + + with pytest.raises(ValueError, match="Missing 'port'"): + get_radio_for_board(board_config) \ No newline at end of file