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
parent b4531bb4fc
commit fbfeeeb0f5
8 changed files with 312 additions and 67 deletions

View File

@ -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,

View File

@ -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,

View File

@ -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).

View File

@ -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;
}

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)

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

View File

@ -979,20 +979,34 @@ this match.
- Reference: the existing `package-install-prune-check` dependency descriptor (dependencies.rs:208)
is the seam to make data-driven.
## 10d. Mesh — Meshtastic MeshCore-parity (in the fleet binary; one open bug) (2026-06-26)
## 10d. Mesh — Meshtastic MeshCore-parity (active blocker: stock 3ccc LoRa text) (2026-06-30)
**Status: shipped as commit `8fdb45e8` and now riding in the rolled fleet binary** (built into the
#9 deploy from HEAD, sha `0060dcd6…`). The Meshtastic driver auto-provisions LoRa **region (EU_868)**
and a shared **channel "archipelago"** via the official admin API (`set_config`=field34,
`set_channel`=field33) — discovery, bidirectional RF, and **sending** are all verified on **.116 + .228**.
Detail + history: [[project_meshtastic_parity]].
**Current deployed canary:** `.116` is running commit `b4531bb4` with backend sha
`4ab53e539d89679ef664401a9a57996267772fed02327abc2912c3e77543acbf` and frontend bundle
`index-YOAeJF7w.js` / `Mesh-BSAo88jN.js`. `main` was pushed to `gitea-vps2`.
**Open work (slot after WS-F #911, before/with multinode):**
- **RECEIVED-message surfacing bug** — the running driver does **not** surface received messages
(`mesh.messages` stays `[]`) even though the radio physically receives them. An instrumentation
build was in flight to locate where the inbound packet is dropped between the radio serial/BLE read
and the `mesh.messages` store. This is the one blocker to closing MeshCore parity.
- **.198 radio is bad** — won't persist config (needs a reflash) so it's not a usable mesh test node;
use .116/.228 for mesh verification.
- Definition of done: a message sent from a MeshCore/Meshtastic peer on channel "archipelago" appears
in `mesh.messages` on the receiving archipelago node, end-to-end, on ≥2 LAN nodes.
**What is fixed in this deployed canary:**
- Public stock Meshtastic interop is intentional: slot 0 PRIMARY is the public default LongFast
channel (`name=""`, default PSK); slot 1 SECONDARY is `archipelago`.
- Outgoing Meshtastic messages to stock peer `3ccc` are recorded with real 2026 timestamps and
`transport:"lora"` in RPC. The Mesh UI label maps `lora` to **LoRa**, not "Mesh".
- Post-send message refresh now polls briefly so FIPS/Tor/LoRa pills do not require a manual browser
refresh.
- Off-grid mode now blocks the mesh-chat federation fallback path as well as the generic transport
router: when enabled it forces LoRa-only sends and the UI banner reads
`Tor/FIPS disabled - LoRa only`.
- Empty mesh-chat placeholder opacity was reduced.
**Still broken / resume here:**
- Stock Meshtastic peer `3ccc` -> `.116` LoRa text still does **not** surface in `mesh.messages`.
- Live `.116` logs prove bytes arrive from 3ccc, but the custom Meshtastic protobuf parser rejects
the packet before it becomes an inbound frame:
`Meshtastic FromRadio.packet did not parse into a decoded MeshPacket len=73 head=0dcc3c3e43153ca5b5432a16df56cbed`.
- 3ccc NodeInfo is discovered and PKC-capable:
`Meshtastic peer is PKC-capable (NodeInfo public_key) node=1128152268 key_len=32`.
- Other received packets are decoded and intentionally ignored as non-text (`portnum=3/4/5`), so
the serial reader is alive; the remaining blocker is the exact `MeshPacket` shape for stock
Meshtastic text.
- Definition of done: a new text sent from stock Meshtastic `3ccc` appears in `.116`
`mesh.messages` as an incoming LoRa message without a browser refresh, and `.116` -> `3ccc`
visibly arrives in the Meshtastic app.

View File

@ -1,6 +1,128 @@
# 1.8.0 OTA Session Progress
Updated: 2026-06-29
Updated: 2026-06-30
---
## ▶️ RESUME HERE — archy↔archy LoRa (2026-06-30 PM) — READ FIRST
**Goal:** archy↔archy text over Meshtastic LoRa must DELIVER and show the E2E pill,
identical in off-grid and normal mode. Test bed = `.116` / `.198` / `.228` (all EU_868).
Don't touch the federation/FIPS path.
### TL;DR of where we are
The **archy software is correct and deployed.** The blocker is now PROVEN to be at the
**radio/RF layer: the three radios are not hearing each other over the air at all.** No
amount of archy code change will fix that until the radios actually RF-link. **Resume by
testing the radios directly at home (Meshtastic phone app over Bluetooth) — see "DO THIS
FIRST AT HOME" below.**
### What is DONE and deployed (commit pending — see below)
- **E2E send fix** (`core/archipelago/src/mesh/mod.rs` `send_message`, ~L1542): archy↔archy
plain chat text is now sent as a **native `TEXT_MESSAGE_APP` DM** (firmware PKC-encrypts
it E2E), NOT wrapped in our binary typed envelope. Archy peers' Sent rows are marked
`encrypted=true` so the pill shows. Rich typed msgs still use `send_typed_wire`. This was
the original root-cause fix (envelope-wrapped text silently broke archy↔archy LoRa).
- **NEW: software radio-reboot** end-to-end, so a wedged/RX-deaf radio can be rebooted
without physical access (and for the Device-tab settings panel the user requested):
- `meshtastic.rs`: `reboot(seconds)` driver method + `ADMIN_REBOOT_SECONDS_FIELD = 97`
(verified vs meshtastic/protobufs admin.proto — `set_owner=32/set_channel=33/set_config=34`
matched our existing constants, confirming the proto read).
- `listener/mod.rs`: `MeshCommand::RebootRadio { seconds }`.
- `listener/session.rs`: device-enum `reboot()` dispatch (Meshtastic only) + handler arm.
- `mesh/mod.rs`: `MeshService::reboot_radio(seconds)`.
- `api/rpc/mesh/messaging.rs`: `handle_mesh_reboot_radio` → RPC **`mesh.reboot-radio`**
`{seconds?}` (default 2); dispatcher arm in `api/rpc/dispatcher.rs`.
- `cargo check` passes. Built release **sha `ba4aed590027690d`** and DEPLOYED + active on
`.116/.198/.228`. The RPC works (`{"reboot":true,"seconds":2}`).
- ⚠️ **Caveat:** when called, archy logged "Sent Meshtastic radio reboot" but the radio did
**not** visibly reboot afterward (no config re-stream). Either field 97 is still off, or
newer firmware requires an admin session passkey even over local serial, or the USB serial
stayed open through the 2s reboot so no reconnect was logged. **Needs on-device verification.**
### The hard evidence (why "nothing works")
- Directed DM tests `.198→.228` AND `.116→.228` (neither path reflashed): sender logs
`Sent plain native DM dest=30d258436d65 part=1 total=1` and RPC returns `sent:true,
encrypted:true`, but `.228` logs **nothing** — packet never reaches archy from the radio.
- A raw broadcast from `.198` (`mesh.broadcast`) was accepted by its radio but **not heard**
by `.228`/`.116`.
- In an 8-minute window, **all three nodes received 0 inbound OTA packets from any other node.**
Each only logs its OWN once-a-minute `Broadcast Meshtastic NodeInfo advert` + local TX
`field=11` queue-status. `.228 mesh.status` = `messages_received:1` total.
- `.198`'s radio is alive and transmitting NodeInfo every 60s — so it's not dead; it's that
**reception is broken on the receivers.** A radio cannot drop a broadcast AND a unicast to
its own node number while config matches, unless it simply isn't on the same airwaves.
- archy provisioning is correct & identical across nodes (read back from device): PRIMARY =
public LongFast (`name="" psk_len=1`), SECONDARY = `archipelago`, region=3 (EU_868). Admin
field constants verified. The send path hands the radio a correct unicast MeshPacket
(`to`=node, want_ack, hop_limit=3, plaintext `decoded` for the firmware to PKC-encrypt).
### PRIME SUSPECT (software-fixable) — modem-preset / frequency mismatch
archy only ever writes `region` + `use_preset` and **never explicitly pins `modem_preset`**
(it parses region but not preset; `set_lora_region` relies on the LongFast default). If ANY
radio has a non-default modem preset / frequency slot persisted (e.g. set via the Meshtastic
app, or a different factory default after the `.198` reflash), the radios are on **different
airwaves despite identical channel name + region**, and archy would never correct it.
### DO THIS FIRST AT HOME (decisive, ~2 min, only the user can do it)
Open the **Meshtastic phone app over Bluetooth** (works alongside archy's USB serial) on each
of `.116/.198/.228` and check:
1. Do the 3 nodes **see each other** in the node list (recent "heard")? → if NO, they're not
RF-reaching (preset/freq/antenna/range).
2. Do all 3 show the **same** Modem preset (LongFast), Region (EU_868), Frequency slot, and
the same PRIMARY channel? → any difference = the cause.
This single test separates "archy misconfigures the radios" from "radios physically can't
reach each other."
### THEN — the archy fix to apply (if preset/config differs)
Make archy **authoritatively write the full LoRaConfig** and force re-provision so all radios
converge: in `core/archipelago/src/mesh/meshtastic.rs::set_lora_region` (and its
caller/guard `ensure_lora_region` ~L304), explicitly set `modem_preset = LONG_FAST (0)` as a
field in the LoRaConfig (it's currently omitted/defaulted), and make the startup provision
path rewrite LoRa config when the preset doesn't match, then reboot the radio (use the new
`mesh.reboot-radio`). Also verify the `mesh.reboot-radio` actually reboots the radio
on-device (the caveat above).
### TEST RECIPE (works on each node)
- RPC helper used this session: a node-side `rpc.sh` that logs in (password
`ThisIsWeb54321@`), grabs the `csrf_token` cookie, echoes it as `X-CSRF-Token`, and POSTs to
`http://127.0.0.1:5678/rpc/v1`. Recreate it or run archy's RPC directly. Methods:
`mesh.peers`, `mesh.status`, `mesh.messages`, `mesh.send {contact_id,message}`,
`mesh.broadcast`, `mesh.reboot-radio {seconds}`.
- **LoRa contact ids:** `.116=1135977788` (prefix `3ca5b543`), `.198=3677050140` (`db2b551c`),
`.228=1129894448` (prefix `30d25843`), stock `3ccc=1128152268`.
- **Link health check (run on each node):** look for inbound `from=Some("!...")` lines in
`journalctl -u archipelago` that are NOT the node's own `Broadcast ... NodeInfo advert`. If
zero across all nodes → RF link is down (the current state).
- **E2E success criteria:** send `.198→.228`, the marker appears in `.228` `mesh.messages` as
an inbound row with `encrypted:true` / `transport:"lora"`, AND `.116↔.228` likewise.
### DEPLOY / BUILD RECIPE
- Build: from `core/`, `CARGO_TARGET_DIR=/tmp/archy-hotfix-target CARGO_INCREMENTAL=0 cargo
build --release -p archipelago --bin archipelago`. (If `rust-lld: undefined hidden symbol`,
it's incremental cache — `CARGO_INCREMENTAL=0` fixes it.)
- SSH key `~/.ssh/archipelago-deploy` is authorized on `.116/.198/.228`. SSH/UI/RPC password
`ThisIsWeb54321@`. Per node: scp the binary, `sudo systemctl stop archipelago`
`kill -9 $(pgrep -x archipelago)``install -m0755` to `/usr/local/bin/archipelago`
`systemctl start archipelago`. Verify by `sha256sum` match + `systemctl is-active`.
- **Current deployed sha on all 3 = `ba4aed590027690d`** (the reboot-enabled build).
### Fleet state (as of 2026-06-30 PM)
- All 3 nodes on binary `ba4aed59`, active. Off-grid mode currently OFF (`mesh_only:false`).
- `.198` radio was reflashed to factory `firmware-heltec-v3-2.7.26` (recovered from corrupt
NVS); region EU_868 persists. Its archy identity is NOT re-bound on `.228` (`.228` shows
`.198` as raw radio "Meshtastic 551c", `arch_pubkey_hex` absent) because `.228` hasn't heard
`.198`'s identity broadcast — a downstream symptom of the dead RF link, not a separate bug.
- The radios are powered & each transmitting; they are simply not hearing each other.
### Deferred UI (after LoRa works)
- Device-tab **settings panel** (gear/desktop) — host the "Reboot radio" button there; calls
`mesh.reboot-radio`. Scoping done: add to the Mesh.vue actions row (mirrors Broadcast/Off-Grid
buttons) + a `rebootRadio()` method in `neode-ui/src/stores/mesh.ts`. See `Mesh.vue` ~L1484
actions row and `mesh.ts` ~L373 `broadcastIdentity()` pattern.
- Device-onboarding modal (detect plugged-in radio).
---
Current scope:
- Preserve existing mesh work: E2E indicators, FIPS/Tor transport indicators, typed-message paths, Meshtastic region/channel provisioning, and dirty Meshtastic receive-attempt changes.
@ -35,11 +157,30 @@ Do not discard:
- That would make live `3ccc` packets look older than 10 minutes and get dropped before `mesh.messages`.
- Current patch treats implausibly early `rx_time` values as unknown rather than stale.
.116 live validation:
.116 live validation after 2026-06-30 hotfix:
- `.116` reachable by SSH; `archipelago` active; `/dev/mesh-radio -> ttyUSB0` attached.
- Recent logs show repeated `FromRadio.queueStatus` frames (`field 11`, bytes like `5a04100e1810`) being rejected by the serial frame prevalidator as invalid payloads.
- Current patch accepts `FromRadio.queueStatus` as a valid ignored frame so non-message status frames no longer look like corrupt serial data.
- Focused Meshtastic tests: green, 7/7.
- Updated patch deployed to `.116` as binary sha `028ec6ff9a60ca8970c081987457d78ed1c517cd81f7089f51b9a01745b5c3c4`.
- After redeploy, logs show `FromRadio field=11` accepted and no new `Dropping stale ... !433e3ccc` entries in the checked post-deploy window.
- There are stale other-agent shell watcher processes on `.116` referencing `RXDIAG`; leave alone unless they interfere.
- Current canary deploy is commit `b4531bb4`; backend sha
`4ab53e539d89679ef664401a9a57996267772fed02327abc2912c3e77543acbf`; frontend bundle
`index-YOAeJF7w.js` / `Mesh-BSAo88jN.js`.
- `main` pushed to `gitea-vps2`.
- RPC on `.116`:
- `transport.status` currently reports `mesh_only:false` (off-grid mode is not enabled unless
the user toggles it).
- `mesh.status` reports Meshtastic connected: `device_type:"meshtastic"`,
`self_node_id:1135977788`, `peer_count:13`.
- Recent `.116` -> `3ccc` sent rows are stored with real 2026 timestamps and `transport:"lora"`.
- UI/backend fixes included in `b4531bb4`:
- `transportLabel("lora")` displays **LoRa**.
- mesh sends refetch messages after send so transport pills settle without browser refresh.
- off-grid mode blocks the mesh-chat FIPS/Tor federation fallback and forces LoRa-only sends;
banner text is `Tor/FIPS disabled - LoRa only`.
- empty mesh-chat placeholder opacity reduced.
- Meshtastic diagnostics now identify the remaining blocker:
- 3ccc NodeInfo is discovered:
`Meshtastic peer is PKC-capable (NodeInfo public_key) node=1128152268 key_len=32`.
- Bytes from stock Meshtastic text reach `.116`, but the custom parser rejects the packet:
`Meshtastic FromRadio.packet did not parse into a decoded MeshPacket len=73 head=0dcc3c3e43153ca5b5432a16df56cbed`.
- Non-text packets decode and are ignored with port numbers (`portnum=3/4/5`), so the serial
read path is alive. Resume inside `core/archipelago/src/mesh/meshtastic.rs::parse_mesh_packet`.
- LoRa is therefore **not fully fixed** yet: stock `3ccc` -> `.116` text does not surface in
`mesh.messages`, and `.116` -> `3ccc` still needs user-visible confirmation in the Meshtastic app.