diff --git a/docs/specification/MESHTASTIC_ANDROID_BLE_CONNECTION_SPEC.md b/docs/specification/MESHTASTIC_ANDROID_BLE_CONNECTION_SPEC.md index e188cc29..58c80fd4 100644 --- a/docs/specification/MESHTASTIC_ANDROID_BLE_CONNECTION_SPEC.md +++ b/docs/specification/MESHTASTIC_ANDROID_BLE_CONNECTION_SPEC.md @@ -490,6 +490,7 @@ Required tags: | `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. | @@ -499,6 +500,13 @@ Important distinction: - 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 diff --git a/modules/core_phone/include/phone/meshtastic/meshtastic_phone_core.h b/modules/core_phone/include/phone/meshtastic/meshtastic_phone_core.h index 4beca667..f765aaed 100644 --- a/modules/core_phone/include/phone/meshtastic/meshtastic_phone_core.h +++ b/modules/core_phone/include/phone/meshtastic/meshtastic_phone_core.h @@ -70,6 +70,7 @@ class MeshtasticPhoneTransport virtual ~MeshtasticPhoneTransport() = default; virtual bool isBleConnected() const = 0; virtual void notifyFromNum(uint32_t from_num) = 0; + virtual void requestPhoneDisconnect() {} }; class MeshtasticPhoneBluetoothConfigHooks diff --git a/modules/core_phone/src/meshtastic/meshtastic_phone_core.cpp b/modules/core_phone/src/meshtastic/meshtastic_phone_core.cpp index 0c1eaf28..7a8b5bd2 100644 --- a/modules/core_phone/src/meshtastic/meshtastic_phone_core.cpp +++ b/modules/core_phone/src/meshtastic/meshtastic_phone_core.cpp @@ -1113,6 +1113,7 @@ bool MeshtasticPhoneCore::handleToRadio(const uint8_t* data, size_t len) return true; case meshtastic_ToRadio_disconnect_tag: reset(); + transport_.requestPhoneDisconnect(); return true; default: return false; diff --git a/modules/core_phone/tests/test_phone_core_smoke.cpp b/modules/core_phone/tests/test_phone_core_smoke.cpp index b1b2edcc..c5b276ce 100644 --- a/modules/core_phone/tests/test_phone_core_smoke.cpp +++ b/modules/core_phone/tests/test_phone_core_smoke.cpp @@ -36,10 +36,12 @@ class FakeMeshtasticTransport final : public phone::meshtastic::MeshtasticPhoneT last_from_num = from_num; notify_count++; } + void requestPhoneDisconnect() override { disconnect_count++; } bool connected = true; uint32_t last_from_num = 0; int notify_count = 0; + int disconnect_count = 0; }; class FakeBluetoothConfigHooks final : public phone::meshtastic::MeshtasticPhoneBluetoothConfigHooks @@ -360,6 +362,20 @@ bool encodeHeartbeatToRadio(uint8_t* out, size_t out_len, size_t& written, uint3 return true; } +bool encodeDisconnectToRadio(uint8_t* out, size_t out_len, size_t& written) +{ + meshtastic_ToRadio to_radio = meshtastic_ToRadio_init_zero; + to_radio.which_payload_variant = meshtastic_ToRadio_disconnect_tag; + + pb_ostream_t to_radio_stream = pb_ostream_from_buffer(out, out_len); + if (!pb_encode(&to_radio_stream, meshtastic_ToRadio_fields, &to_radio)) + { + return false; + } + written = to_radio_stream.bytes_written; + return true; +} + bool encodeMqttProxyToRadio(const meshtastic_MqttClientProxyMessage& msg, uint8_t* out, size_t out_len, @@ -538,6 +554,21 @@ int main() assert(heartbeat_from.queueStatus.res == 0); assert(!heartbeat_session.popToPhone(&heartbeat_frame)); + phone::tests::FakePhoneRuntimeContext disconnect_runtime; + FakeMeshtasticTransport disconnect_transport; + phone::meshtastic::MeshtasticPhoneSession disconnect_session( + disconnect_runtime, disconnect_transport, nullptr, nullptr, nullptr, nullptr, nullptr, nullptr); + enterMeshtasticSendPackets(disconnect_session); + assert(disconnect_session.isSendingPackets()); + uint8_t disconnect_to_radio[meshtastic_ToRadio_size] = {}; + size_t disconnect_to_radio_len = 0; + assert(encodeDisconnectToRadio(disconnect_to_radio, + sizeof(disconnect_to_radio), + disconnect_to_radio_len)); + assert(disconnect_session.handleToRadio(disconnect_to_radio, disconnect_to_radio_len)); + assert(disconnect_transport.disconnect_count == 1); + assert(!disconnect_session.isSendingPackets()); + phone::tests::FakePhoneRuntimeContext backpressure_runtime; FakeMeshtasticTransport backpressure_transport; phone::meshtastic::MeshtasticPhoneSession backpressure_session( diff --git a/platform/esp/arduino_common/include/ble/meshtastic_ble.h b/platform/esp/arduino_common/include/ble/meshtastic_ble.h index 0f713efa..3161096e 100644 --- a/platform/esp/arduino_common/include/ble/meshtastic_ble.h +++ b/platform/esp/arduino_common/include/ble/meshtastic_ble.h @@ -53,6 +53,7 @@ class MeshtasticBleService : public BleService, uint32_t pendingPhoneBlePasskey() const override; void requestPhoneHighThroughputConnection() override; void requestPhoneLowerPowerConnection() override; + void requestPhoneDisconnect() override; void onPhoneBluetoothConfigChanged() override; void onPhoneModuleConfigChanged() override; diff --git a/platform/esp/arduino_common/src/ble/meshtastic_ble_owner_hooks.cpp b/platform/esp/arduino_common/src/ble/meshtastic_ble_owner_hooks.cpp index bdf081c0..587fab9a 100644 --- a/platform/esp/arduino_common/src/ble/meshtastic_ble_owner_hooks.cpp +++ b/platform/esp/arduino_common/src/ble/meshtastic_ble_owner_hooks.cpp @@ -47,6 +47,21 @@ void MeshtasticBleService::requestPhoneLowerPowerConnection() requestLowerPowerConnection(); } +void MeshtasticBleService::requestPhoneDisconnect() +{ + if (!server_ || !connected_ || !conn_handle_valid_) + { + Serial.printf("[BLE][mt] phone disconnect ignored connected=%u handle_valid=%u\n", + connected_ ? 1U : 0U, + conn_handle_valid_ ? 1U : 0U); + return; + } + + Serial.printf("[BLE][mt] phone disconnect request handle=%u\n", + static_cast(conn_handle_)); + server_->disconnect(conn_handle_); +} + void MeshtasticBleService::onPhoneBluetoothConfigChanged() { saveBleConfig(); diff --git a/platform/nrf52/arduino_common/include/ble/meshtastic_ble.h b/platform/nrf52/arduino_common/include/ble/meshtastic_ble.h index 92870600..582a2a91 100644 --- a/platform/nrf52/arduino_common/include/ble/meshtastic_ble.h +++ b/platform/nrf52/arduino_common/include/ble/meshtastic_ble.h @@ -61,6 +61,7 @@ class MeshtasticBleService final : public BleService, uint32_t pendingPhoneBlePasskey() const override; void requestPhoneHighThroughputConnection() override; void requestPhoneLowerPowerConnection() override; + void requestPhoneDisconnect() override; void onPhoneBluetoothConfigChanged() override; void onPhoneModuleConfigChanged() override; @@ -90,6 +91,8 @@ class MeshtasticBleService final : public BleService, void markConfigSavePending(bool bluetooth_changed, bool module_changed); void flushPendingConfigSaves(bool force = false); void applyBleSecurity(); + bool processPendingPhoneDisconnect(); + void checkPhoneSessionLiveness(uint32_t now_ms); void logFromRadioState(const char* tag) const; void logSessionState(const char* tag, uint32_t detail = 0); void requestPairingIfNeeded(uint16_t conn_handle); @@ -136,6 +139,7 @@ class MeshtasticBleService final : public BleService, volatile bool pairing_request_pending_ = false; volatile uint16_t pending_pairing_conn_handle_ = BLE_CONN_HANDLE_INVALID; + volatile bool pending_phone_disconnect_request_ = false; volatile bool pending_connect_log_ = false; volatile bool pending_disconnect_log_ = false; @@ -181,6 +185,7 @@ class MeshtasticBleService final : public BleService, uint32_t last_from_num_notify_ms_ = 0; uint32_t next_connected_session_log_ms_ = 0; uint32_t next_ble_idle_log_ms_ = 0; + uint32_t next_liveness_log_ms_ = 0; ble_gap_addr_t remembered_phone_peer_ = {}; bool remembered_phone_peer_valid_ = false; bool remembered_phone_peer_bonded_ = false; diff --git a/platform/nrf52/arduino_common/src/ble/meshtastic_ble.cpp b/platform/nrf52/arduino_common/src/ble/meshtastic_ble.cpp index fdfb7fa5..6fb33bf3 100644 --- a/platform/nrf52/arduino_common/src/ble/meshtastic_ble.cpp +++ b/platform/nrf52/arduino_common/src/ble/meshtastic_ble.cpp @@ -31,6 +31,8 @@ constexpr uint32_t kDirectedAdvertisingMs = 3000UL; constexpr uint16_t kBleAdvertisingFastInterval = 32; constexpr uint16_t kBleAdvertisingSlowInterval = 668; constexpr uint16_t kBleAdvertisingFastTimeoutSec = 30; +constexpr uint32_t kPhoneSessionStaleMs = 90000UL; +constexpr uint32_t kPhoneSessionStaleLogIntervalMs = 30000UL; constexpr uint8_t kFromRadioEmptyInactive = 1; constexpr uint8_t kFromRadioEmptyNoFrame = 2; constexpr uint8_t kFromRadioEmptyInvalidFrame = 3; @@ -771,6 +773,7 @@ void MeshtasticBleService::start() pending_disconnect_reason_ = 0; pairing_request_pending_ = false; pending_pairing_conn_handle_ = BLE_CONN_HANDLE_INVALID; + pending_phone_disconnect_request_ = false; directed_advertising_active_ = false; directed_advertising_attempted_ = false; directed_advertising_until_ms_ = 0; @@ -786,6 +789,7 @@ void MeshtasticBleService::start() last_from_num_notify_ms_ = 0; next_connected_session_log_ms_ = 0; next_ble_idle_log_ms_ = last_ble_activity_ms_ + 3000UL; + next_liveness_log_ms_ = 0; prepareBluefruit(device_name_); loadRememberedPhonePeer(); @@ -903,6 +907,7 @@ void MeshtasticBleService::stop() pending_disconnect_reason_ = 0; pairing_request_pending_ = false; pending_pairing_conn_handle_ = BLE_CONN_HANDLE_INVALID; + pending_phone_disconnect_request_ = false; directed_advertising_active_ = false; directed_advertising_attempted_ = false; directed_advertising_until_ms_ = 0; @@ -916,6 +921,7 @@ void MeshtasticBleService::stop() last_from_num_notify_ms_ = 0; next_connected_session_log_ms_ = 0; next_ble_idle_log_ms_ = 0; + next_liveness_log_ms_ = 0; if (s_active_service == this) { s_active_service = nullptr; @@ -938,6 +944,10 @@ void MeshtasticBleService::update() processPendingPairingRequest(); processPendingToRadio(); + if (processPendingPhoneDisconnect()) + { + return; + } flushPendingFromRadioReadAuthorize(); flushPendingFromNumNotify(); logDeferredBleEvents(); @@ -993,6 +1003,7 @@ void MeshtasticBleService::update() logSessionState("periodic"); next_connected_session_log_ms_ = now_ms + kBleConnectedSessionLogIntervalMs; } + checkPhoneSessionLiveness(now_ms); } void MeshtasticBleService::handleIncomingTextFromApp(const chat::MeshIncomingText& msg) @@ -1605,7 +1616,9 @@ void MeshtasticBleService::handleConnectEvent(uint16_t conn_handle) last_from_radio_read_ms_ = 0; last_from_num_notify_ms_ = 0; next_connected_session_log_ms_ = last_ble_activity_ms_ + kBleConnectedSessionLogIntervalMs; + next_liveness_log_ms_ = 0; from_num_notify_enabled_ = false; + pending_phone_disconnect_request_ = false; if (phone_session_) { phone_session_->close(); @@ -1666,6 +1679,8 @@ void MeshtasticBleService::handleDisconnectEvent(uint16_t conn_handle, uint8_t r pending_disconnect_conn_handle_ = conn_handle; pending_disconnect_reason_ = reason; pending_disconnect_log_ = true; + pending_phone_disconnect_request_ = false; + next_liveness_log_ms_ = 0; directed_advertising_active_ = false; directed_advertising_attempted_ = false; directed_advertising_until_ms_ = 0; @@ -1779,6 +1794,100 @@ void MeshtasticBleService::requestPhoneLowerPowerConnection() { } +void MeshtasticBleService::requestPhoneDisconnect() +{ + pending_phone_disconnect_request_ = true; +} + +bool MeshtasticBleService::processPendingPhoneDisconnect() +{ + if (!pending_phone_disconnect_request_) + { + return false; + } + + pending_phone_disconnect_request_ = false; + const uint16_t handle = conn_handle_; + const bool handle_valid = handle != BLE_CONN_HANDLE_INVALID; + const bool gap_connected = handle_valid && Bluefruit.connected(handle); + bleLogBoth("[BLE][nrf52][mt][flow] phone disconnect request connected=%u gap=%u handle=%u", + connected_ ? 1U : 0U, + gap_connected ? 1U : 0U, + handle_valid ? static_cast(handle) : 0xFFFFU); + + clearPendingFromRadioReadAuthorize(); + pending_to_radio_head_ = 0; + pending_to_radio_tail_ = 0; + pending_to_radio_count_ = 0; + clearToPhoneQueue(); + from_num_notify_enabled_ = false; + pending_passkey_.store(0); + + if (!connected_ || !gap_connected) + { + if (!Bluefruit.Advertising.isRunning()) + { + startPhoneAdvertising(!directed_advertising_attempted_); + } + return false; + } + + const bool ok = Bluefruit.disconnect(handle); + bleLogBoth("[BLE][nrf52][mt][flow] phone disconnect gap_request handle=%u ok=%u", + static_cast(handle), + ok ? 1U : 0U); + if (!ok) + { + pending_phone_disconnect_request_ = true; + } + return true; +} + +void MeshtasticBleService::checkPhoneSessionLiveness(uint32_t now_ms) +{ + if (!active_ || !connected_ || conn_handle_ == BLE_CONN_HANDLE_INVALID) + { + return; + } + + uint32_t last_phone_activity_ms = last_ble_activity_ms_; + if (last_heartbeat_ms_ > last_phone_activity_ms) + { + last_phone_activity_ms = last_heartbeat_ms_; + } + if (last_from_radio_read_ms_ > last_phone_activity_ms) + { + last_phone_activity_ms = last_from_radio_read_ms_; + } + if (last_to_radio_ms_ > last_phone_activity_ms) + { + last_phone_activity_ms = last_to_radio_ms_; + } + + if (last_phone_activity_ms == 0 || + static_cast(now_ms - last_phone_activity_ms) < kPhoneSessionStaleMs) + { + return; + } + + if (next_liveness_log_ms_ == 0 || static_cast(now_ms - next_liveness_log_ms_) >= 0) + { + bleLogBoth("[BLE][nrf52][mt][flow] phone session stale age_ms=%lu limit_ms=%lu connected=%u gap=%u " + "notify=%u cfg=%u send=%u", + static_cast(now_ms - last_phone_activity_ms), + static_cast(kPhoneSessionStaleMs), + connected_ ? 1U : 0U, + Bluefruit.connected(conn_handle_) ? 1U : 0U, + from_num_notify_enabled_ ? 1U : 0U, + (phone_session_ && phone_session_->isConfigFlowActive()) ? 1U : 0U, + (phone_session_ && phone_session_->isSendingPackets()) ? 1U : 0U); + next_liveness_log_ms_ = now_ms + kPhoneSessionStaleLogIntervalMs; + } + + pending_phone_disconnect_request_ = true; + (void)processPendingPhoneDisconnect(); +} + void MeshtasticBleService::onPhoneBluetoothConfigChanged() { markConfigSavePending(true, false);