Files
trail-mate/docs/specification/MESHTASTIC_ANDROID_BLE_CONNECTION_SPEC.md

1145 lines
56 KiB
Markdown

# Meshtastic Android BLE Connection Specification
This document solidifies the BLE connection, configuration synchronization, and
NodeDB synchronization, Admin configuration writing, MQTT module config saving, and fault diagnosis boundaries.
The goal is not to interpret the logs of a successful connection, but to specify the main path that subsequent AI and engineers must follow.
Any fix for Android App stuck at `Module config received`, `Nodes(0)`, unable to complete connection, save configuration
If there is a crash or abnormal BLE configuration effect, you must first return to this document to confirm whether the main path is damaged.
## Current Baseline
This specification is based on two sets of facts:
1. PhoneAPI interaction rules for official Meshtastic Android App / firmware.
2. Trail Mate currently shares the implementation of `MeshtasticPhoneCore`, ESP32 BLE transport, and nRF52 BLE transport.
Current official source code anchor:
- Android App: `.tmp/official/Meshtastic-Android` commit `e634e71`
- Firmware: `.tmp/official/firmware` commit `0488a46`
Current Trail Mate source code anchor:
- `platform/esp/arduino_common/src/ble/ble_manager.cpp`
- `platform/esp/arduino_common/src/ble/meshtastic_ble.cpp`
- `platform/esp/arduino_common/src/ble/meshtastic_ble_owner_hooks.cpp`
- `platform/esp/arduino_common/src/ble/app_phone_facade.cpp`
- `platform/nrf52/arduino_common/src/ble/meshtastic_ble.cpp`
- `platform/nrf52/arduino_common/src/ble/app_phone_facade.cpp`
- `modules/core_phone/src/meshtastic/meshtastic_phone_session.cpp`
- `modules/core_phone/src/meshtastic/meshtastic_phone_core.cpp`
- `modules/core_phone/include/phone/meshtastic/meshtastic_phone_core.h`
- `modules/core_phone/tests/test_phone_core_smoke.cpp`
The GitNexus index may lag behind the current HEAD. This document is based on the current workspace source code.
## Core Distinctions
### True Objects
| Object | Meaning | Owner |
| --- | --- | --- |
| Meshtastic Android App | External BLE client, writes `ToRadio`, reads `FromRadio`, and subscribes to `FromNum` according to the official Meshtastic BLE GATT convention. | External system |
| Meshtastic BLE Transport | NimBLE GAP/GATT, advertising, pairing, characteristic callbacks, notify/read/write queue. | `MeshtasticBleService` |
| Phone Protocol Session | The protocol status within a mobile phone connection: PhoneAPI phase, configuration flow, queue status, packet queue, deferred save flag. | `MeshtasticPhoneSession` / `MeshtasticPhoneCore` |
| PhoneAPI Phase | Official `PhoneAPI` semantic states: `SEND_NOTHING`, config snapshot phase, `SEND_PACKETS`. It determines which `ToRadio`/`FromRadio` variants are legal. | `MeshtasticPhoneCore` |
| Meshtastic Phone Protocol Core | `ToRadio`/`FromRadio` protobuf semantics, Admin processing, config snapshot frame sequence, NodeInfo/Channel/Config/ModuleConfig projection. | `MeshtasticPhoneCore` |
| MQTT Client Proxy Queue | device->phone->MQTT message queue to be delivered. It is PhoneAPI steady-state data, not config data. | shared phone core / app facade / radio adapter |
| App Facade | Port between Phone core and Trail Mate App states. | `AppPhoneFacade` |
| App State | Actual 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` | It is only displayed on the App side and cannot prove that the firmware has completed the config flow or saved successfully. |
| Android UI text such as `Nodes(0)` | is just the App's current NodeDB view and cannot be used as the source of truth for the firmware node store or BLE queue. |
| `fromNum` characteristic value | is a signal to wake up Android to continue reading `FromRadio`; transport can send a monotonic token or the current pre-framed `from_num`, but it is not an independent business queue. |
| BLE connected flag | Only proves that the GAP connection exists, but does not prove that the Meshtastic config snapshot is completed. |
| Android MQTT connected status | It is just the network status of the Android MQTT client. It does not prove that the firmware has entered `SEND_PACKETS`, nor does it prove that `FromRadio.mqttClientProxyMessage` can be safely delivered. |
| `fromRadio` zero-length read | is a drain end signal, not an error; however, appearing prematurely before the configuration flow is completed will cause the App to stop in an unfinished state. |
| `fromRadioSync` | Currently `kEnableFromRadioSync=false`, not the Android main path. |
### Forbidden Concept Drift
The following methods are illegal:
- Treat `MeshtasticBleService` as the Meshtastic protocol semantic owner.
- Treat the UI copy of Android App as a firmware state machine.
- Treat the notify value of `fromNum` as packet id or config nonce.
- Copy a separate BLE config flow for Pager, TDeck, and a certain version of Android.
- Directly modify App service, save configuration, restart device or call radio adapter in GATT callback.
- Perform blocking save immediately during `set_config` / `set_module_config`, bypassing response drain.
- Bypass fix `Nodes(0)` with `fromRadioSync`, additional notify, forced empty read, forced restart of BLE, etc.
- Let the MeshCore BLE service reuse the protobuf / GATT semantics of the Meshtastic Android App.
- Make `config_flow_active_ == false` directly equivalent to the official `STATE_SEND_PACKETS`.
- Deliver or consume `FromRadio.mqttClientProxyMessage` while Android is still in `Connecting` / config handshake.
- Handle `ToRadio.mqttClientProxyMessage` and inject mesh when PhoneAPI does not enter `SEND_PACKETS`.
- Treat the internal MQTT queue of the radio adapter as the reliable delivery state of the Android side; the truth of deliverability belongs to the PhoneAPI phase.
## GATT Contract
The Meshtastic Android App is connected to the Meshtastic BLE service, not the MeshCore NUS service.
| Name | UUID | Direction | Role |
| --- | --- | --- | --- |
| Mesh Service | `6ba1b218-15a8-461f-9fa8-5dcae273eafd` | service | Meshtastic Android App discovery portal. |
| `ToRadio` | `f75c76d2-129e-4dad-a1dd-7866124401e7` | phone writes | App writes nanopb-encoded `meshtastic_ToRadio`. |
| `FromRadio` | `2c55e69e-4993-11ed-b878-0242ac120002` | phone reads | App reads nanopb-encoded `meshtastic_FromRadio`. |
| `FromNum` | `ed9da18c-a800-4f66-a670-aa7547e34453` | notify/read | Firmware notifies the App that new `FromRadio` data is available for reading. |
| `LogRadio` | `5a3d6e49-06e6-4423-9944-e9de8cdf9547` | read/notify | Log projection, does not participate in connection completion determination. |
| `FromRadioSync` | `888a50c3-982d-45db-9963-c7923769165d` | notify/indicate | Currently disabled, not the main path. |
| Battery | `0x180F/0x2A19` | read/notify | Android readable power projection, does not participate in Meshtastic config flow. |
Pairing mode:
- When `NO_PIN` is used, the characteristic does not require encryption authentication.
- When `RANDOM_PIN` or `FIXED_PIN` is used, `ToRadio`/`FromRadio`/`FromNum`/`LogRadio` must have corresponding authenticated/encrypted properties.
- Passkey display is generated by `MeshtasticServerCallbacks::onPassKeyDisplay()` and `AppPhoneFacade::loadDeviceConnectionStatus()` can be projected to App.
## Architecture Class Diagram
```mermaid
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:
```text
NimBLE callback -> MeshtasticBleService queue -> update() -> MeshtasticPhoneSession
-> MeshtasticPhoneCore -> AppPhoneFacade -> App services
```
Read-authorize rule for BLE transports without a push-style FromRadioSync
characteristic:
```text
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:
```text
App services / mesh adapter events -> MeshtasticBleService.update()
-> MeshtasticPhoneSession.popToPhone()
-> FromRadio queue / FromNum notify
```
## Connection State Model
```mermaid
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:
- `Advertising` means Meshtastic service UUID is visible, not that Android has completed setup.
- `Connected` means GAP connection exists; it is not a PhoneAPI steady-state.
- `SendNothing` means GATT is connected but the PhoneAPI session has not entered config or packet delivery. Steady-state frames, including MQTT proxy, are forbidden.
- `ConfigFlow` owns `config_nonce_` and snapshot indexes while config frames are being emitted.
- `config_complete_id` is the phase boundary. Stage 1 (`CONFIG_NONCE=69420`) returns immediately to `SendNothing`; Stage 2 (`NODE_INFO_NONCE=69421`) enters `SendPackets` immediately.
- `SendPackets` is the only normal steady-state where `FromRadio.packet`,
non-liveness `FromRadio.queueStatus`, `FromRadio.mqttClientProxyMessage`, client
notifications, and normal Admin traffic may be emitted.
- `ToRadio.heartbeat` is the only `SendNothing` exception: firmware may emit its
`FromRadio.queueStatus` liveness response without entering steady-state packet delivery.
This does not permit MQTT proxy frames, normal packets, Admin traffic, or config frames in
`SendNothing`.
- `DeferredSave` must happen after queue status and Admin response frames have been offered to the phone.
- Any implementation that derives `SendPackets` from `!config_flow_active_` alone is wrong because it collapses `SendNothing` and steady-state into one bool.
## Startup And Connect Sequence
```mermaid
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:
- `BleManager` creates `MeshtasticBleService` only for Meshtastic protocol. MeshCore gets `MeshCoreBleService` / 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
```mermaid
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.mqttClientProxyMessage` is a steady-state packet. It must be ignored before `SendPackets`, matching official `PhoneAPI.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
```mermaid
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 `FromNum` notify;
- 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`/`MeshtasticPhoneCore` for the next frame in the same main-loop turn;
- write a 0-length `FROMRADIO` value 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.mqttClientProxyMessage` belongs only to `SendPackets`.
- `popToPhone()` must not poll or pop the MQTT proxy queue while phase is `SendNothing` or `ConfigFlow`.
- 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
`mqttClientProxyMessage` before more P2/P3 frames. This fairness rule never applies before
`SendPackets`, never overtakes P0/P1 frames, and never overtakes deferred save/apply/restart side effects.
FromNum/FromRadio binding rule:
- `FromNum` notification must describe a frame that is already queued or preloaded for `FromRadio`.
- `FromRadio` must 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 `FromRadio` projection carries core-produced `kind` and `priority` metadata. The
transport publishes and sheds by this metadata; it must not parse protobuf payloads, inspect
payload length, or infer semantics from `from_num` to decide priority.
- A transport must not keep an independent pending `from_num` ring that can drift ahead of the readable frame.
- If a transport uses a monotonic notify token, it must log the semantic `source` separately.
- If a transport uses the frame `from_num` as 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
```mermaid
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 `Connecting` while 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 `SendNothing` or `ConfigFlow`.
- `FromNum` can wake reads, but Android also proactively drains after writes. Early `FromRadio` contents are therefore observable even without a notify.
### BLE Session Liveness Observation
`App connected/online` is a UI state projected by Android/iOS based on multiple facts, not a single BLE field. When making a diagnosis, you must also observe:
- BLE transport session: GAP connected、secured、bonded、MTU、connection interval、supervision timeout.
- Notification readiness: `FromNum` CCCD whether to subscribe, the latest `FromNum` notify, the latest `FromRadio` read.
- PhoneAPI phase: `SendNothing``ConfigFlow``SendPackets`.
- App liveness traffic: the latest `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:
```text
[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 `FromRadio` messages 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_config` handling, heartbeat response, and `FromRadio` drain ordering with official firmware.
Transport lifecycle rules:
- `ToRadio.disconnect` is a phone transport lifecycle command. Handling it is not complete until the platform BLE runtime requests a real link disconnect.
- `PhoneCore` may 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_down` cleanup path used by remote disconnects: close PhoneAPI session, release published `FromRadio` slots, 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
```mermaid
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 `SendPackets` must be ignored by firmware, matching official `PhoneAPI`.
### 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:
```mermaid
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
```mermaid
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
```mermaid
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 `FromRadio` gets a strictly increasing `FromRadio.id`.
- `MeshtasticBleFrame.from_num` for config snapshot frames is the config nonce, except peer `node_info`
frames may use the peer node id as `from_num`.
- `config_complete_id` must be emitted exactly once per config snapshot flow.
- After `config_complete_id`, the PhoneAPI phase changes immediately: `CONFIG_NONCE=69420` returns to `SendNothing`; `NODE_INFO_NONCE=69421` and normal full config enter `SendPackets`.
- 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_info` frames must only be emitted for nodes with visible identity facts
(`short_name` or `long_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_APP` is 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 as `Haibara.Ai (MQTT)` and emoji short names.
### Peer Identity Sources
```mermaid
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 decode `NodeInfo`, legacy `User`, or `MapReport`.
- `MAP_REPORT_APP` is 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:
```text
Phase first. Priority second. Never consume a queue that is not legal in the current phase.
```
### Phase 1: SendNothing
Allowed output:
1. No frame.
Forbidden:
- `FromRadio.mqttClientProxyMessage`
- `FromRadio.packet`
- normal `FromRadio.queueStatus`
- deferred save/apply/restart side effects
### Phase 2: ConfigFlow
Allowed output:
1. Active config snapshot frame.
2. 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:
1. Queue status frames.
2. Node info projection frames queued outside config flow.
3. Mesh packet frames.
4. Deferred app config save.
5. Deferred module config save.
6. Deferred Bluetooth config save and enabled-state apply.
7. Deferred restart after module config change.
8. MQTT proxy message from radio/backend to phone.
9. 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:
```text
MeshPacket.decoded.portnum == ADMIN_APP
packet.to == 0 or packet.to == self node id
```
Core behavior:
```mermaid
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:
```text
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.
```mermaid
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()` and `setBleEnabled()` 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
```mermaid
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_settings` opens a transaction and itself only needs queue status.
- Set operations inside a transaction must not save immediately.
- `commit_edit_settings` converts 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:
```mermaid
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 through `sendPhoneText()`.
- 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
```mermaid
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`/`MeshPacket` projection. 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`:
```text
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. |
```mermaid
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 `ChannelSettings` as only name/id/psk/uplink/downlink.
- Do not collapse `has_module_settings=false` and `has_module_settings=true` with default values; protobuf presence must round-trip.
- `set_channel` must write the complete supported `ChannelSettings` projection before `applyMeshConfig()`.
- `get_channel_response` and config snapshots must return the same supported `ChannelSettings` projection Android just saved.
- Durable storage must persist the supported `ChannelSettings` projection across restart.
- New generated `ChannelSettings` fields 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 / `Not yet online`, 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_channel` updates in-memory config, applies mesh runtime, sends queue status, sends Admin response, and saves only after response drain;
- `set_channel.settings.module_settings` presence, `position_precision`, and `is_muted` are 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=69420` sends config-only frames and complete, without node_info;
- `want_config_id=69421` sends self/peer node_info and complete, without config/module frames.
- `config_complete_id=69421` enters `SendPackets` immediately, without a one-shot drain-empty transition state.
- `ToRadio.heartbeat` in `SendPackets` emits a non-empty `QueueStatus` and notifies the same nonce through `FROMNUM`.
- `popToPhone()` does not poll or consume MQTT proxy messages in `SendNothing` or `ConfigFlow`.
- queued MQTT proxy messages survive a phone session close unless already handed to the phone.
- `ToRadio.mqttClientProxyMessage` is ignored before `SendPackets`.
- after the stage 2 node handshake completes and the phase enters `SendPackets`, queued MQTT proxy messages become drainable.
Device-level verification should capture:
```text
[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:
```text
[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:
```text
[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:
1. Is this change in transport, protocol core, app facade, or app service?
2. Does it add a second interpretation of Meshtastic phone protocol outside `MeshtasticPhoneCore`?
3. Can Android observe queue status, Admin response, config frames, and `config_complete_id` in the expected order?
4. Can `FromRadio` return zero-length before `config_complete_id`?
5. Does any BLE callback directly save config, apply mesh config, restart, or send radio data?
6. Does the change treat `Nodes(0)` or `Module config received` as truth instead of symptoms?
7. Does the change preserve response-drain-before-save?
8. Does MQTT proxy delivery happen only in `SendPackets`?
9. Does any queue get consumed before its variant is legal for the current PhoneAPI phase?
10. Does the change accidentally route MeshCore through Meshtastic BLE semantics?
11. Are Pager/TDeck differences limited to resource, memory placement, board capability, or BLE manager lifecycle?
12. 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.