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:
co-authored by
Claude Sonnet 5
parent
494f272815
commit
712df2278f
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user