Files
trail-mate/docs/MESHTASTIC_PHONE_PROTOCOL_REFERENCE.md
vicliuandGitHub 54cf0301e2 Gat562 mesh evb pro (#13)
* feat(gat562): wire nrf52 board runtime and bluetooth stack

* refactor(chat): align shared BLE cores and nrf meshtastic routing

- extract Meshtastic and MeshCore phone/business flows into shared core_chat BLE cores with owner hook seams for ESP and nRF backends

- replace nrf-specific lite adapters with board/platform adapters that consume shared cores and shared storage contracts

- extend nrf Meshtastic radio path with packet history dedupe, flooding rebroadcast, route discovery/traceroute responses, routing ACK/error handling, observed relay learning, retransmit queueing, and next-hop persistence

- persist nrf device identity and node metadata more explicitly, including next-hop state and node-id initialization support

- move nrf chat/contact/blob storage further toward InternalFS-backed persistence and update contact/node store behavior accordingly

- wire gat562 protocol/app facade pieces to the concrete nrf stores/adapters and add supporting app time-sync hooks used by platform runtimes

- add owner-hook split files and planning/status docs to document the ongoing alignment with the reference meshtastic-firmware multi-platform structure

* Add NRF Fusion Pixel font and fix Meshtastic RX

Integrate Fusion Pixel 8px as the NRF mono UI font stack and switch the mono runtime to a dedicated MonoFont/TextRenderer path that can render ASCII, CJK, and symbols from one asset set.

Add the generated NRF font assets and generator script, including the compact font format with per-glyph advance support, and fix the BDF bitmap parsing bug that previously produced blank ASCII glyphs on device.

Move the LVGL Noto CJK font asset out of shared modules into the ESP-specific platform tree and update the related build/config references so the ESP-only font boundary is explicit and NRF does not compile the LVGL asset by accident.

Update the mono UI runtime to use the new renderer, normalize major menu and title labels to uppercase, and wire the GAT562 UI runtime to the Fusion Pixel font.

Fix the core mesh adapter router so Meshtastic and MeshCore backends can coexist without overwriting each other, and ensure active protocol selection is applied before mesh config changes are pushed into the runtime.

Improve GAT562 runtime diagnostics by mirroring debug output to both Serial and Serial2, logging startup radio configuration, and logging raw RX packets with RSSI/SNR so radio-path issues are visible during bring-up.

Relax SX1262 receive polling so it no longer hard-gates RX handling on DIO1 state alone, avoiding a failure mode where the board appeared alive but never delivered packets into the receive path.

Align the NRF Meshtastic adapter with the working ESP behavior for channel handling: use the default expanded public PSK for an unconfigured primary channel, compute channel hashes from the active preset name or Custom label instead of a hard-coded Primary string, and apply the same logic to self-announcement/NodeInfo packets.

Add protocol-layer Meshtastic RX diagnostics for parse, decrypt, decode, text queueing, app-data queueing, unknown-channel, and dedup events to make packet flow visible while validating interoperability.

Fix early duplicate handling for relayed packets so the first valid packet is still delivered into text processing while later relays are correctly suppressed, which restores Broadcast conversation creation and message visibility in the CHATS page.

* feat(gat562): align nrf ui and meshtastic behavior

* Refine mono UI GPS pages and board diagnostics

* refactor: align board runtimes, GAT562 app shell, and BLE integrations

- migrate ESP board-specific implementations and runtime adapters into boards/*
- continue GAT562/Tab5/UI runtime alignment across app and board layers
- sync BLE and runtime integrations, and apply repository clang-format rules

* Refine gat562 mono UI and persist UI settings

* Fix settings persistence and GNSS UI behavior

* Improve mono UI paging and Meshtastic RX fallback

* Align nRF52 Meshtastic BLE and PKI behavior

* Refine mono UI node and chat interactions

* Fix deferred config persistence on gat562 BLE

* Refine mono node compass and GPS layouts

* Include node position in Meshtastic phone info

* Refactor shared board contracts out of ESP platform layer

* Persist node positions across restarts

* Ignore local compile commands database

* Sync README acknowledgements updates

* Apply CI formatting

* Fix board ownership drift and restore pager walkie support

* Add APRS asset, expand System controls, and harden GAT562 self-position persistence

Bundle the current workspace changes into a single checkpoint covering the in-progress shared UI, settings, and GAT562 runtime work.

What this includes:

- add the APRS asset source and image resource currently used by the shared UI asset set

- extend the shared System/settings flow so vibration and screen brightness can be surfaced through the device runtime and settings UI plumbing

- update the mono 128x64 runtime and the GAT562 platform bindings so the new settings controls are available in the GAT562 experience

- wire the ESP runtime/startup helpers needed to support the same device-runtime settings path on the ESP side

- persist the GAT562 self GPS position into the node store during core service updates so a device that already obtained a fix can retain its own last known position across reboot

- add an explicit node-store flush interface and implement it in both the nRF52 and ESP Meshtastic node-store backends so critical self-position updates can be forced to persistent storage immediately instead of waiting for the normal debounce window

- keep the existing debounced persistence behavior for ordinary node-store churn while allowing targeted immediate flushes for data that would otherwise be easy to lose during restart or power loss

* chore: checkpoint current chat and contacts changes

* perf: reduce chat list and message handling latency

* Align Meshtastic phone sync and chat UI refresh

* Remove key verification UI and hide MC position exchange

* Add T-Deck Pro board bring-up and sync pending UI fixes

* Refactor GAT562 runtime and stabilize Meshtastic BLE

Refactor the GAT562 board runtime by extracting dedicated GPS and input runtime components and routing config application through a focused runtime apply service.

Narrow the nRF52 BLE manager dependencies, rework the Meshtastic BLE transport to better match the Android client handshake, and harden the FromRadio/FromNum flow with improved staging, read pacing, diagnostics, and stack-safe config snapshot generation.

Also repair repository text encoding issues in docs and UI comments, and add Meshtastic BLE timing documentation to capture the expected Android/firmware interaction model for future debugging.

* fix: gat562 settings

* Define LoRa tx power caps per board

* Refine mono conversation bubble focus

* Polish mono conversation and message info layout

* Harden Meshtastic BLE flow and cross-target builds

Adjust the Meshtastic BLE transport timing on nRF52, retain pending FROMNUM notifications, and defer local admin/self replies to reduce request/response races during Android settings flows.

Also make the shared chat/UI code compile across non-nRF targets by gating RTOS and Arduino-only helpers and by updating the team page layout to the current page profile fields.

Note: this does not resolve the gat562 Meshtastic Android settings progress bar issue yet; the BLE/admin response path is improved and instrumented, but the root cause of the stuck loading state remains unresolved.

* Fix GAT562 mono channel slot UI and screen timeout

* Revert Meshtastic BLE hardening changes

Revert b9e20b2662 because it causes app-side configuration reads to hang on GAT562.

This restores the previous Meshtastic BLE timing/notification behavior and also drops the cross-target compatibility adjustments that came with that commit.

* Refactor nrf52 fs helpers and show flash free space

* fix sync node info

* Harden Meshtastic BLE receive path

* Fix Meshtastic nodeinfo persistence

* Persist NodeInfo position updates

* Format files to match CI style

* Prepare 0.1.14-alpha docs and CI

* Fix watch chat build and CI formatting

* Format sources for CI clang-format-14
2026-04-13 17:33:21 +08:00

19 KiB

Meshtastic Phone API / BLE Protocol Reference

Purpose

This document summarizes the Meshtastic phone-facing protocol as implemented by the official upstream code currently vendored in this repo:

  • Firmware: .tmp/firmware
  • Apple app: .tmp/Meshtastic-Apple
  • Android app: .tmp/meshtastic-android

The goal is to answer protocol and encoding questions from source-grounded rules instead of relying on local assumptions.

This document focuses on:

  • ToRadio / FromRadio
  • BLE fromNum / fromRadio interaction
  • QueueStatus
  • MeshPacket.id
  • decoded.request_id
  • decoded.reply_id
  • want_ack
  • broadcast vs direct-message behavior
  • how official apps interpret ACK/NAK state

Source Anchors

Primary firmware sources:

  • .tmp/firmware/src/mesh/PhoneAPI.h
  • .tmp/firmware/src/mesh/PhoneAPI.cpp
  • .tmp/firmware/src/mesh/api/PacketAPI.cpp
  • .tmp/firmware/src/mesh/StreamAPI.cpp
  • .tmp/firmware/src/mesh/Router.cpp
  • .tmp/firmware/src/mesh/ReliableRouter.cpp
  • .tmp/firmware/src/mesh/MeshService.cpp
  • .tmp/firmware/src/mesh/MeshModule.cpp
  • .tmp/firmware/src/modules/RoutingModule.cpp
  • .tmp/firmware/src/mesh/generated/meshtastic/mesh.pb.h

Official app sources:

  • .tmp/Meshtastic-Apple/Meshtastic/Accessory/Accessory Manager/AccessoryManager.swift
  • .tmp/Meshtastic-Apple/Meshtastic/Accessory/Accessory Manager/AccessoryManager+FromRadio.swift
  • .tmp/Meshtastic-Apple/Meshtastic/Accessory/Accessory Manager/AccessoryManager+ToRadio.swift
  • .tmp/Meshtastic-Apple/Meshtastic/Accessory/Transports/Bluetooth Low Energy/BLEConnection.swift
  • .tmp/Meshtastic-Apple/Meshtastic/Helpers/MeshPackets.swift
  • .tmp/meshtastic-android/app/src/main/java/com/geeksville/mesh/service/PacketHandler.kt
  • .tmp/meshtastic-android/app/src/main/java/com/geeksville/mesh/service/FromRadioPacketHandler.kt
  • .tmp/meshtastic-android/app/src/main/java/com/geeksville/mesh/service/MeshDataHandler.kt
  • .tmp/meshtastic-android/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DataPacket.kt

Big Picture

Meshtastic exposes a "phone API" over multiple transports:

  • BLE
  • serial stream
  • packet/IPC API
  • HTTP variants

Across those transports, the logical payloads are the same:

  • phone -> device: ToRadio
  • device -> phone: FromRadio

The transport framing differs, but the application-level meaning does not.

Important consequence:

  • If we want to know "what the phone is supposed to believe", we must follow PhoneAPI.cpp plus the official app code.
  • If we want to know "what ACK means", we must follow Router.cpp, ReliableRouter.cpp, MeshModule.cpp, and app-side routing handling.

Transport Layer Rules

Serial / stream transport

StreamAPI.cpp shows the serial framing:

  • start bytes: 0x94 0xC3
  • then 16-bit big-endian payload length
  • then protobuf bytes

Payload directions:

  • toward device: ToRadio
  • toward client: FromRadio

This is transport framing only. After decoding, the same PhoneAPI logic applies.

Packet API transport

PacketAPI.cpp wraps the same behavior in queued protobuf packets.

Important rules:

  • ToRadio.packet is passed into service->handleToRadio(*mp)
  • ToRadio.want_config_id starts the config state machine
  • ToRadio.heartbeat is handled
  • outgoing FromRadio packets are produced by getFromRadio()

BLE transport

Official BLE behavior is split between:

  • firmware BLE implementation
  • PhoneAPI
  • app BLE client logic

Upstream firmware exposes:

  • TORADIO
  • FROMRADIO
  • FROMNUM
  • LOGRADIO

Official Apple BLE behavior in BLEConnection.swift:

  • phone writes protobuf bytes to TORADIO
  • phone receives FROMNUM notification
  • after FROMNUM, phone drains pending packets by repeatedly reading FROMRADIO
  • drain ends when FROMRADIO read returns empty payload

So FROMNUM is not the data itself. It is a wakeup/edge signal telling the client that one or more FromRadio packets are ready.

PhoneAPI State Machine

PhoneAPI.cpp contains the canonical state machine for what the phone receives.

States are:

  1. STATE_SEND_NOTHING
  2. STATE_SEND_UIDATA
  3. STATE_SEND_MY_INFO
  4. STATE_SEND_OWN_NODEINFO
  5. STATE_SEND_METADATA
  6. STATE_SEND_CHANNELS
  7. STATE_SEND_CONFIG
  8. STATE_SEND_MODULECONFIG
  9. STATE_SEND_OTHER_NODEINFOS
  10. STATE_SEND_FILEMANIFEST
  11. STATE_SEND_COMPLETE_ID
  12. STATE_SEND_PACKETS

Important rule explicitly documented in code:

  • client apps assume this config-send order
  • upstream comments say: "DO NOT CHANGE IT"

Config start

When the device receives ToRadio.want_config_id:

  • PhoneAPI::handleStartConfig() is called
  • the connection is considered active
  • the device enters the config-send sequence
  • after config is complete, it sends FromRadio.config_complete_id
  • only then does it move to STATE_SEND_PACKETS

Special nonces

PhoneAPI.h defines:

  • SPECIAL_NONCE_ONLY_CONFIG = 69420
  • SPECIAL_NONCE_ONLY_NODES = 69421

Meaning:

  • 69420: send config-related state without full node DB walk
  • 69421: focus on node info flow

Official Apple app uses the same constants in AccessoryManager.swift.

ToRadio Variants

Source of truth: PhoneAPI.cpp, PacketAPI.cpp.

Officially handled variants include:

  • packet
  • want_config_id
  • disconnect
  • xmodemPacket
  • mqttClientProxyMessage
  • heartbeat

ToRadio.packet

This is the normal way for the app to send a mesh packet through the connected device.

Flow:

  1. phone builds MeshPacket
  2. wraps in ToRadio.packet
  3. device PhoneAPI::handleToRadioPacket()
  4. device applies local rules and rate limits
  5. device calls service->handleToRadio(p)
  6. device injects it into mesh routing via MeshService

ToRadio.want_config_id

Starts config sync.

The response is not a single packet. It is the whole config state machine ending with:

  • FromRadio.config_complete_id = same nonce

ToRadio.heartbeat

In PhoneAPI.cpp, heartbeat only sets a flag:

  • heartbeatReceived = true

Then the next getFromRadio() emits:

  • FromRadio.queueStatus

So on modern firmware, heartbeat is effectively a "please prove you are alive and tell me queue status" request.

Official Apple app uses this to detect link liveness.

FromRadio Variants

PhoneAPI.cpp sends these categories:

  • config-related data: my_info, node_info, metadata, channel, config, moduleConfig, fileInfo, config_complete_id
  • steady-state data: packet, queueStatus, mqttClientProxyMessage, clientNotification, xmodemPacket
  • system events: rebooted, log_record

Important distinction:

  • FromRadio.packet carries a MeshPacket
  • FromRadio.queueStatus is not a mesh packet
  • FromRadio.clientNotification is not a mesh packet

That distinction matters because official apps treat them differently.

MeshPacket Field Semantics

Source of truth: mesh.pb.h, Router.cpp, MeshModule.cpp, app code.

MeshPacket.id

This is the packet identifier for the mesh packet itself.

Important upstream comments say:

  • it is unique per sender for a short time window
  • used by flooding / ACK / retransmission logic
  • used by crypto implementation too

In firmware:

  • if phone did not set id, MeshService::handleToRadio() generates one
  • queue-status responses use the packet's id as mesh_packet_id

Therefore:

  • app-created packet IDs matter
  • if app sets id, later status signals should correlate back to this same ID

decoded.request_id

Upstream protobuf comment:

  • only used in routing or response messages
  • indicates the original message ID this message is reporting on

In practice:

  • routing ACK/NAK packets use decoded.request_id = original_packet.id
  • normal responses to a request also use request_id to point at the request packet
  • official apps use request_id to correlate a response/ACK with the outbound request

decoded.reply_id

Upstream protobuf comment:

  • indicates this message is a reply to a previous message

This is user/content-level threading, not transport ACK.

Examples:

  • text reply to a previous message
  • emoji reaction targeting a prior message

Do not confuse reply_id with ACK state.

want_ack

This means the sender wants reliable delivery behavior and an ACK-style confirmation path.

However, upstream code makes one critical exception:

  • Router.cpp forcibly clears want_ack on broadcast packets before they go over LoRa

That means:

  • broadcast over-the-air packets are never true "normal ACKed unicast sends"
  • any phone/app logic that treats broadcast as awaiting a direct recipient ACK is wrong

Broadcast vs Direct Message

This is the most important rule for current debugging.

Direct message

For non-broadcast packets:

  • want_ack can remain set
  • ReliableRouter tracks retransmissions
  • recipient may send a true routing ACK/NAK
  • official apps can eventually move message to delivered/error based on routing result

Broadcast message

For broadcast packets:

  • Router.cpp clears want_ack before air transmission
  • no normal destination-specific ACK flood is used
  • reliability is based on rebroadcast observation and implicit acknowledgment logic

Upstream ReliableRouter.cpp behavior:

  • if the original sender sees someone rebroadcast its broadcast packet
  • firmware generates an implicit ACK internally
  • this is an optimization for flooding reliability
  • that ACK is generated on the original sender node and then surfaces to the phone as a local ROUTING_APP result tied to the original request_id
  • it should not be rewritten as if it came from the rebroadcaster's node identity

This implicit ACK is not the same thing as:

  • a direct-message ACK from the intended peer
  • a conversation-level proof that one specific remote user acknowledged receipt

Therefore:

  • a broadcast packet must not be surfaced to phone UI as "waiting for DM ACK from peer X"
  • a rebroadcaster or relay identity must not be mistaken for the final application peer

Official ACK / NAK Generation

Routing ACK packet format

MeshModule::allocAckNak() builds a packet with:

  • decoded.portnum = ROUTING_APP
  • payload = encoded Routing
  • decoded.request_id = original packet id
  • to = original sender

This is the canonical ACK/NAK message shape.

Reply packet format

setReplyTo() in MeshModule.cpp sets:

  • p->to = original sender
  • p->channel = original channel
  • p->want_ack = to.want_ack except local-phone case
  • p->decoded.request_id = original request id

So for admin or other request/response flows:

  • an ordinary response packet can also carry request_id
  • apps may use that to match request -> response even when it is not a routing ACK

ReliableRouter rules

ReliableRouter.cpp distinguishes:

  • ACK: routing packet with error_reason == NONE, or non-routing response carrying request_id
  • NAK: routing packet with non-NONE error reason

Key code:

  • ackId = ((c && c->error_reason == NONE) || !c) ? p->decoded.request_id : 0
  • nakId = (c && c->error_reason != NONE) ? p->decoded.request_id : 0

Meaning:

  • a packet with request_id can stop retransmission
  • for routing packets, the error code decides ACK vs NAK

QueueStatus Semantics

This is the second most important rule.

Source of truth:

  • MeshService::sendToMesh()
  • MeshService::sendQueueStatusToPhone()
  • PhoneAPI::handleToRadioPacket()
  • Android PacketHandler.handleQueueStatus()

What QueueStatus means

After a phone-originated mesh packet is handed into routing, firmware always tries to send a QueueStatus back to the phone:

  • res = immediate result of enqueue / local send attempt
  • free = current number of free queue entries
  • maxlen = queue capacity
  • mesh_packet_id = the outbound packet ID this status refers to

This happens in MeshService::sendToMesh().

So QueueStatus answers:

  • was this packet accepted by the local device/radio path?
  • what is the local transmit queue state right now?

It does not answer:

  • whether the remote node received it
  • whether the remote node ACKed it
  • whether a routing error happened later

Heartbeat QueueStatus

Heartbeat also returns a QueueStatus, but that one is only link-liveness / local queue information.

It is not a send result for a specific message unless mesh_packet_id points to one.

Official Android interpretation

PacketHandler.kt:

  • when packet is sent to radio, status becomes ENROUTE
  • handleQueueStatus() only completes the local "radio accepted it" wait
  • if requestId != 0, Android matches by mesh_packet_id

So Android uses QueueStatus to move past the radio-send stage, not to declare final delivery.

This is the exact reason "queueStatus arrived" is not enough to clear "waiting to be acknowledged".

Official App Send-State Interpretation

Android

Relevant code:

  • PacketHandler.kt
  • MeshDataHandler.kt
  • DataPacket.kt

Android status model:

  • QUEUED
  • ENROUTE
  • DELIVERED
  • ERROR
  • RECEIVED

Behavior:

  1. app sends packet -> ENROUTE
  2. firmware returns QueueStatus(mesh_packet_id=id) -> local send gate completes
  3. later, ROUTING_APP packet with request_id=id drives final status

MeshDataHandler.handleRouting():

  • decodes Routing
  • calls handleAckNak(requestId, fromId, routingError, relayNode)

Status mapping:

  • ACK from ultimate target or reaction target may become RECEIVED
  • ACK otherwise becomes DELIVERED
  • non-zero routing error becomes ERROR

The essential point:

  • final delivery status comes from ROUTING_APP, not QueueStatus

Apple

Relevant code:

  • AccessoryManager.swift
  • MeshPackets.swift

Apple receives FromRadio.packet, checks decoded.portnum, and for ROUTING_APP calls:

  • MeshPackets.shared.routingPacket(packet:connectedNodeNum:)

That handler:

  • finds message by packet.decoded.requestID
  • stores ackError
  • if routingMessage.errorReason == .none, sets receivedACK = true
  • records relayNode, ackTimestamp, ackSNR

Apple therefore also treats:

  • routing packet keyed by requestID
  • as the authoritative ACK/NAK path

Again:

  • QueueStatus is not final delivery

BLE Read / Notify Contract

Combining firmware and Apple app:

  1. phone writes ToRadio to TORADIO
  2. firmware eventually increments fromNum
  3. firmware notifies FROMNUM
  4. app starts draining FROMRADIO
  5. app keeps reading until empty read

Important consequences:

  • if firmware queues FromRadio data but does not cause the app to drain, status updates can appear delayed
  • if firmware only wakes the app for some variants and not others, phone-side state may lag
  • if QueueStatus or ROUTING_APP packets are generated but not drained, UI stays stale

What Must Not Be Misinterpreted

Rule 1: QueueStatus is not final ACK

Incorrect:

  • "I saw QueueStatus for packet X, so message X was acknowledged by the peer"

Correct:

  • "QueueStatus means the local radio path accepted or rejected the outbound packet"

Rule 2: reply_id is not ACK state

Incorrect:

  • "This packet has reply_id, so it acknowledges the earlier packet"

Correct:

  • reply_id is conversation-level reply threading

Rule 3: broadcast should not be modeled as direct-message ACK

Incorrect:

  • "Broadcast packet to 0xFFFFFFFF should wait for recipient ACK"

Correct:

  • broadcast ACK-on-air is suppressed by upstream router
  • reliability uses flooding / rebroadcast observation
  • relay observations are not direct recipient ACK semantics

Rule 4: relay / rebroadcast node is not automatically the logical sender of ACK

Incorrect:

  • "I saw a routing-related event from short relay 0x11; therefore node 0x11 is the chat peer who acknowledged"

Correct:

  • it may be an intermediate relay, rebroadcaster, or broadcast-side routing artifact
  • interpretation depends on whether the original packet was unicast or broadcast

Concrete Rules For Our Integration

These rules follow upstream behavior and should be treated as protocol constraints.

Outbound phone message

  • Preserve MeshPacket.id if app/core assigned one.
  • Use that same ID as the stable correlation key across:
    • QueueStatus.mesh_packet_id
    • ROUTING_APP.decoded.request_id
    • any response packet carrying decoded.request_id

Broadcast text send

  • Do not model broadcast text as requiring a direct recipient ACK.
  • Do not convert relay or rebroadcast observations into peer-delivery ACK for chat UI.
  • Do not surface a broadcast routing artifact as if it were a DM acknowledgment from a user node.

Direct-message text send

  • QueueStatus means local acceptance only.
  • Wait for ROUTING_APP or a request-correlated response to decide final state.
  • A Routing.Error.NONE for matching request_id is the canonical success signal.

Admin / request-response flows

  • Some requests may be effectively confirmed by a real response packet carrying request_id.
  • Official Apple app explicitly treats admin responses as an ACK-equivalent for the admin log entry.

BLE transport

  • FROMNUM must wake draining of FROMRADIO.
  • All generated FromRadio packets that matter to UI state must be drainable in a timely way.

Why from=00000011 Was Suspicious In Our Case

From upstream rules alone:

  • if the original outbound packet was a broadcast message
  • and the phone/UI later treated a routing-related event from 0x00000011 as the final peer ACK
  • that interpretation is wrong

Because upstream says:

  • broadcasts do not carry normal over-air want_ack
  • their reliability path is based on flooding and implicit observation
  • relay/rebroadcast evidence is not equivalent to DM recipient acknowledgment

So if a broadcast text on our branch ended up surfacing:

  • request_id = original text id
  • plus a routing-style success attributed to a relay-like node

the likely bug is not "Meshtastic protocol says relay 0x11 is the peer ACK sender".

The likely bug is:

  • our integration mapped a broadcast-side routing observation into a DM-style ACK event for the phone layer

That conclusion is source-consistent with upstream behavior.

Practical Debug Checklist

When debugging a message stuck on "waiting to be acknowledged", check in this order:

  1. Did the phone send ToRadio.packet with a stable MeshPacket.id?
  2. Did firmware emit QueueStatus.mesh_packet_id == that id?
  3. If no, the problem is local enqueue / transport / BLE drain.
  4. If yes, did a later FromRadio.packet arrive with decoded.request_id == that id?
  5. If yes and portnum == ROUTING_APP, decode Routing.error_reason.
  6. If the original packet was broadcast, do not interpret relay observations as DM ACK.
  7. If the original packet was a request expecting content response, also check non-routing response packets carrying request_id.

Short Reference Table

MeshPacket.id

  • ID of the outbound packet itself
  • primary correlation key

QueueStatus.mesh_packet_id

  • local enqueue/send result for outbound packet ID
  • not final remote ACK

decoded.request_id

  • "this packet refers to original packet ID X"
  • used for routing ACK/NAK and normal responses

decoded.reply_id

  • content/thread reply to previous message
  • not transport ACK

want_ack

  • reliable-delivery request for unicast path
  • cleared by router for broadcast over the air

ROUTING_APP

  • canonical ACK/NAK packet family
  • official apps use it for final delivery state

Notes For Future Maintenance

If upstream changes behavior, re-check at least:

  • PhoneAPI.cpp
  • Router.cpp
  • ReliableRouter.cpp
  • MeshService.cpp
  • Apple BLEConnection.swift
  • Apple MeshPackets.swift
  • Android PacketHandler.kt
  • Android MeshDataHandler.kt

If we change our local adapter behavior, we should compare against this document first, then update the implementation, not the rules.