14 KiB
Meshtastic BLE interaction timing
This article organizes the BLE interaction timing between the Meshtastic Android client and the current trail-mate nRF52 firmware. The purpose is to provide a unified baseline for subsequent troubleshooting of "App keeps stopping at connecting" and FromRadio/FromNum compatibility issues.
This article does not discuss UI, GPS, LoRa or flash persistence issues, but only focuses on Meshtastic BLE handshake, configuration flow and connection completion determination.
Normative spec:
docs/specification/MESHTASTIC_ANDROID_BLE_CONNECTION_SPEC.md. This file is a diagnostic timing note. If it conflicts with the specification, the specification wins.
1. Overall conclusion
The Meshtastic BLE connection on the Android side is not:
- Connection successful
- Receive one or two notifications
- Consider it connected immediately
The real determination is a two-phase configuration handshake:
- GATT connection is established, service discovery and notification subscription are completed.
- App issues Stage 1:
ToRadio.want_config_id = CONFIG_NONCE - The device returns a whole configuration stream until
config_complete_id(CONFIG_NONCE) - App issues Stage 2:
ToRadio.want_config_id = NODE_INFO_NONCE - The device returns a whole
node_infostream untilconfig_complete_id(NODE_INFO_NONCE) - App switches the connection status from
ConnectingtoConnected
Therefore, if the App stays at "Connecting" for a long time, it usually does not mean that the BLE physical link is not connected, but that the Meshtastic configuration handshake has not been fully acknowledged.
2. Android side timing
2.1 Connection establishment
The Android side BLE entrance is:
.tmp/meshtastic-android/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleRadioInterface.kt.tmp/meshtastic-android/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableMeshtasticRadioProfile.kt
High-level timing:
BleRadioInterface.connect()establishes GATT connectiondiscoverServicesAndSetupCharacteristics()completes service/profile establishmentfromRadio/logRadioobservation is started- After a short
CCCD_SETTLE_MSwaiting window - Call
service.onConnect()
Corresponding key code location:
BleRadioInterface.discoverServicesAndSetupCharacteristics()BleRadioInterface.service.onConnect()
2.2 How App reads FromRadio
The Android side does not simply rely on FROMNUM notification.
The behavior of KableMeshtasticRadioProfile.fromRadio is as follows:
- If the device supports
FROMRADIOSYNC, subscribe to this feature directly. - If not supported, fall back to legacy mode:
- Subscribe to
FROMNUM - But actively trigger a drain at the same time
- Then read
FROMRADIOin a loop - Read until an empty packet is returned
More specifically, legacy Two key actions will occur in the mode:
triggerDrain.tryEmit(Unit)will actively trigger once when the collector starts.sendToRadio()will triggertriggerDrain.tryEmit(Unit)again every time it sends aToRadio.
This means:
- After sending
want_config_id, the App will actively startread(FROMRADIO) - Even if there is no
FROMNUMnotification at a certain moment, the App will not necessarily stop FROMNUMis more like a steady-state prompt, not the only driving condition
Corresponding key code location:
KableMeshtasticRadioProfile.fromRadioservice.observe(fromNum)service.read(fromRadioChar)triggerDrain.tryEmit(Unit)sendToRadio()
2.3 Two-phase handshake
The upper-layer state machine is in:
.tmp/meshtastic-android/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt.tmp/meshtastic-android/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImpl.kt.tmp/meshtastic-android/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImpl.kt
Stage 1
- After the connection is established,
MeshConnectionManagerImpl.handleConnected()callsstartConfigOnly() startConfigOnly()sends:ToRadio.want_config_id = HandshakeConstants.CONFIG_NONCE
- App starts consuming
FromRadio FromRadioPacketHandlerImpldistributes different variants to config flow manager / config handler- When receiving:
FromRadio.config_complete_id == CONFIG_NONCE
MeshConfigFlowManagerImpl.handleConfigComplete()Enter Stage 1 complete
Stage 1 Typical receiving content during the period includes:
my_infodeviceuimetadataconfigmoduleConfigchannelfileInfo
Stage 2
After Stage 1 is completed:
MeshConfigFlowManagerImpl.handleConfigOnlyComplete()first sends a heartbeat- Then call
startNodeInfoOnly() - Send:
ToRadio.want_config_id = HandshakeConstants.NODE_INFO_NONCE
- App receives a string of
node_info - When receiving:
FromRadio.config_complete_id == NODE_INFO_NONCE
MeshConfigFlowManagerImpl.handleNodeInfoComplete()is actually executed:serviceRepository.setConnectionState(ConnectionState.Connected)
In other words, only Stage 2 is completed, Android will consider the connection complete.
3. Current firmware side timing
The current nRF52 side entrance is mainly at:
platform/nrf52/arduino_common/src/ble/meshtastic_ble.cppmodules/core_phone/src/meshtastic/meshtastic_phone_core.cpp
Note: This section describes the interaction semantics that must be achieved, and does not prove that a historical implementation has satisfied this semantics. The current implementation status must be based on the source code and main specification regression.
3.1 BLE service layer
The key features exposed by the current Meshtastic BLE service:
ToRadioFromRadioFromNumLogRadio
The key sequence that the main loop must reach is:
processPendingToRadio()handleToPhone()prepareReadableFromRadio()
That is:
- First process the
ToRadiowritten by the mobile phone - Then let
MeshtasticPhoneCoreproduce the next frameFromRadio - Then preload the next frame into the
FromRadiocharacteristic and wait for the App to read
3.2 PhoneCore Configuration flow
MeshtasticPhoneCore will start spitting out configuration snapshots after receiving want_config_id.
The sequence of configuration streams that can be seen in the current log is roughly:
cfg#N startframe my_infoframe deviceuiframe self_node- Subsequent frames such as metadata/config/module/channel/node
cfg#N complete
Corresponding log prefix:
[BLE][mtcore][cfg#N] start[BLE][mtcore][cfg#N] frame ...[BLE][mtcore][cfg#N] complete
After encoding, each frame will become a MeshtasticBleFrame and handed over to the transport layer.
3.3 FromNum / FromRadio current implementation
The basic model of the target nRF52 transport is:
MeshtasticPhoneCore.notifyFromNum(from_num)hands the realfrom_numto nRF52 transport- nRF52 transport puts
from_numinto the fixed-depth pending queue - The main loop calls
prepareReadableFromRadio()to letPhoneCore.popToPhone()produces the next frame and preloads it intoFROMRADIO - After the phone completes
FROMNUMsubscription and has preloaded frames, transport uses the same realfrom_numto send notifications - Enter when App reads
FROMRADIOonFromRadioAuthorize(); This callback only records the read/consumption status and no longer produces protobuf frames - The main loop calls
consumeReadableFromRadio()to consume the read pre-assembled frames and immediately try to change to the next frame - The App continues to read until
FROMRADIOreturns an empty packet, indicating that this round of drain is completed
The key constraint is: an artificially empty FROMRADIO value cannot be released first between two frames. consumeReadableFromRadio() is only allowed to write the characteristic with a length of 0 after confirming that
PhoneCore.popToPhone() does not have the next frame; otherwise Android's
read-until-empty may determine the configuration flow as drain completion in advance and miss the subsequent config_complete_id.
There is a stability boundary here: Bluefruit's read-authorize callback cannot perform popToPhone(), MQTT proxy polling, nanopb encoding, or serial port log reactivation. Otherwise, when mobile phone high-frequency drain, over-the-air packet inbound and MQTT downlink are mixed together, USB re-enumeration/disconnection without HardFault log may occur on the nRF52 side.
In order to troubleshoot, the firmware currently also prints these logs:
[BLE][nrf52][mt][flow] link-up ...[BLE][nrf52][mt][flow] from_num subscribed=...[BLE][nrf52][mt][flow] from_num pending source=... depth=...[BLE][nrf52][mt][flow] from_num notify value=... source=...[BLE][nrf52][mt] from_radio read len=...[BLE][nrf52][mt] from_radio read empty reason=...
4. The core relationship between the timing of both parties
Putting Android and firmware together can be summarized into the following main line:
- GATT connected
- App subscribes to
FROMNUM/LOGRADIOand establishesfromRadiocollector - App calls
service.onConnect() - App sends
want_config_id = CONFIG_NONCE - App actively starts draining
FROMRADIO - The firmware provides frame by frame:
my_infodeviceuiself_node- ...
config_complete(CONFIG_NONCE)
- App switches to Stage 2 and sends
want_config_id = NODE_INFO_NONCE - App drains
FROMRADIOagain - The firmware provides several
node_info - The firmware sends
config_complete(NODE_INFO_NONCE) - App is switched to
Connected
5. The most noteworthy deviation points on the current nRF52 side
Based on the real code on the Android side, the most important observation point currently is not "whether there are a large number of FROMNUM notify", but the following.
5.1 Is the App really reading FromRadio?
Since Android will actively drain FROMRADIO after sending want_config, so if you cannot see it in the firmware log:
[BLE][nrf52][mt] from_radio read len=...[BLE][nrf52][mt] from_radio read empty
The problem is more like:
FromRadio's GATT read in nRF52/Bluefruit There is no real reading of the App- It is not that the configuration content itself is wrong
5.2 Is the empty packet semantics closed?
The legacy mode on the Android side will always read(FROMRADIO)` until an empty packet is returned to end this round of drain.
So the firmware must ensure:
- When there is a frame, read returns the current frame
- After the current frame is read, the next read should get the next frame
- When there are no more frames in this round, an empty packet must be returned
If the last step is not established, the App may always think that the configuration flow is not completely completed.
5.3 Stage 1 completion does not equal connection completion
Even if cfg#1 complete has appeared in the firmware log, the App may still display "Connecting".
Because for Android:
- Stage 1 complete is only the configuration reading is completed
- You need to run another Stage 2 node-info handshake
- Only when the second
config_complete_idarrives, the status will becomeConnected
So any link that only completes Stage 1 will keep the UI in Connecting
5.4 Historical issue: loop Stack overflow
Previously, the nRF52 side has confirmed a historical issue related to the strong BLE configuration stream:
-The configuration stream construction path once pushed the loop task stack to stack_hwm=0
- This will cause other objects in the same task to be trampled
- Manifested as damaged GPS status, abnormal
SATnumbers, and guard being overwritten with the word[BLE
This problem has been significantly alleviated after the large object stack reduction of MeshtasticPhoneCore, but it shows:
- Meshtastic The configuration flow is not a "normal low-overhead path"
- any timing analysis needs to be looked at together with the task context and memory behavior
6. Use this timing to determine faults
Following troubleshooting can be done according to the following judgment method.
Case A
Phenomena:
- There is
cfg#start - There is
cfg#complete - But there is no
from_radio read...
Judgment:
- The App has issued
want_config - but the
FROMRADIOread path does not actually hit the nRF52 firmware - The GATT read compatibility of
FromRadiocharacteristic should be checked
Situation B
Phenomena:
- There is
from_radio read len=... - But there is no
from_radio read empty
Judgment:
- drain-until-empty does not close the loop
- App is probably still waiting for the end of this round of reading
Situation C
Phenomena:
- Stage 1's
config_complete(CONFIG_NONCE)has been sent - But the App did not enter the second
want_config_id
Judgment:
- The App did not successfully consume the Stage 1 completion signal
- Priority should be given to checking whether the
config_complete_idframe really arrives at AndroidFromRadioPacketHandler
Situation D
Phenomena:
- Stage 2 has also been completed
- But the App is still
Connecting
Judgment:
- You should check whether the Android side
MeshConfigFlowManagerImpl.handleNodeInfoComplete()is really triggered - Or check whether there is node-info flow interruption/state machine being rolled back during Stage 2
7. Follow-up suggestions
All subsequent Meshtastic BLE fixes should give priority to the following facts in this article:
- Android will actively drain
FROMRADIO FROMNUMis not the only driving condition- Connection completion relies on two stages
config_complete_id FROMRADIOmust have stable semantics of "continuously reading frames until empty packets"- On nRF52, we must not only pay attention to the protocol sequence, but also the task stack and callback context
If you continue to debug, it is recommended to keep the following logs:
[BLE][nrf52][mt][flow] link-up ...[BLE][nrf52][mt][flow] from_num subscribed=...[BLE][nrf52][mt][flow] from_num pending source=... depth=...[BLE][nrf52][mt][flow] from_num notify value=... source=...[BLE][nrf52][mt] from_radio read len=...[BLE][nrf52][mt] from_radio read empty reason=...[BLE][mtcore][cfg#N] start/frame/complete[BLE][mtcore][rt] stage=... stack_hwm=...
These logs are enough to converge the problem to:
-GATT Unable to read
- drain semantics does not close the loop
- Stage 1 not completed
- Stage 2 not completed
- or runtime stack/memory issue