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;
|
const FROM_RADIO_CHANNEL: u64 = 10;
|
||||||
/// Channel.role value for the PRIMARY channel (broadcasts ride here).
|
/// Channel.role value for the PRIMARY channel (broadcasts ride here).
|
||||||
const CHANNEL_ROLE_PRIMARY: u64 = 1;
|
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`).
|
/// Config.lora oneof field number (carries a `LoRaConfig`).
|
||||||
const CONFIG_LORA_FIELD: u64 = 6;
|
const CONFIG_LORA_FIELD: u64 = 6;
|
||||||
/// LoRaConfig field numbers we set when provisioning the radio's region.
|
/// 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.
|
/// provisions a shared channel here the same way it provisions the region.
|
||||||
/// `None` until a primary `Channel` frame is seen.
|
/// `None` until a primary `Channel` frame is seen.
|
||||||
current_primary_channel: Option<(String, Vec<u8>)>,
|
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,
|
device_path: String,
|
||||||
/// PKI-encryption status of the most recent inbound text frame yielded by
|
/// 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
|
/// `try_recv_frame`. The synthetic meshcore-style frame can't carry it, so
|
||||||
@ -130,6 +148,7 @@ impl MeshtasticDevice {
|
|||||||
peer_pubkeys: HashMap::new(),
|
peer_pubkeys: HashMap::new(),
|
||||||
current_region: None,
|
current_region: None,
|
||||||
current_primary_channel: None,
|
current_primary_channel: None,
|
||||||
|
current_secondary_channel: None,
|
||||||
device_path: path.to_string(),
|
device_path: path.to_string(),
|
||||||
last_rx_encrypted: false,
|
last_rx_encrypted: false,
|
||||||
})
|
})
|
||||||
@ -327,41 +346,57 @@ impl MeshtasticDevice {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ensure the radio's PRIMARY channel matches the shared archy channel so
|
/// Provision archy's two channels so the radio works like off-the-shelf
|
||||||
/// all nodes can decode each other. Region gets two radios onto the same
|
/// Meshtastic AND carries our private group:
|
||||||
/// band; a matching channel (name + psk → channel hash) gets them decoding
|
/// - slot 0 (PRIMARY) = the DEFAULT public channel (name "", default key)
|
||||||
/// each other's traffic — without it they hear each other but drop every
|
/// → archy interoperates with every stock device on LongFast and picks
|
||||||
/// packet as undecryptable. The psk is derived deterministically from the
|
/// up default-channel users; our own NodeInfo broadcasts ride here.
|
||||||
/// channel name, so every archy node with the same `channel_name` converges
|
/// - slot 1 (SECONDARY) = "archipelago" (deterministic psk from the name)
|
||||||
/// on the same channel (the parity equivalent of meshcore's named channel).
|
/// → the private archy↔archy group channel (parity with meshcore).
|
||||||
///
|
///
|
||||||
/// Returns `Ok(true)` when it wrote a new channel (the device reboots to
|
/// Writes at most ONE channel per call (each write reboots the radio), so the
|
||||||
/// apply, so the caller should restart the session); `Ok(false)` when no
|
/// existing reboot→reconnect→re-check loop converges over a couple of cycles
|
||||||
/// change was needed — never reboot-loops.
|
/// without ever reboot-looping. Returns `Ok(true)` when it wrote something.
|
||||||
pub async fn ensure_channel(&mut self, channel_name: Option<&str>) -> Result<bool> {
|
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);
|
return Ok(false);
|
||||||
};
|
};
|
||||||
if channel_name.is_empty() {
|
|
||||||
return Ok(false);
|
|
||||||
}
|
|
||||||
let desired_psk = derive_channel_psk(channel_name);
|
let desired_psk = derive_channel_psk(channel_name);
|
||||||
let already = matches!(
|
let already = matches!(
|
||||||
&self.current_primary_channel,
|
&self.current_secondary_channel,
|
||||||
Some((name, psk)) if name == channel_name && psk == &desired_psk
|
Some((name, psk)) if name == channel_name && psk == &desired_psk
|
||||||
);
|
);
|
||||||
if already {
|
if already {
|
||||||
Ok(false)
|
Ok(false)
|
||||||
} else {
|
} 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)
|
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
|
/// (the same local-admin path as `set_advert_name`). The firmware reboots to
|
||||||
/// apply it.
|
/// 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 {
|
let Some(node_num) = self.node_num else {
|
||||||
anyhow::bail!("Meshtastic set_channel: node_num unknown");
|
anyhow::bail!("Meshtastic set_channel: node_num unknown");
|
||||||
};
|
};
|
||||||
@ -371,11 +406,11 @@ impl MeshtasticDevice {
|
|||||||
encode_len_field(2, psk, &mut settings);
|
encode_len_field(2, psk, &mut settings);
|
||||||
encode_len_field(3, name.as_bytes(), &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();
|
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_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 }
|
// AdminMessage { set_channel(33): Channel }
|
||||||
let mut admin = Vec::new();
|
let mut admin = Vec::new();
|
||||||
@ -386,8 +421,13 @@ impl MeshtasticDevice {
|
|||||||
.await
|
.await
|
||||||
.context("Failed to send Meshtastic set_channel admin packet")?;
|
.context("Failed to send Meshtastic set_channel admin packet")?;
|
||||||
|
|
||||||
info!(node_num, channel = %name, "Set Meshtastic primary channel (device will reboot to apply)");
|
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()));
|
self.current_primary_channel = Some((name.to_string(), psk.to_vec()));
|
||||||
|
} else {
|
||||||
|
self.current_secondary_channel = Some((name.to_string(), psk.to_vec()));
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -668,9 +708,13 @@ impl MeshtasticDevice {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
FROM_RADIO_CHANNEL => {
|
FROM_RADIO_CHANNEL => {
|
||||||
if let Some((name, psk)) = parse_primary_channel(value) {
|
if let Some((index, role, name, psk)) = parse_channel(value) {
|
||||||
debug!(name = %name, psk_len = psk.len(), "Meshtastic primary channel from device");
|
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));
|
self.current_primary_channel = Some((name, psk));
|
||||||
|
} else if index == ARCHY_CHANNEL_INDEX {
|
||||||
|
self.current_secondary_channel = Some((name, psk));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
@ -872,11 +916,25 @@ fn parse_config_lora_region(data: &[u8]) -> Option<u32> {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extract `(name, psk)` from a `Channel` message, but only for the PRIMARY
|
/// True when the radio's primary channel is the off-the-shelf DEFAULT public
|
||||||
/// channel (role == 1) — that's the one broadcasts ride on and whose hash must
|
/// channel (empty name + the default key, in either its 1-byte or expanded
|
||||||
/// match for two radios to decode each other. Returns `None` for secondary /
|
/// form). Used so we only rewrite the primary when it's been clobbered (e.g. an
|
||||||
/// disabled channels so the caller keeps the primary it already learned.
|
/// older archy that set "archipelago" as primary) — never on a stock radio.
|
||||||
fn parse_primary_channel(data: &[u8]) -> Option<(String, Vec<u8>)> {
|
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 role = 0u64;
|
||||||
let mut name = String::new();
|
let mut name = String::new();
|
||||||
let mut psk = Vec::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)?;
|
let (field, value, next) = next_field(data, idx)?;
|
||||||
idx = next;
|
idx = next;
|
||||||
match (field, value) {
|
match (field, value) {
|
||||||
|
(1, FieldValue::Varint(v)) => index = v,
|
||||||
(3, FieldValue::Varint(v)) => role = v,
|
(3, FieldValue::Varint(v)) => role = v,
|
||||||
(2, FieldValue::Bytes(b)) => {
|
(2, FieldValue::Bytes(b)) => {
|
||||||
let mut j = 0;
|
let mut j = 0;
|
||||||
@ -903,11 +962,7 @@ fn parse_primary_channel(data: &[u8]) -> Option<(String, Vec<u8>)> {
|
|||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if role == CHANNEL_ROLE_PRIMARY {
|
Some((index, role, name, psk))
|
||||||
Some((name, psk))
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Derive the 32-byte channel PSK deterministically from the channel name, so
|
/// Derive the 32-byte channel PSK deterministically from the channel name, so
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user