From dd1642b6e914419dd4cf0ec03fa7e176c9718c7e Mon Sep 17 00:00:00 2001 From: 4aiman Date: Fri, 2 Oct 2026 14:03:16 +0300 Subject: [PATCH] BLE HR: connect manually only, no auto-scan on every ride (battery) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Recording auto-called ble_connect each ride, and the Kotlin service held a GATT connect to an absent band (radio busy) plus START_STICKY churn - draining the battery when the band was away or Bluetooth off. - gps.js _startHrMonitor: drop the auto ble_connect; keep only the cheap HR poll so a manual connect (now or mid-ride) lands HR in the track. - Settings: a 'Подключить' button for the paired band, usable any time incl. during a ride. BT off -> ble_request_enable (system enable dialog); on -> ble_connect, start the poll if recording, report connected / not found. - ble.rs: ble_request_enable command (+ handler registration). - MainActivity: ble_request_enable -> ACTION_REQUEST_ENABLE (BLUETOOTH_CONNECT already in manifest). - BleHrService: START_NOT_STICKY; if the 5s scan doesn't find the address, stopSelf instead of directConnect to an absent device. --- PROJECT_MAP.md | 2 +- README.md | 2 +- .../2026-10-02_ble-manual-connect.md | 41 +++++++++++++++++++ frontend/js/gps.js | 18 +++----- frontend/js/main.js | 41 +++++++++++++++++++ .../main/java/dev/baldav/app/BleHrService.kt | 12 ++++-- .../main/java/dev/baldav/app/MainActivity.kt | 7 ++++ src-tauri/src/ble.rs | 15 +++++++ src-tauri/src/lib.rs | 2 +- 9 files changed, 121 insertions(+), 19 deletions(-) create mode 100644 docs/session-logs/2026-10-02_ble-manual-connect.md diff --git a/PROJECT_MAP.md b/PROJECT_MAP.md index 41ef82e..fe71580 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` | ~340 | Foreground service BLE HR, FitPro protocol, GATT queue, scan/connect | +| `BleHrService.kt` | ~340 | Foreground service BLE HR, FitPro protocol, GATT queue, scan/connect. START_NOT_STICKY; скан не нашёл адрес -> stopSelf (не держит радио). Подключение инициируется ТОЛЬКО вручную (кнопка в настройках), не при старте записи | | `generated/*.kt` | ~1100 | Tauri-сгенерированные: WebView, IPC, permissions | ### Инструменты (`tools/`) diff --git a/README.md b/README.md index f9ab8f9..562ae00 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ - **Дневник питания** — ~13000 продуктов с меткой источника (Магнит, 5ка, ИОН, KDV, Гриднев, свои). Поиск порядко-независимый (слова в любом порядке). Каталог чистится от неиспользованного импорта без метки (health-diet и пр.) кнопкой в настройках — съеденные и в рецептах сохраняются. 37 нутриентов (витамины, минералы, omega-3/6, вода, клетчатка). Рецепты с термообработкой. Формулы порций (4*30, 300+50). - **GPS трекинг** — запись маршрутов на велосипеде/пешком в фоне (foreground service, точки пишутся при выключенном экране; один файл на сессию с id сессии в имени `gps_points__s.jsonl` для однозначной привязки, переживает START_STICKY-перезапуск ОС; полный до-импорт нативных точек по timestamp при финализации — без потерь, даже если экран был выключен всю запись, а при пробуждении экрана точки видимо нарастают на карте по мере импорта). При живой записи нативный файл — источник истины для БД, watchPosition только рисует маркер (без дублей одного фикса с разными ts). Восстановление точек из файлов на устройстве — матч файла к сессии по id/дате, дозаполнение разрывов с дедупом по близости ±2.5с, безопасный пересчёт без удаления точек, чистка уже импортированных файлов. Оффлайн карты (Leaflet + IndexedDB кэш). Импорт GPX/FTB (FitoTrack). Экспорт GPX. Фильтр altitude outlier'ов и GPS-залипаний. Отбор точек по точности (жёсткий порог по настройке «Мин. точность» — рубит грубые сотовые фиксы ~800м, оставляет слабый но точный GPS; где сигнала нет — честный разрыв). Направление (bearing) каждой точки пишется в БД. Вращение карты по компасу (leaflet-rotate, EMA-сглаживание, gimbal lock компенсация) и course-up по GPS-курсу (в ландшафте карта доворачивается вместе с экраном — курс к физическому верху). Ландшафтный режим (CSS rotation + панель справа, 3 режима: off/auto/lock). Dead reckoning при потере GPS (гироскоп + акселерометр). Placeholder до GPS (пиксельарт). Карта проезженных дорог (дедупликация 2м, вело/пешие). Настраиваемый цвет линии трека (чтобы отличать от цветных дорог). Навигационный маркер позиции с треугольником-направленцем (по компасу или GPS-курсу); порядок слоёв тайлы<дороги<маршрут<трек<маркер. Импорт тайлов по регионам (SQLite, роутер LAN). -- **BLE пульсометр** — подключение к FitPro браслетам и стандартным HR датчикам (0x180D). ЧСС, SpO2, давление в реальном времени при записи трека. +- **BLE пульсометр** — подключение к FitPro браслетам и стандартным HR датчикам (0x180D). ЧСС, SpO2, давление в реальном времени при записи трека. Подключение **только вручную** (Настройки -> «Подключить»), в т.ч. во время записи - пульс пишется в трек с момента подключения; автопоиска при старте записи нет (экономия батареи). BT выключен -> кнопка просит включить (системный диалог). Браслет не найден в скане -> сервис не висит в коннекте. - **Расчёт ккал** — велосипед: НЕТТО-модель (суточный баланс уже учитывает BMR, поэтому активность считается сверх покоя). Кручение = max(физика мощности из gradient+Crr+CdA / КПД, эмпирический MET-нетто по скорости); накат (freewheel, `power≤0` — «велик катился бы сам») = только базовый обмен. Crr на грунтах уточняется по вибрации акселерометра (до 0.020 на траве/колее). Ходьба с велосипедом в горку (только односкоростной) отделяется автоматически по скорости ниже каденса 30 об/мин и не засчитывается как езда. MET-таблица для ходьбы/бега и тренировок (приседания 6.0, скакалка 12.3, растяжки 2.3, бассейн 8.0). Набор высоты — кумулятивный гистерезис (устойчив на плотных треках, где per-point разница <1м); абсолютная точность набора ограничена сырым GPS-альтиметром (порядка десятков процентов, без модели рельефа не разрешается). Известное ограничение: `max(физика, MET)` не складывает накладные расходы, поэтому подъёмы по ккал слегка недосчитываются. Пункт меню «Пересчитать высоту/калории» пересчитывает статистику всех треков без изменения точек. Профили велосипедов. BMR по Mifflin-St Jeor с коэффициентом активности. Вес водителя для ккал берётся из **последнего взвешивания** (настройки - запас); каждая новая поездка **хранит вес на момент записи** (`gps_sessions.rider_weight_kg`), поэтому пересчёт старого трека считает по тогдашнему весу, а не текущему (старые треки без сохранённого веса - по последнему взвешиванию). Поле веса в настройках авто-синкается с последним взвешиванием. Баланс ккал вверху, тренд «Среднее за 7 дн.» и графики (вес/баланс, окна 30/90) считаются от **выбранного дня** (окно кончается на нём), а не всегда от «сегодня». - **Дистанция на разрывах GPS** — на «слепых» участках (интервал между фиксами > `gapThresholdSec`) путь считается по скорости (speed×dt, с ограничением по макс. скорости), а не прямой хордой — иначе разрывы занижают/искажают длину. Телепорт-скачки без временного разрыва отбрасываются. Расчёт зависит только от времени и скорости, поэтому live-счётчик, «Стоп» и пересчёт дают одинаковую длину. Порог `gapThresholdSec` настраивается; при изменении и пересчёте применяется сразу. - **Тренировки** — приседания (подходы x повторения), скакалка, растяжки, бассейн (дистанция). Автоматический расчёт ккал по MET и весу. diff --git a/docs/session-logs/2026-10-02_ble-manual-connect.md b/docs/session-logs/2026-10-02_ble-manual-connect.md new file mode 100644 index 0000000..3b1c0c2 --- /dev/null +++ b/docs/session-logs/2026-10-02_ble-manual-connect.md @@ -0,0 +1,41 @@ +# Сессия 2026-10-02 - BLE-пульсометр: подключение вручную, без авто-поиска + +По утверждённому плану `~/.claude/plans/merry-discovering-dream.md`. + +## Проблема +Расход батареи: при КАЖДОМ старте записи `GPS._startHrMonitor` безусловно звал `ble_connect`, а +Kotlin `BleHrService` при отсутствии браслета всё равно делал `directConnect` (держит радио) + +сервис `START_STICKY`. Если браслета нет рядом или BT выключен - расход впустую. + +## Поведение (по указанию пользователя) +1. BT включён: НЕ искать/коннектиться автоматически. Подключение к сопряжённому браслету - только + вручную, кнопкой в настройках, доступной в любой момент (в т.ч. во время записи). Нажал -> + пульс пишется в трек с этого момента. +2. BT выключен: ничего не делать; при ручной попытке - системный запрос включить BT. + +## Правки +- `frontend/js/gps.js` `_startHrMonitor`: убран авто `ble_connect`. Остался только дешёвый опрос + `ble_get_hr` (2с -> `currentHr`), чтобы ручное подключение (сейчас или позже по ходу записи) + начало класть пульс в трек. +- `frontend/js/main.js` настройки "Пульсометр (BLE)": кнопка **"Подключить"** (видна, когда + устройство сопряжено). Обработчик: `ble_check_bluetooth`; `off` -> `ble_request_enable` + + подсказка; `on` -> `ble_connect(addr,type)`; если идёт запись - поднять опрос + `GPS._startHrMonitor()`; короткий опрос статуса ~6с -> "подключён"/"браслет не найден". +- `src-tauri/src/ble.rs`: команда `ble_request_enable` (android -> send_command + "ble_request_enable"); зарегистрирована в `lib.rs` invoke_handler. +- `MainActivity.kt`: ветка `ble_request_enable` -> `startActivity(ACTION_REQUEST_ENABLE)` под + `ensureBlePermissions()`. Разрешение BLUETOOTH_CONNECT в манифесте уже есть. +- `BleHrService.kt`: `START_STICKY` -> `START_NOT_STICKY` (scan/connect; нет churn после убийства); + `connectDevice` - если скан 5с НЕ нашёл адрес, `stopSelf` вместо `directConnect` к отсутствующему + устройству (именно directConnect держал радио). + +## Коммит +Одним коммитом (JS+Rust+Kotlin - одна связная фича: кнопка зовёт новую команду; дробить на +нерабочие промежутки смысла нет). + +## Проверка (устройство, debug) +- Запись без браслета / BT off: нет "Поиск BLE", нет коннекта (logcat BleHrService молчит). GPS ок. +- BT off -> "Подключить": системный запрос включить BT. +- BT on, браслет надет -> "Подключить" (в т.ч. во время записи): подключается, heart_rate пишется + в точки с момента подключения. +- BT on, браслета нет -> "Подключить": "браслет не найден", сервис остановился (не висит). diff --git a/frontend/js/gps.js b/frontend/js/gps.js index f44db62..375656f 100644 --- a/frontend/js/gps.js +++ b/frontend/js/gps.js @@ -831,18 +831,12 @@ GPS._startHrMonitor = async function() { const type = await DB.getProfileValue("ble_device_type"); if (!addr || !type) { console.log("[HR] No paired device"); return; } - const connected = await window.__TAURI__.core.invoke("ble_is_connected"); - if (!connected) { - console.log("[HR] Connecting to", addr, type); - // Just send connect — Kotlin handles scan+connect internally - try { - await window.__TAURI__.core.invoke("ble_connect", { address: addr, deviceType: type }); - } catch (e) { - console.log("[HR] Connect failed:", e); - } - } - // Start polling HR regardless — data will appear when device connects - console.log("[HR] Starting HR poll"); + // Do NOT auto-connect/scan here. That ran a BLE scan+connect on every ride even when the + // band was absent or Bluetooth was off, holding the radio and draining the battery for + // nothing. Connecting is MANUAL now (Settings -> "Подключить", works mid-ride too). We only + // run the cheap HR poll (reads a local file), so a manual connect now or later starts + // feeding HR into the track via currentHr. + console.log("[HR] Starting HR poll (connect is manual)"); GPS.recording.hrPollInterval = setInterval(async () => { try { const reading = await window.__TAURI__.core.invoke("ble_get_hr"); diff --git a/frontend/js/main.js b/frontend/js/main.js index 6a93b88..0bd12d1 100644 --- a/frontend/js/main.js +++ b/frontend/js/main.js @@ -7200,6 +7200,7 @@ async function renderSettings(container) {
${state.profile.ble_device_addr ? `` : ""} + ${state.profile.ble_device_addr ? `` : ""}
@@ -7598,6 +7599,46 @@ async function renderSettings(container) { }; } + // Manual connect to the already-paired band. Works any time, including during a ride. + // Nothing auto-connects anymore (battery), so this is how HR starts flowing into the track. + let bleConnectBtn = container.querySelector("#s-ble-connect"); + if (bleConnectBtn) bleConnectBtn.onclick = async () => { + let addr = state.profile.ble_device_addr, type = state.profile.ble_device_type || "hr_standard"; + if (!addr) return; + let statusEl = container.querySelector("#s-ble-status"); + let bt = "on"; + try { bt = await window.__TAURI__.core.invoke("ble_check_bluetooth"); } catch {} + if (bt === "off") { + try { await window.__TAURI__.core.invoke("ble_request_enable"); } catch {} + showToast("Включите Bluetooth, затем нажмите «Подключить»"); + return; + } + if (bt === "no_adapter") { showToast("Bluetooth недоступен"); return; } + bleConnectBtn.disabled = true; bleConnectBtn.textContent = "..."; + if (statusEl) statusEl.textContent = "Подключение..."; + try { + await window.__TAURI__.core.invoke("ble_connect", { address: addr, deviceType: type }); + // If a ride is recording, make sure the HR poll runs so HR starts landing in the track. + if (GPS.recording.active && !GPS.recording.hrPollInterval) { try { await GPS._startHrMonitor(); } catch {} } + // Poll briefly (Kotlin scans+connects in the background) for a connection or first reading. + let ok = false; + for (let i = 0; i < 6; i++) { + await new Promise(r => setTimeout(r, 1000)); + try { + if (await window.__TAURI__.core.invoke("ble_is_connected")) { ok = true; break; } + let rd = await window.__TAURI__.core.invoke("ble_get_hr"); + if (rd && rd.hr > 0) { ok = true; break; } + } catch {} + } + if (statusEl) statusEl.textContent = ok ? addr + " (подключён)" : "браслет не найден"; + showToast(ok ? "Пульсометр подключён" : "Браслет не найден - включите его рядом"); + } catch (e) { + if (statusEl) statusEl.textContent = "ошибка подключения"; + showToast("Ошибка подключения: " + (e.message || e)); + } + bleConnectBtn.disabled = false; bleConnectBtn.textContent = "Подключить"; + }; + let bleScanBtn = container.querySelector("#s-ble-scan"); let bleResults = container.querySelector("#s-ble-results"); if (bleScanBtn) { 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 ac1ccb5..70eb9f4 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 @@ -72,7 +72,7 @@ class BleHrService : Service() { ACTION_SCAN -> { startForeground(NOTIFICATION_ID, buildNotification("Поиск BLE...")) scanDevices() - return START_STICKY + return START_NOT_STICKY // don't let Android restart us into an empty null-intent churn } else -> { val address = intent?.getStringExtra(EXTRA_ADDRESS) @@ -84,7 +84,7 @@ class BleHrService : Service() { dataFile = File(appDataDir, "ble_hr_data.json") connectDevice(address) } - return START_STICKY + return START_NOT_STICKY // connect is user-initiated; no sticky restart } } } @@ -184,8 +184,12 @@ class BleHrService : Service() { android.os.Handler(mainLooper).postDelayed({ scanner.stopScan(scanCallback) if (gatt == null) { - Log.d(TAG, "Scan timeout, trying direct connect to $address") - directConnect(adapter, address) + // Not seen in the scan → the band isn't nearby/on. Do NOT hold a GATT connect to + // an absent device (that keeps the radio busy and drains the battery). Give up; + // the user re-taps "Подключить" once the band is on. + Log.d(TAG, "Scan timeout, device $address not found - stopping") + writeData("""{"status":"not_found","ts":${System.currentTimeMillis()/1000}}""") + stopSelf() } }, 5000) } diff --git a/src-tauri/gen/android/app/src/main/java/dev/baldav/app/MainActivity.kt b/src-tauri/gen/android/app/src/main/java/dev/baldav/app/MainActivity.kt index 93b0633..d22b344 100644 --- a/src-tauri/gen/android/app/src/main/java/dev/baldav/app/MainActivity.kt +++ b/src-tauri/gen/android/app/src/main/java/dev/baldav/app/MainActivity.kt @@ -507,6 +507,13 @@ class MainActivity : TauriActivity() { intent.action = BleHrService.ACTION_STOP startService(intent) } + cmd == "ble_request_enable" -> { + Log.d("BalDav", "Requesting Bluetooth enable") + if (ensureBlePermissions()) { + try { startActivity(Intent(android.bluetooth.BluetoothAdapter.ACTION_REQUEST_ENABLE)) } + catch (e: Exception) { Log.e("BalDav", "BT enable request failed: ${e.message}") } + } + } cmd == "check_update" -> { checkForUpdate(appDataDir) } diff --git a/src-tauri/src/ble.rs b/src-tauri/src/ble.rs index 6148583..89e33d2 100644 --- a/src-tauri/src/ble.rs +++ b/src-tauri/src/ble.rs @@ -438,6 +438,21 @@ pub async fn ble_disconnect( } } +// Ask the OS to turn Bluetooth on (system ACTION_REQUEST_ENABLE dialog). BLUETOOTH_CONNECT is +// already granted, so no extra permission. Called when the user taps "Подключить" with BT off. +#[tauri::command] +pub async fn ble_request_enable( + app: tauri::AppHandle, +) -> Result { + #[cfg(not(target_os = "android"))] + { let _ = &app; Ok("desktop: no-op".to_string()) } + #[cfg(target_os = "android")] + { + android::send_command(&app, "ble_request_enable")?; + Ok("Requesting enable".to_string()) + } +} + #[tauri::command] pub fn ble_get_hr( app: tauri::AppHandle, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7cab0af..be7794b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1154,7 +1154,7 @@ pub fn run() { .add_migrations("sqlite:baldav.db", migrations) .build(), ) - .invoke_handler(tauri::generate_handler![get_safe_insets, import_seed_products, import_tiles, download_file, get_points_filename, delete_points_file, gps_set_session_id, list_points_files, read_points_file, pick_file_open, get_picked_file, debug_native_paths, rebuild_roads, get_road_points, get_ride_graph, get_app_info, gps_service_command, gps_service_read_points, brouter_route, brouter_download_tile, brouter_download_status, brouter_list_tiles, save_file, export_image, read_file_base64, export_db, import_db, restart_app, get_update_info, http_request, chzn_http, ble::ble_scan, ble::ble_scan_results, ble::ble_connect, ble::ble_disconnect, ble::ble_get_hr, ble::ble_is_connected, ble::ble_check_bluetooth, ble::ble_save_device, ble::ble_clear_hr]) + .invoke_handler(tauri::generate_handler![get_safe_insets, import_seed_products, import_tiles, download_file, get_points_filename, delete_points_file, gps_set_session_id, list_points_files, read_points_file, pick_file_open, get_picked_file, debug_native_paths, rebuild_roads, get_road_points, get_ride_graph, get_app_info, gps_service_command, gps_service_read_points, brouter_route, brouter_download_tile, brouter_download_status, brouter_list_tiles, save_file, export_image, read_file_base64, export_db, import_db, restart_app, get_update_info, http_request, chzn_http, ble::ble_scan, ble::ble_scan_results, ble::ble_connect, ble::ble_disconnect, ble::ble_get_hr, ble::ble_is_connected, ble::ble_check_bluetooth, ble::ble_save_device, ble::ble_clear_hr, ble::ble_request_enable]) .run(tauri::generate_context!()) .expect("error while running tauri application"); }