55 KiB
Meshtastic Android BLE Connection Specification
本文档固化 Trail Mate 与 Meshtastic Android App 之间的 BLE 连接、配置同步、 NodeDB 同步、Admin 配置写入、MQTT module config 保存、以及故障诊断边界。
目标不是解释一次成功连接的日志,而是把后续 AI 和工程师必须遵守的主路径写成规格。
任何修复 Android App 卡在 Module config received、Nodes(0)、无法完成连接、保存配置
后 crash、或 BLE 配置生效异常的问题,都必须先回到本文档确认有没有破坏主路径。
Current Baseline
本规格基于两组事实:
- 官方 Meshtastic Android App / firmware 的 PhoneAPI 交互规则。
- Trail Mate 当前共享
MeshtasticPhoneCore、ESP32 BLE transport、nRF52 BLE transport 的实现。
当前官方源码锚点:
- Android App:
.tmp/official/Meshtastic-Androidcommite634e71 - Firmware:
.tmp/official/firmwarecommit0488a46
当前 Trail Mate 源码锚点:
platform/esp/arduino_common/src/ble/ble_manager.cppplatform/esp/arduino_common/src/ble/meshtastic_ble.cppplatform/esp/arduino_common/src/ble/meshtastic_ble_owner_hooks.cppplatform/esp/arduino_common/src/ble/app_phone_facade.cppplatform/nrf52/arduino_common/src/ble/meshtastic_ble.cppplatform/nrf52/arduino_common/src/ble/app_phone_facade.cppmodules/core_phone/src/meshtastic/meshtastic_phone_session.cppmodules/core_phone/src/meshtastic/meshtastic_phone_core.cppmodules/core_phone/include/phone/meshtastic/meshtastic_phone_core.hmodules/core_phone/tests/test_phone_core_smoke.cpp
GitNexus 索引可能落后当前 HEAD。本文档以当前工作区源码为准。
Core Distinctions
True Objects
| Object | Meaning | Owner |
|---|---|---|
| Meshtastic Android App | 外部 BLE client,按官方 Meshtastic BLE GATT 约定写 ToRadio、读 FromRadio、订阅 FromNum。 |
外部系统 |
| Meshtastic BLE Transport | NimBLE GAP/GATT、advertising、pairing、characteristic callbacks、notify/read/write 队列。 | MeshtasticBleService |
| Phone Protocol Session | 一次手机连接内的协议状态:PhoneAPI phase、配置流、队列状态、packet 队列、deferred save 标志。 | MeshtasticPhoneSession / MeshtasticPhoneCore |
| PhoneAPI Phase | 官方 PhoneAPI 的语义状态:SEND_NOTHING、config snapshot 阶段、SEND_PACKETS。它决定哪些 ToRadio/FromRadio variant 合法。 |
MeshtasticPhoneCore |
| Meshtastic Phone Protocol Core | ToRadio/FromRadio protobuf 语义、Admin 处理、config snapshot 帧序列、NodeInfo/Channel/Config/ModuleConfig 投影。 |
MeshtasticPhoneCore |
| MQTT Client Proxy Queue | device->phone->MQTT 的待交付消息队列。它是 PhoneAPI steady-state 数据,不是 config 数据。 | shared phone core / app facade / radio adapter |
| App Facade | Phone core 和 Trail Mate App 状态之间的端口。 | AppPhoneFacade |
| App State | 实际 Mesh config、node store、contact store、message send、radio adapter、BLE enabled state。 | AppContext / app services |
Projection, Not Truth
| Projection | Why it is not truth |
|---|---|
Android UI text such as Module config received |
只是 App 侧阶段显示,不能证明 firmware 已完成 config flow 或保存成功。 |
Android UI text such as Nodes(0) |
只是 App 当前 NodeDB 视图,不能作为 firmware node store 或 BLE queue 的事实来源。 |
fromNum characteristic value |
是唤醒 Android 继续读取 FromRadio 的信号;transport 可发送单调 token 或当前预装帧的 from_num,但它不是独立业务队列。 |
| BLE connected flag | 只证明 GAP 连接存在,不证明 Meshtastic config snapshot 完成。 |
| Android MQTT connected status | 只是 Android MQTT client 的网络状态,不证明 firmware 已经进入 SEND_PACKETS,也不证明 FromRadio.mqttClientProxyMessage 可被安全交付。 |
fromRadio zero-length read |
是 drain 结束信号,不是错误;但在配置流完成前过早出现会导致 App 停在未完成状态。 |
fromRadioSync |
当前 kEnableFromRadioSync=false,不是 Android 主路径。 |
Forbidden Concept Drift
以下切法都是非法的:
- 把
MeshtasticBleService当作 Meshtastic 协议语义 owner。 - 把 Android App 的 UI 文案当作 firmware 状态机。
- 把
fromNum的 notify value 当作 packet id 或 config nonce。 - 为 Pager、TDeck、Android 某个版本单独复制一条 BLE config flow。
- 在 GATT callback 里直接修改 App service、保存配置、重启设备或调用 radio adapter。
- 在
set_config/set_module_config时立即执行阻塞保存,绕过 response drain。 - 用
fromRadioSync、额外 notify、强制空读、强制重启 BLE 等旁路修Nodes(0)。 - 让 MeshCore BLE 服务复用 Meshtastic Android App 的 protobuf / GATT 语义。
- 把
config_flow_active_ == false直接等同于官方STATE_SEND_PACKETS。 - 在 Android 仍处于
Connecting/ config handshake 时交付或消费FromRadio.mqttClientProxyMessage。 - 在 PhoneAPI 未进入
SEND_PACKETS时处理ToRadio.mqttClientProxyMessage并注入 mesh。 - 把 radio adapter 内部 MQTT queue 当成 Android 端可靠投递状态;可交付性的真相属于 PhoneAPI phase。
GATT Contract
Meshtastic Android App 连接的是 Meshtastic BLE service,不是 MeshCore NUS service。
| Name | UUID | Direction | Role |
|---|---|---|---|
| Mesh Service | 6ba1b218-15a8-461f-9fa8-5dcae273eafd |
service | Meshtastic Android App 发现入口。 |
ToRadio |
f75c76d2-129e-4dad-a1dd-7866124401e7 |
phone writes | App 写入 nanopb-encoded meshtastic_ToRadio。 |
FromRadio |
2c55e69e-4993-11ed-b878-0242ac120002 |
phone reads | App 读取 nanopb-encoded meshtastic_FromRadio。 |
FromNum |
ed9da18c-a800-4f66-a670-aa7547e34453 |
notify/read | Firmware 通知 App 有新 FromRadio 数据可读。 |
LogRadio |
5a3d6e49-06e6-4423-9944-e9de8cdf9547 |
read/notify | 日志投影,不参与连接完成判定。 |
FromRadioSync |
888a50c3-982d-45db-9963-c7923769165d |
notify/indicate | 当前禁用,不是主路径。 |
| Battery | 0x180F/0x2A19 |
read/notify | Android 可读电量投影,不参与 Meshtastic config flow。 |
Pairing mode:
NO_PIN时 characteristic 不要求加密认证。RANDOM_PIN或FIXED_PIN时ToRadio/FromRadio/FromNum/LogRadio必须带相应 authenticated/encrypted property。- passkey 显示由
MeshtasticServerCallbacks::onPassKeyDisplay()产生,AppPhoneFacade::loadDeviceConnectionStatus()可以投影给 App。
Architecture Class Diagram
classDiagram
direction LR
class BleManager {
+startOrRestart()
+shutdownNimble()
+buildDeviceName()
}
class MeshtasticBleService {
+start()
+stop()
+update()
+notifyFromNum(value)
-handleFromPhone()
-handleToPhone()
-shouldBlockOnRead()
-clearQueues()
-closePhoneSession()
}
class MeshtasticPhoneSession {
+handleToRadio(buf,len)
+popToPhone(out)
+isSendingPackets()
+isConfigFlowActive()
+close()
}
class MeshtasticPhoneCore {
+handleToRadio(data,len)
+popToPhone(out)
+phoneApiPhase()
-handleToRadioPacket(packet)
-handleAdmin(packet)
-enqueueConfigSnapshot(nonce)
-popConfigSnapshotFrame(out)
-canHandleMqttProxy()
-canEmitSteadyStateFrame()
-enqueueQueueStatus(packet_id,ok)
-encodeFromRadio(from,from_num,out,kind,priority)
}
class PhoneApiPhase {
<<enum>>
SEND_NOTHING
SEND_CONFIG
SEND_PACKETS
}
class MqttProxyQueue {
+queueFromDevice(packet)
+peekOrPopWhenSendPackets()
+dropOldestWhenFull()
}
class AppPhoneFacade {
+getMeshtasticPhoneConfig()
+setMeshtasticPhoneConfig(config)
+saveConfig()
+applyMeshConfig()
+loadBluetoothConfig(out)
+saveBluetoothConfig(config)
+loadModuleConfig(out)
+saveModuleConfig(config)
+phoneNodeCount()
+getPhoneNodeByIndex(index,out)
+handleMqttProxyToRadio(msg)
+pollMqttProxyToPhone(out)
}
class IAppBleFacade {
<<interface>>
+getConfig()
+saveConfig()
+applyMeshConfig()
+getMeshAdapter()
+getNodeStore()
+isBleEnabled()
+setBleEnabled(enabled)
}
class MeshtasticPhoneTransport {
<<interface>>
+isBleConnected()
+notifyFromNum(from_num)
}
class IPhoneBleRuntime {
<<interface>>
+requestPhoneHighThroughputConnection()
+requestPhoneLowerPowerConnection()
+onPhoneBluetoothConfigChanged()
+onPhoneModuleConfigChanged()
}
BleManager --> MeshtasticBleService : creates for Meshtastic
MeshtasticBleService --> MeshtasticPhoneSession : owns
MeshtasticPhoneSession --> MeshtasticPhoneCore : owns core
MeshtasticPhoneCore --> PhoneApiPhase : owns
MeshtasticPhoneCore --> AppPhoneFacade : app port
MeshtasticPhoneCore --> MqttProxyQueue : gates delivery
AppPhoneFacade --> IAppBleFacade : delegates
MeshtasticBleService ..|> MeshtasticPhoneTransport
MeshtasticBleService ..|> IPhoneBleRuntime
AppPhoneFacade ..> IPhoneBleRuntime : lifecycle callbacks
Boundary rule:
NimBLE callback -> MeshtasticBleService queue -> update() -> MeshtasticPhoneSession
-> MeshtasticPhoneCore -> AppPhoneFacade -> App services
Read-authorize rule for BLE transports without a push-style FromRadioSync characteristic:
FROMRADIO read callback -> record pending read -> return to BLE stack
update() -> process pending ToRadio -> publish FromRadio slot -> authorize read
The callback must not call popToPhone() or encode protobuf frames directly. If
Android writes a heartbeat and immediately drains FROMRADIO, the transport may
hold the read authorization for a short bounded window so the main loop can turn
the heartbeat into a queueStatus frame. If no frame appears before the window
expires, the read is authorized with a zero-length value.
The reverse direction is:
App services / mesh adapter events -> MeshtasticBleService.update()
-> MeshtasticPhoneSession.popToPhone()
-> FromRadio queue / FromNum notify
Connection State Model
stateDiagram-v2
[*] --> Disabled
Disabled --> Starting: BLE enabled and headroom ok
Starting --> Advertising: NimBLE init + service start ok
Starting --> Disabled: init/service/start advertising failed
Advertising --> Connected: Android GAP connect
Connected --> Pairing: security requires auth
Pairing --> SendNothing: auth complete
Connected --> SendNothing: no auth required
SendNothing --> ConfigFlow: ToRadio.want_config_id
ConfigFlow --> SendNothing: CONFIG_NONCE config_complete_id emitted
ConfigFlow --> SendPackets: NODE_INFO_NONCE/full-config config_complete_id emitted
SendPackets --> AdminEdit: Admin.begin_edit_settings
AdminEdit --> SendPackets: Admin.commit_edit_settings response drained
SendPackets --> DeferredSave: admin response queues drained
DeferredSave --> SendPackets: save/apply/restart hooks executed
SendPackets --> ConfigFlow: new ToRadio.want_config_id
Connected --> Advertising: disconnect + advertising restart
Pairing --> Advertising: disconnect
SendNothing --> Advertising: disconnect
ConfigFlow --> Advertising: disconnect
SendPackets --> Advertising: disconnect
State invariants:
Advertisingmeans Meshtastic service UUID is visible, not that Android has completed setup.Connectedmeans GAP connection exists; it is not a PhoneAPI steady-state.SendNothingmeans GATT is connected but the PhoneAPI session has not entered config or packet delivery. Steady-state frames, including MQTT proxy, are forbidden.ConfigFlowownsconfig_nonce_and snapshot indexes while config frames are being emitted.config_complete_idis the phase boundary. Stage 1 (CONFIG_NONCE=69420) returns immediately toSendNothing; Stage 2 (NODE_INFO_NONCE=69421) entersSendPacketsimmediately.SendPacketsis the only normal steady-state whereFromRadio.packet, non-livenessFromRadio.queueStatus,FromRadio.mqttClientProxyMessage, client notifications, and normal Admin traffic may be emitted.ToRadio.heartbeatis the onlySendNothingexception: firmware may emit itsFromRadio.queueStatusliveness response without entering steady-state packet delivery. This does not permit MQTT proxy frames, normal packets, Admin traffic, or config frames inSendNothing.DeferredSavemust happen after queue status and Admin response frames have been offered to the phone.- Any implementation that derives
SendPacketsfrom!config_flow_active_alone is wrong because it collapsesSendNothingand steady-state into one bool.
Startup And Connect Sequence
sequenceDiagram
autonumber
participant App as AppContext / BleManager Owner
participant BM as BleManager
participant NimBLE as NimBLEDevice
participant Svc as MeshtasticBleService
participant Core as MeshtasticPhoneSession/Core
participant Phone as Android App
App->>BM: start/restart BLE for active protocol
BM->>BM: check protocol and internal RAM headroom
BM->>NimBLE: init(device_name), setPower(P9)
BM->>Svc: new MeshtasticBleService(ctx, device_name)
Svc->>Svc: loadBleConfig(), loadModuleConfig()
Svc->>NimBLE: setMTU(), apply security
Svc->>Svc: create GATT service and characteristics
Svc->>Svc: start advertising Mesh Service UUID
Svc->>Core: create session with AppPhoneFacade hooks
Phone->>Svc: GAP connect
Svc->>Svc: reset connection flags, queues, duplicate detector
Svc->>Core: close/reset previous session state
Core->>Core: phase = SendNothing
Svc->>Phone: connection params, battery characteristic ready
Phone->>Svc: subscribe FromNum
Important:
BleManagercreatesMeshtasticBleServiceonly for Meshtastic protocol. MeshCore getsMeshCoreBleService/ NUS.- BLE start may be skipped if internal RAM headroom is below threshold. That is a startup resource failure, not a Meshtastic protocol state.
- On every connect/disconnect, queues and session state must be reset. Reusing a dirty config flow across Android reconnect is forbidden.
ToRadio Ingress Activity
flowchart TD
A["Android writes ToRadio bytes"] --> B{"len valid and <= meshtastic_ToRadio_size?"}
B -- no --> B1["drop invalid write"]
B -- yes --> C{"duplicate of last write?"}
C -- yes --> C1["drop duplicate"]
C -- no --> D["enqueue into from_phone_queue max depth 3"]
D --> E["MeshtasticBleService.update()"]
E --> F["handleFromPhone() drains queue"]
F --> G["MeshtasticPhoneSession.handleToRadio()"]
G --> H{"ToRadio variant"}
H -->|packet| I["handleToRadioPacket()"]
H -->|want_config_id| J["enqueueConfigSnapshot(nonce)"]
H -->|mqttClientProxyMessage| K{"phase == SendPackets?"}
K -- yes --> K1["Mqtt hook -> radio adapter"]
K -- no --> K2["ignore and log: not ready"]
H -->|heartbeat| L["enqueueQueueStatus(nonce, ok)"]
H -->|disconnect| M["reset session"]
Rules:
- GATT callback may validate, dedupe, and enqueue only.
- Protobuf decode and semantic handling happen in
MeshtasticPhoneCore. - Queue overflow may drop writes and must be diagnosed through
[BLE] fromPhone drop ...logs. - Duplicate suppression belongs to the transport queue only and must not be used to hide semantic retries in core.
ToRadio.mqttClientProxyMessageis a steady-state packet. It must be ignored beforeSendPackets, matching officialPhoneAPI.cpp.- Ignoring pre-steady-state MQTT proxy input is not message-loss by firmware; it means Android attempted broker->phone->device delivery before the device had completed PhoneAPI setup.
FromRadio Egress Activity
flowchart TD
A["Android reads FromRadio"] --> B{"to_phone_queue has frame?"}
B -- yes --> C["set characteristic value to frame bytes"]
B -- no --> P{"PhoneAPI phase"}
P -->|ConfigFlow| P1["try produce next config frame only"]
P -->|SendPackets| P2["try produce steady-state frame"]
P -->|SendNothing| D{"shouldBlockOnRead()?"}
P1 --> F{"frame produced?"}
P2 --> F
D -- yes --> E["mark read_waiting and wait briefly for update() to produce frame"]
D -- no --> H["return zero-length value"]
E --> F{"frame produced?"}
F -- yes --> C
F -- no --> H
C --> G["Android decodes meshtastic_FromRadio"]
H --> I["Android treats current drain as complete"]
Android does not read FromRadio only after a FromNum notification. The official app also
actively drains FromRadio after ToRadio writes and during config setup. Therefore a transport
with pre-published slots must treat "frame is encoded in a readable slot" as sufficient for a
non-empty read. FromNum is a wakeup/announcement signal; it must not be used as a read-permission
gate.
shouldBlockOnRead() must be true when:
- steady-state packet sending is active after a
FromNumnotify; - config flow is active.
This is not optional. During config/setup, returning zero-length before config_complete_id
can make the Android App stop draining FromRadio, causing symptoms such as Nodes(0) or
stuck setup screens.
On transports that preload FROMRADIO outside the GATT read callback, consuming a read frame must atomically
prefer the next available frame over a temporary empty characteristic value. In practice this means:
- after a non-empty read, mark that preloaded frame consumed;
- immediately ask
PhoneAPI/MeshtasticPhoneCorefor the next frame in the same main-loop turn; - write a 0-length
FROMRADIOvalue only if there is truly no next frame.
Publishing an empty value between two real config frames is a protocol-visible empty read, even if the gap lasts only one scheduler slice.
MQTT proxy egress rule:
FromRadio.mqttClientProxyMessagebelongs only toSendPackets.popToPhone()must not poll or pop the MQTT proxy queue while phase isSendNothingorConfigFlow.- A pre-handshake read may return config data or no frame, but it must not consume MQTT proxy data.
- This rule is stronger than queue priority. The first decision is phase, then variant priority within that phase.
- Within
SendPackets, local identity/message projections have priority over MQTT proxy frames:queueStatus -> node_info -> packet -> deferred save/apply/restart -> mqttClientProxyMessage -> empty. - MQTT proxy is still a bounded P3 projection, but it must not be starved forever by a continuous P2
latest-value stream. After a bounded number of P2/P3 deferrals, firmware may emit one pending
mqttClientProxyMessagebefore more P2/P3 frames. This fairness rule never applies beforeSendPackets, never overtakes P0/P1 frames, and never overtakes deferred save/apply/restart side effects.
FromNum/FromRadio binding rule:
FromNumnotification must describe a frame that is already queued or preloaded forFromRadio.FromRadiomust not return empty solely because the head frame has not yet been notified. Android may consume that frame through proactive drain before the wakeup notification is sent.- Every encoded
FromRadioprojection carries core-producedkindandprioritymetadata. The transport publishes and sheds by this metadata; it must not parse protobuf payloads, inspect payload length, or infer semantics fromfrom_numto decide priority. - A transport must not keep an independent pending
from_numring that can drift ahead of the readable frame. - If a transport uses a monotonic notify token, it must log the semantic
sourceseparately. - If a transport uses the frame
from_numas the notify value, that value must be read from the same preloaded frame. - After Android reads a frame, the consumed slot is released. The next frame may then be preloaded and notified; unread frames must not be overtaken by later notifications.
- If proactive drain consumes a not-yet-notified head frame, the transport must skip/cancel the wakeup for that consumed slot and bind any later notification to the new head frame.
- A transport may keep a smaller steady-state published window than its physical slot capacity so ordinary packet projections cannot build a long unread FIFO in front of later liveness/control frames. Config flow may use the full published capacity because config snapshot ordering is the connection-completion boundary.
Android App And Firmware Interaction Contract
This section is normative. It describes the official Android App / firmware behavior that Trail Mate must mirror unless we intentionally document a product-level divergence.
App-Level Connection Is Not GATT Connection
sequenceDiagram
autonumber
participant Android as Meshtastic Android App
participant BLE as BLE Transport
participant Core as PhoneAPI/Core
participant MQTT as Android MQTT Proxy
Android->>BLE: GATT connect + service discovery
Android->>BLE: subscribe FROMNUM / LOGRADIO
BLE->>Core: onConnect()
Core->>Core: phase = SendNothing
Android->>Core: ToRadio.heartbeat
Android->>Core: ToRadio.want_config_id(CONFIG_NONCE)
Core->>Core: phase = ConfigFlow
Android->>Core: drain FROMRADIO until config_complete(CONFIG_NONCE)
Core->>Core: phase = SendNothing after Stage 1 config_complete_id
Android->>Core: ToRadio.want_config_id(NODE_INFO_NONCE)
Core->>Core: phase = ConfigFlow
Android->>Core: drain FROMRADIO until config_complete(NODE_INFO_NONCE)
Core->>Core: phase = SendPackets after Stage 2 config_complete_id
Android->>Android: app connection state = Connected
Android->>MQTT: startProxy(enabled, proxy_to_client_enabled)
Rules:
- Android may show
Connectingwhile GATT is already connected. Firmware must treat this as pre-steady-state. - Android starts MQTT proxy from synchronized module config after node DB readiness. Firmware must not assume Android MQTT is ready during
SendNothingorConfigFlow. FromNumcan wake reads, but Android also proactively drains after writes. EarlyFromRadiocontents are therefore observable even without a notify.
BLE Session Liveness Observation
App connected/online 是 Android/iOS 基于多个事实投影出来的 UI 状态,不是单一 BLE 字段。诊断时必须同时观察:
- BLE transport session: GAP connected、secured、bonded、MTU、connection interval、supervision timeout。
- Notification readiness:
FromNumCCCD 是否订阅、最近一次FromNumnotify、最近一次FromRadioread。 - PhoneAPI phase:
SendNothing、ConfigFlow、SendPackets。 - App liveness traffic: 最近一次
ToRadio.heartbeat、ToRadio.want_config_id、FromRadio.queueStatus。
Trail Mate nRF Meshtastic BLE implementation must emit a low-rate session trace using one session_seq per transport session:
[BLE][nrf52][mt][session] seq=... tag=... detail=... age_ms=... connected=... gap=...
Required tags:
| Tag | Meaning |
|---|---|
link_up |
GAP connected; previous PhoneAPI session and unread FromRadio slots have been closed/reset. |
secured |
The BLE link completed security. This does not by itself mean Android completed PhoneAPI config sync. |
from_num_cccd_on / from_num_cccd_off |
Android subscribed/unsubscribed FromNum; without subscription, FromRadio data may still be read proactively but wakeup semantics are weaker. |
want_config |
Android wrote ToRadio.want_config_id; this starts/restarts PhoneAPI config snapshot. |
heartbeat |
Android wrote ToRadio.heartbeat; firmware must enqueue a non-empty FromRadio.queueStatus for liveness when phase allows it. |
phase_change |
PhoneAPI phase changed while handling a ToRadio write. |
phone_disconnect |
Android wrote ToRadio.disconnect; firmware must request a real GAP/GATT disconnect, not only reset PhoneAPI state. |
periodic |
Low-rate snapshot while connected, used to diagnose stale app UI state when no edge event occurs. |
link_down |
GAP disconnected; PhoneAPI session and unread published slots must be closed/reset. |
Important distinction:
- If logs show
connected=1,gap=1,notify=1,send=1, and heartbeat/read ages are fresh, firmware should treat BLE transport as alive even if the app UI temporarily says offline. - If app UI says offline while
FromRadiomessages still drain, the suspect boundary is app-level connection projection or config freshness, not immediate packet transport. - Do not fix this symptom by forcing a fake online state or by emitting out-of-phase config frames. The proper fix is to align transport session lifecycle,
want_confighandling, heartbeat response, andFromRadiodrain ordering with official firmware.
Transport lifecycle rules:
ToRadio.disconnectis a phone transport lifecycle command. Handling it is not complete until the platform BLE runtime requests a real link disconnect.PhoneCoremay reset PhoneAPI state, but it must delegate physical disconnect to the transport/runtime owner.- Platform runtimes must route the resulting GAP/GATT disconnect through the same
link_downcleanup path used by remote disconnects: close PhoneAPI session, release publishedFromRadioslots, clear pending reads/notifies, clear pairing UI state, then restart advertising as appropriate. - nRF runtimes must also defend against half-open sessions: if GAP still reports connected but no phone-side liveness traffic is observed for the stale-session window, the runtime should proactively disconnect the old link so Android can perform a fresh connection handshake.
MQTT Proxy Reliability Window
sequenceDiagram
autonumber
participant Mesh as Mesh / Radio Adapter
participant Queue as MQTT Proxy Queue
participant Core as PhoneAPI/Core
participant Android as Android App
participant Broker as MQTT Broker
Mesh->>Queue: queue device->phone MQTT proxy message
Android--xCore: BLE disconnect / Android background
Core->>Core: close session, phase = SendNothing
Note over Queue: queued MQTT proxy messages are retained within bounded queue
Android->>Core: reconnect + want_config
Core->>Android: config / node info frames only
Android->>Core: drain completes both stages
Core->>Core: phase = SendPackets
Android->>Broker: start or resume MQTT proxy
Android->>Core: read FROMRADIO after FromNum / active drain
Core->>Queue: pop next MQTT proxy message
Core->>Android: FromRadio.mqttClientProxyMessage
Android->>Broker: publish
Rules:
- Firmware only guarantees a bounded in-memory window, not infinite offline MQTT history.
- The bounded queue should use drop-oldest behavior when full, matching official firmware's MQTT proxy queue semantics.
- Session close may release the current in-flight
mqttClientProxyMessage, but it must not clear the queue of messages not yet handed to the phone. - The queue is consumed only after
SendPackets. This prevents Android from dropping device->broker proxy messages while MQTT proxy is not active. - Broker->phone->device messages received by Android before firmware reaches
SendPacketsmust be ignored by firmware, matching officialPhoneAPI.
Phase-Gated Variant Matrix
| Variant | SendNothing | ConfigFlow | SendPackets |
|---|---|---|---|
ToRadio.want_config_id |
Start config snapshot. | Restart config snapshot only if official behavior allows same-session request; otherwise prefer transport restart recovery. | Start requested config snapshot. |
ToRadio.heartbeat |
May enqueue queue status for liveness if transport requires it. | Must not interrupt config snapshot ordering. | May enqueue queue status. |
ToRadio.packet |
Reject or ignore except explicitly supported local setup traffic. | Reject or ignore except official passphrase/admin exceptions. | Handle packet / Admin / app data. |
ToRadio.mqttClientProxyMessage |
Ignore and log. | Ignore and log. | Validate and inject MQTT downlink. |
FromRadio.config* variants |
Not emitted until want_config_id. |
Allowed, ordered by config snapshot rules. | Only emitted for explicit config snapshot request. |
FromRadio.node_info |
Not emitted until want_config_id. |
Allowed in node snapshot. | Allowed only for explicit steady-state projection events. |
FromRadio.queueStatus |
Avoid unless required for heartbeat liveness. | Must not overtake config frames. | Allowed before MQTT proxy. |
FromRadio.mqttClientProxyMessage |
Forbidden and must not consume queue. | Forbidden and must not consume queue. | Allowed after local status, identity, packet, and deferred-save drain. |
FromRadio.packet |
Forbidden. | Forbidden except explicitly allowed setup/admin responses. | Allowed. |
Implementation consequence:
flowchart TD
A["popToPhone()"] --> B{"phase"}
B -->|SendNothing| C["return no frame; do not poll MQTT queue"]
B -->|ConfigFlow| D["emit next config snapshot frame"]
B -->|SendPackets| F["emit steady-state queues"]
F --> G{"priority"}
G -->|1| H["queueStatus"]
G -->|2| I["node_info"]
G -->|3| J["packet"]
G -->|4| K["deferred save/apply/restart"]
G -->|5| L["mqttClientProxyMessage"]
Config Snapshot Flow
Android requests configuration by writing ToRadio.want_config_id. The firmware must respond with a deterministic
sequence of FromRadio frames and finish with config_complete_id.
Known special nonces:
| Nonce | Name | Meaning |
|---|---|---|
69420 |
stage1_config |
Config-focused snapshot. Sends my_info, deviceuiConfig, metadata, channels, config, module configs, then complete. Must not send node_info. |
69421 |
stage2_nodes |
Node-focused snapshot. Sends self node and peer nodes, then complete. Must not send metadata/channels/config/module configs. |
| Other | stage_unknown |
Full compatibility snapshot. Sends all supported sections in order. |
Stage 1 Config Sequence
sequenceDiagram
autonumber
participant Phone as Android App
participant BLE as MeshtasticBleService
participant Core as MeshtasticPhoneCore
participant App as AppPhoneFacade/App State
Phone->>BLE: Write ToRadio.want_config_id = 69420
BLE->>Core: handleToRadio()
Core->>Core: enqueueConfigSnapshot(69420)
Core->>BLE: notifyFromNum(source=69420)
Phone->>BLE: Read FromRadio repeatedly
BLE->>Core: popToPhone()
Core-->>Phone: my_info
Core-->>Phone: deviceuiConfig
Core-->>Phone: metadata
loop channels 0..7
Core->>App: buildChannel(slot)
Core-->>Phone: channel
end
loop config types
Core->>App: buildConfig(type)
Core-->>Phone: config
end
loop module config types
Core-->>Phone: moduleConfig
end
Core-->>Phone: config_complete_id = 69420
Phone->>BLE: Read FromRadio
BLE-->>Phone: zero-length drain complete
Stage 2 Nodes Sequence
sequenceDiagram
autonumber
participant Phone as Android App
participant BLE as MeshtasticBleService
participant Core as MeshtasticPhoneCore
participant App as AppPhoneFacade/App State
Phone->>BLE: Write ToRadio.want_config_id = 69421
BLE->>Core: handleToRadio()
Core->>Core: enqueueConfigSnapshot(69421)
Core->>BLE: notifyFromNum(source=69421)
Phone->>BLE: Read FromRadio repeatedly
Core-->>Phone: node_info(self)
loop app phone node store
Core->>App: getPhoneNodeByIndex(index)
App-->>Core: PhoneNodeView
Core-->>Phone: node_info(peer)
end
Core-->>Phone: config_complete_id = 69421
Phone->>BLE: Read FromRadio
BLE-->>Phone: zero-length drain complete
Invariants:
- Every encoded
FromRadiogets a strictly increasingFromRadio.id. MeshtasticBleFrame.from_numfor config snapshot frames is the config nonce, except peernode_infoframes may use the peer node id asfrom_num.config_complete_idmust be emitted exactly once per config snapshot flow.- After
config_complete_id, the PhoneAPI phase changes immediately:CONFIG_NONCE=69420returns toSendNothing;NODE_INFO_NONCE=69421and normal full config enterSendPackets. - The next
popToPhone()after completion may return false only because no steady-state frame is available. It must not be used as a deliberate phase-transition sentinel. - Peer
node_infoframes must only be emitted for nodes with visible identity facts (short_nameorlong_name). Observation-only entries must not be projected as empty users because Android/iOS can keep the empty fallback name in their node database. - MQTT
MAP_REPORT_APPis a valid upstream source of peer identity facts. Radio adapters must decode it into the shared node store before the phone node snapshot can project names such asHaibara.Ai (MQTT)and emoji short names.
Peer Identity Sources
flowchart LR
A["LoRa NODEINFO_APP / User"] --> D["core_chat decodeNodeMetadataPayload"]
B["MQTT MAP_REPORT_APP"] --> D
C["POSITION_APP"] --> E["decodePositionPayload"]
D --> F["NodeUpdate / NodeInfoUpdateEvent"]
E --> F
F --> G["ContactService / NodeStore"]
G --> H["AppPhoneFacade PhoneNodeView"]
H --> I["FromRadio.node_info"]
I --> J["Android/iOS node database"]
Rules:
- Node identity parsing belongs to
modules/core_chat; platform adapters must not separately decodeNodeInfo, legacyUser, orMapReport. MAP_REPORT_APPis public MQTT metadata. It may update local node identity and may be forwarded to the phone as packet data, but it must not be LoRa-transmitted as a normal MQTT downlink.- Public-key state is separate from public-key value. Metadata that lacks a key field must leave the existing key state untouched.
Pop Order
MeshtasticPhoneCore::popToPhone() must follow this rule:
Phase first. Priority second. Never consume a queue that is not legal in the current phase.
Phase 1: SendNothing
Allowed output:
- No frame.
Forbidden:
FromRadio.mqttClientProxyMessageFromRadio.packet- normal
FromRadio.queueStatus - deferred save/apply/restart side effects
Phase 2: ConfigFlow
Allowed output:
- Active config snapshot frame.
- No frame only when no config frame is currently available.
Forbidden:
- MQTT proxy egress.
- Steady-state packet egress.
- Popping MQTT proxy data from the queue.
- Deferred save/apply/restart side effects before config flow is done.
Phase 3: SendPackets
Within SendPackets, preserve this priority:
- Queue status frames.
- Node info projection frames queued outside config flow.
- Mesh packet frames.
- Deferred app config save.
- Deferred module config save.
- Deferred Bluetooth config save and enabled-state apply.
- Deferred restart after module config change.
- MQTT proxy message from radio/backend to phone.
- No frame.
Do not reorder this casually. In particular, MQTT proxy must not overtake active config frames, node identity projection, mesh packet projection, or deferred saves. Deferred save must not overtake phone-visible Admin responses. The exception is the bounded P2/P3 fairness window: a continuous low-priority packet stream must not prevent a pending MQTT proxy frame from ever reaching Android. This exception does not apply to P0/P1 frames or deferred side effects.
The response-drain-before-save rule exists because Android expects queue status and Admin response frames promptly. Blocking flash/NVS writes or restart before those frames are observable can make the App appear connected but stuck.
Admin Packet Handling
Android sends Admin operations as ToRadio.packet where:
MeshPacket.decoded.portnum == ADMIN_APP
packet.to == 0 or packet.to == self node id
Core behavior:
flowchart TD
A["ToRadio.packet"] --> B{"decoded payload?"}
B -- no --> B1["enqueueQueueStatus(id,false)"]
B -- yes --> C{"ADMIN_APP to self?"}
C -- yes --> D["handleAdmin(packet)"]
C -- no --> E{"local self response port?"}
E -- yes --> F["handleLocalSelfPacket(packet)"]
E -- no --> G["sendPhoneText / sendPhoneAppData"]
D --> H["enqueueQueueStatus(packet.id, ok)"]
F --> H
G --> H
Admin response order:
set_* request
-> mutate in-memory config snapshot / module config
-> apply immediate runtime effect when safe
-> enqueue queueStatus(packet.id, ok)
-> enqueue Admin response packet if required
-> after both are drained, perform durable save/apply/restart hooks
Lora Config Save
AdminMessage.set_config.lora must:
- update
MeshtasticPhoneConfigSnapshot.mesh; - call
setMeshtasticPhoneConfig(cfg); - mark config save as deferred unless inside edit transaction;
- call
applyMeshConfig()so radio runtime sees new LoRa settings; - return
get_config_response(LORA_CONFIG); - save durable config only after response drain.
This sequence protects the Android App from blocking on storage during the Admin response path.
MQTT Module Config Save
AdminMessage.set_module_config.mqtt must:
- update
module_config_.mqtt; - normalize legacy/default MQTT fields;
- mark module config save as deferred unless inside edit transaction;
- mark restart pending because MQTT module changes may require device restart;
- return
get_module_config_response(MQTT_CONFIG); - save module config only after response drain;
- restart only after module config save has been offered.
sequenceDiagram
autonumber
participant Phone as Android App
participant Core as MeshtasticPhoneCore
participant Facade as AppPhoneFacade
participant Storage as Preferences/AppConfig Store
participant Device as Device Runtime
Phone->>Core: ToRadio.packet(Admin.set_module_config.mqtt)
Core->>Core: update module_config_.mqtt in memory
Core->>Core: deferred_module_config_save_pending = true
Core->>Core: restart_pending = true
Core-->>Phone: FromRadio.queueStatus(packet.id, ok)
Core-->>Phone: FromRadio.packet(Admin.get_module_config_response)
Phone->>Core: continue reading FromRadio until empty
Core->>Facade: saveModuleConfig(module_config_)
Facade->>Storage: persist mt_mod/cfg
Core->>Device: restartDevice()
Bluetooth Config Apply
AdminMessage.set_config.bluetooth must:
- update
bluetooth_config_; - normalize fixed pin when
NO_PIN; - defer
saveBluetoothConfig()andsetBleEnabled()until response drain; - return
get_config_response(BLUETOOTH_CONFIG)before applying the enabled-state change.
If enabling BLE requires a reboot or service restart, that is a BLE manager/runtime lifecycle bug. The Admin path must not hide it by returning success before the requested runtime effect is possible.
Edit Transaction
stateDiagram-v2
[*] --> NoTransaction
NoTransaction --> EditOpen: begin_edit_settings
EditOpen --> EditOpen: set_channel / set_config / set_module_config
EditOpen --> NoTransaction: commit_edit_settings
EditOpen --> NoTransaction: disconnect or session reset
state EditOpen {
[*] --> Clean
Clean --> AppDirty: app config changed
Clean --> ModuleDirty: module config changed
Clean --> BluetoothDirty: bluetooth config changed
AppDirty --> MixedDirty: module or bluetooth changed
ModuleDirty --> MixedDirty: app or bluetooth changed
BluetoothDirty --> MixedDirty: app or module changed
}
Rules:
begin_edit_settingsopens a transaction and itself only needs queue status.- Set operations inside a transaction must not save immediately.
commit_edit_settingsconverts dirty flags into deferred save/apply/restart flags.- Durable save still happens after the commit response drain, not during commit handling.
Steady-State Packet Delivery
After config flow completes, normal phone traffic enters STATE_SEND_PACKETS equivalent behavior:
sequenceDiagram
autonumber
participant Radio as Mesh Adapter / App Data
participant Svc as MeshtasticBleService
participant Core as MeshtasticPhoneCore
participant Phone as Android App
Radio->>Svc: incoming text/app-data event
Svc->>Core: onIncomingText/onIncomingData
Core->>Core: project NodeInfo if identity changed
Core->>Core: build MeshPacket and queue
Svc->>Core: pop/preload next FromRadio frame
Svc-->>Phone: FromNum notify(bound to preloaded frame)
Phone->>Svc: Read FromRadio
Core-->>Phone: FromRadio.node_info or FromRadio.packet
Phone->>Svc: Read FromRadio until empty
When App sends:
TEXT_MESSAGE_APP: route throughsendPhoneText().- Other app-data: route through
sendPhoneAppData(). - self telemetry/position/nodeinfo request: may be answered locally by
handleLocalSelfPacket(). - unsupported self loopback ports without response: suppress and report queue success.
MQTT Proxy In Steady State
sequenceDiagram
autonumber
participant Broker as MQTT Broker
participant Android as Android MQTT Proxy
participant Core as PhoneAPI/Core
participant Radio as Mesh Adapter
Broker->>Android: subscribed MQTT message
Android->>Core: ToRadio.mqttClientProxyMessage
Core->>Core: require phase == SendPackets
Core->>Radio: handleMqttProxyToRadio()
Radio->>Radio: validate channel, decode envelope, mark via_mqtt
Radio->>Radio: inject packet / update node store
Radio->>Core: queueMqttProxyPublish(packet)
Core->>Core: require phase == SendPackets before pop
Core->>Android: FromRadio.mqttClientProxyMessage
Android->>Broker: publish
Rules:
- MQTT downlink into the device is legal only after
SendPackets. - MQTT uplink out of the device may be queued before Android reconnects, but may be consumed only after
SendPackets. - The bounded MQTT proxy queue is a loss boundary. If it fills, drop-oldest is acceptable and must be logged; silent early consumption is not acceptable.
- MQTT proxy egress is lower priority than local
NodeInfo/MeshPacketprojection. A broker burst must not delay the identity frames Android needs to render sender name, emoji,(MQTT), and cloud state.
Runtime Concurrency Rules
This BLE path must satisfy RUNTIME_CONCURRENCY_SPEC.md:
BLE callback owns transport timing only.
BLE callback must not directly mutate app services.
Mutable app-service state must have a single owner context.
Storage work must not run inside BLE stack callback.
Allowed in GATT callback:
- validate length;
- copy bytes;
- queue frame;
- update connection flags;
- set characteristic value for read response.
Forbidden in GATT callback:
- call
AppContext::saveConfig(); - call
applyMeshConfig(); - call radio send;
- restart device;
- perform protobuf business decisions;
- walk node store;
- mutate UI directly.
ChannelSettings Contract
meshtastic_ChannelSettings is the protocol object. Local app config and phone snapshots are projections of it, not definitions of it.
Current required round-trip fields:
| Protocol field | Local projection | Notes |
|---|---|---|
settings.name |
MeshConfig.primary_channel_name / secondary_channel_name |
Keep bounded string semantics. |
settings.id |
MeshConfig.primary_channel_id / secondary_channel_id |
Preserve numeric channel identity. |
settings.psk |
MeshConfig.primary_key / secondary_key plus key length |
Shorthand PSK may expand internally but must be preserved in Admin response where applicable. |
settings.uplink_enabled |
primary_uplink_enabled / secondary_uplink_enabled |
MQTT gateway direction flag. |
settings.downlink_enabled |
primary_downlink_enabled / secondary_downlink_enabled |
MQTT gateway direction flag. |
settings.has_module_settings |
primary_channel_has_module_settings / secondary_channel_has_module_settings |
Presence is part of protocol state and must not be inferred away. |
settings.module_settings.position_precision |
primary_channel_position_precision / secondary_channel_position_precision |
Android location/precise-location channel setting. |
settings.module_settings.is_muted |
primary_channel_is_muted / secondary_channel_is_muted |
Per-channel muted state. |
classDiagram
class ChannelSettings {
name
id
psk
uplink_enabled
downlink_enabled
has_module_settings
module_settings.position_precision
module_settings.is_muted
}
class MeshtasticPhoneConfigSnapshot {
mesh channel fields
per-channel uplink/downlink
per-channel module settings presence
per-channel module settings values
}
class AppConfig {
persistent mesh channel fields
persistent per-channel module settings
}
ChannelSettings --> MeshtasticPhoneConfigSnapshot : set_channel / get_channel_response
MeshtasticPhoneConfigSnapshot --> AppConfig : bridge
Rules:
- Do not treat
ChannelSettingsas only name/id/psk/uplink/downlink. - Do not collapse
has_module_settings=falseandhas_module_settings=truewith default values; protobuf presence must round-trip. set_channelmust write the complete supportedChannelSettingsprojection beforeapplyMeshConfig().get_channel_responseand config snapshots must return the same supportedChannelSettingsprojection Android just saved.- Durable storage must persist the supported
ChannelSettingsprojection across restart. - New generated
ChannelSettingsfields must be classified here before they are ignored, persisted, or intentionally rejected.
Bypass Cleanup Rules
Any future change touching this area must remove or reject these bypasses:
| Bypass | Why forbidden | Correct path |
|---|---|---|
Direct App mutation from NimBLECharacteristicCallbacks::onWrite() |
Violates runtime ownership and makes callback timing define protocol semantics. | Enqueue bytes, drain in MeshtasticBleService.update(), interpret in MeshtasticPhoneCore. |
Immediate config save inside handleAdmin() |
Can stall Android before queueStatus/Admin response drain; previously led to visible stuck states and stack pressure. | Mark deferred flag, save after popToPhone() has no response frames left. |
Extra Android-specific Nodes(0) workaround |
Treats App UI symptom as firmware state and risks breaking iOS/official BLE drain semantics. | Fix config snapshot ordering, blocking read semantics, config_complete_id, or node projection. |
Forcing empty FromRadio reads to unblock UI |
Premature empty read terminates Android drain early. | Return empty only after config complete or when steady-state queue is actually empty. |
| Creating Pager/TDeck-specific phone core variants | Board resource issues are real, but phone protocol semantics are not board facts. | Keep protocol in shared phone core; board/platform only adapt IO, memory placement, and capabilities. |
| Using MeshCore BLE code for Meshtastic App compatibility | MeshCore NUS and Meshtastic BLE protobuf are different protocols. | Keep MeshCoreBleService separate from MeshtasticBleService. |
Enabling FromRadioSync without spec/test coverage |
Android currently follows legacy FromRadio read drain. |
Add explicit spec and tests before enabling sync path. |
Emitting MQTT proxy before SendPackets |
Android starts MQTT proxy only after config/node readiness; early proxy frames can be consumed and dropped during Connecting. |
Gate MQTT proxy egress behind PhoneAPI phase and do not poll the queue before SendPackets. |
Treating !config_flow_active_ as steady-state |
Collapses SendNothing and SendPackets, allowing data before Android handshake completion. |
Add/maintain explicit PhoneAPI phase and make all variant gates use it. |
Diagnostic Log Contract
Useful logs and what they mean:
| Log prefix | Meaning |
|---|---|
[BLE] starting protocol=meshtastic uuid=... |
BLE manager selected Meshtastic BLE service and attempted start. |
[BLE] connected; uuid=... |
GAP connection established; config flow not necessarily done. |
[BLE] fromNum subscribe sub=... |
Android subscribed to notification trigger. |
[BLE] fromPhone write len=... |
Android wrote ToRadio; callback queued it. |
[BLE][mtcore] to_radio variant=... |
Phone core decoded ToRadio. |
[BLE][mtcore][flow] want_config ... |
Android requested config snapshot. |
[BLE][mtcore][phase] ... |
PhoneAPI phase changed. This is the authority for whether steady-state data can flow. |
[BLE][mtcore][mqtt] skip reason=not-send-packets ... |
MQTT proxy was intentionally not consumed or not injected because Android/firmware handshake is not complete. |
[BLE][mtcore][mqtt] pop ... |
MQTT proxy message was consumed for Android delivery; this must only appear in SendPackets. |
[BLE][mtcore][channel] ... module=... pos_prec=... muted=... |
Admin channel request/response/snapshot includes the supported module-settings projection. |
[BLE][mtcore][cfg#...] start ... |
Config flow became active. |
[BLE] toPhone enqueue from_num=... len=... |
A FromRadio frame was queued for Android read. |
[BLE] fromRadio read len=... |
Android read a non-empty FromRadio frame. |
[BLE][mtcore][flow] cfg_complete ... |
config_complete_id was encoded. |
[BLE][mtcore][phase] SendPackets reason=config_complete_send_packets |
Stage 2/full config completed and steady-state is active. |
[BLE] fromRadio read empty |
Current drain round is complete. Before config_complete_id this is suspicious. |
[BLE][mtcore] admin handled variant=... |
Admin request produced a response or was accepted. |
[BLE][mtcore] queue status mesh_packet_id=... ok=... |
QueueStatus frame queued and FromNum notified. |
[BLE][mtcore] deferred config save after response drain |
Durable config save starts only after phone-visible responses are drained. |
Symptom mapping:
| Symptom | Most likely violated rule |
|---|---|
Android stuck at Module config received |
Admin response or module config response did not drain; save/restart happened too early; FromRadio empty arrived before expected response. |
Android stuck at Nodes(0) |
Stage 2 nodes snapshot missing self/peer node_info; premature empty read; config_complete_id missing; fromNum notify/read loop broken. |
| App connects but never finishes setup | want_config_id not handled; config flow inactive; shouldBlockOnRead() false during config; queue full/drop. |
App UI says offline / 尚未联机, but peer messages still appear |
GATT and data-plane are alive, but Android app-level connection projection did not complete or was reset. Check config/node handshake completion, heartbeat response freshness, and whether proactive FromRadio drain was prematurely ended by an empty read such as an unread-but-not-notified frame. |
get_device_connection_status_response.bluetooth is part of the app-level
connection projection. ESP and nRF transports must compute it from the same
rule: is_connected comes from the active BLE runtime, while pin is resolved
from the persisted Bluetooth config plus any currently pending pairing
passkey. A connected session must not report a zero PIN solely because there is
no active pairing prompt when the configured mode is fixed PIN.
| MQTT messages are lost throughout Android Connecting | FromRadio.mqttClientProxyMessage was emitted before SendPackets, or ToRadio.mqttClientProxyMessage was accepted before config/node handshake completed. |
| Save config causes reboot | Save path stack/heap/storage bug after response drain; not a BLE protocol success/failure by itself. |
| BLE setting says enabled but not visible until reboot | BLE manager/runtime lifecycle failed to start or restart advertising at runtime; do not hide with Admin response bypass. |
Acceptance Tests
Shared phone core tests must continue to assert:
- invalid Meshtastic bytes are rejected;
set_channelupdates in-memory config, applies mesh runtime, sends queue status, sends Admin response, and saves only after response drain;set_channel.settings.module_settingspresence,position_precision, andis_mutedare preserved in config, response, and storage projection;- primary channel shorthand PSK is preserved in response while expanded in internal config;
- manual LoRa config updates all expected fields and saves only after response drain;
- Bluetooth config disables runtime only after response drain;
- edit transaction defers saves until commit and then still saves only after commit response drain;
- MQTT module config responds before module save and restart;
want_config_id=69420sends config-only frames and complete, without node_info;want_config_id=69421sends self/peer node_info and complete, without config/module frames.config_complete_id=69421entersSendPacketsimmediately, without a one-shot drain-empty transition state.ToRadio.heartbeatinSendPacketsemits a non-emptyQueueStatusand notifies the same nonce throughFROMNUM.popToPhone()does not poll or consume MQTT proxy messages inSendNothingorConfigFlow.- queued MQTT proxy messages survive a phone session close unless already handed to the phone.
ToRadio.mqttClientProxyMessageis ignored beforeSendPackets.- after the stage 2 node handshake completes and the phase enters
SendPackets, queued MQTT proxy messages become drainable.
Device-level verification should capture:
[BLE] connected
[BLE] fromNum subscribe
[BLE][mtcore][flow] want_config nonce=00010F2C stage=stage1_config
[BLE][mtcore][flow] cfg_complete stage=stage1_config nonce=00010F2C
[BLE][mtcore][flow] want_config nonce=00010F2D stage=stage2_nodes
[BLE][mtcore][flow] cfg_complete stage=stage2_nodes nonce=00010F2D
[BLE][mtcore][phase] SendPackets
[BLE] fromRadio read empty
For MQTT reconnect-window verification:
[BLE][mtcore][mqtt] skip reason=not-send-packets ...
[BLE][mtcore][flow] cfg_complete stage=stage2_nodes ...
[BLE][mtcore][phase] SendPackets
[BLE][mtcore][mqtt] pop ...
The pop log must not appear before SendPackets.
For Admin save verification:
[BLE][mtcore] admin handled ...
[BLE][mtcore] queue status ...
[BLE] fromRadio read len=...
[BLE][mtcore] deferred config save after response drain
[AppCfg][SAVE_ASYNC] flush begin ...
The save log must come after phone-visible response frames have been drained.
Future Change Checklist
Before modifying this path, answer these questions:
- Is this change in transport, protocol core, app facade, or app service?
- Does it add a second interpretation of Meshtastic phone protocol outside
MeshtasticPhoneCore? - Can Android observe queue status, Admin response, config frames, and
config_complete_idin the expected order? - Can
FromRadioreturn zero-length beforeconfig_complete_id? - Does any BLE callback directly save config, apply mesh config, restart, or send radio data?
- Does the change treat
Nodes(0)orModule config receivedas truth instead of symptoms? - Does the change preserve response-drain-before-save?
- Does MQTT proxy delivery happen only in
SendPackets? - Does any queue get consumed before its variant is legal for the current PhoneAPI phase?
- Does the change accidentally route MeshCore through Meshtastic BLE semantics?
- Are Pager/TDeck differences limited to resource, memory placement, board capability, or BLE manager lifecycle?
- Are smoke tests updated when behavior changes?
If any answer indicates a bypass, fix the main path first. Do not add another branch around Android.