Add support for new radio hardware configurations and CH341 USB adapter

This commit is contained in:
Lloyd
2026-02-03 22:28:53 +00:00
parent 076d87dcde
commit f7c4e2b4a8
9 changed files with 258 additions and 67 deletions
+1
View File
@@ -34,6 +34,7 @@ debian/pymc-repeater.substvars
# Virtual environments
.venv/
.venv_new/
env/
ENV/
+30 -12
View File
@@ -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
+1 -1
View File
@@ -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",
+23
View File
@@ -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
}
}
}
+30 -4
View File
@@ -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"
)
@@ -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")
+79 -46
View File
@@ -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 <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...")
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...")
finally:
await self._shutdown()
def main():
+36 -3
View File
@@ -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:
+51
View File
@@ -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