feat(mesh): operator-configurable Meshcore LoRa PHY params (freq/bw/sf/cr)

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 <noreply@anthropic.com>
This commit is contained in:
archipelago 2026-07-21 06:10:45 -04:00
parent c1dfc6672c
commit 4c3cd4b6ad
6 changed files with 173 additions and 0 deletions

View File

@ -158,6 +158,29 @@ impl RpcHandler {
anyhow::bail!("Unknown LoRa region: {trimmed}"); 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"/"" // Firmware pin: probe only the named firmware on the port ("auto"/""
// clears the pin and restores strict-probe auto-detect). // clears the pin and restores strict-probe auto-detect).
if let Some(kind) = params.get("device_kind").and_then(|v| v.as_str()) { if let Some(kind) = params.get("device_kind").and_then(|v| v.as_str()) {

View File

@ -421,6 +421,7 @@ pub fn spawn_mesh_listener(
our_x25519_pubkey_hex: String, our_x25519_pubkey_hex: String,
server_name: Option<String>, server_name: Option<String>,
lora_region: Option<String>, lora_region: Option<String>,
lora_radio_params: Option<super::LoraRadioParams>,
channel_name: Option<String>, channel_name: Option<String>,
device_kind: Option<super::types::DeviceType>, device_kind: Option<super::types::DeviceType>,
reticulum_tcp: Option<super::types::ReticulumTcpConfig>, reticulum_tcp: Option<super::types::ReticulumTcpConfig>,
@ -456,6 +457,7 @@ pub fn spawn_mesh_listener(
&our_x25519_pubkey_hex, &our_x25519_pubkey_hex,
server_name.as_deref(), server_name.as_deref(),
lora_region.as_deref(), lora_region.as_deref(),
lora_radio_params,
channel_name.as_deref(), channel_name.as_deref(),
device_kind, device_kind,
reticulum_tcp.clone(), reticulum_tcp.clone(),

View File

@ -836,6 +836,10 @@ const MAX_REGION_PROVISION_ATTEMPTS: u32 = 3;
static REGION_PROVISION_ATTEMPTS: std::sync::atomic::AtomicU32 = static REGION_PROVISION_ATTEMPTS: std::sync::atomic::AtomicU32 =
std::sync::atomic::AtomicU32::new(0); 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. /// Same retry-cap idea as the region, for the shared-channel write.
static CHANNEL_PROVISION_ATTEMPTS: std::sync::atomic::AtomicU32 = static CHANNEL_PROVISION_ATTEMPTS: std::sync::atomic::AtomicU32 =
std::sync::atomic::AtomicU32::new(0); std::sync::atomic::AtomicU32::new(0);
@ -851,6 +855,7 @@ pub(super) async fn run_mesh_session(
our_x25519_pubkey_hex: &str, our_x25519_pubkey_hex: &str,
server_name: Option<&str>, server_name: Option<&str>,
lora_region: Option<&str>, lora_region: Option<&str>,
lora_radio_params: Option<crate::mesh::LoraRadioParams>,
channel_name: Option<&str>, channel_name: Option<&str>,
device_kind: Option<DeviceType>, device_kind: Option<DeviceType>,
reticulum_tcp: Option<ReticulumTcpConfig>, reticulum_tcp: Option<ReticulumTcpConfig>,
@ -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<crate::mesh::LoraRadioParams> = 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(&params) {
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"), // Set advert name to the server's human-readable name (e.g. "ThinkPad"),
// falling back to the DID fragment if no name is configured. // falling back to the DID fragment if no name is configured.
let advert_name = if let Some(name) = server_name { let advert_name = if let Some(name) = server_name {

View File

@ -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). /// Mesh configuration (persisted to disk).
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MeshConfig { pub struct MeshConfig {
@ -340,6 +356,11 @@ pub struct MeshConfig {
/// unset/None. /// unset/None.
#[serde(default)] #[serde(default)]
pub lora_region: Option<String>, pub lora_region: Option<String>,
/// 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<LoraRadioParams>,
/// Whether to periodically broadcast our identity. /// Whether to periodically broadcast our identity.
#[serde(default)] #[serde(default)]
pub broadcast_identity: bool, pub broadcast_identity: bool,
@ -422,6 +443,7 @@ impl Default for MeshConfig {
device_path: None, device_path: None,
channel_name: Some("archipelago".to_string()), channel_name: Some("archipelago".to_string()),
lora_region: None, lora_region: None,
lora_radio_params: None,
broadcast_identity: true, broadcast_identity: true,
advert_name: None, advert_name: None,
mesh_only_mode: None, mesh_only_mode: None,
@ -722,6 +744,7 @@ impl MeshService {
self.our_x25519_pubkey_hex.clone(), self.our_x25519_pubkey_hex.clone(),
self.server_name.clone(), self.server_name.clone(),
self.config.lora_region.clone(), self.config.lora_region.clone(),
self.config.lora_radio_params,
self.config.channel_name.clone(), self.config.channel_name.clone(),
self.config.device_kind, self.config.device_kind,
self.config.reticulum_tcp.clone(), self.config.reticulum_tcp.clone(),

View File

@ -210,6 +210,24 @@ pub fn build_set_device_time(unix_secs: u64) -> Vec<u8> {
encode_frame(&data) 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<u8> {
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. /// CMD_SET_ADVERT_NAME (0x08): Set the node's advertised name on the mesh.
pub fn build_set_advert_name(name: &str) -> Vec<u8> { pub fn build_set_advert_name(name: &str) -> Vec<u8> {
let mut data = vec![CMD_SET_ADVERT_NAME]; let mut data = vec![CMD_SET_ADVERT_NAME];
@ -730,6 +748,29 @@ mod tests {
assert_eq!(frame[4], PROTOCOL_VERSION); 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] #[test]
fn test_decode_frame_complete() -> Result<()> { fn test_decode_frame_complete() -> Result<()> {
// Simulate an inbound frame: < + len(2) + [RESP_OK] // Simulate an inbound frame: < + len(2) + [RESP_OK]

View File

@ -164,6 +164,29 @@ impl MeshcoreDevice {
Ok(()) 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. /// Broadcast our advertisement to the mesh.
pub async fn send_self_advert(&mut self) -> Result<()> { pub async fn send_self_advert(&mut self) -> Result<()> {
self.send_raw(&protocol::build_send_self_advert()).await?; self.send_raw(&protocol::build_send_self_advert()).await?;