From 9bc71b19c9345d98b51aca766eb8ff605f822918 Mon Sep 17 00:00:00 2001 From: 4aiman Date: Tue, 20 Oct 2026 23:25:06 +0300 Subject: [PATCH] BleHrService: auto-reconnect a dropped strap (bounded, never for an absent one) On an unexpected disconnect of an established connection (connectedOnce, i.e. services were discovered), reconnect via the scan path up to 3 times, 8s apart, resetting the budget on each successful reconnect. The scan gives up (stopSelf) if the strap isn't found, so we never hammer a band that wasn't present at start or wasn't connected by hand. A user stop, Bluetooth off, or onDestroy sets a stopping flag that suppresses reconnect. --- PROJECT_MAP.md | 2 +- docs/session-logs/2026-10-20_hr-reconnect.md | 32 +++++++++++++++++++ .../main/java/dev/baldav/app/BleHrService.kt | 26 ++++++++++++++- 3 files changed, 58 insertions(+), 2 deletions(-) create mode 100644 docs/session-logs/2026-10-20_hr-reconnect.md diff --git a/PROJECT_MAP.md b/PROJECT_MAP.md index 45ce42e..05670d4 100644 --- a/PROJECT_MAP.md +++ b/PROJECT_MAP.md @@ -53,7 +53,7 @@ |------|-------|------------| | `MainActivity.kt` | ~300 | FileObserver IPC, seed DB, safe insets, SAF, BLE commands, OTA check | | `GpsRecorderService.kt` | ~530 | Foreground service GPS, WakeLock, JSONL (один файл на сессию до Стоп, идемпотентный onStartCommand при START_STICKY-перезапуске ОС; `bindSessionIfPending()` дописывает id сессии в имя файла `_s` по `gps_session.txt`), 50Hz акселерометр/гироскоп, dead reckoning, raw sensor files | -| `BleHrService.kt` | ~390 | Foreground service BLE HR, FitPro protocol, GATT queue, scan/connect. START_NOT_STICKY; скан не нашёл адрес -> stopSelf (не держит радио). Подключение инициируется ТОЛЬКО вручную (кнопка в настройках), не при старте записи. Выключение BT в любой момент не роняет app: единый `handler` (все postDelayed, снимаются разом), `BroadcastReceiver` на `ACTION_STATE_CHANGED` -> на STATE_OFF чистая остановка (disconnect+stopSelf), все нативные BLE-вызовы (scan/stopScan/connectGatt/writeCharacteristic/discoverServices) в try/catch. Каждый замер ЧСС дописывается в `ble_hr_log.jsonl` (append) прямо в сервисе — запись истории переживает гаснущий экран (JS-поллинг в фоне заморожен Android); воркаут при остановке пересобирает сэмплы из этого лога (`GPS._readHrLogSamples`), а не из JS-массива | +| `BleHrService.kt` | ~390 | Foreground service BLE HR, FitPro protocol, GATT queue, scan/connect. START_NOT_STICKY; скан не нашёл адрес -> stopSelf (не держит радио). Подключение инициируется ТОЛЬКО вручную (кнопка в настройках), не при старте записи. Выключение BT в любой момент не роняет app: единый `handler` (все postDelayed, снимаются разом), `BroadcastReceiver` на `ACTION_STATE_CHANGED` -> на STATE_OFF чистая остановка (disconnect+stopSelf), все нативные BLE-вызовы (scan/stopScan/connectGatt/writeCharacteristic/discoverServices) в try/catch. Каждый замер ЧСС дописывается в `ble_hr_log.jsonl` (append) прямо в сервисе — запись истории переживает гаснущий экран (JS-поллинг в фоне заморожен Android); воркаут при остановке пересобирает сэмплы из этого лога (`GPS._readHrLogSamples`), а не из JS-массива. Авто-реконнект: обрыв УЖЕ установленного соединения (connectedOnce) переподключается через скан (до 3 попыток, 8с), сброс бюджета при успехе; если скан не нашёл ремень или это stopping (ACTION_STOP/BT-off/onDestroy) - stopSelf, не долбится в отсутствующий/не запущенный вручную ремень | | `generated/*.kt` | ~1100 | Tauri-сгенерированные: WebView, IPC, permissions | ### Инструменты (`tools/`) diff --git a/docs/session-logs/2026-10-20_hr-reconnect.md b/docs/session-logs/2026-10-20_hr-reconnect.md new file mode 100644 index 0000000..b86a16c --- /dev/null +++ b/docs/session-logs/2026-10-20_hr-reconnect.md @@ -0,0 +1,32 @@ +# Сессия 2026-10-20 (доб.) - авто-реконнект ремня ЧСС + +## Задача +Чтобы ночная запись не оборвалась при кратком обрыве BLE. Условие пользователя: НЕ долбиться +в ремень, если его не было при старте или его не подключали руками - реконнект только для +реально установленного и оборвавшегося соединения. + +## Сделано (BleHrService.kt) +- Поля `lastAddress`, `connectedOnce`, `stopping`, `reconnectTries`, `maxReconnect=3`. +- Фреш-коннект (onStartCommand connect-ветка): `lastAddress=address; stopping=false; + connectedOnce=false; reconnectTries=0`. +- `onServicesDiscovered` (реально подключились): `connectedOnce=true; reconnectTries=0; + isRunning=true`. +- `onConnectionStateChange` DISCONNECTED: закрыть gatt; если `!stopping && connectedOnce && + reconnectTries reconnectTries++, + `connectDevice(lastAddress)` через 8с; иначе stopSelf. Реконнект идёт через скан: ремень не + найден -> timeout-ветка stopSelf (не зацикливается на отсутствующем). +- `stopping=true` на ACTION_STOP, BT-off (ACTION_STATE_CHANGED), onDestroy - там реконнекта нет. + +Начальный "скан не нашёл при старте -> stopSelf" не тронут: если ремня не было при старте, +сервис гаснет, ничего не долбит. + +## Установка (важно) +Сборка БЕЗ -i, чтобы не убить идущую ночную запись. Порядок: пользователь жмёт Стоп (короткая +сессия финализируется из нативного лога), затем установка APK, затем свежий старт ночи уже с +реконнектом. Переустановка убивает процесс и чистит лог при следующем старте - поэтому не +ставить поверх идущей записи. + +## Проверка +Компиляция Kotlin - в сборке. На устройстве (после установки): старт воркаута, снять/вернуть +ремень на 10-20с -> запись переподключается и продолжается; снять надолго -> через ~3 попытки +сервис гаснет. Живую проверку делает пользователь. diff --git a/src-tauri/gen/android/app/src/main/java/dev/baldav/app/BleHrService.kt b/src-tauri/gen/android/app/src/main/java/dev/baldav/app/BleHrService.kt index dd2c09b..8ded889 100644 --- a/src-tauri/gen/android/app/src/main/java/dev/baldav/app/BleHrService.kt +++ b/src-tauri/gen/android/app/src/main/java/dev/baldav/app/BleHrService.kt @@ -60,6 +60,11 @@ class BleHrService : Service() { private var deviceType = "hr_standard" private var dataFile: File? = null private var logFile: File? = null // append-only HR history (ble_hr_log.jsonl); survives screen-off + private var lastAddress: String? = null // for auto-reconnect of a DROPPED connection + private var connectedOnce = false // a real connection (services discovered) happened — only then do we reconnect + private var stopping = false // user / BT-off asked to stop — never auto-reconnect + private var reconnectTries = 0 + private val maxReconnect = 3 // ride out a brief drop, then give up (don't hammer an absent strap) private var lastBattery: Int = -1 // strap charge %, -1 = unknown private var scanResultsFile: File? = null private var fitproWriteChar: BluetoothGattCharacteristic? = null @@ -76,6 +81,7 @@ class BleHrService : Service() { val state = intent.getIntExtra(BluetoothAdapter.EXTRA_STATE, BluetoothAdapter.ERROR) if (state == BluetoothAdapter.STATE_TURNING_OFF || state == BluetoothAdapter.STATE_OFF) { Log.d(TAG, "Bluetooth turning off - stopping BLE service") + stopping = true // BT off: do not auto-reconnect handler.removeCallbacksAndMessages(null) disconnect() writeData("""{"status":"bt_off","ts":${System.currentTimeMillis()/1000}}""") @@ -99,6 +105,7 @@ class BleHrService : Service() { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { when (intent?.action) { ACTION_STOP -> { + stopping = true // user-initiated stop: do not auto-reconnect disconnect() stopSelf() return START_NOT_STICKY @@ -117,6 +124,8 @@ class BleHrService : Service() { val appDataDir = filesDir.parentFile ?: filesDir dataFile = File(appDataDir, "ble_hr_data.json") logFile = File(appDataDir, "ble_hr_log.jsonl") + // fresh user-initiated connect: arm reconnect only after it actually connects + lastAddress = address; stopping = false; connectedOnce = false; reconnectTries = 0 connectDevice(address) } return START_NOT_STICKY // connect is user-initiated; no sticky restart @@ -249,7 +258,19 @@ class BleHrService : Service() { Log.d(TAG, "Disconnected") isRunning = false writeData("""{"status":"disconnected","ts":${System.currentTimeMillis()/1000}}""") - stopSelf() + try { g.close() } catch (e: Exception) { Log.e(TAG, "gatt close on drop: ${e.message}") } + gatt = null + // Reconnect ONLY a connection that was really established (connectedOnce) and that the + // user didn't stop. The reconnect goes through connectDevice's scan — if the strap is + // gone it won't be found and we stopSelf (so we never hammer an absent/never-present band). + if (!stopping && connectedOnce && reconnectTries < maxReconnect && adapter()?.isEnabled == true && lastAddress != null) { + reconnectTries++ + Log.d(TAG, "Auto-reconnect $reconnectTries/$maxReconnect in 8s") + updateNotification("Переподключение...") + handler.postDelayed({ if (!stopping) connectDevice(lastAddress!!) }, 8000) + } else { + stopSelf() + } } } @@ -268,6 +289,8 @@ class BleHrService : Service() { Log.d(TAG, "Auto-detected device type: $deviceType") } Log.d(TAG, "Services discovered, setting up for $deviceType") + connectedOnce = true; reconnectTries = 0 // real connection established — arm/refresh reconnect budget + isRunning = true when (deviceType) { "hr_standard" -> setupStandardHr(g) "fitpro" -> setupFitPro(g) @@ -544,6 +567,7 @@ class BleHrService : Service() { } override fun onDestroy() { + stopping = true try { unregisterReceiver(btStateReceiver) } catch (e: Exception) { /* not registered / already gone */ } disconnect() super.onDestroy()