From 810127fd3e730545a4691beb0a01b4d5125b1278 Mon Sep 17 00:00:00 2001 From: archipelago Date: Mon, 29 Jun 2026 07:40:10 -0400 Subject: [PATCH] =?UTF-8?q?feat(mesh):=20meshtastic=20off-the-shelf=20inte?= =?UTF-8?q?rop=20=E2=80=94=20default=20channel=20+=20private=20archipelago?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make a meshtastic-equipped archy node work like a stock Meshtastic device AND keep the private archy group, instead of being isolated on a custom primary: - slot 0 (PRIMARY) = the DEFAULT public channel (empty name + default key) → interoperates with every off-the-shelf device on LongFast and picks up default-channel users; our NodeInfo broadcasts ride here like normal. - slot 1 (SECONDARY) = "archipelago" (deterministic psk) → private archy↔archy. Previously the driver set "archipelago" as the PRIMARY, isolating archy from the public mesh. Now ensure_channel writes at most one channel per call (default primary first, then archipelago secondary), reusing the existing reboot→ reconnect→re-check loop so it converges in ≤2 cycles without reboot-looping; primary_is_default() accepts the default key in 1-byte or expanded form so a stock radio is never needlessly rewritten. set_channel generalized to (index, name, psk, role); want_config parse tracks both slots. MeshCore needs no change — it never overrides channels (ensure_channel is a no-op) and already rides MeshCore's default Public channel off the shelf. cargo check green. NEEDS radio verify on .116/.198 (default-channel RX + archy group on the secondary). Channel provision cap (3) covers the 2-write migration. Co-Authored-By: Claude Opus 4.8 (1M context) --- core/archipelago/src/mesh/meshtastic.rs | 127 +++++++++++++++++------- 1 file changed, 91 insertions(+), 36 deletions(-) diff --git a/core/archipelago/src/mesh/meshtastic.rs b/core/archipelago/src/mesh/meshtastic.rs index 61c86144..29206870 100644 --- a/core/archipelago/src/mesh/meshtastic.rs +++ b/core/archipelago/src/mesh/meshtastic.rs @@ -55,6 +55,19 @@ const ADMIN_SET_CHANNEL_FIELD: u64 = 33; const FROM_RADIO_CHANNEL: u64 = 10; /// Channel.role value for the PRIMARY channel (broadcasts ride here). const CHANNEL_ROLE_PRIMARY: u64 = 1; +/// Channel.role value for a SECONDARY channel (extra channels we also decode). +const CHANNEL_ROLE_SECONDARY: u64 = 2; +/// Slot index our private archipelago channel occupies (secondary). Slot 0 is +/// kept as the off-the-shelf default public channel so archy interoperates with +/// stock Meshtastic devices (LongFast) AND picks up default-channel users. +const ARCHY_CHANNEL_INDEX: u64 = 1; +/// Meshtastic's default-channel PSK is the single byte 0x01 ("use the well-known +/// default key"); the firmware also reports it expanded to these 16 bytes. Treat +/// EITHER form as "the default channel" so we never reboot-loop re-setting it. +const DEFAULT_PSK_BYTE: &[u8] = &[1]; +const DEFAULT_PSK_EXPANDED: &[u8] = &[ + 0xd4, 0xf1, 0xbb, 0x3a, 0x20, 0x29, 0x07, 0x59, 0xf0, 0xbc, 0xff, 0xab, 0xcf, 0x4e, 0x69, 0x01, +]; /// Config.lora oneof field number (carries a `LoRaConfig`). const CONFIG_LORA_FIELD: u64 = 6; /// LoRaConfig field numbers we set when provisioning the radio's region. @@ -96,6 +109,11 @@ pub struct MeshtasticDevice { /// provisions a shared channel here the same way it provisions the region. /// `None` until a primary `Channel` frame is seen. current_primary_channel: Option<(String, Vec)>, + /// The radio's current SECONDARY channel at `ARCHY_CHANNEL_INDEX`, learned + /// from `want_config`. This is where our private "archipelago" channel lives + /// (slot 0 stays the public default). `None` until that slot's `Channel` + /// frame is seen. + current_secondary_channel: Option<(String, Vec)>, device_path: String, /// PKI-encryption status of the most recent inbound text frame yielded by /// `try_recv_frame`. The synthetic meshcore-style frame can't carry it, so @@ -130,6 +148,7 @@ impl MeshtasticDevice { peer_pubkeys: HashMap::new(), current_region: None, current_primary_channel: None, + current_secondary_channel: None, device_path: path.to_string(), last_rx_encrypted: false, }) @@ -327,41 +346,57 @@ impl MeshtasticDevice { Ok(()) } - /// Ensure the radio's PRIMARY channel matches the shared archy channel so - /// all nodes can decode each other. Region gets two radios onto the same - /// band; a matching channel (name + psk → channel hash) gets them decoding - /// each other's traffic — without it they hear each other but drop every - /// packet as undecryptable. The psk is derived deterministically from the - /// channel name, so every archy node with the same `channel_name` converges - /// on the same channel (the parity equivalent of meshcore's named channel). + /// Provision archy's two channels so the radio works like off-the-shelf + /// Meshtastic AND carries our private group: + /// - slot 0 (PRIMARY) = the DEFAULT public channel (name "", default key) + /// → archy interoperates with every stock device on LongFast and picks + /// up default-channel users; our own NodeInfo broadcasts ride here. + /// - slot 1 (SECONDARY) = "archipelago" (deterministic psk from the name) + /// → the private archy↔archy group channel (parity with meshcore). /// - /// Returns `Ok(true)` when it wrote a new channel (the device reboots to - /// apply, so the caller should restart the session); `Ok(false)` when no - /// change was needed — never reboot-loops. + /// Writes at most ONE channel per call (each write reboots the radio), so the + /// existing reboot→reconnect→re-check loop converges over a couple of cycles + /// without ever reboot-looping. Returns `Ok(true)` when it wrote something. pub async fn ensure_channel(&mut self, channel_name: Option<&str>) -> Result { - let Some(channel_name) = channel_name else { + // 1) Primary must be the default public channel (off-the-shelf interop). + if !primary_is_default(&self.current_primary_channel) { + self.set_channel(0, "", DEFAULT_PSK_BYTE, CHANNEL_ROLE_PRIMARY) + .await?; + return Ok(true); + } + // 2) Secondary slot = our private archipelago channel (when configured). + let Some(channel_name) = channel_name.filter(|n| !n.is_empty()) else { return Ok(false); }; - if channel_name.is_empty() { - return Ok(false); - } let desired_psk = derive_channel_psk(channel_name); let already = matches!( - &self.current_primary_channel, + &self.current_secondary_channel, Some((name, psk)) if name == channel_name && psk == &desired_psk ); if already { Ok(false) } else { - self.set_channel(channel_name, &desired_psk).await?; + self.set_channel( + ARCHY_CHANNEL_INDEX, + channel_name, + &desired_psk, + CHANNEL_ROLE_SECONDARY, + ) + .await?; Ok(true) } } - /// Write the PRIMARY channel via `AdminMessage { set_channel: Channel { … } }` + /// Write a channel slot via `AdminMessage { set_channel: Channel { … } }` /// (the same local-admin path as `set_advert_name`). The firmware reboots to /// apply it. - pub async fn set_channel(&mut self, name: &str, psk: &[u8]) -> Result<()> { + pub async fn set_channel( + &mut self, + index: u64, + name: &str, + psk: &[u8], + role: u64, + ) -> Result<()> { let Some(node_num) = self.node_num else { anyhow::bail!("Meshtastic set_channel: node_num unknown"); }; @@ -371,11 +406,11 @@ impl MeshtasticDevice { encode_len_field(2, psk, &mut settings); encode_len_field(3, name.as_bytes(), &mut settings); - // Channel { index(1)=0, settings(2), role(3)=PRIMARY } + // Channel { index(1), settings(2), role(3) } let mut channel = Vec::new(); - encode_varint_field_into(1, 0, &mut channel); + encode_varint_field_into(1, index, &mut channel); encode_len_field(2, &settings, &mut channel); - encode_varint_field_into(3, CHANNEL_ROLE_PRIMARY, &mut channel); + encode_varint_field_into(3, role, &mut channel); // AdminMessage { set_channel(33): Channel } let mut admin = Vec::new(); @@ -386,8 +421,13 @@ impl MeshtasticDevice { .await .context("Failed to send Meshtastic set_channel admin packet")?; - info!(node_num, channel = %name, "Set Meshtastic primary channel (device will reboot to apply)"); - self.current_primary_channel = Some((name.to_string(), psk.to_vec())); + let slot = if role == CHANNEL_ROLE_PRIMARY { "primary(default)" } else { "secondary(archipelago)" }; + info!(node_num, index, channel = %name, slot, "Set Meshtastic channel (device will reboot to apply)"); + if role == CHANNEL_ROLE_PRIMARY { + self.current_primary_channel = Some((name.to_string(), psk.to_vec())); + } else { + self.current_secondary_channel = Some((name.to_string(), psk.to_vec())); + } Ok(()) } @@ -668,9 +708,13 @@ impl MeshtasticDevice { None } FROM_RADIO_CHANNEL => { - if let Some((name, psk)) = parse_primary_channel(value) { - debug!(name = %name, psk_len = psk.len(), "Meshtastic primary channel from device"); - self.current_primary_channel = Some((name, psk)); + if let Some((index, role, name, psk)) = parse_channel(value) { + debug!(index, role, name = %name, psk_len = psk.len(), "Meshtastic channel from device"); + if role == CHANNEL_ROLE_PRIMARY { + self.current_primary_channel = Some((name, psk)); + } else if index == ARCHY_CHANNEL_INDEX { + self.current_secondary_channel = Some((name, psk)); + } } None } @@ -872,11 +916,25 @@ fn parse_config_lora_region(data: &[u8]) -> Option { None } -/// Extract `(name, psk)` from a `Channel` message, but only for the PRIMARY -/// channel (role == 1) — that's the one broadcasts ride on and whose hash must -/// match for two radios to decode each other. Returns `None` for secondary / -/// disabled channels so the caller keeps the primary it already learned. -fn parse_primary_channel(data: &[u8]) -> Option<(String, Vec)> { +/// True when the radio's primary channel is the off-the-shelf DEFAULT public +/// channel (empty name + the default key, in either its 1-byte or expanded +/// form). Used so we only rewrite the primary when it's been clobbered (e.g. an +/// older archy that set "archipelago" as primary) — never on a stock radio. +fn primary_is_default(primary: &Option<(String, Vec)>) -> bool { + match primary { + Some((name, psk)) => { + name.is_empty() + && (psk.as_slice() == DEFAULT_PSK_BYTE || psk.as_slice() == DEFAULT_PSK_EXPANDED) + } + None => false, + } +} + +/// Extract `(index, role, name, psk)` from a `Channel` message. The caller +/// stores the primary (slot 0) and our secondary slot separately so it can keep +/// both the public default channel and the private archipelago channel in sync. +fn parse_channel(data: &[u8]) -> Option<(u64, u64, String, Vec)> { + let mut index = 0u64; let mut role = 0u64; let mut name = String::new(); let mut psk = Vec::new(); @@ -885,6 +943,7 @@ fn parse_primary_channel(data: &[u8]) -> Option<(String, Vec)> { let (field, value, next) = next_field(data, idx)?; idx = next; match (field, value) { + (1, FieldValue::Varint(v)) => index = v, (3, FieldValue::Varint(v)) => role = v, (2, FieldValue::Bytes(b)) => { let mut j = 0; @@ -903,11 +962,7 @@ fn parse_primary_channel(data: &[u8]) -> Option<(String, Vec)> { _ => {} } } - if role == CHANNEL_ROLE_PRIMARY { - Some((name, psk)) - } else { - None - } + Some((index, role, name, psk)) } /// Derive the 32-byte channel PSK deterministically from the channel name, so