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:
parent
067002b04b
commit
810127fd3e
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user