feat(mesh): meshtastic off-the-shelf interop — default channel + private archipelago

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) <noreply@anthropic.com>
This commit is contained in:
archipelago 2026-06-29 07:40:10 -04:00
parent 067002b04b
commit 810127fd3e

View File

@ -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<u8>)>,
/// 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<u8>)>,
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<bool> {
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<u32> {
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<u8>)> {
/// 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<u8>)>) -> 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<u8>)> {
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<u8>)> {
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<u8>)> {
_ => {}
}
}
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