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.
This commit is contained in:
4aiman 2026-10-20 23:25:06 +03:00
commit 9bc71b19c9
3 changed files with 58 additions and 2 deletions

View file

@ -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<id>` по `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/`)

View file

@ -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<max && adapter.isEnabled && lastAddress!=null` -> reconnectTries++,
`connectDevice(lastAddress)` через 8с; иначе stopSelf. Реконнект идёт через скан: ремень не
найден -> timeout-ветка stopSelf (не зацикливается на отсутствующем).
- `stopping=true` на ACTION_STOP, BT-off (ACTION_STATE_CHANGED), onDestroy - там реконнекта нет.
Начальный "скан не нашёл при старте -> stopSelf" не тронут: если ремня не было при старте,
сервис гаснет, ничего не долбит.
## Установка (важно)
Сборка БЕЗ -i, чтобы не убить идущую ночную запись. Порядок: пользователь жмёт Стоп (короткая
сессия финализируется из нативного лога), затем установка APK, затем свежий старт ночи уже с
реконнектом. Переустановка убивает процесс и чистит лог при следующем старте - поэтому не
ставить поверх идущей записи.
## Проверка
Компиляция Kotlin - в сборке. На устройстве (после установки): старт воркаута, снять/вернуть
ремень на 10-20с -> запись переподключается и продолжается; снять надолго -> через ~3 попытки
сервис гаснет. Живую проверку делает пользователь.

View file

@ -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()