Compare commits
65 commits
6dfe262801
...
802b2cc6e9
| Author | SHA1 | Date | |
|---|---|---|---|
| 802b2cc6e9 | |||
| eeca9a7d94 | |||
| 13c61f506a | |||
| 1df24000a3 | |||
| 87a7fccfcc | |||
| 69b36be591 | |||
| e88c2756f7 | |||
| dffadf3cd8 | |||
| 0a4ffcb49d | |||
| 553a4fcf80 | |||
| 2d0e51bd8d | |||
| f7ba059628 | |||
| 62434d6155 | |||
| f704498d11 | |||
| dd1642b6e9 | |||
| 93908e3365 | |||
| 3e2a6e47f7 | |||
| fd178ad97d | |||
| 06cc77fdec | |||
| 834204a58a | |||
| 74f3ec4d6e | |||
| 2d91c06290 | |||
| dbb6c2ea9f | |||
| 0123d3562b | |||
| d6fe47a924 | |||
| 5da02c598a | |||
| 749064ce59 | |||
| 5f58eabcc5 | |||
| 514ff4edb3 | |||
| 862b1ffc96 | |||
| 5d24f8c3a3 | |||
| bb9f03131d | |||
| dff9342c0d | |||
| 6534d6601c | |||
| 3ba8751664 | |||
| 685151b76b | |||
| c84767fc9e | |||
| b379160ff5 | |||
| c913cc530c | |||
| ea7c6e68a8 | |||
| 492c54990f | |||
| ae90b4368b | |||
| 9e0910a35f | |||
| d4faed200d | |||
| d0abb72aba | |||
| 5b3239258f | |||
| bb8a739b7e | |||
| 263163cf52 | |||
| 682bee6e9a | |||
| 75eca0e501 | |||
| d6139a99f7 | |||
| db62d52650 | |||
| 15a2881c6d | |||
| 44bb08e81e | |||
| ea462ad9c4 | |||
| c5386ae22b | |||
| cfadad4690 | |||
| 4895cbb042 | |||
| 2ffaf1581d | |||
| de93f77c3a | |||
| 7d2581dc2d | |||
| 1a57f36621 | |||
| 0d8b5f97b9 | |||
| b2defb4564 | |||
| 7717a73129 |
34 changed files with 2828 additions and 241 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -30,5 +30,9 @@ chzn_session.json
|
|||
binaries/
|
||||
test.exe
|
||||
|
||||
# Build script holds a deploy secret (router password); build counter is local state
|
||||
build.sh
|
||||
BUILD_NUMBER
|
||||
|
||||
# Reference material kept locally, not in the repo
|
||||
*.pdf
|
||||
|
|
|
|||
|
|
@ -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` |
|
||||
| `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/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`) |
|
||||
|
|
@ -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` | ~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 |
|
||||
| `generated/*.kt` | ~1100 | Tauri-сгенерированные: WebView, IPC, permissions |
|
||||
|
||||
### Инструменты (`tools/`)
|
||||
|
|
@ -81,7 +81,7 @@
|
|||
|
||||
| Файл | Назначение |
|
||||
|------|------------|
|
||||
| `build.sh` | Сборка: android/debug/release, sync frontend, версионирование, -u upload на VPS |
|
||||
| `build.sh` | Сборка: android/debug/release, sync frontend, версионирование, -u upload на VPS, `clean`/авто-чистка старых бандлов (`clean_bundles`) |
|
||||
| `BUILD_NUMBER` | Автоинкремент номера сборки |
|
||||
| `ion_products.json` | 1111 продуктов ION (сырые данные скрейпера) |
|
||||
| `5ka_products_fresh.json` | 3103 продукта 5ka (сырые данные) |
|
||||
|
|
@ -142,7 +142,7 @@
|
|||
### Экраны
|
||||
- **Сегодня** — баланс ккал, еда (продукты + блюда), GPS тренировки, активность, вес, сон, добавки, прогресс-бар жидкости, нутриенты (приход + запасы)
|
||||
- **Продукты** — подтабы [Продукты | Рецепты], поиск, карточки с КБЖУ, добавление в дневник
|
||||
- **Трек** — GPS тренировки (запись с HR), карта с компасом (leaflet-rotate). Навигационный «пак» позиции (SVG divIcon): круг цвета трека (на 20% темнее) с белой + 1px чёрной обводкой + треугольник, указывающий ВВЕРХ (карта и так доворачивается активным источником — курс/компас — вверх, поэтому без покадрового поворота: не трясёт). Мини-компас только в режиме компаса. Z-order панелями: тайлы < дороги(`roadsPane` 240) < маршрут(`routePane` 250) < трек(overlay) < пак(marker). course-up по GPS-курсу (карта и панель доворачиваются в ландшафт вместе, `bearing = h`), ландшафтный режим (CSS rotation), графики скорости/высоты/ЧСС, импорт GPX/FTB, dead reckoning, placeholder до GPS, карта дорог (дедуп 2м, цвета по вело/пешие/бег), сохранённые маршруты (оверлей-навигация, кнопка-тоггл «М», предпросмотр с профилем высоты, топо/уклон-обводка, тоггл «∡/⛰», высоты по цепочке GPXZ→open-elevation→open-meteo→OpenTopoData), де-джиттер трека (Савицкий-Голей, кнопка «≈» на детали)
|
||||
- **Трек** — GPS тренировки (запись с HR), карта с компасом (leaflet-rotate). Навигационный «пак» позиции (SVG divIcon): круг цвета трека (на 20% темнее) с белой + 1px чёрной обводкой + треугольник, указывающий ВВЕРХ (карта и так доворачивается активным источником — курс/компас — вверх, поэтому без покадрового поворота: не трясёт). Мини-компас только в режиме компаса. Z-order панелями: тайлы < дороги(`roadsPane` 240) < маршрут(`routePane` 250) < трек(overlay) < пак(marker). course-up по GPS-курсу (карта и панель доворачиваются в ландшафт вместе, `bearing = h`), ландшафтный режим (CSS rotation), графики скорости/высоты/ЧСС, импорт GPX/FTB, dead reckoning, placeholder до GPS, карта дорог (дедуп 2м, цвета по вело/пешие/бег), сохранённые маршруты (оверлей-навигация, кнопка-тоггл «М», предпросмотр с профилем высоты, топо/уклон-обводка, тоггл «∡/⛰», высоты по цепочке GPXZ→open-elevation→open-meteo→OpenTopoData), де-джиттер трека (Савицкий-Голей, кнопка «≈» на детали), тоггл следования карты за позицией (`followMode`, кнопка `⊕` в `rec-map-btn` стеке; guard вокруг `panTo`/`setView` в `onPoint`/`onBatchUpdate`/`onStateChange`; выкл = свободный просмотр карты на ходу, повторный тап центрирует; не запоминается). Карты с тяжёлыми векторами (`showSessionDetail`, `showRoadsMap`, `showComparisonView`, `showRouteDetail`) создаются с `preferCanvas: true` — большой трек/дороги в дефолтном SVG превышают лимит памяти тайлов компоновщика Chromium на зуме (`tile_manager tile memory limits exceeded`) и тайлы гаснут до перезапуска; canvas рисует в один холст. НЕ на карте записи (leaflet-rotate плохо дружит с Canvas) и не в редакторе маршрута (клик по линии)
|
||||
- **Добавки** — 41 нутриент (витамины, минералы, жирные кислоты, женьшень, ресвератрол, CoQ10)
|
||||
- **Настройки** — профиль, образ жизни, нормы ЧСС/давления, BLE пульсометр, велосипеды, GPS конфиг (в т.ч. цвет трека `trackColor`, цвета дорог, GPXZ-ключ), OTA обновления, импорт тайлов, карта дорог
|
||||
|
||||
|
|
@ -151,6 +151,9 @@
|
|||
- Рецепты: showRecipeDetailModal, showRecipeEditorModal, showAddIngredientModal
|
||||
- Тренировки: showSessionDetail (карта, графики скорости/высоты, секция «Сегменты» - сворачиваемый `<details>`-аккордеон, свёрнут по умолчанию, чтобы кнопки внизу были доступны; пересчёт, оверлей маршрута, панель де-джиттера SG), showRoutesScreen (сохранённые маршруты)
|
||||
- Похвастаться: showShareModal / buildShareImage (сторис-PNG: форма трека + статы; `opts.fields` галочками в модалке, выбор в `localStorage.baldav_share_fields`; прозрачный/фото; в стеке окон - `winPush("share")`), exportShareImage (нативный share/save)
|
||||
- Объединение поездок: в `renderGpsScreen` долгий тап (500мс) по `.gps-session-item` -> режим выделения (`.selected`, плавающая `#gps-sel-bar`), 2+ -> `DB.mergeGpsSessions(idStrs)` (db.js): точки в самую раннюю сессию, `orig_session_id` per-point (реверс), `seq=NULL` (иначе совпадающие seq перемешивают порядок), лишние сессии удаляются; затем `GPS.recalculateSession(target,{noDelete})`. Колонка `gps_points.orig_session_id` (миграция). Склейка блокируется, если у выбранных треков разные `bike_id` (appAlert)
|
||||
- Разрезание трека: кнопка `#sd-split` в `showSessionDetail` включает `#sd-map.split-mode`; в попапе каждой точки кнопка `.split-here` (видна только в split-mode) -> `confirmSplitAt(k)` -> `DB.splitGpsSession(sessionId, part2Ids, part1EndTime, part2StartTime)` (db.js): вторая половина (точки по id, чанки) в НОВУЮ сессию (id=Date.now(), копия date/activity/bike/source), `seq=NULL`, у старой правится end_time; пересчёт обоих кусков. Велик копируется, потом меняется вручную
|
||||
- Разбор повторов: `GPS.detectRepeats(points)` (gps.js) - самоподобие: сетка ~10м, горячие ячейки (>=2 входов = minPasses), самый длинный эталонный проход -> опорная линия; каждая точка ПРОЕЦИРУЕТСЯ на неё (cell-индекс, допуск вбок 30м) -> прогресс; проход = прогресс растёт от низа до >=80% (порт идеи `tools/track/pers.py`). Мост через короткие остановки/дрожь; время заезда `movingSec` = только движение (стопы вырезаны). Возвращает `{found,segLength,passCount,passes:[{startIdx,endIdx,movingSec}]}`. Секция «Разбор повторов» в `showSessionDetail` (только вело): таблица + вердикт последний/первый + подсветка участка на карте. Мощность на заезд - `avgPower` (новое поле возврата `GPS.computeStats`)
|
||||
- Роутинг по проезженному: loadRideGraph (кэш графа из `get_ride_graph`, grid-индекс) / rideRoute (Dijkstra между концами ноги, снап ≤60м, отсев кривого) - в `showRouteEditor` пробуется до BRouter
|
||||
- Прочее: showWeightModal, showSleepModal, showActivityModal, showProfileModal, showBikeModal, showMapDownloadModal, showLogSupplementModal
|
||||
|
||||
|
|
@ -179,6 +182,9 @@
|
|||
| `ble_save_device` | Сохранение привязанного устройства |
|
||||
| `import_tiles` | Чтение тайлового SQLite батчами (base64) |
|
||||
| `download_file` | Скачивание бинарного файла по URL (без лимита размера) |
|
||||
| `export_db` | Бэкап всей БД: `VACUUM INTO` чистой копии в app-root -> команда `exportdb:` -> Kotlin SAF-сохранение (`saveDbLauncher`, стрим файла) |
|
||||
| `import_db` | Восстановление: валидирует `picked_import.db` (`quick_check` + таблицы BalDav), кладёт в `pending_restore.db`; Kotlin `onCreate` свопит на `baldav.db` до открытия БД |
|
||||
| `restart_app` | Команда `restartapp` -> Kotlin перезапускает процесс (применить восстановление) |
|
||||
| `rebuild_roads` | Перестройка road_points из всех треков (2м дедуп; пеший point (`walk=1`) → atype 'walk') |
|
||||
| `get_road_points` | Чтение road_points батчами (обход лимита SQL plugin) |
|
||||
| `get_ride_graph` | Граф проезженных дорог для роутинга по своим трекам: узлы на сетке ~20м (исключая `walk=1`), рёбра между последовательными точками сессии (вес = между узлами). `{nodes,edges}` |
|
||||
|
|
@ -254,8 +260,10 @@ kcal = (dt/60) * (MET * 3.5 * weight_kg) / 200
|
|||
./build.sh android # release APK (подпись debug.keystore)
|
||||
./build.sh -u -d android # debug + install + заливка на роутер/VPS (OTA)
|
||||
./build.sh install -d # поставить уже собранный APK без пересборки
|
||||
./build.sh clean # убрать старые AAB-бандлы и APK-выхлопы gen/ (Rust-кэш и binaries/ не трогает)
|
||||
```
|
||||
Без таргета `./build.sh` = dev-сервер (десктоп), НЕ сборка APK.
|
||||
`clean_bundles` также вызывается автоматически в конце каждой android-сборки (после копии APK в binaries/): удаляет `gen/.../intermediary_bundle` + `outputs/bundle` (AAB, мы раздаём APK) и `outputs/apk/*.apk`. Регенерируются Gradle при следующей сборке.
|
||||
|
||||
## Отладка WebView (debug-сборки)
|
||||
|
||||
|
|
|
|||
27
README.md
27
README.md
|
|
@ -2,12 +2,30 @@
|
|||
|
||||
Персональный трекер здоровья и физической активности.
|
||||
|
||||
## Текущие версии (2026-09-22, build 474)
|
||||
|
||||
| Что | Версия |
|
||||
|-----|--------|
|
||||
| Приложение (BalDav) | **0.1.474**, build **474** |
|
||||
| Выкачено на роутер (OTA) | build **474** - `http://192.168.1.1:88/baldav/baldav.apk` |
|
||||
| Резерв OTA (VPS) | build **474** - `https://4aiman.top/baldav/baldav.apk` |
|
||||
| Встроенный движок BRouter | 1.6.3 |
|
||||
| Сид продуктов (`products_seed.db`) | 11 |
|
||||
| Сборка | Tauri v2, Android arm64, debug-подпись |
|
||||
|
||||
Версия/номер сборки бампаются автоматически при `./build.sh android` (файлы `BUILD_NUMBER`,
|
||||
`tauri.conf.json`, `Cargo.toml/lock`). Телефон сам проверяет обновление: сначала роутер
|
||||
(LAN `192.168.1.1:88/baldav`), затем VPS - берёт `BUILD_NUMBER`, если больше локального,
|
||||
предлагает скачать `baldav.apk`. Выкатка на роутер - существующим APK (без пересборки, чтобы
|
||||
номер не уезжал) либо `./build.sh android -d -u` (соберёт новый номер и зальёт на роутер+VPS).
|
||||
|
||||
## Возможности
|
||||
|
||||
- **Дневник питания** — ~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, давление в реальном времени при записи трека.
|
||||
- **Расчёт ккал** — велосипед: НЕТТО-модель (суточный баланс уже учитывает 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 с коэффициентом активности.
|
||||
- **BLE пульсометр** — подключение к FitPro браслетам и стандартным HR датчикам (0x180D). ЧСС, SpO2, давление в реальном времени при записи трека. Подключение **только вручную** (Настройки -> «Подключить»), в т.ч. во время записи - пульс пишется в трек с момента подключения; автопоиска при старте записи нет (экономия батареи). BT выключен -> кнопка просит включить (системный диалог). Браслет не найден в скане -> сервис не висит в коннекте.
|
||||
- **Зарядка (пульс)** — режим записи БЕЗ GPS для домашней тренировки: геолокация не включается и не пишется, только пульс с датчика + таймер. Экран без карты (крупный пульс, время, живые ккал, средний/макс). Нагрузка (ккал) считается по пульсу формулой Keytel (нетто, как и остальная активность) и идёт в дневной баланс. Деталь - без карты, график пульса по HR-зонам. Хранится в `gps_sessions` (`activity_type="workout"`, без точек, ряд пульса в `hr_samples`).
|
||||
- **Расчёт ккал** — велосипед: НЕТТО-модель (суточный баланс уже учитывает 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 и весу.
|
||||
- **Нутриенты** — 37 микронутриентов (витамины, минералы, жирные кислоты, женьшень, ресвератрол, CoQ10). Нормы РСП/АУП (РФ/ЕС/США). Фармакокинетика — запасы в организме по полураспаду.
|
||||
|
|
@ -24,12 +42,17 @@
|
|||
- **Объезд с возвратом на маршрут (на ходу)** — на карте записи кнопка **«↪»**: если активный маршрут ведёт через непроезжее место, строит от текущей позиции объезд обратно НА маршрут за местом затыка (отступ ~1.5 км вперёд, растёт при неудаче до конца маршрута), избегая nogo и разворота на 180° (учитывает курс). Показывается ярким навигационным оверлеем (не меняет сохранённый маршрут и не прерывает запись); долгий тап по кнопке убирает оверлей.
|
||||
- **Детект ходьбы в вело-треке** - устойчиво низкая скорость (по умолчанию < 8 км/ч дольше 60с; порог уточняется по передаче для односкоростного) в вело/другой сессии = ведёшь/идёшь пешком. Такие участки красятся отдельно (цвет ходьбы) на детали трека, в статах показывается «пешком: N мин / X км», и они НЕ попадают в «проезженные дороги» (не строятся по ним). Флаг хранится по точкам (`gps_points.walk`), пересчитывается на «Пересчитать»/«Пересчитать всё».
|
||||
- **Роутинг сначала по проезженному** - публичные данные BRouter часто не совпадают с землёй (роутит по несуществующим дорогам). Поэтому планировщик для каждой дорожной ноги сначала пробует проложить путь **по дорогам, где ты реально ездил** (граф из своих треков: узлы на сетке ~20м, исключая пешие куски), и только если проезженного рядом нет или путь выходит сильно кривой - обращается к BRouter. Ридден-нога рисуется тонкой линией (без высот), BRouter-нога - оковкой с раскраской. Граф строится из `gps_points` командой `get_ride_graph`, кэшируется.
|
||||
- **Объединение поездок** - долгий тап по треку в истории включает выделение, тап отмечает ещё; когда выбрано 2+, кнопка **«Объединить»** склеивает их в одну тренировку (точки всех сессий переносятся в самую раннюю по времени, порядок по таймстампу, стата пересчитывается). Полезно, когда одна тренировка записалась несколькими кусками (например, отдельный заезд оказался в своей сессии - после склейки он попадёт в «Разбор повторов»). Разрыв между треками больше 5 минут - предупреждает, но не мешает. **Разные велосипеды склеить нельзя** - показывает предупреждение (передача/вес отличаются, мощность и каденс посчитались бы неверно); если это один велосипед, задай его трекам одинаково и склеивай. Обратимо: у каждой точки хранится исходная сессия (`gps_points.orig_session_id`).
|
||||
- **Разрезание трека** - в детали трека кнопка **«Разрезать трек»** включает режим: тап по точке на карте -> в её окошке кнопка **«✂ Разрезать здесь»** -> подтверждение -> второй кусок становится отдельной тренировкой (с тем же велосипедом; порядок точек по времени). Зачем: если часть трека проехана на другом велосипеде или другой передаче - разрежь в точке смены, назначь каждому куску свой велосипед (в детали: «Велосипед» + «Пересчитать»), и мощность/каденс посчитаются по правильной передаче на каждом куске.
|
||||
- **Резервная копия всех данных** - в Настройках **Экспорт** сливает всю базу (`baldav.db`) в файл через SAF (`export_db` -> `VACUUM INTO` чистой сжатой копии, ~на треть меньше), **Импорт** выбирает файл, проверяет его (`PRAGMA quick_check` + наличие таблиц BalDav) и восстанавливает с ЗАМЕНОЙ: файл кладётся в `pending_restore.db`, приложение перезапускается, и Kotlin в `onCreate` (до открытия БД) свопит его на `baldav.db`. Секретов в базе нет. Зачем: переносить наработки между сборками (**debug <-> release** - разные подписи, переустановка стирает данные) без сторонних бэкаперов. Внутриприложенческой авто-установки APK намеренно нет - только экспорт/импорт данных.
|
||||
- **Разбор повторов (интервалы)** - катаешь один подъём несколько раз за поездку (один трек, без стоп/старт на каждом заезде) - деталь трека сама находит повторяемый участок по самоподобию (сетка ~10м, повторно посещённые ячейки, самый длинный эталонный проход) и в сворачиваемой секции «Разбор повторов» показывает таблицу заездов (номер, время, скорость, каденс, мощность) и главное число - **последний / первый**, с вердиктом: >=100% зелёный (можно завтра), 90-99% жёлтый (тяжёлая норма), <90% красный (два дня спокойно). Каждая точка проецируется на опорную линию (снос вбок в пределах ~30м, например при возне с седлом, не режет покрытие); время заезда - только секунды в движении (остановки вырезаются, скорость нормируется на длину участка). Найденный участок подсвечивается на карте. Только вело. `GPS.detectRepeats`; повторов нет (обычная поездка A-B) - так и пишет.
|
||||
- **Похвастаться (шэр-картинка)** - в детали трека кнопка **«Похвастаться»**: генерирует картинку в формате сторис (9:16) с формой трека и статами (Дистанция/Время/Средняя скорость крупно + набор/ккал/дата), **прозрачную** (PNG с альфой для наложения на своё фото) или **на фото-подложке** из настроек. В модалке предпросмотра галочками выбирается, что класть на картинку (форма трека, дистанция, время, средняя/макс. скорость, набор высоты, калории, дата; выбор запоминается), переключатель прозрачный/фото и кнопки **Поделиться** (системный шэр) и **Сохранить** (SAF). Подложка выбирается в Настройках.
|
||||
- **Сегменты - сворачиваемо** - в детали трека список сегментов высот (climb/descent, до десятков штук) свёрнут в аккордеон (`<details>`, свёрнут по умолчанию, счётчик в заголовке); тап разворачивает/сворачивает. Кнопки внизу (Похвастаться, Экспорт, Пересчитать, Удалить) остаются доступны без долгого скролла.
|
||||
- **Де-джиттер трека** — сглаживание дрожащего GPS-трека методом Савицкого-Голея (настраиваемое окно/число итераций, живое превью на карте детали трека). Хранится обратимо: оригинал и сглаженный вариант лежат раздельно, калории пересчитываются по сглаженным точкам, откат к оригиналу в один тап. Точность каждого фикса (`accuracy_m`) пишется в БД: при пересчёте трека точки хуже порога «Макс. погрешность GPS» исключаются из расчёта и линии (обратимо — точки в БД остаются; поднять порог = вернуть). Работает во всех пересчётах (кнопка «Пересчитать», «Пересчитать всё», после де-джиттера); импорт/старьё без точности и dead-reckoning заполнения не трогает.
|
||||
- **POI на карте** — точки интереса (кафе, магазины, вода, туалеты, банки/ATM, остановки, укрытия, смотровые и т.д.) круглыми контрастными значками (эмодзи в кружке, читаемо на светлой и тёмной карте). Кнопка `📍` с панелью-фильтром категорий; рендер по видимой области от зума 13. Данные оффлайн (`data/pois.json`, ~100 км вокруг опорной точки, 5662 объекта из OSM/Overpass). На картах записи, деталей трека и дорог.
|
||||
- **Тёмная карта** — тумблер `☾` на экранах записи, деталей трека и дорог: инвертирует ТОЛЬКО слой тайлов (`invert + hue-rotate 180° + brightness`, трек/маршрут/дороги остаются цветными), выбор запоминается. Работает одинаково на офлайн-тайлах и онлайне, без ключей/квот/перепаковки.
|
||||
- **Метки ветра** — кнопка «Ветер» на экране записи: тап открывает интервал встречного ветра, тап закрывает. Метки хранятся с треком (`wind_marks`) как эталон для проверки оценки ветра по треку; на расчёт калорий/мощности не влияют. Голосовые метки — на будущее.
|
||||
- **Слежение карты (тумблер `⊕`)** — на экране записи карта по умолчанию центрируется на текущей позиции при каждой новой точке. Кнопка `⊕` (подсвечена = слежу) выключает автоцентрирование: карту можно свободно подвигать и осмотреться, когда «где я» непонятно (отклонился от трека или трек ведёт туда, где дороги нет), а новые точки её не возвращают. Повторный тап центрирует на текущей позиции и включает слежение снова. По умолчанию включено при каждом заходе (не запоминается).
|
||||
- **Экспорт/импорт** — свои продукты, рецепты и добавки одним файлом (JSON).
|
||||
- **Вес, сон, добавки** — логирование с историей.
|
||||
|
||||
|
|
|
|||
|
|
@ -351,3 +351,299 @@ Aeropod). Трубка, направленная вперёд, плюс датч
|
|||
Готовый код обоих методов варианта 1 -- в `tools/track/track.py`
|
||||
(функция `wind_by_passes` и секция «ветер» в `report`), выше описан
|
||||
алгоритмически.
|
||||
|
||||
---
|
||||
|
||||
# БЭКЛОГ: автоматический разбор повторов внутри одной поездки
|
||||
|
||||
Заказано пользователем 17 сентября 2026.
|
||||
|
||||
## Задача
|
||||
|
||||
Пользователь катает интервалы: приезжает к подъёму, проезжает его 5 раз,
|
||||
скатываясь вниз между заездами, потом уезжает домой. Сейчас, чтобы разобрать
|
||||
такую тренировку, приходится либо останавливать и заново стартовать трек
|
||||
на каждом повторе (пользователь называет это бредом), либо разбирать
|
||||
вручную задним числом.
|
||||
|
||||
Нужно: **один трек на всю тренировку, повторы находятся сами.** Пользователь
|
||||
готов на отдельный режим «велотренировка», если так проще.
|
||||
|
||||
## Что должно получиться на выходе
|
||||
|
||||
Таблица повторов: номер, время, средняя скорость, каденс, мощность.
|
||||
И главное число, по которому принимается решение о завтрашнем дне:
|
||||
|
||||
отношение последнего повтора к первому
|
||||
|
||||
Порог, выведенный из данных (11 и 17 сентября, по 5 повторов):
|
||||
- последний быстрее или равен первому -- не на пределе, можно повторить завтра
|
||||
- последний медленнее на 5-10% -- нормальная тяжёлая тренировка
|
||||
- последний медленнее больше чем на 10% -- два дня спокойно
|
||||
|
||||
## Алгоритм, проверен вручную на этих данных
|
||||
|
||||
Повторы ищутся по самоподобию трека, без опорных точек от пользователя.
|
||||
|
||||
1. **Разложить точки поездки по ячейкам** ~10 м (сетка как в `heightmap.py`).
|
||||
2. **Найти ячейки, посещённые больше одного раза**, и отбросить те, где
|
||||
направление движения (`bearing_deg`) при посещениях различается больше
|
||||
чем на 60 градусов: проезд вниз по той же дороге повтором не считается.
|
||||
**Без проверки направления половина повторов теряется или считается
|
||||
задом наперёд** -- проверено, это главная ловушка.
|
||||
3. **Собрать самую длинную цепочку** таких ячеек, идущую подряд по ходу
|
||||
движения. Это и есть повторяемый участок. Требовать длину не меньше
|
||||
300 м и не меньше 3 проходов, иначе это перекрёсток у дома, а не подъём.
|
||||
4. **Разметить проходы:** для каждого прохода записать вход и выход
|
||||
по цепочке, время, длину.
|
||||
5. **Нормировать время на общую длину:** у пользователя GPS даёт от 1138
|
||||
до 1199 м на одном и том же подъёме, то есть 5% разброса. Без нормировки
|
||||
сравнение повторов между собой врёт.
|
||||
|
||||
## Чего делать НЕ надо
|
||||
|
||||
**Не просить пользователя ставить метки.** Он специально сказал, что
|
||||
останавливать и перезапускать трек не будет.
|
||||
|
||||
**Не опираться на высоту отдельного проезда.** Профиль повторяемого участка
|
||||
брать из карты высот по многим проездам (см. соседний раздел): у одиночного
|
||||
проезда GPS даёт ступеньки вида 0, 0, 0, 0, 8.5 м на ровном месте.
|
||||
|
||||
**Не считать мощность усреднением по точкам.** Усреднять по времени.
|
||||
При усреднении по точкам отрезки чуть разной длины дают разный вес
|
||||
медленным кускам, и на одинаковой скорости выходят разные ватты
|
||||
(проверено: 17.9 км/ч -> 173 Вт и 18.1 км/ч -> 209 Вт на одном подъёме).
|
||||
|
||||
## Готовый код
|
||||
|
||||
Ручная версия детектора по двум опорным точкам -- в истории этой сессии,
|
||||
в `tools/track/`. Она находит проходы правильно, но опорные точки задаются
|
||||
руками. Автоматизировать надо шаги 1-3.
|
||||
|
||||
---
|
||||
|
||||
# БЭКЛОГ: массовое уточнение высот по всем трекам
|
||||
|
||||
Заказано пользователем 17 сентября 2026. Приоритет свой, не срочный.
|
||||
|
||||
## Что нужно
|
||||
|
||||
Пункт меню, который уточняет высоты **пакетно**: идёт от последнего трека
|
||||
назад по времени и обрабатывает те, где `altitude_srtm_m` ещё не заполнена.
|
||||
Пользователь запускает его вручную, когда сидит на проводном интернете.
|
||||
|
||||
Требования к исполнению, из общих правил проекта:
|
||||
- показывать прогресс числами: сколько треков обработано, сколько осталось
|
||||
- прерываемость: в поле связь рвут РЭБ и РКН, процесс должен переживать
|
||||
обрыв и продолжаться с того места, где встал, а не начинать заново
|
||||
- уже уточнённые треки не трогать повторно
|
||||
|
||||
## Зачем, с измерениями
|
||||
|
||||
Аудит всех 83 велопоездок показал, что **высота за май и июнь непригодна**:
|
||||
|
||||
май-июнь: набор в БД 0-23 м, расчётный по сглаженной высоте 50-207 м
|
||||
июль-сентябрь: набор в БД и расчётный сходятся (168/138, 251/275, 227/235)
|
||||
|
||||
До 17 мая запись шла раз в 3-4 секунды при шуме высотомера 0.8 м между
|
||||
соседними точками. Пример 10 мая (session_id 3): сырая высота скачет
|
||||
`36 20 23 20 20 36 36 35 35 35 34 34 33 20 23 20 19 32 32 32 31`, то есть
|
||||
приёмник бегает между двумя уровнями с размахом 16 м. Медианный фильтр
|
||||
и скользящее среднее это приглаживают, но остаток даёт уклоны до 12.8%
|
||||
на треке, где реального набора 14 м на 13 км.
|
||||
|
||||
Практическое следствие: **любой анализ по уклонам за май и июнь даёт
|
||||
фальшивые результаты**, причём правдоподобные на вид. Построенная по этим
|
||||
данным кривая «лучший удержанный момент на шатунах» показала май лучшим
|
||||
месяцем сезона, хотя по мощности за 20 минут рост с мая по сентябрь
|
||||
составил 1.12 -> 1.75 Вт/кг.
|
||||
|
||||
Отдельно найдены отказы высотомера внутри годных треков: 2 сентября
|
||||
(session_id 1788361994788) после точки 4194 значение падает на 10 м
|
||||
и **замерзает** -- 26.8, 26.9, 26.9, 26.9 шестьдесят точек подряд,
|
||||
а после перерыва записи прыгает на 64.8 м с точностью 22 м. Уклоны
|
||||
на этом участке выдуманы целиком.
|
||||
|
||||
## Признаки негодной высоты, годятся как автотест
|
||||
|
||||
Все три считаются по уже записанным данным, без сети:
|
||||
|
||||
1. `elevation_gain_m` в сессии и набор, пересчитанный по точкам, расходятся
|
||||
больше чем вдвое в любую сторону
|
||||
2. медиана модуля разности высоты между соседними точками выше 1.5 м
|
||||
3. доля точек с шагом записи больше 8 секунд выше 15%
|
||||
|
||||
По этим признакам из 83 поездок отсеиваются 31. После уточнения высот
|
||||
через SRTM проверку надо прогнать повторно: она же покажет, помогло ли.
|
||||
|
||||
## Объём работы, посчитан по базе
|
||||
|
||||
всего точек 418 465
|
||||
уже уточнено 47 420 (11.3%, 12 сессий из 170)
|
||||
осталось 371 045
|
||||
запросов по 100 3 711
|
||||
|
||||
Размер пачки 100 задан в `frontend/js/main.js` около строки 3085 и совпадает
|
||||
с потолком OpenTopoData.
|
||||
|
||||
## Что надо поправить ДО массового прогона
|
||||
|
||||
**Откат по цепочке источников менять не надо.** Он сделан намеренно:
|
||||
лучше получить хоть какую-то высоту, чем никакой. Проблема не в откате,
|
||||
а в том, что после него не остаётся следа, кто именно ответил.
|
||||
|
||||
**1. Нет колонки источника.** В `gps_points` есть `altitude_srtm_m`, но нет
|
||||
поля, откуда это значение пришло. При работающем откате база неизбежно
|
||||
становится смесью источников, и без этой колонки отличить их потом нельзя
|
||||
никак. Нужна колонка (ALTER TABLE, без пересоздания), заполняемая при записи.
|
||||
Это единственное обязательное изменение.
|
||||
|
||||
**2. Откат должен быть виден пользователю.** Сейчас он сообщается только
|
||||
строкой в консоли (`[gpxz] http 403`). Достаточно показать в конце итог
|
||||
вида «уточнено 2314 точек: GPXZ 0, open-elevation 0, open-meteo 2314».
|
||||
|
||||
**3. Обработка 429 и 402 отдельно от сетевой ошибки.** По лимиту
|
||||
останавливаться до завтра, по обрыву повторять. Иначе при массовом прогоне
|
||||
упёршийся лимит молча отправит весь остаток базы к запасному источнику.
|
||||
|
||||
## Чего ждать и чего не ждать
|
||||
|
||||
Copernicus GLO-30 это сетка 30 м. На подъём длиной 200-400 м приходится
|
||||
7-13 отсчётов, этого хватает. На короткий рамп 60-100 м приходится 2-3
|
||||
отсчёта, форму по ним не восстановить. То есть массовое уточнение закроет
|
||||
затяжные подъёмы и общий набор, но НЕ закроет короткие рампы, на которых
|
||||
пользователь срывается. Для них нужен либо барометр, либо разовый замер
|
||||
профиля конкретного подъёма угломером.
|
||||
|
||||
## Лимиты GPXZ против объёма
|
||||
|
||||
Тарифы с их сайта: Evaluation бесплатно и 100 запросов в сутки (помечен
|
||||
«testing only»), Small 99 USD/мес и 2500 в сутки, Large 249 USD/мес и 7500.
|
||||
|
||||
3711 запросов при 100/сутки = 37 дней
|
||||
3711 запросов при 2500/сутки = 1.5 дня
|
||||
|
||||
Отдельно: одна поездка это медиана 19 запросов, но максимум 133. То есть
|
||||
**длинная поездка не влезает в бесплатный суточный лимит даже одна.**
|
||||
Если массовый прогон делать на бесплатном ключе, он обязан быть
|
||||
возобновляемым и считать свой суточный расход сам.
|
||||
|
||||
## Вариант без лимитов: свой Open Topo Data
|
||||
|
||||
GPXZ на своём сайте прямо пишет, что открытый форк их API-сервера
|
||||
называется Open Topo Data. Размер пачки 100, уже зашитый в коде,
|
||||
взят как раз из его потолка, то есть **протокол совместим и менять
|
||||
в клиенте нужен только базовый URL** (сейчас `https://api.gpxz.io/v1/...`
|
||||
захардкожен в `frontend/js/main.js` около строки 3091).
|
||||
|
||||
Считано по базе: все треки пользователя укрываются **двумя градусными
|
||||
тайлами 1x1**. Для GLO-30 это порядка сотни мегабайт на диске, разово
|
||||
скачивается по проводу.
|
||||
|
||||
Что это даёт против платного тарифа:
|
||||
- лимита нет вообще, весь бэклог прогоняется за один вечер
|
||||
- в поле сеть не нужна: у пользователя её глушат, и это постоянная
|
||||
проблема всех сетевых зависимостей приложения
|
||||
- тот же софт и тот же набор данных, значит результаты сопоставимы
|
||||
с уже уточнёнными 12 сессиями, если там был GPXZ
|
||||
|
||||
Минус: нужен хост, на котором это крутится, и разовая возня с датасетом.
|
||||
|
||||
## Но своего хоста может и не понадобиться
|
||||
|
||||
Автор Open Topo Data держит бесплатный публичный инстанс
|
||||
`api.opentopodata.org`. Его ограничения:
|
||||
|
||||
100 точек на запрос -- ровно тот размер пачки, что уже в коде
|
||||
1 запрос в секунду -- нужен троттлинг, сейчас пауз между пачками нет
|
||||
1000 запросов в сутки -- в десять раз больше бесплатного GPXZ
|
||||
|
||||
весь бэклог 3711 запросов -> 3.7 суток, 62 минуты чистого времени,
|
||||
то есть ~15 минут работы в день
|
||||
|
||||
Сравнение по срокам на тот же объём:
|
||||
|
||||
GPXZ Evaluation (бесплатно) 37.1 дней
|
||||
Open Topo Data публичный 3.7 дней
|
||||
GPXZ Small (99 USD/мес) 1.5 дня
|
||||
|
||||
**Ключ не нужен вообще**, значит отпадает и вопрос, укладывается ли
|
||||
массовый прогон личного архива в пометку «testing only» бесплатного
|
||||
тарифа GPXZ.
|
||||
|
||||
### Выбор датасета, и почему это не мелочь
|
||||
|
||||
На публичном инстансе **нет Copernicus GLO-30**, на котором работает GPXZ.
|
||||
Из глобальных 30-метровых доступны `mapzen`, `aster30m`, `srtm30m`.
|
||||
`eudem25m` (25 м, лучшая точность) покрывает только страны EEA, регион
|
||||
пользователя в его extent не входит.
|
||||
|
||||
Отсюда два следствия:
|
||||
|
||||
1. Датасет будет **не тот**, которым уточнены нынешние 12 сессий.
|
||||
Смешивать нельзя, и колонка источника из пункта 2 выше становится
|
||||
не пожеланием, а обязательной: без неё база превратится в винегрет
|
||||
из трёх рельефов.
|
||||
2. Разумный выбор по умолчанию `srtm30m` (USGS): у `aster30m` известны
|
||||
шумы и артефакты, `mapzen` это композит из нескольких источников,
|
||||
то есть внутри одного датасета точность неоднородна.
|
||||
|
||||
Хорошая проверка после прогона: 12 уже уточнённых сессий уточнить заново
|
||||
новым датасетом в отдельную колонку и сравнить набор высоты. Разойдутся
|
||||
сильно - значит выбор датасета важнее, чем кажется, и это надо знать
|
||||
до того, как на этих высотах строить разбор поездок.
|
||||
|
||||
## ПРОВЕРЕНО: в базе лежит не то, что все думали
|
||||
|
||||
Проверка сделана, и она важнее всего остального в этом разделе.
|
||||
|
||||
**Ключ GPXZ не работает и, судя по всему, не работал никогда.** Запрос
|
||||
к `api.gpxz.io` возвращает HTTP 403 с телом `error code: 1010`
|
||||
и заголовком `Server: cloudflare`. Ответ **одинаковый с ключом и без
|
||||
ключа**, то есть запрос режет Cloudflare по подписи клиента, до всякой
|
||||
проверки ключа. Ключ при этом валидный на вид: 28 символов, префикс `ak_`.
|
||||
|
||||
**Те 12 «уточнённых» сессий уточнены open-meteo.** Это третий, последний
|
||||
запасной источник в цепочке `GPXZ -> open-elevation -> open-meteo`.
|
||||
Доказательство: опорные целые значения в `altitude_srtm_m` совпадают
|
||||
с ответом open-meteo точка в точку и расходятся с `srtm30m` на 2-3 м.
|
||||
|
||||
**Дробные значения в базе создаёт приложение, а не источник.** Записанная
|
||||
последовательность `39.000, 38.857, 38.714, 38.571, 38.429, 38.286,
|
||||
38.143, 38.000` это `39 - k/7`, линейная интерполяция между двумя целыми
|
||||
отсчётами. Все внешние DEM (srtm30m, aster30m, mapzen, srtm90m,
|
||||
open-elevation, open-meteo) отдают **целые метры**. То есть дроби
|
||||
в колонке выглядят как высокая точность, а на деле это прямая, натянутая
|
||||
между узлами сетки в 30-90 м.
|
||||
|
||||
Практическое следствие для любого будущего разбора: **нельзя считать
|
||||
уклон по `altitude_srtm_m` на коротких кусках.** Интерполяция даёт
|
||||
гладкий правдоподобный профиль там, где данных нет вообще.
|
||||
|
||||
### Что из этого следует для задачи
|
||||
|
||||
1. Тихий откат (пункт 1 выше) не гипотетическая опасность, а уже
|
||||
случившийся факт: база заполнена последним источником в цепочке,
|
||||
и об этом никто не знал.
|
||||
2. Колонка источника (пункт 2) обязательна.
|
||||
3. Отказ надо показывать пользователю, а не писать в консоль.
|
||||
|
||||
### Покупать ли GPXZ: нет
|
||||
|
||||
С карты покрытия на их сайте: лидар 0.5-2 м у GPXZ есть только в США,
|
||||
западной и северной Европе и Новой Зеландии. Регион пользователя
|
||||
в жёлтой зоне, где по их же легенде 30 м, а по их тексту база
|
||||
из Copernicus 30m. То есть платный GPXZ дал бы Copernicus 30m вместо
|
||||
бесплатного SRTM 30m: другая вертикальная точность, **та же
|
||||
горизонтальная сетка 30 м**.
|
||||
|
||||
А упирается всё именно в горизонтальную: подъём длиной 60-100 м, на
|
||||
котором пользователь срывается, это 2-3 отсчёта на любой тридцатиметровой
|
||||
сетке. Ни один DEM его не разрешит, ни платный, ни бесплатный.
|
||||
|
||||
Измерено на одном подъёме (1153 м, 229 точек, первый заезд 16 сентября):
|
||||
источники расходятся между собой до 2-3% уклона на сотню метров, что
|
||||
того же порядка, что шум GPS. `mapzen` при этом отдаёт числа, побитово
|
||||
совпадающие с `srtm30m`.
|
||||
|
||||
Вывод: брать бесплатный `srtm30m` с публичного Open Topo Data.
|
||||
|
|
|
|||
76
docs/plans/2026-09-26-map-follow-toggle.md
Normal file
76
docs/plans/2026-09-26-map-follow-toggle.md
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
# План: кнопка тоггла "следование карты за позицией" на экране записи
|
||||
|
||||
## Context (зачем)
|
||||
Во время записи карта на экране трекинга постоянно снэпится (`panTo`) на текущую GPS-позицию
|
||||
при каждой новой точке. Кейс пользователя: отклонился от трека или трек ведёт туда, где дороги
|
||||
нет, надо осмотреться по карте вокруг - но карта тут же прыгает обратно на позицию, подвигать
|
||||
её невозможно. Нужна кнопка-тумблер: нажал - карта перестаёт следовать за позицией (можно
|
||||
свободно двигать/смотреть), нажал снова - карта центрируется на текущей позиции и следование
|
||||
возобновляется.
|
||||
|
||||
Решения по UX (согласованы с пользователем):
|
||||
- Кнопка - плитка 32x32 в общем правом ряду кнопок карты, с символом (не словом), активное
|
||||
состояние подсвечивается (как freeze/roads). Активна = следование включено.
|
||||
- Только кнопка-тумблер. Драг карты пальцем следование НЕ выключает (строго как в запросе).
|
||||
- Повторный тап (включение следования) сразу центрирует карту на текущей позиции.
|
||||
- Следование по умолчанию включено при каждом заходе на экран (в localStorage не храним).
|
||||
|
||||
## Где сейчас следование (frontend/js/main.js, функция renderRecordingInline)
|
||||
Автоследование = центрирование вида карты на текущей точке. Происходит в:
|
||||
- `GPS.recording.onPoint`, ~4840: `map.panTo(ll)` - основное следование при записи.
|
||||
- `GPS.recording.onPoint` состояние waiting_gps, ~4857: `map.setView([p.lat,p.lon],15)`.
|
||||
- `GPS.recording.onStateChange` "ready", ~4814: `map.setView([p.lat,p.lon],16)` - разовый locate.
|
||||
- `GPS.recording.onBatchUpdate`, ~5005: `map.panTo(last)` - следование при пакетном апдейте.
|
||||
|
||||
Маркер позиции (`updateMe`/`refreshMe`) вид карты НЕ трогает - при выключенном следовании
|
||||
точка продолжит обновляться на своём месте, просто карта не будет прыгать. Компас/поворот
|
||||
(`compassFrozen`, `applyBearing`) ортогональны следованию - `panTo` на bearing не влияет.
|
||||
|
||||
## Фикс (frontend/js/main.js, только renderRecordingInline)
|
||||
1. Флаг: рядом с `compassFrozen`/`rotOffset` (~4470-4477) добавить `let followMode = true;`.
|
||||
2. Кнопка: по образцу `freezeBtn`/`roadsBtn` (~4624-4676) создать `followBtn`
|
||||
(`class="rec-map-btn rec-follow-btn"`, символ-цель, `title="Следить за позицией"`),
|
||||
добавить в `btnWrap`, сразу выставить `followBtn.classList.add("active")` (followMode=true).
|
||||
onclick:
|
||||
- `followMode = !followMode;`
|
||||
- `followBtn.classList.toggle("active", followMode);`
|
||||
- при включении - центрировать на текущей точке: взять последнюю точку
|
||||
`GPS.recording.points` (как в onBatchUpdate: сортировка по ts, последняя) и `map.panTo`.
|
||||
3. Загардить все 4 места следования условием `if (followMode)`:
|
||||
- ~4840 `map.panTo(ll)` -> `if (followMode) map.panTo(ll);`
|
||||
- ~4857 waiting_gps `map.setView(...)` -> обернуть в `if (followMode)` (return оставить).
|
||||
- ~4814 "ready" `map.setView(...)` -> обернуть в `if (followMode)`.
|
||||
- ~5005 `map.panTo(last)` -> `if (followMode) map.panTo(last);`
|
||||
|
||||
## CSS (frontend/css/styles.css)
|
||||
После `.rec-dark-toggle { right:8px; bottom:238px; }` (~1248) добавить позицию для новой кнопки
|
||||
в следующем свободном слоте стека: `.rec-follow-btn { right: 8px; bottom: 276px; }`.
|
||||
Базовый вид и `.active` уже есть у `.rec-map-btn` / `.rec-map-btn.active` - переиспользуем.
|
||||
|
||||
## Символ кнопки
|
||||
Плитка-цель, читается как "центрировать на мне": glyph круг-с-точкой/крест-в-круге
|
||||
(например "⊕" плюс-в-круге или "◎" bullseye) - выбрать широко отрисовываемый.
|
||||
Смысл несёт `title` (текст). Активная подсветка = следование включено.
|
||||
|
||||
## Регрессии (проверить рядом)
|
||||
- Freeze-кнопка компаса (`compassFrozen`) и поворот карты - не задеты (следование = только
|
||||
центр вида, поворот = bearing). Убедиться, что обе кнопки работают независимо.
|
||||
- Маркер позиции `updateMe` при выключенном следовании продолжает двигаться (карта стоит).
|
||||
- Второй, отдельный экран `setupLiveUpdates` (мини-карта gpsMap, ~5030, свой panTo 5039/5055)
|
||||
НЕ трогаем - это превью в списке, у него нет ряда кнопок. Пользователь просил "экран
|
||||
трекинга" = renderRecordingInline.
|
||||
|
||||
## Верификация
|
||||
- Десктоп (Xvfb :99, `DISPLAY=:99 ./build.sh`): открыть экран записи, начать запись
|
||||
(или подсадить точки), проверить:
|
||||
1) следование по умолчанию включено, кнопка подсвечена, карта идёт за точкой;
|
||||
2) тап -> подсветка гаснет, карту можно свободно двигать, новые точки её не возвращают;
|
||||
3) тап -> карта центрируется на текущей позиции, подсветка загорается, следование снова идёт.
|
||||
Скрин каждого состояния.
|
||||
- Сборка APK + телефон (по явной просьбе): в реальной записи проехать/постоять, отжать кнопку,
|
||||
подвигать карту, вернуть кнопкой. Коммит ДО теста на телефоне, доработки - отдельным коммитом.
|
||||
- README/PROJECT_MAP + лог сессии обновить вместе с коммитом.
|
||||
|
||||
## Не в этой задаче
|
||||
Авто-выключение следования при драге карты (пользователь выбрал "только кнопка").
|
||||
Хранение состояния следования между сессиями (по умолчанию всегда включено).
|
||||
107
docs/session-logs/2026-09-17_repeat-intervals.md
Normal file
107
docs/session-logs/2026-09-17_repeat-intervals.md
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
# Сессия 2026-09-17 - автоматический разбор повторов внутри поездки (интервалы)
|
||||
|
||||
## Контекст
|
||||
Заказ из `docs/gps-model-findings.md` (раздел «БЭКЛОГ: автоматический разбор повторов»).
|
||||
Пользователь катает интервалы: один подъём 5 раз за поездку, один трек, без стоп/старт.
|
||||
Нужно: повторы находятся сами по самоподобию, таблица заездов и число «последний/первый».
|
||||
Действовали по утверждённому плану `~/.claude/plans/merry-discovering-dream.md`
|
||||
(в docs/plans не копировал - план короткий и одноразовый; здесь ссылка).
|
||||
|
||||
## Сделано
|
||||
- `GPS.detectRepeats(points, opts)` (gps.js) - порт `tools/track/pers.py`, но эталонный
|
||||
проход находится сам:
|
||||
1. сетка ~10м (формула из `wind_by_passes`/`heightmap.py`);
|
||||
2. горячие ячейки = >= minPasses (3) входов (заход = смена ключа ячейки);
|
||||
3. эталон = ОДИН чистый проход: от первой горячей ячейки, добавляя новые, до первого
|
||||
возврата к более ранней (склейка кругов давала абсурд - см. баги);
|
||||
4. проходы = порт `passes()`: позиция в seq растёт от старта (допуск назад 3, гэп 25),
|
||||
покрытие >= 80%; старт-гвард `pos<=15%` не даёт спуску открыть заезд;
|
||||
5. нормировка: сравнение по `L_seq / T_прохода` (снимает 5% разброса длины GPS).
|
||||
Направление без явной проверки bearing: seq хранит первую (подъёмную) позицию ячейки,
|
||||
спуск роняет позицию и закрывает заезд.
|
||||
- `avgPower` в возврат `GPS.computeStats` (накопление `power*dt` в вело-ветке / время
|
||||
педалирования) - для колонки «Вт».
|
||||
- UI: секция-аккордеон «Разбор повторов» в `showSessionDetail` (только вело), таблица
|
||||
(№/время/км-ч норм/каденс/Вт), вердикт последний/первый (зелёный >=100%, жёлтый >=90%,
|
||||
красный <90%), подсветка участка на карте (бирюза `#00e5ff`). CSS в styles.css.
|
||||
|
||||
## Баги по пути (и фиксы)
|
||||
- Эталон как «самый длинный горячий ран» склеивал 5 кругов -> segLength 4406м и заезды по
|
||||
9с (норм. скорость 1762 км/ч). Фикс: эталон = один проход до первого возврата.
|
||||
- Следующая точка в ТОЙ ЖЕ ячейке считалась «возвратом» и сразу обрывала seq (seq=1).
|
||||
Фикс: `c===seq[last]` -> продолжить, не обрывать.
|
||||
|
||||
## Проверка
|
||||
- Node-харнесс на `tools/track/baldav.db` (реальные данные):
|
||||
- 11 сен (3214 тчк): участок 1577м, **5 заездов**, норм. скорости 15.0/17.1/17.8/17.9/17.5,
|
||||
последний/первый 117%.
|
||||
- 17 сен (3079 тчк): участок 1630м, **5 заездов**, 16.9/15.8/17.3/16.4/17.7, 105%.
|
||||
Совпадает с находками («11 и 17 сентября, по 5 повторов»).
|
||||
- Контроль: 5 обычных вело-поездок (8-23 км) + 2 короткие -> «повторов не найдено» (0 ложных).
|
||||
- Десктоп (Xvfb): временно подсажена сессия 17 сен в дев-БД (бэкап + точечное удаление
|
||||
после, БД байт-в-байт восстановлена). Секция рисуется, аккордеон работает, таблица 5 строк
|
||||
совпадает с харнессом, вердикт зелёный 105%, участок подсвечен бирюзой на карте. Скрин.
|
||||
- Далее: сборка APK + проверка на телефоне (реальные интервалы там же).
|
||||
|
||||
## Фикс после проверки на теле (трек 16 сен)
|
||||
- Симптом (пользователь): на 16 сен в повторы попала только часть горки, 729м вместо 1200+,
|
||||
на карте видно прямую, что идёт дальше.
|
||||
- Причина: эталон брался с ПЕРВОЙ горячей ячейки. Поездка 16 сен стартует прямо в горячей
|
||||
ячейке (подъезд частично совпал с коридором), проход с индекса 0 обрывался гэпом на 143й
|
||||
точке -> 729м, хотя горячих ячеек ~183 (~1830м).
|
||||
- Фикс: эталон = САМЫЙ ДЛИННЫЙ чистый проход (перебор всех стартов горячих ранов,
|
||||
`buildTraversal`), не первый. Дёшево (стартов немного).
|
||||
- Проверка: харнесс 16 сен -> участок 1572м (было 729), 3 полных заезда. 11/17 сен без
|
||||
изменений (по 5). Контроль (5 обычных) - 0 ложных. Десктоп (Xvfb, подсажена 16 сен,
|
||||
бэкап+удаление): подсветка на карте покрывает весь верхний коридор, таблица 1.57км/3 заезда,
|
||||
вердикт 104%. По коридору 4 траверса, но первый 74% (начат выше низа) - отсечён правилом
|
||||
80% (как pers.py MIN_FRAC), иначе нормировка длина/время завысила бы его скорость.
|
||||
|
||||
## Фикс 2: остановка внутри заезда (седло) + сшивка (трек 16 сен)
|
||||
- Пользователь: первый заезд 16 сен был полным, но разорвался остановкой (опущенное седло,
|
||||
ноги не крутили) - такие стопы <~минуты сшивать в один заезд, время стопа оставлять в
|
||||
заезде как штраф.
|
||||
- Проверка данных: остановка ЕСТЬ - idx 310-319, **89 секунд** сдвиг <15м. По reported speed
|
||||
её не видно (fused показывал 8 км/ч), нашлась по СМЕЩЕНИЮ. Плюс у вершины трек первого
|
||||
заезда уходит с общей линии в негорячие ячейки (GPS-разброс на 20 км/ч), из-за чего покрытие
|
||||
вышло 74%.
|
||||
- Сделано: (1) в разметке проходов - мост через короткое прерывание рядом с коридором
|
||||
(`bridgeSec` 90с, `bridgeM` 60м) + сохранена терпимость к нулевым пропускам (gap<=25);
|
||||
время простоя остаётся в длительности заезда -> честный штраф скорости. (2) Порог покрытия
|
||||
`minFrac` 0.8 -> **0.7**: полный заезд, у которого GPS сполз с линии у вершины, засчитывается.
|
||||
- Проверка (харнесс): 16 сен -> **4 заезда** (был 3), норм.скор [19.6, 21.3, 21.2, 23.8] -
|
||||
первый самый медленный (штраф за 89с стоп), вердикт 121%. 11/17 сен без изменений (по 5),
|
||||
контроль 5 обычных - 0 ложных при 0.7. Десктоп: 16 сен подсажен, таблица 4 строки.
|
||||
|
||||
## Фикс 3: проекция на опорную линию + время только в движении
|
||||
- Пользователь: снос вбок не случайность (седло падало, подкручивал) - проецировать на
|
||||
опорную линию, чтобы боковой снос не резал покрытие; остановки на краях заездов не считать
|
||||
во время.
|
||||
- Сделано: детект проходов переписан с «точного совпадения ячеек» на ПРОЕКЦИЮ. Опорная линия =
|
||||
точки лучшего прохода с накопленной дистанцией; каждая точка поездки проецируется на неё
|
||||
(ближайшая вершина через cell-индекс, допуск вбок `projTolM` 30м) -> прогресс в метрах.
|
||||
Проход = прогресс растёт от низа до >= minFrac. Снос на 30м больше не роняет покрытие,
|
||||
поэтому `minFrac` вернул к строгому 0.8 (как pers.py).
|
||||
- Время заезда = только СЕКУНДЫ В ДВИЖЕНИИ (`movingSec`, по фактическому смещению >=0.8 м/с),
|
||||
стопы на краях и в середине вырезаются. Нормировка норм.скор = длина/движ.время.
|
||||
- Проверка (харнесс, minFrac 0.8): 16 сен -> 4 заезда, движ.время [281,268,271,252]с (89с стоп
|
||||
вырезан из первого), норм.скор [20.1,21.1,20.9,22.5], посл/перв 112%. 11 сен 5× (16.6..18.8,
|
||||
113%), 17 сен 5× (101%), контроль 5 обычных - 0 ложных, короткие - нет.
|
||||
|
||||
## Фикс 4: детекция при 2 заездах (порог 3 -> 2)
|
||||
- Симптом (пользователь + разбор другого агента): при двух проездах туда-обратно детекции
|
||||
нет по построению.
|
||||
- Проверка по коду + харнесс: `minPasses` (по умолч. 3) работал И как порог «горячей» ячейки
|
||||
(>=3 входов), И как финальный порог числа проходов. Урезанный до 2 заездов трек 16 сен:
|
||||
порог 3 -> НЕ НАЙДЕНО, порог 2 -> найдено (2 заезда, 1555м). Подтверждено.
|
||||
- Фикс: `minPasses` по умолчанию 2. Перекрёстки отсекаются длиной (`minLenM` 300м), не
|
||||
счётчиком; обычная поездка «туда-обратно» (коммьют) не ловится, т.к. проходы считаются
|
||||
монотонно в ОДНУ сторону (спуск/возврат start-guard отсекает).
|
||||
- Проверка: 2-заездный -> 2×; 16 сен 4×, 11/17 по 5× (без изменений); 5 обычных поездок
|
||||
(включая туда-обратно) - 0 ложных. Признаки из разбора агента (возвратный участок, время,
|
||||
высота) не понадобились - длина+направление уже отсекают мусор. Снос вбок и дырки в цепочке
|
||||
уже покрыты (проекция 30м в разметке проходов; gap>5 в эталонном проходе).
|
||||
|
||||
## Не в этой задаче
|
||||
Из того же файла НЕ делалось: поправка модели п.2 (аддитивная мощность вместо max(физика,MET);
|
||||
п.1 про Crr уже был сделан ранее), массовое уточнение высот, ветер (Часть 2 - там вопросы).
|
||||
41
docs/session-logs/2026-09-18_merge-rides.md
Normal file
41
docs/session-logs/2026-09-18_merge-rides.md
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
# Сессия 2026-09-18 - объединение поездок (склейка треков)
|
||||
|
||||
## Контекст
|
||||
Трек 16 сен: реально 5 заездов, но первый оказался в отдельной сессии (запись
|
||||
несколькими кусками). Нужна склейка: долгий тап = выделение, 2+ = объединить,
|
||||
разрыв >5 мин по времени - предупреждать но не мешать, только склейка (разрез/вырез
|
||||
были проблемными, позже). После склейки «Разбор повторов» должен увидеть все 5.
|
||||
|
||||
## Сделано
|
||||
- Миграция: `gps_points.orig_session_id TEXT` - исходная сессия точки (для будущего
|
||||
разделения; склейка обратима без потери данных).
|
||||
- `DB.mergeGpsSessions(idStrs)` (db.js): сессии сортируются по времени, target = самая
|
||||
ранняя; точки остальных переносятся (`session_id=target`, `orig_session_id=COALESCE(...)`),
|
||||
end_time продлевается, лишние сессии удаляются. `seq=NULL` у объединённого трека -
|
||||
иначе совпадающие seq (у каждой сессии были 1..N) перемешивают порядок в getGpsPoints.
|
||||
Ids - строками с CAST в INTEGER (i64 corruption).
|
||||
- UI (`renderGpsScreen`): долгий тап 500мс (touch+mouse) -> режим выделения (`.selected`,
|
||||
плавающая `#gps-sel-bar`: «Выбрано N», «Объединить» (>=2), «Отмена»). Тап в режиме
|
||||
переключает. Клик после long-press подавляется. Диалог `appConfirm` с пометкой об
|
||||
обратимости; предупреждение о разрыве >5 мин. После склейки - `recalculateSession
|
||||
(noDelete)`, тост, ре-рендер.
|
||||
- CSS: `.gps-session-item.selected`, `.gps-sel-bar`, фикс `[hidden]` (правило `display:flex`
|
||||
перебивало атрибут hidden - панель висела всегда).
|
||||
|
||||
## Баги по пути (и фиксы)
|
||||
- Панель выделения показывалась всегда: `.gps-sel-bar{display:flex}` перебивал `hidden`.
|
||||
Фикс: `.gps-sel-bar[hidden]{display:none}`.
|
||||
- После склейки в приложении: участок 43 км, скорости 6745 км/ч. Причина: у трёх сессий
|
||||
seq совпадали (1..N), `getGpsPoints` (ORDER BY seq) перемешивал треки. В node-тесте я
|
||||
сортировал по ts и не поймал. Фикс: `seq=NULL` при склейке -> порядок по ts.
|
||||
|
||||
## Проверка (десктоп Xvfb, подсажены 3 сессии 16 сен, БД восстановлена из бэкапа)
|
||||
- Node: склейка 3 сессий -> detectRepeats 5 заездов, 1572м, [21.4,20.1,21.1,20.9,22.5], 105%
|
||||
(по отдельности: две малые - «нет повторов», большая - 4).
|
||||
- В приложении: долгий тап выделил 11:38 (рамка+фон, «Выбрано 1»), тап добрал 11:50/12:04
|
||||
(«Выбрано 3»), «Объединить» -> диалог -> 3 сессии стали одной (11:38, 19.89 км, суммарно).
|
||||
Открыл: «Разбор повторов» = 5 заездов, совпало с node, каденс 88-93, вердикт 105%. Скрины.
|
||||
- Далее: сборка APK + проверка на телефоне (реальный трек 16 сен там же).
|
||||
|
||||
## Не сделано (по просьбе - позже)
|
||||
Разрезание и вырезание треков (были проблемы). Сейчас только склейка.
|
||||
54
docs/session-logs/2026-09-21_db-backup.md
Normal file
54
docs/session-logs/2026-09-21_db-backup.md
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
# Сессия 2026-09-21 (2) - экспорт/импорт всей БД (резервная копия)
|
||||
|
||||
## Контекст
|
||||
Смена сборки debug<->release стирает данные (разные подписи -> переустановка). Решили:
|
||||
НЕ делать внутриприложенческую авто-установку APK, а сделать полный бэкап/восстановление БД.
|
||||
Записано в память: `project_no_self_install`. Секретов в базе нет.
|
||||
|
||||
## Сделано
|
||||
- Rust (`lib.rs`):
|
||||
- `db_and_root(app)` - резолв пути `baldav.db` + app-root (как в rebuild_roads).
|
||||
- `export_db(filename)` - `VACUUM INTO` чистой копии в `app-root/export_db.tmp` (консистентно
|
||||
при живом соединении, сжимает ~на треть), команда `exportdb:<filename>` в gps_command.txt.
|
||||
- `import_db()` - валидирует `picked_import.db` (`PRAGMA quick_check`=='ok' + есть хоть одна
|
||||
таблица из gps_sessions/products/profile), копирует в `pending_restore.db`, удаляет picked.
|
||||
- `restart_app()` - команда `restartapp`.
|
||||
- Все три в generate_handler.
|
||||
- Kotlin (`MainActivity.kt`):
|
||||
- `pendingDbFile` + `saveDbLauncher` (CreateDocument octet-stream, СТРИМ файла, не в память -
|
||||
файл десятки МБ).
|
||||
- Ветка `exportdb:` -> SAF-сохранение `export_db.tmp`.
|
||||
- Ветка `restartapp` -> relaunch (getLaunchIntentForPackage + FLAG_ACTIVITY_NEW_TASK) +
|
||||
`Runtime.exit(0)`.
|
||||
- `onCreate` (сразу после appDataDir, ДО открытия БД): если есть `pending_restore.db` -
|
||||
скопировать на `baldav.db`, удалить journal/wal/shm и pending. Тот же паттерн, что копия сида.
|
||||
- JS (`main.js`): в Настройках блок «Резервная копия всех данных» с кнопками Экспорт/Импорт.
|
||||
Экспорт -> `export_db`. Импорт -> confirm (заменит всё) -> `pickLocalFile` -> `import_db` ->
|
||||
confirm перезапуска -> `restart_app`. Импорт только на Android.
|
||||
|
||||
## Проверка
|
||||
- JS синтаксис ок. Автономный тест `VACUUM INTO` на десктоп-БД: 53МБ -> 36МБ, quick_check ok,
|
||||
сессии на месте (валидная сжатая копия). Ядро экспорта подтверждено.
|
||||
- SAF-сохранение, своп при onCreate и рестарт - только на устройстве (Kotlin), десктоп не
|
||||
воспроизводит. Проверка на телефоне: экспорт -> файл-бэкап валиден; импорт другого бэкапа ->
|
||||
данные заменились после рестарта. (Сборка bump до 0.1.465.)
|
||||
|
||||
## Замечания
|
||||
- Сжатие gzip не делал: VACUUM INTO уже компактит; flate2 в зависимостях нет. Добавлю, если
|
||||
нужно меньше.
|
||||
- Схема-совместимость: миграции в db.js идемпотентны (ALTER ... IF NOT EXISTS), старый бэкап
|
||||
подтянется вверх, новый в старое приложение - лишние колонки безвредны.
|
||||
|
||||
## Проверка на теле + фиксы
|
||||
- Экспорт сперва падал: `vacuum: unable to open database: /data/user/...`. Причина - я взял
|
||||
корень как `app_data_dir().parent().parent()` (на Android app_data_dir САМ = корень -> ../..
|
||||
уводит в непишемый `/data/user`). ЭТО УЖЕ ЧИНИЛИ функцией `native_root()`. Исправил на
|
||||
`native_root()` (как save-команды/rebuild_roads), убрал data_dir-дубли. Записано в память
|
||||
`reference_android_native_root`. Подтверждено на теле: корень `/data/user/0/dev.baldav.app`
|
||||
писабелен, там baldav.db + точки + командные файлы -> native_root резолвится туда.
|
||||
- Экспорт + импорт (восстановление) пользователь проверил на теле - работают.
|
||||
- Авто-рестарт после импорта не срабатывал: `startActivity()+exit(0)` - гонка (процесс умирает
|
||||
до подъёма активити). Исправлено: relaunch через AlarmManager+PendingIntent ПОСЛЕ смерти
|
||||
процесса (ProcessPhoenix-паттерн) + finishAffinity.
|
||||
|
||||
## На роутере всё ещё 0.1.464 (build 464). Бэкап-билды на роутер не выкатывал.
|
||||
52
docs/session-logs/2026-09-21_split-and-bike-guard.md
Normal file
52
docs/session-logs/2026-09-21_split-and-bike-guard.md
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
# Сессия 2026-09-21 - разрезание трека + блокировка склейки разных великов
|
||||
|
||||
## Контекст
|
||||
Три вопроса пользователя (сначала разобрал по коду, потом делал):
|
||||
1. «Пересчитать» берёт велик СЕССИИ (не активный) - пользователя устраивает, не трогаем.
|
||||
2. Хочет разрезать трек в точке смены передачи, чтобы разным кускам назначать свой велик
|
||||
(два велика = один и тот же, разные передачи) - для точных мощности/каденса.
|
||||
3. Опасение: склейка разных великов слепит всё в один велик. Решение (по просьбе): при
|
||||
склейке треков с РАЗНЫМИ великами - предупреждать и НЕ склеивать (не хранить велик по точкам).
|
||||
|
||||
## Сделано
|
||||
- **Блокировка склейки (D):** в обработчике `#gps-sel-merge` (main.js) - если у выбранных
|
||||
сессий разные `bike_id` (Set по bike_id, null=none), `appAlert` «велосипеды разные, склеить
|
||||
нельзя, задайте одинаковый и склеивайте» и return.
|
||||
- **Разрезание (B):** `DB.splitGpsSession(sessionId, part2Ids, part1EndTime, part2StartTime)`
|
||||
(db.js): вторая половина -> НОВАЯ сессия (id=Date.now(), копия date/activity/bike/source,
|
||||
start=part2 время, end=старый end), точки part2 переносятся по id чанками (200), `seq=NULL`;
|
||||
у старой end_time=part1. UI: кнопка `#sd-split` включает `#sd-map.split-mode`; в попапе точки
|
||||
кнопка `.split-here` (видна только в split-mode) -> `confirmSplitAt(k)` -> подтверждение ->
|
||||
splitGpsSession -> пересчёт обоих кусков (`recalculateSession noDelete`) -> назад в историю.
|
||||
- Точку разреза выбирают ТАПОМ ПО ТОЧКЕ (хит-таргеты уже есть), а не кликом карты - маркеры
|
||||
перехватывают клик, поэтому кнопка живёт в попапе.
|
||||
|
||||
## Проверка (десктоп Xvfb, подсажен склеенный трек 16 сен 1789547934760, 3614 тчк, +2й велик)
|
||||
- Разрез: «Разрезать трек» -> тост + скролл к карте -> тап точки #3547 -> попап с «✂ Разрезать
|
||||
здесь» -> диалог (точка/время/км) -> «Разрезать» -> в истории ДВЕ сессии 16-го (part1 3548
|
||||
тчк 19.54км, part2 66 тчк 343м), обе bike, стата пересчитана. Скрины.
|
||||
- Блокировка: part2 велик -> 2 (через SQL), выделил обе (долгий тап + тап), «Объединить» ->
|
||||
предупреждение «велосипеды разные, склеить нельзя», склейки не произошло. Скрин.
|
||||
- БД восстановлена из бэкапа (0 сессий 16 сен, 1 велик).
|
||||
|
||||
## Замечания
|
||||
- Инъекция `INSERT ... SELECT *` тихо даёт 0 строк, если id сессии больше нет в pulled-БД
|
||||
(tools/track/baldav.db пере-пулена, старая 1789549492046 уже склеена на телефоне в
|
||||
1789547934760). Проверять существование id перед выводами.
|
||||
- `Xvfb :99` снова умирал; `pgrep -f "Xvfb :99"` ловит собственную команду (ложно «жив») -
|
||||
проверять сокет `/tmp/.X11-unix/X99`.
|
||||
- part2 start_time берётся из ts точки (UTC), а поле сессии - локальное; мелкая косметическая
|
||||
рассинхронизация времени (данные так хранятся), на функциональность не влияет.
|
||||
|
||||
## Выкатка на роутер (по просьбе)
|
||||
- Залит СУЩЕСТВУЮЩИЙ `binaries/baldav-debug.apk` (0.1.464, build 464) на роутер keenetic
|
||||
(LAN 192.168.1.1) в `/opt/share/www/baldav/baldav.apk` + `BUILD_NUMBER=464`, БЕЗ пересборки
|
||||
(иначе номер уехал бы на 465). Проверено: файл 190101489 б, BUILD_NUMBER=464 на роутере.
|
||||
Доступно: `http://192.168.1.1:88/baldav/baldav.apk`. Пароль роутера взят из build.sh, не
|
||||
печатался. VPS-зеркало не трогал (просили только роутер).
|
||||
- Текущие версии сведены в README (раздел «Текущие версии»): app 0.1.464/build 464, на роутере
|
||||
464, BRouter 1.6.3, сид продуктов 11.
|
||||
|
||||
## Не сделано (по просьбе - позже/не нужно)
|
||||
- Велик по точкам (вариант C) не делаем: пользователь просто не будет склеивать разные велики.
|
||||
- Вырезание участка трека - позже.
|
||||
25
docs/session-logs/2026-09-21_ui-fixes.md
Normal file
25
docs/session-logs/2026-09-21_ui-fixes.md
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
# Сессия 2026-09-21 (3) - мелкие UI-фиксы
|
||||
|
||||
## #1 balance-trend показывал 8 дней вместо 7 (исправлено)
|
||||
- Симптом: вверху страницы «Среднее за 8 дн.», а график ниже - за 7.
|
||||
- Причина: `getDailyFoodTotals(7)` -> SQL `WHERE date >= date('now','-7 days')` - диапазон
|
||||
включает оба конца (today-7 ... today) = **8 календарных дней**. Balance-trend показывал
|
||||
`balances.length` (сырое число строк) = 8. График строит свой ряд, поэтому у него 7.
|
||||
- Фикс точечный (не трогая общий запрос и график): в balance-trend `food7 = food7.slice(-7)`
|
||||
- берём последние 7 дней. Корневой off-by-one в `getDailyFoodTotals` не трогал, чтобы не
|
||||
сдвинуть график 30/90.
|
||||
|
||||
## #2 сегодняшние повторы не опознались (исправлено)
|
||||
- Трек 1790002321789 (out-and-back A(47.4915,40.0817)<->B(47.4917,40.1025), ~1.5км, 5 раз
|
||||
туда-обратно). `detectRepeats` -> «не найдено».
|
||||
- Диагностика: горячих ячеек полно (197 с >=3 входами, макс 10). Эталон (самый длинный проход)
|
||||
при пороге горячей ячейки **2** раздувается до **2190м** - тянет разово/дважды накатанный
|
||||
подъезд и развороты. Полный заезд 1585м покрывает лишь 72% этого эталона (<80%), поэтому
|
||||
засчитывался лишь 1 проход. При пороге 3+ эталон сжимается до ядра **1585м** и находятся все 5.
|
||||
- Причина глубже минусов порога: жадный порог 2 (нужен для «2 заездов») ломает эталон, когда
|
||||
подъезд накатан 2 раза.
|
||||
- Фикс: `detectRepeats` теперь ПЕРЕБИРАЕТ порог горячей ячейки от 2 до макс.входов и берёт тот,
|
||||
что даёт БОЛЬШЕ проходов (при равенстве - длиннее участок). `minPasses` -> только финальный
|
||||
порог числа проходов (`minReps`), развязан с порогом горячей ячейки. Вынесено в `detectAt(T)`.
|
||||
- Проверка (харнесс): СЕГОДНЯ 5× 1590м [16.8,17.6,17.1,17.1,16.8]; 11сен 5×, 17сен 5×,
|
||||
16сен(склеен) 5×; 5 обычных поездок - 0 ложных. ~0.9с/сессия на десктопе (перебор порогов).
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
# Сессия 2026-09-22 - баланс/графики от выбранного дня + вес
|
||||
|
||||
По утверждённому плану `~/.claude/plans/merry-discovering-dream.md`.
|
||||
|
||||
## Часть 1 - окно от выбранного дня
|
||||
- `getRecentWeights/getDailyFoodTotals/getDailyActivityTotals` (db.js) получили опциональный
|
||||
`endDate` (по умолч. 'now'). Диапазон сменён с `date >= date('now','-N days')` на
|
||||
`date > date(<end>,'-N days') AND date <= date(<end>)` - ровно N дней, кончая на endDate
|
||||
(заодно чинит старый off-by-one N+1).
|
||||
- `renderToday` (main.js): BMR верхнего баланса `getRecentWeights(7, state.date)`; тренд
|
||||
`getDaily*(7, state.date)` (убрал костыль `slice(-7)`); `renderStatsCharts(...,refDate=state.date)`
|
||||
- все три запроса графиков и чипы 30/90 теперь от выбранного дня.
|
||||
- Единый BMR у баланс-графика оставлен (от веса выбранного дня); per-day BMR не делал.
|
||||
|
||||
## Часть 2 - вес
|
||||
### 2a синк настроек
|
||||
- `DB.addWeight`: после вставки `setProfileValue("weight_kg", latest.weight_kg)` - настройки
|
||||
всегда = последнее взвешивание. Колбэк модалки веса (main.js) обновляет `state.latestWeight`
|
||||
и `state.profile.weight_kg`.
|
||||
### 2b вес на момент поездки
|
||||
- Миграция `gps_sessions.rider_weight_kg REAL`. Новый сеттер `DB.setSessionRiderWeight` (отдельно
|
||||
от finishGpsSession, её зовёт и пересчёт).
|
||||
- Запись: при финализации трека (gps.js) сохраняется `GPS.recording.weightKg`.
|
||||
- Резолв веса везде первым берёт `session.rider_weight_kg`: `recalculateSession` (recorded+manual),
|
||||
`recomputeAllStats` (per-session в цикле), деталь трека (2 места), `recomputeStatsFor`, разбор
|
||||
повторов. Старые сессии (NULL) -> latest weigh-in (без регресса).
|
||||
|
||||
## Ответ пользователю (вопрос)
|
||||
Ккал брал вес из последнего взвешивания (не из настроек; настройки - запас). Теперь новые
|
||||
поездки хранят вес на момент записи, пересчёт использует его; настройки авто-синкаются с весом.
|
||||
|
||||
## Проверка
|
||||
- SQL-диапазон на десктоп-БД: `> date(end,'-7') AND <= date(end)` = ровно 7 дней, кончая на
|
||||
выбранном дне (старый `>=` давал 8). Подтверждено.
|
||||
- Дальше: десктоп (Xvfb) - листание дней двигает баланс/тренд/графики; вес-синк; затем APK+тело.
|
||||
27
docs/session-logs/2026-09-22_gapfill-order-fix.md
Normal file
27
docs/session-logs/2026-09-22_gapfill-order-fix.md
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
# Сессия 2026-09-22 (2) - достройка пропусков уходила «в начало»
|
||||
|
||||
По утверждённому плану `~/.claude/plans/merry-discovering-dream.md`.
|
||||
|
||||
## Симптом
|
||||
Достройка GPS-пропусков (🧩) показывала превью нормально, но сохранённый трек тянул прямую
|
||||
линию от старта к месту пропуска («возврат в начало»). На любом (недавнем) треке.
|
||||
|
||||
## Корень
|
||||
`saveGapFill` (db.js) ставит достроенным точкам `seq = lastSeq` (seq последней РЕАЛЬНОЙ точки
|
||||
перед пропуском). У склеенных/разрезанных треков реальные точки имеют `seq = NULL` (склейка/
|
||||
разрез специально обнуляют seq -> порядок по ts). Тогда `lastSeq` остаётся 0, все достроенные
|
||||
точки получают seq=0, а `getGpsPoints` (`ORDER BY COALESCE(seq,999999), ts`) ставит их ПЕРЕД
|
||||
реальными (NULL→999999) -> прямая к началу. Подтверждено: единственный NULL-seq трек в БД -
|
||||
склеенный 16.09; недавние треки пользователя склеены.
|
||||
|
||||
## Фикс
|
||||
`saveGapFill`: после вставки достроенных точек - пере-нумерация seq ВСЕЙ сессии по ts (читаю
|
||||
id `ORDER BY ts,id`, seq=порядковый номер, пакетный `CASE id WHEN..` чанками по 300, паттерн
|
||||
из `saveSmoothedTrack`). Теперь getGpsPoints всегда в порядке ts независимо от исходного seq
|
||||
(плотный/NULL/смешанный). Для плотных треков - no-op, для склеенных - чинит.
|
||||
|
||||
## Проверка
|
||||
- SQL: у склеенного 16.09 реальные точки seq=NULL (seq=0 достроенной встала бы перед ними) -
|
||||
корень подтверждён.
|
||||
- Десктоп (Xvfb): подсадка склеенного трека -> 🧩 -> Сохранить -> переоткрыть: без прямой к
|
||||
началу. Затем сборка + телефон, вытянуть сегодняшний трек.
|
||||
62
docs/session-logs/2026-09-26_map-follow-toggle.md
Normal file
62
docs/session-logs/2026-09-26_map-follow-toggle.md
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
# Сессия 2026-09-26 - тумблер "следование карты за позицией" на экране записи
|
||||
|
||||
Работали по утверждённому плану: `docs/plans/2026-09-26-map-follow-toggle.md`.
|
||||
|
||||
## Контекст
|
||||
Во время записи карта на экране трекинга при каждой новой GPS-точке центрируется на текущей
|
||||
позиции (`map.panTo`). Кейс: отклонился от трека или трек ведёт туда, где дороги нет, надо
|
||||
осмотреться по карте - но она тут же прыгает обратно, подвигать её нельзя. Нужен тумблер:
|
||||
нажал - карта перестаёт следовать (можно двигать), нажал снова - центрируется на позиции и
|
||||
следование возобновляется.
|
||||
|
||||
Согласовано с пользователем (AskUserQuestion): кнопка - символ-плитка в общем ряду кнопок
|
||||
карты (не слово), активная подсветка = слежу; только кнопка-тумблер (драг карты следование НЕ
|
||||
выключает); повторный тап центрирует; состояние не запоминается между сессиями.
|
||||
|
||||
## Действия
|
||||
- `frontend/js/main.js`, `renderRecordingInline`:
|
||||
- добавлен флаг `let followMode = true;` рядом с `compassFrozen`/`rotOffset`;
|
||||
- создана кнопка `followBtn` (`class="rec-map-btn rec-follow-btn active"`, символ `⊕`,
|
||||
`title="Следить за позицией"`) по образцу `freezeBtn`; onclick тогглит `followMode` и класс
|
||||
`active`, при включении центрирует `map.panTo` на последней точке `GPS.recording.points`
|
||||
(сортировка по ts);
|
||||
- 4 места автоследования загардены `if (followMode)`: `onPoint` `panTo` (~4840), `onPoint`
|
||||
waiting_gps `setView` (~4857), `onStateChange` "ready" `setView` (~4814), `onBatchUpdate`
|
||||
`panTo` (~5005).
|
||||
- `frontend/css/styles.css`: `.rec-follow-btn { right: 8px; bottom: 276px; font-size: 18px; }`
|
||||
(следующий слот стека после `.rec-dark-toggle` 238px). Базовый вид и `.active` -
|
||||
переиспользован `.rec-map-btn` / `.rec-map-btn.active`.
|
||||
- README: добавлен буллет "Слежение карты (тумблер ⊕)".
|
||||
|
||||
## Проверка
|
||||
- `node -c frontend/js/main.js` - OK.
|
||||
- Десктоп (Xvfb :99, `DISPLAY=:99 ./build.sh` = tauri dev, без бампа версии): открыт экран
|
||||
записи (Старт), кнопка `⊕` в правом стеке вверху, по умолчанию подсвечена (слежу). Тап ->
|
||||
подсветка гаснет (слежу выкл). Тап -> подсветка возвращается (слежу вкл). JS-ошибок в логе
|
||||
нет. Скрины: s_rec.png / s_rec_off.png / s_rec_on2.png в scratchpad.
|
||||
- НЕ проверено на десктопе: живое поведение "карта не прыгает на новую точку при выкл / прыгает
|
||||
при вкл" - под Xvfb настоящего GPS нет, экран в waiting_gps, поток точек не идёт. Логика -
|
||||
тривиальный guard вокруг уже работающих `panTo`/`setView`. Реальная проверка потока - на
|
||||
телефоне.
|
||||
|
||||
## Деплой build 475
|
||||
`./build.sh -u -i -d android`: собран debug APK (0.1.475, 182М), залит на роутер
|
||||
(http://192.168.1.1:88/baldav/baldav.apk, build 475) и VPS
|
||||
(https://4aiman.top/baldav/baldav.apk, build 475), установлен на 320245779368 через
|
||||
`adb install -r --user 0` - Success (подпись совпала, данные сохранены). Живую проверку тоггла
|
||||
следования в реальной записи делает пользователь на телефоне.
|
||||
|
||||
## Чистка старых бандлов (build.sh)
|
||||
Место забивалось регенерируемыми выхлопами Android-сборки. Добавлена `clean_bundles()` в
|
||||
build.sh: удаляет `gen/android/app/build/intermediates/intermediary_bundle` +
|
||||
`outputs/bundle` (AAB - мы раздаём APK) и `outputs/apk/*.apk`. НЕ трогает `src-tauri/target`
|
||||
(14Г Rust-инкрементал, иначе полная пересборка) и `binaries/` (последние APK). Вызывается
|
||||
авто в конце каждой android-сборки (после копии APK в binaries/) и вручную `./build.sh clean`.
|
||||
Согласовано с пользователем: объём "только бандлы/APK", запуск "авто + ручная".
|
||||
Разовый прогон: gen build 1.6G -> 1.0G, освобождено ~557 МБ.
|
||||
|
||||
## Следующие шаги
|
||||
- Живая проверка тоггла `⊕` на телефоне: проехать/постоять, отжать, подвигать карту (новые
|
||||
точки не возвращают), вернуть кнопкой (центрируется). Доработки - отдельным коммитом.
|
||||
- Компас (freeze `X`) и поворот карты не задеты (следование = только центр вида, поворот =
|
||||
bearing) - убедиться, что обе кнопки работают независимо.
|
||||
44
docs/session-logs/2026-09-29_progress-and-zoom.md
Normal file
44
docs/session-logs/2026-09-29_progress-and-zoom.md
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
# Сессия 2026-09-29 (3) - прогресс на кнопках, память масштаба карты
|
||||
|
||||
Продолжение дня (см. `2026-09-29_startup-slowness.md`). По списку задач пользователя.
|
||||
|
||||
## Что значат сообщения достройки (объяснение, без правок)
|
||||
`GPS.fillGaps` (gps.js) чинит только реальные потери сигнала: разрыв > 60с И хорда > 150м.
|
||||
Для такого разрыва спрашивает у BRouter дорожный путь между концами:
|
||||
- "больше нужного" = `skipped-long`: дорога есть, но путь длиннее прямой более чем в 2 раза
|
||||
(крюк). Значит ехал не по дороге (тропа/поле/грунт, которого нет в графе) - оставляем прямую.
|
||||
- "нет данных" = `skipped-noroute`: BRouter не проложил вообще - нет дорожных данных на участке
|
||||
(не скачан .rd5-сегмент региона / пусто). Мостить нечем.
|
||||
Это не баг, а честный отказ достраивать заведомо кривым путём.
|
||||
|
||||
## Прогресс на долгих действиях (заливка фона кнопки по %)
|
||||
- CSS `.btn-progress` (styles.css): фон кнопки заливается слева по `--pct` (ставится из JS через
|
||||
CSS-переменную, не инлайн-стиль). Хелпер `setBtnProgress(el, pct)` (main.js).
|
||||
- "Уточнить высоты" (`#sd-refine`): реальный % от сетевых батчей `fetchElevations(pts, onProgress)`
|
||||
(0-80%), затем сохранение+пересчёт (85-100%). Сеть - api.gpxz.io / open-elevation /
|
||||
open-meteo / opentopodata.
|
||||
- "Пересчитать" (`#sd-recalc`): `GPS.recalculateSession(id, {onProgress})` шлёт честные вехи по
|
||||
фазам (5 старт / 40 фильтр / 70 стата / 100 запись).
|
||||
- "Перестроить" дороги (`#roads-rebuild`): Rust `rebuild_roads` эмитит `roads_progress`
|
||||
[current,total] каждые ~2000 точек (в проекте раньше событий из Rust не было вообще - добавлен
|
||||
`use tauri::Emitter`), JS слушает и заливает кнопку + пишет "Построение: cur / tot". Убирает
|
||||
"просто подвисает".
|
||||
|
||||
## Гонка reopenDetail - распространил на recalc/refine
|
||||
`#sd-recalc` и `#sd-refine` раньше делали тот же ручной `winStack.pop + history.back +
|
||||
showSessionDetail` (гонка с пустой картой/графиками). Переведены на `reopenDetail` (ждёт
|
||||
popstate) - фикс той же гонки.
|
||||
|
||||
## Память масштаба карты
|
||||
- Деталь трека: `_sdMapView[sessionId]` хранит {центр, зум}, обновляется на moveend/zoomend; при
|
||||
рендере, если вид сохранён - `setView` вместо `fitBounds`. Так после пересчёта/уточнения (reopen)
|
||||
и при возврате к треку масштаб сохраняется, а не сбрасывается на весь трек. Первый показ - как
|
||||
раньше, fitBounds.
|
||||
- Карта записи (`renderRecordingInline`): зум хранится в localStorage `baldav_rec_zoom`
|
||||
(сохраняется на zoomend, восстанавливается при создании карты вместо жёсткого 15). Так возврат
|
||||
на экран трекинга держит масштаб.
|
||||
|
||||
## Проверка
|
||||
- Синтаксис JS - ок. Сборка debug + телефон. Проверить: заливка кнопок идёт по %, дороги больше
|
||||
не "висят молча"; после "Пересчитать"/"Уточнить" карта на том же масштабе; вернулся на запись -
|
||||
масштаб прежний. (Задача 1 - фикс гонки reopenDetail - проверена через CDP отдельно.)
|
||||
115
docs/session-logs/2026-09-29_startup-slowness.md
Normal file
115
docs/session-logs/2026-09-29_startup-slowness.md
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
# Сессия 2026-09-29 - долгий старт БалДава + .gitignore
|
||||
|
||||
По утверждённому плану `~/.claude/plans/merry-discovering-dream.md` (замерить -> починить).
|
||||
|
||||
## Задача
|
||||
1. build.sh + BUILD_NUMBER в .gitignore (в build.sh пароль роутера, не место в git).
|
||||
2. Разобраться, почему БалДав стал долго стартовать, и починить.
|
||||
|
||||
## .gitignore
|
||||
Добавлены `build.sh` и `BUILD_NUMBER` (оба были untracked, не в игноре). Коммит 2d91c06.
|
||||
|
||||
## Диагностика старта (по требованию - сначала замер)
|
||||
Собирается только debug (release не рассматриваем), значит причина не в профиле сборки, а в
|
||||
работе на каждом старте. Замер на устройстве (adb am start -W + логи Tauri/Console, тайминг-логи
|
||||
временно добавлял в init/lib.rs, потом убрал):
|
||||
- `am start -W` TotalTime ~400ms (первый кадр - экран загрузки, быстро). Медленно - JS-init
|
||||
ПОСЛЕ первого кадра, он не виден в TotalTime.
|
||||
- В логах: `[db.js] Repairing seq for 3 sessions...` -> сессии 687 + 3614 + 10865 точек с
|
||||
`seq IS NULL`. Репейр (db.js `runMigrations`, ~2019) делал **по одному UPDATE на точку** -
|
||||
~15000 последовательных IPC-round-trip'ов в debug-Rust. По таймингам одна сессия 3614 =
|
||||
~3.8с, сессия 10865 не досчитывалась.
|
||||
- Ключевое (проверено через `run-as cat baldav.db` + sqlite3, journal_mode=delete, WAL ни при
|
||||
чём): репейр гонялся на КАЖДОМ старте (2 подряд без действий пользователя) и для крупных
|
||||
сессий seq НЕ сохранялся -> заново каждый запуск. Это и есть "долго": ~15с блокирующего
|
||||
init'а на старте.
|
||||
|
||||
Причина "стал": недавние merge/split (свежие фичи) создали NULL-seq сессии на 10865 и 3614
|
||||
точек; поточечный репейр давился на них.
|
||||
|
||||
## Фикс (frontend/js/db.js, репейр seq в runMigrations)
|
||||
Поточечный цикл заменён на **батчевую пере-нумерацию** (CASE чанками по 300, паттерн
|
||||
`saveGapFill`/`saveSmoothedTrack`): один UPDATE на ~300 точек вместо одного на точку. Порядок -
|
||||
по ts (как и было). ~15000 invoke'ов -> ~50.
|
||||
|
||||
## Проверка на устройстве (build 477, debug)
|
||||
- BOOT 1 (seq ещё NULL): репейр всех 3 сессий (15166 точек) за **~1.1с** (было ~15с+);
|
||||
`[BOOT] migrations` = 1356ms.
|
||||
- BOOT 2 (без действий между): "checking seq repair" есть, "Repairing seq" НЕТ - `nullSeqSessions`
|
||||
пусто, репейр не запускался. `[BOOT] migrations` = **357ms**. seq сохранился, репейр стал
|
||||
одноразовым.
|
||||
- Дамп БД после чистого force-stop: 0 NULL seq во всей БД; сессия 1789547934760 - seq 1..3614,
|
||||
инверсий по ts = 0 (порядок корректный, регрессии в отрисовке трека нет).
|
||||
|
||||
## Осталось / отдельно
|
||||
- `import_seed_products` (lib.rs) на up-to-date-старте всё равно копирует сид 8.5МБ + гоняет
|
||||
дедуп = ~259ms каждый старт. Это НЕ было причиной "долго" (репейр был), поэтому seed-опт не
|
||||
делал: область products/dedup имела баги (FK), трогать без нужды рискованно. Вынес пользователю
|
||||
на решение.
|
||||
- Тайминг-обвязку (JS [BOOT], Rust [SEED-TIMING]) убрал; lib.rs/main.js вернулись к HEAD.
|
||||
|
||||
## Пропажа тайлов карты на большом треке (отдельный баг)
|
||||
Симптом: смотришь трек 60 км (сессия 1790501377655, 10865 точек), зумишь/таскаешь - тайлы карты
|
||||
пропадают, помогает только force-stop+старт.
|
||||
|
||||
Диагностика по живому logcat (пользователь воспроизвёл: открыл трек -> достроил -> сохранил ->
|
||||
reopen -> убрал достройку -> зум/таскание; буфер предварительно увеличил до 16МБ и очистил):
|
||||
- `E chromium: [ERROR:cc/tiles/tile_manager.cc:1046] tile memory limits exceeded, some content
|
||||
may not draw` - 40+ раз ровно в окно зум/таскания. Это лимит памяти тайлов КОМПОНОВЩИКА
|
||||
Chromium (не карты, не TileCache). Никаких OOM-килла, крахов рендера, CSP по тайлам.
|
||||
- Причина: трек рисуется дефолтным SVG-рендерером Leaflet (`preferCanvas` нигде не задан), да
|
||||
ещё многослойно (casing/core + раскраска по уклону мелкими сегментами). Гигантский SVG-слой на
|
||||
весь трек при зуме превышает бюджет растровой памяти -> компоновщик перестаёт рисовать часть,
|
||||
тайлы гаснут. Force-stop сбрасывает компоновщик. Проявляется на любом крупном треке/карте.
|
||||
|
||||
Фикс (frontend/js/main.js): `preferCanvas: true` на картах с тяжёлыми векторами (canvas рисует
|
||||
в один вьюпортный холст вместо тысяч SVG-путей):
|
||||
- `showSessionDetail` (деталь трека, исходный баг), `showRoadsMap` (карта дорог - много
|
||||
полилиний, пользователь отметил подтормаживания), `showComparisonView` (два трека),
|
||||
`showRouteDetail` (превью маршрута с раскраской).
|
||||
- НЕ трогал: `renderRecordingInline` (там `rotate:true`, а leaflet-rotate почти не поддерживает
|
||||
Canvas - 1 ссылка на L.Canvas против 5 на SVG; canvas+rotate рискован, отдельная проверка),
|
||||
редактор/рисование маршрута (клик/тап по линии, малые полилинии), цель-маркер (без треков).
|
||||
|
||||
Проверка: воспроизвести зум/таскание на треке 60км и на карте дорог, снять logcat, убедиться,
|
||||
что `tile memory limits exceeded` больше НЕ появляется, тайлы держатся.
|
||||
|
||||
### Итог проверки canvas (build 479) - помог частично
|
||||
Пользователь воспроизвёл на 479: "подвисло, но восстановилось" (раньше - только force-stop).
|
||||
Снял logcat + инспекция живой страницы через CDP (`test_recovery/cdp.js`,
|
||||
`adb forward ... webview_devtools_remote_<pid>`):
|
||||
- Canvas ПРИМЕНИЛСЯ: в DOM детали `svgPaths=0`, оверлей - canvas.
|
||||
- Но `tile_manager tile memory limits exceeded` всё равно ~46 раз (столько же). Значит бюджет
|
||||
растровых тайлов компоновщика забивают САМИ тайлы карты (высокий DPI: 1260 физ / 404 css ≈ 3x)
|
||||
+ объём трека, а не SVG. Canvas дал живучесть (само восстанавливается), но джанк от 10865
|
||||
сегментов остался.
|
||||
|
||||
### Догоняющие правки (по решению пользователя: урезать тайлы + меньше точек на зум-ауте + POI)
|
||||
1. `tilecache.js` - у тайл-слоя `keepBuffer: 1` (было 2) и `updateWhenZooming: false`: меньше
|
||||
офскрин-тайлов в памяти компоновщика, тайлы не грузятся в процессе зума.
|
||||
2. `main.js` - `smoothFactor: 2.5` на главных полилиниях трека (деталь, `showSessionDetail`) и
|
||||
на сегментах карты дорог (`showRoadsMap`). smoothFactor - допуск Дугласа-Пекера в ПИКСЕЛЯХ,
|
||||
поэтому сам прореживает: при зум-ауте точки сходятся в пиксели и выбрасываются (меньше
|
||||
сегментов), при зум-ине - полная детализация. Ровно "меньше точек на сильном зум-ауте".
|
||||
3. `poi.js` - кап маркеров POI масштабируется зумом (z>=16: 500, 15: 200, 14: 80, 13: 30):
|
||||
на сильном зум-ауте показываем горстку ближайших, а не всё подряд.
|
||||
|
||||
Проверка: воспроизвести тот же сценарий, снять logcat - ожидаем, что `tile memory limits
|
||||
exceeded` резко упадёт/исчезнет, джанк уйдёт; POI на малом зуме - мало маркеров.
|
||||
Результат (build 480): пользователь - "всё ок".
|
||||
|
||||
## Баг: после "убрать достройку" пусто (карта+графики) до следующего действия
|
||||
Симптом: revert достройки -> графики и карта пропадают, из экрана трека не выкидывает,
|
||||
появляются только после следующего действия ("Отмена"). Стек окон + гонка.
|
||||
|
||||
Корень: `reopenDetail` (main.js:1176) стрелял `history.go(-count)` (асинхронные popstate) и
|
||||
СРАЗУ синхронно `showSessionDetail`. showSessionDetail пушит свою history-запись, потом
|
||||
отложенные popstate от go(-count) раскручивают уже ЕЁ и десинхронят стек; при fallthrough в
|
||||
renderScreen второй showSessionDetail бампает `_sessionDetailRender`, и первый async-рендер
|
||||
карты/графиков бросается на проверке токена (5411) -> пусто до следующего события. Ровно тот
|
||||
же класс, что чинил `closeThen()` (комментарий там про это).
|
||||
|
||||
Фикс: `reopenDetail` сделан async и ДОЖИДАЕТСЯ popstate от history.go перед showSessionDetail -
|
||||
через Promise + слушатель popstate (как `closeThen`), без setTimeout/setInterval.
|
||||
Проверка: revert на устройстве, по [SD]-логам один прогон showSessionDetail, карта+графики
|
||||
сразу на месте.
|
||||
30
docs/session-logs/2026-09-30_gapfill-ridden-router.md
Normal file
30
docs/session-logs/2026-09-30_gapfill-ridden-router.md
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
# Сессия 2026-09-30 - достройка пропусков предпочитает проеханные дороги
|
||||
|
||||
## Проблема (замечание пользователя)
|
||||
Достройка отказывалась мостить разрыв там, где реально проезжал (есть два проезда), с
|
||||
сообщением "больше нужного"/"нет данных". Причина: `GPS.fillGaps` звал только публичный BRouter
|
||||
"shortest" по OSM и ничего не знал о собственных треках пользователя. В проекте уже есть router
|
||||
по проеханным дорогам (`rideRoute`, `get_ride_graph`), которым пользуется редактор маршрутов, но
|
||||
достройка к нему не была подключена. Отсюда: где ехал по тропе/грунту (нет в OSM) - публичный
|
||||
BRouter либо в объезд > 2x, либо noroute.
|
||||
|
||||
## Фикс
|
||||
- `GPS.fillGaps(points, opts)` (gps.js): добавлен `opts.routeFn(a,b) -> {points, ridden}`; дефолт -
|
||||
публичный BRouter. Для `ridden:true` (путь по проеханным дорогам) НЕ применяется гвард "объезд
|
||||
> 2x" - проеханный путь это факт, а не крюк (`rideRoute` и так ограничивает себя 2.5x). Каждому
|
||||
разрыву в `gaps[]` пишется `ridden`.
|
||||
- Место вызова (main.js, деталь трека): `routeFn` = `rideRoute` сначала, публичный BRouter
|
||||
fallback. Так: есть моя дорога -> по ней; нет -> публичное; фэйл (в объезд / нет данных)
|
||||
остаётся, но с понятным сообщением.
|
||||
- Сообщение (drawGapPatches + панель): скипы считаются раздельно (skipped-long / skipped-noroute)
|
||||
и словами: "Не достроено: N (нет твоей проеханной дороги; K — публичный маршрут уводит в объезд,
|
||||
L — нет дорожных данных)."
|
||||
|
||||
## Оговорка
|
||||
Если участок проезжен ТОЛЬКО в этой поездке (где сам разрыв - точек нет), в ride-графе его нет,
|
||||
идёт fallback на публичный BRouter (как раньше). Ожидаемо: не ехал по другим - нельзя достроить.
|
||||
|
||||
## Проверка
|
||||
Синтаксис ок. Собрать + телефон: на воскресном 60-км нажать 🧩, убедиться, что разрывы по
|
||||
проеханным дорогам теперь достраиваются (голубым по реальной дороге), а сообщение по недостроенным
|
||||
- понятное. Отдельно: где не ехал - остаётся честный отказ.
|
||||
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, браслета нет -> "Подключить": "браслет не найден", сервис остановился (не висит).
|
||||
30
docs/session-logs/2026-10-17_bt-gps-prompts.md
Normal file
30
docs/session-logs/2026-10-17_bt-gps-prompts.md
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
# Сессия 2026-10-17 (2) - попап "включи BT/гео" при старте записи + 2 настройки "не спрашивать"
|
||||
|
||||
## Задача
|
||||
Пользователь забывает включать Bluetooth и GPS. Нужно: при старте записи, если выключено -
|
||||
предложить включить; дать отказаться; 2 отдельные настройки "не спрашивать".
|
||||
|
||||
Факт-чек: телефон С Google-сервисами (ранее ошибочно решил, что без). Тихо включать BT/гео
|
||||
приложение на target 36 не может (ОС), поэтому - попап + действие: BT через системный диалог
|
||||
включения, гео через экран настроек геолокации.
|
||||
|
||||
## Сделано
|
||||
- Rust `read_state_file(name)` (allowlist "location_state.json") - читает ответ Kotlin из
|
||||
native_root. Зарегистрирован.
|
||||
- Kotlin MainActivity: команды `check_location` (пишет `location_state.json` {enabled} через
|
||||
LocationManager.isLocationEnabled) и `open_location_settings` (ACTION_LOCATION_SOURCE_SETTINGS).
|
||||
- `gps.js` `GPS.isLocationEnabled()` - дёргает check_location, читает state-файл; true/false или
|
||||
null (неизвестно - тогда не спрашивать).
|
||||
- `main.js` `ensureSystemReady(needBt, needGps)` - перед startRecording: BT off ->
|
||||
appConfirm "Включить?" -> `ble_request_enable`; гео off -> appConfirm "Открыть настройки?" ->
|
||||
`open_location_settings`. Гейтится настройками `baldav_dontask_bt` / `baldav_dontask_gps`.
|
||||
Вызов в кнопке записи: needBt = есть привязанный датчик, needGps = не зарядка.
|
||||
- Настройки (раздел Пульсометр): 2 чекбокса "Не спрашивать про Bluetooth" / "...геолокацию".
|
||||
|
||||
Ограничение (честно): авто-ВЫКЛ BT/гео после тренировки приложение сделать не может (ОС).
|
||||
Тихое включение - тоже; поэтому попап + один тап.
|
||||
|
||||
## Проверка
|
||||
Синтаксис JS ок. На телефоне: выключить BT и гео, старт записи трека -> два попапа по очереди
|
||||
(Включить BT / Открыть настройки гео); отказ -> запись идёт как есть; включить чекбоксы "не
|
||||
спрашивать" -> попапы не показываются.
|
||||
66
docs/session-logs/2026-10-17_hr-workout-mode.md
Normal file
66
docs/session-logs/2026-10-17_hr-workout-mode.md
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
# Сессия 2026-10-17 - HR-датчик Coospo H808S: чтение + режим "Зарядка" без GPS
|
||||
|
||||
## Датчик прочитан с ПК (без спаривания)
|
||||
`F7:72:74:A4:FB:07` (`808S 0026275`), стандарт 0x180D/0x2A37. `tools/ble_probe.py`
|
||||
подключается без pair(), живой пульс ~1/сек (+ RR). `tools/hr_log.py` - фоновый логгер
|
||||
(epoch,bpm в файл, авто-переподключение). Адаптер ПК - hci1 (один). Подробности в памяти
|
||||
project-hr-sensor.
|
||||
|
||||
## Режим "Зарядка" (workout) - запись без GPS, только пульс + нагрузка
|
||||
Нужен был режим трекинга без геолокации (домашняя зарядка): меряет пульс и ккал, GPS не
|
||||
включает и не пишет.
|
||||
|
||||
- `frontend/js/gps.js`:
|
||||
- `GPS.hrKcalNet(samples, profile)` - нетто-ккал по пульсу (Keytel gross минус BMR/мин,
|
||||
как и остальная активность). samples = [[relSec,bpm]].
|
||||
- `startRecording(type, wkg, profile)` - ветка `workout`: НЕ запускает нативный GPS-сервис,
|
||||
watchPosition, поллинг точек. Создаёт сессию (`activity_type="workout"`), запускает HR-опрос.
|
||||
- HR-опрос (`_startHrMonitor`) при workout пушит `[relSec,bpm]` в `GPS.recording.hrSamples`.
|
||||
- `stopRecording` ветка workout: считает ккал (Keytel), avg/max пульс, сохраняет `hr_samples`
|
||||
(JSON) + `finishGpsSession(dist=0, kcal)`.
|
||||
- `frontend/js/db.js`: миграция `gps_sessions.hr_samples TEXT`; `setSessionHrSamples`.
|
||||
- `frontend/js/main.js`:
|
||||
- выпадашка записи: "Зарядка (пульс)".
|
||||
- `renderWorkoutInline` - экран записи без карты: крупный пульс, таймер, живые ккал,
|
||||
средний/макс, кнопка "Подключить пульсометр" (с проверкой BT), Стоп -> деталь.
|
||||
- `renderWorkoutDetail` - деталь без карты: график пульса (цвет по HR-зонам), время,
|
||||
нагрузка (ккал), средний/макс, удалить.
|
||||
- `frontend/css/styles.css`: `.workout-screen` и пр.
|
||||
|
||||
Ккал зарядки идёт в дневной баланс автоматически (`getDailyActivityTotals` суммирует
|
||||
`gps_sessions.total_kcal`). `recalculateSession` для workout - no-op (точек нет, возвращает null),
|
||||
так что "Пересчитать всё" не затирает HR-ккал.
|
||||
|
||||
## Заряд ремня ЧСС + голос/писк на 10% и 5% (сделано)
|
||||
- Kotlin `BleHrService`: читает 0x180F/0x2A19 (readBattery) через 2.5с после подписки и раз в
|
||||
60с; `lastBattery` пишется в `ble_hr_data.json` как `"bat":N`. onCharacteristicRead (две
|
||||
перегрузки под API 33+/старый).
|
||||
- Rust `HrReading`: поле `battery: Option<u8>` (alias "bat").
|
||||
- JS `gps.js`: поллинг ловит `reading.battery` -> `GPS.recording.currentBattery` +
|
||||
`GPS._checkBatteryAlert`: на 10% и 5% ОДИН раз - голос (`speechSynthesis` ru-RU) + писк
|
||||
(Web Audio, гарантированный сигнал, т.к. TTS на de-Googled ROM может не быть). Сброс флагов
|
||||
при заряде > 15% (перезарядили/заменили).
|
||||
- Экран зарядки показывает "заряд N%" в статусе.
|
||||
- Заметка: аудио в WebView может требовать user-gesture; старт записи - это жест, должно
|
||||
играть. Проверить на устройстве.
|
||||
|
||||
## Правка после теста: авто-ЧСС на старте + тоггл ВКЛ/ВЫКЛ вместо "Ветер"
|
||||
Я перестарался: по просьбе "не искать, если браслета нет" убрал автоконнект ВООБЩЕ. Нужно было
|
||||
оставить авто с проверкой наличия. Исправлено:
|
||||
- `GPS.setHrOn(on)` (gps.js): ON - проверяет BT (off -> запрос включения), коннектит привязанный
|
||||
датчик, запускает поллинг; OFF - стоп поллинг + disconnect. Безопасно: BleHrService при
|
||||
отсутствии браслета коротко сканит и `stopSelf` (драйна нет).
|
||||
- Старт записи (и GPS, и зарядки): если датчик привязан - авто `setHrOn(true)`. Так "надел
|
||||
датчик -> начал -> подключился сам", без ручного коннекта каждый раз. Нет привязанного - ЧСС off.
|
||||
- Экран записи: кнопка **"Ветер" убрана** (неудобна), на её месте **тоггл ЧСС** (квадрат с
|
||||
сердечком+линией пульса; серый = выкл, красный = вкл), дёргает `GPS.setHrOn`.
|
||||
- Ручная кнопка "Подключить" в настройках остаётся.
|
||||
|
||||
Факт-чек (я ошибся ранее): на телефоне Google-сервисы ЕСТЬ (`com.google.android.gms`, Play
|
||||
Store) - значит системный диалог включения гео через Play Services доступен. Авто-вкл BT/гео по
|
||||
запросу (попап + 2 настройки "не спрашивать") - отдельная задача, теперь подтверждено выполнимо.
|
||||
|
||||
## Проверка
|
||||
Синтаксис JS ок. На телефоне: выбрать "Зарядка", Старт (без браслета покажет "Подключите
|
||||
пульсометр"), подключить датчик -> пульс крупно + ккал растут; Стоп -> деталь с графиком.
|
||||
Нужен BT на телефоне + привязанный датчик. (Сейчас BT на телефоне выключен.)
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
# Сессия 2026-10-19 - краш при выключении Bluetooth + один запрос на включение вместо нескольких
|
||||
|
||||
## Контекст
|
||||
Пользователь: "при выключении блютуса балдав падает", и попапы про включение BT лезут
|
||||
повторно ("если запретил - не просить до нового трекинга; просить ОДИН раз, а не 20").
|
||||
Действовали по утверждённому плану `~/.claude/plans/merry-discovering-dream.md` (финальный
|
||||
вариант; сохранён в docs/plans не был - план короткий, воспроизведён в этом логе).
|
||||
|
||||
## Разбор (две независимые причины)
|
||||
1. Краш. Крэш-буфер телефона: нативный abort libc FORTIFY "pthread_mutex_lock called on a
|
||||
destroyed mutex" (pid 19579), не Kotlin-исключение. Причина: живой GATT/scanner трогают
|
||||
уже после выключения BT-адаптера. В `BleHrService.kt` не было приёмника состояния BT, а
|
||||
отложенные `scanner.stopScan`, fitpro-ретриггер и периодические хендлеры продолжали дёргать
|
||||
GATT. Точный фрейм без tombstone не прибивался - фикс сделан defense-in-depth (решение
|
||||
пользователя: сразу защита + проверка воспроизведением).
|
||||
2. Двойной запрос. `ensureSystemReady` (main.js) спрашивает один раз через appConfirm, но
|
||||
`startRecording` авто-зовёт `setHrOn(true)`, а тот при BT off САМ дёргал нативный
|
||||
`ble_request_enable` (gps.js:899), игнорируя отказ -> второе (системное) окно сразу после
|
||||
отказа, и так каждый старт.
|
||||
|
||||
## Сделано (2 коммита)
|
||||
Коммит 1 (краш, Kotlin `BleHrService.kt`):
|
||||
- Единый `handler = Handler(Looper.getMainLooper())`; все postDelayed через него;
|
||||
`handler.removeCallbacksAndMessages(null)` в `disconnect()` и `onDestroy()`.
|
||||
- `BroadcastReceiver` на `BluetoothAdapter.ACTION_STATE_CHANGED`: на STATE_TURNING_OFF/OFF -
|
||||
снять хендлеры, disconnect, `writeData(status:bt_off)`, stopSelf. Снятие в onDestroy в try.
|
||||
- try/catch на всех нативных BLE-вызовах: startScan/stopScan/connectGatt/discoverServices/
|
||||
writeCharacteristic/setCharacteristicNotification/writeDescriptor; directConnect проверяет
|
||||
adapter.isEnabled.
|
||||
|
||||
Коммит 2 (попапы, JS):
|
||||
- `GPS.setHrOn(on, interactive = false)` (gps.js): системное окно `ble_request_enable` только
|
||||
при `interactive === true`. Авто-режим при BT off: hrOn=false, не подключаться, без окна.
|
||||
- Авто-вызовы на старте (gps.js 1433 зарядка, 1462 трек) остаются `setHrOn(true)` (без
|
||||
interactive) -> единственный авто-запрос при старте = один appConfirm в ensureSystemReady.
|
||||
- Ручной тоггл ЧСС (main.js:5148) -> `setHrOn(..., true)`: одно системное окно на тап.
|
||||
Ручное "Подключить" (настройки) не трогали - оно и так user-initiated.
|
||||
|
||||
## Проверка
|
||||
Синтаксис JS ок (node -c обоих файлов). На телефоне (сборка `./build.sh -i -d android`):
|
||||
1) ЧСС подключён, выключить BT в записи -> app НЕ падает, ЧСС гаснет, трек идёт; в logcat
|
||||
`BleHR` виден STATE_OFF и чистый stop, нет нового FATAL/FORTIFY.
|
||||
2) BT off, старт трека -> ровно одно окно "Включить Bluetooth?"; отказ -> системное окно НЕ
|
||||
появляется, до конца записи повторов нет; новый старт -> снова один вопрос.
|
||||
3) Тап по тогглу ЧСС при BT off -> одно системное окно.
|
||||
4) Регрессии рядом: зарядка с ЧСС считает kcal; "Не спрашивать" гасят свои запросы;
|
||||
"Подключить" в настройках работает на ходу.
|
||||
|
||||
## Отдельно (read-only, без правок)
|
||||
Разобрал `wind_marks` в 5 треках из 228: отметки встречного ветра нигде не используются
|
||||
(коммент в коде - "ground truth для оценки ветра из трека", которой в коде нет), и
|
||||
эмпирически сигнал противоречив (в 2 из 4 велотреков "в ветер" скорость даже выше). Вывод:
|
||||
пользы нет; кнопки ветра уже нет, новые треки пишут пустой wind_marks, помех нет - оставили
|
||||
как есть.
|
||||
|
|
@ -714,6 +714,34 @@ select {
|
|||
|
||||
.full-width { width: 100%; }
|
||||
|
||||
/* HR-only workout (зарядка) recording screen: big heart rate, timer, kcal, no map. */
|
||||
.workout-screen { padding: 24px 16px; display: flex; flex-direction: column; align-items: center; gap: 8px; }
|
||||
.wk-hr-big { font-size: 92px; font-weight: 700; line-height: 1; color: var(--primary); }
|
||||
.wk-hr-lbl { color: var(--muted); margin-top: -4px; }
|
||||
.wk-stats { display: flex; gap: 18px; margin: 14px 0; flex-wrap: wrap; justify-content: center; }
|
||||
.wk-stat { display: flex; flex-direction: column; align-items: center; min-width: 64px; }
|
||||
.wk-val { font-size: 22px; font-weight: 600; }
|
||||
.wk-lbl { font-size: 12px; color: var(--muted); }
|
||||
.wk-status { color: var(--muted); font-size: 13px; text-align: center; min-height: 18px; margin-bottom: 6px; }
|
||||
.workout-screen .btn-primary, .workout-screen .btn-secondary { margin-top: 8px; width: 100%; }
|
||||
|
||||
/* HR on/off toggle on the record screen (replaces the wind button): grey = off, coloured = on. */
|
||||
.rec-hr-toggle { color: #888; display: inline-flex; align-items: center; justify-content: center; gap: 4px;
|
||||
/* Strap battery as a background fill, bottom-up, 0-100% (--bat), coloured by level (--bat-color). */
|
||||
background-image: linear-gradient(to top, var(--bat-color, transparent) var(--bat, 0%), transparent var(--bat, 0%)); }
|
||||
.rec-hr-toggle svg { display: block; }
|
||||
.rec-hr-toggle.active { color: #fff; } /* HR on = white icon + bpm (over the battery fill) */
|
||||
.rec-hr-num { font-size: 15px; font-weight: 700; min-width: 0; }
|
||||
.rec-hr-num:empty { display: none; }
|
||||
|
||||
/* Progress fill for long-running action buttons: the background fills left-to-right by
|
||||
--pct (0-100%, set from JS). The label stays on top and readable; the unfilled part keeps
|
||||
the button's own background. Used by recompute / elevation-refine / roads-rebuild. */
|
||||
.btn-progress {
|
||||
background-image: linear-gradient(90deg, var(--primary) var(--pct, 0%), rgba(0,0,0,0) var(--pct, 0%));
|
||||
transition: background-image 0.15s linear;
|
||||
}
|
||||
|
||||
/* Products screen */
|
||||
.products-screen, .stub-screen, .settings-screen {
|
||||
padding: 16px;
|
||||
|
|
@ -1246,6 +1274,11 @@ select {
|
|||
.rec-mode-btn { right: 8px; bottom: 162px; font-size: 11px; }
|
||||
.rec-route-toggle { right: 8px; bottom: 200px; }
|
||||
.rec-dark-toggle { right: 8px; bottom: 238px; }
|
||||
.rec-follow-btn { right: 8px; bottom: 276px; font-size: 18px; }
|
||||
/* nogo (⛔) and reroute (↪) had no position class — they piled at the map's top-left over the
|
||||
zoom controls. Give them slots in the right-hand stack. */
|
||||
.rec-nogo-btn { right: 8px; bottom: 314px; }
|
||||
.rec-reroute-btn { right: 8px; bottom: 352px; }
|
||||
/* Dark map: invert ONLY the tile layer (overlays — track, route, roads, puck — keep
|
||||
their colours). User-tuned filter on OSM standard tiles. Applies to every Leaflet map. */
|
||||
body.dark-map .leaflet-tile-pane { filter: invert(1) hue-rotate(180deg) brightness(1.5); }
|
||||
|
|
@ -1283,20 +1316,9 @@ body.dark-map .poi-badge { background: #111; border-color: #eee; }
|
|||
cursor: pointer;
|
||||
}
|
||||
/* Keep Leaflet zoom controls below the status bar on the recording map. */
|
||||
.rec-map-wrap .leaflet-top { margin-top: var(--safe-area-top, 0px); }
|
||||
/* Status-bar backdrop. The recording map is edge-to-edge (draws behind the system
|
||||
status bar), so the bar had no backing and its icons were unreadable over the map
|
||||
tiles. Paint a solid strip the height of the top inset — like the header on every
|
||||
other screen — so the status bar stays visible. Controls already start below it. */
|
||||
.rec-map-wrap::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0; left: 0; right: 0;
|
||||
height: var(--safe-area-top, 0px);
|
||||
background: var(--surface);
|
||||
z-index: 1100;
|
||||
pointer-events: none;
|
||||
}
|
||||
/* The top strip (.rec-topbar) now sits above the map and already backs the status bar, so the
|
||||
map no longer draws under the status bar — no backdrop strip, and controls start at the map top. */
|
||||
.rec-map-wrap .leaflet-top { margin-top: 0; }
|
||||
.rec-screen.rot-90 .rec-map-wrap::before,
|
||||
.rec-screen.rot-270 .rec-map-wrap::before { display: none; }
|
||||
.rec-compass-arrow { position: absolute; inset: 0; transition: transform 0.12s linear; }
|
||||
|
|
@ -1379,11 +1401,21 @@ body.dark-map .poi-badge { background: #111; border-color: #eee; }
|
|||
}
|
||||
|
||||
.rec-val {
|
||||
font-size: 22px;
|
||||
font-size: 27px;
|
||||
font-weight: 700;
|
||||
color: var(--on-surface);
|
||||
}
|
||||
|
||||
/* Top strip (external info): training time (left), temperature (centre), clock (right).
|
||||
Same big digits as the bottom stats (.rec-val). */
|
||||
.rec-topbar {
|
||||
display: flex; justify-content: space-between; align-items: baseline;
|
||||
padding: 4px 16px; padding-top: calc(4px + var(--safe-area-top));
|
||||
background: var(--surface);
|
||||
}
|
||||
.rec-topbar .rec-top { font-size: 27px; font-weight: 700; color: var(--on-surface); }
|
||||
.rec-topbar .rec-top-mid { color: var(--muted); }
|
||||
|
||||
.rec-lbl {
|
||||
font-size: 10px;
|
||||
color: var(--muted);
|
||||
|
|
@ -1401,24 +1433,16 @@ body.dark-map .poi-badge { background: #111; border-color: #eee; }
|
|||
|
||||
.rec-bottom {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
padding: 8px 12px;
|
||||
padding-bottom: calc(8px + var(--safe-area-bottom));
|
||||
align-items: stretch;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
padding-bottom: calc(6px + var(--safe-area-bottom));
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.rec-bottom .rec-status {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.rec-bottom .gps-rec {
|
||||
padding: 12px 32px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
/* All three controls (HR toggle, pause, stop) share the width equally — no clustering right.
|
||||
border-box + one padding so the different per-button paddings don't make them unequal. */
|
||||
.rec-bottom > button { flex: 1 1 0; box-sizing: border-box; min-width: 0; padding: 12px 8px; }
|
||||
|
||||
.gps-header {
|
||||
padding: 10px 12px;
|
||||
|
|
@ -1542,6 +1566,18 @@ body.dark-map .poi-badge { background: #111; border-color: #eee; }
|
|||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Repeat / interval analysis table + verdict */
|
||||
.repeats-head { font-size: 12px; color: var(--on-bg); opacity: 0.8; margin-bottom: 8px; }
|
||||
.repeats-tbl { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
.repeats-tbl th, .repeats-tbl td { padding: 5px 6px; text-align: right; border-bottom: 1px solid var(--border); }
|
||||
.repeats-tbl th:first-child, .repeats-tbl td:first-child { text-align: left; }
|
||||
.repeats-tbl th { color: var(--primary); font-weight: 600; }
|
||||
.repeats-verdict { margin-top: 10px; padding: 10px; border-radius: var(--radius); text-align: center; line-height: 1.4; }
|
||||
.repeats-verdict .rv-num { font-size: 22px; font-weight: 700; }
|
||||
.repeats-verdict.ok { background: rgba(46, 160, 67, 0.18); color: #4caf50; }
|
||||
.repeats-verdict.warn { background: rgba(255, 179, 0, 0.16); color: #ffb300; }
|
||||
.repeats-verdict.bad { background: rgba(207, 102, 121, 0.18); color: #cf6679; }
|
||||
|
||||
.sd-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
|
|
@ -1728,6 +1764,45 @@ body.dark-map .poi-badge { background: #111; border-color: #eee; }
|
|||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.gps-session-item.selected {
|
||||
border-color: var(--primary);
|
||||
background: color-mix(in srgb, var(--primary) 20%, var(--surface));
|
||||
box-shadow: inset 0 0 0 1px var(--primary);
|
||||
}
|
||||
|
||||
/* Floating action bar for multi-select merge (sits above the bottom tab bar) */
|
||||
.gps-sel-bar {
|
||||
position: fixed;
|
||||
left: 0; right: 0;
|
||||
bottom: calc(56px + var(--safe-area-bottom));
|
||||
z-index: 90;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 16px;
|
||||
background: var(--surface);
|
||||
border-top: 1px solid var(--border);
|
||||
box-shadow: 0 -2px 8px rgba(0,0,0,0.3);
|
||||
}
|
||||
.gps-sel-bar[hidden] { display: none; }
|
||||
|
||||
/* "Split here" button inside a point popup — visible only when the map is in split mode */
|
||||
.split-here {
|
||||
display: inline-block;
|
||||
margin-top: 6px;
|
||||
padding: 4px 10px;
|
||||
background: #ff9800;
|
||||
color: #000;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
#sd-map:not(.split-mode) .split-here { display: none; }
|
||||
.gps-sel-bar #gps-sel-count { flex: 1; font-size: 14px; }
|
||||
.gps-sel-bar button { padding: 8px 14px; }
|
||||
|
||||
.gps-session-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
|
|
|||
|
|
@ -48,6 +48,9 @@ async function addWeight(date, time, kg) {
|
|||
"INSERT INTO weight_log (date, time, weight_kg) VALUES (?1, ?2, ?3)",
|
||||
[date, time, kg]
|
||||
);
|
||||
// Keep the settings weight in sync with the latest weigh-in (today's if there is one).
|
||||
const latest = await getLatestWeight();
|
||||
if (latest && latest.weight_kg != null) await setProfileValue("weight_kg", latest.weight_kg);
|
||||
}
|
||||
|
||||
async function deleteWeight(id) {
|
||||
|
|
@ -65,13 +68,16 @@ async function getLatestWeight() {
|
|||
return rows.length ? rows[0] : null;
|
||||
}
|
||||
|
||||
async function getRecentWeights(days) {
|
||||
// `days` calendar days ENDING at endDate (default today). Range is (end-days, end] so it is
|
||||
// exactly `days` days (the old `>= date('now','-N')` gave N+1) and follows the selected day.
|
||||
async function getRecentWeights(days, endDate) {
|
||||
const end = endDate || "now";
|
||||
return select(
|
||||
`SELECT date, MIN(weight_kg) as weight_kg
|
||||
FROM weight_log
|
||||
WHERE date >= date('now', ?1)
|
||||
WHERE date > date(?2, ?1) AND date <= date(?2)
|
||||
GROUP BY date ORDER BY date ASC`,
|
||||
[`-${days ?? 30} days`]
|
||||
[`-${days ?? 30} days`, end]
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -680,25 +686,28 @@ async function getActivityTotalsForDate(date) {
|
|||
return { kcal: manualKcal + gpsKcal, minutes: manual[0]?.minutes ?? 0 };
|
||||
}
|
||||
|
||||
async function getDailyFoodTotals(days) {
|
||||
// N calendar days ending at endDate (default today), range (end-N, end] = exactly N days.
|
||||
async function getDailyFoodTotals(days, endDate) {
|
||||
const end = endDate || "now";
|
||||
return select(
|
||||
`SELECT date, SUM(kcal) as kcal, SUM(protein_g) as protein_g,
|
||||
SUM(fat_g) as fat_g, SUM(carbs_g) as carbs_g
|
||||
FROM food_log WHERE date >= date('now', ?1)
|
||||
FROM food_log WHERE date > date(?2, ?1) AND date <= date(?2)
|
||||
GROUP BY date ORDER BY date ASC`,
|
||||
[`-${days ?? 30} days`]
|
||||
[`-${days ?? 30} days`, end]
|
||||
);
|
||||
}
|
||||
|
||||
async function getDailyActivityTotals(days) {
|
||||
async function getDailyActivityTotals(days, endDate) {
|
||||
const offset = `-${days ?? 30} days`;
|
||||
const end = endDate || "now";
|
||||
return select(
|
||||
`SELECT date, SUM(kcal) as kcal FROM (
|
||||
SELECT date, kcal FROM activity_log WHERE date >= date('now', ?1)
|
||||
SELECT date, kcal FROM activity_log WHERE date > date(?2, ?1) AND date <= date(?2)
|
||||
UNION ALL
|
||||
SELECT date, total_kcal as kcal FROM gps_sessions WHERE date >= date('now', ?2) AND total_kcal IS NOT NULL
|
||||
SELECT date, total_kcal as kcal FROM gps_sessions WHERE date > date(?2, ?1) AND date <= date(?2) AND total_kcal IS NOT NULL
|
||||
) GROUP BY date ORDER BY date ASC`,
|
||||
[offset, offset]
|
||||
[offset, end]
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -801,6 +810,24 @@ async function saveGapFill(sessionId, points) {
|
|||
}
|
||||
await execute(`INSERT INTO gps_points (session_id, ts, lat, lon, seq, estimated) VALUES ${rowSql.join(",")}`, vals);
|
||||
}
|
||||
// Re-sequence the WHOLE session by ts so getGpsPoints (ORDER BY seq,ts) returns the true
|
||||
// order. Without this, on merged/split tracks the real points have seq=NULL, the inserted
|
||||
// gap-fill points get seq=0, and they sort to the FRONT — a straight line from the start to
|
||||
// the gap ("returns to the beginning"). Re-numbering by ts is a no-op for dense tracks and
|
||||
// fixes NULL/mixed ones. Batched CASE update (like saveSmoothedTrack) to beat the IPC/bind cost.
|
||||
let ordered = await select("SELECT id FROM gps_points WHERE session_id=?1 ORDER BY ts ASC, id ASC", [sessionId]);
|
||||
let SEQCHUNK = 300; // 2 params/row (id, seq) → under SQLite's 999 bind-var limit
|
||||
for (let i = 0; i < ordered.length; i += SEQCHUNK) {
|
||||
let chunk = ordered.slice(i, i + SEQCHUNK);
|
||||
let when = [], ids = [], params = [];
|
||||
for (let k = 0; k < chunk.length; k++) {
|
||||
params.push(chunk[k].id); let idi = params.length;
|
||||
params.push(i + k + 1); let sqi = params.length;
|
||||
when.push(`WHEN ?${idi} THEN ?${sqi}`);
|
||||
ids.push(`?${idi}`);
|
||||
}
|
||||
await execute(`UPDATE gps_points SET seq=CASE id ${when.join(" ")} END WHERE id IN (${ids.join(",")})`, params);
|
||||
}
|
||||
await execute("UPDATE gps_sessions SET gap_filled=1 WHERE id=?1", [sessionId]);
|
||||
return toInsert.length;
|
||||
}
|
||||
|
|
@ -914,6 +941,66 @@ async function deleteGpsSession(id) {
|
|||
await execute("DELETE FROM gps_sessions WHERE id = ?1", [id]);
|
||||
}
|
||||
|
||||
// Merge several sessions into one. Points from every session move into the EARLIEST
|
||||
// (by start time) session; each moved point keeps its original session in
|
||||
// orig_session_id (so a future "split" can undo this), the target's end_time extends to
|
||||
// the latest, and the other session rows are deleted. Ids are text (large i64 read back
|
||||
// off-by-one), passed as strings and CAST to INTEGER for the id/session_id columns.
|
||||
// Returns the surviving (target) session id_str. Caller recomputes stats afterwards.
|
||||
async function mergeGpsSessions(idStrs) {
|
||||
if (!Array.isArray(idStrs) || idStrs.length < 2) return null;
|
||||
const sessions = [];
|
||||
for (const id of idStrs) { const s = await getGpsSession(id); if (s) sessions.push(s); }
|
||||
if (sessions.length < 2) return null;
|
||||
const ord = (s) => (s.date || "1970-01-01") + "T" + (s.start_time || "00:00:00");
|
||||
sessions.sort((a, b) => ord(a) < ord(b) ? -1 : ord(a) > ord(b) ? 1 : 0);
|
||||
const target = sessions[0];
|
||||
const targetId = target.id_str;
|
||||
const endOf = (s) => (s.date || "1970-01-01") + "T" + (s.end_time || s.start_time || "00:00:00");
|
||||
const latestEnd = sessions.reduce((m, s) => endOf(s) > endOf(m) ? s : m, target).end_time;
|
||||
// Tag the target's own points too, so orig_session_id is complete for a later split.
|
||||
await execute("UPDATE gps_points SET orig_session_id = CAST(?1 AS TEXT) WHERE session_id = CAST(?1 AS INTEGER) AND orig_session_id IS NULL", [targetId]);
|
||||
for (const s of sessions.slice(1)) {
|
||||
await execute(
|
||||
"UPDATE gps_points SET orig_session_id = COALESCE(orig_session_id, CAST(session_id AS TEXT)), session_id = CAST(?1 AS INTEGER) WHERE session_id = CAST(?2 AS INTEGER)",
|
||||
[targetId, s.id_str]
|
||||
);
|
||||
await execute("DELETE FROM gps_sessions WHERE id = CAST(?1 AS INTEGER)", [s.id_str]);
|
||||
}
|
||||
if (latestEnd) await execute("UPDATE gps_sessions SET end_time = ?1 WHERE id = CAST(?2 AS INTEGER)", [latestEnd, targetId]);
|
||||
// Each source session numbered its points seq 1..N, so after the move the seq values
|
||||
// collide and "ORDER BY seq" would interleave the tracks (measured: a 43 km "segment"
|
||||
// and 6745 km/h passes). Clear seq so getGpsPoints falls back to timestamp order, which
|
||||
// is the true order across the merged tracks.
|
||||
await execute("UPDATE gps_points SET seq = NULL WHERE session_id = CAST(?1 AS INTEGER)", [targetId]);
|
||||
return targetId;
|
||||
}
|
||||
|
||||
// Split one session in two at a chosen point. The second part (points whose ids are in
|
||||
// part2Ids) moves to a NEW session that copies the original's date / activity / bike /
|
||||
// source; the first part keeps the original session. Bike stays the same on both halves -
|
||||
// the rider then reassigns the bike on a half and recomputes if the gear differed. Times
|
||||
// are "HH:MM:SS". Returns the new (second-part) session id_str. Caller recomputes both.
|
||||
async function splitGpsSession(sessionId, part2Ids, part1EndTime, part2StartTime) {
|
||||
if (!Array.isArray(part2Ids) || part2Ids.length === 0) return null;
|
||||
const s = await getGpsSession(sessionId);
|
||||
if (!s) return null;
|
||||
const newId = Date.now();
|
||||
await execute(
|
||||
`INSERT INTO gps_sessions (id, date, start_time, end_time, activity_type, source, bike_id)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)`,
|
||||
[newId, s.date, part2StartTime, s.end_time, s.activity_type, s.source ?? "recorded", s.bike_id ?? null]
|
||||
);
|
||||
let CHUNK = 200;
|
||||
for (let i = 0; i < part2Ids.length; i += CHUNK) {
|
||||
let ch = part2Ids.slice(i, i + CHUNK);
|
||||
let ph = ch.map((_, k) => "?" + (k + 2)).join(",");
|
||||
await execute(`UPDATE gps_points SET session_id = CAST(?1 AS INTEGER), seq = NULL WHERE id IN (${ph})`, [newId, ...ch]);
|
||||
}
|
||||
if (part1EndTime) await execute("UPDATE gps_sessions SET end_time = ?1 WHERE id = CAST(?2 AS INTEGER)", [part1EndTime, sessionId]);
|
||||
return String(newId);
|
||||
}
|
||||
|
||||
async function importGpxSession(session, points) {
|
||||
await execute(
|
||||
`INSERT INTO gps_sessions (date, start_time, end_time, activity_type,
|
||||
|
|
@ -1012,6 +1099,18 @@ async function updateSessionBike(sessionId, bikeId) {
|
|||
await execute("UPDATE gps_sessions SET bike_id = ?1 WHERE id = ?2", [bikeId, sessionId]);
|
||||
}
|
||||
|
||||
// Store the rider's weight at ride time (set once when a recording finalises). A separate
|
||||
// setter from finishGpsSession, which recompute also calls — recompute must NOT overwrite it.
|
||||
async function setSessionRiderWeight(sessionId, kg) {
|
||||
if (kg == null || isNaN(kg)) return;
|
||||
await execute("UPDATE gps_sessions SET rider_weight_kg = ?1 WHERE id = ?2", [kg, sessionId]);
|
||||
}
|
||||
|
||||
// HR-only "workout" session: store the heart-rate time series as JSON [[relSec,bpm],...].
|
||||
async function setSessionHrSamples(sessionId, samplesJson) {
|
||||
await execute("UPDATE gps_sessions SET hr_samples = ?1 WHERE id = ?2", [samplesJson, sessionId]);
|
||||
}
|
||||
|
||||
// Bulk-set terrain altitude (altitude_srtm_m) for points by id. rows: [{id, srtm}].
|
||||
// Batched CASE update — values are numbers (ids small, no i64 corruption), safe to inline.
|
||||
async function updatePointsSrtm(rows) {
|
||||
|
|
@ -1289,6 +1388,8 @@ window.DB.getGpsSessionsForDate = getGpsSessionsForDate;
|
|||
window.DB.getGpsSession = getGpsSession;
|
||||
window.DB.getGpsPoints = getGpsPoints;
|
||||
window.DB.deleteGpsSession = deleteGpsSession;
|
||||
window.DB.mergeGpsSessions = mergeGpsSessions;
|
||||
window.DB.splitGpsSession = splitGpsSession;
|
||||
window.DB.importGpxSession = importGpxSession;
|
||||
window.DB.saveGapFill = saveGapFill;
|
||||
window.DB.revertGapFill = revertGapFill;
|
||||
|
|
@ -1313,6 +1414,8 @@ window.DB.saveBike = saveBike;
|
|||
window.DB.updateBike = updateBike;
|
||||
window.DB.deleteBike = deleteBike;
|
||||
window.DB.updateSessionBike = updateSessionBike;
|
||||
window.DB.setSessionRiderWeight = setSessionRiderWeight;
|
||||
window.DB.setSessionHrSamples = setSessionHrSamples;
|
||||
window.DB.updatePointsSrtm = updatePointsSrtm;
|
||||
window.DB.getBikeMaintenanceLog = getBikeMaintenanceLog;
|
||||
window.DB.addBikeMaintenance = addBikeMaintenance;
|
||||
|
|
@ -1516,6 +1619,7 @@ async function runMigrations() {
|
|||
if (!gpsCols.includes("lon_smoothed")) await execute("ALTER TABLE gps_points ADD COLUMN lon_smoothed REAL");
|
||||
if (!gpsCols.includes("estimated")) await execute("ALTER TABLE gps_points ADD COLUMN estimated INTEGER DEFAULT 0"); // gap-fill (BRouter) points, reversible
|
||||
if (!gpsCols.includes("walk")) await execute("ALTER TABLE gps_points ADD COLUMN walk INTEGER DEFAULT 0"); // point is in a "walking" run inside a ride (excluded from ridden roads)
|
||||
if (!gpsCols.includes("orig_session_id")) await execute("ALTER TABLE gps_points ADD COLUMN orig_session_id TEXT"); // set on merge = the session this point first belonged to (lets a future split undo the merge)
|
||||
|
||||
// --- Cooking methods table ---
|
||||
await execute(`CREATE TABLE IF NOT EXISTS cooking_methods (
|
||||
|
|
@ -1634,6 +1738,8 @@ async function runMigrations() {
|
|||
if (!gsCols.includes("pauses")) await execute("ALTER TABLE gps_sessions ADD COLUMN pauses TEXT"); // JSON [{startTs,endTs,...}] manual pauses
|
||||
if (!gsCols.includes("wind_marks")) await execute("ALTER TABLE gps_sessions ADD COLUMN wind_marks TEXT"); // JSON [{startTs,endTs}] headwind labels (ground truth for wind estimate)
|
||||
if (!gsCols.includes("gap_filled")) await execute("ALTER TABLE gps_sessions ADD COLUMN gap_filled INTEGER DEFAULT 0"); // BRouter gap-fill applied (reversible)
|
||||
if (!gsCols.includes("rider_weight_kg")) await execute("ALTER TABLE gps_sessions ADD COLUMN rider_weight_kg REAL"); // rider weight at ride time; recompute uses it (NULL on old sessions -> latest weigh-in)
|
||||
if (!gsCols.includes("hr_samples")) await execute("ALTER TABLE gps_sessions ADD COLUMN hr_samples TEXT"); // HR-only "workout" sessions (no GPS): JSON [[relSec,bpm],...]
|
||||
|
||||
// Add tire_pressure_bar to bikes (for Crr correction by tire pressure)
|
||||
const bikeCols = (await select("PRAGMA table_info(bikes)")).map(r => r.name);
|
||||
|
|
@ -1925,13 +2031,26 @@ async function runMigrations() {
|
|||
);
|
||||
if (nullSeqSessions.length > 0) {
|
||||
console.log(`[db.js] Repairing seq for ${nullSeqSessions.length} sessions...`);
|
||||
for (const row of nullSeqSessions) {
|
||||
const pts = await select(
|
||||
"SELECT rowid, ts FROM gps_points WHERE session_id = ?1 ORDER BY ts ASC, rowid ASC",
|
||||
for (let row of nullSeqSessions) {
|
||||
// Batched CASE update (like saveGapFill/saveSmoothedTrack): one UPDATE per ~300 points
|
||||
// instead of one per point. The per-point loop was ~15000 awaited IPC round-trips on
|
||||
// merged/split tracks (seq=NULL by design) — many seconds of startup, EVERY boot, and
|
||||
// for the big sessions it did not even persist before the next launch re-ran it.
|
||||
let pts = await select(
|
||||
"SELECT id FROM gps_points WHERE session_id = ?1 ORDER BY ts ASC, id ASC",
|
||||
[row.session_id]
|
||||
);
|
||||
for (let i = 0; i < pts.length; i++) {
|
||||
await execute("UPDATE gps_points SET seq = ?1 WHERE rowid = ?2", [i + 1, pts[i].rowid]);
|
||||
let SEQCHUNK = 300; // 2 params/row (id, seq) → under SQLite's 999 bind-var limit
|
||||
for (let i = 0; i < pts.length; i += SEQCHUNK) {
|
||||
let chunk = pts.slice(i, i + SEQCHUNK);
|
||||
let when = [], ids = [], params = [];
|
||||
for (let k = 0; k < chunk.length; k++) {
|
||||
params.push(chunk[k].id); let idi = params.length;
|
||||
params.push(i + k + 1); let sqi = params.length;
|
||||
when.push(`WHEN ?${idi} THEN ?${sqi}`);
|
||||
ids.push(`?${idi}`);
|
||||
}
|
||||
await execute(`UPDATE gps_points SET seq=CASE id ${when.join(" ")} END WHERE id IN (${ids.join(",")})`, params);
|
||||
}
|
||||
console.log(`[db.js] Repaired seq for session ${row.session_id}: ${pts.length} points`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -91,6 +91,141 @@ GPS.detectWalk = function(points, opts) {
|
|||
return out;
|
||||
};
|
||||
|
||||
// Auto-detect repeated segments within ONE ride (interval training: the same climb
|
||||
// ridden N times, one track, no stop/restart). The repeated corridor is found by
|
||||
// self-similarity — no user marks. Ported from tools/track/pers.py (a pass = a run
|
||||
// where position along a reference segment rises monotonically), but the reference
|
||||
// segment is DISCOVERED from the ride itself (the longest corridor of cells the ride
|
||||
// revisits), not given as road_points ids.
|
||||
// Returns { found:false } or { found:true, segLength(m), passCount, passes:[{startIdx,endIdx}] }.
|
||||
// Direction is handled without an explicit bearing check: seq keeps each cell's FIRST
|
||||
// (uphill) position, so coasting back down makes position fall and ends the pass, and
|
||||
// the "must start near seq beginning" guard stops a descent from opening a new pass.
|
||||
GPS.detectRepeats = function(points, opts) {
|
||||
opts = opts || {};
|
||||
let cell = opts.cellM ?? 10; // grid cell ~10 m (as in tools/track heightmap)
|
||||
let minLenM = opts.minLenM ?? 300; // repeated segment must be at least this long
|
||||
let minReps = opts.minPasses ?? 2; // minimum passes to REPORT (final gate)
|
||||
let minFrac = opts.minFrac ?? 0.8; // a pass must cover this fraction of the segment
|
||||
let bridgeSec = opts.bridgeSec ?? 90; // bridge an interruption inside a pass up to this long
|
||||
let bridgeM = opts.bridgeM ?? 60; // ...as long as the rider stays within this of the corridor
|
||||
let projTolM = opts.projTolM ?? 30; // max lateral distance to count as on the corridor
|
||||
let backM = opts.backM ?? 30; // progress may dip this far without ending the pass
|
||||
let n = points ? points.length : 0;
|
||||
if (n < 20) return { found: false };
|
||||
|
||||
// Grid cell key per point.
|
||||
let key = new Array(n);
|
||||
for (let i = 0; i < n; i++) {
|
||||
let la = points[i].lat, lo = points[i].lon;
|
||||
if (la == null || lo == null) { key[i] = null; continue; }
|
||||
key[i] = Math.floor(la * 111320 / cell) + "," + Math.floor(lo * 111320 * Math.cos(la * Math.PI / 180) / cell);
|
||||
}
|
||||
|
||||
// Entries per cell: count each time the track enters a cell (key changes into it).
|
||||
let visits = new Map();
|
||||
for (let i = 0; i < n; i++) {
|
||||
if (key[i] == null) continue;
|
||||
if (i === 0 || key[i] !== key[i - 1]) visits.set(key[i], (visits.get(key[i]) || 0) + 1);
|
||||
}
|
||||
let maxEntries = 0; for (let v of visits.values()) if (v > maxEntries) maxEntries = v;
|
||||
if (maxEntries < 2) return { found: false };
|
||||
|
||||
// Find passes for a given hot-cell threshold (cells entered >= hotThr times = the corridor).
|
||||
// Returns { segLength, passes } or null. The reference is the LONGEST clean traversal of hot
|
||||
// cells; each ride point is PROJECTED onto it (progress in metres); a pass is a run where
|
||||
// progress rises from near the bottom to >= minFrac, tolerating drift/stops (bridge) and a
|
||||
// small backstep. Pass time = moving seconds only (stops trimmed).
|
||||
let detectAt = (hotThr) => {
|
||||
let isHot = (k) => k != null && (visits.get(k) || 0) >= hotThr;
|
||||
let buildTraversal = (start) => {
|
||||
let seq = [], order = new Map(), end = start;
|
||||
for (let k = start, gap = 0; k < n; k++) {
|
||||
let c = key[k];
|
||||
if (c == null || !isHot(c)) { gap++; if (gap > 5) break; continue; } // left the corridor
|
||||
gap = 0;
|
||||
if (seq.length && c === seq[seq.length - 1]) { end = k; continue; } // still in the same cell
|
||||
if (order.has(c)) break; // revisit of an earlier cell -> traversal complete
|
||||
order.set(c, seq.length); seq.push(c); end = k;
|
||||
}
|
||||
let len = 0;
|
||||
for (let k = start + 1; k <= end; k++)
|
||||
len += GPS.distance(points[k - 1].lat, points[k - 1].lon, points[k].lat, points[k].lon);
|
||||
return { seq, len, start, end };
|
||||
};
|
||||
let best = null;
|
||||
for (let k = 0; k < n; k++) {
|
||||
if (!isHot(key[k]) || (k > 0 && isHot(key[k - 1]))) continue; // only at a hot-run start
|
||||
let t = buildTraversal(k);
|
||||
if (!best || t.len > best.len) best = t;
|
||||
}
|
||||
if (!best || best.seq.length < 3 || best.len < minLenM) return null;
|
||||
let rLat = [], rLon = [], cum = [0];
|
||||
for (let k = best.start; k <= best.end; k++) {
|
||||
rLat.push(points[k].lat); rLon.push(points[k].lon);
|
||||
if (k > best.start) cum.push(cum[cum.length - 1] + GPS.distance(points[k - 1].lat, points[k - 1].lon, points[k].lat, points[k].lon));
|
||||
}
|
||||
let refLen = cum[cum.length - 1];
|
||||
if (rLat.length < 3 || refLen < minLenM) return null;
|
||||
let refCell = new Map();
|
||||
for (let v = 0; v < rLat.length; v++) {
|
||||
let ck = Math.floor(rLat[v] * 111320 / cell) + "," + Math.floor(rLon[v] * 111320 * Math.cos(rLat[v] * Math.PI / 180) / cell);
|
||||
(refCell.get(ck) || refCell.set(ck, []).get(ck)).push(v);
|
||||
}
|
||||
let rad = Math.ceil(projTolM / cell);
|
||||
let prog = new Array(n).fill(null);
|
||||
for (let i = 0; i < n; i++) {
|
||||
if (points[i].lat == null) continue;
|
||||
let cy = Math.floor(points[i].lat * 111320 / cell), cx = Math.floor(points[i].lon * 111320 * Math.cos(points[i].lat * Math.PI / 180) / cell);
|
||||
let bv = -1, bd = projTolM;
|
||||
for (let da = -rad; da <= rad; da++) for (let db = -rad; db <= rad; db++) {
|
||||
let arr = refCell.get((cy + da) + "," + (cx + db)); if (!arr) continue;
|
||||
for (let v of arr) { let d = GPS.distance(points[i].lat, points[i].lon, rLat[v], rLon[v]); if (d < bd) { bd = d; bv = v; } }
|
||||
}
|
||||
if (bv >= 0) prog[i] = cum[bv];
|
||||
}
|
||||
let passes = [];
|
||||
let p = 0;
|
||||
while (p < n) {
|
||||
if (prog[p] == null || prog[p] > refLen * 0.15) { p++; continue; }
|
||||
let q = p, lo = prog[p], hi = prog[p], anchor = p, gap = 0;
|
||||
while (q < n - 1) {
|
||||
q++;
|
||||
if (prog[q] != null && prog[q] >= hi - backM) { gap = 0; hi = Math.max(hi, prog[q]); anchor = q; continue; }
|
||||
let dtStop = (new Date(points[q].ts) - new Date(points[anchor].ts)) / 1000;
|
||||
let dispStop = GPS.distance(points[anchor].lat, points[anchor].lon, points[q].lat, points[q].lon);
|
||||
if ((dtStop <= bridgeSec && dispStop <= bridgeM) || ++gap <= 25) continue;
|
||||
break;
|
||||
}
|
||||
if ((hi - lo) >= refLen * minFrac) {
|
||||
let mov = 0;
|
||||
for (let k = p + 1; k <= anchor; k++) {
|
||||
let dt = (new Date(points[k].ts) - new Date(points[k - 1].ts)) / 1000;
|
||||
let disp = GPS.distance(points[k - 1].lat, points[k - 1].lon, points[k].lat, points[k].lon);
|
||||
if (dt > 0 && disp / dt >= 0.8) mov += dt; // moving; a stop (dropped seat) is excluded
|
||||
}
|
||||
passes.push({ startIdx: p, endIdx: anchor, movingSec: Math.round(mov) });
|
||||
}
|
||||
p = Math.max(q, anchor) + 1;
|
||||
}
|
||||
return { segLength: Math.round(refLen), passes };
|
||||
};
|
||||
|
||||
// A too-low hot threshold pulls the once/twice-ridden approach and turnaround into the
|
||||
// reference, inflating it so a full rep no longer covers minFrac (measured on a real
|
||||
// out-and-back: 1 pass at threshold 2, 5 at threshold 3+). Try every threshold from 2 up to
|
||||
// the busiest cell and keep the one yielding the MOST passes (tie -> the longest segment).
|
||||
let bestRes = null;
|
||||
for (let T = 2; T <= Math.min(maxEntries, 20); T++) {
|
||||
let r = detectAt(T);
|
||||
if (!r || r.passes.length < minReps) continue;
|
||||
if (!bestRes || r.passes.length > bestRes.passes.length ||
|
||||
(r.passes.length === bestRes.passes.length && r.segLength > bestRes.segLength)) bestRes = r;
|
||||
}
|
||||
if (!bestRes) return { found: false };
|
||||
return { found: true, segLength: bestRes.segLength, passCount: bestRes.passes.length, passes: bestRes.passes };
|
||||
};
|
||||
|
||||
// One-time migration: accuracy gate is now a HARD reject (coarse cell/wifi fixes
|
||||
// "sit at the tower"). Bump the old default 20m -> 50m so weak but real GPS survives.
|
||||
// Only touch the value if it's still the old default; respect a user-set threshold.
|
||||
|
|
@ -246,6 +381,9 @@ GPS.computeStats = function(points, weightKg, opts) {
|
|||
|
||||
let totalKcal = 0;
|
||||
const basalPerMin = (1.0 * 3.5 * wKg) / 200;
|
||||
// Mechanical work while pedalling, for avgPower output (used by the repeat/interval
|
||||
// analysis to compare passes). Averaged over pedalling time, like tools/track pers.py.
|
||||
let mechWorkJ = 0, powerSec = 0;
|
||||
|
||||
// Cadence tracking
|
||||
let totalPedalRevs = 0;
|
||||
|
|
@ -407,6 +545,7 @@ GPS.computeStats = function(points, weightKg, opts) {
|
|||
// 2. MET floor: Compendium cycling METs by speed, MINUS the 1 MET of rest
|
||||
// BMR already covers — the empirically measured cost.
|
||||
const physicsKcal = Math.max(0, power) * dt / efficiency / 4184;
|
||||
mechWorkJ += Math.max(0, power) * dt; powerSec += dt; // avg mechanical power
|
||||
const vKmh = v * 3.6;
|
||||
if (vKmh >= 5) {
|
||||
const met = vKmh < 16 ? 6.0 : vKmh < 19 ? 6.8 : vKmh < 22 ? 8.0 : vKmh < 25 ? 10.0 : vKmh < 28 ? 12.0 : 14.0;
|
||||
|
|
@ -474,6 +613,8 @@ GPS.computeStats = function(points, weightKg, opts) {
|
|||
const avgCadence = (hasGearData && pedalingSeconds > 0)
|
||||
? Math.round(totalPedalRevs / (pedalingSeconds / 60))
|
||||
: null;
|
||||
// Average mechanical power (W) over pedalling time, null if never pedalled.
|
||||
const avgPower = powerSec > 0 ? Math.round(mechWorkJ / powerSec) : null;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Auto climb segments: detect climb (gradient > 2%), descent (< -2%), flat
|
||||
|
|
@ -570,6 +711,7 @@ GPS.computeStats = function(points, weightKg, opts) {
|
|||
kcal: Math.round(totalKcal),
|
||||
duration: Math.round(duration),
|
||||
avgCadence,
|
||||
avgPower,
|
||||
segments,
|
||||
walkBikeSec: Math.round(walkBikeSec), // time spent pushing the bike (single-speed, ≥30s runs)
|
||||
walkBikeMeters: Math.round(walkBikeDist), // distance walked pushing it (excluded from ride distance)
|
||||
|
|
@ -680,6 +822,19 @@ GPS.requestBackgroundLocation = async function() {
|
|||
} catch (e) { console.log("[GPS] reqbgloc:", e); }
|
||||
};
|
||||
|
||||
// Is the OS location toggle ON (not just the permission)? Kotlin checks LocationManager and
|
||||
// writes location_state.json; we read it back. Returns true/false, or null if undeterminable
|
||||
// (desktop / no answer) — callers must not prompt on null.
|
||||
GPS.isLocationEnabled = async function() {
|
||||
try {
|
||||
await window.__TAURI__.core.invoke("gps_service_command", { cmd: "check_location" });
|
||||
await new Promise(r => setTimeout(r, 300));
|
||||
let raw = await window.__TAURI__.core.invoke("read_state_file", { name: "location_state.json" });
|
||||
if (!raw) return null;
|
||||
return !!JSON.parse(raw).enabled;
|
||||
} catch { return null; }
|
||||
};
|
||||
|
||||
// BLE HR monitor — tied to GPS session (start→stop), but NOT paused when GPS pauses.
|
||||
// HR keeps measuring even when rider is stationary.
|
||||
GPS._startHrMonitor = async function() {
|
||||
|
|
@ -689,24 +844,23 @@ 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");
|
||||
if (reading && reading.battery != null) { GPS.recording.currentBattery = reading.battery; GPS._checkBatteryAlert(reading.battery); }
|
||||
if (reading && reading.hr > 0) {
|
||||
GPS.recording.currentHr = reading.hr;
|
||||
GPS.recording.currentBleData = reading;
|
||||
// HR-only workout: the poll IS the recorder — append [relSec, bpm] each reading.
|
||||
if (GPS.recording.activityType === "workout" && GPS.recording.startTime) {
|
||||
GPS.recording.hrSamples.push([Math.round((Date.now() - GPS.recording.startTime.getTime()) / 1000), reading.hr]);
|
||||
}
|
||||
if (GPS.recording.onHrUpdate) GPS.recording.onHrUpdate(reading);
|
||||
}
|
||||
} catch {}
|
||||
|
|
@ -727,6 +881,80 @@ GPS._stopHrMonitor = function() {
|
|||
try { window.__TAURI__.core.invoke("ble_clear_hr"); } catch {}
|
||||
};
|
||||
|
||||
// Turn HR on/off during a recording (the toggle on the record screen). ON connects to the
|
||||
// paired strap (Kotlin scans ~5s and stops cleanly if it's absent — so no constant scanning /
|
||||
// drain when the strap isn't there) and polls; OFF stops the poll and disconnects.
|
||||
//
|
||||
// `interactive` = the user tapped something (the record-screen toggle, "Подключить"). Only then
|
||||
// may we pop the OS "turn on Bluetooth?" dialog when BT is off. Auto-calls at recording start
|
||||
// pass interactive=false: if BT is off they just leave HR off (no second popup after the single
|
||||
// start-time prompt in ensureSystemReady), and a later manual connect still works.
|
||||
GPS.setHrOn = async function(on, interactive = false) {
|
||||
GPS.recording.hrOn = !!on;
|
||||
if (!on) {
|
||||
GPS._stopHrMonitor();
|
||||
try { await window.__TAURI__.core.invoke("ble_disconnect"); } catch {}
|
||||
return false;
|
||||
}
|
||||
const addr = await DB.getProfileValue("ble_device_addr");
|
||||
const type = await DB.getProfileValue("ble_device_type");
|
||||
if (!addr) { GPS.recording.hrOn = false; return false; }
|
||||
try {
|
||||
const bt = await window.__TAURI__.core.invoke("ble_check_bluetooth");
|
||||
if (bt === "off") {
|
||||
// Auto path never nags; only a user tap asks the OS to enable BT. Either way we can't
|
||||
// connect with the radio off — leave HR off so the toggle shows grey, not a fake "on".
|
||||
if (interactive) { try { await window.__TAURI__.core.invoke("ble_request_enable"); } catch {} }
|
||||
GPS.recording.hrOn = false;
|
||||
return false;
|
||||
}
|
||||
if (bt !== "no_adapter") { try { await window.__TAURI__.core.invoke("ble_connect", { address: addr, deviceType: type || "hr_standard" }); } catch {} }
|
||||
} catch {}
|
||||
try { await GPS._startHrMonitor(); } catch {}
|
||||
return true;
|
||||
};
|
||||
|
||||
// Low-battery warning for the HR strap: announce (voice) and beep ONCE at 10% and 5%.
|
||||
// Resets when the strap reads healthy again (recharged / battery replaced).
|
||||
GPS._batAlerted = {};
|
||||
GPS._checkBatteryAlert = function(bat) {
|
||||
if (bat == null || bat < 0 || bat > 100) return;
|
||||
if (bat > 15) { GPS._batAlerted = {}; return; }
|
||||
for (let thr of [10, 5]) {
|
||||
if (bat <= thr && !GPS._batAlerted[thr]) {
|
||||
GPS._batAlerted[thr] = true;
|
||||
GPS._speakOrBeep(`Заряд пульсометра ${thr} процентов`);
|
||||
}
|
||||
}
|
||||
};
|
||||
GPS._speakOrBeep = function(text) {
|
||||
let spoke = false;
|
||||
try {
|
||||
if (window.speechSynthesis && typeof SpeechSynthesisUtterance !== "undefined") {
|
||||
let u = new SpeechSynthesisUtterance(text);
|
||||
u.lang = "ru-RU"; u.rate = 0.95;
|
||||
window.speechSynthesis.speak(u);
|
||||
spoke = true;
|
||||
}
|
||||
} catch {}
|
||||
// Always beep too (TTS may be absent on a de-Googled ROM; the beep is the guaranteed cue).
|
||||
try { GPS._beep(spoke ? 1 : 3); } catch {}
|
||||
};
|
||||
GPS._beep = function(times) {
|
||||
times = times || 1;
|
||||
let ctx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
let one = (i) => {
|
||||
let o = ctx.createOscillator(), g = ctx.createGain();
|
||||
o.frequency.value = 880; o.type = "sine";
|
||||
o.connect(g); g.connect(ctx.destination);
|
||||
g.gain.value = 0.25;
|
||||
let t0 = ctx.currentTime + i * 0.35;
|
||||
o.start(t0); o.stop(t0 + 0.2);
|
||||
};
|
||||
for (let i = 0; i < times; i++) one(i);
|
||||
setTimeout(() => { try { ctx.close(); } catch {} }, times * 350 + 400);
|
||||
};
|
||||
|
||||
// Read points from native service file
|
||||
// Parse raw JSONL (one native point per line) into sorted point objects.
|
||||
GPS._parseNativePoints = function(raw) {
|
||||
|
|
@ -1160,15 +1388,65 @@ GPS.recoverSession = async function() {
|
|||
}
|
||||
};
|
||||
|
||||
GPS.startRecording = async function(activityType, weightKg) {
|
||||
// Net kcal from a heart-rate series (Keytel 2005 gross, minus BMR for the same time so it is
|
||||
// consistent with the app's NET model — BMR is already booked in the daily balance).
|
||||
// samples: [[relSec, bpm], ...]. profile: {weightKg, age, sex ("m"/"f"), heightCm}.
|
||||
GPS.hrKcalNet = function(samples, profile) {
|
||||
if (!samples || samples.length < 2) return 0;
|
||||
profile = profile || {};
|
||||
const W = parseFloat(profile.weightKg) || 75, A = parseInt(profile.age) || 38;
|
||||
const H = parseFloat(profile.heightCm) || 178, male = (profile.sex || "m") === "m";
|
||||
const bmrMin = (10 * W + 6.25 * H - 5 * A + (male ? -5 : 161)) / 1440; // Mifflin -> kcal/min
|
||||
const grossPerMin = hr => male
|
||||
? (-55.0969 + 0.6309 * hr + 0.1988 * W + 0.2017 * A) / 4.184
|
||||
: (-20.4022 + 0.4472 * hr - 0.1263 * W + 0.0740 * A) / 4.184;
|
||||
let kcal = 0;
|
||||
for (let i = 1; i < samples.length; i++) {
|
||||
const dtMin = Math.max(0, samples[i][0] - samples[i-1][0]) / 60;
|
||||
if (dtMin <= 0 || dtMin > 1) continue; // skip > 1 min gaps (disconnect)
|
||||
const net = grossPerMin((samples[i-1][1] + samples[i][1]) / 2) - bmrMin;
|
||||
if (net > 0) kcal += net * dtMin;
|
||||
}
|
||||
return Math.round(kcal);
|
||||
};
|
||||
|
||||
GPS.startRecording = async function(activityType, weightKg, profile) {
|
||||
if (GPS.recording.active) return;
|
||||
if (!navigator.geolocation) throw new Error("Geolocation not available");
|
||||
|
||||
GPS.recording.active = true;
|
||||
GPS.recording.activityType = activityType || "bike";
|
||||
GPS.recording.points = [];
|
||||
GPS.recording.hrSamples = [];
|
||||
GPS.recording.hrProfile = profile || null;
|
||||
// Clear any HR left over from the PREVIOUS session (else a stale bpm hangs on the display and
|
||||
// gets written onto this session's points). Only a fresh reading this session re-fills it.
|
||||
GPS.recording.currentHr = null;
|
||||
GPS.recording.currentBleData = null;
|
||||
GPS.recording.currentBattery = null;
|
||||
try { window.__TAURI__.core.invoke("ble_clear_hr"); } catch {}
|
||||
GPS.recording.startTime = null;
|
||||
GPS.recording.weightKg = parseFloat(weightKg) || 80;
|
||||
|
||||
// HR-only "workout" (зарядка): no GPS service, no location, no track points — just the
|
||||
// heart-rate poll + a timer. kcal comes from HR (Keytel) at finish.
|
||||
if (GPS.recording.activityType === "workout") {
|
||||
GPS.recording.state = "recording";
|
||||
GPS.recording.startTime = new Date();
|
||||
GPS.recording.nativeService = false;
|
||||
GPS.recording.sessionId = null;
|
||||
try {
|
||||
GPS.recording.sessionId = await DB.createGpsSession(
|
||||
new Date().toISOString().slice(0, 10), new Date().toTimeString().slice(0, 5), "workout");
|
||||
} catch (e) { console.error("[workout] createGpsSession failed:", e); }
|
||||
// Auto-connect the paired strap on start (workout = you want HR). Absent strap -> stops clean.
|
||||
try {
|
||||
const hrAddr = await DB.getProfileValue("ble_device_addr");
|
||||
if (hrAddr) await GPS.setHrOn(true); else GPS.recording.hrOn = false;
|
||||
} catch (e) { console.error("[HR] start error:", e); }
|
||||
return;
|
||||
}
|
||||
|
||||
if (!navigator.geolocation) throw new Error("Geolocation not available");
|
||||
GPS.recording.state = "waiting_gps";
|
||||
GPS.recording.sessionId = null;
|
||||
GPS.recording.pauseStart = null;
|
||||
|
|
@ -1187,8 +1465,13 @@ GPS.startRecording = async function(activityType, weightKg) {
|
|||
// Start native foreground service (Android)
|
||||
await GPS._startNativeService();
|
||||
|
||||
// Start BLE HR monitor (auto-connect to paired device)
|
||||
try { await GPS._startHrMonitor(); } catch (e) { console.error("[HR] start error:", e); }
|
||||
// HR: default ON if a strap is paired — auto-connect + poll at start (the record screen has an
|
||||
// on/off toggle). Safe: BleHrService scans briefly and stops if the strap isn't present, so no
|
||||
// constant scanning/drain when it's absent. No paired strap -> HR stays off.
|
||||
try {
|
||||
const hrAddr = await DB.getProfileValue("ble_device_addr");
|
||||
if (hrAddr) await GPS.setHrOn(true); else GPS.recording.hrOn = false;
|
||||
} catch (e) { console.error("[HR] start error:", e); }
|
||||
|
||||
// Poll native service output file
|
||||
if (GPS.recording.nativeService) {
|
||||
|
|
@ -1264,6 +1547,26 @@ GPS.stopRecording = async function() {
|
|||
GPS.recording.active = false;
|
||||
|
||||
if (GPS.recording._pollTimer) clearInterval(GPS.recording._pollTimer);
|
||||
|
||||
// HR-only workout: no GPS to stop. Compute net kcal from HR, save samples, finalise.
|
||||
if (GPS.recording.activityType === "workout") {
|
||||
const samples = GPS.recording.hrSamples || [];
|
||||
const kcal = GPS.hrKcalNet(samples, GPS.recording.hrProfile);
|
||||
const durationSec = GPS.recording.startTime ? Math.round((Date.now() - GPS.recording.startTime.getTime()) / 1000) : 0;
|
||||
const hrs = samples.map(s => s[1]);
|
||||
const avgHr = hrs.length ? Math.round(hrs.reduce((a, b) => a + b, 0) / hrs.length) : null;
|
||||
const maxHr = hrs.length ? Math.max(...hrs) : null;
|
||||
const sid = GPS.recording.sessionId;
|
||||
GPS._stopHrMonitor();
|
||||
if (sid) {
|
||||
try { await DB.setSessionHrSamples(sid, JSON.stringify(samples)); } catch (e) { console.error("[workout] save samples", e); }
|
||||
// Reuse finishGpsSession: distance 0, kcal from HR; avg/max HR ride the speed columns' siblings? No —
|
||||
// store them nowhere extra; detail derives avg/max from hr_samples. Just distance 0 + kcal.
|
||||
try { await DB.finishGpsSession(sid, new Date().toTimeString().slice(0, 5), 0, kcal, null, null, 0, 0); } catch (e) { console.error("[workout] finish", e); }
|
||||
}
|
||||
return { sessionId: sid, workout: true, kcal, durationSec, avgHr, maxHr, samples };
|
||||
}
|
||||
|
||||
GPS._stopHrMonitor();
|
||||
|
||||
if (GPS.recording.watchId != null) {
|
||||
|
|
@ -1339,6 +1642,8 @@ GPS.stopRecording = async function() {
|
|||
stats.elevGain, stats.elevLoss, recPauses
|
||||
);
|
||||
try { await DB.setSessionWindMarks(GPS.recording.sessionId, recWindMarks); } catch (e) { console.error("[GPS] wind marks save failed:", e); }
|
||||
// Persist the rider weight used for this ride, so a later recompute keeps ride-time weight.
|
||||
try { await DB.setSessionRiderWeight(GPS.recording.sessionId, GPS.recording.weightKg); } catch (e) { console.error("[GPS] rider weight save failed:", e); }
|
||||
// DB save confirmed — now safe to delete the points file
|
||||
if (nativePointsFile) {
|
||||
try { await window.__TAURI__.core.invoke("delete_points_file", { filename: nativePointsFile }); } catch {}
|
||||
|
|
@ -1454,6 +1759,13 @@ GPS.fillGaps = async function(points, opts) {
|
|||
let minChord = opts.minChordM ?? 150; // and only when the endpoints are genuinely far apart
|
||||
let ratio = opts.maxRatio ?? 2; // road > 2× chord = big detour, likely wrong → keep straight
|
||||
let minGain = opts.minGainM ?? 40; // road ≈ chord = nothing to gain → keep straight
|
||||
// Router for one gap: the caller can prefer already-ridden roads (returns {points, ridden:true}
|
||||
// when the bridge runs over the user's own tracks); default is the public BRouter shortest.
|
||||
// A ridden path is what actually happened, so it is NOT subject to the "detour too long" guard.
|
||||
let routeFn = opts.routeFn || (async (a, b) => {
|
||||
let gpx = await BRouter.route([[a.lon, a.lat], [b.lon, b.lat]], "shortest");
|
||||
return { points: (GPS.parseRouteGPX(gpx) || {}).points || [], ridden: false };
|
||||
});
|
||||
let sorted = points.slice().sort((a, b) => new Date(a.ts) - new Date(b.ts));
|
||||
let out = [];
|
||||
let gaps = [];
|
||||
|
|
@ -1465,16 +1777,17 @@ GPS.fillGaps = async function(points, opts) {
|
|||
let chord = GPS.distance(a.lat, a.lon, b.lat, b.lon);
|
||||
if (dt <= gapThr || chord < minChord) continue; // not a real dropout worth bridging
|
||||
let ta = new Date(a.ts).getTime(), tb = new Date(b.ts).getTime();
|
||||
let routed = [], status = "skipped-noroute", addedM = 0;
|
||||
let routed = [], status = "skipped-noroute", addedM = 0, ridden = false;
|
||||
try {
|
||||
let gpx = await BRouter.route([[a.lon, a.lat], [b.lon, b.lat]], "shortest");
|
||||
let rp = (GPS.parseRouteGPX(gpx) || {}).points || [];
|
||||
let r = await routeFn(a, b);
|
||||
let rp = (r && r.points) || [];
|
||||
ridden = !!(r && r.ridden);
|
||||
if (rp.length >= 3) {
|
||||
let rlen = 0;
|
||||
for (let k = 1; k < rp.length; k++) rlen += GPS.distance(rp[k-1].lat, rp[k-1].lon, rp[k].lat, rp[k].lon);
|
||||
addedM = Math.max(0, rlen - chord);
|
||||
if (rlen > chord * ratio) {
|
||||
status = "skipped-long"; // road detoured too far — keep the chord
|
||||
if (!ridden && rlen > chord * ratio) {
|
||||
status = "skipped-long"; // PUBLIC route detoured too far — keep the chord (a ridden path is trusted as-is)
|
||||
} else if (addedM < minGain) {
|
||||
status = "skipped-nogain"; // road ≈ straight — nothing to bridge
|
||||
} else {
|
||||
|
|
@ -1488,7 +1801,7 @@ GPS.fillGaps = async function(points, opts) {
|
|||
}
|
||||
}
|
||||
} catch (e) { status = "skipped-noroute"; }
|
||||
gaps.push({ a: { lat: a.lat, lon: a.lon }, b: { lat: b.lat, lon: b.lon }, routed, status, addedM, chordM: chord });
|
||||
gaps.push({ a: { lat: a.lat, lon: a.lon }, b: { lat: b.lat, lon: b.lon }, routed, status, addedM, chordM: chord, ridden });
|
||||
}
|
||||
return { points: out, gaps };
|
||||
};
|
||||
|
|
@ -1766,6 +2079,9 @@ GPS.exportGPX = async function(sessionId) {
|
|||
// ---------------------------------------------------------------------------
|
||||
GPS.recalculateSession = async function(sessionId, opts) {
|
||||
opts = opts || {};
|
||||
// Coarse progress by phase (load → filter → stats → write). Honest milestones, not a timer.
|
||||
const onProg = (typeof opts.onProgress === "function") ? opts.onProgress : () => {};
|
||||
onProg(5);
|
||||
const session = await DB.getGpsSession(sessionId);
|
||||
if (!session) return null;
|
||||
const points = await DB.getGpsPoints(sessionId);
|
||||
|
|
@ -1807,7 +2123,7 @@ GPS.recalculateSession = async function(sessionId, opts) {
|
|||
// Compute stats and finish
|
||||
const latestW = await DB.getLatestWeight();
|
||||
const profile = await DB.getFullProfile();
|
||||
const weightKg = parseFloat(latestW?.weight_kg) || parseFloat(profile?.weight_kg) || 80;
|
||||
const weightKg = parseFloat(session.rider_weight_kg) || parseFloat(latestW?.weight_kg) || parseFloat(profile?.weight_kg) || 80;
|
||||
const bike = session.bike_id ? await DB.getBike(session.bike_id) : await DB.getActiveBike();
|
||||
const CRR_MAP = { asphalt: 0.004, mixed: 0.006, gravel: 0.008 };
|
||||
const stats = GPS.computeStats(sorted, weightKg, {
|
||||
|
|
@ -1818,6 +2134,7 @@ GPS.recalculateSession = async function(sessionId, opts) {
|
|||
});
|
||||
await DB.finishGpsSession(sessionId, session.end_time ?? session.start_time,
|
||||
stats.distance, stats.kcal, stats.avgSpeed, stats.maxSpeed, stats.elevGain, stats.elevLoss);
|
||||
onProg(100);
|
||||
return stats;
|
||||
}
|
||||
|
||||
|
|
@ -1872,6 +2189,7 @@ GPS.recalculateSession = async function(sessionId, opts) {
|
|||
}
|
||||
if (deleted) console.log(`[GPS] Deleted ${deleted} glitch points from session ${sessionId}`);
|
||||
}
|
||||
onProg(40);
|
||||
|
||||
// Interpolate gaps where accelerometer shows movement
|
||||
// If gap > 30s but accel before/after > 2 → person was walking/riding, GPS lost signal
|
||||
|
|
@ -1930,10 +2248,10 @@ GPS.recalculateSession = async function(sessionId, opts) {
|
|||
}
|
||||
}
|
||||
|
||||
// Get user weight: latest weight log, or profile, or 80
|
||||
// Rider weight: the ride's stored weight (ride time), else latest weigh-in, else profile, else 80.
|
||||
const latestW = await DB.getLatestWeight();
|
||||
const profile = await DB.getFullProfile();
|
||||
const weightKg = parseFloat(latestW?.weight_kg) || parseFloat(profile?.weight_kg) || 80;
|
||||
const weightKg = parseFloat(session.rider_weight_kg) || parseFloat(latestW?.weight_kg) || parseFloat(profile?.weight_kg) || 80;
|
||||
|
||||
// Get bike if linked to session, otherwise use active bike
|
||||
const bike = session.bike_id ? await DB.getBike(session.bike_id) : await DB.getActiveBike();
|
||||
|
|
@ -1943,6 +2261,7 @@ GPS.recalculateSession = async function(sessionId, opts) {
|
|||
// Use cleaned sorted array, not original points
|
||||
const sessPauses = Array.isArray(session.pauses) ? session.pauses
|
||||
: (typeof session.pauses === "string" ? (() => { try { return JSON.parse(session.pauses); } catch { return []; } })() : []);
|
||||
onProg(70);
|
||||
const stats = GPS.computeStats(sorted, weightKg, {
|
||||
bikeWeightKg: bike?.weight_kg ?? 0,
|
||||
activityType: session.activity_type ?? "bike",
|
||||
|
|
@ -1961,6 +2280,7 @@ GPS.recalculateSession = async function(sessionId, opts) {
|
|||
stats.distance, stats.kcal, stats.avgSpeed, stats.maxSpeed,
|
||||
stats.elevGain, stats.elevLoss, sessPauses
|
||||
);
|
||||
onProg(100);
|
||||
|
||||
return stats;
|
||||
};
|
||||
|
|
@ -1997,7 +2317,8 @@ GPS.recomputeAllStats = async function(onProgress) {
|
|||
const bike = session.bike_id ? await DB.getBike(session.bike_id) : await DB.getActiveBike();
|
||||
const bulkPauses = Array.isArray(session.pauses) ? session.pauses
|
||||
: (typeof session.pauses === "string" ? (() => { try { return JSON.parse(session.pauses); } catch { return []; } })() : []);
|
||||
const stats = GPS.computeStats(points, weightKg, {
|
||||
const sessW = parseFloat(session.rider_weight_kg) || weightKg; // ride-time weight, else the fallback
|
||||
const stats = GPS.computeStats(points, sessW, {
|
||||
bikeWeightKg: bike?.weight_kg ?? 0,
|
||||
activityType: session.activity_type ?? "bike",
|
||||
surfaceCrr: CRR_MAP[bike?.surface_type] ?? 0.006,
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -49,7 +49,11 @@
|
|||
|
||||
function render() {
|
||||
group.clearLayers();
|
||||
if (!shown || map.getZoom() < MINZOOM || !POI._data) return;
|
||||
let z = map.getZoom();
|
||||
if (!shown || z < MINZOOM || !POI._data) return;
|
||||
// Fewer markers the further you zoom out: a wide view spans too many POIs to read (and to
|
||||
// draw). Scale the cap with zoom - a handful near MINZOOM, the full CAP once zoomed in.
|
||||
let cap = z >= 16 ? CAP : z >= 15 ? 200 : z >= 14 ? 80 : 30;
|
||||
let b = map.getBounds(), n = 0;
|
||||
for (let i = 0; i < POI._data.length; i++) {
|
||||
let p = POI._data[i];
|
||||
|
|
@ -59,7 +63,7 @@
|
|||
let c = CATS[p.c] || { l: p.c };
|
||||
m.bindPopup((p.n ? "<b>" + p.n + "</b><br>" : "") + c.l);
|
||||
group.addLayer(m);
|
||||
if (++n >= CAP) break;
|
||||
if (++n >= cap) break;
|
||||
}
|
||||
}
|
||||
map.on("moveend zoomend", render);
|
||||
|
|
|
|||
|
|
@ -127,6 +127,13 @@ TileCache.downloadArea = async function(bounds, zoomMin, zoomMax, onProgress) {
|
|||
// Custom Leaflet TileLayer that checks IndexedDB first
|
||||
TileCache.createLayer = function() {
|
||||
return L.TileLayer.extend({
|
||||
options: {
|
||||
// Keep fewer off-screen tile rings and don't reload tiles mid-zoom: on a high-DPI
|
||||
// screen the compositor's raster-tile budget (cc/tiles "tile memory limits exceeded")
|
||||
// is filled by the map tiles themselves, so trimming retained tiles keeps content drawing.
|
||||
keepBuffer: 1, // default 2
|
||||
updateWhenZooming: false, // defer tile loads until the zoom gesture settles
|
||||
},
|
||||
createTile: function(coords, done) {
|
||||
const tile = document.createElement("img");
|
||||
const key = `${coords.z}/${coords.x}/${coords.y}`;
|
||||
|
|
|
|||
2
src-tauri/Cargo.lock
generated
2
src-tauri/Cargo.lock
generated
|
|
@ -139,7 +139,7 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
|||
|
||||
[[package]]
|
||||
name = "baldav"
|
||||
version = "0.1.457"
|
||||
version = "0.1.496"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"btleplug",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "baldav"
|
||||
version = "0.1.457"
|
||||
version = "0.1.496"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
|
|
|
|||
|
|
@ -7,10 +7,15 @@ import android.app.PendingIntent
|
|||
import android.app.Service
|
||||
import android.bluetooth.*
|
||||
import android.bluetooth.le.*
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.IBinder
|
||||
import android.os.Looper
|
||||
import android.os.ParcelUuid
|
||||
import android.util.Log
|
||||
import java.io.File
|
||||
|
|
@ -31,6 +36,9 @@ class BleHrService : Service() {
|
|||
val HR_SERVICE_UUID: UUID = UUID.fromString("0000180d-0000-1000-8000-00805f9b34fb")
|
||||
val HR_MEASUREMENT_UUID: UUID = UUID.fromString("00002a37-0000-1000-8000-00805f9b34fb")
|
||||
val CCCD_UUID: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
|
||||
// Standard Battery Service (strap charge)
|
||||
val BATTERY_SERVICE: UUID = UUID.fromString("0000180f-0000-1000-8000-00805f9b34fb")
|
||||
val BATTERY_LEVEL: UUID = UUID.fromString("00002a19-0000-1000-8000-00805f9b34fb")
|
||||
|
||||
// FitPro Nordic UART
|
||||
val FITPRO_SERVICE: UUID = UUID.fromString("6e400001-b5a3-f393-e0a9-e50e24dcca9d")
|
||||
|
|
@ -51,15 +59,40 @@ class BleHrService : Service() {
|
|||
private var gatt: BluetoothGatt? = null
|
||||
private var deviceType = "hr_standard"
|
||||
private var dataFile: File? = null
|
||||
private var lastBattery: Int = -1 // strap charge %, -1 = unknown
|
||||
private var scanResultsFile: File? = null
|
||||
private var fitproWriteChar: BluetoothGattCharacteristic? = null
|
||||
private var fitproGatt: BluetoothGatt? = null
|
||||
|
||||
// One handler for all postDelayed work, so teardown can cancel every pending callback at once
|
||||
// (a stray callback touching a GATT/scanner after the adapter is off crashes the native BT stack).
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
|
||||
// When the user turns Bluetooth off, stop touching GATT immediately and shut the service down.
|
||||
private val btStateReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(ctx: Context?, intent: Intent?) {
|
||||
if (intent?.action != BluetoothAdapter.ACTION_STATE_CHANGED) return
|
||||
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")
|
||||
handler.removeCallbacksAndMessages(null)
|
||||
disconnect()
|
||||
writeData("""{"status":"bt_off","ts":${System.currentTimeMillis()/1000}}""")
|
||||
stopSelf()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun adapter(): BluetoothAdapter? =
|
||||
(getSystemService(BLUETOOTH_SERVICE) as? android.bluetooth.BluetoothManager)?.adapter
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
createNotificationChannel()
|
||||
try { registerReceiver(btStateReceiver, IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED)) }
|
||||
catch (e: Exception) { Log.e(TAG, "register BT receiver: ${e.message}") }
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
|
|
@ -72,7 +105,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 +117,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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -134,10 +167,10 @@ class BleHrService : Service() {
|
|||
}
|
||||
}
|
||||
|
||||
scanner.startScan(callback)
|
||||
try { scanner.startScan(callback) } catch (e: Exception) { Log.e(TAG, "startScan: ${e.message}"); writeScanResults("[]"); stopSelf(); return }
|
||||
// Stop after 5 seconds
|
||||
android.os.Handler(mainLooper).postDelayed({
|
||||
scanner.stopScan(callback)
|
||||
handler.postDelayed({
|
||||
try { scanner.stopScan(callback) } catch (e: Exception) { Log.e(TAG, "stopScan: ${e.message}") }
|
||||
writeScanResults("[${results.joinToString(",")}]")
|
||||
Log.d(TAG, "Scan complete: ${results.size} devices")
|
||||
// Don't stopSelf() — connect intent may follow immediately
|
||||
|
|
@ -170,8 +203,9 @@ class BleHrService : Service() {
|
|||
override fun onScanResult(callbackType: Int, result: ScanResult) {
|
||||
if (result.device.address == address) {
|
||||
Log.d(TAG, "Found target device in scan, connecting...")
|
||||
scanner.stopScan(this)
|
||||
gatt = result.device.connectGatt(this@BleHrService, false, gattCallback, BluetoothDevice.TRANSPORT_LE)
|
||||
try { scanner.stopScan(this) } catch (e: Exception) { Log.e(TAG, "stopScan: ${e.message}") }
|
||||
try { gatt = result.device.connectGatt(this@BleHrService, false, gattCallback, BluetoothDevice.TRANSPORT_LE) }
|
||||
catch (e: Exception) { Log.e(TAG, "connectGatt: ${e.message}") }
|
||||
}
|
||||
}
|
||||
override fun onScanFailed(errorCode: Int) {
|
||||
|
|
@ -179,21 +213,28 @@ class BleHrService : Service() {
|
|||
directConnect(adapter, address)
|
||||
}
|
||||
}
|
||||
scanner.startScan(scanCallback)
|
||||
try { scanner.startScan(scanCallback) } catch (e: Exception) { Log.e(TAG, "startScan: ${e.message}"); stopSelf(); return }
|
||||
// Timeout: if device not found in 5 seconds, try direct connect
|
||||
android.os.Handler(mainLooper).postDelayed({
|
||||
scanner.stopScan(scanCallback)
|
||||
handler.postDelayed({
|
||||
try { scanner.stopScan(scanCallback) } catch (e: Exception) { Log.e(TAG, "stopScan: ${e.message}") }
|
||||
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)
|
||||
}
|
||||
|
||||
private fun directConnect(adapter: BluetoothAdapter, address: String) {
|
||||
val device = adapter.getRemoteDevice(address)
|
||||
Log.d(TAG, "Direct connecting to $address ($deviceType)")
|
||||
gatt = device.connectGatt(this, false, gattCallback, BluetoothDevice.TRANSPORT_LE)
|
||||
if (!adapter.isEnabled) { Log.e(TAG, "directConnect: adapter off"); stopSelf(); return }
|
||||
try {
|
||||
val device = adapter.getRemoteDevice(address)
|
||||
Log.d(TAG, "Direct connecting to $address ($deviceType)")
|
||||
gatt = device.connectGatt(this, false, gattCallback, BluetoothDevice.TRANSPORT_LE)
|
||||
} catch (e: Exception) { Log.e(TAG, "directConnect: ${e.message}"); stopSelf() }
|
||||
}
|
||||
|
||||
private val gattCallback = object : BluetoothGattCallback() {
|
||||
|
|
@ -201,7 +242,7 @@ class BleHrService : Service() {
|
|||
if (newState == BluetoothProfile.STATE_CONNECTED) {
|
||||
Log.d(TAG, "Connected, discovering services...")
|
||||
updateNotification("Подключён, поиск сервисов...")
|
||||
g.discoverServices()
|
||||
try { g.discoverServices() } catch (e: Exception) { Log.e(TAG, "discoverServices: ${e.message}") }
|
||||
} else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
|
||||
Log.d(TAG, "Disconnected")
|
||||
isRunning = false
|
||||
|
|
@ -256,6 +297,15 @@ class BleHrService : Service() {
|
|||
override fun onCharacteristicChanged(g: BluetoothGatt, char: BluetoothGattCharacteristic) {
|
||||
onCharacteristicChanged(g, char, char.value)
|
||||
}
|
||||
// Battery level read (0x2A19). Two overloads: API 33+ passes the value, older reads char.value.
|
||||
override fun onCharacteristicRead(g: BluetoothGatt, char: BluetoothGattCharacteristic, value: ByteArray, status: Int) {
|
||||
if (char.uuid == BATTERY_LEVEL && value.isNotEmpty()) { lastBattery = value[0].toInt() and 0xFF; Log.d(TAG, "Battery: $lastBattery%") }
|
||||
}
|
||||
@Deprecated("Deprecated in API 33")
|
||||
override fun onCharacteristicRead(g: BluetoothGatt, char: BluetoothGattCharacteristic, status: Int) {
|
||||
val v = char.value
|
||||
if (char.uuid == BATTERY_LEVEL && v != null && v.isNotEmpty()) { lastBattery = v[0].toInt() and 0xFF; Log.d(TAG, "Battery: $lastBattery%") }
|
||||
}
|
||||
|
||||
override fun onDescriptorWrite(g: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int) {
|
||||
Log.d(TAG, "onDescriptorWrite status=$status")
|
||||
|
|
@ -273,13 +323,32 @@ class BleHrService : Service() {
|
|||
if (service == null) { Log.e(TAG, "HR service not found"); return }
|
||||
val char = service.getCharacteristic(HR_MEASUREMENT_UUID)
|
||||
if (char == null) { Log.e(TAG, "HR characteristic not found"); return }
|
||||
g.setCharacteristicNotification(char, true)
|
||||
val desc = char.getDescriptor(CCCD_UUID)
|
||||
if (desc != null) {
|
||||
desc.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
|
||||
g.writeDescriptor(desc)
|
||||
}
|
||||
try {
|
||||
g.setCharacteristicNotification(char, true)
|
||||
val desc = char.getDescriptor(CCCD_UUID)
|
||||
if (desc != null) {
|
||||
desc.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
|
||||
g.writeDescriptor(desc)
|
||||
}
|
||||
} catch (e: Exception) { Log.e(TAG, "HR notify setup: ${e.message}"); return }
|
||||
Log.d(TAG, "Standard HR setup complete")
|
||||
// Strap battery (0x180F/0x2A19): read once after the HR subscribe settles, then every 60s.
|
||||
val readBat = object : Runnable {
|
||||
override fun run() {
|
||||
if (!isRunning) return
|
||||
readBattery(g)
|
||||
handler.postDelayed(this, 60000)
|
||||
}
|
||||
}
|
||||
handler.postDelayed(readBat, 2500)
|
||||
}
|
||||
|
||||
private fun readBattery(g: BluetoothGatt) {
|
||||
try {
|
||||
val bs = g.getService(BATTERY_SERVICE) ?: return
|
||||
val bc = bs.getCharacteristic(BATTERY_LEVEL) ?: return
|
||||
g.readCharacteristic(bc)
|
||||
} catch (e: Exception) { Log.e(TAG, "readBattery: ${e.message}") }
|
||||
}
|
||||
|
||||
private val gattQueue = mutableListOf<() -> Unit>()
|
||||
|
|
@ -298,7 +367,7 @@ class BleHrService : Service() {
|
|||
|
||||
fun onGattOperationComplete() {
|
||||
gattBusy = false
|
||||
android.os.Handler(mainLooper).postDelayed({ processGattQueue() }, 100)
|
||||
handler.postDelayed({ processGattQueue() }, 100)
|
||||
}
|
||||
|
||||
private fun setupFitPro(g: BluetoothGatt) {
|
||||
|
|
@ -311,16 +380,18 @@ class BleHrService : Service() {
|
|||
|
||||
// Queue all operations: enable notify → PAIR → HR_SWITCH → REALTIME → MEASURE
|
||||
enqueueGatt {
|
||||
g.setCharacteristicNotification(notifyChar, true)
|
||||
val desc = notifyChar.getDescriptor(CCCD_UUID)
|
||||
if (desc != null) {
|
||||
desc.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
|
||||
g.writeDescriptor(desc)
|
||||
Log.d(TAG, "FitPro: writing CCCD descriptor")
|
||||
} else {
|
||||
Log.d(TAG, "FitPro: no CCCD, skipping")
|
||||
onGattOperationComplete()
|
||||
}
|
||||
try {
|
||||
g.setCharacteristicNotification(notifyChar, true)
|
||||
val desc = notifyChar.getDescriptor(CCCD_UUID)
|
||||
if (desc != null) {
|
||||
desc.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
|
||||
g.writeDescriptor(desc)
|
||||
Log.d(TAG, "FitPro: writing CCCD descriptor")
|
||||
} else {
|
||||
Log.d(TAG, "FitPro: no CCCD, skipping")
|
||||
onGattOperationComplete()
|
||||
}
|
||||
} catch (e: Exception) { Log.e(TAG, "FitPro notify setup: ${e.message}"); onGattOperationComplete() }
|
||||
}
|
||||
enqueueGatt { Log.d(TAG, "FitPro: PAIR"); writeCommand(g, writeChar, FITPRO_PAIR) }
|
||||
enqueueGatt { Log.d(TAG, "FitPro: HR_SWITCH"); writeCommand(g, writeChar, FITPRO_HR_SWITCH_ON) }
|
||||
|
|
@ -333,37 +404,36 @@ class BleHrService : Service() {
|
|||
// Re-trigger measurement every 25 seconds
|
||||
fitproWriteChar = writeChar
|
||||
fitproGatt = g
|
||||
android.os.Handler(mainLooper).also { h ->
|
||||
val retrigger = object : Runnable {
|
||||
override fun run() {
|
||||
if (!isRunning) return
|
||||
Log.d(TAG, "FitPro: re-trigger measurement")
|
||||
fitproGatt?.let { gg -> fitproWriteChar?.let { wc ->
|
||||
writeCommand(gg, wc, FITPRO_MEASURE_START)
|
||||
android.os.Handler(mainLooper).postDelayed({
|
||||
writeCommand(gg, wc, FITPRO_MEASURE_OLD)
|
||||
}, 300)
|
||||
}}
|
||||
h.postDelayed(this, 25000)
|
||||
}
|
||||
val retrigger = object : Runnable {
|
||||
override fun run() {
|
||||
if (!isRunning) return
|
||||
Log.d(TAG, "FitPro: re-trigger measurement")
|
||||
fitproGatt?.let { gg -> fitproWriteChar?.let { wc ->
|
||||
writeCommand(gg, wc, FITPRO_MEASURE_START)
|
||||
handler.postDelayed({
|
||||
writeCommand(gg, wc, FITPRO_MEASURE_OLD)
|
||||
}, 300)
|
||||
}}
|
||||
handler.postDelayed(this, 25000)
|
||||
}
|
||||
h.postDelayed(retrigger, 25000)
|
||||
}
|
||||
handler.postDelayed(retrigger, 25000)
|
||||
}
|
||||
|
||||
private fun writeCommand(g: BluetoothGatt, char: BluetoothGattCharacteristic, data: ByteArray) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
g.writeCharacteristic(char, data, BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE)
|
||||
// NO_RESPONSE may not trigger onCharacteristicWrite, so auto-complete after 200ms
|
||||
android.os.Handler(mainLooper).postDelayed({ onGattOperationComplete() }, 200)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
char.value = data
|
||||
char.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE
|
||||
@Suppress("DEPRECATION")
|
||||
g.writeCharacteristic(char)
|
||||
android.os.Handler(mainLooper).postDelayed({ onGattOperationComplete() }, 200)
|
||||
}
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
g.writeCharacteristic(char, data, BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
char.value = data
|
||||
char.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE
|
||||
@Suppress("DEPRECATION")
|
||||
g.writeCharacteristic(char)
|
||||
}
|
||||
} catch (e: Exception) { Log.e(TAG, "writeCommand: ${e.message}") }
|
||||
// NO_RESPONSE may not trigger onCharacteristicWrite, so auto-complete after 200ms
|
||||
handler.postDelayed({ onGattOperationComplete() }, 200)
|
||||
}
|
||||
|
||||
private fun parseStandardHr(data: ByteArray) {
|
||||
|
|
@ -378,8 +448,9 @@ class BleHrService : Service() {
|
|||
}
|
||||
if (hr <= 0) return
|
||||
val ts = System.currentTimeMillis() / 1000
|
||||
writeData("""{"hr":$hr,"ts":$ts}""")
|
||||
Log.d(TAG, "HR: $hr")
|
||||
val batJson = if (lastBattery in 0..100) ""","bat":$lastBattery""" else ""
|
||||
writeData("""{"hr":$hr,"ts":$ts$batJson}""")
|
||||
Log.d(TAG, "HR: $hr bat: $lastBattery")
|
||||
}
|
||||
|
||||
private fun parseFitProData(data: ByteArray) {
|
||||
|
|
@ -430,10 +501,13 @@ class BleHrService : Service() {
|
|||
}
|
||||
|
||||
private fun disconnect() {
|
||||
gatt?.disconnect()
|
||||
gatt?.close()
|
||||
gatt = null
|
||||
isRunning = false
|
||||
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}") }
|
||||
gatt = null
|
||||
fitproGatt = null
|
||||
fitproWriteChar = null
|
||||
}
|
||||
|
||||
private fun createNotificationChannel() {
|
||||
|
|
@ -462,6 +536,7 @@ class BleHrService : Service() {
|
|||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
try { unregisterReceiver(btStateReceiver) } catch (e: Exception) { /* not registered / already gone */ }
|
||||
disconnect()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ class MainActivity : TauriActivity() {
|
|||
private var commandObserver: FileObserver? = null
|
||||
private var pendingSaveContent: String? = null
|
||||
private var pendingImageBytes: ByteArray? = null
|
||||
private var pendingDbFile: File? = null
|
||||
private var backWebView: WebView? = null
|
||||
|
||||
// Disable WryActivity's generated back handler: its mWebView.goBack() does NOT
|
||||
|
|
@ -78,6 +79,22 @@ class MainActivity : TauriActivity() {
|
|||
pendingImageBytes = null
|
||||
}
|
||||
|
||||
// SAF "create document" for the full DB backup — streams the staged file (can be tens of MB)
|
||||
// straight to the picked location instead of loading it into memory.
|
||||
private val saveDbLauncher = registerForActivityResult(
|
||||
ActivityResultContracts.CreateDocument("application/octet-stream")
|
||||
) { uri: Uri? ->
|
||||
val src = pendingDbFile
|
||||
if (uri != null && src != null && src.exists()) {
|
||||
try {
|
||||
contentResolver.openOutputStream(uri)?.use { out -> src.inputStream().use { it.copyTo(out, 1 shl 20) } }
|
||||
Log.d("BalDav", "DB exported to $uri (${src.length()} bytes)")
|
||||
src.delete()
|
||||
} catch (e: Exception) { Log.e("BalDav", "Failed to export DB", e) }
|
||||
}
|
||||
pendingDbFile = null
|
||||
}
|
||||
|
||||
// Share a PNG via the system chooser (write to cache, FileProvider content:// URI, ACTION_SEND).
|
||||
private fun shareImage(bytes: ByteArray, filename: String) {
|
||||
try {
|
||||
|
|
@ -127,6 +144,22 @@ class MainActivity : TauriActivity() {
|
|||
val appDataDir = filesDir.parentFile ?: filesDir
|
||||
Log.d("BalDav", "App data dir: ${appDataDir.absolutePath}")
|
||||
|
||||
// Restore-on-launch: a full-DB import stages pending_restore.db; swap it onto baldav.db NOW,
|
||||
// before the app opens the DB (tauri-plugin-sql opens it later, when db.js runs). Then clear
|
||||
// any stale journal so the restored file is the single source of truth.
|
||||
try {
|
||||
val pending = File(appDataDir, "pending_restore.db")
|
||||
if (pending.exists() && pending.length() > 0) {
|
||||
val dbFile = File(appDataDir, "baldav.db")
|
||||
pending.inputStream().use { i -> dbFile.outputStream().use { o -> i.copyTo(o, 1 shl 20) } }
|
||||
File(appDataDir, "baldav.db-journal").delete()
|
||||
File(appDataDir, "baldav.db-wal").delete()
|
||||
File(appDataDir, "baldav.db-shm").delete()
|
||||
pending.delete()
|
||||
Log.d("BalDav", "Restored DB from pending_restore.db (${dbFile.length()} bytes)")
|
||||
}
|
||||
} catch (e: Exception) { Log.e("BalDav", "DB restore failed", e) }
|
||||
|
||||
// Create command files if not exist (FileObserver needs them)
|
||||
val cmdFile = File(appDataDir, "gps_command.txt")
|
||||
if (!cmdFile.exists()) cmdFile.createNewFile()
|
||||
|
|
@ -474,6 +507,26 @@ 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_location" -> {
|
||||
val lm = getSystemService(LOCATION_SERVICE) as? LocationManager
|
||||
val enabled = when {
|
||||
lm == null -> false
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.P -> lm.isLocationEnabled
|
||||
else -> lm.isProviderEnabled(LocationManager.GPS_PROVIDER) || lm.isProviderEnabled(LocationManager.NETWORK_PROVIDER)
|
||||
}
|
||||
File(appDataDir, "location_state.json").writeText("""{"enabled":$enabled}""")
|
||||
}
|
||||
cmd == "open_location_settings" -> {
|
||||
try { startActivity(Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS)) }
|
||||
catch (e: Exception) { Log.e("BalDav", "open location settings: ${e.message}") }
|
||||
}
|
||||
cmd == "check_update" -> {
|
||||
checkForUpdate(appDataDir)
|
||||
}
|
||||
|
|
@ -515,6 +568,33 @@ class MainActivity : TauriActivity() {
|
|||
}
|
||||
}
|
||||
}
|
||||
cmd.startsWith("exportdb:") -> {
|
||||
// exportdb:<filename> — SAF-save the DB copy Rust wrote to export_db.tmp.
|
||||
val filename = cmd.substringAfter("exportdb:").ifBlank { "baldav-backup.db" }
|
||||
val f = File(appDataDir, "export_db.tmp")
|
||||
if (f.exists()) { pendingDbFile = f; saveDbLauncher.launch(filename) }
|
||||
else Log.e("BalDav", "exportdb: export_db.tmp missing")
|
||||
}
|
||||
cmd == "restartapp" -> {
|
||||
// Relaunch the process so the staged DB (pending_restore.db) is swapped in onCreate.
|
||||
// startActivity()+exit() races (the new activity doesn't come up before the process
|
||||
// dies — measured: app just closed, no restart). Schedule the relaunch via AlarmManager
|
||||
// so it fires AFTER this process is gone (the ProcessPhoenix approach).
|
||||
Log.d("BalDav", "Restarting app (DB restore)")
|
||||
try {
|
||||
val intent = packageManager.getLaunchIntentForPackage(packageName)
|
||||
if (intent != null) {
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
val piFlags = android.app.PendingIntent.FLAG_CANCEL_CURRENT or
|
||||
(if (Build.VERSION.SDK_INT >= 23) android.app.PendingIntent.FLAG_IMMUTABLE else 0)
|
||||
val pi = android.app.PendingIntent.getActivity(this, 0, intent, piFlags)
|
||||
val am = getSystemService(ALARM_SERVICE) as android.app.AlarmManager
|
||||
am.set(android.app.AlarmManager.RTC, System.currentTimeMillis() + 300, pi)
|
||||
}
|
||||
} catch (e: Exception) { Log.e("BalDav", "restart schedule failed", e) }
|
||||
finishAffinity()
|
||||
Runtime.getRuntime().exit(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -25,6 +25,8 @@ pub struct HrReading {
|
|||
pub timestamp: u64,
|
||||
#[serde(default)]
|
||||
pub device: String,
|
||||
#[serde(alias = "bat", default)]
|
||||
pub battery: Option<u8>,
|
||||
}
|
||||
|
||||
// Shared state for BLE operations
|
||||
|
|
@ -438,6 +440,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,
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
use tauri_plugin_sql::{Migration, MigrationKind};
|
||||
#[allow(unused_imports)]
|
||||
use tauri::Manager;
|
||||
#[allow(unused_imports)]
|
||||
use tauri::Emitter; // app.emit(...) for progress events (Tauri v2)
|
||||
use serde::Serialize;
|
||||
|
||||
#[allow(unused, dead_code)]
|
||||
|
|
@ -226,6 +228,14 @@ fn read_points_file(app: tauri::AppHandle, filename: String) -> Result<String, S
|
|||
}
|
||||
}
|
||||
|
||||
// Read a small app state file (allowlisted) written by Kotlin, e.g. the location on/off state.
|
||||
#[tauri::command]
|
||||
fn read_state_file(app: tauri::AppHandle, name: String) -> Result<String, String> {
|
||||
if name != "location_state.json" { return Err("bad name".to_string()); }
|
||||
let p = native_root(&app).join(&name);
|
||||
if p.exists() { std::fs::read_to_string(&p).map_err(|e| e.to_string()) } else { Ok(String::new()) }
|
||||
}
|
||||
|
||||
// Download binary file from URL to local path (for tile DB import from router)
|
||||
// read_to_vec() has a 10MB limit, so use as_reader() for unlimited binary reads
|
||||
#[tauri::command]
|
||||
|
|
@ -284,8 +294,12 @@ fn rebuild_roads(app: tauri::AppHandle, min_speed_ms: f64) -> Result<String, Str
|
|||
|
||||
db.execute("BEGIN", []).map_err(|e| format!("begin: {e}"))?;
|
||||
|
||||
let total = points.len();
|
||||
let _ = app.emit("roads_progress", (0u64, total as u64)); // start
|
||||
let mut inserted = 0u32;
|
||||
for (lat, lon, atype) in &points {
|
||||
for (i, (lat, lon, atype)) in points.iter().enumerate() {
|
||||
// Progress heartbeat every ~2000 points (JS fills the Rebuild button by %).
|
||||
if i % 2000 == 0 { let _ = app.emit("roads_progress", (i as u64, total as u64)); }
|
||||
let exists: bool = db.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM road_points WHERE lat BETWEEN ?1 AND ?2 AND lon BETWEEN ?3 AND ?4)",
|
||||
rusqlite::params![lat - d_lat, lat + d_lat, lon - d_lon, lon + d_lon],
|
||||
|
|
@ -302,6 +316,7 @@ fn rebuild_roads(app: tauri::AppHandle, min_speed_ms: f64) -> Result<String, Str
|
|||
}
|
||||
|
||||
db.execute("COMMIT", []).map_err(|e| format!("commit: {e}"))?;
|
||||
let _ = app.emit("roads_progress", (total as u64, total as u64)); // done
|
||||
Ok(format!("{{\"total_points\":{},\"road_points\":{}}}", points.len(), inserted))
|
||||
}
|
||||
|
||||
|
|
@ -624,6 +639,65 @@ fn read_file_base64(path: String) -> Result<String, String> {
|
|||
Ok(base64::engine::general_purpose::STANDARD.encode(&bytes))
|
||||
}
|
||||
|
||||
// App root (holds baldav.db + point files + the command files Kotlin watches) and the DB path.
|
||||
// MUST use native_root(): app_data_dir() nesting on Android is unreliable, so any ad-hoc
|
||||
// app_data_dir().parent().parent() / db.parent() is wrong (measured: it lands in the
|
||||
// unwritable /data/user and VACUUM INTO fails). native_root() scans candidates and returns the
|
||||
// real writable app root that Kotlin's filesDir.parentFile also uses. Same as save points do.
|
||||
fn db_and_root(app: &tauri::AppHandle) -> Result<(std::path::PathBuf, std::path::PathBuf), String> {
|
||||
let root = native_root(app);
|
||||
let db = root.join("baldav.db");
|
||||
Ok((db, root))
|
||||
}
|
||||
|
||||
// Full backup: VACUUM the live DB into a clean copy in the app root, then tell Kotlin to
|
||||
// SAF-save it as `filename`. VACUUM INTO is consistent even while the app holds the DB open.
|
||||
#[tauri::command]
|
||||
fn export_db(app: tauri::AppHandle, filename: String) -> Result<String, String> {
|
||||
let (db_path, root) = db_and_root(&app)?; // root = native_root: the writable app dir Kotlin watches
|
||||
if !db_path.exists() { return Err("baldav.db не найдена".into()); }
|
||||
let out = root.join("export_db.tmp");
|
||||
let _ = std::fs::remove_file(&out);
|
||||
{
|
||||
let conn = rusqlite::Connection::open(&db_path).map_err(|e| format!("open: {e}"))?;
|
||||
conn.execute("VACUUM INTO ?1", rusqlite::params![out.to_str().unwrap_or("")]).map_err(|e| format!("vacuum: {e}"))?;
|
||||
}
|
||||
let size = std::fs::metadata(&out).map(|m| m.len()).unwrap_or(0);
|
||||
let _ = std::fs::write(root.join("gps_command.txt"), format!("exportdb:{}", filename));
|
||||
Ok(format!("{{\"size\":{}}}", size))
|
||||
}
|
||||
|
||||
// Full restore: validate the picked DB (picked_import.db) and stage it as pending_restore.db.
|
||||
// Kotlin swaps it onto baldav.db on the next launch, BEFORE the app opens the DB. Returns ok;
|
||||
// caller then restarts the app.
|
||||
#[tauri::command]
|
||||
fn import_db(app: tauri::AppHandle) -> Result<String, String> {
|
||||
let (_db, root) = db_and_root(&app)?; // root = native_root: Kotlin wrote picked_import.db here
|
||||
let picked = root.join("picked_import.db");
|
||||
if !picked.exists() { return Err("файл не выбран".into()); }
|
||||
{
|
||||
let conn = rusqlite::Connection::open(&picked).map_err(|e| format!("не открыть файл: {e}"))?;
|
||||
let ok: String = conn.query_row("PRAGMA quick_check", [], |r| r.get(0)).map_err(|e| format!("проверка: {e}"))?;
|
||||
if ok != "ok" { return Err(format!("база повреждена: {ok}")); }
|
||||
let has: i64 = conn.query_row(
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name IN ('gps_sessions','products','profile')",
|
||||
[], |r| r.get(0)).map_err(|e| format!("схема: {e}"))?;
|
||||
if has < 1 { return Err("это не похоже на базу BalDav".into()); }
|
||||
}
|
||||
let pending = root.join("pending_restore.db");
|
||||
std::fs::copy(&picked, &pending).map_err(|e| format!("подготовка: {e}"))?;
|
||||
let _ = std::fs::remove_file(&picked);
|
||||
Ok("staged".into())
|
||||
}
|
||||
|
||||
// Ask Kotlin to restart the app process (used after import_db to load the restored DB).
|
||||
#[tauri::command]
|
||||
fn restart_app(app: tauri::AppHandle) -> Result<String, String> {
|
||||
let (_db, root) = db_and_root(&app)?; // root = native_root, the dir Kotlin's FileObserver watches
|
||||
let _ = std::fs::write(root.join("gps_command.txt"), "restartapp");
|
||||
Ok("ok".into())
|
||||
}
|
||||
|
||||
// Read tiles from a SQLite file in batches for import into IndexedDB
|
||||
#[tauri::command]
|
||||
fn import_tiles(path: String, offset: u32, batch_size: u32) -> Result<String, String> {
|
||||
|
|
@ -1088,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, 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, read_state_file])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"$schema": "https://raw.githubusercontent.com/nicegui/tauri-v2/refs/heads/main/tooling/cli/schema.json",
|
||||
"productName": "BalDav",
|
||||
"version": "0.1.457",
|
||||
"version": "0.1.496",
|
||||
"identifier": "dev.baldav.app",
|
||||
"build": {
|
||||
"frontendDist": "../frontend"
|
||||
|
|
|
|||
51
tools/hr_log.py
Normal file
51
tools/hr_log.py
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Фоновый логгер ЧСС с BLE HR-датчика (стандарт 0x180D / 0x2A37, напр. Coospo H808S).
|
||||
Пишет строки 'epoch,bpm' в указанный файл, пока процесс не убьют. Авто-переподключение
|
||||
при обрыве. Спаривание не требуется.
|
||||
|
||||
Использование: python3 tools/hr_log.py <BLE-addr> <logfile>
|
||||
"""
|
||||
import asyncio, sys, time
|
||||
from bleak import BleakClient, BleakScanner
|
||||
|
||||
ADDR = sys.argv[1]
|
||||
LOG = sys.argv[2]
|
||||
HR_CHR = "00002a37-0000-1000-8000-00805f9b34fb"
|
||||
|
||||
|
||||
def make_cb(logf):
|
||||
def cb(_, data: bytearray):
|
||||
flags = data[0]
|
||||
bpm = int.from_bytes(data[1:3], "little") if flags & 1 else data[1]
|
||||
logf.write(f"{time.time():.1f},{bpm}\n")
|
||||
logf.flush()
|
||||
return cb
|
||||
|
||||
|
||||
async def main():
|
||||
logf = open(LOG, "a")
|
||||
logf.write(f"# {time.time():.1f} start addr={ADDR}\n")
|
||||
logf.flush()
|
||||
while True:
|
||||
try:
|
||||
dev = await BleakScanner.find_device_by_address(ADDR, timeout=15.0)
|
||||
if dev is None:
|
||||
logf.write(f"# {time.time():.1f} not_found, retry\n")
|
||||
logf.flush()
|
||||
await asyncio.sleep(3)
|
||||
continue
|
||||
async with BleakClient(dev) as cli:
|
||||
logf.write(f"# {time.time():.1f} connected\n")
|
||||
logf.flush()
|
||||
await cli.start_notify(HR_CHR, make_cb(logf))
|
||||
while cli.is_connected:
|
||||
await asyncio.sleep(1)
|
||||
logf.write(f"# {time.time():.1f} disconnected, reconnect\n")
|
||||
logf.flush()
|
||||
except Exception as e:
|
||||
logf.write(f"# {time.time():.1f} err {e}\n")
|
||||
logf.flush()
|
||||
await asyncio.sleep(3)
|
||||
|
||||
|
||||
asyncio.run(main())
|
||||
Loading…
Add table
Add a link
Reference in a new issue