Compare commits
13 commits
802b2cc6e9
...
c7dd588f37
| Author | SHA1 | Date | |
|---|---|---|---|
| c7dd588f37 | |||
| 76efc9238b | |||
| 1cb6efbee3 | |||
| 435a0dd27c | |||
| abc4e3cef0 | |||
| 9bc71b19c9 | |||
| ee47af4479 | |||
| 85d5e47972 | |||
| 10ff635192 | |||
| f2d39459e6 | |||
| 1d133d0067 | |||
| 3f092cced0 | |||
| d4dda3da0c |
23 changed files with 908 additions and 95 deletions
|
|
@ -22,11 +22,11 @@
|
|||
| Файл | Строк | Назначение |
|
||||
|------|-------|------------|
|
||||
| `js/main.js` | ~5900 | Экраны, навигация, модалки, графики, нутриенты, HR UI, компас (EMA + gimbal lock), ландшафт (3 режима), дороги. `appConfirm`/`appPrompt` (in-app диалоги вместо нативных, в стеке окон). Страница «Сохранённые маршруты», оверлей маршрута на записи и просмотре (`whenMapReady` — await готовности карты, не таймаут). Панель де-джиттера на детали трека (ползунки окно/итерации, живое превью, сохранить/откат, `reopenDetail` — переоткрытие детали без наложения оверлеев). `showRouteDetail` — предпросмотр маршрута (карта+профиль высоты+тап-высоты+фуллскрин), `showRouteEditor` — создание/правка маршрута (тап карты=точка в конец, тап линии=вставка в сегмент, тащить=двигать, тап точки=удалить; `makeMarker` возвращает маркер для push/splice). `appConfirm`/`appPrompt`/`appAlert` — in-app диалоги (нативных `alert/prompt/confirm` не осталось; `<select>` оставлены). `orient-dbg` (сенсор-оверлей на карте) — только при `window.__BALDAV_DEBUG`. `drawRoutePolyline`/`routeSlopeColor` (полосы уклона по 1%)/`routeElevColor` — топо/уклон-обводка в панели `routePane` (маршрут ПОД треком; на rotate-карте панель вешается внутрь `_rotatePane`, иначе маршрут не виден при записи). `fetchElevations` — цепочка GPXZ→open-elevation→open-meteo→OpenTopoData. Диагностические `console.log("[тег]...")` глушатся в release по `get_app_info().debug` (cfg!(debug_assertions)) |
|
||||
| `js/db.js` | ~1550 | SQLite через Tauri IPC. Миграции, CRUD, 41 поле добавок, water_pct, reserves, sync. Хелперы маршрутов (routes CRUD, `updateRoutePoints`/`addRoute(...,waypoints)` — плотная геометрия + `waypoints_json` {waypoints,legMode} для правки планировщиком, `getRoutes.hasEle/.wpts`). `saveSmoothedTrack` — чанковый `CASE`-апдейт lat/lon_smoothed (18 UPDATE на 4366 точек вместо 4366), `setSessionSmoothed` — откат де-джиттера. `saveGapFill`/`revertGapFill` — обратимый гэп-филл: вставка достроенных точек с `estimated=1` (seq = seq предыдущей реальной точки) / удаление их; колонки `gps_points.estimated`, `gps_sessions.gap_filled`. Nogo: таблица `nogo_areas` (lat/lon/radius_m) + `getNogoAreas`/`addNogoArea`/`deleteNogoArea`. `runMigrations` -> стартовый seq-репейр для NULL-seq сессий (merge/split создают их намеренно): батчевый `CASE` по 300 точек (как `saveGapFill`), а не UPDATE на точку - иначе ~15000 IPC на старте = ~15с и не досохранялось, гонялось каждый запуск |
|
||||
| `js/db.js` | ~1550 | SQLite через Tauri IPC. Миграции, CRUD, 41 поле добавок, water_pct, reserves, sync. Хелперы маршрутов (routes CRUD, `updateRoutePoints`/`addRoute(...,waypoints)` — плотная геометрия + `waypoints_json` {waypoints,legMode} для правки планировщиком, `getRoutes.hasEle/.wpts`). `saveSmoothedTrack` — чанковый `CASE`-апдейт lat/lon_smoothed (18 UPDATE на 4366 точек вместо 4366), `setSessionSmoothed` — откат де-джиттера. `saveGapFill`/`revertGapFill` — обратимый гэп-филл: вставка достроенных точек с `estimated=1` (seq = seq предыдущей реальной точки) / удаление их; колонки `gps_points.estimated`, `gps_sessions.gap_filled`. Nogo: таблица `nogo_areas` (lat/lon/radius_m) + `getNogoAreas`/`addNogoArea`/`deleteNogoArea`. `unmergeGpsSession`/`countMergedOrigins` — разделение склейки обратно по `orig_session_id` (парно к `mergeGpsSessions`): пересоздаёт сессии по исходным id, ретег точек, выставляет дату/время каждой из её точек; кнопка "Разделить склейку" в деталях трека (видна только если склейка). `runMigrations` -> стартовый seq-репейр для NULL-seq сессий (merge/split создают их намеренно): батчевый `CASE` по 300 точек (как `saveGapFill`), а не UPDATE на точку - иначе ~15000 IPC на старте = ~15с и не досохранялось, гонялось каждый запуск |
|
||||
| `js/gps.js` | ~1650 | GPS запись, ккал (нетто-модель + MET-нетто floor, накат=базовый), altitude filter + гистерезис высоты, BLE HR polling, dead reckoning. Источник истины БД при живой записи — нативный файл (`_processIncomingPoint(p, fromNative)`: watchPosition только рисует, поллинг пишет). `recoverFromFiles` — восстановление точек с устройства (матч по id/дате + `_s<id>` в имени, дедуп ±2.5с, `recalculateSession(noDelete)`, чистка импортированных файлов). `recomputeAllStats` — пересчёт статы всех треков без изменения точек. `filterByAccuracy` — отбор по точности (minAccuracyM) внутри `computeStats`+`filterNoise`: неточные фиксы исключаются из расчёта/линии (обратимо, non-destructive; null/≥999 keep), bearing_deg. `smoothTrackSG(pts,W,it)` — де-джиттер Савицкого-Голея (квадратичный МНК по окну, итеративно, концы закреплены; lat/lon→метры→обратно, все поля точки сохраняются). `parseRouteGPX(xml)` — парсер маршрутов (`rtept`/`trkpt`/`wpt`, читает `<ele>`, DOMParser). `routeElevStats(pts)` — профиль маршрута (накопл. дистанция, грейд к след. точке, набор/сброс с гистерезисом, макс-уклон; высоты сглаживаются). `fillGaps(points)` → `{points, gaps}` — дорожная достройка GPS-дропаутов (BRouter shortest) с интерполяцией ts и флагом `estimated`; `gaps[].status` = filled/skipped-long/skipped-noroute для UI-предпросмотра. `config.gpxzKey` — ключ GPXZ (localStorage) |
|
||||
| `js/sync.js` | ~300 | Синхронизация между устройствами (LAN, PHP сервер на роутере) |
|
||||
| `js/chzn.js` | ~330 | Честный знак (CRPT). Скан DataMatrix → карточка Нацкаталога → префилл КБЖУ. `ChZ`: сессия в localStorage (`chzn_session`), `importFromText`, `refresh`/`ensureSid` (sid 48ч, refreshToken ~год), `authedPost/Get` (userId+sid в query, авто-refresh на 401), `check`, `nutritionInfo` (витамины, часто 204), `normalizeCode` (реконструкция GS 0x1D перед AI 93), `parseCard` (группа «Энергетическая ценность…», имена атрибутов разнятся у производителей), `resolve`. `/authorize/anonymous` закрыт Play Integrity — свою сессию не завести, только импорт из приложения (`tools/chzn_export.sh`) |
|
||||
| `js/tilecache.js` | 178 | Оффлайн кэш тайлов карты (IndexedDB) |
|
||||
| `js/tilecache.js` | ~300 | Оффлайн кэш тайлов карты (IndexedDB). `createLayer` — Leaflet-слой, тянет из IndexedDB, при промахе (если не offline) фетчит с OSM и кэширует; НЕ ретраит упавший тайл (Leaflet метит его loaded-with-error). `startHealer/stopHealer/probeOsm` — онлайн-долечивание: раз в 30с, если OSM доступен, дофетчивает НЕДОСТАЮЩИЕ тайлы (текущий зум) по видимой области и вдоль проеханного маршрута (rotating cursor, трот 300мс, ≤40 фетчей/цикл — бережно к OSM), кэширует и `layer.redraw()`. Гейтится offline-режимом. Запускается на картах записи/детали/сравнения/дорог, глушится в `renderScreen` |
|
||||
| `js/router.js` | ~80 | Оффлайн-роутинг BRouter. `BRouter.route(points,[profile])`: Android → встроенный движок (`invoke("brouter_route")`), иначе/при ошибке → PC-RouteServer по HTTP (`http_request`, мимо CSP; LAN 192.168.1.18:17777 / WAN mirror.invalid:17777). Профили `trekking` (планирование) / `shortest` (гэпы). `BRouter.route(points, profile, nogos)` — nogos `[{lat,lon,radius}]` → `&nogos=lon,lat,r` в RouteServer и в запрос встроенного движка (Kotlin `rc.nogopoints`). Докачка сегментов `.rd5`: `downloadTile/downloadStatus/listTiles/downloadTileAndWait` |
|
||||
| `js/poi.js` | ~200 | POI-оверлей на картах: круглые контрастные бейджи (эмодзи в круге, не пины), фильтр категорий (`POI.attach(map)`, данные `data/pois.json`, localStorage `baldav_poi_cats/shown`) |
|
||||
| `js/bmr.js` | ~70 | BMR (Mifflin-St Jeor), РСП из БД (33 нутриента), полураспад |
|
||||
|
|
@ -42,7 +42,7 @@
|
|||
| Файл | Строк | Назначение |
|
||||
|------|-------|------------|
|
||||
| `src/lib.rs` | ~790 | Миграции v1, IPC команды, seed import, update check, tile import, road map rebuild. `native_root()` — выбор корня приложения по наличию `gps_live_points.jsonl`/`gps_points_*` (на этом Tauri `app_data_dir()` = корень напрямую; старый `.parent().parent()` читал мимо, отсюда потеря фоновых точек). `http_request` (GET/POST, тело) и `chzn_http` (браузерный User-Agent + возврат `{status,body}` для 204/401/427, `http_status_as_error(false)`) — прокси HTTP мимо CORS WebView |
|
||||
| `src/ble.rs` | ~470 | BLE HR: btleplug (desktop), file IPC (Android), FitPro + Standard HR |
|
||||
| `src/ble.rs` | ~480 | BLE HR: btleplug (desktop), file IPC (Android), FitPro + Standard HR. `ble_read_hr_log` — читает нативный лог ЧСС `ble_hr_log.jsonl` (пишет foreground-сервис, переживает гаснущий экран); `ble_clear_hr` чистит и его. `ble_read_strap_uptime`/`ble_reset_strap_uptime` — наработка ремня (время реальной отдачи ЧСС + шаги заряда, персистентно в `strap_uptime.json`, для расчёта ресурса батареи самими) |
|
||||
| `src/main.rs` | 5 | Точка входа |
|
||||
| `Cargo.toml` | — | tauri, tauri-plugin-sql, rusqlite, serde, btleplug, tokio, uuid |
|
||||
| `tauri.conf.json` | — | Конфиг приложения, плагины, ресурсы |
|
||||
|
|
@ -53,7 +53,7 @@
|
|||
|------|-------|------------|
|
||||
| `MainActivity.kt` | ~300 | FileObserver IPC, seed DB, safe insets, SAF, BLE commands, OTA check |
|
||||
| `GpsRecorderService.kt` | ~530 | Foreground service GPS, WakeLock, JSONL (один файл на сессию до Стоп, идемпотентный onStartCommand при START_STICKY-перезапуске ОС; `bindSessionIfPending()` дописывает id сессии в имя файла `_s<id>` по `gps_session.txt`), 50Hz акселерометр/гироскоп, dead reckoning, raw sensor files |
|
||||
| `BleHrService.kt` | ~390 | Foreground service BLE HR, FitPro protocol, GATT queue, scan/connect. START_NOT_STICKY; скан не нашёл адрес -> stopSelf (не держит радио). Подключение инициируется ТОЛЬКО вручную (кнопка в настройках), не при старте записи. Выключение BT в любой момент не роняет app: единый `handler` (все postDelayed, снимаются разом), `BroadcastReceiver` на `ACTION_STATE_CHANGED` -> на STATE_OFF чистая остановка (disconnect+stopSelf), все нативные BLE-вызовы (scan/stopScan/connectGatt/writeCharacteristic/discoverServices) в try/catch |
|
||||
| `BleHrService.kt` | ~390 | Foreground service BLE HR, FitPro protocol, GATT queue, scan/connect. START_NOT_STICKY; скан не нашёл адрес -> stopSelf (не держит радио). Подключение инициируется ТОЛЬКО вручную (кнопка в настройках), не при старте записи. Выключение BT в любой момент не роняет app: единый `handler` (все postDelayed, снимаются разом), `BroadcastReceiver` на `ACTION_STATE_CHANGED` -> на STATE_OFF чистая остановка (disconnect+stopSelf), все нативные BLE-вызовы (scan/stopScan/connectGatt/writeCharacteristic/discoverServices) в try/catch. Каждый замер ЧСС дописывается в `ble_hr_log.jsonl` (append) прямо в сервисе — запись истории переживает гаснущий экран (JS-поллинг в фоне заморожен Android); воркаут при остановке пересобирает сэмплы из этого лога (`GPS._readHrLogSamples`), а не из JS-массива. Авто-реконнект: обрыв УЖЕ установленного соединения (connectedOnce) переподключается через скан (до 3 попыток, 8с), сброс бюджета при успехе; если скан не нашёл ремень или это stopping (ACTION_STOP/BT-off/onDestroy) - stopSelf, не долбится в отсутствующий/не запущенный вручную ремень. Наработка ремня: `accrueUptime`/`writeUptimeFile` копят время реальной отдачи ЧСС (только промежутки без разрывов) + шаги заряда в `strap_uptime.json` (персистентно, не чистится per-session, flush ~20с и при обрыве) |
|
||||
| `generated/*.kt` | ~1100 | Tauri-сгенерированные: WebView, IPC, permissions |
|
||||
|
||||
### Инструменты (`tools/`)
|
||||
|
|
@ -81,7 +81,7 @@
|
|||
|
||||
| Файл | Назначение |
|
||||
|------|------------|
|
||||
| `build.sh` | Сборка: android/debug/release, sync frontend, версионирование, -u upload на VPS, `clean`/авто-чистка старых бандлов (`clean_bundles`) |
|
||||
| `build.sh` | Сборка: android/debug/release, ПК linux/windows/macos (кросс), sync frontend, версионирование (один бамп за запуск), любые комбинации целей и флагов, `-u` выкладка собранного на роутер (`upload_router`, `.build` на файл) + APK на VPS (`upload_vps`), `clean`/авто-чистка старых бандлов (`clean_bundles`) |
|
||||
| `BUILD_NUMBER` | Автоинкремент номера сборки |
|
||||
| `ion_products.json` | 1111 продуктов ION (сырые данные скрейпера) |
|
||||
| `5ka_products_fresh.json` | 3103 продукта 5ka (сырые данные) |
|
||||
|
|
@ -112,7 +112,7 @@
|
|||
| `activity_log` | Лог активности | date, type, minutes, kcal, activity_type (squats/jumprope/stretch/swim/manual), sets, reps, distance_m |
|
||||
| `supplements` | Добавки (БАД) | name, daily_dose_g, витамины/минералы |
|
||||
| `supplement_log` | Приём добавок | date, supplement_id, dose_g |
|
||||
| `gps_sessions` | GPS тренировки | date, activity_type, distance, kcal, speed, elevation, bike_id, smoothed, smooth_window, smooth_iters (де-джиттер), pauses (JSON ручных пауз), wind_marks (JSON меток «ветер в лицо») |
|
||||
| `gps_sessions` | GPS тренировки | date, activity_type (bike/walk/run/workout/vr), distance, kcal, speed, elevation, bike_id, smoothed, smooth_window, smooth_iters (де-джиттер), pauses (JSON ручных пауз), wind_marks (JSON меток «ветер в лицо»), hr_samples (ряд ЧСС для workout/vr) |
|
||||
| `gps_points` | Точки трека | session_id, ts, lat, lon, altitude_gps_m, altitude_srtm_m, speed_ms, seq, bearing_deg, accuracy_m, lat_smoothed, lon_smoothed (обратимый де-джиттер), heart_rate, spo2, bp_sys, bp_dia, accel_rms/max, gyro_pitch/yaw |
|
||||
| `routes` | Сохранённые маршруты (оверлей навигации) | name, color, points_json (`{lat,lon,ele?}`), created_at |
|
||||
| `bikes` | Велосипеды | name, weight_kg, wheel, звёзды, surface_type, is_active |
|
||||
|
|
@ -120,7 +120,7 @@
|
|||
| `rdi_values` | Нормы нутриентов | standard_id, nutrient_key, value, norm_type (rdi/aup) |
|
||||
| `nutrient_halflife` | Полураспад нутриентов | nutrient_key, halflife_avg_days |
|
||||
| `nutrient_reserves` | Запасы нутриентов по дням | date, reserves (JSON) |
|
||||
| `road_points` | Дедуплицированные точки всех треков | lat, lon, atype (bike/walk) |
|
||||
| `road_points` | Дедуплицированные точки всех треков | lat, lon, atype (bike/walk), passes (число проездов ячейки, для толщины линии) |
|
||||
|
||||
### Seed DB (products_seed.db)
|
||||
|
||||
|
|
@ -185,13 +185,13 @@
|
|||
| `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') |
|
||||
| `rebuild_roads` | Перестройка road_points из всех треков (2м дедуп в памяти по сетке; пеший point (`walk=1`) → atype 'walk'; считает passes = входы в ячейку = число проездов) |
|
||||
| `get_road_points` | Чтение road_points батчами (обход лимита SQL plugin) |
|
||||
| `get_ride_graph` | Граф проезженных дорог для роутинга по своим трекам: узлы на сетке ~20м (исключая `walk=1`), рёбра между последовательными точками сессии (вес = между узлами). `{nodes,edges}` |
|
||||
|
||||
## Формула ккал (GPS)
|
||||
|
||||
### Велосипед — НЕТТО-модель (суточный баланс = intake − BMR*activityLevel − activity, поэтому активность считается СВЕРХ покоя)
|
||||
### Велосипед — НЕТТО-модель (суточный баланс = intake − BMR*activityLevel − activity, поэтому активность считается СВЕРХ покоя; с опциональным TEF: база = BMR*activityLevel*(1−tefFrac), затем +tefFrac*intake, tefFrac=0.10 — сохраняет поддержку, масштабирует переваривание от реальной еды)
|
||||
```
|
||||
Power = v * (m*g*sin(gradient) + Crr*m*g*cos(gradient) + 0.5*CdA*rho*v^2)
|
||||
m = rider_kg + bike_kg; gradient из СГЛАЖЕННОЙ высоты
|
||||
|
|
@ -261,7 +261,12 @@ kcal = (dt/60) * (MET * 3.5 * weight_kg) / 200
|
|||
./build.sh -u -d android # debug + install + заливка на роутер/VPS (OTA)
|
||||
./build.sh install -d # поставить уже собранный APK без пересборки
|
||||
./build.sh clean # убрать старые AAB-бандлы и APK-выхлопы gen/ (Rust-кэш и binaries/ не трогает)
|
||||
./build.sh linux|windows|macos # ПК под одну ОС (windows - mingw, macos - osxcross, оба кросс)
|
||||
./build.sh pc | all # все ПК | android + все ПК
|
||||
./build.sh -da upload # = -u all: debug APK + release ПК, один номер, -> keenetic:/opt/share/www/baldav
|
||||
```
|
||||
Цели и флаги (`-d`, `-da`, `-i`, `-u`) комбинируются в любом виде, номер один на запуск. `BUILD_NUMBER`
|
||||
на роутере пишется только вместе с APK (по нему OTA телефона); у каждого файла есть свой `<файл>.build`.
|
||||
Без таргета `./build.sh` = dev-сервер (десктоп), НЕ сборка APK.
|
||||
`clean_bundles` также вызывается автоматически в конце каждой android-сборки (после копии APK в binaries/): удаляет `gen/.../intermediary_bundle` + `outputs/bundle` (AAB, мы раздаём APK) и `outputs/apk/*.apk`. Регенерируются Gradle при следующей сборке.
|
||||
|
||||
|
|
|
|||
26
README.md
26
README.md
|
|
@ -22,10 +22,10 @@
|
|||
## Возможности
|
||||
|
||||
- **Дневник питания** — ~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).
|
||||
- **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). Онлайн-долечивание пустых тайлов: раз в 30с, когда OSM доступен, БалДав дофетчивает недостающие тайлы (текущий зум) по видимой области и вдоль проеханного маршрута, кэширует и перерисовывает - дырки, оставшиеся от плохого сигнала в поездке, закрываются сами, когда связь есть (бережно к OSM: троттлинг, лимит на цикл; в режиме «офлайн карты» не фетчит).
|
||||
- **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 для домашней тренировки: геолокация не включается и не пишется, только пульс с датчика + таймер. Экран без карты (крупный пульс, время, живые ккал, средний/макс). Нагрузка (ккал) считается по пульсу формулой Keytel (нетто, как и остальная активность) и идёт в дневной баланс. Деталь - без карты, график пульса по HR-зонам. Хранится в `gps_sessions` (`activity_type="workout"`, без точек, ряд пульса в `hr_samples`). Режим **«Руки/VR (пульс)»** (`activity_type="vr"`) - то же самое для махательных/танцевальных/VR-тренировок, трекается отдельно от зарядки.
|
||||
- **Расчёт ккал** — велосипед: НЕТТО-модель (суточный баланс уже учитывает 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) считаются от **выбранного дня** (окно кончается на нём), а не всегда от «сегодня». Опциональный **TEF** (термический эффект пищи, галочка в профиле, 10% прихода): вводится с сохранением поддержки - база = BMR*actLevel*(1-0.1), затем +0.1*приход, так что на поддержке баланс не меняется, а в дефиците расход чуть ниже (ел меньше - переварил меньше). В сводке дня строка «расход: база + переваривание + активность».
|
||||
- **Дистанция на разрывах GPS** — на «слепых» участках (интервал между фиксами > `gapThresholdSec`) путь считается по скорости (speed×dt, с ограничением по макс. скорости), а не прямой хордой — иначе разрывы занижают/искажают длину. Телепорт-скачки без временного разрыва отбрасываются. Расчёт зависит только от времени и скорости, поэтому live-счётчик, «Стоп» и пересчёт дают одинаковую длину. Порог `gapThresholdSec` настраивается; при изменении и пересчёте применяется сразу.
|
||||
- **Тренировки** — приседания (подходы x повторения), скакалка, растяжки, бассейн (дистанция). Автоматический расчёт ккал по MET и весу.
|
||||
- **Нутриенты** — 37 микронутриентов (витамины, минералы, жирные кислоты, женьшень, ресвератрол, CoQ10). Нормы РСП/АУП (РФ/ЕС/США). Фармакокинетика — запасы в организме по полураспаду.
|
||||
|
|
@ -50,7 +50,7 @@
|
|||
- **Сегменты - сворачиваемо** - в детали трека список сегментов высот (climb/descent, до десятков штук) свёрнут в аккордеон (`<details>`, свёрнут по умолчанию, счётчик в заголовке); тап разворачивает/сворачивает. Кнопки внизу (Похвастаться, Экспорт, Пересчитать, Удалить) остаются доступны без долгого скролла.
|
||||
- **Де-джиттер трека** — сглаживание дрожащего GPS-трека методом Савицкого-Голея (настраиваемое окно/число итераций, живое превью на карте детали трека). Хранится обратимо: оригинал и сглаженный вариант лежат раздельно, калории пересчитываются по сглаженным точкам, откат к оригиналу в один тап. Точность каждого фикса (`accuracy_m`) пишется в БД: при пересчёте трека точки хуже порога «Макс. погрешность GPS» исключаются из расчёта и линии (обратимо — точки в БД остаются; поднять порог = вернуть). Работает во всех пересчётах (кнопка «Пересчитать», «Пересчитать всё», после де-джиттера); импорт/старьё без точности и dead-reckoning заполнения не трогает.
|
||||
- **POI на карте** — точки интереса (кафе, магазины, вода, туалеты, банки/ATM, остановки, укрытия, смотровые и т.д.) круглыми контрастными значками (эмодзи в кружке, читаемо на светлой и тёмной карте). Кнопка `📍` с панелью-фильтром категорий; рендер по видимой области от зума 13. Данные оффлайн (`data/pois.json`, ~100 км вокруг опорной точки, 5662 объекта из OSM/Overpass). На картах записи, деталей трека и дорог.
|
||||
- **Тёмная карта** — тумблер `☾` на экранах записи, деталей трека и дорог: инвертирует ТОЛЬКО слой тайлов (`invert + hue-rotate 180° + brightness`, трек/маршрут/дороги остаются цветными), выбор запоминается. Работает одинаково на офлайн-тайлах и онлайне, без ключей/квот/перепаковки.
|
||||
- **Тёмная карта** — тумблер `☾` на экранах записи, деталей трека и дорог: инвертирует слой тайлов (`invert + hue-rotate 180° + brightness`, трек/маршрут остаются цветными), выбор запоминается. Оверлей дорог получает ТОТ ЖЕ фильтр (пан `roadsPane`), чтобы его цвета сдвигались вместе с тайлами и оставались контрастными на тёмной карте. Работает одинаково на офлайн-тайлах и онлайне, без ключей/квот/перепаковки.
|
||||
- **Метки ветра** — кнопка «Ветер» на экране записи: тап открывает интервал встречного ветра, тап закрывает. Метки хранятся с треком (`wind_marks`) как эталон для проверки оценки ветра по треку; на расчёт калорий/мощности не влияют. Голосовые метки — на будущее.
|
||||
- **Слежение карты (тумблер `⊕`)** — на экране записи карта по умолчанию центрируется на текущей позиции при каждой новой точке. Кнопка `⊕` (подсвечена = слежу) выключает автоцентрирование: карту можно свободно подвигать и осмотреться, когда «где я» непонятно (отклонился от трека или трек ведёт туда, где дороги нет), а новые точки её не возвращают. Повторный тап центрирует на текущей позиции и включает слежение снова. По умолчанию включено при каждом заходе (не запоминается).
|
||||
- **Экспорт/импорт** — свои продукты, рецепты и добавки одним файлом (JSON).
|
||||
|
|
@ -72,8 +72,26 @@
|
|||
./build.sh -i android -d # debug + установка на устройство
|
||||
./build.sh -u android # release + upload на роутер (LAN) и VPS (OTA)
|
||||
./build.sh -d -i -u android # debug + install + upload
|
||||
./build.sh linux # ПК Linux: .deb, .rpm, .AppImage
|
||||
./build.sh windows # ПК Windows: NSIS-установщик (кросс, mingw)
|
||||
./build.sh macos # ПК macOS: .app в zip, Intel + Apple Silicon (кросс, osxcross)
|
||||
./build.sh pc # linux + windows + macos
|
||||
./build.sh all # android + pc
|
||||
./build.sh -da upload # = -u all, APK debug (как на телефоне), ПК release -> роутер
|
||||
./build.sh -u android windows # любая комбинация целей и флагов
|
||||
```
|
||||
|
||||
Цели и флаги комбинируются как угодно; за запуск номер сборки растёт один раз и общий для всех
|
||||
целей. `-d` - debug для всего, `-da` - debug только для APK. `-u` выкладывает в
|
||||
`http://192.168.1.1:88/baldav/` всё, что собралось в этом запуске: `baldav.apk`,
|
||||
`baldav-linux-amd64.deb`, `baldav-linux-x86_64.rpm`, `baldav-linux-x86_64.AppImage`,
|
||||
`baldav-windows-x64-setup.exe`, `baldav-macos-x64.zip`, `baldav-macos-arm64.zip`, рядом с каждым
|
||||
`<файл>.build` с номером сборки. `BUILD_NUMBER` (по нему телефон проверяет OTA) пишется только
|
||||
вместе с APK, иначе телефону предложилось бы «обновление» на старый APK; APK, как и раньше, уходит
|
||||
ещё и на VPS. Упавшие цели не мешают выкладке остальных и перечисляются в итоге. Кросс-сборка под
|
||||
macOS берёт osxcross из `$OSXCROSS` (по умолчанию `/mnt/02F6E7F6F6E7E7BD/osxcross`, SDK 12.3 из
|
||||
fpcupdeluxe); `.app` собирается скриптом, т.к. бандлер Tauri на Linux его пропускает.
|
||||
|
||||
## Версия и трассируемость сборки
|
||||
|
||||
Чтобы по розданному APK однозначно находить, где регресс/косяк:
|
||||
|
|
|
|||
26
docs/plans/2026-10-19-router-upload-all-os.md
Normal file
26
docs/plans/2026-10-19-router-upload-all-os.md
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
# БалДав: выкатка сборок под все ОС на роутер (по образцу nlve/build.sh) - выполнено
|
||||
|
||||
## Контекст
|
||||
`./build.sh android -u` выкладывал только APK. Нужно: Android + ПК (Linux, Windows, macOS Intel и
|
||||
Apple Silicon) в любой комбинации целей и флагов, под одним новым номером за запуск, с выкладкой
|
||||
собранного на роутер `keenetic:/opt/share/www/baldav/`.
|
||||
|
||||
## Решения
|
||||
1. Глобальная память (`~/.claude/CLAUDE.md`, раздел «macOS Cross Toolchain»): osxcross в
|
||||
`/mnt/02F6E7F6F6E7E7BD/osxcross/`, SDK в `/home/chaiman/fpcupdeluxe/cross/lib/all-darwin/`.
|
||||
2. osxcross достроен: SDK 12.3 упакован в `tarballs/`, сборка
|
||||
`CC="clang --gcc-install-dir=/usr/lib/gcc/x86_64-linux-gnu/13" CXX="clang++ --gcc-install-dir=..." UNATTENDED=1 ./build.sh`
|
||||
(у clang выбирается GCC 14 без libstdc++-14-dev). `rustup target add x86_64-apple-darwin aarch64-apple-darwin`.
|
||||
3. `build.sh`:
|
||||
- `update_version` раз за запуск; цели `android`, `linux|desktop`, `windows`, `macos`, `pc`, `all`,
|
||||
`upload` (= `-u all`) в любой комбинации; флаги `-d` (debug всё), `-da` (debug только APK), `-i`, `-u`.
|
||||
- Windows: `--target x86_64-pc-windows-gnu --config '{"bundle":{"targets":["nsis"]}}'`
|
||||
(`--bundles` на Linux принимает только deb/rpm/appimage); нужен `icons/icon.ico`.
|
||||
- macOS: `--no-bundle` + `.app` собирается скриптом (бандлер Tauri на Linux пропускает `app`), zip.
|
||||
- Выкладка: файлы под фиксированными именами + `<файл>.build`; `BUILD_NUMBER` только вместе с APK
|
||||
(OTA телефона); APK ещё и на VPS; упавшие цели не блокируют остальные.
|
||||
4. Документация: README, PROJECT_MAP, лог сессии.
|
||||
|
||||
## Проверка
|
||||
`./build.sh -da upload` -> сборка 506, 7 файлов на роутере, HTTP 200, номера сходятся.
|
||||
Запуск на Windows/macOS не проверялся.
|
||||
25
docs/session-logs/2026-10-19_dark-roads-and-tile-diag.md
Normal file
25
docs/session-logs/2026-10-19_dark-roads-and-tile-diag.md
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
# Сессия 2026-10-19 - тёмные дороги на тёмной карте + диагностика незагруженных тайлов
|
||||
|
||||
## Задача 1: дороги не видны на тёмной карте
|
||||
Тёмный фильтр (`invert(1) hue-rotate(180deg) brightness(1.5)`) висел только на
|
||||
`.leaflet-tile-pane`; цвета дорог (под светлые тайлы) на инвертированных тайлах сливались.
|
||||
|
||||
Сделано (тот же фильтр на пан дорог, авто по классу `body.dark-map`, без перерисовки):
|
||||
- styles.css: добавлено `body.dark-map .leaflet-roadsPane { filter: invert(1) hue-rotate(180deg) brightness(1.5); }`.
|
||||
- Экран записи: дороги уже в `roadsPane` (SVG) - правок не нужно, правило применилось само.
|
||||
- Экран "Дороги" (`showRoadsMap`, canvas): добавлен пан `roadsPane` (zIndex 240) и
|
||||
`L.canvas({pane:"roadsPane"})`-рендер, полилинии дорог рисуются через него -> тот же фильтр
|
||||
накрывает и этот экран.
|
||||
- Трек/маршрут/POI не трогали (пользователь просил дороги).
|
||||
|
||||
Проверка: синтаксис JS ок. На телефоне: тумблер Луна на записи и на экране "Дороги" - дороги
|
||||
контрастны и на тёмной, и на светлой; трек/маршрут не инвертированы. (Живую проверку делает
|
||||
пользователь.)
|
||||
|
||||
## Задача 2: незагруженные тайлы на 90-км треке (только диагностика, без правок)
|
||||
Факты от пользователя: офлайн-карты ВЫКЛ, мобильный интернет в поездке БЫЛ. Значит код
|
||||
(`tilecache.js` createTile) должен был тянуть тайлы с OSM - пустота при живой сети это
|
||||
аномалия, нужны факты. bbox трека: lat 47.47-47.57, lon 40.08-40.48 (Ростовская обл.).
|
||||
План диагностики (read-only): CDP в живой WebView (localStorage.baldav_offline_maps,
|
||||
TileCache.count(), перебор IndexedDB baldav_tiles по bbox на зумах 13-16) + logcat при
|
||||
открытии трека (идут ли фетчи на a.tile.openstreetmap.org и статус). Результат - отдельно.
|
||||
47
docs/session-logs/2026-10-19_router-upload-all-os.md
Normal file
47
docs/session-logs/2026-10-19_router-upload-all-os.md
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
# 2026-10-19 - выкатка на роутер сборок под все ОС
|
||||
|
||||
## Контекст
|
||||
По образцу `upload` из соседнего проекта nlve (`build.sh`): одной командой собрать Android + ПК
|
||||
(Linux, Windows, macOS Intel/Apple Silicon) под одним номером и выложить на роутер.
|
||||
Работали по утверждённому плану: `docs/plans/2026-10-19-router-upload-all-os.md`.
|
||||
|
||||
## Действия
|
||||
- `build.sh`: `update_version` срабатывает раз за запуск; цели `linux`/`desktop`, `windows`
|
||||
(`x86_64-pc-windows-gnu`, NSIS), `macos` (osxcross, `--bundles app`, zip), `release`, `upload`;
|
||||
`upload_router` (ssh по ключу, файлы первыми, `BUILD_NUMBER` последним), итог `finish` со списком упавших.
|
||||
Выкладываются только файлы, собранные в этом запуске. `-u` у `android` не менялся (APK на роутер+VPS).
|
||||
- osxcross в `/mnt/02F6E7F6F6E7E7BD/osxcross` не был собран (не было `target/` и SDK в `tarballs/`).
|
||||
SDK 12.3 упакован из fpcupdeluxe (`cross/lib/all-darwin/MacOSX12.3.sdk`) в `tarballs/`.
|
||||
Сборка osxcross с clang++ падала: clang выбирает GCC 14, а `libstdc++-14-dev` не установлен ->
|
||||
собран с `CC=gcc CXX=g++`.
|
||||
- `rustup target add x86_64-apple-darwin aarch64-apple-darwin`.
|
||||
- AppImage, которого не было после сборки 0.1.499, при повторной упаковке собрался; причина
|
||||
прошлого отсутствия не выяснена.
|
||||
|
||||
- Сборка osxcross с clang заработала с `--gcc-install-dir=/usr/lib/gcc/x86_64-linux-gnu/13`
|
||||
(с gcc падает cctools-port: «must compile with clang»).
|
||||
- Tauri CLI на Linux не принимает `--bundles nsis/app` -> Windows через `--config '{"bundle":{"targets":["nsis"]}}'`;
|
||||
macOS-бандл бандлер на Linux пропускает -> `--no-bundle` + `.app` собирается скриптом (Info.plist,
|
||||
`Resources/data/products_seed.db`, `icon.icns` из `icon.png` записью ic07, NSBluetoothAlwaysUsageDescription).
|
||||
- Для Windows нужен `src-tauri/icons/icon.ico` - сделан ImageMagick из `icon.png`.
|
||||
- По запросу пользователя: любая комбинация целей и флагов за один запуск, номер растёт раз;
|
||||
`-da` - debug только APK (на телефоне и на роутере debug-подписанный APK);
|
||||
`BUILD_NUMBER` на роутере только вместе с APK (OTA телефона читает его, `MainActivity.kt:801-806`),
|
||||
у каждого файла `<файл>.build`; что собралось - выкладывается, упавшее в итоге.
|
||||
|
||||
## Результат
|
||||
`./build.sh -da upload`: сборка 506, выложено на роутер и APK на VPS:
|
||||
baldav.apk 181.5 МБ, baldav-linux-amd64.deb 9.9, baldav-linux-x86_64.rpm 9.9,
|
||||
baldav-linux-x86_64.AppImage 82.2, baldav-windows-x64-setup.exe 9.7, baldav-macos-x64.zip 9.3,
|
||||
baldav-macos-arm64.zip 9.1.
|
||||
|
||||
## Проверка
|
||||
- Роутер: все 7 файлов отдаются по HTTP (200), BUILD_NUMBER = 506, все `.build` = 506.
|
||||
- `file`: PE (NSIS) с baldav.exe + WebView2Loader.dll + data/products_seed.db внутри;
|
||||
Mach-O x86_64 (min 10.13) и arm64 (min 11.0, есть LC_CODE_SIGNATURE ad-hoc).
|
||||
- Запуск на Windows и macOS не проверялся: машин с этими ОС здесь нет.
|
||||
|
||||
## Следующие шаги
|
||||
- Проверить установщик на Windows и .app на macOS (Intel и Apple M). Сборки не подписаны:
|
||||
на macOS первый запуск через ПКМ -> «Открыть».
|
||||
- `build.sh` в `.gitignore` - в репозиторий не попадает.
|
||||
27
docs/session-logs/2026-10-19_tile-healer.md
Normal file
27
docs/session-logs/2026-10-19_tile-healer.md
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
# Сессия 2026-10-19 (доб.) - онлайн-долечивание пустых тайлов
|
||||
|
||||
## Причина (из диагностики ранее)
|
||||
`createTile` (tilecache.js) при падении фетча не ретраит: Leaflet метит тайл
|
||||
loaded-with-error и не перезапрашивает, пока тайл не пересоздан (панорама/зум/redraw/
|
||||
новая карта). Поэтому дырки от плохого сигнала в поездке висели даже после возврата связи
|
||||
(подтверждено: офлайн ВЫКЛ, OSM через VPN отдавал 200, но пустые тайлы не оживали).
|
||||
|
||||
## Сделано (фича по запросу пользователя)
|
||||
tilecache.js - хилер:
|
||||
- `probeOsm()` - navigator.onLine + один тайл 0/0/0.png (таймаут 6с) = OSM доступен.
|
||||
- `startHealer(map, layer, pointsFn)` / `stopHealer()` - раз в 30с, если онлайн и не
|
||||
offline-режим: собирает недостающие тайлы на ТЕКУЩЕМ зуме по видимой области + (если
|
||||
pointsFn есть) вдоль маршрута (rotating cursor по длинному треку, чтобы не бурстить),
|
||||
фетчит с OSM, кэширует, по заполнению `layer.redraw()`. Лимиты: троттл 300мс, ≤40
|
||||
фетчей/цикл, ≤500 lookups/цикл - бережно к политике OSM (без массовой скачки).
|
||||
Также немедленный проход по событию `online` и через 3с после открытия карты.
|
||||
- Решения пользователя: зум - только текущий; охват - запись + просмотр.
|
||||
|
||||
main.js - подключение: хилер стартует на картах записи (pointsFn = точки текущей записи),
|
||||
детали трека (точки трека), сравнения треков и экрана "Дороги" (оба - по видимой области,
|
||||
pointsFn=null); глушится в `renderScreen` при уходе с экрана (и на map 'unload').
|
||||
|
||||
## Проверка
|
||||
Синтаксис JS ок (tilecache.js, main.js). На телефоне: открыть трек с дырявыми тайлами при
|
||||
живой сети - в течение ~30с дырки на текущем зуме заполняются; при офлайн-режиме докачки нет;
|
||||
нагрузка на OSM ограничена троттлингом. Живую проверку делает пользователь.
|
||||
32
docs/session-logs/2026-10-20_hr-reconnect.md
Normal file
32
docs/session-logs/2026-10-20_hr-reconnect.md
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
# Сессия 2026-10-20 (доб.) - авто-реконнект ремня ЧСС
|
||||
|
||||
## Задача
|
||||
Чтобы ночная запись не оборвалась при кратком обрыве BLE. Условие пользователя: НЕ долбиться
|
||||
в ремень, если его не было при старте или его не подключали руками - реконнект только для
|
||||
реально установленного и оборвавшегося соединения.
|
||||
|
||||
## Сделано (BleHrService.kt)
|
||||
- Поля `lastAddress`, `connectedOnce`, `stopping`, `reconnectTries`, `maxReconnect=3`.
|
||||
- Фреш-коннект (onStartCommand connect-ветка): `lastAddress=address; stopping=false;
|
||||
connectedOnce=false; reconnectTries=0`.
|
||||
- `onServicesDiscovered` (реально подключились): `connectedOnce=true; reconnectTries=0;
|
||||
isRunning=true`.
|
||||
- `onConnectionStateChange` DISCONNECTED: закрыть gatt; если `!stopping && connectedOnce &&
|
||||
reconnectTries<max && adapter.isEnabled && lastAddress!=null` -> reconnectTries++,
|
||||
`connectDevice(lastAddress)` через 8с; иначе stopSelf. Реконнект идёт через скан: ремень не
|
||||
найден -> timeout-ветка stopSelf (не зацикливается на отсутствующем).
|
||||
- `stopping=true` на ACTION_STOP, BT-off (ACTION_STATE_CHANGED), onDestroy - там реконнекта нет.
|
||||
|
||||
Начальный "скан не нашёл при старте -> stopSelf" не тронут: если ремня не было при старте,
|
||||
сервис гаснет, ничего не долбит.
|
||||
|
||||
## Установка (важно)
|
||||
Сборка БЕЗ -i, чтобы не убить идущую ночную запись. Порядок: пользователь жмёт Стоп (короткая
|
||||
сессия финализируется из нативного лога), затем установка APK, затем свежий старт ночи уже с
|
||||
реконнектом. Переустановка убивает процесс и чистит лог при следующем старте - поэтому не
|
||||
ставить поверх идущей записи.
|
||||
|
||||
## Проверка
|
||||
Компиляция Kotlin - в сборке. На устройстве (после установки): старт воркаута, снять/вернуть
|
||||
ремень на 10-20с -> запись переподключается и продолжается; снять надолго -> через ~3 попытки
|
||||
сервис гаснет. Живую проверку делает пользователь.
|
||||
36
docs/session-logs/2026-10-20_native-hr-log.md
Normal file
36
docs/session-logs/2026-10-20_native-hr-log.md
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
# Сессия 2026-10-20 - нативный лог ЧСС (запись воркаута переживает гаснущий экран)
|
||||
|
||||
## Причина (из разбора 21ч-прогона эксперимента)
|
||||
Сессия-воркаут 1792436740262 (19.10 22:05 -> 20.10 17:27) в сохранёнке имела дырки 11.6ч,
|
||||
НО пользователь видел живые цифры засыпая/просыпаясь/весь день. Проверка: шаг внутри
|
||||
покрытия ровно 2.0с (это JS-поллинг), границы дыр = сон/утро/обед (экран гас). Вывод: ремень
|
||||
и дисплей работали; молчал ПЕРСИСТ - историю сэмплов воркаута писал JS setInterval(2с),
|
||||
который Android замораживает в фоне/при гаснущем экране. Нативная часть клала только ТЕКУЩЕЕ
|
||||
значение (ble_hr_data.json, overwrite), истории не было.
|
||||
|
||||
## Сделано
|
||||
Нативный лог истории ЧСС (как GPS-точки пишутся foreground-сервисом):
|
||||
- Kotlin BleHrService: поле `logFile` = `ble_hr_log.jsonl`; в `writeData` каждая строка с
|
||||
`"hr":` дописывается (appendText) в лог. Пишет сам foreground-сервис -> переживает
|
||||
гаснущий экран.
|
||||
- Rust ble.rs: команда `ble_read_hr_log` (читает лог), `ble_clear_hr` теперь чистит и лог.
|
||||
Зарегистрировано в lib.rs invoke_handler.
|
||||
- JS gps.js: `GPS._readHrLogSamples()` - парсит лог в [[relSec,bpm]] относительно startTime
|
||||
(фильтр hr>0, rel>=0, дедуп по секунде, сортировка). На остановке воркаута сэмплы берутся
|
||||
из лога, если он полнее JS-массива (fallback на JS-массив, если лог пуст). Чтение - ДО
|
||||
`_stopHrMonitor` (который чистит лог). Лог чистится при старте воркаута (ble_clear_hr, 1453).
|
||||
|
||||
## Не делал (осознанно, по риску разовой ночной записи)
|
||||
Авто-реконнект ремня при обрыве BLE - НЕ добавлял сейчас. Причина дыр была в заморозке JS,
|
||||
а не в обрыве BLE (днём была длинная непрерывная связь). Ночью пользователь запишет сон -
|
||||
это проверит, рвётся ли BLE на самом деле. Если нативный лог покажет обрыв среди ночи -
|
||||
тогда добавим реконнект. Иначе он не нужен.
|
||||
|
||||
## Также отложено
|
||||
Разделение склеенного велотрека 1792506450721 (3 поездки по orig_session_id: 17:27-17:57,
|
||||
18:00-18:32, 18:44-18:49) - одобрено, но отложено ради срочного ночного фикса.
|
||||
|
||||
## Проверка
|
||||
Синтаксис JS ок. Rust/Kotlin компиляция - в сборке. На телефоне: старт воркаута "зарядка
|
||||
(пульс)", экран гаснет на ночь, утром стоп -> сэмплы должны быть сплошные (из нативного лога),
|
||||
включая сон. Живую проверку делает пользователь ночью.
|
||||
33
docs/session-logs/2026-10-20_strap-uptime-and-unmerge.md
Normal file
33
docs/session-logs/2026-10-20_strap-uptime-and-unmerge.md
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
# Сессия 2026-10-20 (доб.) - наработка ремня ЧСС + разделение склейки (UI)
|
||||
|
||||
## Наработка ремня (считаем ресурс батареи сами)
|
||||
Проблема: ремень >24ч пишет 100%, непонятно шагами по 25% или как. Нужно самим считать
|
||||
"300 часов". Заряд (0x2A19) грубый/запаздывает - поэтому копим ВРЕМЯ реальной отдачи ЧСС.
|
||||
- Kotlin BleHrService: `accrueUptime`/`writeUptimeFile` - на каждый замер ЧСС копят время
|
||||
(только промежутки <10с, т.е. без разрывов) и шаги заряда в `strap_uptime.json`
|
||||
(персистентно, НЕ чистится per-session; flush ~20с и принудительно при обрыве/disconnect).
|
||||
- Rust: `ble_read_strap_uptime` (чтение), `ble_reset_strap_uptime` (сброс при зарядке).
|
||||
Зарегистрированы в lib.rs.
|
||||
- JS: в настройках (Пульсометр) строка "Ремень ЧСС: наработка" - заряд, Hч Mм с последней
|
||||
зарядки, шаги заряда, кнопка "Заряжен" (сброс через appConfirm).
|
||||
Счётчик считает С МОМЕНТА установки/сброса; прошедшие сутки задним числом не восстановить.
|
||||
|
||||
## Авто-реконнект ремня (из прошлого лога этой даты)
|
||||
Отдельный коммит ранее: переподключение только УЖЕ установленного соединения (connectedOnce),
|
||||
до 3 попыток, не долбится в отсутствующий/не запущенный вручную ремень.
|
||||
|
||||
## Разделение склейки (по образцу merge, из UI)
|
||||
Пользователь склеил 3 велопоездки в одну (session 1792506450721) и пожалел; склейка растянула
|
||||
время и перекрыла воркауты между кусками. Все точки хранят `orig_session_id` -> разъединяемо.
|
||||
- db.js `unmergeGpsSession(sessionId)`: по distinct `orig_session_id` пересоздаёт исходные
|
||||
сессии (id = исходные), ретегает точки (orig_session_id=NULL, seq=NULL), выставляет каждой
|
||||
дату/start/end из её точек. `countMergedOrigins` - сколько исходных склеено (>1 = склейка).
|
||||
- main.js: кнопка "Разделить склейку (N)" в деталях трека (после "Разрезать"), видна только
|
||||
для склейки; по тапу - unmerge + пересчёт каждой (computeStats+finishGpsSession, MET-оценка;
|
||||
точную по физике - кнопкой "Пересчитать" в каждой) + возврат к списку.
|
||||
|
||||
## Проверка
|
||||
Синтаксис JS ок (db/gps/main/tilecache). Rust/Kotlin - в сборке. Собираем в одну сборку с
|
||||
реконнектом; ставим после Стопа ночной записи (переустановка её прерывает, данные в бэкапе
|
||||
`scratchpad/night_hr_log_backup.jsonl`). На устройстве: наработка растёт и показывается в
|
||||
настройках; "Разделить склейку" даёт 3 трека; реконнект переживает снятие ремня на 10-20с.
|
||||
35
docs/session-logs/2026-10-21_hr-base-and-calib.md
Normal file
35
docs/session-logs/2026-10-21_hr-base-and-calib.md
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
# Сессия 2026-10-21 - база покоя из ночной записи + справочная калибровка ЧСС
|
||||
|
||||
## Ночь записалась чисто (нативный лог сработал)
|
||||
Воркаут `1792529811087` (20.10 23:56 -> 21.10 09:20): 9.4ч непрерывно, 0 дыр. Нативный лог
|
||||
ЧСС (foreground-сервис, ble_hr_log.jsonl) переживает гаснущий экран - прошлый 21ч-прогон имел
|
||||
11.6ч дыр из-за заморозки JS-поллинга, теперь починено.
|
||||
|
||||
## База покоя
|
||||
Истинный пульс сна **~48 (46-50)**, устойчивый 10-мин минимум 50 в 05:46, общий минимум 46.
|
||||
Всплеск 112 в 05:00 = подъём в туалет. Прежняя прикидка 65-85 завышена.
|
||||
|
||||
## Прикидка моделей (разбор, объяснено пользователю словами)
|
||||
Поездка 90км по-всякому: физика 2756, Keytel-net 4283, резерв-ЧСС/Карвонен (от покоя 48,
|
||||
VO2max 56 по Uth) ~4841. Пульсовые ~1.5-1.8x физики. Якорь на покой поездку не уменьшил.
|
||||
Сутки (сон этой ночью + работа 10-17 вчера, 16ч бодрств.): по пульсу ~2300 против веса
|
||||
BMR1670 x1.2=2005 (x1.3=2172). Пульс завышает и день (+10-15%), и поездку. Чисто НЕ сходятся;
|
||||
грубый множитель ~0.65 мирит обе точки (поездка 2756/4283=0.64; день 335/632=0.53..0.79).
|
||||
Физика у велосипеда занижает (неэффективный односкоростной складной - реально жжёшь больше
|
||||
идеала); пульс завышает (жара/обезвоживание/еда + неспособность отделить их от усилия).
|
||||
|
||||
## Сделано (reference-only, баланс не трогаем)
|
||||
- Память `project_hr_calibration_experiment.md` дополнена результатами ночи; статус - база
|
||||
получена. Напоминалка снята (state.json status=done).
|
||||
- `gps.js`: `config.hrCalibFactor = 0.65` (грубый персональный множитель, правится вручную).
|
||||
- `main.js` showSessionDetail блок "Калории": добавлена строка "ЧСС откалибр. (грубо)" =
|
||||
Keytel x hrCalibFactor, рядом с "По ЧСС (Keytel)"; подпись "грубая прикидка". В баланс
|
||||
по-прежнему идёт физика.
|
||||
|
||||
## Остатки (не срочно)
|
||||
Собрать ещё 1-2 чистых дня для устаканивания множителя; скидка на переваривание (TEF);
|
||||
батарея ремня - ждём первого шага вниз (9.4ч @ 100%, ноль шагов).
|
||||
|
||||
## Проверка
|
||||
Синтаксис JS ок. На телефоне: открыть трек с ЧСС -> в "Калории" три числа (физика,
|
||||
По ЧСС Keytel, ЧСС откалибр.); баланс дня не изменился. Живую проверку делает пользователь.
|
||||
42
docs/session-logs/2026-10-22_tef-vr-roads-poi.md
Normal file
42
docs/session-logs/2026-10-22_tef-vr-roads-poi.md
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
# 2026-10-22 - TEF, вид Руки/VR, толщина дорог по проездам, офлайн-POI в brouter
|
||||
|
||||
## Контекст
|
||||
Работа по нескольким запросам подряд (без утверждённого заранее плана, правки по ходу).
|
||||
|
||||
## Сделано
|
||||
|
||||
### brouter-web (вне git приложения, staging + /var/www/brouter-web)
|
||||
- Офлайн-POI: Overpass недоступен из сети, поэтому взят статический экстракт Geofabrik
|
||||
south-fed-district, osmium вырезал POI в радиусе 150 км от НЭВЗ -> poi-nevz.geojson
|
||||
(26.6k точек). Конвейер tools/poi/make_poi.sh + poi_reduce.py.
|
||||
- POI подключены в brouter-web ИКОНКАМИ (emoji) и в РОДНОЙ список слоёв (addOverlay в
|
||||
пойманный L.Control.Layers), а не отдельными кнопками. Покрытие и Высоты - тоже в родной
|
||||
список; бегунки высот всплывают только при включённом слое. Ползунок ребра высот ограничен
|
||||
1000 (в hm_mesh.py MAX_EDGE=1000, выше бесполезно - дыры это промежутки >1км без проездов).
|
||||
|
||||
### Приложение (этот коммит)
|
||||
- Толщина дорог по проездам: rebuild_roads считает passes (входы в 2м-ячейку), толщина
|
||||
линии = база × min(3, 1+0.2·(проезды−1)). Опционально (настройка, по умолчанию вкл).
|
||||
- Вид «Руки/VR (пульс)» (activity_type="vr") - HR-only как зарядка, трекается отдельно.
|
||||
GPS.isHrOnly заменил жёсткие проверки "workout".
|
||||
- TEF (термический эффект пищи): галочка в профиле, 10% прихода, с сохранением поддержки
|
||||
(база = BMR*actLevel*(1−tef), +tef*приход). Строка «расход: база + переваривание +
|
||||
активность» в сводке, 7-дневный тренд тоже с TEF.
|
||||
|
||||
## Проверка
|
||||
- brouter-web: CDP + скриншоты на https://192.168.1.18/brouter-web/ - слои в родном списке,
|
||||
POI иконками, ползунок 1000, 4777 точек по дефолтным категориям.
|
||||
- Приложение: сборка 0.1.513, CDP в WebView - tefFrac=0.1, isHrOnly('vr')=true, опция
|
||||
"vr:Руки/VR (пульс)" в списке, сводка «расход: база 1807 + переваривание 194 + активность
|
||||
624» = 2625 (математика сходится).
|
||||
|
||||
## Разбор
|
||||
- Сегодняшняя VR-сессия (id 1792607289599, 66 мин): ЧСС средняя 93, макс 113, резерв ~33% -
|
||||
лёгко-умеренная зона без пиков. По ЧСС адекватно судить ЗОНУ; абсолютные ккал Keytel для
|
||||
рук/VR завышает (верхняя граница), для этого и завели отдельный вид + множитель 0.65.
|
||||
- Эта сессия переведена workout -> vr по просьбе (UPDATE через собственное DB-соединение
|
||||
приложения по CDP).
|
||||
|
||||
## Дальше (не делалось)
|
||||
- TEF по составу БЖУ (сейчас фиксированные 10%).
|
||||
- MET-строка для VR как перекрёстная сверка (основным оставлен ЧСС).
|
||||
|
|
@ -1279,9 +1279,13 @@ select {
|
|||
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. */
|
||||
/* Dark map: invert ONLY the tile layer (track, route, 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); }
|
||||
/* Roads overlay was tuned for LIGHT tiles, so on inverted (dark) tiles it blended in.
|
||||
Give the roads pane the SAME filter as the tiles so road colours shift identically and
|
||||
stay contrasting. Roads live in `roadsPane` (record screen SVG + roads screen canvas). */
|
||||
body.dark-map .leaflet-roadsPane { filter: invert(1) hue-rotate(180deg) brightness(1.5); }
|
||||
/* Darken the map backdrop too, else the light Leaflet container (#ddd) flashes bright
|
||||
between/behind tiles while zooming or where a tile is missing. */
|
||||
body.dark-map .leaflet-container { background: #1b1f24; }
|
||||
|
|
|
|||
|
|
@ -1001,6 +1001,65 @@ async function splitGpsSession(sessionId, part2Ids, part1EndTime, part2StartTime
|
|||
return String(newId);
|
||||
}
|
||||
|
||||
// Undo a merge: split a merged session back into its original sessions by orig_session_id
|
||||
// (merge preserved it on every point). The current session keeps its own original group; each
|
||||
// other orig_session_id gets its session row recreated (metadata copied). Points are retagged
|
||||
// (orig_session_id cleared, seq cleared → timestamp order). Returns the list of resulting
|
||||
// session id_strs (incl. the current one), or null if this session isn't a merge. The caller
|
||||
// recomputes stats + times for each (computeStats + finishGpsSession).
|
||||
async function unmergeGpsSession(sessionId) {
|
||||
const s = await getGpsSession(sessionId);
|
||||
if (!s) return null;
|
||||
const rows = await select(
|
||||
"SELECT DISTINCT CAST(orig_session_id AS TEXT) AS o FROM gps_points WHERE session_id = CAST(?1 AS INTEGER) AND orig_session_id IS NOT NULL",
|
||||
[s.id_str]
|
||||
);
|
||||
const origs = rows.map(r => r.o).filter(Boolean);
|
||||
if (origs.length < 2) return null; // not a merge — nothing to split
|
||||
const resultIds = [];
|
||||
for (const oid of origs) {
|
||||
if (oid !== s.id_str) {
|
||||
await execute(
|
||||
`INSERT INTO gps_sessions (id, date, start_time, end_time, activity_type, source, bike_id)
|
||||
VALUES (CAST(?1 AS INTEGER), ?2, ?3, ?4, ?5, ?6, ?7)`,
|
||||
[oid, s.date, s.start_time, s.end_time, s.activity_type, s.source ?? "recorded", s.bike_id ?? null]
|
||||
);
|
||||
await execute(
|
||||
"UPDATE gps_points SET session_id = CAST(?1 AS INTEGER), orig_session_id = NULL, seq = NULL WHERE session_id = CAST(?2 AS INTEGER) AND orig_session_id = CAST(?1 AS TEXT)",
|
||||
[oid, s.id_str]
|
||||
);
|
||||
} else {
|
||||
await execute(
|
||||
"UPDATE gps_points SET orig_session_id = NULL, seq = NULL WHERE session_id = CAST(?1 AS INTEGER) AND orig_session_id = CAST(?1 AS TEXT)",
|
||||
[s.id_str]
|
||||
);
|
||||
}
|
||||
resultIds.push(oid);
|
||||
}
|
||||
// Fix each piece's date/times from its own points (merge had stretched them to the whole span).
|
||||
for (const id of resultIds) {
|
||||
const ts = await select("SELECT MIN(ts) AS a, MAX(ts) AS b FROM gps_points WHERE session_id = CAST(?1 AS INTEGER)", [id]);
|
||||
const a = ts[0] && ts[0].a;
|
||||
if (a) {
|
||||
const hm = (t) => new Date(t).toTimeString().slice(0, 8);
|
||||
await execute("UPDATE gps_sessions SET date = ?1, start_time = ?2, end_time = ?3 WHERE id = CAST(?4 AS INTEGER)",
|
||||
[new Date(a).toISOString().slice(0, 10), hm(a), hm(ts[0].b), id]);
|
||||
}
|
||||
}
|
||||
return resultIds;
|
||||
}
|
||||
|
||||
// How many distinct original sessions are glued into this one (>1 ⇒ it's a merge that can be split).
|
||||
async function countMergedOrigins(sessionId) {
|
||||
const s = await getGpsSession(sessionId);
|
||||
if (!s) return 0;
|
||||
const r = await select(
|
||||
"SELECT COUNT(DISTINCT orig_session_id) AS c FROM gps_points WHERE session_id = CAST(?1 AS INTEGER) AND orig_session_id IS NOT NULL",
|
||||
[s.id_str]
|
||||
);
|
||||
return (r[0] && r[0].c) || 0;
|
||||
}
|
||||
|
||||
async function importGpxSession(session, points) {
|
||||
await execute(
|
||||
`INSERT INTO gps_sessions (date, start_time, end_time, activity_type,
|
||||
|
|
@ -1389,6 +1448,8 @@ window.DB.getGpsSession = getGpsSession;
|
|||
window.DB.getGpsPoints = getGpsPoints;
|
||||
window.DB.deleteGpsSession = deleteGpsSession;
|
||||
window.DB.mergeGpsSessions = mergeGpsSessions;
|
||||
window.DB.unmergeGpsSession = unmergeGpsSession;
|
||||
window.DB.countMergedOrigins = countMergedOrigins;
|
||||
window.DB.splitGpsSession = splitGpsSession;
|
||||
window.DB.importGpxSession = importGpxSession;
|
||||
window.DB.saveGapFill = saveGapFill;
|
||||
|
|
@ -1538,7 +1599,7 @@ async function getRoadPoints() {
|
|||
while (true) {
|
||||
const raw = await invoke("get_road_points", { offset, batchSize: BATCH });
|
||||
const resp = JSON.parse(raw);
|
||||
for (const p of resp.points) all.push({ lat: p[0], lon: p[1], atype: p[2] || "b" });
|
||||
for (const p of resp.points) all.push({ lat: p[0], lon: p[1], atype: p[2] || "b", passes: p[3] || 1 });
|
||||
console.log(`[getRoadPoints] batch offset=${offset} got=${resp.points.length} total_db=${resp.total}`);
|
||||
if (!resp.has_more) break;
|
||||
offset += BATCH;
|
||||
|
|
|
|||
|
|
@ -14,6 +14,9 @@ GPS.config = {
|
|||
airDrag: 0.6, // аэродинамическое сопротивление (CdA, м2). Город=0.6, спорт=0.4
|
||||
airDensity: 1.225, // плотность воздуха (кг/м3). Уровень моря=1.225, горы=1.0
|
||||
pedalEfficiency: 25, // КПД педалирования (%). Человек ~25%
|
||||
hrCalibFactor: 0.65, // грубый персональный множитель для справочной "ЧСС откалибр." (Keytel x этого). Пульс завышает ~в 1.5x vs физика/вес; 0.65 мирит по 2 точкам (поездка 90км + сидячий день). НЕ истина, для справки.
|
||||
tefFrac: 0.10, // термический эффект пищи (TEF): доля прихода, уходящая на переваривание. 0 = выкл. Вводится С СОХРАНЕНИЕМ поддержки: база = BMR*actLevel*(1-tefFrac), затем +tefFrac*приход, так что на поддержке итог не меняется, но TEF масштабируется от реальной еды. actLevel 1.2 уже включал TEF — поэтому не прибавляем сверху, а перераспределяем.
|
||||
|
||||
altSmoothHalf: 2, // полуокно сглаживания высоты (точек). 2 = 5pt, 5 = 11pt, 7 = 15pt
|
||||
walkMaxSpeedKmh: 12, // макс. скорость ходьбы/бега (выше = глитч GPS)
|
||||
walkMinSpeedMs: 0.3, // мин. скорость ходьбы (ниже = стоит). 0.3 м/с = 1 км/ч
|
||||
|
|
@ -42,6 +45,11 @@ GPS.saveConfig = function() {
|
|||
localStorage.setItem("baldav_gps_config", JSON.stringify(GPS.config));
|
||||
};
|
||||
|
||||
// HR-only session kinds (no GPS, no track — just heart rate + timer): "workout" (зарядка)
|
||||
// and "vr" (руки/VR/танцы). Both estimate kcal from HR (Keytel). Kept as a set so new
|
||||
// HR-only kinds get the same no-GPS recording/detail path without touching every check.
|
||||
GPS.isHrOnly = function(t) { return t === "workout" || t === "vr"; };
|
||||
|
||||
// One-time migration: raw GPS_PROVIDER doesn't deliver on this device, so default
|
||||
// tracking to fused (like Yandex/Google Maps). Runs once, then respects the toggle.
|
||||
try {
|
||||
|
|
@ -858,7 +866,7 @@ GPS._startHrMonitor = async function() {
|
|||
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) {
|
||||
if (GPS.isHrOnly(GPS.recording.activityType) && 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);
|
||||
|
|
@ -881,6 +889,33 @@ GPS._stopHrMonitor = function() {
|
|||
try { window.__TAURI__.core.invoke("ble_clear_hr"); } catch {}
|
||||
};
|
||||
|
||||
// Rebuild the workout's HR series from the NATIVE log (ble_hr_log.jsonl), which the
|
||||
// foreground service appends to even with the screen off — unlike the JS poll, which
|
||||
// Android freezes in the background (so a sleep-long workout kept only the on-screen bits).
|
||||
// Returns [[relSec, bpm], …] relative to the recording start, sorted and de-duplicated,
|
||||
// or [] if the log is empty/unavailable.
|
||||
GPS._readHrLogSamples = async function() {
|
||||
if (!GPS.recording.startTime) return [];
|
||||
let raw = "";
|
||||
try { raw = await window.__TAURI__.core.invoke("ble_read_hr_log"); } catch { return []; }
|
||||
if (!raw) return [];
|
||||
const startSec = GPS.recording.startTime.getTime() / 1000;
|
||||
const out = [];
|
||||
let lastRel = -1;
|
||||
for (const line of raw.split("\n")) {
|
||||
const s = line.trim();
|
||||
if (!s) continue;
|
||||
let o; try { o = JSON.parse(s); } catch { continue; } // skip a torn last line
|
||||
if (!o || !(o.hr > 0) || o.ts == null) continue;
|
||||
const rel = Math.round(o.ts - startSec);
|
||||
if (rel < 0 || rel === lastRel) continue; // before start, or same-second dup
|
||||
out.push([rel, o.hr]);
|
||||
lastRel = rel;
|
||||
}
|
||||
out.sort((a, b) => a[0] - b[0]);
|
||||
return out;
|
||||
};
|
||||
|
||||
// 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.
|
||||
|
|
@ -1427,16 +1462,16 @@ GPS.startRecording = async function(activityType, weightKg, profile) {
|
|||
GPS.recording.startTime = null;
|
||||
GPS.recording.weightKg = parseFloat(weightKg) || 80;
|
||||
|
||||
// HR-only "workout" (зарядка): no GPS service, no location, no track points — just the
|
||||
// HR-only "workout"/"vr": 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") {
|
||||
if (GPS.isHrOnly(GPS.recording.activityType)) {
|
||||
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");
|
||||
new Date().toISOString().slice(0, 10), new Date().toTimeString().slice(0, 5), GPS.recording.activityType);
|
||||
} catch (e) { console.error("[workout] createGpsSession failed:", e); }
|
||||
// Auto-connect the paired strap on start (workout = you want HR). Absent strap -> stops clean.
|
||||
try {
|
||||
|
|
@ -1548,9 +1583,15 @@ GPS.stopRecording = async function() {
|
|||
|
||||
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 || [];
|
||||
// HR-only workout/vr: no GPS to stop. Compute net kcal from HR, save samples, finalise.
|
||||
if (GPS.isHrOnly(GPS.recording.activityType)) {
|
||||
// Prefer the native HR log (complete through screen-off) over the JS poll's in-memory
|
||||
// array (frozen in the background). Fall back to the poll array if the log is empty.
|
||||
let samples = GPS.recording.hrSamples || [];
|
||||
try {
|
||||
const native = await GPS._readHrLogSamples();
|
||||
if (native.length > samples.length) samples = native;
|
||||
} catch (e) { console.error("[workout] native hr log read", e); }
|
||||
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]);
|
||||
|
|
|
|||
|
|
@ -250,6 +250,7 @@ function renderScreen(name, fromPopstate) {
|
|||
// Release screen wake lock and compass listener when leaving recording screen
|
||||
if (window._screenLock) { try { window._screenLock.release(); } catch {} window._screenLock = null; }
|
||||
if (GPS.recording._compassCleanup) { GPS.recording._compassCleanup(); GPS.recording._compassCleanup = null; }
|
||||
try { TileCache.stopHealer(); } catch {} // leaving a map screen stops online tile-healing; map screens restart it
|
||||
|
||||
let prevScreen = state.screen;
|
||||
state.screen = name;
|
||||
|
|
@ -354,10 +355,15 @@ async function renderToday(container) {
|
|||
let recentWeightsForBmr = await DB.getRecentWeights(7, state.date);
|
||||
let bmr = calcBmr(recentWeightsForBmr);
|
||||
let actLevel = parseFloat(state.profile.activity_level) || 1.2;
|
||||
let dailyBase = bmr ? Math.round(bmr * actLevel) : null;
|
||||
let intake = Math.round(foodTotals.kcal ?? 0);
|
||||
let actKcal = Math.round(actTotals.kcal ?? 0);
|
||||
let expend = dailyBase != null ? dailyBase + actKcal : actKcal > 0 ? actKcal : null;
|
||||
// TEF (термический эффект пищи): доля прихода на переваривание. Вводим с сохранением
|
||||
// поддержки — базу урезаем на tefFrac, а TEF добавляем от реального прихода, поэтому на
|
||||
// поддержке итог тот же, но в дефицит (ел меньше — переварил меньше) расход чуть ниже.
|
||||
let tefFrac = Math.max(0, Math.min(0.3, GPS.config.tefFrac ?? 0));
|
||||
let dailyBase = bmr ? Math.round(bmr * actLevel * (1 - tefFrac)) : null;
|
||||
let tefKcal = Math.round(tefFrac * intake);
|
||||
let expend = dailyBase != null ? dailyBase + tefKcal + actKcal : (actKcal > 0 ? actKcal + tefKcal : null);
|
||||
let balance = expend != null ? intake - expend : null;
|
||||
container.innerHTML = `
|
||||
<div class="day-header">
|
||||
|
|
@ -381,6 +387,10 @@ async function renderToday(container) {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
${expend != null ? `<div class="expend-breakdown" style="text-align:center;font-size:11px;color:#9aa4ae;margin-top:-4px">
|
||||
расход: база ${dailyBase ?? "—"}${tefKcal > 0 ? ` + переваривание ${tefKcal}` : ""}${actKcal > 0 ? ` + активность ${actKcal}` : ""}
|
||||
</div>` : ""}
|
||||
|
||||
<div class="balance-trend" id="balance-trend"></div>
|
||||
|
||||
<div class="drink-bar-wrap" id="drink-bar-wrap"></div>
|
||||
|
|
@ -559,7 +569,7 @@ async function renderToday(container) {
|
|||
for (let d of food7) {
|
||||
let dayIntake = foodMap[d.date] ?? 0;
|
||||
let dayAct = actMap[d.date] ?? 0;
|
||||
let dayBalance = dayIntake - (dailyBase + dayAct);
|
||||
let dayBalance = dayIntake - ((dailyBase ?? 0) + Math.round(tefFrac * dayIntake) + dayAct);
|
||||
balances.push(dayBalance);
|
||||
}
|
||||
if (balances.length === 0) return;
|
||||
|
|
@ -2813,6 +2823,7 @@ async function renderGpsScreen(container) {
|
|||
<option value="walk"${localStorage.getItem("baldav_gps_type")==="walk"?" selected":""}>Ходьба</option>
|
||||
<option value="run"${localStorage.getItem("baldav_gps_type")==="run"?" selected":""}>Бег</option>
|
||||
<option value="workout"${localStorage.getItem("baldav_gps_type")==="workout"?" selected":""}>Зарядка (пульс)</option>
|
||||
<option value="vr"${localStorage.getItem("baldav_gps_type")==="vr"?" selected":""}>Руки/VR (пульс)</option>
|
||||
</select>
|
||||
<button id="gps-rec-btn" class="btn-primary gps-rec ${isRec ? "recording" : ""}">
|
||||
${isRec ? "Трекинг" : "Старт"}
|
||||
|
|
@ -2876,10 +2887,10 @@ async function renderGpsScreen(container) {
|
|||
}
|
||||
// Nudge BT (for the strap) / location (for the track) on if off. Non-blocking to the user's
|
||||
// choice: they can decline, and the two "Не спрашивать" settings suppress each prompt.
|
||||
await ensureSystemReady(!!state.profile?.ble_device_addr, type !== "workout");
|
||||
await ensureSystemReady(!!state.profile?.ble_device_addr, !GPS.isHrOnly(type));
|
||||
|
||||
// Workout (HR-only): pass the profile so finish can compute kcal from HR (Keytel).
|
||||
let hrProfile = type === "workout" ? {
|
||||
// Workout/VR (HR-only): pass the profile so finish can compute kcal from HR (Keytel).
|
||||
let hrProfile = GPS.isHrOnly(type) ? {
|
||||
weightKg: wkg,
|
||||
age: BMR.getAge(parseInt(state.profile?.birth_year) || 1988),
|
||||
sex: state.profile?.sex || "m",
|
||||
|
|
@ -3317,6 +3328,40 @@ function roadColor(atype) {
|
|||
return c.roadColorBike || "#2266aa";
|
||||
}
|
||||
|
||||
// Line thickness by how often a stretch was ridden (road_points.passes from rebuild_roads):
|
||||
// base width x (1..3), +0.2 per extra pass, capped at 3x. Optional — disabled by the flag.
|
||||
function roadsThickOn() { return localStorage.getItem("baldav_roads_thick") !== "0"; }
|
||||
function roadWeight(passes, base) {
|
||||
base = base || 3;
|
||||
if (!roadsThickOn()) return base;
|
||||
return base * Math.min(3, 1 + 0.2 * ((passes || 1) - 1));
|
||||
}
|
||||
// Draw one road segment (array of {lat,lon,passes}), split into runs of equal thickness so
|
||||
// often-ridden parts read thicker. Each run bridges the boundary point so the line stays
|
||||
// continuous across a thickness change. `extra`: { pane, renderer, base, opacity, smoothFactor }.
|
||||
function drawRoadSegment(map, seg, color, out, extra) {
|
||||
if (!seg || seg.length < 2) return;
|
||||
extra = extra || {};
|
||||
let base = extra.base || 3;
|
||||
let opts = { color, opacity: extra.opacity != null ? extra.opacity : 0.6 };
|
||||
if (extra.pane) opts.pane = extra.pane;
|
||||
if (extra.renderer) opts.renderer = extra.renderer;
|
||||
if (extra.smoothFactor != null) opts.smoothFactor = extra.smoothFactor;
|
||||
if (!roadsThickOn()) {
|
||||
out.push(L.polyline(seg.map(p => [p.lat, p.lon]), { ...opts, weight: base }).addTo(map));
|
||||
return;
|
||||
}
|
||||
let run = [seg[0]];
|
||||
let runW = roadWeight(seg[0].passes, base);
|
||||
let flush = () => { if (run.length >= 2) out.push(L.polyline(run.map(p => [p.lat, p.lon]), { ...opts, weight: runW }).addTo(map)); };
|
||||
for (let i = 1; i < seg.length; i++) {
|
||||
let w = roadWeight(seg[i].passes, base);
|
||||
if (Math.abs(w - runW) > 0.01) { flush(); run = [seg[i - 1], seg[i]]; runW = w; }
|
||||
else run.push(seg[i]);
|
||||
}
|
||||
flush();
|
||||
}
|
||||
|
||||
// Resolve once the Leaflet map has finished its initial layout, so layers added
|
||||
// after this actually render (adding a polyline before the map is loaded/sized
|
||||
// leaves it invisible until the next redraw). Awaits the real 'load' event — the
|
||||
|
|
@ -4475,7 +4520,7 @@ function renderWorkoutDetail(session) {
|
|||
let max = hrs.length ? Math.max(...hrs) : null;
|
||||
let kcal = Math.round(session.total_kcal || 0);
|
||||
let html = `
|
||||
<div class="sd-section"><div class="sd-title">Зарядка · ${session.date} ${session.start_time ?? ""}</div>
|
||||
<div class="sd-section"><div class="sd-title">${session.activity_type === "vr" ? "Руки/VR" : "Зарядка"} · ${session.date} ${session.start_time ?? ""}</div>
|
||||
<div class="sd-row">
|
||||
<div class="sd-stat"><span class="sd-val">${GPS.formatDuration(durSec)}</span><span class="sd-lbl">Время</span></div>
|
||||
<div class="sd-stat"><span class="sd-val">${kcal} ккал</span><span class="sd-lbl">Нагрузка</span></div>
|
||||
|
|
@ -4585,7 +4630,7 @@ async function renderRecordingInline(container) {
|
|||
}
|
||||
|
||||
// HR-only workout (зарядка): no map, no GPS — just heart rate, timer, live kcal.
|
||||
if (GPS.recording.activityType === "workout") { renderWorkoutInline(container); return; }
|
||||
if (GPS.isHrOnly(GPS.recording.activityType)) { renderWorkoutInline(container); return; }
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="rec-screen">
|
||||
|
|
@ -4632,7 +4677,9 @@ async function renderRecordingInline(container) {
|
|||
}, { passive: false });
|
||||
addZoomDisplay(map);
|
||||
let CachedLayer = TileCache.createLayer();
|
||||
new CachedLayer({ maxZoom: 19 }).addTo(map);
|
||||
let tileLayer = new CachedLayer({ maxZoom: 19 }).addTo(map);
|
||||
// Heal blank tiles along the ridden route whenever OSM is reachable.
|
||||
TileCache.startHealer(map, tileLayer, () => (GPS.recording.points || []).map(p => [p.lat, p.lon]));
|
||||
// Force recalc after flex layout settles — fixes black screen on init
|
||||
setTimeout(() => map.invalidateSize(), 200);
|
||||
|
||||
|
|
@ -4855,19 +4902,13 @@ async function renderRecordingInline(container) {
|
|||
let dlat = (pts[i].lat - prev.lat) * 111000;
|
||||
let dlon = (pts[i].lon - prev.lon) * 111000 * Math.cos(pts[i].lat * Math.PI / 180);
|
||||
if (dlat * dlat + dlon * dlon > 10000 || pts[i].atype !== prev.atype) {
|
||||
if (seg.length >= 2) {
|
||||
let color = roadColor(seg[0].atype);
|
||||
roadLayers.push(L.polyline(seg.map(p => [p.lat, p.lon]), { color, weight: 3, opacity: 0.6, pane: "roadsPane" }).addTo(map));
|
||||
}
|
||||
if (seg.length >= 2) drawRoadSegment(map, seg, roadColor(seg[0].atype), roadLayers, { pane: "roadsPane" });
|
||||
seg = [];
|
||||
}
|
||||
}
|
||||
seg.push(pts[i]);
|
||||
}
|
||||
if (seg.length >= 2) {
|
||||
let color = roadColor(seg[0].atype);
|
||||
roadLayers.push(L.polyline(seg.map(p => [p.lat, p.lon]), { color, weight: 3, opacity: 0.6, pane: "roadsPane" }).addTo(map));
|
||||
}
|
||||
if (seg.length >= 2) drawRoadSegment(map, seg, roadColor(seg[0].atype), roadLayers, { pane: "roadsPane" });
|
||||
};
|
||||
btnWrap.appendChild(roadsBtn);
|
||||
// Auto-load roads if was enabled
|
||||
|
|
@ -5389,7 +5430,7 @@ async function showSessionDetail(sessionId) {
|
|||
let session = await DB.getGpsSession(sessionId);
|
||||
if (!session) { console.error("[SD] session not found"); return; }
|
||||
// HR-only workout: no GPS/map — its own detail (HR chart + load), reuse this modal path.
|
||||
if (session.activity_type === "workout") { renderWorkoutDetail(session); return; }
|
||||
if (GPS.isHrOnly(session.activity_type)) { renderWorkoutDetail(session); return; }
|
||||
console.log("[SD] loading points");
|
||||
let rawPoints = await DB.getGpsPoints(sessionId);
|
||||
console.log("[SD] got", rawPoints.length, "raw points");
|
||||
|
|
@ -5458,7 +5499,7 @@ async function showSessionDetail(sessionId) {
|
|||
let t0 = points.length ? new Date(points[0].ts).getTime() : 0;
|
||||
let timeLabels = points.slice(1).map(p => ((new Date(p.ts).getTime() - t0) / 60000).toFixed(1));
|
||||
|
||||
let actNames = { bike: "Велосипед", walk: "Ходьба", run: "Бег", other: "Другое" };
|
||||
let actNames = { bike: "Велосипед", walk: "Ходьба", run: "Бег", workout: "Зарядка", vr: "Руки/VR", other: "Другое" };
|
||||
let actName = actNames[session.activity_type] || session.activity_type || "Тренировка";
|
||||
let duration = session.end_time && session.start_time
|
||||
? GPS.formatDuration(stats.duration)
|
||||
|
|
@ -5480,6 +5521,7 @@ async function showSessionDetail(sessionId) {
|
|||
heightCm: parseFloat(state.profile?.height_cm) || 178,
|
||||
};
|
||||
let hrKcal = hasHr ? GPS.hrKcalNet(hrSamples, hrProfile) : null;
|
||||
let hrKcalCalib = hasHr ? Math.round(hrKcal * (GPS.config.hrCalibFactor ?? 0.65)) : null; // грубая справочная прикидка
|
||||
let hrAvg = hasHr ? Math.round(hrSamples.reduce((a, s) => a + s[1], 0) / hrSamples.length) : null;
|
||||
let hrMax = hasHr ? Math.max(...hrSamples.map(s => s[1])) : null;
|
||||
|
||||
|
|
@ -5524,8 +5566,9 @@ async function showSessionDetail(sessionId) {
|
|||
<div class="sd-stat"><span class="sd-val">${session.total_kcal ?? stats.kcal} ккал</span><span class="sd-lbl">${hasHr ? "Физика (в баланс)" : "Всего"}</span></div>
|
||||
<div class="sd-stat"><span class="sd-val">${kcalMin} ккал/мин</span><span class="sd-lbl">Расход</span></div>
|
||||
</div>
|
||||
${hasHr ? `<div class="sd-row"><div class="sd-stat"><span class="sd-val">${hrKcal} ккал</span><span class="sd-lbl">По ЧСС (сравнение)</span></div></div>
|
||||
<div class="prod-hint" style="text-align:center;margin-top:4px">В баланс идёт «физика»; цифра по ЧСС (Keytel) - для сравнения.</div>` : ""}
|
||||
${hasHr ? `<div class="sd-row"><div class="sd-stat"><span class="sd-val">${hrKcal} ккал</span><span class="sd-lbl">По ЧСС (Keytel)</span></div>
|
||||
<div class="sd-stat"><span class="sd-val">${hrKcalCalib} ккал</span><span class="sd-lbl">ЧСС откалибр. (грубо)</span></div></div>
|
||||
<div class="prod-hint" style="text-align:center;margin-top:4px">В баланс идёт «физика». По ЧСС (Keytel) завышает; «откалибр.» = Keytel x ${(GPS.config.hrCalibFactor ?? 0.65)} - грубая прикидка для сравнения.</div>` : ""}
|
||||
</div>
|
||||
|
||||
${elevations.length > 0 ? `
|
||||
|
|
@ -5594,6 +5637,7 @@ async function showSessionDetail(sessionId) {
|
|||
<button id="sd-recalc" class="btn-secondary full-width" style="margin-top:8px">Пересчитать</button>
|
||||
<button id="sd-refine" class="btn-secondary full-width" style="margin-top:8px">Уточнить высоты (онлайн)</button>
|
||||
<button id="sd-split" class="btn-secondary full-width" style="margin-top:8px">Разрезать трек</button>
|
||||
<button id="sd-unmerge" class="btn-secondary full-width" style="margin-top:8px;display:none">Разделить склейку</button>
|
||||
<button id="sd-export" class="btn-secondary full-width" style="margin-top:8px">Экспорт GPX</button>
|
||||
<button id="sd-share" class="btn-secondary full-width" style="margin-top:8px">Похвастаться</button>
|
||||
<button id="sd-delete" class="btn-secondary full-width" style="margin-top:8px;color:#cf6679">Удалить тренировку</button>
|
||||
|
|
@ -5620,7 +5664,9 @@ async function showSessionDetail(sessionId) {
|
|||
}, { passive: false });
|
||||
addZoomDisplay(map);
|
||||
let CachedLayer = TileCache.createLayer();
|
||||
new CachedLayer({ maxZoom: 19 }).addTo(map);
|
||||
let tileLayer = new CachedLayer({ maxZoom: 19 }).addTo(map);
|
||||
// Heal blank tiles along this track's route when OSM is reachable.
|
||||
TileCache.startHealer(map, tileLayer, () => points.map(p => [p.lat, p.lon]));
|
||||
addDarkMapControl(map); // dark-tiles toggle on the track view
|
||||
POI.attach(map); // POI overlay + category filter
|
||||
{ let nl = null; let rn = async () => { nl = await drawNogoAreas(map, nl, rn); }; rn(); } // impassable-spot circles
|
||||
|
|
@ -6382,6 +6428,35 @@ async function showSessionDetail(sessionId) {
|
|||
};
|
||||
}
|
||||
|
||||
// Un-merge button: only for a merged session (points carry >1 orig_session_id). Splits it
|
||||
// back into the original rides, recomputing each; shown only when it's actually a merge.
|
||||
let unmergeBtn = document.querySelector("#sd-unmerge");
|
||||
if (unmergeBtn) (async () => {
|
||||
let n = 0; try { n = await DB.countMergedOrigins(sessionId); } catch {}
|
||||
if (n < 2) return;
|
||||
unmergeBtn.style.display = "";
|
||||
unmergeBtn.textContent = `Разделить склейку (${n})`;
|
||||
unmergeBtn.onclick = async () => {
|
||||
if (!(await appConfirm(`Разделить трек обратно на ${n} исходные поездки? Статистика каждой пересчитается (точную по физике можно потом «Пересчитать» в каждой).`, "Разделить"))) return;
|
||||
unmergeBtn.disabled = true; unmergeBtn.textContent = "Разделяю...";
|
||||
try {
|
||||
let ids = await DB.unmergeGpsSession(sessionId);
|
||||
if (!ids) { showToast("Это не склейка"); unmergeBtn.disabled = false; unmergeBtn.textContent = `Разделить склейку (${n})`; return; }
|
||||
let w = session.rider_weight_kg ?? state.latestWeight?.weight_kg ?? state.profile?.weight_kg ?? 80;
|
||||
for (let id of ids) {
|
||||
let pts = await DB.getGpsPoints(id);
|
||||
if (!pts.length) continue;
|
||||
let st = GPS.computeStats(pts, w, { activityType: session.activity_type });
|
||||
let endT = new Date(pts[pts.length - 1].ts).toTimeString().slice(0, 5);
|
||||
try { await DB.finishGpsSession(id, endT, st.distance, st.kcal, st.avgSpeed, st.maxSpeed, st.elevGain, st.elevLoss, []); } catch (e) { console.error("[unmerge] recompute", e); }
|
||||
}
|
||||
showToast(`Разделено на ${ids.length} трека`);
|
||||
if (winStack.length > 0) winStack.pop().close();
|
||||
_skipPops = 1; history.back(); renderScreen("gps");
|
||||
} catch (e) { showToast("Ошибка: " + (e.message || e)); unmergeBtn.disabled = false; unmergeBtn.textContent = `Разделить склейку (${n})`; }
|
||||
};
|
||||
})();
|
||||
|
||||
// Recalculate button
|
||||
let recalcBtn = document.querySelector("#sd-recalc");
|
||||
if (recalcBtn) recalcBtn.onclick = async () => {
|
||||
|
|
@ -6575,7 +6650,7 @@ async function showCompareModal(sessionId, currentSession, currentPoints) {
|
|||
return;
|
||||
}
|
||||
|
||||
let actNames = { bike: "Вело", walk: "Ходьба", run: "Бег", other: "Другое" };
|
||||
let actNames = { bike: "Вело", walk: "Ходьба", run: "Бег", workout: "Зарядка", vr: "Руки/VR", other: "Другое" };
|
||||
showModal(`
|
||||
<h3>Сравнить с...</h3>
|
||||
<div class="compare-list">
|
||||
|
|
@ -6669,7 +6744,8 @@ async function showComparisonView(currentId, compareId, currentSession, currentP
|
|||
mapEl._leafletMap = map;
|
||||
addZoomDisplay(map);
|
||||
let CachedLayer = TileCache.createLayer();
|
||||
new CachedLayer({ maxZoom: 19 }).addTo(map);
|
||||
let tileLayer = new CachedLayer({ maxZoom: 19 }).addTo(map);
|
||||
TileCache.startHealer(map, tileLayer, null); // heal visible tiles when OSM is reachable
|
||||
|
||||
// Build speed lookup for older track
|
||||
let olderCoords = olderPoints.map(p => [p.lat, p.lon]);
|
||||
|
|
@ -7289,6 +7365,10 @@ async function renderSettings(container) {
|
|||
<div class="settings-item-left"><div class="settings-item-title">Цвет дорог: бег</div></div>
|
||||
<input id="s-road-color-run" type="color" value="${GPS.config.roadColorRun || '#e67e22'}" style="width:40px;height:28px;border:none;background:none;padding:0">
|
||||
</div>
|
||||
<div class="settings-item">
|
||||
<div class="settings-item-left"><div class="settings-item-title">Толщина дорог по проездам</div><div class="settings-item-desc">Чаще проезжал - толще линия (x1..x3, +0.2 за проезд)</div></div>
|
||||
<input id="s-roads-thick" type="checkbox" ${localStorage.getItem("baldav_roads_thick") !== "0" ? "checked" : ""}>
|
||||
</div>
|
||||
<div class="settings-item">
|
||||
<div class="settings-item-left">
|
||||
<div class="settings-item-title">Fused-локация (Google Play)</div>
|
||||
|
|
@ -7389,6 +7469,13 @@ async function renderSettings(container) {
|
|||
<div class="settings-item-desc">Не предлагать включить GPS при старте записи трека</div></div>
|
||||
<input id="s-dontask-gps" type="checkbox" ${localStorage.getItem("baldav_dontask_gps") === "1" ? "checked" : ""} style="width:20px;height:20px;">
|
||||
</label>
|
||||
<div class="settings-item" id="s-strap-usage">
|
||||
<div class="settings-item-left">
|
||||
<div class="settings-item-title">Ремень ЧСС: наработка</div>
|
||||
<div class="settings-item-desc" id="s-strap-usage-desc">—</div>
|
||||
</div>
|
||||
<button class="btn-secondary" id="s-strap-reset" style="padding:6px 12px;font-size:13px">Заряжен</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
|
|
@ -7679,6 +7766,8 @@ async function renderSettings(container) {
|
|||
GPS.config.roadColorBike = container.querySelector("#s-road-color-bike").value;
|
||||
GPS.config.roadColorWalk = container.querySelector("#s-road-color-walk").value;
|
||||
GPS.config.roadColorRun = container.querySelector("#s-road-color-run").value;
|
||||
let roadsThickEl = container.querySelector("#s-roads-thick");
|
||||
if (roadsThickEl) localStorage.setItem("baldav_roads_thick", roadsThickEl.checked ? "1" : "0");
|
||||
let trackColorEl = container.querySelector("#s-track-color");
|
||||
if (trackColorEl) GPS.config.trackColor = trackColorEl.value;
|
||||
let fusedEl = container.querySelector("#s-use-fused");
|
||||
|
|
@ -7828,6 +7917,33 @@ async function renderSettings(container) {
|
|||
let dontAskGps = container.querySelector("#s-dontask-gps");
|
||||
if (dontAskGps) dontAskGps.onchange = () => { try { localStorage.setItem("baldav_dontask_gps", dontAskGps.checked ? "1" : "0"); } catch {} };
|
||||
|
||||
// Strap usage readout: total HR-delivering time since last "charged" reset + battery steps,
|
||||
// so we can judge real battery life against the claimed 300h (the % reading is coarse/laggy).
|
||||
let strapDesc = container.querySelector("#s-strap-usage-desc");
|
||||
let fillStrapUsage = async () => {
|
||||
if (!strapDesc) return;
|
||||
try {
|
||||
let raw = await window.__TAURI__.core.invoke("ble_read_strap_uptime");
|
||||
let u = JSON.parse(raw || "{}");
|
||||
let sec = Math.round(u.active_sec || 0);
|
||||
let h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60);
|
||||
let bat = (u.bat != null) ? `${u.bat}%` : "?";
|
||||
let steps = Array.isArray(u.steps) ? u.steps : [];
|
||||
let since = u.since ? new Date(u.since * 1000).toLocaleDateString("ru-RU") : "—";
|
||||
let stepStr = steps.length
|
||||
? " · шаги: " + steps.map(s => `${s.bat}%`).join("->")
|
||||
: " · шагов заряда нет (ещё 100%?)";
|
||||
strapDesc.textContent = `заряд ${bat} · в работе ${h}ч ${m}м с ${since}${stepStr}`;
|
||||
} catch (e) { strapDesc.textContent = "нет данных"; }
|
||||
};
|
||||
fillStrapUsage();
|
||||
let strapReset = container.querySelector("#s-strap-reset");
|
||||
if (strapReset) strapReset.onclick = async () => {
|
||||
if (!(await appConfirm("Сбросить счётчик наработки ремня (отметить как заряженный)?", "Сбросить"))) return;
|
||||
try { await window.__TAURI__.core.invoke("ble_reset_strap_uptime"); } catch {}
|
||||
fillStrapUsage();
|
||||
};
|
||||
|
||||
let bleScanBtn = container.querySelector("#s-ble-scan");
|
||||
let bleResults = container.querySelector("#s-ble-results");
|
||||
if (bleScanBtn) {
|
||||
|
|
@ -8022,6 +8138,13 @@ function showProfileModal() {
|
|||
<option value="1.725">Высокая (физический труд)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||||
<input id="m-tef" type="checkbox" ${(GPS.config.tefFrac ?? 0) > 0 ? "checked" : ""}>
|
||||
Учитывать переваривание (TEF 10%)
|
||||
</label>
|
||||
<div style="font-size:11px;color:#9aa4ae;margin-top:2px">Часть съеденного тратится на переваривание. Расход масштабируется от еды; на поддержке баланс не меняется.</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Норма воды (мл/день)</label>
|
||||
<input id="m-water-norm" type="number" inputmode="numeric" value="${p.daily_water_ml ?? "2000"}" placeholder="2000" />
|
||||
|
|
@ -8038,6 +8161,8 @@ function showProfileModal() {
|
|||
await DB.setProfileValue("sex", selectedSex);
|
||||
let activityLevel = overlay.querySelector("#m-activity-level").value;
|
||||
await DB.setProfileValue("activity_level", activityLevel);
|
||||
let tefEl = overlay.querySelector("#m-tef");
|
||||
if (tefEl) { GPS.config.tefFrac = tefEl.checked ? 0.10 : 0; GPS.saveConfig(); }
|
||||
let waterNorm = parseInt(overlay.querySelector("#m-water-norm").value) || 2000;
|
||||
await DB.setProfileValue("daily_water_ml", waterNorm);
|
||||
|
||||
|
|
@ -8674,7 +8799,8 @@ async function showRoadsMap() {
|
|||
let map = L.map(mapEl, { preferCanvas: true, attributionControl: false }).setView([47.42, 40.08], 13); // canvas: many road polylines vs SVG compositor tile-memory limit
|
||||
addZoomDisplay(map);
|
||||
let CachedLayer = TileCache.createLayer();
|
||||
new CachedLayer({ maxZoom: 19 }).addTo(map);
|
||||
let tileLayer = new CachedLayer({ maxZoom: 19 }).addTo(map);
|
||||
TileCache.startHealer(map, tileLayer, null); // heal visible tiles when OSM is reachable
|
||||
addDarkMapControl(map); // dark-tiles toggle on the roads map
|
||||
POI.attach(map); // POI overlay + category filter
|
||||
|
||||
|
|
@ -8728,32 +8854,37 @@ async function showRoadsMap() {
|
|||
return;
|
||||
}
|
||||
|
||||
// Split points into segments by gap (>100m) and activity type
|
||||
let segments = []; // { coords: [[lat,lon],...], atype: "b"|"w" }
|
||||
// Split points into segments by gap (>100m) and activity type. Keep point objects (with
|
||||
// `passes`) so line thickness can follow how often each stretch was ridden.
|
||||
let segments = []; // { pts: [{lat,lon,passes}], atype: "b"|"w" }
|
||||
let seg = [];
|
||||
let segType = pts[0]?.atype || "b";
|
||||
for (let i = 0; i < pts.length; i++) {
|
||||
let newType = pts[i].atype || "b";
|
||||
if (seg.length > 0) {
|
||||
let prev = seg[seg.length - 1];
|
||||
let dlat = (pts[i].lat - prev[0]) * 111000;
|
||||
let dlon = (pts[i].lon - prev[1]) * 111000 * Math.cos(pts[i].lat * Math.PI / 180);
|
||||
let dlat = (pts[i].lat - prev.lat) * 111000;
|
||||
let dlon = (pts[i].lon - prev.lon) * 111000 * Math.cos(pts[i].lat * Math.PI / 180);
|
||||
if (dlat * dlat + dlon * dlon > 10000 || newType !== segType) {
|
||||
if (seg.length >= 2) segments.push({ coords: seg, atype: segType });
|
||||
if (seg.length >= 2) segments.push({ pts: seg, atype: segType });
|
||||
seg = [];
|
||||
segType = newType;
|
||||
}
|
||||
}
|
||||
seg.push([pts[i].lat, pts[i].lon]);
|
||||
seg.push(pts[i]);
|
||||
}
|
||||
if (seg.length >= 2) segments.push({ coords: seg, atype: segType });
|
||||
if (seg.length >= 2) segments.push({ pts: seg, atype: segType });
|
||||
|
||||
// Draw: bike = blue, walk = brown
|
||||
// Draw: bike = blue, walk = brown, thickness by pass count. Roads go in their own pane with
|
||||
// a dedicated canvas renderer so the dark-map CSS filter (body.dark-map .leaflet-roadsPane)
|
||||
// can invert/shift road colours the same way it does the tiles — keeps them visible on dark.
|
||||
if (!map.getPane("roadsPane")) { map.createPane("roadsPane"); map.getPane("roadsPane").style.zIndex = 240; }
|
||||
let roadsRenderer = L.canvas({ pane: "roadsPane" });
|
||||
let drawn = [];
|
||||
let allCoords = [];
|
||||
for (let s of segments) {
|
||||
let color = roadColor(s.atype);
|
||||
L.polyline(s.coords, { color, weight: 4, opacity: 1, smoothFactor: 2.5 }).addTo(map); // px-space simplify: fewer points zoomed out
|
||||
allCoords.push(...s.coords);
|
||||
drawRoadSegment(map, s.pts, roadColor(s.atype), drawn, { renderer: roadsRenderer, base: 4, opacity: 1, smoothFactor: 2.5 });
|
||||
for (let p of s.pts) allCoords.push([p.lat, p.lon]);
|
||||
}
|
||||
|
||||
if (allCoords.length > 0) {
|
||||
|
|
|
|||
|
|
@ -216,5 +216,113 @@ TileCache.importFromSqlite = async function(path, onProgress) {
|
|||
return imported;
|
||||
};
|
||||
|
||||
// ---- Online tile healer ---------------------------------------------------
|
||||
// The on-demand loader (createTile) never retries a tile whose fetch failed:
|
||||
// Leaflet marks it loaded-with-error and won't re-request until the tile is
|
||||
// re-created (pan out+back / zoom / redraw). So blanks from a bad moment stay
|
||||
// blank even after the network recovers. The healer fixes that: periodically,
|
||||
// when OSM is reachable, it re-fetches MISSING tiles (current zoom only) for the
|
||||
// visible area and — during recording / track review — along the ridden route,
|
||||
// caches them, and redraws so the holes fill. Respects offline mode. Gentle to
|
||||
// OSM (one-tile probe, throttled fetches, capped per cycle) per their tile usage
|
||||
// policy — we never bulk-download.
|
||||
let _healTimer = null, _healMap = null, _healLayer = null, _healPointsFn = null;
|
||||
let _healing = false, _healCursor = 0, _onlineHandler = null;
|
||||
const HEAL_INTERVAL_MS = 30000, HEAL_THROTTLE_MS = 300;
|
||||
const HEAL_MAX_FETCH = 40; // tiles fetched per cycle (≈1.3/s — modest)
|
||||
const HEAL_MAX_SCAN = 500; // IndexedDB lookups per cycle (bounds CPU on long routes)
|
||||
|
||||
function _sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
|
||||
|
||||
// One small tile tells us OSM is reachable (navigator.onLine gates first — cheap).
|
||||
TileCache.probeOsm = async function() {
|
||||
if (!navigator.onLine) return false;
|
||||
try {
|
||||
const c = new AbortController();
|
||||
const t = setTimeout(() => c.abort(), 6000);
|
||||
const r = await fetch("https://a.tile.openstreetmap.org/0/0/0.png", { cache: "no-store", signal: c.signal });
|
||||
clearTimeout(t);
|
||||
return r.ok;
|
||||
} catch { return false; }
|
||||
};
|
||||
|
||||
function _lonLatToTile(lat, lon, z) {
|
||||
const n = Math.pow(2, z);
|
||||
return {
|
||||
x: Math.floor((lon + 180) / 360 * n),
|
||||
y: Math.floor((1 - Math.log(Math.tan(lat * Math.PI / 180) + 1 / Math.cos(lat * Math.PI / 180)) / Math.PI) / 2 * n),
|
||||
};
|
||||
}
|
||||
|
||||
// Tiles covering a polyline at zoom z (+1 ring around each point so the road's
|
||||
// tiles, not just the exact centre, are covered), de-duplicated.
|
||||
function _tileKeysForLatLngs(latlngs, z) {
|
||||
const seen = new Set();
|
||||
for (const ll of latlngs) {
|
||||
const lat = ll[0], lon = ll[1];
|
||||
if (lat == null || lon == null) continue;
|
||||
const t = _lonLatToTile(lat, lon, z);
|
||||
for (let dx = -1; dx <= 1; dx++) for (let dy = -1; dy <= 1; dy++) seen.add(`${z}/${t.x + dx}/${t.y + dy}`);
|
||||
}
|
||||
return [...seen];
|
||||
}
|
||||
|
||||
function _tileKeysForBounds(bounds, z) {
|
||||
const r = getTileRange(bounds, z);
|
||||
const keys = [];
|
||||
for (let x = r.xMin; x <= r.xMax; x++) for (let y = r.yMin; y <= r.yMax; y++) keys.push(`${z}/${x}/${y}`);
|
||||
return keys;
|
||||
}
|
||||
|
||||
async function _healCycle() {
|
||||
if (_healing || TileCache.offlineMode || !_healMap) return;
|
||||
_healing = true;
|
||||
try {
|
||||
if (!(await TileCache.probeOsm())) return;
|
||||
const z = _healMap.getZoom();
|
||||
// Visible tiles first (fills what you actually see), then the ridden route
|
||||
// from a rotating cursor so a long track trickles across cycles, not in a burst.
|
||||
let keys = _tileKeysForBounds(_healMap.getBounds(), z);
|
||||
const pts = _healPointsFn && _healPointsFn();
|
||||
if (pts && pts.length) {
|
||||
const routeKeys = _tileKeysForLatLngs(pts, z);
|
||||
if (_healCursor >= routeKeys.length) _healCursor = 0;
|
||||
keys = keys.concat(routeKeys.slice(_healCursor), routeKeys.slice(0, _healCursor));
|
||||
}
|
||||
let fetched = 0, scanned = 0, advanced = 0;
|
||||
for (const key of keys) {
|
||||
if (fetched >= HEAL_MAX_FETCH || scanned >= HEAL_MAX_SCAN) break;
|
||||
scanned++; advanced++;
|
||||
if (await getTile(key)) continue; // already cached
|
||||
try {
|
||||
const r = await fetch(`https://a.tile.openstreetmap.org/${key}.png`, { cache: "no-store" });
|
||||
if (r.ok) { await putTile(key, await r.blob()); fetched++; }
|
||||
} catch { /* leave for a later cycle */ }
|
||||
await _sleep(HEAL_THROTTLE_MS);
|
||||
}
|
||||
if (pts && pts.length) _healCursor += advanced; // continue the route next cycle
|
||||
if (fetched && _healLayer && _healLayer.redraw) _healLayer.redraw();
|
||||
} finally { _healing = false; }
|
||||
}
|
||||
|
||||
// Start healing for a map. layer = the tile layer to redraw once holes fill.
|
||||
// pointsFn() → [[lat,lon],…] of the ridden route (recording / track review), or
|
||||
// null to heal only the visible area (e.g. the roads screen).
|
||||
TileCache.startHealer = function(map, layer, pointsFn) {
|
||||
TileCache.stopHealer();
|
||||
_healMap = map; _healLayer = layer; _healPointsFn = pointsFn || null; _healCursor = 0;
|
||||
_healTimer = setInterval(_healCycle, HEAL_INTERVAL_MS);
|
||||
setTimeout(_healCycle, 3000); // first pass soon after the map opens
|
||||
_onlineHandler = () => _healCycle(); // and immediately when the link returns
|
||||
window.addEventListener("online", _onlineHandler);
|
||||
if (map && map.on) map.on("unload", TileCache.stopHealer);
|
||||
};
|
||||
|
||||
TileCache.stopHealer = function() {
|
||||
if (_healTimer) { clearInterval(_healTimer); _healTimer = null; }
|
||||
if (_onlineHandler) { window.removeEventListener("online", _onlineHandler); _onlineHandler = null; }
|
||||
_healMap = _healLayer = _healPointsFn = null; _healCursor = 0;
|
||||
};
|
||||
|
||||
window.TileCache = TileCache;
|
||||
console.log("[tilecache.js] window.TileCache ready, offline:", TileCache.offlineMode);
|
||||
|
|
|
|||
2
src-tauri/Cargo.lock
generated
2
src-tauri/Cargo.lock
generated
|
|
@ -139,7 +139,7 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
|||
|
||||
[[package]]
|
||||
name = "baldav"
|
||||
version = "0.1.496"
|
||||
version = "0.1.513"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"btleplug",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "baldav"
|
||||
version = "0.1.496"
|
||||
version = "0.1.513"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@ import android.os.ParcelUuid
|
|||
import android.util.Log
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
class BleHrService : Service() {
|
||||
|
||||
|
|
@ -59,6 +61,19 @@ class BleHrService : Service() {
|
|||
private var gatt: BluetoothGatt? = null
|
||||
private var deviceType = "hr_standard"
|
||||
private var dataFile: File? = null
|
||||
private var logFile: File? = null // append-only HR history (ble_hr_log.jsonl); survives screen-off
|
||||
private var lastAddress: String? = null // for auto-reconnect of a DROPPED connection
|
||||
private var connectedOnce = false // a real connection (services discovered) happened — only then do we reconnect
|
||||
private var stopping = false // user / BT-off asked to stop — never auto-reconnect
|
||||
private var reconnectTries = 0
|
||||
private val maxReconnect = 3 // ride out a brief drop, then give up (don't hammer an absent strap)
|
||||
// Strap usage/battery accounting — persistent across sessions (strap_uptime.json), so we can
|
||||
// compute real battery life ourselves and see the battery-level steps over time.
|
||||
private var uptimeFile: File? = null
|
||||
private var lastReadingMs = 0L // for accruing only time the strap was actually delivering HR
|
||||
private var pendingActiveMs = 0L
|
||||
private var lastUptimeFlushMs = 0L
|
||||
private var lastBatLogged = -1
|
||||
private var lastBattery: Int = -1 // strap charge %, -1 = unknown
|
||||
private var scanResultsFile: File? = null
|
||||
private var fitproWriteChar: BluetoothGattCharacteristic? = null
|
||||
|
|
@ -75,6 +90,7 @@ class BleHrService : Service() {
|
|||
val state = intent.getIntExtra(BluetoothAdapter.EXTRA_STATE, BluetoothAdapter.ERROR)
|
||||
if (state == BluetoothAdapter.STATE_TURNING_OFF || state == BluetoothAdapter.STATE_OFF) {
|
||||
Log.d(TAG, "Bluetooth turning off - stopping BLE service")
|
||||
stopping = true // BT off: do not auto-reconnect
|
||||
handler.removeCallbacksAndMessages(null)
|
||||
disconnect()
|
||||
writeData("""{"status":"bt_off","ts":${System.currentTimeMillis()/1000}}""")
|
||||
|
|
@ -98,6 +114,7 @@ class BleHrService : Service() {
|
|||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
when (intent?.action) {
|
||||
ACTION_STOP -> {
|
||||
stopping = true // user-initiated stop: do not auto-reconnect
|
||||
disconnect()
|
||||
stopSelf()
|
||||
return START_NOT_STICKY
|
||||
|
|
@ -115,6 +132,10 @@ class BleHrService : Service() {
|
|||
isRunning = true
|
||||
val appDataDir = filesDir.parentFile ?: filesDir
|
||||
dataFile = File(appDataDir, "ble_hr_data.json")
|
||||
logFile = File(appDataDir, "ble_hr_log.jsonl")
|
||||
uptimeFile = File(appDataDir, "strap_uptime.json")
|
||||
// fresh user-initiated connect: arm reconnect only after it actually connects
|
||||
lastAddress = address; stopping = false; connectedOnce = false; reconnectTries = 0
|
||||
connectDevice(address)
|
||||
}
|
||||
return START_NOT_STICKY // connect is user-initiated; no sticky restart
|
||||
|
|
@ -247,7 +268,19 @@ class BleHrService : Service() {
|
|||
Log.d(TAG, "Disconnected")
|
||||
isRunning = false
|
||||
writeData("""{"status":"disconnected","ts":${System.currentTimeMillis()/1000}}""")
|
||||
stopSelf()
|
||||
try { g.close() } catch (e: Exception) { Log.e(TAG, "gatt close on drop: ${e.message}") }
|
||||
gatt = null
|
||||
// Reconnect ONLY a connection that was really established (connectedOnce) and that the
|
||||
// user didn't stop. The reconnect goes through connectDevice's scan — if the strap is
|
||||
// gone it won't be found and we stopSelf (so we never hammer an absent/never-present band).
|
||||
if (!stopping && connectedOnce && reconnectTries < maxReconnect && adapter()?.isEnabled == true && lastAddress != null) {
|
||||
reconnectTries++
|
||||
Log.d(TAG, "Auto-reconnect $reconnectTries/$maxReconnect in 8s")
|
||||
updateNotification("Переподключение...")
|
||||
handler.postDelayed({ if (!stopping) connectDevice(lastAddress!!) }, 8000)
|
||||
} else {
|
||||
stopSelf()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -266,6 +299,8 @@ class BleHrService : Service() {
|
|||
Log.d(TAG, "Auto-detected device type: $deviceType")
|
||||
}
|
||||
Log.d(TAG, "Services discovered, setting up for $deviceType")
|
||||
connectedOnce = true; reconnectTries = 0 // real connection established — arm/refresh reconnect budget
|
||||
isRunning = true
|
||||
when (deviceType) {
|
||||
"hr_standard" -> setupStandardHr(g)
|
||||
"fitpro" -> setupFitPro(g)
|
||||
|
|
@ -498,10 +533,53 @@ class BleHrService : Service() {
|
|||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Write data failed", e)
|
||||
}
|
||||
// Append real HR readings (not status lines) to the history log. This runs in the
|
||||
// foreground service, so it keeps recording with the screen off — the workout
|
||||
// recorder rebuilds its samples from here (ble_read_hr_log) instead of the JS poll.
|
||||
if (json.contains("\"hr\":")) {
|
||||
try { logFile?.appendText(json + "\n") } catch (e: Exception) { Log.e(TAG, "Append hr log failed", e) }
|
||||
accrueUptime()
|
||||
}
|
||||
}
|
||||
|
||||
// Accrue strap "actually delivering HR" time + battery-level steps into strap_uptime.json
|
||||
// (persistent — NOT cleared per session). Flushed every ~20s (read-modify-write, small file).
|
||||
private fun accrueUptime() {
|
||||
val now = System.currentTimeMillis()
|
||||
val dt = now - lastReadingMs
|
||||
if (lastReadingMs > 0L && dt in 1..10000) pendingActiveMs += dt // only count gap-free stretches
|
||||
lastReadingMs = now
|
||||
if (now - lastUptimeFlushMs < 20000) return
|
||||
writeUptimeFile()
|
||||
}
|
||||
|
||||
// Persist accrued active time + battery step to strap_uptime.json (read-modify-write).
|
||||
private fun writeUptimeFile() {
|
||||
val now = System.currentTimeMillis()
|
||||
lastUptimeFlushMs = now
|
||||
val f = uptimeFile ?: return
|
||||
try {
|
||||
val obj = if (f.exists() && f.length() > 0) JSONObject(f.readText()) else JSONObject()
|
||||
obj.put("active_sec", obj.optDouble("active_sec", 0.0) + pendingActiveMs / 1000.0)
|
||||
if (!obj.has("since")) obj.put("since", now / 1000)
|
||||
val bat = lastBattery
|
||||
if (bat in 0..100) {
|
||||
obj.put("bat", bat)
|
||||
if (bat != lastBatLogged) {
|
||||
val steps = obj.optJSONArray("steps") ?: JSONArray()
|
||||
steps.put(JSONObject().put("ts", now / 1000).put("bat", bat))
|
||||
obj.put("steps", steps)
|
||||
lastBatLogged = bat
|
||||
}
|
||||
}
|
||||
f.writeText(obj.toString())
|
||||
pendingActiveMs = 0L
|
||||
} catch (e: Exception) { Log.e(TAG, "uptime flush: ${e.message}") }
|
||||
}
|
||||
|
||||
private fun disconnect() {
|
||||
isRunning = false
|
||||
try { writeUptimeFile() } catch (e: Exception) {} // persist the last accrued strap time
|
||||
handler.removeCallbacksAndMessages(null) // no stray callback may touch GATT after this
|
||||
try { gatt?.disconnect() } catch (e: Exception) { Log.e(TAG, "gatt.disconnect: ${e.message}") }
|
||||
try { gatt?.close() } catch (e: Exception) { Log.e(TAG, "gatt.close: ${e.message}") }
|
||||
|
|
@ -536,6 +614,7 @@ class BleHrService : Service() {
|
|||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
stopping = true
|
||||
try { unregisterReceiver(btStateReceiver) } catch (e: Exception) { /* not registered / already gone */ }
|
||||
disconnect()
|
||||
super.onDestroy()
|
||||
|
|
|
|||
BIN
src-tauri/icons/icon.ico
Normal file
BIN
src-tauri/icons/icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 97 KiB |
|
|
@ -465,6 +465,44 @@ pub fn ble_get_hr(
|
|||
{ android::read_hr_data(&app) }
|
||||
}
|
||||
|
||||
// Full HR history for the current workout. The Kotlin foreground service appends every
|
||||
// reading to ble_hr_log.jsonl (one {"hr","ts"[,"bat"]} per line) — that keeps logging with
|
||||
// the screen off, unlike the JS poll which Android freezes in the background. The workout
|
||||
// recorder reads this at finalize to rebuild a gap-free sample series. Returns "" if none.
|
||||
#[tauri::command]
|
||||
pub fn ble_read_hr_log(app: tauri::AppHandle) -> Result<String, String> {
|
||||
#[cfg(not(target_os = "android"))]
|
||||
{ let _ = &app; Ok(String::new()) }
|
||||
#[cfg(target_os = "android")]
|
||||
{ Ok(android::read_file(&app, "ble_hr_log.jsonl").unwrap_or_default()) }
|
||||
}
|
||||
|
||||
// Strap usage accounting (strap_uptime.json): total time the strap actually delivered HR plus
|
||||
// battery-level steps, accumulated by the foreground service across sessions (persistent — so we
|
||||
// can compute real battery life ourselves). Returns "{}" if none yet.
|
||||
#[tauri::command]
|
||||
pub fn ble_read_strap_uptime(app: tauri::AppHandle) -> Result<String, String> {
|
||||
#[cfg(not(target_os = "android"))]
|
||||
{ let _ = &app; Ok("{}".to_string()) }
|
||||
#[cfg(target_os = "android")]
|
||||
{ Ok(android::read_file(&app, "strap_uptime.json").unwrap_or_else(|| "{}".to_string())) }
|
||||
}
|
||||
|
||||
// Reset the usage counter — call when the strap is put on charge / newly charged.
|
||||
#[tauri::command]
|
||||
pub fn ble_reset_strap_uptime(app: tauri::AppHandle) -> Result<(), String> {
|
||||
use tauri::Manager;
|
||||
let data_dir = app.path().app_data_dir().map_err(|e: tauri::Error| e.to_string())?;
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs();
|
||||
let fresh = format!("{{\"active_sec\":0,\"since\":{},\"steps\":[]}}", now);
|
||||
let _ = std::fs::write(data_dir.join("strap_uptime.json"), &fresh);
|
||||
if let Some(root) = data_dir.parent().and_then(|p| p.parent()) {
|
||||
let _ = std::fs::write(root.join("strap_uptime.json"), &fresh);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn ble_is_connected(
|
||||
app: tauri::AppHandle,
|
||||
|
|
@ -488,10 +526,14 @@ pub fn ble_is_connected(
|
|||
pub fn ble_clear_hr(app: tauri::AppHandle) -> Result<(), String> {
|
||||
use tauri::Manager;
|
||||
let data_dir = app.path().app_data_dir().map_err(|e: tauri::Error| e.to_string())?;
|
||||
// Clear HR data file so stale values don't show on next session
|
||||
// Clear HR data file so stale values don't show on next session, and truncate the
|
||||
// native HR history log (the foreground service appends every reading there — see
|
||||
// ble_read_hr_log) so a new workout starts from an empty log.
|
||||
let _ = std::fs::write(data_dir.join("ble_hr_data.json"), "{}");
|
||||
let _ = std::fs::write(data_dir.join("ble_hr_log.jsonl"), "");
|
||||
if let Some(root) = data_dir.parent().and_then(|p| p.parent()) {
|
||||
let _ = std::fs::write(root.join("ble_hr_data.json"), "{}");
|
||||
let _ = std::fs::write(root.join("ble_hr_log.jsonl"), "");
|
||||
}
|
||||
#[cfg(not(target_os = "android"))]
|
||||
{
|
||||
|
|
|
|||
|
|
@ -265,59 +265,72 @@ fn rebuild_roads(app: tauri::AppHandle, min_speed_ms: f64) -> Result<String, Str
|
|||
let db = rusqlite::Connection::open(&db_path).map_err(|e| format!("open: {e}"))?;
|
||||
|
||||
db.execute("DROP TABLE IF EXISTS road_points", []).ok();
|
||||
db.execute("CREATE TABLE road_points (id INTEGER PRIMARY KEY, lat REAL NOT NULL, lon REAL NOT NULL, atype TEXT)", [])
|
||||
// `passes` = how many distinct times this ~2 m stretch was ridden (used for line thickness).
|
||||
db.execute("CREATE TABLE road_points (id INTEGER PRIMARY KEY, lat REAL NOT NULL, lon REAL NOT NULL, atype TEXT, passes INTEGER NOT NULL DEFAULT 1)", [])
|
||||
.map_err(|e| format!("create: {e}"))?;
|
||||
db.execute("CREATE INDEX idx_road_lat_lon ON road_points(lat, lon)", [])
|
||||
.map_err(|e| format!("index: {e}"))?;
|
||||
|
||||
// 2m in degrees: lat ~0.000018, lon ~0.000027 at 47N
|
||||
// ~2 m cell in degrees at 47N: lat ~0.000018, lon ~0.000027.
|
||||
let d_lat: f64 = 0.000018;
|
||||
let d_lon: f64 = 0.000027;
|
||||
|
||||
// A "walking" point inside a ride (walk=1) is classified as walk, not the session's
|
||||
// activity — so pushed-across-a-field bits don't become a rideable road.
|
||||
// activity — so pushed-across-a-field bits don't become a rideable road. session_id is
|
||||
// read too, so a cell re-entered in a later session counts as another pass.
|
||||
let mut read_stmt = db.prepare(
|
||||
"SELECT p.lat, p.lon, CASE WHEN COALESCE(p.walk,0)=1 THEN 'walk' ELSE s.activity_type END
|
||||
"SELECT p.lat, p.lon, CASE WHEN COALESCE(p.walk,0)=1 THEN 'walk' ELSE s.activity_type END, p.session_id
|
||||
FROM gps_points p
|
||||
JOIN gps_sessions s ON s.id = p.session_id
|
||||
WHERE p.speed_ms >= ?1
|
||||
ORDER BY p.session_id, p.seq"
|
||||
).map_err(|e| format!("prepare read: {e}"))?;
|
||||
|
||||
let points: Vec<(f64, f64, String)> = read_stmt.query_map(rusqlite::params![min_speed_ms], |row| {
|
||||
Ok((row.get::<_, f64>(0)?, row.get::<_, f64>(1)?, row.get::<_, String>(2).unwrap_or_default()))
|
||||
let points: Vec<(f64, f64, String, i64)> = read_stmt.query_map(rusqlite::params![min_speed_ms], |row| {
|
||||
Ok((row.get::<_, f64>(0)?, row.get::<_, f64>(1)?, row.get::<_, String>(2).unwrap_or_default(), row.get::<_, i64>(3).unwrap_or(-1)))
|
||||
}).map_err(|e| format!("query: {e}"))?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
|
||||
drop(read_stmt);
|
||||
|
||||
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 (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],
|
||||
|r| r.get(0),
|
||||
).unwrap_or(false);
|
||||
|
||||
if !exists {
|
||||
db.execute(
|
||||
"INSERT INTO road_points (lat, lon, atype) VALUES (?1, ?2, ?3)",
|
||||
rusqlite::params![lat, lon, atype],
|
||||
).map_err(|e| format!("insert: {e}"))?;
|
||||
inserted += 1;
|
||||
// Glue + count passes in memory: one cell per ~2 m grid square, first-seen geometry kept,
|
||||
// `passes` incremented once per ENTRY into the cell (not per point, so a slow crawl that
|
||||
// drops many points in one cell still counts as a single pass). Leaving and re-entering
|
||||
// the cell — or a new session — is a fresh entry. Much faster than a per-point SQL EXISTS.
|
||||
use std::collections::HashMap;
|
||||
let mut index: HashMap<(i64, i64), usize> = HashMap::new();
|
||||
let mut cells: Vec<(f64, f64, String, u32)> = Vec::new(); // lat, lon, atype, passes
|
||||
let mut prev_key: Option<(i64, i64)> = None;
|
||||
let mut prev_session: i64 = i64::MIN;
|
||||
for (i, (lat, lon, atype, session)) in points.iter().enumerate() {
|
||||
if i % 20000 == 0 { let _ = app.emit("roads_progress", (i as u64, total as u64)); }
|
||||
let key = ((lat / d_lat).floor() as i64, (lon / d_lon).floor() as i64);
|
||||
let entry = prev_key != Some(key) || *session != prev_session;
|
||||
match index.get(&key) {
|
||||
Some(&idx) => { if entry { cells[idx].3 += 1; } }
|
||||
None => { index.insert(key, cells.len()); cells.push((*lat, *lon, atype.clone(), 1)); }
|
||||
}
|
||||
prev_key = Some(key);
|
||||
prev_session = *session;
|
||||
}
|
||||
|
||||
db.execute("BEGIN", []).map_err(|e| format!("begin: {e}"))?;
|
||||
{
|
||||
let mut ins = db.prepare("INSERT INTO road_points (lat, lon, atype, passes) VALUES (?1, ?2, ?3, ?4)")
|
||||
.map_err(|e| format!("prepare insert: {e}"))?;
|
||||
for (lat, lon, atype, passes) in cells.iter() {
|
||||
ins.execute(rusqlite::params![lat, lon, atype, passes])
|
||||
.map_err(|e| format!("insert: {e}"))?;
|
||||
}
|
||||
}
|
||||
db.execute("COMMIT", []).map_err(|e| format!("commit: {e}"))?;
|
||||
let inserted = cells.len();
|
||||
let _ = app.emit("roads_progress", (total as u64, total as u64)); // done
|
||||
Ok(format!("{{\"total_points\":{},\"road_points\":{}}}", points.len(), inserted))
|
||||
Ok(format!("{{\"total_points\":{},\"road_points\":{}}}", total, inserted))
|
||||
}
|
||||
|
||||
// Read road_points in batches — bypasses Tauri SQL plugin's ~28K row limit
|
||||
|
|
@ -337,20 +350,28 @@ fn get_road_points(app: tauri::AppHandle, offset: u32, batch_size: u32) -> Resul
|
|||
let total: u32 = db.query_row(
|
||||
"SELECT count(*) FROM road_points", [], |r| r.get(0)
|
||||
).unwrap_or(0);
|
||||
let mut stmt = db.prepare(
|
||||
"SELECT lat, lon, COALESCE(atype,'bike') FROM road_points ORDER BY id LIMIT ?1 OFFSET ?2"
|
||||
).map_err(|e| format!("prepare: {e}"))?;
|
||||
// `passes` column exists only after a rebuild on this build; tolerate older DBs (passes=1).
|
||||
let has_passes: bool = db.query_row(
|
||||
"SELECT COUNT(*) FROM pragma_table_info('road_points') WHERE name='passes'", [], |r| r.get::<_, i64>(0)
|
||||
).map(|n| n > 0).unwrap_or(false);
|
||||
let sql = if has_passes {
|
||||
"SELECT lat, lon, COALESCE(atype,'bike'), COALESCE(passes,1) FROM road_points ORDER BY id LIMIT ?1 OFFSET ?2"
|
||||
} else {
|
||||
"SELECT lat, lon, COALESCE(atype,'bike'), 1 FROM road_points ORDER BY id LIMIT ?1 OFFSET ?2"
|
||||
};
|
||||
let mut stmt = db.prepare(sql).map_err(|e| format!("prepare: {e}"))?;
|
||||
let mut points = Vec::new();
|
||||
let rows = stmt.query_map(rusqlite::params![batch_size, offset], |row| {
|
||||
let lat: f64 = row.get(0)?;
|
||||
let lon: f64 = row.get(1)?;
|
||||
let atype: String = row.get(2)?;
|
||||
Ok((lat, lon, atype))
|
||||
let passes: i64 = row.get(3)?;
|
||||
Ok((lat, lon, atype, passes))
|
||||
}).map_err(|e| format!("query: {e}"))?;
|
||||
for row in rows {
|
||||
let (lat, lon, atype) = row.map_err(|e| format!("row: {e}"))?;
|
||||
let (lat, lon, atype, passes) = row.map_err(|e| format!("row: {e}"))?;
|
||||
let t = if atype == "walk" { "w" } else if atype == "run" { "r" } else { "b" };
|
||||
points.push(format!("[{:.7},{:.7},\"{}\"]", lat, lon, t));
|
||||
points.push(format!("[{:.7},{:.7},\"{}\",{}]", lat, lon, t, passes));
|
||||
}
|
||||
let has_more = offset + batch_size < total;
|
||||
Ok(format!("{{\"points\":[{}],\"total\":{},\"has_more\":{}}}", points.join(","), total, has_more))
|
||||
|
|
@ -1162,7 +1183,7 @@ pub fn run() {
|
|||
.add_migrations("sqlite:baldav.db", migrations)
|
||||
.build(),
|
||||
)
|
||||
.invoke_handler(tauri::generate_handler![get_safe_insets, import_seed_products, import_tiles, download_file, get_points_filename, delete_points_file, gps_set_session_id, list_points_files, read_points_file, pick_file_open, get_picked_file, debug_native_paths, rebuild_roads, get_road_points, get_ride_graph, get_app_info, gps_service_command, gps_service_read_points, brouter_route, brouter_download_tile, brouter_download_status, brouter_list_tiles, save_file, export_image, read_file_base64, export_db, import_db, restart_app, get_update_info, http_request, chzn_http, ble::ble_scan, ble::ble_scan_results, ble::ble_connect, ble::ble_disconnect, ble::ble_get_hr, ble::ble_is_connected, ble::ble_check_bluetooth, ble::ble_save_device, ble::ble_clear_hr, ble::ble_request_enable, read_state_file])
|
||||
.invoke_handler(tauri::generate_handler![get_safe_insets, import_seed_products, import_tiles, download_file, get_points_filename, delete_points_file, gps_set_session_id, list_points_files, read_points_file, pick_file_open, get_picked_file, debug_native_paths, rebuild_roads, get_road_points, get_ride_graph, get_app_info, gps_service_command, gps_service_read_points, brouter_route, brouter_download_tile, brouter_download_status, brouter_list_tiles, save_file, export_image, read_file_base64, export_db, import_db, restart_app, get_update_info, http_request, chzn_http, ble::ble_scan, ble::ble_scan_results, ble::ble_connect, ble::ble_disconnect, ble::ble_get_hr, ble::ble_is_connected, ble::ble_check_bluetooth, ble::ble_save_device, ble::ble_clear_hr, ble::ble_read_hr_log, ble::ble_read_strap_uptime, ble::ble_reset_strap_uptime, ble::ble_request_enable, read_state_file])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"$schema": "https://raw.githubusercontent.com/nicegui/tauri-v2/refs/heads/main/tooling/cli/schema.json",
|
||||
"productName": "BalDav",
|
||||
"version": "0.1.496",
|
||||
"version": "0.1.513",
|
||||
"identifier": "dev.baldav.app",
|
||||
"build": {
|
||||
"frontendDist": "../frontend"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue