Fix Meshtastic BLE disconnect recovery

This commit is contained in:
liu weikai
2026-07-03 21:13:42 +08:00
parent e23600254d
commit 83ecaf65db
8 changed files with 171 additions and 0 deletions
@@ -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
@@ -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
@@ -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;
@@ -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(
@@ -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;
@@ -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<unsigned>(conn_handle_));
server_->disconnect(conn_handle_);
}
void MeshtasticBleService::onPhoneBluetoothConfigChanged()
{
saveBleConfig();
@@ -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;
@@ -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<unsigned>(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<unsigned>(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<uint32_t>(now_ms - last_phone_activity_ms) < kPhoneSessionStaleMs)
{
return;
}
if (next_liveness_log_ms_ == 0 || static_cast<int32_t>(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<unsigned long>(now_ms - last_phone_activity_ms),
static_cast<unsigned long>(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);