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:
parent
c1dfc6672c
commit
4c3cd4b6ad
@ -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()) {
|
||||||
|
|||||||
@ -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(),
|
||||||
|
|||||||
@ -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(¶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"),
|
// 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 {
|
||||||
|
|||||||
@ -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(),
|
||||||
|
|||||||
@ -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]
|
||||||
|
|||||||
@ -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?;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user