From f7c4e2b4a86051d090e0901a6670a233c196d8fe Mon Sep 17 00:00:00 2001 From: Lloyd Date: Tue, 3 Feb 2026 22:28:53 +0000 Subject: [PATCH] Add support for new radio hardware configurations and CH341 USB adapter --- .gitignore | 1 + config.yaml.example | 42 ++++-- pyproject.toml | 2 +- radio-settings.json | 23 ++++ repeater/config.py | 34 ++++- .../data_acquisition/storage_collector.py | 8 +- repeater/main.py | 125 +++++++++++------- repeater/web/api_endpoints.py | 39 +++++- setup-radio-config.sh | 51 +++++++ 9 files changed, 258 insertions(+), 67 deletions(-) diff --git a/.gitignore b/.gitignore index 1769786..0f6aeca 100644 --- a/.gitignore +++ b/.gitignore @@ -34,6 +34,7 @@ debian/pymc-repeater.substvars # Virtual environments .venv/ +.venv_new/ env/ ENV/ diff --git a/config.yaml.example b/config.yaml.example index 9f96c83..d04c764 100644 --- a/config.yaml.example +++ b/config.yaml.example @@ -88,17 +88,29 @@ identities: # 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" - # type: "room_server" - # settings: - # node_name: "Social Hub" - # latitude: 0.0 - # longitude: 0.0 - # admin_password: "social_admin_123" - # guest_password: "social_guest_123" - + # Add more room servers as needed + # - name: "SocialHub" + # identity_key: "another_identity_key_hex_here" + # type: "room_server" + # settings: + # node_name: "Social Hub" + # latitude: 0.0 + # longitude: 0.0 + # admin_password: "social_admin_123" + # guest_password: "social_guest_123" + +# 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 @@ -128,12 +140,16 @@ radio: implicit_header: false # 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 @@ -148,6 +164,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/pyproject.toml b/pyproject.toml index 075c7e8..bf28765 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,7 +31,7 @@ keywords = ["mesh", "networking", "lora", "repeater", "daemon", "iot"] dependencies = [ - "pymc_core[hardware] @ git+https://github.com/rightup/pyMC_core.git@dev", + "pymc_core[hardware] @ git+https://github.com/rightup/pyMC_core.git@feat/newRadios", "pyyaml>=6.0.0", "cherrypy>=18.0.0", "paho-mqtt>=1.6.0", diff --git a/radio-settings.json b/radio-settings.json index b4f35dc..8cff524 100644 --- a/radio-settings.json +++ b/radio-settings.json @@ -127,6 +127,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/config.py b/repeater/config.py index ffe9268..5927fc8 100644 --- a/repeater/config.py +++ b/repeater/config.py @@ -197,9 +197,18 @@ 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(), 0) + raise ValueError(f"Invalid int value type: {type(value)}") + radio_type = board_config.get("radio_type", "sx1262").lower() - 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 @@ -211,7 +220,22 @@ 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"], @@ -224,6 +248,7 @@ def get_radio_for_board(board_config: dict): "txled_pin": spi_config.get("txled_pin", -1), "rxled_pin": spi_config.get("rxled_pin", -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"]), @@ -245,5 +270,6 @@ def get_radio_for_board(board_config: dict): return radio - else: - raise RuntimeError(f"Unknown radio type: {radio_type}. Supported: sx1262") + raise RuntimeError( + f"Unknown radio type: {radio_type}. Supported: sx1262, sx1262_ch341" + ) diff --git a/repeater/data_acquisition/storage_collector.py b/repeater/data_acquisition/storage_collector.py index 1019415..e065647 100644 --- a/repeater/data_acquisition/storage_collector.py +++ b/repeater/data_acquisition/storage_collector.py @@ -19,7 +19,13 @@ class StorageCollector: def __init__(self, config: dict, local_identity=None, repeater_handler=None): self.config = config self.repeater_handler = repeater_handler - self.storage_dir = Path(config.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 dd9c785..cc736b0 100644 --- a/repeater/main.py +++ b/repeater/main.py @@ -53,7 +53,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) @@ -458,57 +459,89 @@ 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...") - 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...") + + finally: + await self._shutdown() def main(): diff --git a/repeater/web/api_endpoints.py b/repeater/web/api_endpoints.py index e3c3155..2a2a661 100644 --- a/repeater/web/api_endpoints.py +++ b/repeater/web/api_endpoints.py @@ -262,7 +262,12 @@ class APIEndpoints: import json # Check config-based location first, then development location - config_dir = Path(self.config.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') @@ -301,7 +306,12 @@ class APIEndpoints: import json # Check config-based location first, then development location - config_dir = Path(self.config.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') @@ -353,7 +363,12 @@ class APIEndpoints: # Load hardware configuration - check installed path first, then dev path import json - config_dir = Path(self.config.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') @@ -388,6 +403,22 @@ class APIEndpoints: config_yaml['repeater']['security'] = {} config_yaml['repeater']['security']['admin_password'] = admin_password + # Update radio backend selection (radio_type + optional CH341 USB adapter info) + if 'radio_type' in hw_config: + config_yaml['radio_type'] = hw_config.get('radio_type') + + # CH341 settings can be provided as top-level fields or nested object + 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 + # Update radio settings - convert MHz/kHz to Hz if 'radio' not in config_yaml: config_yaml['radio'] = {} @@ -439,6 +470,8 @@ class APIEndpoints: # Hardware flags 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: diff --git a/setup-radio-config.sh b/setup-radio-config.sh index a0141fa..276fb64 100644 --- a/setup-radio-config.sh +++ b/setup-radio-config.sh @@ -206,6 +206,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') @@ -220,6 +224,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" @@ -284,6 +311,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 # Cleanup