32 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
本规格基于当前 ESP32 Arduino Meshtastic BLE 实现:
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.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 | 一次手机连接内的协议状态:配置流、队列状态、packet 队列、deferred save 标志。 | MeshtasticPhoneSession / MeshtasticPhoneCore |
| Meshtastic Phone Protocol Core | ToRadio/FromRadio protobuf 语义、Admin 处理、config snapshot 帧序列、NodeInfo/Channel/Config/ModuleConfig 投影。 |
MeshtasticPhoneCore |
| 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 的单调 notify token,不是业务 packet id。日志里的 source 才是 firmware 侧语义来源。 |
| BLE connected flag | 只证明 GAP 连接存在,不证明 Meshtastic config snapshot 完成。 |
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 语义。
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)
-handleToRadioPacket(packet)
-handleAdmin(packet)
-enqueueConfigSnapshot(nonce)
-popConfigSnapshotFrame(out)
-enqueueQueueStatus(packet_id,ok)
-encodeFromRadio(from,from_num,out)
}
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 --> AppPhoneFacade : app port
AppPhoneFacade --> IAppBleFacade : delegates
MeshtasticBleService ..|> MeshtasticPhoneTransport
MeshtasticBleService ..|> IPhoneBleRuntime
AppPhoneFacade ..> IPhoneBleRuntime : lifecycle callbacks
Boundary rule:
NimBLE callback -> MeshtasticBleService queue -> update() -> MeshtasticPhoneSession
-> MeshtasticPhoneCore -> AppPhoneFacade -> App services
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 mode requires auth
Pairing --> Connected: auth complete
Connected --> ConfigFlow: ToRadio.want_config_id
ConfigFlow --> ConfigDrainEmpty: config_complete_id emitted
ConfigDrainEmpty --> SteadyState: next FromRadio read returns zero length
Connected --> SteadyState: no config flow active
SteadyState --> AdminEdit: Admin.begin_edit_settings
AdminEdit --> SteadyState: Admin.commit_edit_settings response drained
SteadyState --> DeferredSave: admin response queues drained
DeferredSave --> SteadyState: save/apply/restart hooks executed
Connected --> Advertising: disconnect + advertising restart
Pairing --> Advertising: disconnect
ConfigFlow --> Advertising: disconnect
SteadyState --> Advertising: disconnect
State invariants:
Advertisingmeans Meshtastic service UUID is visible, not that Android has completed setup.Connectedmeans GAP connection exists; queues andphone_session_must be reset for this connection.ConfigFlowownsconfig_nonce_, snapshot indexes, andconfig_flow_active_.ConfigDrainEmptyis a deliberate one-shot zero-length read afterconfig_complete_id.DeferredSavemust happen after queue status and Admin response frames have been offered to the phone.
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
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["Mqtt hook -> MtAdapter"]
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.
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 --> D{"shouldBlockOnRead()?"}
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"]
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.
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,config_flow_active_becomes false andconfig_drain_empty_pending_becomes true. - The next
popToPhone()after completion must return false once, allowingFromRadioto expose zero-length drain completion.
Pop Order
MeshtasticPhoneCore::popToPhone() must preserve this priority:
- MQTT proxy message from radio/backend to phone.
- Explicit pre-encoded frame queue.
- Active config snapshot frame.
- One-shot config drain-empty signal.
- Queue status frames.
- Packet frames.
- Deferred app config save.
- Deferred module config save.
- Deferred Bluetooth config save and enabled-state apply.
- Deferred restart after module config change.
- No frame.
Do not reorder this casually.
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: build MeshPacket and queue
Core->>Svc: notifyFromNum(source=packet.id)
Svc-->>Phone: FromNum notify(token)
Phone->>Svc: Read FromRadio
Svc->>Core: popToPhone()
Core-->>Phone: 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.
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. |
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][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] config snapshot drain-empty |
Next read may observe zero-length drain completion. |
[BLE] fromRadio read empty |
Current drain round is complete. During active config flow 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. |
| 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.
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] fromRadio read empty
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 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.