feat(mesh): Meshtastic provisioning robustness (backlog #12)

Three fixes:
1. Modem-preset authoritative: parse_config_lora_region now also decodes
   modem_preset (field 2) alongside region, tracked as current_modem_preset.
   ensure_lora_region's "region already set, don't touch it" branch (correct,
   unchanged) now ALSO re-asserts LONG_FAST when a real observed preset has
   drifted -- previously modem_preset only ever got written when region was
   UNSET, so a radio with the right region but wrong preset was never fixed.
   Only acts on an actually-observed wrong value (never speculative), so it
   can't reboot-loop.
2. RX-stall watchdog: run_mesh_session now bails (triggering the existing
   auto-reconnect path) if no frame has been successfully received in 5
   minutes -- the existing consecutive_write_failures counter is blind to a
   receive-only stall (writes can keep succeeding while inbound streaming is
   wedged).
3. Hot-swap detection: spawn_mesh_listener now compares self_node_id across
   session restarts and logs clearly when the physical radio itself changed
   (not just an ordinary reconnect of the same board). Per-session device
   state (contacts, current_region, etc.) was already naturally isolated
   per-session (fresh struct each reconnect) -- nothing else needed clearing.

107/107 mesh tests pass (2 new: modem_preset decode + the
absent-field-defaults-to-LONG_FAST case).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-06-30 23:21:29 -04:00
co-authored by Claude Sonnet 5
parent 494f272815
commit 712df2278f
3 changed files with 131 additions and 12 deletions
+73 -9
View File
@@ -132,6 +132,14 @@ pub struct MeshtasticDevice {
/// provision the operator-configured region — and to avoid a reboot loop by
/// only writing when it actually differs.
current_region: Option<u32>,
/// The radio's currently-configured LoRa modem preset, learned alongside
/// `current_region` from the same `Config.lora` block. Unlike region
/// (deliberately never overridden once set), this SHOULD be authoritative
/// -- two archy radios with mismatched presets (different spreading
/// factor/bandwidth) can't decode each other even on the correct region.
/// `None` until a Config frame is seen -- never write speculatively before
/// we've actually observed the radio's real value.
current_modem_preset: Option<u32>,
/// The radio's current PRIMARY channel as `(name, psk)`, learned from the
/// `Channel` blocks during `initialize`. Two radios only decode each other
/// when their primary channel (name + psk → channel hash) matches, so archy
@@ -184,6 +192,7 @@ impl MeshtasticDevice {
contacts: HashMap::new(),
peer_pubkeys: HashMap::new(),
current_region: None,
current_modem_preset: None,
current_primary_channel: None,
current_secondary_channel: None,
device_path: path.to_string(),
@@ -348,7 +357,24 @@ impl MeshtasticDevice {
"Respecting the radio's own LoRa region (not overriding with the configured one)"
);
}
Ok(false)
// Region is untouched either way, but the modem preset IS
// authoritative (backlog #12): two archy radios on mismatched
// presets can't decode each other even on the same region.
// Only acts once we've actually observed a real (non-None)
// preset that's wrong — never speculative, so this can't
// reboot-loop (the write sets LONG_FAST, the next Config
// frame confirms it, and this branch goes quiet).
match self.current_modem_preset {
Some(p) if p != LORA_MODEM_PRESET_LONG_FAST as u32 => {
debug!(
device_preset = p,
"Modem preset drifted from LONG_FAST — re-provisioning (region unchanged)"
);
self.set_lora_region(cur).await?;
Ok(true)
}
_ => Ok(false),
}
}
// Region is UNSET → a fresh radio is RF-silent and can't mesh at all.
// Set the operator-configured region so it can transmit/receive.
@@ -855,9 +881,10 @@ impl MeshtasticDevice {
FROM_RADIO_CONFIG => {
// Only the LoRa sub-config carries a region; other Config
// variants (device/position/…) return None and are ignored.
if let Some(region) = parse_config_lora_region(value) {
if let Some((region, modem_preset)) = parse_config_lora_region(value) {
self.current_region = Some(region);
debug!(region, "Meshtastic LoRa region from device config");
self.current_modem_preset = Some(modem_preset);
debug!(region, modem_preset, "Meshtastic LoRa region/preset from device config");
}
None
}
@@ -1182,7 +1209,12 @@ fn encode_heartbeat() -> Vec<u8> {
/// code. Returns `Some(REGION_UNSET)` when the LoRa block is present but has no
/// region field (a fresh radio), and `None` when this Config carries a
/// non-LoRa variant (device/position/…) so the caller keeps the prior value.
fn parse_config_lora_region(data: &[u8]) -> Option<u32> {
/// Returns `(region, modem_preset)` from a `Config.lora` block. `modem_preset`
/// defaults to `LORA_MODEM_PRESET_LONG_FAST` when the field is absent — a
/// fresh/never-configured radio reports no preset field at all, and treating
/// that as "already correct" (rather than "unknown, needs fixing") avoids a
/// spurious reboot before the operator-region provisioning step even runs.
fn parse_config_lora_region(data: &[u8]) -> Option<(u32, u32)> {
let mut idx = 0;
while idx < data.len() {
let (field, value, next) = next_field(data, idx)?;
@@ -1191,16 +1223,17 @@ fn parse_config_lora_region(data: &[u8]) -> Option<u32> {
if let FieldValue::Bytes(b) = value {
let mut j = 0;
let mut region = REGION_UNSET;
let mut modem_preset = LORA_MODEM_PRESET_LONG_FAST as u32;
while j < b.len() {
let (lf, lv, ln) = next_field(b, j)?;
j = ln;
if lf == LORA_REGION_FIELD {
if let FieldValue::Varint(v) = lv {
region = v as u32;
}
match (lf, lv) {
(LORA_REGION_FIELD, FieldValue::Varint(v)) => region = v as u32,
(LORA_MODEM_PRESET_FIELD, FieldValue::Varint(v)) => modem_preset = v as u32,
_ => {}
}
}
return Some(region);
return Some((region, modem_preset));
}
}
}
@@ -1803,6 +1836,37 @@ mod tests {
assert!(parse_position_lat_lon(&position).is_none());
}
#[test]
fn parse_config_lora_region_decodes_region_and_modem_preset() {
let mut lora = Vec::new();
encode_varint_field_into(LORA_REGION_FIELD, 3, &mut lora); // some real region code
encode_varint_field_into(LORA_MODEM_PRESET_FIELD, 4, &mut lora); // drifted preset
let mut config = Vec::new();
encode_len_field(CONFIG_LORA_FIELD, &lora, &mut config);
let (region, preset) = parse_config_lora_region(&config).expect("lora config present");
assert_eq!(region, 3);
assert_eq!(preset, 4);
}
#[test]
fn parse_config_lora_region_defaults_preset_to_long_fast_when_absent() {
// A fresh/never-provisioned radio's Config.lora may carry a region
// with no modem_preset field at all -- must default to "already
// correct" (LONG_FAST), not "unknown/wrong", or ensure_lora_region
// would try to fix a value it never actually observed.
let mut lora = Vec::new();
encode_varint_field_into(LORA_REGION_FIELD, 3, &mut lora);
let mut config = Vec::new();
encode_len_field(CONFIG_LORA_FIELD, &lora, &mut config);
let (region, preset) = parse_config_lora_region(&config).expect("lora config present");
assert_eq!(region, 3);
assert_eq!(preset, LORA_MODEM_PRESET_LONG_FAST as u32);
}
#[test]
fn packet_to_inbound_frame_updates_contact_signal_and_position_without_a_chat_frame() {
let from = 0x0000_4444;