From 4c3cd4b6ad5e34e0614b2f005efad7fc05809cdd Mon Sep 17 00:00:00 2001 From: archipelago Date: Tue, 21 Jul 2026 06:10:45 -0400 Subject: [PATCH] feat(mesh): operator-configurable Meshcore LoRa PHY params (freq/bw/sf/cr) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Archy set no radio params on Meshcore devices — SF/CR/BW/freq lived only in device firmware, so a radio on the wrong preset could not be fixed from the node (it hears RF energy but demodulates nothing; the fleet hit exactly this on an RNode: docs/RETICULUM-TRANSPORT-PROGRESS.md item 4). - protocol.rs: build_set_radio_params — wire format verified against the MeshCore companion firmware handler (CMD_SET_RADIO_PARAMS=11: [11][freq:u32 LE MHz*1000][bw:u32 LE kHz*1000][sf][cr]); byte-layout test pinned to the Portugal preset 869.618 MHz / 62.5 kHz / SF8 / CR8. - serial.rs: MeshcoreDevice::set_radio_params (device reboots on OK). - MeshConfig.lora_radio_params: Option — None (default) leaves the radio untouched, so only explicitly-configured deployments are affected. - session.rs: provision on connect, gated on a persisted last-applied marker (SELF_INFO offsets shift across firmware versions) + the same attempt cap as region/channel so a refusing radio never reboot-loops. - mesh.configure RPC: lora_radio_params arm with firmware-range validation. mesh tests: 114/114 pass. Co-Authored-By: Claude Fable 5 --- .../archipelago/src/api/rpc/mesh/messaging.rs | 23 +++++++ core/archipelago/src/mesh/listener/mod.rs | 2 + core/archipelago/src/mesh/listener/session.rs | 61 +++++++++++++++++++ core/archipelago/src/mesh/mod.rs | 23 +++++++ core/archipelago/src/mesh/protocol.rs | 41 +++++++++++++ core/archipelago/src/mesh/serial.rs | 23 +++++++ 6 files changed, 173 insertions(+) diff --git a/core/archipelago/src/api/rpc/mesh/messaging.rs b/core/archipelago/src/api/rpc/mesh/messaging.rs index 61cd976f..01ee5107 100644 --- a/core/archipelago/src/api/rpc/mesh/messaging.rs +++ b/core/archipelago/src/api/rpc/mesh/messaging.rs @@ -158,6 +158,29 @@ impl RpcHandler { anyhow::bail!("Unknown LoRa region: {trimmed}"); } } + // Meshcore LoRa PHY params (freq/bw/sf/cr, firmware field units — see + // mesh::LoraRadioParams). Validated against the firmware's accepted + // ranges here so a bad value errors at the API instead of being sent + // to the radio and rejected on-device. `null` clears the setting. + if let Some(rp) = params.get("lora_radio_params") { + if rp.is_null() { + config.lora_radio_params = None; + } else { + let parsed: mesh::LoraRadioParams = serde_json::from_value(rp.clone()) + .map_err(|e| anyhow::anyhow!("Invalid lora_radio_params: {e}"))?; + anyhow::ensure!( + (150_000..=2_500_000).contains(&parsed.freq_khz), + "freq_khz out of range (150000..=2500000)" + ); + anyhow::ensure!( + (7_000..=500_000).contains(&parsed.bw_hz), + "bw_hz out of range (7000..=500000)" + ); + anyhow::ensure!((5..=12).contains(&parsed.sf), "sf out of range (5..=12)"); + anyhow::ensure!((5..=8).contains(&parsed.cr), "cr out of range (5..=8)"); + config.lora_radio_params = Some(parsed); + } + } // Firmware pin: probe only the named firmware on the port ("auto"/"" // clears the pin and restores strict-probe auto-detect). if let Some(kind) = params.get("device_kind").and_then(|v| v.as_str()) { diff --git a/core/archipelago/src/mesh/listener/mod.rs b/core/archipelago/src/mesh/listener/mod.rs index ad77f6cc..4cd8baf3 100644 --- a/core/archipelago/src/mesh/listener/mod.rs +++ b/core/archipelago/src/mesh/listener/mod.rs @@ -421,6 +421,7 @@ pub fn spawn_mesh_listener( our_x25519_pubkey_hex: String, server_name: Option, lora_region: Option, + lora_radio_params: Option, channel_name: Option, device_kind: Option, reticulum_tcp: Option, @@ -456,6 +457,7 @@ pub fn spawn_mesh_listener( &our_x25519_pubkey_hex, server_name.as_deref(), lora_region.as_deref(), + lora_radio_params, channel_name.as_deref(), device_kind, reticulum_tcp.clone(), diff --git a/core/archipelago/src/mesh/listener/session.rs b/core/archipelago/src/mesh/listener/session.rs index d49fd86a..a6e4c6c7 100644 --- a/core/archipelago/src/mesh/listener/session.rs +++ b/core/archipelago/src/mesh/listener/session.rs @@ -836,6 +836,10 @@ const MAX_REGION_PROVISION_ATTEMPTS: u32 = 3; static REGION_PROVISION_ATTEMPTS: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0); +/// Same retry-cap idea as the region, for the Meshcore radio-params write. +static RADIO_PARAMS_PROVISION_ATTEMPTS: std::sync::atomic::AtomicU32 = + std::sync::atomic::AtomicU32::new(0); + /// Same retry-cap idea as the region, for the shared-channel write. static CHANNEL_PROVISION_ATTEMPTS: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0); @@ -851,6 +855,7 @@ pub(super) async fn run_mesh_session( our_x25519_pubkey_hex: &str, server_name: Option<&str>, lora_region: Option<&str>, + lora_radio_params: Option, channel_name: Option<&str>, device_kind: Option, reticulum_tcp: Option, @@ -978,6 +983,62 @@ pub(super) async fn run_mesh_session( ); } + // Provision Meshcore LoRa PHY params (freq/bw/sf/cr) when the operator has + // configured them. Meshcore-only: Meshtastic radios get region+preset via + // ensure_lora_region above, and Reticulum carries its own RNode profile. + // Gated on a persisted marker of the last-applied params rather than the + // device's SELF_INFO readback (its field offsets shift across firmware + // versions), so we send the set-command once per configured value and never + // reboot-loop a radio that refuses it. The firmware reboots on RESP_OK, so + // a successful write restarts the session like the region path. + if let (Some(params), MeshRadioDevice::Meshcore(dev)) = (lora_radio_params, &mut device) { + let marker_path = data_dir.join("meshcore-radio-params.json"); + let applied: Option = tokio::fs::read(&marker_path) + .await + .ok() + .and_then(|b| serde_json::from_slice(&b).ok()); + if applied != Some(params) { + let attempts = RADIO_PARAMS_PROVISION_ATTEMPTS.load(Ordering::Relaxed); + if attempts < MAX_REGION_PROVISION_ATTEMPTS { + match dev + .set_radio_params(params.freq_khz, params.bw_hz, params.sf, params.cr) + .await + { + Ok(()) => { + RADIO_PARAMS_PROVISION_ATTEMPTS.fetch_add(1, Ordering::Relaxed); + if let Ok(json) = serde_json::to_vec(¶ms) { + if let Err(e) = tokio::fs::write(&marker_path, json).await { + warn!("Failed to persist radio-params marker: {}", e); + } + } + info!( + freq_khz = params.freq_khz, + bw_hz = params.bw_hz, + sf = params.sf, + cr = params.cr, + "Provisioned Meshcore radio params — radio rebooting, \ + restarting mesh session" + ); + tokio::time::sleep(Duration::from_secs(10)).await; + return Ok(()); + } + Err(e) => { + RADIO_PARAMS_PROVISION_ATTEMPTS.fetch_add(1, Ordering::Relaxed); + warn!("Failed to provision Meshcore radio params: {}", e); + } + } + } else { + warn!( + attempts = MAX_REGION_PROVISION_ATTEMPTS, + "Meshcore radio rejected the configured radio params after \ + repeated attempts — continuing with the device's own settings." + ); + } + } else { + RADIO_PARAMS_PROVISION_ATTEMPTS.store(0, Ordering::Relaxed); + } + } + // Set advert name to the server's human-readable name (e.g. "ThinkPad"), // falling back to the DID fragment if no name is configured. let advert_name = if let Some(name) = server_name { diff --git a/core/archipelago/src/mesh/mod.rs b/core/archipelago/src/mesh/mod.rs index 73339bf3..8dcb3101 100644 --- a/core/archipelago/src/mesh/mod.rs +++ b/core/archipelago/src/mesh/mod.rs @@ -322,6 +322,22 @@ pub(crate) async fn seed_federation_peers_into_mesh( } } +/// Operator-configured LoRa PHY parameters for a Meshcore radio, in the +/// firmware's own field units: `freq_khz` = MHz×1000 (869618 → 869.618 MHz), +/// `bw_hz` = kHz×1000 (62500 → 62.5 kHz), `sf` 5..=12, `cr` 5..=8. These are +/// region/deployment-specific (e.g. the Portugal preset 869618/62500/8/8) and +/// MUST match every radio on the local mesh — a mismatched radio hears RF +/// energy but demodulates nothing. None (the default) leaves the device's own +/// settings untouched, so nodes outside the configured deployment are never +/// affected. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +pub struct LoraRadioParams { + pub freq_khz: u32, + pub bw_hz: u32, + pub sf: u8, + pub cr: u8, +} + /// Mesh configuration (persisted to disk). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct MeshConfig { @@ -340,6 +356,11 @@ pub struct MeshConfig { /// unset/None. #[serde(default)] pub lora_region: Option, + /// Meshcore LoRa PHY parameters (freq/bw/sf/cr). Provisioned onto the + /// radio on connect when set; None leaves the device untouched. Ignored + /// for Meshtastic (region/preset covers it) and Reticulum. + #[serde(default)] + pub lora_radio_params: Option, /// Whether to periodically broadcast our identity. #[serde(default)] pub broadcast_identity: bool, @@ -422,6 +443,7 @@ impl Default for MeshConfig { device_path: None, channel_name: Some("archipelago".to_string()), lora_region: None, + lora_radio_params: None, broadcast_identity: true, advert_name: None, mesh_only_mode: None, @@ -722,6 +744,7 @@ impl MeshService { self.our_x25519_pubkey_hex.clone(), self.server_name.clone(), self.config.lora_region.clone(), + self.config.lora_radio_params, self.config.channel_name.clone(), self.config.device_kind, self.config.reticulum_tcp.clone(), diff --git a/core/archipelago/src/mesh/protocol.rs b/core/archipelago/src/mesh/protocol.rs index 807a07d1..cb53f82d 100644 --- a/core/archipelago/src/mesh/protocol.rs +++ b/core/archipelago/src/mesh/protocol.rs @@ -210,6 +210,24 @@ pub fn build_set_device_time(unix_secs: u64) -> Vec { encode_frame(&data) } +/// CMD_SET_RADIO_PARAMS (0x0B): set the LoRa PHY config. The device reboots to +/// apply. `freq_field` and `bw_field` are the raw firmware fields (freq = +/// MHz×1000 e.g. 869618 for 869.618 MHz; bw = kHz×1000 e.g. 62500 for 62.5 kHz); +/// `sf` is 5..=12 and `cr` is 5..=8. Wire format verified against the MeshCore +/// companion firmware handler (`examples/companion_radio/MyMesh.cpp`, +/// `CMD_SET_RADIO_PARAMS`): `[11][freq:u32 LE][bw:u32 LE][sf:u8][cr:u8]`. The +/// same fields (same units) come back in the SELF_INFO reply, so a caller can +/// read them to detect drift. Values outside the firmware's accepted ranges are +/// rejected by the device (it replies with an error frame), not clamped here. +pub fn build_set_radio_params(freq_field: u32, bw_field: u32, sf: u8, cr: u8) -> Vec { + let mut data = vec![CMD_SET_RADIO_PARAMS]; + data.extend_from_slice(&freq_field.to_le_bytes()); + data.extend_from_slice(&bw_field.to_le_bytes()); + data.push(sf); + data.push(cr); + encode_frame(&data) +} + /// CMD_SET_ADVERT_NAME (0x08): Set the node's advertised name on the mesh. pub fn build_set_advert_name(name: &str) -> Vec { let mut data = vec![CMD_SET_ADVERT_NAME]; @@ -730,6 +748,29 @@ mod tests { assert_eq!(frame[4], PROTOCOL_VERSION); } + #[test] + fn test_build_set_radio_params_wire_layout() { + // Portugal preset: 869.618 MHz, 62.5 kHz BW, SF 8, CR 8. + // freq field = MHz*1000 = 869618; bw field = kHz*1000 = 62500. + let frame = build_set_radio_params(869_618, 62_500, 8, 8); + assert_eq!(frame[0], OUTBOUND_MARKER); + // payload length = 1 (cmd) + 4 (freq) + 4 (bw) + 1 (sf) + 1 (cr) = 11 + assert_eq!(u16::from_le_bytes([frame[1], frame[2]]), 11); + let data = &frame[3..]; + assert_eq!(data[0], CMD_SET_RADIO_PARAMS); + assert_eq!( + u32::from_le_bytes([data[1], data[2], data[3], data[4]]), + 869_618 + ); + assert_eq!( + u32::from_le_bytes([data[5], data[6], data[7], data[8]]), + 62_500 + ); + assert_eq!(data[9], 8); // sf + assert_eq!(data[10], 8); // cr + assert_eq!(data.len(), 11); + } + #[test] fn test_decode_frame_complete() -> Result<()> { // Simulate an inbound frame: < + len(2) + [RESP_OK] diff --git a/core/archipelago/src/mesh/serial.rs b/core/archipelago/src/mesh/serial.rs index e9106d5e..6caa3a03 100644 --- a/core/archipelago/src/mesh/serial.rs +++ b/core/archipelago/src/mesh/serial.rs @@ -164,6 +164,29 @@ impl MeshcoreDevice { Ok(()) } + /// Set the radio's LoRa PHY parameters (freq/bw/sf/cr, firmware field + /// units — see `protocol::build_set_radio_params`). On RESP_OK the + /// firmware persists the params and reboots to apply them, so the caller + /// must treat the session as gone and reconnect. + pub async fn set_radio_params( + &mut self, + freq_khz: u32, + bw_hz: u32, + sf: u8, + cr: u8, + ) -> Result<()> { + self.send_raw(&protocol::build_set_radio_params(freq_khz, bw_hz, sf, cr)) + .await?; + let frame = self.recv_frame_timeout(READ_TIMEOUT).await?; + if frame.code == protocol::RESP_ERR { + anyhow::bail!( + "Set radio params failed: {}", + protocol::parse_error(&frame.data) + ); + } + Ok(()) + } + /// Broadcast our advertisement to the mesh. pub async fn send_self_advert(&mut self) -> Result<()> { self.send_raw(&protocol::build_send_self_advert()).await?;