fix(mesh): restore Meshtastic inbound stream after radio reboot

archy went deaf to inbound LoRa packets after every config write.
A config write (region/channel/owner) reboots the radio, which resets
the firmware PhoneAPI to STATE_SEND_NOTHING; it won't stream received
packets again until the client re-sends want_config. archy ignored
FromRadio.rebooted (field 8) so never resubscribed — which is why old
messages only arrived after a full restart (restart = fresh want_config).

- meshtastic.rs: handle FROM_RADIO_REBOOTED -> set pending_reinit;
  try_recv_frame re-sends want_config to resubscribe the packet stream.
  Add send_keepalive (bare heartbeat) and pin modem_preset=LONG_FAST in
  set_lora_region so all radios share frequency.
- listener/session.rs: MeshRadioDevice::send_keepalive; 10s sync_timer
  sends a keepalive each tick (insurance vs 15-min idle serial close).
- mod.rs send_message: device-aware send — Meshtastic archy peers get a
  plain TEXT_MESSAGE_APP DM (firmware PKC E2E); Meshcore archy peers keep
  the typed envelope (no meshcore regression).

Verified: .198->.228 directed DM arrives as RECEIVED enc=True
peer="Arch Optiplex"; all 3 nodes (.116/.198/.228) + 3ccc hear each
other. Binary 737b16c3 deployed+active on all three.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-06-30 12:44:31 -04:00
co-authored by Claude Opus 4.8
parent fbfeeeb0f5
commit a57ae388ec
3 changed files with 115 additions and 22 deletions
+39 -16
View File
@@ -1542,21 +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;
// 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 device_type = self.state.status.read().await.device_type;
let archy = self.is_archy_peer(contact_id).await;
// Transport choice is DEVICE-AWARE so we fix Meshtastic without regressing
// Meshcore:
// • Meshtastic (any peer) → plain text native DM on TEXT_MESSAGE_APP. The
// firmware end-to-end (PKC/Curve25519) encrypts a directed DM to any
// peer whose public key it knows (archy peers exchange them via
// NodeInfo), so it's delivered E2E and shows as chat on every client.
// Meshtastic firmware 2.7.x will NOT deliver our opaque binary typed
// envelope as a message (PRIVATE_APP is opaque app-data; a base64
// envelope overflows one LoRa frame and chunk-fails) — wrapping text
// is exactly what silently broke archy↔archy Meshtastic LoRa.
// • Meshcore archy peer → keep the rich signed typed envelope. Meshcore
// frames are binary-safe (no UTF-8 mangling) and it carries its own
// session E2E + our signature for `!ai` auth / seq reply addressing,
// so the envelope works there and we must not drop it.
// • Meshcore stock client → plain text (can't decode our envelope).
// Rich typed messages (invoice/coordinate/reaction/…) always use the
// typed-wire path via `send_typed_wire`; only plain Text is routed here.
let use_typed_envelope = archy && device_type == DeviceType::Meshcore;
if use_typed_envelope {
// Sign with our archipelago identity so the receiver can authenticate
// us over LoRa (verifies against our bound `arch_pubkey_hex`). `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()?;
return self
.send_typed_wire(contact_id, wire, "text", text, None, seq)
.await;
}
let dest_prefix = self.peer_dest_prefix(contact_id).await?;
self.state
.send_cmd(listener::MeshCommand::SendNativeText {
@@ -1569,7 +1593,6 @@ impl MeshService {
// 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,
@@ -1578,7 +1601,7 @@ impl MeshService {
None,
seq,
Some("lora".to_string()),
e2e,
archy,
)
.await)
}