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
+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()