fix(mesh): native E2E DM for archy↔archy text + software radio-reboot

- send_message now sends archy↔archy plain text as a native TEXT_MESSAGE_APP
  DM (firmware PKC-encrypts E2E), not wrapped in the binary typed envelope
  that silently broke archy↔archy LoRa delivery. Archy peers' Sent rows are
  marked encrypted so the E2E pill shows; rich typed msgs still use the
  typed-wire path.
- Add a software radio-reboot to recover a wedged/RX-deaf radio without
  physical access (and for the Device-tab settings panel): driver reboot()
  via AdminMessage reboot_seconds=97 (verified vs meshtastic/protobufs),
  MeshCommand::RebootRadio, MeshService::reboot_radio, RPC mesh.reboot-radio.
- Handoff doc: docs/SESSION-1.8.0-OTA-PROGRESS.md "RESUME HERE" — RF link is
  the proven blocker (radios not hearing each other); modem_preset mismatch
  is the prime suspect; on-device Meshtastic-app check + fix plan documented.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-06-30 10:39:34 -04:00
co-authored by Claude Opus 4.8
parent b4531bb4fc
commit fbfeeeb0f5
8 changed files with 312 additions and 67 deletions
@@ -366,6 +366,7 @@ impl RpcHandler {
"mesh.send" => self.handle_mesh_send(params).await,
"mesh.send-channel" => self.handle_mesh_send_channel(params).await,
"mesh.broadcast" => self.handle_mesh_broadcast().await,
"mesh.reboot-radio" => self.handle_mesh_reboot_radio(params).await,
"mesh.configure" => self.handle_mesh_configure(params).await,
"mesh.send-invoice" => self.handle_mesh_send_invoice(params).await,
"mesh.send-coordinate" => self.handle_mesh_send_coordinate(params).await,
@@ -86,6 +86,29 @@ impl RpcHandler {
Ok(serde_json::json!({ "broadcast": true }))
}
/// mesh.reboot-radio — Reboot the locally-connected radio firmware to
/// recover a wedged / RX-deaf radio. Optional `seconds` delay (default 2).
pub(in crate::api::rpc) async fn handle_mesh_reboot_radio(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let seconds = params
.as_ref()
.and_then(|p| p.get("seconds"))
.and_then(|v| v.as_i64())
.unwrap_or(2);
let service = self.mesh_service.read().await;
let svc = service
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Mesh service not running. Enable mesh first."))?;
svc.reboot_radio(seconds).await?;
info!(seconds, "Mesh radio reboot requested via RPC");
Ok(serde_json::json!({ "reboot": true, "seconds": seconds }))
}
/// mesh.configure — Enable/disable mesh and set device path.
pub(in crate::api::rpc) async fn handle_mesh_configure(
&self,
@@ -77,6 +77,11 @@ pub enum MeshCommand {
payload: Vec<u8>,
},
SendAdvert,
/// Reboot the locally-connected radio firmware to recover a wedged /
/// RX-deaf radio. Meshtastic-only; meshcore ignores it.
RebootRadio {
seconds: i64,
},
/// Re-fetch contact list from the radio device.
RefreshContacts,
/// Delete a contact from the firmware table (clear-all / unreachable wipe).
@@ -96,6 +96,15 @@ impl MeshRadioDevice {
}
}
async fn reboot(&mut self, seconds: i64) -> Result<()> {
match self {
// Meshcore has no equivalent local-admin reboot in our driver; the
// RX-deaf recovery this targets is Meshtastic-specific.
Self::Meshcore(_) => Ok(()),
Self::Meshtastic(device) => device.reboot(seconds).await,
}
}
async fn remove_contact(&mut self, pubkey: &[u8; 32]) -> Result<()> {
match self {
Self::Meshcore(device) => device.remove_contact(pubkey).await,
@@ -901,6 +910,13 @@ async fn handle_send_command(
*consecutive_write_failures = 0;
}
}
MeshCommand::RebootRadio { seconds } => {
if let Err(e) = device.reboot(seconds).await {
warn!("Failed to reboot radio: {}", e);
} else {
info!(seconds, "Radio reboot command sent to device");
}
}
MeshCommand::RefreshContacts => {
refresh_contacts(device, state).await;
}
+32
View File
@@ -59,6 +59,10 @@ const FROM_RADIO_MAX: usize = 4096;
const ADMIN_SET_CONFIG_FIELD: u64 = 34;
/// AdminMessage.set_channel oneof field number (carries a `Channel`).
const ADMIN_SET_CHANNEL_FIELD: u64 = 33;
/// AdminMessage.reboot_seconds oneof field number (int32). Verified against
/// meshtastic/protobufs admin.proto: `reboot_seconds = 97` (NOT 40 — the
/// payload_variant numbers jump after the setters).
const ADMIN_REBOOT_SECONDS_FIELD: u64 = 97;
/// FromRadio.channel (field 10): a `Channel` streamed during want_config.
const FROM_RADIO_CHANNEL: u64 = 10;
const FROM_RADIO_QUEUE_STATUS: u64 = 11;
@@ -383,6 +387,34 @@ impl MeshtasticDevice {
Ok(())
}
/// Reboot the locally-connected radio via `AdminMessage { reboot_seconds }`
/// on the ADMIN_APP port — the same local-admin path `set_advert_name` /
/// `set_lora_region` use (no session passkey needed over serial). The
/// firmware reboots after `seconds`, which clears a wedged / RX-deaf radio
/// (a radio that has stopped hearing the mesh while still transmitting) and
/// re-runs its LoRa init. The listener's reboot→reconnect loop reopens the
/// serial link when it comes back.
pub async fn reboot(&mut self, seconds: i64) -> Result<()> {
let Some(node_num) = self.node_num else {
anyhow::bail!("Meshtastic reboot: node_num unknown");
};
// AdminMessage { reboot_seconds(97): int32 }. We only ever pass a small
// positive delay, which encodes as a plain varint.
let mut admin = Vec::new();
encode_varint_field_into(ADMIN_REBOOT_SECONDS_FIELD, seconds as u64, &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 reboot admin packet")?;
info!(
node_num,
seconds, "Sent Meshtastic radio reboot (device will reboot to recover)"
);
Ok(())
}
/// 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)
+57 -44
View File
@@ -1542,52 +1542,45 @@ impl MeshService {
/// MeshMessage carries a stable MessageKey — this is what makes replies
/// and reactions addressable against plain text bubbles.
pub async fn send_message(&self, contact_id: u32, text: &str) -> Result<MeshMessage> {
use crate::mesh::message_types::{MeshMessageType, TypedEnvelope};
let seq = self.state.next_send_seq(contact_id).await;
// Stock (non-archipelago) radio contacts — e.g. a phone running the
// MeshCore app — can't decode our typed envelope and would render it as
// garbled bytes. Send them the raw text as a plain native DM instead.
// Archipelago peers still get the typed envelope (seq/reply/reaction
// addressing + encryption).
if !self.is_archy_peer(contact_id).await {
let dest_prefix = self.peer_dest_prefix(contact_id).await?;
self.state
.send_cmd(listener::MeshCommand::SendNativeText {
dest_pubkey_prefix: dest_prefix,
payload: text.as_bytes().to_vec(),
})
.await
.map_err(|_| anyhow::anyhow!("Mesh listener not running"))?;
return Ok(self
.record_sent_typed(
contact_id,
"text",
text,
None,
seq,
Some("lora".to_string()),
false,
)
.await);
}
// Sign the envelope with our archipelago identity key so the receiver
// can authenticate us over LoRa (it verifies against our bound
// `arch_pubkey_hex`). This is what lets a `!ai` typed in chat to a
// trusted node pass the receiver's `trusted_only` gate over the radio —
// an unsigned radio packet can never authenticate. The signature is
// optional on the wire and ignored by peers that don't know our key, so
// it stays backward compatible. (Federation/Tor sends already sign in
// `send_typed_wire_via_federation`.) `with_seq` is applied after signing
// — seq is not covered by the signature.
let envelope = TypedEnvelope::new_signed(
MeshMessageType::Text,
text.as_bytes().to_vec(),
&self.signing_key,
)
.with_seq(seq);
let wire = envelope.to_wire()?;
self.send_typed_wire(contact_id, wire, "text", text, None, seq)
// Plain chat text — to BOTH archy peers and stock devices — is sent as a
// native Meshtastic DM on TEXT_MESSAGE_APP. The firmware end-to-end
// (PKC / Curve25519) encrypts a directed DM whenever it knows the
// destination's public key, which archy peers exchange via NodeInfo, so
// the message is delivered E2E and surfaces as chat on every client.
//
// We deliberately do NOT wrap archy↔archy text in our binary typed
// envelope here. Meshtastic firmware 2.7.x will not deliver an opaque
// directed payload as a message: PRIVATE_APP is treated as opaque app
// data (never shown as chat), and a base64 envelope overflows a single
// LoRa frame and chunk-fails. Wrapping text was exactly what silently
// broke archy↔archy LoRa while archy→stock (plain text) kept working.
// Rich typed messages (invoice/coordinate/reaction/…) still use the
// typed-wire path via `send_typed_wire`; only plain Text goes native.
let dest_prefix = self.peer_dest_prefix(contact_id).await?;
self.state
.send_cmd(listener::MeshCommand::SendNativeText {
dest_pubkey_prefix: dest_prefix,
payload: text.as_bytes().to_vec(),
})
.await
.map_err(|_| anyhow::anyhow!("Mesh listener not running"))?;
// The firmware PKI-encrypts a directed DM to any peer whose key it knows;
// archy peers always exchange keys, so mark those Sent rows E2E so the
// pill shows immediately. (The receiver independently stamps E2E from the
// radio's `pki_encrypted` flag, so an inbound row is accurate regardless.)
let e2e = self.is_archy_peer(contact_id).await;
Ok(self
.record_sent_typed(
contact_id,
"text",
text,
None,
seq,
Some("lora".to_string()),
e2e,
)
.await)
}
/// Whether `contact_id` is an archipelago peer (vs a stock meshcore client).
@@ -1724,6 +1717,26 @@ impl MeshService {
Ok(())
}
/// Reboot the locally-connected radio firmware to recover a wedged /
/// RX-deaf radio (one that has stopped hearing the mesh while still able to
/// transmit). The device reconnects via the listener's reboot→reconnect
/// loop. `seconds` is the firmware reboot delay.
pub async fn reboot_radio(&self, seconds: i64) -> Result<()> {
let status = self.state.status.read().await;
if !status.device_connected {
anyhow::bail!("No mesh device connected. Check USB connection.");
}
drop(status);
self.state
.send_cmd(listener::MeshCommand::RebootRadio { seconds })
.await
.map_err(|_| anyhow::anyhow!("Mesh listener not running"))?;
info!(seconds, "Mesh radio reboot triggered");
Ok(())
}
/// Current mesh-AI assistant settings (issue #50).
pub async fn assistant_config(&self) -> listener::AssistantConfig {
self.state.assistant.read().await.clone()