BLE HR: connect manually only, no auto-scan on every ride (battery)
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.
This commit is contained in:
parent
93908e3365
commit
dd1642b6e9
9 changed files with 121 additions and 19 deletions
|
|
@ -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` | ~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/`)
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@
|
|||
|
||||
- **Дневник питания** — ~13000 продуктов с меткой источника (Магнит, 5ка, ИОН, KDV, Гриднев, свои). Поиск порядко-независимый (слова в любом порядке). Каталог чистится от неиспользованного импорта без метки (health-diet и пр.) кнопкой в настройках — съеденные и в рецептах сохраняются. 37 нутриентов (витамины, минералы, omega-3/6, вода, клетчатка). Рецепты с термообработкой. Формулы порций (4*30, 300+50).
|
||||
- **GPS трекинг** — запись маршрутов на велосипеде/пешком в фоне (foreground service, точки пишутся при выключенном экране; один файл на сессию с id сессии в имени `gps_points_<ms>_s<id>.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 и весу.
|
||||
|
|
|
|||
41
docs/session-logs/2026-10-02_ble-manual-connect.md
Normal file
41
docs/session-logs/2026-10-02_ble-manual-connect.md
Normal file
|
|
@ -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, браслета нет -> "Подключить": "браслет не найден", сервис остановился (не висит).
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -7200,6 +7200,7 @@ async function renderSettings(container) {
|
|||
</div>
|
||||
<div style="display:flex;gap:6px">
|
||||
${state.profile.ble_device_addr ? `<button id="s-ble-unbind" style="padding:6px 12px;font-size:13px;background:#cf6679;color:#fff;border:none;border-radius:4px">Отвязать</button>` : ""}
|
||||
${state.profile.ble_device_addr ? `<button class="btn-primary" id="s-ble-connect" style="padding:6px 12px;font-size:13px">Подключить</button>` : ""}
|
||||
<button class="btn-primary" id="s-ble-scan" style="padding:6px 12px;font-size:13px">Поиск</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String, String> {
|
||||
#[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,
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue