diff --git a/app-catalog/catalog.json b/app-catalog/catalog.json index 86656b4f..f6b4f8ee 100644 --- a/app-catalog/catalog.json +++ b/app-catalog/catalog.json @@ -214,31 +214,6 @@ ] } }, - { - "id": "meshtastic", - "title": "Meshtastic", - "version": "2-daily-alpine", - "description": "Open-source mesh networking for LoRa radios. Create decentralized communication networks.", - "icon": "/assets/img/app-icons/meshcore.svg", - "author": "Meshtastic", - "category": "networking", - "tier": "recommended", - "dockerImage": "docker.io/meshtastic/meshtasticd:daily-alpine", - "repoUrl": "https://github.com/meshtastic/firmware", - "containerConfig": { - "ports": [ - "4403:4403" - ], - "volumes": [ - "/var/lib/archipelago/meshtastic:/var/lib/meshtasticd" - ], - "env": [ - "MESHTASTIC_PORT=/dev/ttyUSB0", - "MESHTASTIC_SERIAL=true" - ], - "notes": "Requires a LoRa radio device at /dev/ttyUSB0. The config file is rendered from the app manifest before container start." - } - }, { "id": "vaultwarden", "title": "Vaultwarden", diff --git a/core/archipelago/src/mesh/listener/mod.rs b/core/archipelago/src/mesh/listener/mod.rs index 3773fc7b..ccc9086a 100644 --- a/core/archipelago/src/mesh/listener/mod.rs +++ b/core/archipelago/src/mesh/listener/mod.rs @@ -373,6 +373,8 @@ pub fn spawn_mesh_listener( our_x25519_secret: [u8; 32], our_x25519_pubkey_hex: String, server_name: Option, + lora_region: Option, + channel_name: Option, shutdown: tokio::sync::watch::Receiver, cmd_rx: mpsc::Receiver, ) -> tokio::task::JoinHandle<()> { @@ -394,6 +396,8 @@ pub fn spawn_mesh_listener( &our_x25519_secret, &our_x25519_pubkey_hex, server_name.as_deref(), + lora_region.as_deref(), + channel_name.as_deref(), &mut shutdown, &mut cmd_rx, ) diff --git a/core/archipelago/src/mesh/listener/session.rs b/core/archipelago/src/mesh/listener/session.rs index 49e28662..b1b19b0a 100644 --- a/core/archipelago/src/mesh/listener/session.rs +++ b/core/archipelago/src/mesh/listener/session.rs @@ -39,6 +39,30 @@ impl MeshRadioDevice { } } + /// Provision the operator-configured LoRa region. Meshcore radios manage + /// their own band on the device, so this is a no-op for them; Meshtastic + /// radios ship region-UNSET (RF-silent) and must be set or they never mesh. + /// Returns `Ok(true)` when a region was written (the device reboots to + /// apply, so the caller should restart the session). + async fn ensure_lora_region(&mut self, region: Option<&str>) -> Result { + match self { + Self::Meshcore(_) => Ok(false), + Self::Meshtastic(device) => device.ensure_lora_region(region).await, + } + } + + /// Provision the shared archy primary channel so all nodes can decode each + /// other. No-op for meshcore (it joins its channel by name on the device); + /// Meshtastic radios can sit on mismatched channels otherwise and silently + /// drop every packet as undecryptable. Returns `Ok(true)` when a channel was + /// written (device reboots; caller should restart the session). + async fn ensure_channel(&mut self, channel_name: Option<&str>) -> Result { + match self { + Self::Meshcore(_) => Ok(false), + Self::Meshtastic(device) => device.ensure_channel(channel_name).await, + } + } + async fn send_self_advert(&mut self) -> Result<()> { match self { Self::Meshcore(device) => device.send_self_advert().await, @@ -46,6 +70,17 @@ impl MeshRadioDevice { } } + /// Actively advertise our identity over the air. Meshcore already does this + /// inside `send_self_advert` (CMD_SEND_SELF_ADVERT), so this is a no-op for + /// it; Meshtastic needs an explicit NodeInfo broadcast or peers never learn + /// about an already-running node. + async fn send_nodeinfo_advert(&mut self, want_response: bool) -> Result<()> { + match self { + Self::Meshcore(_) => Ok(()), + Self::Meshtastic(device) => device.send_nodeinfo_broadcast(want_response).await, + } + } + async fn send_channel_text(&mut self, channel: u8, payload: &[u8]) -> Result<()> { match self { Self::Meshcore(device) => device.send_channel_text(channel, payload).await, @@ -471,6 +506,23 @@ async fn sync_queued_messages( } } +/// How many times we will try to write the LoRa region across reconnects before +/// giving up. A healthy radio accepts it on the first try (the reboot-and-verify +/// resolves on the next session). A radio that silently refuses to persist +/// config — corrupt/full flash, managed mode, etc. — would otherwise reboot-loop +/// forever; after this many attempts we stop, log, and run without it. +const MAX_REGION_PROVISION_ATTEMPTS: u32 = 3; + +/// Process-global count of LoRa-region writes attempted (one radio per process). +/// Reset to 0 whenever the radio reports the desired region, so genuine later +/// drift re-provisions but a broken radio doesn't loop. +static REGION_PROVISION_ATTEMPTS: std::sync::atomic::AtomicU32 = + std::sync::atomic::AtomicU32::new(0); + +/// Same retry-cap idea as the region, for the shared-channel write. +static CHANNEL_PROVISION_ATTEMPTS: std::sync::atomic::AtomicU32 = + std::sync::atomic::AtomicU32::new(0); + /// Run a single mesh session (connect, initialize, main loop). pub(super) async fn run_mesh_session( state: &Arc, @@ -480,6 +532,8 @@ pub(super) async fn run_mesh_session( our_x25519_secret: &[u8; 32], our_x25519_pubkey_hex: &str, server_name: Option<&str>, + lora_region: Option<&str>, + channel_name: Option<&str>, shutdown: &mut tokio::sync::watch::Receiver, cmd_rx: &mut mpsc::Receiver, ) -> Result<()> { @@ -512,6 +566,73 @@ pub(super) async fn run_mesh_session( let _ = state.event_tx.send(MeshEvent::DeviceConnected(device_info)); + // Provision the LoRa region before anything else. A fresh Meshtastic radio + // is region-UNSET and therefore RF-silent — it can neither hear nor be + // heard, so contact discovery and DMs would all silently fail. If we write + // a new region the firmware reboots to apply it; restart the session so we + // re-handshake the freshly-rebooted radio (and then set its name on the + // reconnect, where the region already matches and no reboot occurs). + use std::sync::atomic::Ordering; + let region_attempts = REGION_PROVISION_ATTEMPTS.load(Ordering::Relaxed); + if region_attempts < MAX_REGION_PROVISION_ATTEMPTS { + match device.ensure_lora_region(lora_region).await { + Ok(true) => { + REGION_PROVISION_ATTEMPTS.fetch_add(1, Ordering::Relaxed); + info!( + region = lora_region.unwrap_or(""), + attempt = region_attempts + 1, + max = MAX_REGION_PROVISION_ATTEMPTS, + "Provisioned LoRa region — radio rebooting, restarting mesh session" + ); + // Give the radio time to reboot before the reconnect re-opens it. + tokio::time::sleep(Duration::from_secs(10)).await; + return Ok(()); + } + // Radio reports the desired region (or none configured): clear the + // attempt counter so a future genuine drift re-provisions cleanly. + Ok(false) => REGION_PROVISION_ATTEMPTS.store(0, Ordering::Relaxed), + Err(e) => warn!("Failed to provision LoRa region: {}", e), + } + } else if lora_region.is_some() { + warn!( + region = lora_region.unwrap_or(""), + attempts = MAX_REGION_PROVISION_ATTEMPTS, + "Radio did not persist the configured LoRa region after repeated \ + attempts — continuing without it. The radio likely needs a manual \ + factory reset / reflash; mesh discovery stays offline until its \ + region is set." + ); + } + + // Provision the shared primary channel (after the region, since both reboot + // the radio). Without a matching channel two same-region radios still can't + // decode each other's traffic. Same retry-cap + restart-on-change pattern. + let channel_attempts = CHANNEL_PROVISION_ATTEMPTS.load(Ordering::Relaxed); + if channel_attempts < MAX_REGION_PROVISION_ATTEMPTS { + match device.ensure_channel(channel_name).await { + Ok(true) => { + CHANNEL_PROVISION_ATTEMPTS.fetch_add(1, Ordering::Relaxed); + info!( + channel = channel_name.unwrap_or(""), + attempt = channel_attempts + 1, + max = MAX_REGION_PROVISION_ATTEMPTS, + "Provisioned shared mesh channel — radio rebooting, restarting mesh session" + ); + tokio::time::sleep(Duration::from_secs(10)).await; + return Ok(()); + } + Ok(false) => CHANNEL_PROVISION_ATTEMPTS.store(0, Ordering::Relaxed), + Err(e) => warn!("Failed to provision mesh channel: {}", e), + } + } else if channel_name.is_some() { + warn!( + channel = channel_name.unwrap_or(""), + attempts = MAX_REGION_PROVISION_ATTEMPTS, + "Radio did not persist the shared mesh channel after repeated \ + attempts — continuing without it; the radio may need a manual reset." + ); + } + // Set advert name to the server's human-readable name (e.g. "ThinkPad"), // falling back to the DID fragment if no name is configured. let advert_name = if let Some(name) = server_name { @@ -536,6 +657,13 @@ pub(super) async fn run_mesh_session( if let Err(e) = device.send_self_advert().await { warn!("Failed to send initial advert: {}", e); } + // Actively announce our identity over the air with want_response, so any + // already-running neighbour both learns about us and replies with its own + // NodeInfo — immediate two-way discovery instead of waiting for the radio's + // multi-hour NodeInfo cycle. (No-op for meshcore.) + if let Err(e) = device.send_nodeinfo_advert(true).await { + warn!("Failed to send initial NodeInfo advert: {}", e); + } // NOTE: Archipelago identity adverts (`ARCHY:2:{ed}:{x25519}`) are intentionally // NOT broadcast on the shared public channel (channel 0). Doing so spams every @@ -615,6 +743,13 @@ pub(super) async fn run_mesh_session( } else { consecutive_write_failures = 0; } + // Periodic over-air identity beacon (no want_response, to avoid + // reply storms) so peers that come online later still discover + // us between the radio's own infrequent NodeInfo broadcasts. + // No-op for meshcore (its self-advert above already goes out). + if let Err(e) = device.send_nodeinfo_advert(false).await { + debug!("Periodic NodeInfo advert failed: {}", e); + } // (Identity re-broadcast on the public channel intentionally // removed — see the note at session startup. It spammed the // shared channel every advert tick.) diff --git a/core/archipelago/src/mesh/meshtastic.rs b/core/archipelago/src/mesh/meshtastic.rs index 4b2ec14b..6a8d9c97 100644 --- a/core/archipelago/src/mesh/meshtastic.rs +++ b/core/archipelago/src/mesh/meshtastic.rs @@ -22,6 +22,10 @@ const START2: u8 = 0xc3; const TO_RADIO_MAX: usize = 512; const BROADCAST_NUM: u32 = 0xffff_ffff; const TEXT_MESSAGE_APP: u32 = 1; +/// Meshtastic PortNum for NodeInfo (identity) packets — used to actively +/// advertise ourselves over the air so neighbours discover us, the parity +/// equivalent of meshcore's self-advert. +const NODEINFO_APP: u32 = 4; /// Meshtastic PortNum for admin (config) packets. const ADMIN_APP: u32 = 6; /// AdminMessage.set_owner oneof field number (carries a `User`). @@ -37,9 +41,31 @@ const TO_RADIO_HEARTBEAT: u64 = 7; const FROM_RADIO_PACKET: u64 = 2; const FROM_RADIO_MY_INFO: u64 = 3; const FROM_RADIO_NODE_INFO: u64 = 4; +/// FromRadio.config (field 5): a `Config` block streamed during want_config. +const FROM_RADIO_CONFIG: u64 = 5; const FROM_RADIO_CONFIG_COMPLETE_ID: u64 = 7; const FROM_RADIO_REBOOTED: u64 = 8; +/// AdminMessage.set_config oneof field number (carries a `Config`). NB: 33 is +/// `set_channel` — `set_config` is 34 (verified against meshtastic/protobufs). +const ADMIN_SET_CONFIG_FIELD: u64 = 34; +/// AdminMessage.set_channel oneof field number (carries a `Channel`). +const ADMIN_SET_CHANNEL_FIELD: u64 = 33; +/// FromRadio.channel (field 10): a `Channel` streamed during want_config. +const FROM_RADIO_CHANNEL: u64 = 10; +/// Channel.role value for the PRIMARY channel (broadcasts ride here). +const CHANNEL_ROLE_PRIMARY: u64 = 1; +/// 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. +const LORA_USE_PRESET_FIELD: u64 = 1; +const LORA_REGION_FIELD: u64 = 7; +const LORA_HOP_LIMIT_FIELD: u64 = 8; +const LORA_TX_ENABLED_FIELD: u64 = 9; +/// RegionCode::UNSET — a radio in this state refuses to transmit or receive on +/// LoRa, so it can never mesh. Fresh-flashed radios ship UNSET. +const REGION_UNSET: u32 = 0; + /// Async Meshtastic device handle. pub struct MeshtasticDevice { port: serial2_tokio::SerialPort, @@ -57,6 +83,19 @@ pub struct MeshtasticDevice { /// records which peers are PKC-capable, so we can tell a true end-to-end /// (PKI) DM from a channel-PSK fallback. peer_pubkeys: HashMap>, + /// The radio's currently-configured LoRa region code, learned from the + /// `Config.lora` block during `initialize`. `None` until that frame is + /// seen; `Some(REGION_UNSET)` for a fresh radio that has never had a region + /// set (which means it is RF-silent). Used to decide whether we need to + /// provision the operator-configured region — and to avoid a reboot loop by + /// only writing when it actually differs. + current_region: Option, + /// 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 + /// 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)>, device_path: String, } @@ -84,6 +123,8 @@ impl MeshtasticDevice { short_name: None, contacts: HashMap::new(), peer_pubkeys: HashMap::new(), + current_region: None, + current_primary_channel: None, device_path: path.to_string(), }) } @@ -203,10 +244,207 @@ impl MeshtasticDevice { Ok(()) } + /// Ensure the radio is provisioned for the operator-configured LoRa region. + /// A freshly-flashed Meshtastic radio ships with `region = UNSET`, which + /// makes the firmware refuse to transmit or receive anything — so two such + /// radios can never see each other and the mesh appears empty. This is the + /// Meshtastic analog of how a meshcore radio comes up on its configured + /// band: archy brings every node onto the same region automatically. + /// + /// Returns `Ok(true)` when it actually wrote a new region (the device then + /// reboots to apply it, so the caller should restart the session). Returns + /// `Ok(false)` when no change was needed (already correct, no region + /// configured, or an unrecognised region string) — never reboot-loops. + pub async fn ensure_lora_region(&mut self, region: Option<&str>) -> Result { + let Some(region_str) = region else { + return Ok(false); + }; + let Some(code) = region_name_to_code(region_str) else { + warn!( + region = region_str, + "Unknown LoRa region in mesh-config — leaving radio region unchanged" + ); + return Ok(false); + }; + if code == REGION_UNSET { + // Operator explicitly asked for UNSET (or blank) — don't fight it. + return Ok(false); + } + match self.current_region { + Some(cur) if cur == code => Ok(false), + _ => { + self.set_lora_region(code).await?; + Ok(true) + } + } + } + + /// Write a LoRa region to the locally-connected radio via an + /// `AdminMessage { set_config: Config { lora: LoRaConfig { … } } }` on the + /// ADMIN_APP port — the same local-admin path `set_advert_name` uses (no + /// session passkey needed over serial). We send a minimal, valid preset + /// config: `use_preset` + `LONG_FAST` (the default modem preset), the + /// chosen `region`, a sane `hop_limit`, and `tx_enabled`. The firmware + /// reboots to apply the change. + pub async fn set_lora_region(&mut self, region_code: u32) -> Result<()> { + let Some(node_num) = self.node_num else { + anyhow::bail!("Meshtastic set_lora_region: node_num unknown"); + }; + + // LoRaConfig { use_preset(1)=true, region(7)=code, hop_limit(8)=3, + // tx_enabled(9)=true }. modem_preset defaults to LONG_FAST (0) and + // tx_power defaults to max, which is what we want for a stock mesh. + let mut lora = Vec::new(); + encode_varint_field_into(LORA_USE_PRESET_FIELD, 1, &mut lora); + encode_varint_field_into(LORA_REGION_FIELD, region_code as u64, &mut lora); + encode_varint_field_into(LORA_HOP_LIMIT_FIELD, 3, &mut lora); + encode_varint_field_into(LORA_TX_ENABLED_FIELD, 1, &mut lora); + + // Config { lora(6): LoRaConfig } + let mut config = Vec::new(); + encode_len_field(CONFIG_LORA_FIELD, &lora, &mut config); + + // AdminMessage { set_config(33): Config } + let mut admin = Vec::new(); + encode_len_field(ADMIN_SET_CONFIG_FIELD, &config, &mut admin); + + let packet = encode_mesh_packet(node_num, ADMIN_APP, &admin); + self.send_to_radio(&encode_to_radio_variant(TO_RADIO_PACKET, &packet)) + .await + .context("Failed to send Meshtastic set_config(LoRa region) admin packet")?; + + info!( + node_num, + region_code, "Set Meshtastic LoRa region (device will reboot to apply)" + ); + self.current_region = Some(region_code); + 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). + /// + /// 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. + pub async fn ensure_channel(&mut self, channel_name: Option<&str>) -> Result { + let Some(channel_name) = channel_name 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, + Some((name, psk)) if name == channel_name && psk == &desired_psk + ); + if already { + Ok(false) + } else { + self.set_channel(channel_name, &desired_psk).await?; + Ok(true) + } + } + + /// Write the PRIMARY channel 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<()> { + let Some(node_num) = self.node_num else { + anyhow::bail!("Meshtastic set_channel: node_num unknown"); + }; + + // ChannelSettings { psk(2), name(3) } + let mut settings = Vec::new(); + 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 } + let mut channel = Vec::new(); + encode_varint_field_into(1, 0, &mut channel); + encode_len_field(2, &settings, &mut channel); + encode_varint_field_into(3, CHANNEL_ROLE_PRIMARY, &mut channel); + + // AdminMessage { set_channel(33): Channel } + let mut admin = Vec::new(); + encode_len_field(ADMIN_SET_CHANNEL_FIELD, &channel, &mut admin); + + let packet = encode_mesh_packet(node_num, ADMIN_APP, &admin); + self.send_to_radio(&encode_to_radio_variant(TO_RADIO_PACKET, &packet)) + .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())); + Ok(()) + } + pub async fn send_self_advert(&mut self) -> Result<()> { self.send_to_radio(&encode_heartbeat()).await } + /// Build our own `User` protobuf (id/long_name/short_name) for a NodeInfo + /// advert. Returns `None` until the handshake has learned our identity. + fn build_self_user(&self) -> Option> { + let mut user = Vec::new(); + if let Some(id) = &self.user_id { + encode_len_field(1, id.as_bytes(), &mut user); + } + if let Some(long_name) = &self.long_name { + encode_len_field(2, long_name.as_bytes(), &mut user); + } + if let Some(short_name) = &self.short_name { + encode_len_field(3, short_name.as_bytes(), &mut user); + } + if user.is_empty() { + None + } else { + Some(user) + } + } + + /// Actively advertise our identity over the air by broadcasting a NodeInfo + /// packet (our `User`) on the primary channel. Meshtastic radios otherwise + /// only emit NodeInfo on boot and every few hours, so without this two + /// already-running nodes can sit forever without discovering each other. + /// This is the Meshtastic analog of meshcore's periodic self-advert. + /// + /// `want_response` solicits each neighbour to reply with its own NodeInfo — + /// use it on connect for immediate two-way discovery; leave it off for the + /// periodic beacon so a busy mesh doesn't trigger reply storms. + pub async fn send_nodeinfo_broadcast(&mut self, want_response: bool) -> Result<()> { + let Some(user) = self.build_self_user() else { + debug!("Meshtastic NodeInfo advert skipped — local identity not known yet"); + return Ok(()); + }; + + // Data { portnum(1)=NODEINFO_APP, payload(2)=User, want_response(3)? } + let mut data = Vec::new(); + encode_varint_field_into(1, NODEINFO_APP as u64, &mut data); + encode_len_field(2, &user, &mut data); + if want_response { + encode_varint_field_into(3, 1, &mut data); + } + + // MeshPacket { to(2)=BROADCAST (fixed32), decoded(4)=Data }. The firmware + // fills in `from` = our node-num when it transmits. + let mut packet = Vec::new(); + encode_fixed32_field(2, BROADCAST_NUM, &mut packet); + encode_len_field(4, &data, &mut packet); + + self.send_to_radio(&encode_to_radio_variant(TO_RADIO_PACKET, &packet)) + .await + .context("Failed to send Meshtastic NodeInfo broadcast")?; + debug!(want_response, "Broadcast Meshtastic NodeInfo advert"); + Ok(()) + } + pub async fn send_channel_text(&mut self, _channel: u8, msg: &[u8]) -> Result<()> { let text = String::from_utf8_lossy(msg); let packet = encode_mesh_packet(BROADCAST_NUM, TEXT_MESSAGE_APP, text.as_bytes()); @@ -339,12 +577,36 @@ impl MeshtasticDevice { return Ok(Some(frame)); } + // Drain aggressively. Meshtastic firmware interleaves verbose debug-log + // text with protobuf frames on the same serial line, so a single small + // read per poll can fall behind the byte stream, overflow the OS serial + // buffer, and corrupt/drop inbound frames — which silently kills message + // reception while leaving sends working. Pull up to a bounded burst of + // bytes per call, decoding as soon as a complete frame appears. let mut tmp = [0u8; READ_BUF_SIZE]; - match tokio::time::timeout(Duration::from_millis(50), self.port.read(&mut tmp)).await { - Ok(Ok(0)) => anyhow::bail!("Meshtastic serial port closed"), - Ok(Ok(n)) => self.read_buf.extend_from_slice(&tmp[..n]), - Ok(Err(e)) => return Err(e).context("Meshtastic serial read error"), - Err(_) => return Ok(None), + for _ in 0..32 { + match tokio::time::timeout(Duration::from_millis(30), self.port.read(&mut tmp)).await { + Ok(Ok(0)) => anyhow::bail!("Meshtastic serial port closed"), + Ok(Ok(n)) => { + self.read_buf.extend_from_slice(&tmp[..n]); + if let Some(frame) = decode_serial_frame(&mut self.read_buf) { + return Ok(Some(frame)); + } + // Bound memory if it's a pure-debug flood with no frames: + // keep only from the last possible frame-start marker. + if self.read_buf.len() > 64 * 1024 { + if let Some(pos) = + self.read_buf.windows(2).rposition(|w| w == [START1, START2]) + { + self.read_buf.drain(..pos); + } else { + self.read_buf.clear(); + } + } + } + Ok(Err(e)) => return Err(e).context("Meshtastic serial read error"), + Err(_) => break, // no more bytes available right now + } } Ok(decode_serial_frame(&mut self.read_buf)) @@ -352,8 +614,14 @@ impl MeshtasticDevice { fn handle_from_radio(&mut self, frame: &[u8]) -> Option { let Some((field, value)) = decode_top_level_variant(frame) else { + debug!( + len = frame.len(), + head = %hex::encode(&frame[..frame.len().min(8)]), + "Meshtastic FromRadio frame did not decode to a known top-level field" + ); return None; }; + debug!(field, value_len = value.len(), "Meshtastic FromRadio field"); match field { FROM_RADIO_MY_INFO => { if let Some((node_num, user_id)) = parse_my_info(value) { @@ -369,6 +637,22 @@ impl MeshtasticDevice { None } FROM_RADIO_PACKET => self.packet_to_inbound_frame(value), + 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) { + self.current_region = Some(region); + debug!(region, "Meshtastic LoRa region from device config"); + } + 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)); + } + None + } FROM_RADIO_CONFIG_COMPLETE_ID | FROM_RADIO_REBOOTED => None, other => { debug!( @@ -424,6 +708,12 @@ impl MeshtasticDevice { if Some(from) == self.node_num { return None; } + info!( + from = format!("!{:08x}", from), + len = packet.payload.len(), + pki = packet.pki_encrypted, + "Meshtastic received text packet over the air" + ); // Record E2E status: a `pki_encrypted` packet (or one carrying the // sender's `public_key`) proves this DM arrived end-to-end encrypted via // the PKI, not the shared channel PSK. We learn the sender's key here too @@ -504,6 +794,116 @@ fn encode_heartbeat() -> Vec { encode_to_radio_variant(TO_RADIO_HEARTBEAT, &[]) } +/// Extract `LoRaConfig.region` from a `Config` message, returning the region +/// 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 { + let mut idx = 0; + while idx < data.len() { + let (field, value, next) = next_field(data, idx)?; + idx = next; + if field == CONFIG_LORA_FIELD { + if let FieldValue::Bytes(b) = value { + let mut j = 0; + let mut region = REGION_UNSET; + 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; + } + } + } + return Some(region); + } + } + } + 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)> { + let mut role = 0u64; + let mut name = String::new(); + let mut psk = Vec::new(); + let mut idx = 0; + while idx < data.len() { + let (field, value, next) = next_field(data, idx)?; + idx = next; + match (field, value) { + (3, FieldValue::Varint(v)) => role = v, + (2, FieldValue::Bytes(b)) => { + let mut j = 0; + while j < b.len() { + let (sf, sv, sn) = next_field(b, j)?; + j = sn; + match (sf, sv) { + (2, FieldValue::Bytes(p)) => psk = p.to_vec(), + (3, FieldValue::Bytes(n)) => { + name = String::from_utf8_lossy(n).to_string() + } + _ => {} + } + } + } + _ => {} + } + } + if role == CHANNEL_ROLE_PRIMARY { + Some((name, psk)) + } else { + None + } +} + +/// Derive the 32-byte channel PSK deterministically from the channel name, so +/// every archy node configured with the same `channel_name` converges on the +/// exact same primary channel (identical hash) and meshes automatically. +fn derive_channel_psk(channel_name: &str) -> Vec { + use sha2::{Digest, Sha256}; + let mut hasher = Sha256::new(); + hasher.update(b"archipelago-mesh:"); + hasher.update(channel_name.as_bytes()); + hasher.finalize().to_vec() +} + +/// Map a Meshtastic `RegionCode` name (as set in `mesh-config.json`, e.g. +/// "EU_868", "US", "ANZ") to its protobuf enum value. Case-insensitive. +/// Returns `None` for an unrecognised name so we never write a bogus region. +fn region_name_to_code(name: &str) -> Option { + Some(match name.trim().to_uppercase().as_str() { + "UNSET" => 0, + "US" => 1, + "EU_433" => 2, + "EU_868" | "EU868" => 3, + "CN" => 4, + "JP" => 5, + "ANZ" => 6, + "KR" => 7, + "TW" => 8, + "RU" => 9, + "IN" => 10, + "NZ_865" => 11, + "TH" => 12, + "LORA_24" => 13, + "UA_433" => 14, + "UA_868" => 15, + "MY_433" => 16, + "MY_919" => 17, + "SG_923" => 18, + "PH_433" => 19, + "PH_868" => 20, + "PH_915" => 21, + "ANZ_433" => 22, + _ => return None, + }) +} + fn encode_to_radio_variant(field: u64, bytes: &[u8]) -> Vec { let mut out = Vec::new(); encode_len_field(field, bytes, &mut out); @@ -544,7 +944,11 @@ fn decode_top_level_variant(buf: &[u8]) -> Option<(u64, &[u8])> { } if matches!( field, - FROM_RADIO_PACKET | FROM_RADIO_MY_INFO | FROM_RADIO_NODE_INFO + FROM_RADIO_PACKET + | FROM_RADIO_MY_INFO + | FROM_RADIO_NODE_INFO + | FROM_RADIO_CONFIG + | FROM_RADIO_CHANNEL ) { return Some((field, &buf[idx..end])); } diff --git a/core/archipelago/src/mesh/mod.rs b/core/archipelago/src/mesh/mod.rs index 956dc27e..da0649e9 100644 --- a/core/archipelago/src/mesh/mod.rs +++ b/core/archipelago/src/mesh/mod.rs @@ -326,6 +326,14 @@ pub struct MeshConfig { /// Channel name for broadcasts. #[serde(default)] pub channel_name: Option, + /// Meshtastic LoRa region (e.g. "EU_868", "US", "ANZ"). Fresh-flashed + /// Meshtastic radios ship region-UNSET and are RF-silent until a region is + /// set, so archy provisions this region on connect to bring every node onto + /// the same band automatically (the parity equivalent of a meshcore radio + /// coming up on its configured band). Ignored for meshcore devices and when + /// unset/None. + #[serde(default)] + pub lora_region: Option, /// Whether to periodically broadcast our identity. #[serde(default)] pub broadcast_identity: bool, @@ -385,6 +393,7 @@ impl Default for MeshConfig { enabled: false, device_path: None, channel_name: Some("archipelago".to_string()), + lora_region: None, broadcast_identity: true, advert_name: None, mesh_only_mode: None, @@ -675,6 +684,8 @@ impl MeshService { self.our_x25519_secret, self.our_x25519_pubkey_hex.clone(), self.server_name.clone(), + self.config.lora_region.clone(), + self.config.channel_name.clone(), shutdown_rx, cmd_rx, ); diff --git a/neode-ui/public/catalog.json b/neode-ui/public/catalog.json index 86656b4f..f6b4f8ee 100644 --- a/neode-ui/public/catalog.json +++ b/neode-ui/public/catalog.json @@ -214,31 +214,6 @@ ] } }, - { - "id": "meshtastic", - "title": "Meshtastic", - "version": "2-daily-alpine", - "description": "Open-source mesh networking for LoRa radios. Create decentralized communication networks.", - "icon": "/assets/img/app-icons/meshcore.svg", - "author": "Meshtastic", - "category": "networking", - "tier": "recommended", - "dockerImage": "docker.io/meshtastic/meshtasticd:daily-alpine", - "repoUrl": "https://github.com/meshtastic/firmware", - "containerConfig": { - "ports": [ - "4403:4403" - ], - "volumes": [ - "/var/lib/archipelago/meshtastic:/var/lib/meshtasticd" - ], - "env": [ - "MESHTASTIC_PORT=/dev/ttyUSB0", - "MESHTASTIC_SERIAL=true" - ], - "notes": "Requires a LoRa radio device at /dev/ttyUSB0. The config file is rendered from the app manifest before container start." - } - }, { "id": "vaultwarden", "title": "Vaultwarden", diff --git a/releases/app-catalog.json b/releases/app-catalog.json index 1f02e4e3..790862a6 100644 --- a/releases/app-catalog.json +++ b/releases/app-catalog.json @@ -3046,90 +3046,6 @@ } } }, - "meshtastic": { - "version": "2-daily-alpine", - "manifest": { - "app": { - "id": "meshtastic", - "name": "Meshtastic", - "version": "2-daily-alpine", - "description": "Open-source mesh networking for LoRa radios. Create decentralized communication networks.", - "container": { - "image": "docker.io/meshtastic/meshtasticd:daily-alpine", - "pull_policy": "if-not-present" - }, - "dependencies": [ - { - "storage": "1Gi" - } - ], - "resources": { - "cpu_limit": 1, - "memory_limit": "512Mi", - "disk_limit": "1Gi" - }, - "security": { - "capabilities": [ - "NET_ADMIN", - "SYS_ADMIN" - ], - "readonly_root": false, - "no_new_privileges": true, - "user": 1000, - "seccomp_profile": "default", - "network_policy": "host", - "apparmor_profile": "meshtastic" - }, - "ports": [ - { - "host": 4403, - "container": 4403, - "protocol": "tcp" - } - ], - "devices": [ - "/dev/ttyUSB0" - ], - "volumes": [ - { - "type": "bind", - "source": "/var/lib/archipelago/meshtastic", - "target": "/var/lib/meshtasticd", - "options": [ - "rw" - ] - } - ], - "files": [ - { - "path": "/var/lib/archipelago/meshtastic/config.yaml", - "content": "General:\n MACAddress: AA:BB:CC:DD:EE:01\nWebserver:\n Port: 4403\n" - } - ], - "environment": [ - "MESHTASTIC_PORT=/dev/ttyUSB0", - "MESHTASTIC_SERIAL=true" - ], - "health_check": { - "type": "cmd", - "endpoint": "test -f /var/lib/meshtasticd/config.yaml", - "interval": "30s", - "timeout": "30s", - "retries": 5 - }, - "networking": { - "mesh_enabled": true, - "local_network_access": true - }, - "metadata": { - "icon": "/assets/img/app-icons/meshcore.svg", - "category": "networking", - "tier": "recommended", - "repo": "https://github.com/meshtastic/firmware" - } - } - } - }, "morphos-server": { "version": "1.0.0", "manifest": { diff --git a/tests/lifecycle/remote-lifecycle.sh b/tests/lifecycle/remote-lifecycle.sh index 1eb9fa0b..2dfd0ba6 100755 --- a/tests/lifecycle/remote-lifecycle.sh +++ b/tests/lifecycle/remote-lifecycle.sh @@ -157,7 +157,6 @@ image_for() { indeedhub) echo "146.59.87.168:3000/lfg2025/indeedhub:1.0.0" ;; botfights) echo "146.59.87.168:3000/lfg2025/botfights:1.1.0" ;; gitea) echo "docker.io/gitea/gitea:1.23" ;; - meshtastic) echo "docker.io/meshtastic/meshtasticd:daily-alpine" ;; *) return 1 ;; esac }