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:
co-authored by
Claude Opus 4.8
parent
b4531bb4fc
commit
fbfeeeb0f5
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user