Strap usage tracking: accrue real HR-delivering time + battery steps natively
The strap's battery % is coarse/laggy (reads 100% for >24h), so to judge real battery life we accumulate the time it actually delivered HR plus every battery step into strap_uptime.json in the foreground service (persistent across sessions). ble_read_strap_uptime / ble_reset_strap_uptime expose and reset it; Settings shows hours-since-charge + steps with a Charged reset button.
This commit is contained in:
parent
9bc71b19c9
commit
abc4e3cef0
6 changed files with 174 additions and 4 deletions
|
|
@ -22,7 +22,7 @@
|
|||
| Файл | Строк | Назначение |
|
||||
|------|-------|------------|
|
||||
| `js/main.js` | ~5900 | Экраны, навигация, модалки, графики, нутриенты, HR UI, компас (EMA + gimbal lock), ландшафт (3 режима), дороги. `appConfirm`/`appPrompt` (in-app диалоги вместо нативных, в стеке окон). Страница «Сохранённые маршруты», оверлей маршрута на записи и просмотре (`whenMapReady` — await готовности карты, не таймаут). Панель де-джиттера на детали трека (ползунки окно/итерации, живое превью, сохранить/откат, `reopenDetail` — переоткрытие детали без наложения оверлеев). `showRouteDetail` — предпросмотр маршрута (карта+профиль высоты+тап-высоты+фуллскрин), `showRouteEditor` — создание/правка маршрута (тап карты=точка в конец, тап линии=вставка в сегмент, тащить=двигать, тап точки=удалить; `makeMarker` возвращает маркер для push/splice). `appConfirm`/`appPrompt`/`appAlert` — in-app диалоги (нативных `alert/prompt/confirm` не осталось; `<select>` оставлены). `orient-dbg` (сенсор-оверлей на карте) — только при `window.__BALDAV_DEBUG`. `drawRoutePolyline`/`routeSlopeColor` (полосы уклона по 1%)/`routeElevColor` — топо/уклон-обводка в панели `routePane` (маршрут ПОД треком; на rotate-карте панель вешается внутрь `_rotatePane`, иначе маршрут не виден при записи). `fetchElevations` — цепочка GPXZ→open-elevation→open-meteo→OpenTopoData. Диагностические `console.log("[тег]...")` глушатся в release по `get_app_info().debug` (cfg!(debug_assertions)) |
|
||||
| `js/db.js` | ~1550 | SQLite через Tauri IPC. Миграции, CRUD, 41 поле добавок, water_pct, reserves, sync. Хелперы маршрутов (routes CRUD, `updateRoutePoints`/`addRoute(...,waypoints)` — плотная геометрия + `waypoints_json` {waypoints,legMode} для правки планировщиком, `getRoutes.hasEle/.wpts`). `saveSmoothedTrack` — чанковый `CASE`-апдейт lat/lon_smoothed (18 UPDATE на 4366 точек вместо 4366), `setSessionSmoothed` — откат де-джиттера. `saveGapFill`/`revertGapFill` — обратимый гэп-филл: вставка достроенных точек с `estimated=1` (seq = seq предыдущей реальной точки) / удаление их; колонки `gps_points.estimated`, `gps_sessions.gap_filled`. Nogo: таблица `nogo_areas` (lat/lon/radius_m) + `getNogoAreas`/`addNogoArea`/`deleteNogoArea`. `runMigrations` -> стартовый seq-репейр для NULL-seq сессий (merge/split создают их намеренно): батчевый `CASE` по 300 точек (как `saveGapFill`), а не UPDATE на точку - иначе ~15000 IPC на старте = ~15с и не досохранялось, гонялось каждый запуск |
|
||||
| `js/db.js` | ~1550 | SQLite через Tauri IPC. Миграции, CRUD, 41 поле добавок, water_pct, reserves, sync. Хелперы маршрутов (routes CRUD, `updateRoutePoints`/`addRoute(...,waypoints)` — плотная геометрия + `waypoints_json` {waypoints,legMode} для правки планировщиком, `getRoutes.hasEle/.wpts`). `saveSmoothedTrack` — чанковый `CASE`-апдейт lat/lon_smoothed (18 UPDATE на 4366 точек вместо 4366), `setSessionSmoothed` — откат де-джиттера. `saveGapFill`/`revertGapFill` — обратимый гэп-филл: вставка достроенных точек с `estimated=1` (seq = seq предыдущей реальной точки) / удаление их; колонки `gps_points.estimated`, `gps_sessions.gap_filled`. Nogo: таблица `nogo_areas` (lat/lon/radius_m) + `getNogoAreas`/`addNogoArea`/`deleteNogoArea`. `unmergeGpsSession`/`countMergedOrigins` — разделение склейки обратно по `orig_session_id` (парно к `mergeGpsSessions`): пересоздаёт сессии по исходным id, ретег точек, выставляет дату/время каждой из её точек; кнопка "Разделить склейку" в деталях трека (видна только если склейка). `runMigrations` -> стартовый seq-репейр для NULL-seq сессий (merge/split создают их намеренно): батчевый `CASE` по 300 точек (как `saveGapFill`), а не UPDATE на точку - иначе ~15000 IPC на старте = ~15с и не досохранялось, гонялось каждый запуск |
|
||||
| `js/gps.js` | ~1650 | GPS запись, ккал (нетто-модель + MET-нетто floor, накат=базовый), altitude filter + гистерезис высоты, BLE HR polling, dead reckoning. Источник истины БД при живой записи — нативный файл (`_processIncomingPoint(p, fromNative)`: watchPosition только рисует, поллинг пишет). `recoverFromFiles` — восстановление точек с устройства (матч по id/дате + `_s<id>` в имени, дедуп ±2.5с, `recalculateSession(noDelete)`, чистка импортированных файлов). `recomputeAllStats` — пересчёт статы всех треков без изменения точек. `filterByAccuracy` — отбор по точности (minAccuracyM) внутри `computeStats`+`filterNoise`: неточные фиксы исключаются из расчёта/линии (обратимо, non-destructive; null/≥999 keep), bearing_deg. `smoothTrackSG(pts,W,it)` — де-джиттер Савицкого-Голея (квадратичный МНК по окну, итеративно, концы закреплены; lat/lon→метры→обратно, все поля точки сохраняются). `parseRouteGPX(xml)` — парсер маршрутов (`rtept`/`trkpt`/`wpt`, читает `<ele>`, DOMParser). `routeElevStats(pts)` — профиль маршрута (накопл. дистанция, грейд к след. точке, набор/сброс с гистерезисом, макс-уклон; высоты сглаживаются). `fillGaps(points)` → `{points, gaps}` — дорожная достройка GPS-дропаутов (BRouter shortest) с интерполяцией ts и флагом `estimated`; `gaps[].status` = filled/skipped-long/skipped-noroute для UI-предпросмотра. `config.gpxzKey` — ключ GPXZ (localStorage) |
|
||||
| `js/sync.js` | ~300 | Синхронизация между устройствами (LAN, PHP сервер на роутере) |
|
||||
| `js/chzn.js` | ~330 | Честный знак (CRPT). Скан DataMatrix → карточка Нацкаталога → префилл КБЖУ. `ChZ`: сессия в localStorage (`chzn_session`), `importFromText`, `refresh`/`ensureSid` (sid 48ч, refreshToken ~год), `authedPost/Get` (userId+sid в query, авто-refresh на 401), `check`, `nutritionInfo` (витамины, часто 204), `normalizeCode` (реконструкция GS 0x1D перед AI 93), `parseCard` (группа «Энергетическая ценность…», имена атрибутов разнятся у производителей), `resolve`. `/authorize/anonymous` закрыт Play Integrity — свою сессию не завести, только импорт из приложения (`tools/chzn_export.sh`) |
|
||||
|
|
@ -42,7 +42,7 @@
|
|||
| Файл | Строк | Назначение |
|
||||
|------|-------|------------|
|
||||
| `src/lib.rs` | ~790 | Миграции v1, IPC команды, seed import, update check, tile import, road map rebuild. `native_root()` — выбор корня приложения по наличию `gps_live_points.jsonl`/`gps_points_*` (на этом Tauri `app_data_dir()` = корень напрямую; старый `.parent().parent()` читал мимо, отсюда потеря фоновых точек). `http_request` (GET/POST, тело) и `chzn_http` (браузерный User-Agent + возврат `{status,body}` для 204/401/427, `http_status_as_error(false)`) — прокси HTTP мимо CORS WebView |
|
||||
| `src/ble.rs` | ~480 | BLE HR: btleplug (desktop), file IPC (Android), FitPro + Standard HR. `ble_read_hr_log` — читает нативный лог ЧСС `ble_hr_log.jsonl` (пишет foreground-сервис, переживает гаснущий экран); `ble_clear_hr` чистит и его |
|
||||
| `src/ble.rs` | ~480 | BLE HR: btleplug (desktop), file IPC (Android), FitPro + Standard HR. `ble_read_hr_log` — читает нативный лог ЧСС `ble_hr_log.jsonl` (пишет foreground-сервис, переживает гаснущий экран); `ble_clear_hr` чистит и его. `ble_read_strap_uptime`/`ble_reset_strap_uptime` — наработка ремня (время реальной отдачи ЧСС + шаги заряда, персистентно в `strap_uptime.json`, для расчёта ресурса батареи самими) |
|
||||
| `src/main.rs` | 5 | Точка входа |
|
||||
| `Cargo.toml` | — | tauri, tauri-plugin-sql, rusqlite, serde, btleplug, tokio, uuid |
|
||||
| `tauri.conf.json` | — | Конфиг приложения, плагины, ресурсы |
|
||||
|
|
@ -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-массива. Авто-реконнект: обрыв УЖЕ установленного соединения (connectedOnce) переподключается через скан (до 3 попыток, 8с), сброс бюджета при успехе; если скан не нашёл ремень или это stopping (ACTION_STOP/BT-off/onDestroy) - stopSelf, не долбится в отсутствующий/не запущенный вручную ремень |
|
||||
| `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, не долбится в отсутствующий/не запущенный вручную ремень. Наработка ремня: `accrueUptime`/`writeUptimeFile` копят время реальной отдачи ЧСС (только промежутки без разрывов) + шаги заряда в `strap_uptime.json` (персистентно, не чистится per-session, flush ~20с и при обрыве) |
|
||||
| `generated/*.kt` | ~1100 | Tauri-сгенерированные: WebView, IPC, permissions |
|
||||
|
||||
### Инструменты (`tools/`)
|
||||
|
|
|
|||
33
docs/session-logs/2026-10-20_strap-uptime-and-unmerge.md
Normal file
33
docs/session-logs/2026-10-20_strap-uptime-and-unmerge.md
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
# Сессия 2026-10-20 (доб.) - наработка ремня ЧСС + разделение склейки (UI)
|
||||
|
||||
## Наработка ремня (считаем ресурс батареи сами)
|
||||
Проблема: ремень >24ч пишет 100%, непонятно шагами по 25% или как. Нужно самим считать
|
||||
"300 часов". Заряд (0x2A19) грубый/запаздывает - поэтому копим ВРЕМЯ реальной отдачи ЧСС.
|
||||
- Kotlin BleHrService: `accrueUptime`/`writeUptimeFile` - на каждый замер ЧСС копят время
|
||||
(только промежутки <10с, т.е. без разрывов) и шаги заряда в `strap_uptime.json`
|
||||
(персистентно, НЕ чистится per-session; flush ~20с и принудительно при обрыве/disconnect).
|
||||
- Rust: `ble_read_strap_uptime` (чтение), `ble_reset_strap_uptime` (сброс при зарядке).
|
||||
Зарегистрированы в lib.rs.
|
||||
- JS: в настройках (Пульсометр) строка "Ремень ЧСС: наработка" - заряд, Hч Mм с последней
|
||||
зарядки, шаги заряда, кнопка "Заряжен" (сброс через appConfirm).
|
||||
Счётчик считает С МОМЕНТА установки/сброса; прошедшие сутки задним числом не восстановить.
|
||||
|
||||
## Авто-реконнект ремня (из прошлого лога этой даты)
|
||||
Отдельный коммит ранее: переподключение только УЖЕ установленного соединения (connectedOnce),
|
||||
до 3 попыток, не долбится в отсутствующий/не запущенный вручную ремень.
|
||||
|
||||
## Разделение склейки (по образцу merge, из UI)
|
||||
Пользователь склеил 3 велопоездки в одну (session 1792506450721) и пожалел; склейка растянула
|
||||
время и перекрыла воркауты между кусками. Все точки хранят `orig_session_id` -> разъединяемо.
|
||||
- db.js `unmergeGpsSession(sessionId)`: по distinct `orig_session_id` пересоздаёт исходные
|
||||
сессии (id = исходные), ретегает точки (orig_session_id=NULL, seq=NULL), выставляет каждой
|
||||
дату/start/end из её точек. `countMergedOrigins` - сколько исходных склеено (>1 = склейка).
|
||||
- main.js: кнопка "Разделить склейку (N)" в деталях трека (после "Разрезать"), видна только
|
||||
для склейки; по тапу - unmerge + пересчёт каждой (computeStats+finishGpsSession, MET-оценка;
|
||||
точную по физике - кнопкой "Пересчитать" в каждой) + возврат к списку.
|
||||
|
||||
## Проверка
|
||||
Синтаксис JS ок (db/gps/main/tilecache). Rust/Kotlin - в сборке. Собираем в одну сборку с
|
||||
реконнектом; ставим после Стопа ночной записи (переустановка её прерывает, данные в бэкапе
|
||||
`scratchpad/night_hr_log_backup.jsonl`). На устройстве: наработка растёт и показывается в
|
||||
настройках; "Разделить склейку" даёт 3 трека; реконнект переживает снятие ремня на 10-20с.
|
||||
|
|
@ -5597,6 +5597,7 @@ async function showSessionDetail(sessionId) {
|
|||
<button id="sd-recalc" class="btn-secondary full-width" style="margin-top:8px">Пересчитать</button>
|
||||
<button id="sd-refine" class="btn-secondary full-width" style="margin-top:8px">Уточнить высоты (онлайн)</button>
|
||||
<button id="sd-split" class="btn-secondary full-width" style="margin-top:8px">Разрезать трек</button>
|
||||
<button id="sd-unmerge" class="btn-secondary full-width" style="margin-top:8px;display:none">Разделить склейку</button>
|
||||
<button id="sd-export" class="btn-secondary full-width" style="margin-top:8px">Экспорт GPX</button>
|
||||
<button id="sd-share" class="btn-secondary full-width" style="margin-top:8px">Похвастаться</button>
|
||||
<button id="sd-delete" class="btn-secondary full-width" style="margin-top:8px;color:#cf6679">Удалить тренировку</button>
|
||||
|
|
@ -6387,6 +6388,35 @@ async function showSessionDetail(sessionId) {
|
|||
};
|
||||
}
|
||||
|
||||
// Un-merge button: only for a merged session (points carry >1 orig_session_id). Splits it
|
||||
// back into the original rides, recomputing each; shown only when it's actually a merge.
|
||||
let unmergeBtn = document.querySelector("#sd-unmerge");
|
||||
if (unmergeBtn) (async () => {
|
||||
let n = 0; try { n = await DB.countMergedOrigins(sessionId); } catch {}
|
||||
if (n < 2) return;
|
||||
unmergeBtn.style.display = "";
|
||||
unmergeBtn.textContent = `Разделить склейку (${n})`;
|
||||
unmergeBtn.onclick = async () => {
|
||||
if (!(await appConfirm(`Разделить трек обратно на ${n} исходные поездки? Статистика каждой пересчитается (точную по физике можно потом «Пересчитать» в каждой).`, "Разделить"))) return;
|
||||
unmergeBtn.disabled = true; unmergeBtn.textContent = "Разделяю...";
|
||||
try {
|
||||
let ids = await DB.unmergeGpsSession(sessionId);
|
||||
if (!ids) { showToast("Это не склейка"); unmergeBtn.disabled = false; unmergeBtn.textContent = `Разделить склейку (${n})`; return; }
|
||||
let w = session.rider_weight_kg ?? state.latestWeight?.weight_kg ?? state.profile?.weight_kg ?? 80;
|
||||
for (let id of ids) {
|
||||
let pts = await DB.getGpsPoints(id);
|
||||
if (!pts.length) continue;
|
||||
let st = GPS.computeStats(pts, w, { activityType: session.activity_type });
|
||||
let endT = new Date(pts[pts.length - 1].ts).toTimeString().slice(0, 5);
|
||||
try { await DB.finishGpsSession(id, endT, st.distance, st.kcal, st.avgSpeed, st.maxSpeed, st.elevGain, st.elevLoss, []); } catch (e) { console.error("[unmerge] recompute", e); }
|
||||
}
|
||||
showToast(`Разделено на ${ids.length} трека`);
|
||||
if (winStack.length > 0) winStack.pop().close();
|
||||
_skipPops = 1; history.back(); renderScreen("gps");
|
||||
} catch (e) { showToast("Ошибка: " + (e.message || e)); unmergeBtn.disabled = false; unmergeBtn.textContent = `Разделить склейку (${n})`; }
|
||||
};
|
||||
})();
|
||||
|
||||
// Recalculate button
|
||||
let recalcBtn = document.querySelector("#sd-recalc");
|
||||
if (recalcBtn) recalcBtn.onclick = async () => {
|
||||
|
|
@ -7395,6 +7425,13 @@ async function renderSettings(container) {
|
|||
<div class="settings-item-desc">Не предлагать включить GPS при старте записи трека</div></div>
|
||||
<input id="s-dontask-gps" type="checkbox" ${localStorage.getItem("baldav_dontask_gps") === "1" ? "checked" : ""} style="width:20px;height:20px;">
|
||||
</label>
|
||||
<div class="settings-item" id="s-strap-usage">
|
||||
<div class="settings-item-left">
|
||||
<div class="settings-item-title">Ремень ЧСС: наработка</div>
|
||||
<div class="settings-item-desc" id="s-strap-usage-desc">—</div>
|
||||
</div>
|
||||
<button class="btn-secondary" id="s-strap-reset" style="padding:6px 12px;font-size:13px">Заряжен</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
|
|
@ -7834,6 +7871,33 @@ async function renderSettings(container) {
|
|||
let dontAskGps = container.querySelector("#s-dontask-gps");
|
||||
if (dontAskGps) dontAskGps.onchange = () => { try { localStorage.setItem("baldav_dontask_gps", dontAskGps.checked ? "1" : "0"); } catch {} };
|
||||
|
||||
// Strap usage readout: total HR-delivering time since last "charged" reset + battery steps,
|
||||
// so we can judge real battery life against the claimed 300h (the % reading is coarse/laggy).
|
||||
let strapDesc = container.querySelector("#s-strap-usage-desc");
|
||||
let fillStrapUsage = async () => {
|
||||
if (!strapDesc) return;
|
||||
try {
|
||||
let raw = await window.__TAURI__.core.invoke("ble_read_strap_uptime");
|
||||
let u = JSON.parse(raw || "{}");
|
||||
let sec = Math.round(u.active_sec || 0);
|
||||
let h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60);
|
||||
let bat = (u.bat != null) ? `${u.bat}%` : "?";
|
||||
let steps = Array.isArray(u.steps) ? u.steps : [];
|
||||
let since = u.since ? new Date(u.since * 1000).toLocaleDateString("ru-RU") : "—";
|
||||
let stepStr = steps.length
|
||||
? " · шаги: " + steps.map(s => `${s.bat}%`).join("->")
|
||||
: " · шагов заряда нет (ещё 100%?)";
|
||||
strapDesc.textContent = `заряд ${bat} · в работе ${h}ч ${m}м с ${since}${stepStr}`;
|
||||
} catch (e) { strapDesc.textContent = "нет данных"; }
|
||||
};
|
||||
fillStrapUsage();
|
||||
let strapReset = container.querySelector("#s-strap-reset");
|
||||
if (strapReset) strapReset.onclick = async () => {
|
||||
if (!(await appConfirm("Сбросить счётчик наработки ремня (отметить как заряженный)?", "Сбросить"))) return;
|
||||
try { await window.__TAURI__.core.invoke("ble_reset_strap_uptime"); } catch {}
|
||||
fillStrapUsage();
|
||||
};
|
||||
|
||||
let bleScanBtn = container.querySelector("#s-ble-scan");
|
||||
let bleResults = container.querySelector("#s-ble-results");
|
||||
if (bleScanBtn) {
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@ import android.os.ParcelUuid
|
|||
import android.util.Log
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
class BleHrService : Service() {
|
||||
|
||||
|
|
@ -65,6 +67,13 @@ class BleHrService : Service() {
|
|||
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)
|
||||
// Strap usage/battery accounting — persistent across sessions (strap_uptime.json), so we can
|
||||
// compute real battery life ourselves and see the battery-level steps over time.
|
||||
private var uptimeFile: File? = null
|
||||
private var lastReadingMs = 0L // for accruing only time the strap was actually delivering HR
|
||||
private var pendingActiveMs = 0L
|
||||
private var lastUptimeFlushMs = 0L
|
||||
private var lastBatLogged = -1
|
||||
private var lastBattery: Int = -1 // strap charge %, -1 = unknown
|
||||
private var scanResultsFile: File? = null
|
||||
private var fitproWriteChar: BluetoothGattCharacteristic? = null
|
||||
|
|
@ -124,6 +133,7 @@ class BleHrService : Service() {
|
|||
val appDataDir = filesDir.parentFile ?: filesDir
|
||||
dataFile = File(appDataDir, "ble_hr_data.json")
|
||||
logFile = File(appDataDir, "ble_hr_log.jsonl")
|
||||
uptimeFile = File(appDataDir, "strap_uptime.json")
|
||||
// fresh user-initiated connect: arm reconnect only after it actually connects
|
||||
lastAddress = address; stopping = false; connectedOnce = false; reconnectTries = 0
|
||||
connectDevice(address)
|
||||
|
|
@ -528,11 +538,48 @@ class BleHrService : Service() {
|
|||
// recorder rebuilds its samples from here (ble_read_hr_log) instead of the JS poll.
|
||||
if (json.contains("\"hr\":")) {
|
||||
try { logFile?.appendText(json + "\n") } catch (e: Exception) { Log.e(TAG, "Append hr log failed", e) }
|
||||
accrueUptime()
|
||||
}
|
||||
}
|
||||
|
||||
// Accrue strap "actually delivering HR" time + battery-level steps into strap_uptime.json
|
||||
// (persistent — NOT cleared per session). Flushed every ~20s (read-modify-write, small file).
|
||||
private fun accrueUptime() {
|
||||
val now = System.currentTimeMillis()
|
||||
val dt = now - lastReadingMs
|
||||
if (lastReadingMs > 0L && dt in 1..10000) pendingActiveMs += dt // only count gap-free stretches
|
||||
lastReadingMs = now
|
||||
if (now - lastUptimeFlushMs < 20000) return
|
||||
writeUptimeFile()
|
||||
}
|
||||
|
||||
// Persist accrued active time + battery step to strap_uptime.json (read-modify-write).
|
||||
private fun writeUptimeFile() {
|
||||
val now = System.currentTimeMillis()
|
||||
lastUptimeFlushMs = now
|
||||
val f = uptimeFile ?: return
|
||||
try {
|
||||
val obj = if (f.exists() && f.length() > 0) JSONObject(f.readText()) else JSONObject()
|
||||
obj.put("active_sec", obj.optDouble("active_sec", 0.0) + pendingActiveMs / 1000.0)
|
||||
if (!obj.has("since")) obj.put("since", now / 1000)
|
||||
val bat = lastBattery
|
||||
if (bat in 0..100) {
|
||||
obj.put("bat", bat)
|
||||
if (bat != lastBatLogged) {
|
||||
val steps = obj.optJSONArray("steps") ?: JSONArray()
|
||||
steps.put(JSONObject().put("ts", now / 1000).put("bat", bat))
|
||||
obj.put("steps", steps)
|
||||
lastBatLogged = bat
|
||||
}
|
||||
}
|
||||
f.writeText(obj.toString())
|
||||
pendingActiveMs = 0L
|
||||
} catch (e: Exception) { Log.e(TAG, "uptime flush: ${e.message}") }
|
||||
}
|
||||
|
||||
private fun disconnect() {
|
||||
isRunning = false
|
||||
try { writeUptimeFile() } catch (e: Exception) {} // persist the last accrued strap time
|
||||
handler.removeCallbacksAndMessages(null) // no stray callback may touch GATT after this
|
||||
try { gatt?.disconnect() } catch (e: Exception) { Log.e(TAG, "gatt.disconnect: ${e.message}") }
|
||||
try { gatt?.close() } catch (e: Exception) { Log.e(TAG, "gatt.close: ${e.message}") }
|
||||
|
|
|
|||
|
|
@ -477,6 +477,32 @@ pub fn ble_read_hr_log(app: tauri::AppHandle) -> Result<String, String> {
|
|||
{ Ok(android::read_file(&app, "ble_hr_log.jsonl").unwrap_or_default()) }
|
||||
}
|
||||
|
||||
// Strap usage accounting (strap_uptime.json): total time the strap actually delivered HR plus
|
||||
// battery-level steps, accumulated by the foreground service across sessions (persistent — so we
|
||||
// can compute real battery life ourselves). Returns "{}" if none yet.
|
||||
#[tauri::command]
|
||||
pub fn ble_read_strap_uptime(app: tauri::AppHandle) -> Result<String, String> {
|
||||
#[cfg(not(target_os = "android"))]
|
||||
{ let _ = &app; Ok("{}".to_string()) }
|
||||
#[cfg(target_os = "android")]
|
||||
{ Ok(android::read_file(&app, "strap_uptime.json").unwrap_or_else(|| "{}".to_string())) }
|
||||
}
|
||||
|
||||
// Reset the usage counter — call when the strap is put on charge / newly charged.
|
||||
#[tauri::command]
|
||||
pub fn ble_reset_strap_uptime(app: tauri::AppHandle) -> Result<(), String> {
|
||||
use tauri::Manager;
|
||||
let data_dir = app.path().app_data_dir().map_err(|e: tauri::Error| e.to_string())?;
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs();
|
||||
let fresh = format!("{{\"active_sec\":0,\"since\":{},\"steps\":[]}}", now);
|
||||
let _ = std::fs::write(data_dir.join("strap_uptime.json"), &fresh);
|
||||
if let Some(root) = data_dir.parent().and_then(|p| p.parent()) {
|
||||
let _ = std::fs::write(root.join("strap_uptime.json"), &fresh);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn ble_is_connected(
|
||||
app: tauri::AppHandle,
|
||||
|
|
|
|||
|
|
@ -1162,7 +1162,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, ble::ble_read_hr_log, ble::ble_request_enable, read_state_file])
|
||||
.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_read_hr_log, ble::ble_read_strap_uptime, ble::ble_reset_strap_uptime, ble::ble_request_enable, read_state_file])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue