GPS: safe on-device recovery, net-kcal model, poll/watchPosition dedup
- native_root(): pick app root by presence of gps_live/gps_points files. app_data_dir() is the root directly on this Tauri; the old .parent().parent() read the wrong dir, so background (screen-off) points were never imported. - recoverFromFiles: match file->session by id/date, plus session id stamped into the native file name (gps_points_<ms>_s<id>.jsonl) for exact binding (gps_set_session_id -> Kotlin bindSessionIfPending). Proximity dedup +-2.5s, recalc with noDelete (never runs the destructive glitch/>24h filter that once wiped real rides), delete already-imported files. Progress bar UI. - bike kcal is NET (daily balance already books BMR*activityLevel): pedaling = max(physics power/efficiency, MET-net by speed), coasting (power<=0, the bike would roll unpowered) = basal only. Elevation via cumulative hysteresis. recomputeAllStats + "recompute elevation/kcal" menu refresh stored tracks without touching points. - live recording: the native file is the DB source of truth; watchPosition only drives the live marker, so a fix is not stored twice with two timestamps. - track list pagination, SAF local-file picker, tile regions from router.
This commit is contained in:
parent
84a2b786ca
commit
56c2ff4ec7
12 changed files with 854 additions and 190 deletions
10
.gitignore
vendored
10
.gitignore
vendored
|
|
@ -12,3 +12,13 @@ fitotrack_src/
|
|||
fitotrack_v156/
|
||||
fitotrack_*.apk
|
||||
de.tadris.fitness_*.tar.gz
|
||||
|
||||
# Offline map artifacts: raw PNG tiles and packed tile DBs (regenerable, large)
|
||||
data/tiles/
|
||||
tiles_*.db
|
||||
|
||||
# Local DB backups pulled from the phone
|
||||
db_backups/
|
||||
|
||||
# Session-local analysis harnesses, copied DBs, pulled 50Hz sensor dumps (large)
|
||||
test_recovery/
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@
|
|||
|------|-------|------------|
|
||||
| `js/main.js` | ~5800 | Экраны, навигация, модалки, графики, нутриенты, HR UI, компас (EMA + gimbal lock), ландшафт (3 режима), дороги |
|
||||
| `js/db.js` | ~1500 | SQLite через Tauri IPC. Миграции, CRUD, 41 поле добавок, water_pct, reserves, sync |
|
||||
| `js/gps.js` | ~1400 | GPS запись, физика ккал (power model), altitude filter, BLE HR polling, dead reckoning. Импорт нативных точек: JS-поллинг (экран-gated) + ПОЛНЫЙ до-импорт по множеству ts на «Стоп»/recoverSession (без потерь при экране-офф), отбор по точности (minAccuracyM), bearing_deg |
|
||||
| `js/gps.js` | ~1600 | GPS запись, ккал (нетто-модель + MET-нетто floor, накат=базовый), altitude filter + гистерезис высоты, BLE HR polling, dead reckoning. Источник истины БД при живой записи — нативный файл (`_processIncomingPoint(p, fromNative)`: watchPosition только рисует, поллинг пишет). `recoverFromFiles` — восстановление точек с устройства (матч по id/дате + `_s<id>` в имени, дедуп ±2.5с, `recalculateSession(noDelete)`, чистка импортированных файлов). `recomputeAllStats` — пересчёт статы всех треков без изменения точек. Отбор по точности (minAccuracyM), bearing_deg |
|
||||
| `js/sync.js` | ~300 | Синхронизация между устройствами (LAN, PHP сервер на роутере) |
|
||||
| `js/tilecache.js` | 178 | Оффлайн кэш тайлов карты (IndexedDB) |
|
||||
| `js/bmr.js` | ~70 | BMR (Mifflin-St Jeor), РСП из БД (33 нутриента), полураспад |
|
||||
|
|
@ -38,7 +38,7 @@
|
|||
|
||||
| Файл | Строк | Назначение |
|
||||
|------|-------|------------|
|
||||
| `src/lib.rs` | ~700 | Миграции v1, IPC команды, seed import, update check, tile import, road map rebuild |
|
||||
| `src/lib.rs` | ~750 | Миграции 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()` читал мимо, отсюда потеря фоновых точек) |
|
||||
| `src/ble.rs` | ~470 | BLE HR: btleplug (desktop), file IPC (Android), FitPro + Standard HR |
|
||||
| `src/main.rs` | 5 | Точка входа |
|
||||
| `Cargo.toml` | — | tauri, tauri-plugin-sql, rusqlite, serde, btleplug, tokio, uuid |
|
||||
|
|
@ -49,7 +49,7 @@
|
|||
| Файл | Строк | Назначение |
|
||||
|------|-------|------------|
|
||||
| `MainActivity.kt` | ~300 | FileObserver IPC, seed DB, safe insets, SAF, BLE commands, OTA check |
|
||||
| `GpsRecorderService.kt` | ~500 | Foreground service GPS, WakeLock, JSONL (один файл на сессию до Стоп, идемпотентный onStartCommand при START_STICKY-перезапуске ОС), 50Hz акселерометр/гироскоп, dead reckoning, raw sensor files |
|
||||
| `GpsRecorderService.kt` | ~530 | Foreground service GPS, WakeLock, JSONL (один файл на сессию до Стоп, идемпотентный onStartCommand при START_STICKY-перезапуске ОС; `bindSessionIfPending()` дописывает id сессии в имя файла `_s<id>` по `gps_session.txt`), 50Hz акселерометр/гироскоп, dead reckoning, raw sensor files |
|
||||
| `BleHrService.kt` | ~340 | Foreground service BLE HR, FitPro protocol, GATT queue, scan/connect |
|
||||
| `generated/*.kt` | ~1100 | Tauri-сгенерированные: WebView, IPC, permissions |
|
||||
|
||||
|
|
@ -146,7 +146,11 @@
|
|||
| `import_seed_products` | Импорт продуктов из seed DB, прогресс-события |
|
||||
| `get_safe_insets` | Отступы Android status/nav bar |
|
||||
| `gps_service_command` | start/stop GPS + BLE команды (ble_scan/connect/disconnect) |
|
||||
| `gps_service_read_points` | Чтение JSONL файла точек |
|
||||
| `gps_service_read_points` | Чтение активного JSONL файла точек (через `native_root`) |
|
||||
| `gps_set_session_id` | Передать id сессии сервису (пишет `gps_session.txt` → сервис дописывает `_s<id>` в имя файла) |
|
||||
| `list_points_files` / `read_points_file` / `delete_points_file` | Список/чтение/удаление нативных `gps_points_*` (восстановление + чистка) |
|
||||
| `get_points_filename` | Имя активного файла точек (по указателю `gps_live_points.jsonl`) |
|
||||
| `debug_native_paths` | Диагностика путей нативных файлов |
|
||||
| `save_file` | SAF диалог сохранения (GPX экспорт) |
|
||||
| `get_app_info` | Версия, git hash, дата сборки |
|
||||
| `get_update_info` | Проверка OTA обновления (BUILD_NUMBER с VPS) |
|
||||
|
|
@ -165,15 +169,21 @@
|
|||
|
||||
## Формула ккал (GPS)
|
||||
|
||||
### Велосипед — физическая модель
|
||||
### Велосипед — НЕТТО-модель (суточный баланс = intake − BMR*activityLevel − activity, поэтому активность считается СВЕРХ покоя)
|
||||
```
|
||||
Power = v * (m*g*sin(gradient) + Crr*m*g*cos(gradient) + 0.5*CdA*rho*v^2)
|
||||
m = rider_kg + bike_kg
|
||||
m = rider_kg + bike_kg; gradient из СГЛАЖЕННОЙ высоты
|
||||
Crr = 0.004 (асфальт) / 0.006 (смешанный) / 0.008 (грунт)
|
||||
CdA = 0.6, rho = 1.225, efficiency = 0.25
|
||||
|
||||
Power > 0: педалирование → kcal = power*dt / (0.25 * 4184)
|
||||
Power <= 0: каботаж → kcal = базальный метаболизм
|
||||
Педалирование (power>0): kcal = max(физика, MET-нетто), физика = power*dt/(0.25*4184)
|
||||
MET по скорости: <16км/ч=6.0, <19=6.8, <22=8.0, <25=10.0, <28=12.0, иначе 14.0
|
||||
MET-нетто = (dt/60)*((MET-1)*3.5*weight)/200 (−1 MET покоя, он уже в BMR)
|
||||
Floor только при v≥5км/ч (ниже — дрожание GPS, не езда)
|
||||
Накат / freewheel (power≤0, «велик катился бы сам»): kcal = базальный метаболизм
|
||||
(детектор кручу/накат по IMU-каденсу проверен на 50Hz — сигнал слишком слаб, не используется; хук cadence_rpm — под BLE-датчик шатуна)
|
||||
|
||||
Набор высоты: кумулятивный гистерезис (elevRef, порог 1м) — на плотных треках per-point разница <1м даёт 0
|
||||
```
|
||||
|
||||
### Ходьба/бег — MET-таблица
|
||||
|
|
|
|||
|
|
@ -5,9 +5,9 @@
|
|||
## Возможности
|
||||
|
||||
- **Дневник питания** — 24400+ продуктов (Магнит, 5ка, Health-Diet.ru, Ашан, Макдоналдс, KFC, Додо Пицца, KDV, ИОН, Гриднев). 37 нутриентов (витамины, минералы, omega-3/6, вода, клетчатка). Рецепты с термообработкой. Формулы порций (4*30, 300+50).
|
||||
- **GPS трекинг** — запись маршрутов на велосипеде/пешком в фоне (foreground service, точки пишутся при выключенном экране; один файл на сессию, переживает START_STICKY-перезапуск ОС; полный до-импорт нативных точек по timestamp при финализации — без потерь, даже если экран был выключен всю запись, а при пробуждении экрана точки видимо нарастают на карте по мере импорта). Оффлайн карты (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м, вело/пешие). Импорт тайлов по регионам (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м, вело/пешие). Импорт тайлов по регионам (SQLite, роутер LAN).
|
||||
- **BLE пульсометр** — подключение к FitPro браслетам и стандартным HR датчикам (0x180D). ЧСС, SpO2, давление в реальном времени при записи трека.
|
||||
- **Расчёт ккал** — физическая модель для велосипеда (мощность из gradient + Crr + CdA). MET-таблица для ходьбы/бега и тренировок (приседания 6.0, скакалка 12.3, растяжки 2.3, бассейн 8.0). Профили велосипедов. BMR по Mifflin-St Jeor с коэффициентом активности.
|
||||
- **Расчёт ккал** — велосипед: НЕТТО-модель (суточный баланс уже учитывает BMR, поэтому активность считается сверх покоя). Кручение = max(физика мощности из gradient+Crr+CdA / КПД, эмпирический MET-нетто по скорости); накат (freewheel, `power≤0` — «велик катился бы сам») = только базовый обмен. MET-таблица для ходьбы/бега и тренировок (приседания 6.0, скакалка 12.3, растяжки 2.3, бассейн 8.0). Набор высоты — кумулятивный гистерезис (устойчив на плотных треках, где per-point разница <1м). Пункт меню «Пересчитать высоту/калории» пересчитывает статистику всех треков без изменения точек. Профили велосипедов. BMR по Mifflin-St Jeor с коэффициентом активности.
|
||||
- **Тренировки** — приседания (подходы x повторения), скакалка, растяжки, бассейн (дистанция). Автоматический расчёт ккал по MET и весу.
|
||||
- **Нутриенты** — 37 микронутриентов (витамины, минералы, жирные кислоты, женьшень, ресвератрол, CoQ10). Нормы РСП/АУП (РФ/ЕС/США). Фармакокинетика — запасы в организме по полураспаду.
|
||||
- **Трекинг жидкости** — water_pct в продуктах, прогресс-бар, настраиваемая норма.
|
||||
|
|
|
|||
|
|
@ -711,6 +711,41 @@ async function addGpsPoint(sessionId, ts, lat, lon, altGps, speedMs, seq, bleDat
|
|||
await execute(`INSERT INTO gps_points (${cols.join(",")}) VALUES (${ph})`, vals);
|
||||
}
|
||||
|
||||
// Bulk insert native points (recovery). Multi-row INSERT in chunks — MUCH faster
|
||||
// than per-point addGpsPoint (one IPC per ~75 rows instead of per row). `points`
|
||||
// are native point objects {ts, lat, lon, altitude, speed_ms, bearing, accel_*, gyro_*}.
|
||||
// Returns the number of rows inserted. seq continues from startSeq.
|
||||
async function addGpsPointsBulk(sessionId, points, startSeq) {
|
||||
if (!points || !points.length) return 0;
|
||||
if (!_gpsPointCols) {
|
||||
try { const info = await select("PRAGMA table_info(gps_points)"); _gpsPointCols = new Set(info.map(r => r.name)); }
|
||||
catch { _gpsPointCols = new Set(["session_id","ts","lat","lon","altitude_gps_m","speed_ms","seq"]); }
|
||||
}
|
||||
const base = ["session_id","ts","lat","lon","altitude_gps_m","speed_ms","seq"];
|
||||
const extra = ["accel_rms","accel_max","gyro_pitch","gyro_yaw","bearing_deg"].filter(c => _gpsPointCols.has(c));
|
||||
const allCols = base.concat(extra);
|
||||
const perRow = allCols.length;
|
||||
const maxRows = Math.max(1, Math.floor(900 / perRow)); // stay under SQLite bind-var limit
|
||||
let seq = startSeq || 0;
|
||||
let inserted = 0;
|
||||
for (let i = 0; i < points.length; i += maxRows) {
|
||||
const chunk = points.slice(i, i + maxRows);
|
||||
const rowSql = [];
|
||||
const vals = [];
|
||||
let ph = 1;
|
||||
for (const p of chunk) {
|
||||
seq++;
|
||||
const row = [sessionId, p.ts, p.lat, p.lon, p.altitude ?? null, p.speed_ms ?? null, seq];
|
||||
for (const c of extra) row.push(c === "bearing_deg" ? (p.bearing ?? null) : (p[c] ?? null));
|
||||
rowSql.push("(" + row.map(() => `?${ph++}`).join(",") + ")");
|
||||
vals.push(...row);
|
||||
}
|
||||
await execute(`INSERT INTO gps_points (${allCols.join(",")}) VALUES ${rowSql.join(",")}`, vals);
|
||||
inserted += chunk.length;
|
||||
}
|
||||
return inserted;
|
||||
}
|
||||
|
||||
async function finishGpsSession(id, endTime, distM, kcal, avgSpd, maxSpd, elevGain, elevLoss) {
|
||||
// Guard against NaN — SQLite may reject or corrupt NaN values
|
||||
const safe = v => (v != null && !isNaN(v)) ? v : null;
|
||||
|
|
@ -724,10 +759,10 @@ async function finishGpsSession(id, endTime, distM, kcal, avgSpd, maxSpd, elevGa
|
|||
|
||||
// NOTE: tauri-plugin-sql corrupts large i64 ids when returned as JS numbers
|
||||
// (off by ±1). Always read/pass session ids via the text `id_str` column.
|
||||
async function getGpsSessions(limit) {
|
||||
async function getGpsSessions(limit, offset) {
|
||||
return select(
|
||||
"SELECT *, CAST(id AS TEXT) AS id_str FROM gps_sessions ORDER BY date DESC, start_time DESC, id DESC LIMIT ?1",
|
||||
[limit ?? 50]
|
||||
"SELECT *, CAST(id AS TEXT) AS id_str FROM gps_sessions ORDER BY date DESC, start_time DESC, id DESC LIMIT ?1 OFFSET ?2",
|
||||
[limit ?? 50, offset ?? 0]
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -1033,6 +1068,7 @@ window.DB.cacheRecipeNutrients = cacheRecipeNutrients;
|
|||
window.DB.addFoodFromRecipe = addFoodFromRecipe;
|
||||
window.DB.createGpsSession = createGpsSession;
|
||||
window.DB.addGpsPoint = addGpsPoint;
|
||||
window.DB.addGpsPointsBulk = addGpsPointsBulk;
|
||||
window.DB.finishGpsSession = finishGpsSession;
|
||||
window.DB.getGpsSessions = getGpsSessions;
|
||||
window.DB.getGpsSessionsForDate = getGpsSessionsForDate;
|
||||
|
|
|
|||
|
|
@ -97,6 +97,7 @@ GPS.computeStats = function(points, weightKg, opts) {
|
|||
const sorted = points.slice().sort((a, b) => new Date(a.ts) - new Date(b.ts));
|
||||
|
||||
let distance = 0, elevGain = 0, elevLoss = 0;
|
||||
let elevRef = null; // running reference for cumulative elevation hysteresis
|
||||
const speeds = []; // m/s per segment
|
||||
|
||||
// 5-point moving average on altitude to kill GPS jitter (typically 5-15m noise)
|
||||
|
|
@ -202,11 +203,16 @@ GPS.computeStats = function(points, weightKg, opts) {
|
|||
if (validSeg) {
|
||||
distance += d;
|
||||
speeds.push(v);
|
||||
// Elevation: accumulate from smoothed altitude with 1m dead zone.
|
||||
if (smoothedAlt[i] != null && smoothedAlt[i - 1] != null) {
|
||||
let diff = smoothedAlt[i] - smoothedAlt[i - 1];
|
||||
if (diff > 1) elevGain += diff;
|
||||
else if (diff < -1) elevLoss += Math.abs(diff);
|
||||
// Elevation: cumulative hysteresis against a running reference — NOT the diff
|
||||
// between adjacent points. On dense tracks (e.g. 5000 pts / 16 km) the per-point
|
||||
// change is < 1 m, so an adjacent-diff dead zone accumulates 0 even on real
|
||||
// climbs. Accumulate the rise/fall from the reference and only bank it (and
|
||||
// advance the reference) once it clears the 1 m dead zone.
|
||||
if (smoothedAlt[i] != null) {
|
||||
if (elevRef == null) elevRef = smoothedAlt[i];
|
||||
let rise = smoothedAlt[i] - elevRef;
|
||||
if (rise > 1) { elevGain += rise; elevRef = smoothedAlt[i]; }
|
||||
else if (rise < -1) { elevLoss += -rise; elevRef = smoothedAlt[i]; }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -227,9 +233,11 @@ GPS.computeStats = function(points, weightKg, opts) {
|
|||
}
|
||||
|
||||
if (isBike) {
|
||||
// Gradient from filtered (outlier-removed) raw altitude
|
||||
const altHere = filteredAlt[i] ?? 0;
|
||||
const altPrev = filteredAlt[i-1] ?? 0;
|
||||
// Gradient from SMOOTHED altitude (terrain-preferred, outlier-removed, smoothed)
|
||||
// — using the raw filtered altitude let GPS-altitude noise inflate the gradient
|
||||
// (and kcal) on un-refined tracks, making sessions inconsistent.
|
||||
const altHere = smoothedAlt[i] ?? filteredAlt[i] ?? 0;
|
||||
const altPrev = smoothedAlt[i-1] ?? filteredAlt[i-1] ?? 0;
|
||||
const altDiff = altHere - altPrev;
|
||||
const gradient = d > 2 ? Math.max(-0.5, Math.min(0.5, altDiff / d)) : 0;
|
||||
const sinG = gradient;
|
||||
|
|
@ -247,20 +255,44 @@ GPS.computeStats = function(points, weightKg, opts) {
|
|||
const pAero = 0.5 * CdA * rho * v * v;
|
||||
const power = v * (pGravity + pRolling + pAero);
|
||||
|
||||
if (power > 0) {
|
||||
// Pedaling: convert mechanical power to metabolic energy
|
||||
const mechEnergy = power * dt; // joules
|
||||
const metabEnergy = mechEnergy / efficiency; // joules (total metabolic cost)
|
||||
totalKcal += metabEnergy / 4184; // convert to kcal
|
||||
// Pedaling vs coasting. If the bike is freewheeling (you're NOT turning the
|
||||
// pedals) it costs only basal — no matter the speed. How we know:
|
||||
// - Physics proxy (what we use): power<=0 means the bike would roll at >=
|
||||
// this speed unpowered — a downhill coast → basal. Otherwise → pedaling.
|
||||
// (This proxy misses FLAT coasting, but that's rare and minor.)
|
||||
// - cadence_rpm hook: reserved for a real crank cadence sensor (BLE) if one
|
||||
// is ever added — that's the only reliable source. Deriving cadence from
|
||||
// the phone IMU was tested on real 50Hz data and does NOT separate
|
||||
// pedaling from coasting (~76-81% ≈ baseline), so it is not used.
|
||||
const cad = sorted[i].cadence_rpm;
|
||||
const pedaling = (cad != null) ? (cad >= 20) : (power > 0);
|
||||
|
||||
// Cadence: wheel revolutions / gear ratio = pedal revolutions
|
||||
if (pedaling) {
|
||||
// Both estimates NET (above resting): the daily balance already books BMR
|
||||
// for the whole day (expend = BMR*activityLevel + activity, main.js), so
|
||||
// activity kcal must be NET — never add basal here.
|
||||
// 1. physics: mechanical work / drivetrain efficiency. At 15-18 km/h the
|
||||
// mechanical power is only ~50-80 W, so on the flat this UNDERESTIMATES
|
||||
// real metabolic cost ~2x — a lower bound; wins only on steep climbs.
|
||||
// 2. MET floor: Compendium cycling METs by speed, MINUS the 1 MET of rest
|
||||
// BMR already covers — the empirically measured cost.
|
||||
const physicsKcal = Math.max(0, power) * dt / efficiency / 4184;
|
||||
const vKmh = v * 3.6;
|
||||
if (vKmh >= 5) {
|
||||
const met = vKmh < 16 ? 6.0 : vKmh < 19 ? 6.8 : vKmh < 22 ? 8.0 : vKmh < 25 ? 10.0 : vKmh < 28 ? 12.0 : 14.0;
|
||||
const metNetKcal = (dt / 60) * ((met - 1) * 3.5 * wKg) / 200;
|
||||
totalKcal += Math.max(physicsKcal, metNetKcal);
|
||||
} else {
|
||||
totalKcal += physicsKcal; // creeping < 5 km/h: negligible
|
||||
}
|
||||
// Cadence estimate from gearing (theoretical) for avgCadence output.
|
||||
if (hasGearData && wheelCirc > 0) {
|
||||
const wheelRevs = d / wheelCirc;
|
||||
totalPedalRevs += wheelRevs / gearRatio;
|
||||
pedalingSeconds += dt;
|
||||
}
|
||||
} else {
|
||||
// Coasting downhill: only basal metabolic rate
|
||||
// Coasting / freewheeling: only basal metabolic rate.
|
||||
totalKcal += basalPerMin * (dt / 60);
|
||||
}
|
||||
} else {
|
||||
|
|
@ -517,36 +549,188 @@ GPS._stopHrMonitor = function() {
|
|||
};
|
||||
|
||||
// Read points from native service file
|
||||
// Parse raw JSONL (one native point per line) into sorted point objects.
|
||||
GPS._parseNativePoints = function(raw) {
|
||||
const points = [];
|
||||
if (!raw) return points;
|
||||
for (const line of raw.trim().split("\n")) {
|
||||
if (!line) continue;
|
||||
try {
|
||||
const d = JSON.parse(line);
|
||||
if (d.lat == null || d.lon == null) continue; // skip marker/invalid lines
|
||||
points.push({
|
||||
lat: d.lat, lon: d.lon, altitude: d.alt,
|
||||
speed_ms: d.speed, accuracy: d.acc,
|
||||
bearing: (d.bearing != null && d.bearing >= 0) ? d.bearing : null, // GPS course over ground
|
||||
ts: new Date(d.ts).toISOString(),
|
||||
accel_rms: d.accel_rms, accel_max: d.accel_max,
|
||||
gyro_pitch: d.gyro_pitch, gyro_yaw: d.gyro_yaw,
|
||||
estimated: d.estimated || false,
|
||||
});
|
||||
} catch { /* skip partial/corrupt lines */ }
|
||||
}
|
||||
points.sort((a, b) => new Date(a.ts) - new Date(b.ts));
|
||||
return points;
|
||||
};
|
||||
|
||||
GPS._readNativePoints = async function() {
|
||||
try {
|
||||
const raw = await window.__TAURI__.core.invoke("gps_service_read_points");
|
||||
if (!raw) return [];
|
||||
const points = [];
|
||||
for (const line of raw.trim().split("\n")) {
|
||||
if (!line) continue;
|
||||
try {
|
||||
const d = JSON.parse(line);
|
||||
if (d.lat == null || d.lon == null) continue; // skip marker/invalid lines
|
||||
points.push({
|
||||
lat: d.lat, lon: d.lon, altitude: d.alt,
|
||||
speed_ms: d.speed, accuracy: d.acc,
|
||||
bearing: (d.bearing != null && d.bearing >= 0) ? d.bearing : null, // GPS course over ground
|
||||
ts: new Date(d.ts).toISOString(),
|
||||
accel_rms: d.accel_rms, accel_max: d.accel_max,
|
||||
gyro_pitch: d.gyro_pitch, gyro_yaw: d.gyro_yaw,
|
||||
estimated: d.estimated || false,
|
||||
});
|
||||
} catch { /* skip partial/corrupt lines */ }
|
||||
}
|
||||
// Sort by timestamp to fix any ordering issues
|
||||
points.sort((a, b) => new Date(a.ts) - new Date(b.ts));
|
||||
return points;
|
||||
return GPS._parseNativePoints(raw);
|
||||
} catch { return []; }
|
||||
};
|
||||
|
||||
// Core point processing — used by both WebView GPS and native service polling
|
||||
GPS._processIncomingPoint = async function(p) {
|
||||
// On-device track recovery. The foreground service writes every point to a
|
||||
// native gps_points_<ms>.jsonl regardless of screen state, so these files are
|
||||
// the source of truth for screen-off stretches the WebView missed.
|
||||
//
|
||||
// MATCHING — both a file and a session carry their own start timestamp, so we
|
||||
// match them directly by id/date, never by "which session has points here":
|
||||
// * The filename's <ms> is when the service started; a session's id is
|
||||
// Date.now() at first movement. Empirically the file always starts a few
|
||||
// seconds to ~25 min BEFORE its session (warmup before moving), same date.
|
||||
// * A file belongs to session S iff S.date == file's date AND the file's start
|
||||
// falls inside S's point window ±30 min. That rejects orphan recordings that
|
||||
// left no session (e.g. the 07-05 files, hours from any session) and can
|
||||
// never glue a file onto a foreign date.
|
||||
//
|
||||
// SAFETY (learned the hard way — a naive version corrupted real rides):
|
||||
// * Only import file points whose own date == the session's date, so a stray
|
||||
// glitched timestamp inside a correctly-matched file can't leak in.
|
||||
// * recalculateSession is called with {noDelete:true}: recovery ADDS points, it
|
||||
// must never trigger the destructive glitch/>24h filter that once wiped the
|
||||
// real 07-22/07-24 rides.
|
||||
GPS.recoverFromFiles = async function(onProgress) {
|
||||
if (GPS.recording.active) return { error: "recording" };
|
||||
const invoke = window.__TAURI__.core.invoke;
|
||||
const sel = (q, v) => invoke("plugin:sql|select", { db: "sqlite:baldav.db", query: q, values: v || [] });
|
||||
let files = [];
|
||||
try { files = JSON.parse(await invoke("list_points_files")); }
|
||||
catch (e) { console.error("[recover] list_points_files FAILED", e); }
|
||||
console.log(`[recover] files=${files.length}`);
|
||||
|
||||
// Load every session's date + actual point window once. CAST id to TEXT — the
|
||||
// SQL plugin corrupts large i64 ids returned as numbers.
|
||||
let sessRows = [];
|
||||
try {
|
||||
sessRows = await sel(
|
||||
"SELECT CAST(s.id AS TEXT) AS sid, s.date AS d, MIN(p.ts) AS mn, MAX(p.ts) AS mx " +
|
||||
"FROM gps_sessions s JOIN gps_points p ON p.session_id = s.id GROUP BY s.id"
|
||||
);
|
||||
} catch (e) { console.error("[recover] session windows FAILED", e); }
|
||||
const sessions = sessRows.map(r => ({
|
||||
sid: r.sid, date: r.d,
|
||||
mn: new Date(r.mn).getTime(), mx: new Date(r.mx).getTime(),
|
||||
}));
|
||||
|
||||
const dayOf = (ms) => new Date(ms).toISOString().slice(0, 10);
|
||||
const NEAR_MS = 30 * 60 * 1000; // file start may lead the session by up to ~25 min
|
||||
|
||||
// The file the service is (or was last) writing — never delete it, a resumed
|
||||
// session may still append to it.
|
||||
let activeFile = "";
|
||||
try { activeFile = (await invoke("get_points_filename")) || ""; } catch {}
|
||||
|
||||
const results = [];
|
||||
for (let fi = 0; fi < files.length; fi++) {
|
||||
const name = files[fi];
|
||||
if (onProgress) { try { onProgress(fi + 1, files.length); } catch {} }
|
||||
|
||||
// The file's start ms and (for new files) its owning session id live in the
|
||||
// name: gps_points_<ms>.jsonl or gps_points_<ms>_s<sessionId>.jsonl.
|
||||
const m = /gps_points_(\d+)(?:_s(\d+))?\.jsonl/.exec(name);
|
||||
if (!m) { console.log(`[recover] f=${name} no id in name, skip`); continue; }
|
||||
const fileMs = Number(m[1]);
|
||||
const boundSid = m[2] || null; // service stamped the session id into the name
|
||||
const fDate = dayOf(fileMs);
|
||||
|
||||
let match = null;
|
||||
if (boundSid) {
|
||||
// Unambiguous binding: the file already names its session. Bind directly;
|
||||
// if that session was deleted, leave the file unmatched (never inject).
|
||||
match = sessions.find(s => s.sid === boundSid) || null;
|
||||
if (!match) {
|
||||
console.log(`[recover] f=${name} bound sid=${boundSid} but session gone — UNMATCHED`);
|
||||
results.push({ file: name, imported: 0, unmatched: true });
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
// Legacy files (no session id in name): match by id/date — same date, file
|
||||
// start inside the session window ±30 min, nearest such session wins.
|
||||
let bestGap = Infinity;
|
||||
for (const s of sessions) {
|
||||
if (s.date !== fDate) continue;
|
||||
const gap = fileMs < s.mn ? s.mn - fileMs : (fileMs > s.mx ? fileMs - s.mx : 0);
|
||||
if (gap <= NEAR_MS && gap < bestGap) { bestGap = gap; match = s; }
|
||||
}
|
||||
if (!match) {
|
||||
console.log(`[recover] f=${name} start=${new Date(fileMs).toISOString()} UNMATCHED`);
|
||||
results.push({ file: name, imported: 0, unmatched: true });
|
||||
continue;
|
||||
}
|
||||
}
|
||||
const sid = match.sid;
|
||||
|
||||
let raw = "";
|
||||
try { raw = await invoke("read_points_file", { filename: name }); }
|
||||
catch (e) { console.error(`[recover] read FAILED ${name}`, e); continue; }
|
||||
const pts = GPS._parseNativePoints(raw);
|
||||
if (pts.length < 2) { results.push({ file: name, sessionId: sid, imported: 0 }); continue; }
|
||||
|
||||
// Only points on the matched session's own date may be imported (blocks a
|
||||
// stray glitched timestamp inside an otherwise-correct file).
|
||||
const sameDay = pts.filter(p => p.ts.slice(0, 10) === match.date);
|
||||
if (!sameDay.length) { results.push({ file: name, sessionId: sid, imported: 0 }); continue; }
|
||||
|
||||
const existing = await DB.getGpsPoints(sid);
|
||||
// Dedup by TIME PROXIMITY, not exact ms. The native service and the WebView
|
||||
// watchPosition record the SAME fixes with slightly different millisecond
|
||||
// stamps, so exact-ms dedup keeps both → interleaved zig-zag inflates
|
||||
// distance/kcal. Skip a native point if any existing point is within PROX ms;
|
||||
// keep only points in genuine gaps (screen-off stretches the WebView missed).
|
||||
const PROX = 2500;
|
||||
const existMs = existing.map(p => new Date(p.ts).getTime()).sort((a, b) => a - b);
|
||||
const nearExisting = (t) => {
|
||||
let lo = 0, hi = existMs.length - 1;
|
||||
while (lo <= hi) { let mid = (lo + hi) >> 1; if (existMs[mid] < t) lo = mid + 1; else hi = mid - 1; }
|
||||
let best = Infinity;
|
||||
if (lo < existMs.length) best = Math.min(best, existMs[lo] - t);
|
||||
if (lo > 0) best = Math.min(best, t - existMs[lo - 1]);
|
||||
return best <= PROX;
|
||||
};
|
||||
const missing = sameDay.filter(p => !nearExisting(new Date(p.ts).getTime()));
|
||||
let imported = 0;
|
||||
if (missing.length) imported = await DB.addGpsPointsBulk(sid, missing, existing.length);
|
||||
// Recalc so elevation/kcal fixes apply even when nothing new was imported.
|
||||
// noDelete: recovery must NEVER run the destructive glitch/>24h filter.
|
||||
try { await GPS.recalculateSession(sid, { noDelete: true }); }
|
||||
catch (e) { console.error("[recover] recalc failed", e); }
|
||||
// The file's points are now safely in the DB — delete it to clear the backlog
|
||||
// clutter. Skip the active file. Orphan/unmatched files are kept (their points
|
||||
// have no session, so deleting would lose data).
|
||||
let removed = false;
|
||||
if (name !== activeFile) {
|
||||
try { await invoke("delete_points_file", { filename: name }); removed = true; }
|
||||
catch (e) { console.warn(`[recover] delete failed ${name}`, e); }
|
||||
}
|
||||
console.log(`[recover] f=${name} pts=${pts.length} match=${sid} existing=${existing.length} imported=${imported} removed=${removed}`);
|
||||
results.push({ file: name, sessionId: sid, imported, removed });
|
||||
}
|
||||
const removedN = results.filter(r => r.removed).length;
|
||||
console.log(`[recover] DONE files=${results.length} imported=${results.reduce((a,r)=>a+(r.imported||0),0)} removed=${removedN}`);
|
||||
return { results, removed: removedN };
|
||||
};
|
||||
|
||||
// Core point processing — used by both WebView GPS (watchPosition) and native
|
||||
// service polling. `fromNative` = the point came from the native file via the
|
||||
// poll. On Android the native file is the complete, screen-independent source of
|
||||
// truth, so only native points are stored; watchPosition then just drives the
|
||||
// live marker — the same fix is never written twice with different timestamps.
|
||||
GPS._processIncomingPoint = async function(p, fromNative) {
|
||||
if (!GPS.recording.active) return;
|
||||
// DB writes come only from the source of truth: native points on Android, or
|
||||
// watchPosition on desktop (no native service).
|
||||
const writeDb = !!fromNative || !GPS.recording.nativeService;
|
||||
|
||||
// State: waiting_gps → ready (got first accurate fix)
|
||||
if (GPS.recording.state === "waiting_gps") {
|
||||
|
|
@ -589,6 +773,12 @@ GPS._processIncomingPoint = async function(p) {
|
|||
// Show visible error — this is data loss
|
||||
if (typeof showToast === "function") showToast("DB: " + (e.message || e));
|
||||
}
|
||||
// Stamp the session id into the native file's name so recovery binds it
|
||||
// unambiguously (the service renames gps_points_<ms>.jsonl -> ..._s<id>.jsonl).
|
||||
if (GPS.recording.sessionId != null) {
|
||||
try { await window.__TAURI__.core.invoke("gps_set_session_id", { sessionId: String(GPS.recording.sessionId) }); }
|
||||
catch (e) { console.warn("[GPS] gps_set_session_id failed (non-critical):", e); }
|
||||
}
|
||||
GPS._persistState();
|
||||
if (GPS.recording.onStateChange) GPS.recording.onStateChange("recording", p);
|
||||
}
|
||||
|
|
@ -605,7 +795,7 @@ GPS._processIncomingPoint = async function(p) {
|
|||
GPS.recording.pauseStart = new Date(p.ts);
|
||||
}
|
||||
// Save point with HR even during pause (no seq = excluded from distance/kcal)
|
||||
if (GPS.recording.sessionId && GPS.recording.currentBleData) {
|
||||
if (writeDb && GPS.recording.sessionId && GPS.recording.currentBleData) {
|
||||
DB.addGpsPoint(GPS.recording.sessionId, p.ts, p.lat, p.lon, p.altitude, 0, null, GPS.recording.currentBleData, p);
|
||||
}
|
||||
if (GPS.recording.onPoint) GPS.recording.onPoint(p);
|
||||
|
|
@ -620,12 +810,16 @@ GPS._processIncomingPoint = async function(p) {
|
|||
if (GPS.recording.onStateChange) GPS.recording.onStateChange("resumed", p);
|
||||
}
|
||||
|
||||
// Save moving point with seq
|
||||
// Save moving point with seq — only from the source of truth. watchPosition on
|
||||
// Android skips the write (the poll stores the native equivalent), so distance
|
||||
// and kcal aren't inflated by the same fix landing twice.
|
||||
GPS.recording.lastMovingPoint = p;
|
||||
GPS.recording.points.push(p);
|
||||
GPS.recording._pointSeq = (GPS.recording._pointSeq || 0) + 1;
|
||||
if (GPS.recording.sessionId) {
|
||||
await DB.addGpsPoint(GPS.recording.sessionId, p.ts, p.lat, p.lon, p.altitude, p.speed_ms, GPS.recording._pointSeq, GPS.recording.currentBleData, p);
|
||||
if (writeDb) {
|
||||
GPS.recording.points.push(p);
|
||||
GPS.recording._pointSeq = (GPS.recording._pointSeq || 0) + 1;
|
||||
if (GPS.recording.sessionId) {
|
||||
await DB.addGpsPoint(GPS.recording.sessionId, p.ts, p.lat, p.lon, p.altitude, p.speed_ms, GPS.recording._pointSeq, GPS.recording.currentBleData, p);
|
||||
}
|
||||
}
|
||||
if (GPS.recording.onPoint) GPS.recording.onPoint(p);
|
||||
};
|
||||
|
|
@ -784,11 +978,13 @@ GPS.startRecording = async function(activityType, weightKg) {
|
|||
if (GPS.recording.nativeService) {
|
||||
let polling = false;
|
||||
GPS.recording._pollTimer = setInterval(async () => {
|
||||
console.log(`[poll] tick active=${GPS.recording.active} polling=${polling} hidden=${document.hidden} lastTs=${GPS.recording._lastProcessedTs}`);
|
||||
if (!GPS.recording.active) { clearInterval(GPS.recording._pollTimer); return; }
|
||||
if (polling) return; // skip if previous poll still running
|
||||
polling = true;
|
||||
try {
|
||||
const nativePoints = await GPS._readNativePoints();
|
||||
console.log(`[poll] native=${nativePoints.length} firstTs=${nativePoints[0]?.ts} lastNativeTs=${nativePoints[nativePoints.length-1]?.ts}`);
|
||||
let newCount = 0;
|
||||
// Keep the heavy per-point path off (onPoint runs computeStats over all
|
||||
// points + DB writes → O(n²) on a big backlog). Draw each imported point
|
||||
|
|
@ -800,16 +996,21 @@ GPS.startRecording = async function(activityType, weightKg) {
|
|||
for (const p of nativePoints) {
|
||||
const pTs = new Date(p.ts).getTime();
|
||||
if (pTs <= GPS.recording._lastProcessedTs) continue;
|
||||
await GPS._processIncomingPoint(p);
|
||||
await GPS._processIncomingPoint(p, true);
|
||||
GPS.recording._lastProcessedTs = pTs;
|
||||
newCount++;
|
||||
if (GPS.recording.onImportPoint) GPS.recording.onImportPoint(p);
|
||||
// Yield a frame so the growing track repaints — but ONLY when visible.
|
||||
// requestAnimationFrame never fires while the screen is off (page hidden),
|
||||
// so awaiting it there would hang this loop forever and freeze the import.
|
||||
if (newCount % 20 === 0 && !document.hidden) await new Promise(r => requestAnimationFrame(r));
|
||||
if (newCount % 20 === 0 && !document.hidden) {
|
||||
console.log(`[poll] raf-wait at ${newCount}`);
|
||||
await new Promise(r => requestAnimationFrame(r));
|
||||
console.log(`[poll] raf-done at ${newCount}`);
|
||||
}
|
||||
}
|
||||
GPS.recording.onPoint = origOnPoint;
|
||||
console.log(`[poll] imported=${newCount} newLastTs=${GPS.recording._lastProcessedTs}`);
|
||||
// Final sync — marker, pan, course and stats from the full point set.
|
||||
if (newCount > 0 && GPS.recording.onBatchUpdate) {
|
||||
GPS.recording.onBatchUpdate();
|
||||
|
|
@ -856,12 +1057,14 @@ GPS.stopRecording = async function() {
|
|||
|
||||
// Stop native service — read remaining points but DON'T delete the file yet
|
||||
let nativePointsFile = null;
|
||||
console.log(`[stop] enter nativeService=${GPS.recording.nativeService} sessionId=${GPS.recording.sessionId}`);
|
||||
if (GPS.recording.nativeService) {
|
||||
// Import EVERY native point not yet in the DB. The live poll is screen-gated
|
||||
// (WebView freezes timers while the screen is off), so a walk with the screen
|
||||
// off leaves most points only in the native file. Idempotent by timestamp —
|
||||
// same pattern as recoverSession. Then recompute stats over the COMPLETE set.
|
||||
const nativePoints = await GPS._readNativePoints();
|
||||
let stopImported = 0;
|
||||
if (GPS.recording.sessionId && nativePoints.length) {
|
||||
const existing = await DB.getGpsPoints(GPS.recording.sessionId);
|
||||
const dbTs = new Set(existing.map(p => new Date(p.ts).getTime()));
|
||||
|
|
@ -870,8 +1073,12 @@ GPS.stopRecording = async function() {
|
|||
if (dbTs.has(new Date(p.ts).getTime())) continue;
|
||||
seq++;
|
||||
await DB.addGpsPoint(GPS.recording.sessionId, p.ts, p.lat, p.lon, p.altitude, p.speed_ms, seq, null, p);
|
||||
stopImported++;
|
||||
}
|
||||
console.log(`[stop] native=${nativePoints.length} existing=${existing.length} imported=${stopImported}`);
|
||||
GPS.recording.points = await DB.getGpsPoints(GPS.recording.sessionId);
|
||||
} else {
|
||||
console.log(`[stop] SKIP backfill: sessionId=${GPS.recording.sessionId} native=${nativePoints.length}`);
|
||||
}
|
||||
// Remember the file name for cleanup after DB save
|
||||
try {
|
||||
|
|
@ -1221,7 +1428,8 @@ GPS.exportGPX = async function(sessionId) {
|
|||
// ---------------------------------------------------------------------------
|
||||
// Recalculate session stats using physics-based formula
|
||||
// ---------------------------------------------------------------------------
|
||||
GPS.recalculateSession = async function(sessionId) {
|
||||
GPS.recalculateSession = async function(sessionId, opts) {
|
||||
opts = opts || {};
|
||||
const session = await DB.getGpsSession(sessionId);
|
||||
if (!session) return null;
|
||||
const points = await DB.getGpsPoints(sessionId);
|
||||
|
|
@ -1271,54 +1479,54 @@ GPS.recalculateSession = async function(sessionId) {
|
|||
const isWalk = session.activity_type === "walk" || session.activity_type === "run";
|
||||
const maxMs = (isWalk ? GPS.config.walkMaxSpeedKmh : GPS.config.maxSpeedKmh) / 3.6;
|
||||
|
||||
// First pass: remove points with absurd timestamps (>1 day jump)
|
||||
// Glitch filtering DELETES points. It is destructive and MUST be skipped for
|
||||
// recovery (opts.noDelete): if a wrong-date file ever slips in, the ">24h"
|
||||
// rule below would wipe the real track. Recovery only adds points, never deletes.
|
||||
let deleted = 0;
|
||||
if (sorted.length >= 3) {
|
||||
const sessionStart = new Date(sorted[0].ts).getTime();
|
||||
const sessionEnd = new Date(sorted[sorted.length - 1].ts).getTime();
|
||||
const maxDuration = 24 * 3600 * 1000; // 24 hours max session
|
||||
for (let i = sorted.length - 1; i >= 0; i--) {
|
||||
const t = new Date(sorted[i].ts).getTime();
|
||||
if (t - sessionStart > maxDuration || t < sessionStart - 60000) {
|
||||
if (!opts.noDelete) {
|
||||
// First pass: remove points with absurd timestamps (>1 day jump)
|
||||
if (sorted.length >= 3) {
|
||||
const sessionStart = new Date(sorted[0].ts).getTime();
|
||||
const maxDuration = 24 * 3600 * 1000; // 24 hours max session
|
||||
for (let i = sorted.length - 1; i >= 0; i--) {
|
||||
const t = new Date(sorted[i].ts).getTime();
|
||||
if (t - sessionStart > maxDuration || t < sessionStart - 60000) {
|
||||
await invoke("DELETE FROM gps_points WHERE session_id = ?1 AND ts = ?2", [sessionId, sorted[i].ts]);
|
||||
sorted.splice(i, 1);
|
||||
deleted++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: speed + accel + altitude filter
|
||||
for (let i = 1; i < sorted.length; i++) {
|
||||
const d = GPS.distance(sorted[i-1].lat, sorted[i-1].lon, sorted[i].lat, sorted[i].lon);
|
||||
const dt = (new Date(sorted[i].ts) - new Date(sorted[i-1].ts)) / 1000;
|
||||
const speedMs = dt > 0 ? d / dt : 0;
|
||||
const accel = sorted[i].accel_rms;
|
||||
|
||||
// Altitude jump: >100m between consecutive points = impossible
|
||||
const alt = sorted[i].altitude_gps_m;
|
||||
const prevAlt = sorted[i-1].altitude_gps_m;
|
||||
const altJump = (alt != null && prevAlt != null) ? Math.abs(alt - prevAlt) : 0;
|
||||
|
||||
// Glitch: speed over max, accel=0 but moving, fast+walking-accel, or >100m alt jump.
|
||||
const isGlitch =
|
||||
(dt > 0 && speedMs > maxMs) ||
|
||||
(accel != null && accel === 0 && speedMs > 3) ||
|
||||
(isWalk && accel != null && accel < 15 && speedMs > maxMs * 0.8) ||
|
||||
(altJump > 100);
|
||||
|
||||
if (isGlitch) {
|
||||
await invoke("DELETE FROM gps_points WHERE session_id = ?1 AND ts = ?2", [sessionId, sorted[i].ts]);
|
||||
sorted.splice(i, 1);
|
||||
i--;
|
||||
deleted++;
|
||||
}
|
||||
}
|
||||
if (deleted) console.log(`[GPS] Deleted ${deleted} glitch points from session ${sessionId}`);
|
||||
}
|
||||
|
||||
// Second pass: speed + accel + altitude filter
|
||||
for (let i = 1; i < sorted.length; i++) {
|
||||
const d = GPS.distance(sorted[i-1].lat, sorted[i-1].lon, sorted[i].lat, sorted[i].lon);
|
||||
const dt = (new Date(sorted[i].ts) - new Date(sorted[i-1].ts)) / 1000;
|
||||
const speedMs = dt > 0 ? d / dt : 0;
|
||||
const accel = sorted[i].accel_rms;
|
||||
|
||||
// Altitude jump: >100m between consecutive points = impossible
|
||||
const alt = sorted[i].altitude_gps_m;
|
||||
const prevAlt = sorted[i-1].altitude_gps_m;
|
||||
const altJump = (alt != null && prevAlt != null) ? Math.abs(alt - prevAlt) : 0;
|
||||
|
||||
// Glitch detection:
|
||||
// 1. Speed exceeds max for activity type
|
||||
// 2. Accelerometer = 0 but GPS shows movement (phone was still → GPS drift)
|
||||
// 3. High speed + normal walking accel (can't walk at 40 km/h)
|
||||
// 4. Altitude jumps >100m in one step (impossible on foot or bike)
|
||||
const isGlitch =
|
||||
(dt > 0 && speedMs > maxMs) ||
|
||||
(accel != null && accel === 0 && speedMs > 3) ||
|
||||
(isWalk && accel != null && accel < 15 && speedMs > maxMs * 0.8) ||
|
||||
(altJump > 100);
|
||||
|
||||
if (isGlitch) {
|
||||
await invoke("DELETE FROM gps_points WHERE session_id = ?1 AND ts = ?2", [sessionId, sorted[i].ts]);
|
||||
sorted.splice(i, 1);
|
||||
i--;
|
||||
deleted++;
|
||||
}
|
||||
}
|
||||
if (deleted) console.log(`[GPS] Deleted ${deleted} glitch points from session ${sessionId}`);
|
||||
|
||||
// Interpolate gaps where accelerometer shows movement
|
||||
// If gap > 30s but accel before/after > 2 → person was walking/riding, GPS lost signal
|
||||
let interpolated = 0;
|
||||
|
|
@ -1407,6 +1615,54 @@ GPS.recalculateSession = async function(sessionId) {
|
|||
return stats;
|
||||
};
|
||||
|
||||
// Recompute ONLY the stored stats (distance / kcal / elevation / speeds) for
|
||||
// every recorded session with the current computeStats. No point is added,
|
||||
// deleted, or retimed — this touches just the 7 stat columns. Use it to refresh
|
||||
// historical sessions after a formula change (the elevation-hysteresis and
|
||||
// smoothed-gradient kcal fix left old sessions showing ~0 gain / inflated kcal).
|
||||
GPS.recomputeAllStats = async function(onProgress) {
|
||||
const invoke = window.__TAURI__.core.invoke;
|
||||
const sel = (q, v) => invoke("plugin:sql|select", { db: "sqlite:baldav.db", query: q, values: v || [] });
|
||||
let ids = [];
|
||||
try { ids = (await sel("SELECT CAST(id AS TEXT) AS sid FROM gps_sessions ORDER BY id")).map(r => r.sid); }
|
||||
catch (e) { console.error("[recalc-all] list FAILED", e); return { error: String(e) }; }
|
||||
|
||||
const latestW = await DB.getLatestWeight();
|
||||
const profile = await DB.getFullProfile();
|
||||
const weightKg = parseFloat(latestW?.weight_kg) || parseFloat(profile?.weight_kg) || 80;
|
||||
const CRR_MAP = { asphalt: 0.004, mixed: 0.006, gravel: 0.008 };
|
||||
|
||||
let updated = 0, skipped = 0;
|
||||
for (let i = 0; i < ids.length; i++) {
|
||||
const sid = ids[i];
|
||||
if (onProgress) { try { onProgress(i + 1, ids.length); } catch {} }
|
||||
try {
|
||||
const session = await DB.getGpsSession(sid);
|
||||
if (!session) { skipped++; continue; }
|
||||
// Manual routes are user-drawn estimates (no altitude, fake timestamps) —
|
||||
// leave them untouched.
|
||||
if (session.source === "manual") { skipped++; continue; }
|
||||
const points = await DB.getGpsPoints(sid);
|
||||
if (points.length < 2) { skipped++; continue; }
|
||||
const bike = session.bike_id ? await DB.getBike(session.bike_id) : await DB.getActiveBike();
|
||||
const stats = GPS.computeStats(points, weightKg, {
|
||||
bikeWeightKg: bike?.weight_kg ?? 0,
|
||||
activityType: session.activity_type ?? "bike",
|
||||
surfaceCrr: CRR_MAP[bike?.surface_type] ?? 0.006,
|
||||
tirePressureBar: bike?.tire_pressure_bar ?? 3.0,
|
||||
chainringTeeth: bike?.chainring_teeth,
|
||||
cogTeeth: bike?.cog_teeth,
|
||||
wheelDiameterInch: bike?.wheel_diameter_inch,
|
||||
});
|
||||
await DB.finishGpsSession(sid, session.end_time ?? session.start_time,
|
||||
stats.distance, stats.kcal, stats.avgSpeed, stats.maxSpeed, stats.elevGain, stats.elevLoss);
|
||||
updated++;
|
||||
} catch (e) { console.error(`[recalc-all] ${sid} failed`, e); skipped++; }
|
||||
}
|
||||
console.log(`[recalc-all] DONE updated=${updated} skipped=${skipped} total=${ids.length}`);
|
||||
return { updated, skipped, total: ids.length };
|
||||
};
|
||||
|
||||
// Build opts from current recording state (for live stats during recording)
|
||||
GPS.getRecordingOpts = function() {
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -2337,7 +2337,21 @@ async function renderGpsScreen(container) {
|
|||
} catch {}
|
||||
}
|
||||
|
||||
let sessions = await DB.getGpsSessions(20);
|
||||
let PAGE_SIZE = 20;
|
||||
let sessionCard = (s) => `
|
||||
<div class="gps-session-item" data-sid="${s.id_str ?? s.id}">
|
||||
<div class="gps-session-head">
|
||||
<span>${s.date} ${s.start_time ?? ""}</span>
|
||||
<span class="gps-session-type">${s.activity_type ?? ""}</span>
|
||||
</div>
|
||||
<div class="gps-session-stats">
|
||||
${s.total_distance_m != null ? GPS.formatDistance(s.total_distance_m) : "—"}
|
||||
· ${s.total_kcal != null ? Math.round(s.total_kcal) + " кк" : ""}
|
||||
· ${s.avg_speed_kmh != null ? s.avg_speed_kmh + " км/ч" : ""}
|
||||
${s.elevation_gain_m ? " · +" + Math.round(s.elevation_gain_m) + "м" : ""}
|
||||
</div>
|
||||
</div>`;
|
||||
let sessions = await DB.getGpsSessions(PAGE_SIZE, 0);
|
||||
let isRec = GPS.recording.active;
|
||||
|
||||
container.innerHTML = `
|
||||
|
|
@ -2359,6 +2373,8 @@ async function renderGpsScreen(container) {
|
|||
<div class="gps-menu-item" id="gps-draw-route">Нарисовать маршрут</div>
|
||||
<div class="gps-menu-item" id="gps-download-map">Скачать карту</div>
|
||||
<div class="gps-menu-item" id="gps-import">Импорт тренировки</div>
|
||||
<div class="gps-menu-item" id="gps-recover">Восстановить точки из файлов</div>
|
||||
<div class="gps-menu-item" id="gps-recalc-stats">Пересчитать высоту/калории</div>
|
||||
<div class="gps-menu-item" id="gps-clear-cache">Очистить кэш карт</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -2366,20 +2382,8 @@ async function renderGpsScreen(container) {
|
|||
</div>
|
||||
<div class="gps-sessions">
|
||||
<h3>История</h3>
|
||||
${sessions.length ? sessions.map(s => `
|
||||
<div class="gps-session-item" data-sid="${s.id_str ?? s.id}">
|
||||
<div class="gps-session-head">
|
||||
<span>${s.date} ${s.start_time ?? ""}</span>
|
||||
<span class="gps-session-type">${s.activity_type ?? ""}</span>
|
||||
</div>
|
||||
<div class="gps-session-stats">
|
||||
${s.total_distance_m != null ? GPS.formatDistance(s.total_distance_m) : "—"}
|
||||
· ${s.total_kcal != null ? Math.round(s.total_kcal) + " кк" : ""}
|
||||
· ${s.avg_speed_kmh != null ? s.avg_speed_kmh + " км/ч" : ""}
|
||||
${s.elevation_gain_m ? " · +" + Math.round(s.elevation_gain_m) + "м" : ""}
|
||||
</div>
|
||||
</div>
|
||||
`).join("") : `<div class="prod-hint">Нет записей</div>`}
|
||||
<div id="gps-session-list">${sessions.length ? sessions.map(sessionCard).join("") : `<div class="prod-hint">Нет записей</div>`}</div>
|
||||
<button id="gps-more" class="btn-secondary" style="width:100%;margin-top:10px;${sessions.length < PAGE_SIZE ? "display:none" : ""}">Показать ещё</button>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
|
|
@ -2417,9 +2421,30 @@ async function renderGpsScreen(container) {
|
|||
|
||||
// Session click → detail view. Keep sid as a STRING (numeric id from the
|
||||
// SQL plugin is corrupted off-by-1 for large i64 values).
|
||||
container.querySelectorAll(".gps-session-item").forEach(item => {
|
||||
item.onclick = () => showSessionDetail(item.dataset.sid);
|
||||
});
|
||||
let bindSessionClick = (item) => { item.onclick = () => showSessionDetail(item.dataset.sid); };
|
||||
container.querySelectorAll(".gps-session-item").forEach(bindSessionClick);
|
||||
|
||||
// Pagination: "Показать ещё" loads the next page from the DB and appends it.
|
||||
let sessOffset = sessions.length;
|
||||
let moreBtn = container.querySelector("#gps-more");
|
||||
if (moreBtn) moreBtn.onclick = async () => {
|
||||
moreBtn.disabled = true;
|
||||
let label = moreBtn.textContent;
|
||||
moreBtn.textContent = "Загрузка...";
|
||||
let next = await DB.getGpsSessions(PAGE_SIZE, sessOffset);
|
||||
let listEl = container.querySelector("#gps-session-list");
|
||||
next.forEach(s => {
|
||||
let tmp = document.createElement("div");
|
||||
tmp.innerHTML = sessionCard(s);
|
||||
let item = tmp.firstElementChild;
|
||||
bindSessionClick(item);
|
||||
listEl.appendChild(item);
|
||||
});
|
||||
sessOffset += next.length;
|
||||
moreBtn.disabled = false;
|
||||
moreBtn.textContent = label;
|
||||
if (next.length < PAGE_SIZE) moreBtn.style.display = "none";
|
||||
};
|
||||
|
||||
// Three-dot menu
|
||||
let menuBtn = container.querySelector("#gps-menu-btn");
|
||||
|
|
@ -2445,6 +2470,67 @@ async function renderGpsScreen(container) {
|
|||
console.log("Cache cleared");
|
||||
};
|
||||
|
||||
// Recover points from native files — rebuilds sessions whose DB rows fell behind
|
||||
// the service's JSONL (e.g. screen-off import glitch). Works fully on-device.
|
||||
container.querySelector("#gps-recover").onclick = async () => {
|
||||
menu.classList.add("hidden");
|
||||
if (GPS.recording.active) { showToast("Нельзя во время записи"); return; }
|
||||
// Keep the screen awake — recovery runs in the WebView and stalls if it sleeps.
|
||||
let wl = null;
|
||||
try { if (navigator.wakeLock) wl = await navigator.wakeLock.request("screen"); } catch {}
|
||||
// Visible progress bar (fixed at the bottom) so it's clear it's working.
|
||||
let prog = document.createElement("div");
|
||||
prog.style.cssText = "position:fixed;left:0;right:0;bottom:0;z-index:99999;background:var(--primary);color:#000;text-align:center;padding:14px;font-size:15px;font-weight:600";
|
||||
prog.textContent = "Восстановление: подготовка...";
|
||||
document.body.appendChild(prog);
|
||||
try {
|
||||
let res = await GPS.recoverFromFiles((i, n) => { prog.textContent = `Восстановление: файл ${i}/${n}`; });
|
||||
if (res.error === "recording") { prog.remove(); showToast("Нельзя во время записи"); return; }
|
||||
let list = res.results || [];
|
||||
let totalPts = list.reduce((a, r) => a + (r.imported || 0), 0);
|
||||
let sessions = list.filter(r => r.imported > 0).length;
|
||||
let unmatched = list.filter(r => r.unmatched).length;
|
||||
let removed = res.removed || 0;
|
||||
// Show the result IN the bar — a toast gets overwritten by the Bluetooth
|
||||
// check when the screen re-renders. Tap the bar to close and refresh.
|
||||
prog.textContent = `Готово: файлов ${list.length}, вставлено ${totalPts} в ${sessions} сессий, удалено ${removed}, сирот ${unmatched}. Нажмите, чтобы закрыть`;
|
||||
prog.style.cursor = "pointer";
|
||||
prog.onclick = () => { prog.remove(); renderScreen("gps"); };
|
||||
} catch (e) {
|
||||
console.error("[recover] failed", e);
|
||||
prog.textContent = "Ошибка восстановления: " + (e.message || e) + " — нажмите, чтобы закрыть";
|
||||
prog.onclick = () => prog.remove();
|
||||
} finally {
|
||||
if (wl) { try { await wl.release(); } catch {} }
|
||||
}
|
||||
};
|
||||
|
||||
// Recompute stored elevation/kcal for every recorded session (formula fix).
|
||||
// Stats-only: never touches points. Same progress-bar UX as recovery.
|
||||
container.querySelector("#gps-recalc-stats").onclick = async () => {
|
||||
menu.classList.add("hidden");
|
||||
if (GPS.recording.active) { showToast("Нельзя во время записи"); return; }
|
||||
let wl = null;
|
||||
try { if (navigator.wakeLock) wl = await navigator.wakeLock.request("screen"); } catch {}
|
||||
let prog = document.createElement("div");
|
||||
prog.style.cssText = "position:fixed;left:0;right:0;bottom:0;z-index:99999;background:var(--primary);color:#000;text-align:center;padding:14px;font-size:15px;font-weight:600";
|
||||
prog.textContent = "Пересчёт: подготовка...";
|
||||
document.body.appendChild(prog);
|
||||
try {
|
||||
let res = await GPS.recomputeAllStats((i, n) => { prog.textContent = `Пересчёт: ${i}/${n}`; });
|
||||
if (res.error) { prog.textContent = "Ошибка: " + res.error + " — нажмите, чтобы закрыть"; prog.onclick = () => prog.remove(); return; }
|
||||
prog.textContent = `Готово: пересчитано ${res.updated}, пропущено ${res.skipped}. Нажмите, чтобы закрыть`;
|
||||
prog.style.cursor = "pointer";
|
||||
prog.onclick = () => { prog.remove(); renderScreen("gps"); };
|
||||
} catch (e) {
|
||||
console.error("[recalc-all] failed", e);
|
||||
prog.textContent = "Ошибка пересчёта: " + (e.message || e) + " — нажмите, чтобы закрыть";
|
||||
prog.onclick = () => prog.remove();
|
||||
} finally {
|
||||
if (wl) { try { await wl.release(); } catch {} }
|
||||
}
|
||||
};
|
||||
|
||||
// Unified import — detect by extension
|
||||
container.querySelector("#gps-import").onclick = () => {
|
||||
menu.classList.add("hidden");
|
||||
|
|
@ -4634,6 +4720,90 @@ function showEditSupplementModal(existing) {
|
|||
// SETTINGS screen
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Offline maps live on the router under /baldav/tiles/. The list is read from the
|
||||
// directory listing, so a new .db uploaded there appears without an app rebuild.
|
||||
let TILES_BASE_URL = "http://192.168.1.1:88/baldav/tiles/";
|
||||
|
||||
// Regions the packing tools know about. Shown even when the router has no .db for
|
||||
// them yet (greyed out) so nothing silently disappears from the list.
|
||||
let KNOWN_REGIONS = {
|
||||
novocherkassk: "город",
|
||||
rostov_city: "Ростов-на-Дону",
|
||||
rostov_oblast: "Ростовская обл.",
|
||||
moscow_city: "Москва",
|
||||
moscow_oblast: "Московская обл.",
|
||||
spb: "Санкт-Петербург",
|
||||
krasnodar_city: "Краснодар",
|
||||
krasnodar_krai: "Краснодарский край",
|
||||
sochi: "Сочи (+Адлер, Кр. Поляна)",
|
||||
kazan: "Казань",
|
||||
novosibirsk: "Новосибирск",
|
||||
ekaterinburg: "Екатеринбург",
|
||||
};
|
||||
|
||||
// Fetch [{ file, name, sizeMB }] from the router's tiles directory listing.
|
||||
async function fetchRouterTiles() {
|
||||
let html = await window.__TAURI__.core.invoke("http_request", {
|
||||
url: TILES_BASE_URL, method: "GET", body: null,
|
||||
});
|
||||
let out = [];
|
||||
let re = /(tiles_[A-Za-z0-9_-]+\.db)<\/a>[\s\S]*?-\s*([\d.]+)\s*kbyte/g;
|
||||
let m;
|
||||
while ((m = re.exec(html)) !== null) {
|
||||
out.push({ file: m[1], name: m[1].replace(/^tiles_|\.db$/g, ""), sizeMB: (parseFloat(m[2]) / 1024).toFixed(1) });
|
||||
}
|
||||
if (!out.length) {
|
||||
for (let f of [...new Set(html.match(/tiles_[A-Za-z0-9_-]+\.db/g) || [])]) {
|
||||
out.push({ file: f, name: f.replace(/^tiles_|\.db$/g, ""), sizeMB: null });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Open the Android SAF file picker and return the path of the picked file.
|
||||
// A content:// URI has no path, so Kotlin copies the file into the app dir and
|
||||
// writes the path to open_file_result.txt, which we poll here. Returns null if
|
||||
// the user cancelled. Desktop has no picker — callers fall back to a path prompt.
|
||||
async function pickLocalFile(mime) {
|
||||
let invoke = window.__TAURI__.core.invoke;
|
||||
await invoke("pick_file_open", { mime: mime || "*/*" });
|
||||
for (let i = 0; i < 600; i++) { // poll up to ~3 min while the picker is open
|
||||
await new Promise(r => setTimeout(r, 300));
|
||||
let res = await invoke("get_picked_file");
|
||||
if (!res) continue;
|
||||
if (res === "CANCELLED") return null;
|
||||
if (res.startsWith("ERROR:")) throw new Error(res.slice(6));
|
||||
return res;
|
||||
}
|
||||
throw new Error("Таймаут выбора файла");
|
||||
}
|
||||
|
||||
// Download a tile DB by URL into the app dir, then import it into the tile cache.
|
||||
async function importTilesFromUrl(url, label, btn, container) {
|
||||
let invoke = window.__TAURI__.core.invoke;
|
||||
let origText = btn.textContent;
|
||||
btn.disabled = true;
|
||||
btn.textContent = "Скачивание...";
|
||||
try {
|
||||
let appDir = await window.__TAURI__.path.appDataDir();
|
||||
let dest = appDir + "/" + (label || "tiles_download.db");
|
||||
let bytes = await invoke("download_file", { url, dest });
|
||||
btn.textContent = `Импорт (${(parseInt(bytes) / 1048576).toFixed(1)} MB)...`;
|
||||
let count = await TileCache.importFromSqlite(dest, (done, total) => {
|
||||
btn.textContent = `${Math.round(done / total * 100)}% (${done}/${total})`;
|
||||
});
|
||||
showToast(`${label}: ${count} тайлов импортировано`);
|
||||
TileCache.count().then(n => {
|
||||
let el = container.querySelector("#s-tile-count");
|
||||
if (el) el.textContent = `${n} тайлов в кэше`;
|
||||
});
|
||||
} catch (e) {
|
||||
showToast("Ошибка: " + String(e));
|
||||
}
|
||||
btn.disabled = false;
|
||||
btn.textContent = origText;
|
||||
}
|
||||
|
||||
async function renderSettings(container) {
|
||||
let p = state.profile;
|
||||
let profileSummary = [
|
||||
|
|
@ -4938,29 +5108,25 @@ async function renderSettings(container) {
|
|||
if (el) el.textContent = `${n} тайлов в кэше`;
|
||||
});
|
||||
|
||||
// Import tiles from SQLite file
|
||||
// User pushes .db to app dir: adb push tiles_region.db /data/user/0/dev.baldav.app/files/
|
||||
// Import tiles from a file the user picks on the device (SAF picker on Android).
|
||||
container.querySelector("#s-import-tiles").onclick = async () => {
|
||||
let btn = container.querySelector("#s-import-tiles");
|
||||
// Prompt for path — user can type full path or just filename in app files dir
|
||||
let filePath = prompt("Путь к файлу тайлов (.db).\nНа Android: скопируйте файл через\nadb push file.db /data/user/0/dev.baldav.app/files/\nи введите имя файла:");
|
||||
if (!filePath) return;
|
||||
// If just a filename, resolve relative to app files dir
|
||||
if (!filePath.includes("/")) {
|
||||
let filePath = null;
|
||||
if (/Android/i.test(navigator.userAgent)) {
|
||||
btn.disabled = true;
|
||||
let prev = btn.textContent;
|
||||
btn.textContent = "Выбор файла...";
|
||||
try {
|
||||
let appDir = await window.__TAURI__.path.appDataDir();
|
||||
// appDataDir on Android = /data/user/0/pkg/files/dev.baldav.app/
|
||||
// files dir = /data/user/0/pkg/files/
|
||||
// Try both locations
|
||||
let candidates = [
|
||||
appDir + "/" + filePath,
|
||||
appDir + "/../../files/" + filePath,
|
||||
appDir + "/../" + filePath,
|
||||
];
|
||||
filePath = candidates[0]; // try appDataDir first
|
||||
} catch {
|
||||
// desktop: use as-is
|
||||
filePath = await pickLocalFile("*/*");
|
||||
} catch (e) {
|
||||
showToast("Ошибка выбора: " + (e.message || e));
|
||||
}
|
||||
btn.disabled = false;
|
||||
btn.textContent = prev;
|
||||
if (!filePath) return; // cancelled
|
||||
} else {
|
||||
filePath = prompt("Путь к файлу тайлов (.db):");
|
||||
if (!filePath) return;
|
||||
}
|
||||
let origText = btn.textContent;
|
||||
btn.disabled = true;
|
||||
|
|
@ -4982,55 +5148,51 @@ async function renderSettings(container) {
|
|||
}
|
||||
};
|
||||
|
||||
// Download tiles from router
|
||||
// Download tiles from router — the list comes from the router's directory
|
||||
// listing, so a newly uploaded .db shows up without rebuilding the app.
|
||||
container.querySelector("#s-download-tiles").onclick = async () => {
|
||||
let regions = [
|
||||
["novocherkassk", "region"],
|
||||
["rostov_city", "Rostov-on-Don"],
|
||||
["rostov_oblast", "Rostov Oblast"],
|
||||
["moscow_city", "Moscow"],
|
||||
["moscow_oblast", "Moscow Oblast"],
|
||||
["spb", "Saint Petersburg"],
|
||||
["krasnodar_city", "Krasnodar"],
|
||||
["krasnodar_krai", "Krasnodar Krai"],
|
||||
["kazan", "Kazan"],
|
||||
["novosibirsk", "Novosibirsk"],
|
||||
["ekaterinburg", "Ekaterinburg"],
|
||||
];
|
||||
let list = regions.map((r, i) => `${i + 1}. ${r[1]}`).join("\n");
|
||||
let choice = prompt("Выберите регион (номер):\n" + list);
|
||||
if (!choice) return;
|
||||
let idx = parseInt(choice) - 1;
|
||||
if (idx < 0 || idx >= regions.length) { showToast("Неверный номер"); return; }
|
||||
let regionKey = regions[idx][0];
|
||||
let regionName = regions[idx][1];
|
||||
let url = `http://192.168.1.1:88/baldav/tiles/tiles_${regionKey}.db`;
|
||||
let invoke = window.__TAURI__.core.invoke;
|
||||
let btn = container.querySelector("#s-download-tiles");
|
||||
let origText = btn.textContent;
|
||||
btn.disabled = true;
|
||||
btn.textContent = "Скачивание...";
|
||||
btn.textContent = "Список...";
|
||||
let files = [];
|
||||
try {
|
||||
let appDir = await window.__TAURI__.path.appDataDir();
|
||||
let dest = appDir + `/tiles_${regionKey}.db`;
|
||||
let bytes = await invoke("download_file", { url, dest });
|
||||
let sizeMB = (parseInt(bytes) / 1024 / 1024).toFixed(1);
|
||||
btn.textContent = `Импорт (${sizeMB} MB)...`;
|
||||
let count = await TileCache.importFromSqlite(dest, (done, total) => {
|
||||
let pct = Math.round(done / total * 100);
|
||||
btn.textContent = `${pct}% (${done}/${total})`;
|
||||
});
|
||||
btn.textContent = "Регионы";
|
||||
btn.disabled = false;
|
||||
showToast(`${regionName}: ${count} тайлов импортировано`);
|
||||
TileCache.count().then(n => {
|
||||
let el = container.querySelector("#s-tile-count");
|
||||
if (el) el.textContent = `${n} тайлов в кэше`;
|
||||
});
|
||||
files = await fetchRouterTiles();
|
||||
} catch (e) {
|
||||
btn.textContent = "Регионы";
|
||||
btn.disabled = false;
|
||||
showToast("Ошибка: " + String(e));
|
||||
btn.disabled = false; btn.textContent = origText;
|
||||
showToast("Роутер недоступен: " + (e.message || e));
|
||||
return;
|
||||
}
|
||||
btn.disabled = false; btn.textContent = origText;
|
||||
|
||||
// Show every known region. Ones present on the router are tappable; the rest
|
||||
// are greyed out so the list never silently loses entries. Any extra .db on
|
||||
// the router that isn't a known region is listed too.
|
||||
let byKey = {};
|
||||
for (let f of files) byKey[f.name] = f;
|
||||
let rows = Object.keys(KNOWN_REGIONS).map(k => ({ key: k, label: KNOWN_REGIONS[k], f: byKey[k] }));
|
||||
for (let f of files) if (!KNOWN_REGIONS[f.name]) rows.push({ key: f.name, label: f.name, f });
|
||||
|
||||
let items = rows.map((r, i) => r.f
|
||||
? `<div class="supp-pick-item" data-idx="${i}">${esc(r.label)} <span style="color:var(--muted);font-size:12px">${r.f.sizeMB ? r.f.sizeMB + " MB" : ""}</span></div>`
|
||||
: `<div class="supp-pick-item" style="opacity:.4;pointer-events:none">${esc(r.label)} <span style="font-size:12px">нет на роутере</span></div>`
|
||||
).join("");
|
||||
|
||||
// Tap a map to start downloading it; the OK button is unused here.
|
||||
showModal(`
|
||||
<h3>Карты на роутере</h3>
|
||||
<div class="prod-hint">Нажмите на карту, чтобы скачать и импортировать</div>
|
||||
<div id="t-file-list" class="supp-pick-list">${items}</div>`,
|
||||
async () => "norender",
|
||||
overlay => {
|
||||
overlay.querySelectorAll("#t-file-list .supp-pick-item[data-idx]").forEach(el => {
|
||||
el.onclick = () => {
|
||||
let r = rows[parseInt(el.dataset.idx)];
|
||||
overlay.querySelector("#modal-cancel").click(); // closes the modal
|
||||
importTilesFromUrl(TILES_BASE_URL + r.f.file, r.f.file, btn, container);
|
||||
};
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// Roads map
|
||||
|
|
|
|||
2
src-tauri/Cargo.lock
generated
2
src-tauri/Cargo.lock
generated
|
|
@ -139,7 +139,7 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
|||
|
||||
[[package]]
|
||||
name = "baldav"
|
||||
version = "0.1.366"
|
||||
version = "0.1.382"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"btleplug",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "baldav"
|
||||
version = "0.1.366"
|
||||
version = "0.1.382"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
|
|
|
|||
|
|
@ -126,6 +126,9 @@ class GpsRecorderService : Service(), LocationListener, SensorEventListener {
|
|||
// Genuine start from JS — new session file
|
||||
pointsFile = File(appDataDir, "gps_points_${System.currentTimeMillis()}.jsonl")
|
||||
ptrFile.writeText(pointsFile!!.name)
|
||||
// Drop any stale session id left from a previous recording so the new
|
||||
// warmup file can't be bound to the wrong session before movement.
|
||||
try { File(appDataDir, "gps_session.txt").writeText("") } catch (_: Exception) {}
|
||||
}
|
||||
|
||||
// Raw sensor file (50Hz, ~5MB/hour) with rotation (keep last 15)
|
||||
|
|
@ -207,6 +210,7 @@ class GpsRecorderService : Service(), LocationListener, SensorEventListener {
|
|||
// Flush buffers every 10 seconds (GPS) and 5 seconds (raw sensors)
|
||||
fun scheduleFlush() {
|
||||
flushHandler.postDelayed({
|
||||
bindSessionIfPending()
|
||||
flushBuffer()
|
||||
flushRawSensors()
|
||||
// Heartbeat: proves the loop is alive and shows GPS freshness.
|
||||
|
|
@ -463,6 +467,33 @@ class GpsRecorderService : Service(), LocationListener, SensorEventListener {
|
|||
Log.d(TAG, "Buffered point #$pointCount: ${location.latitude},${location.longitude} spd=${location.speed} acc=${location.accuracy} (buf=${pointBuffer.size})")
|
||||
}
|
||||
|
||||
// Rename the active points file to carry its session id once JS reports it:
|
||||
// gps_points_<ms>.jsonl -> gps_points_<ms>_s<id>.jsonl. Runs on the flush
|
||||
// thread under bufferLock, so it can't race a concurrent write. flushBuffer
|
||||
// reopens the file by path each time, so updating pointsFile is enough.
|
||||
private fun bindSessionIfPending() {
|
||||
val appDataDir = filesDir.parentFile ?: filesDir
|
||||
val sessFile = File(appDataDir, "gps_session.txt")
|
||||
val sid = try { if (sessFile.exists()) sessFile.readText().trim() else "" } catch (e: Exception) { "" }
|
||||
if (sid.isEmpty() || !sid.all { it.isDigit() }) return
|
||||
synchronized(bufferLock) {
|
||||
val cur = pointsFile ?: return@synchronized
|
||||
if (!cur.name.contains("_s")) {
|
||||
val newName = cur.name.removeSuffix(".jsonl") + "_s" + sid + ".jsonl"
|
||||
val dest = File(appDataDir, newName)
|
||||
if (cur.renameTo(dest)) {
|
||||
pointsFile = dest
|
||||
File(appDataDir, "gps_live_points.jsonl").writeText(newName)
|
||||
Log.d(TAG, "Bound session $sid -> $newName")
|
||||
} else {
|
||||
Log.w(TAG, "Bind rename FAILED for $newName")
|
||||
return@synchronized // keep sess file for a retry next tick
|
||||
}
|
||||
}
|
||||
}
|
||||
try { sessFile.writeText("") } catch (_: Exception) {}
|
||||
}
|
||||
|
||||
private fun flushBuffer() {
|
||||
synchronized(bufferLock) {
|
||||
if (pointBuffer.isEmpty()) return
|
||||
|
|
|
|||
|
|
@ -37,6 +37,31 @@ class MainActivity : TauriActivity() {
|
|||
pendingSaveContent = null
|
||||
}
|
||||
|
||||
// SAF "open document" picker. A content:// URI has no filesystem path, so the
|
||||
// picked file is copied into the app dir and its path is written to
|
||||
// open_file_result.txt, which the JS side polls via the Rust get_picked_file.
|
||||
private val openFileLauncher = registerForActivityResult(
|
||||
ActivityResultContracts.OpenDocument()
|
||||
) { uri: Uri? ->
|
||||
val appDataDir = filesDir.parentFile ?: filesDir
|
||||
val resultFile = File(appDataDir, "open_file_result.txt")
|
||||
if (uri == null) {
|
||||
resultFile.writeText("CANCELLED")
|
||||
return@registerForActivityResult
|
||||
}
|
||||
try {
|
||||
val dest = File(appDataDir, "picked_import.db")
|
||||
contentResolver.openInputStream(uri)?.use { input ->
|
||||
dest.outputStream().use { out -> input.copyTo(out, 1 shl 20) }
|
||||
}
|
||||
Log.d("BalDav", "Picked file copied to ${dest.absolutePath} (${dest.length()} bytes)")
|
||||
resultFile.writeText(dest.absolutePath)
|
||||
} catch (e: Exception) {
|
||||
Log.e("BalDav", "Failed to copy picked file", e)
|
||||
resultFile.writeText("ERROR:" + (e.message ?: "copy failed"))
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
enableEdgeToEdge()
|
||||
super.onCreate(savedInstanceState)
|
||||
|
|
@ -214,6 +239,13 @@ class MainActivity : TauriActivity() {
|
|||
saveFileLauncher.launch(filename)
|
||||
}
|
||||
}
|
||||
cmd.startsWith("open:") -> {
|
||||
// open:<mime> — show SAF open picker; result path lands in open_file_result.txt
|
||||
val mime = cmd.substringAfter("open:").ifBlank { "*/*" }
|
||||
File(appDataDir, "open_file_result.txt").writeText("")
|
||||
Log.d("BalDav", "Opening file picker for mime=$mime")
|
||||
openFileLauncher.launch(arrayOf(mime))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,55 @@ use serde::Serialize;
|
|||
#[allow(unused, dead_code)]
|
||||
mod ble;
|
||||
|
||||
// Directory where the Kotlin GpsRecorderService writes native point files.
|
||||
// Kotlin uses `filesDir.parentFile` (the app root, e.g. /data/user/0/<pkg>/).
|
||||
// Rust's app_data_dir() nesting on Android is unreliable, so instead of a fixed
|
||||
// `parent().parent()`, scan candidate ancestors and pick the one that actually
|
||||
// holds the pointer / point files. Falls back to the old app_data_dir()/../.. .
|
||||
fn native_root(app: &tauri::AppHandle) -> std::path::PathBuf {
|
||||
let data_dir = app.path().app_data_dir().unwrap_or_default();
|
||||
let candidates: Vec<std::path::PathBuf> = [
|
||||
data_dir.parent().and_then(|p| p.parent()).map(|p| p.to_path_buf()),
|
||||
data_dir.parent().map(|p| p.to_path_buf()),
|
||||
Some(data_dir.clone()),
|
||||
].into_iter().flatten().collect();
|
||||
// Prefer the dir that has the live pointer file.
|
||||
for c in &candidates {
|
||||
if c.join("gps_live_points.jsonl").exists() { return c.clone(); }
|
||||
}
|
||||
// Otherwise the dir that has any gps_points_*.jsonl.
|
||||
for c in &candidates {
|
||||
if let Ok(rd) = std::fs::read_dir(c) {
|
||||
if rd.flatten().any(|e| e.file_name().to_str()
|
||||
.map_or(false, |n| n.starts_with("gps_points_") && n.ends_with(".jsonl"))) {
|
||||
return c.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
data_dir.parent().and_then(|p| p.parent()).map(|p| p.to_path_buf()).unwrap_or(data_dir)
|
||||
}
|
||||
|
||||
// Diagnostic: report app_data_dir, each candidate root, whether the pointer
|
||||
// exists there and how many gps_points_*.jsonl it holds. Surfaced to JS/logcat.
|
||||
#[tauri::command]
|
||||
fn debug_native_paths(app: tauri::AppHandle) -> Result<String, String> {
|
||||
let data_dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
|
||||
let candidates: Vec<(String, std::path::PathBuf)> = vec![
|
||||
("grandparent(../..)".into(), data_dir.parent().and_then(|p| p.parent()).map(|p| p.to_path_buf()).unwrap_or_default()),
|
||||
("parent(..)".into(), data_dir.parent().map(|p| p.to_path_buf()).unwrap_or_default()),
|
||||
("app_data_dir".into(), data_dir.clone()),
|
||||
];
|
||||
let mut out = format!("app_data_dir={} chosen={}", data_dir.display(), native_root(&app).display());
|
||||
for (label, dir) in &candidates {
|
||||
let ptr = dir.join("gps_live_points.jsonl").exists();
|
||||
let cnt = std::fs::read_dir(dir).map(|rd| rd.flatten()
|
||||
.filter(|e| e.file_name().to_str().map_or(false, |n| n.starts_with("gps_points_") && n.ends_with(".jsonl")))
|
||||
.count()).unwrap_or(0);
|
||||
out.push_str(&format!(" | {}={} ptr={} gps_points={}", label, dir.display(), ptr, cnt));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
// Simple HTTP proxy for sync requests (WebView blocks mixed http/https content).
|
||||
// Runs the blocking ureq call on a worker thread (spawn_blocking) with short
|
||||
// timeouts, so an unreachable host (e.g. router 192.168.1.1 when off home wifi)
|
||||
|
|
@ -38,8 +87,7 @@ async fn http_request(url: String, method: String, body: Option<String>) -> Resu
|
|||
// Get the active points file name from gps_live_points.jsonl pointer
|
||||
#[tauri::command]
|
||||
fn get_points_filename(app: tauri::AppHandle) -> Result<String, String> {
|
||||
let data_dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
|
||||
let app_root = data_dir.parent().and_then(|p| p.parent()).map(|p| p.to_path_buf()).unwrap_or(data_dir);
|
||||
let app_root = native_root(&app);
|
||||
let ptr_path = app_root.join("gps_live_points.jsonl");
|
||||
if ptr_path.exists() {
|
||||
let content = std::fs::read_to_string(&ptr_path).unwrap_or_default().trim().to_string();
|
||||
|
|
@ -49,11 +97,26 @@ fn get_points_filename(app: tauri::AppHandle) -> Result<String, String> {
|
|||
}
|
||||
}
|
||||
|
||||
// Hand the just-created session id to the running GPS service. The service, on
|
||||
// its next flush tick, renames the active gps_points_<ms>.jsonl to
|
||||
// gps_points_<ms>_s<id>.jsonl so recovery can bind the file to its session with
|
||||
// no guessing. Written to gps_session.txt in the same dir the service watches.
|
||||
#[tauri::command]
|
||||
fn gps_set_session_id(app: tauri::AppHandle, session_id: String) -> Result<String, String> {
|
||||
// Guard: digits only (it becomes part of a filename).
|
||||
if session_id.is_empty() || !session_id.chars().all(|c| c.is_ascii_digit()) {
|
||||
return Err("invalid session id".to_string());
|
||||
}
|
||||
let app_root = native_root(&app);
|
||||
let path = app_root.join("gps_session.txt");
|
||||
std::fs::write(&path, session_id).map_err(|e| e.to_string())?;
|
||||
Ok("ok".to_string())
|
||||
}
|
||||
|
||||
// Delete a GPS points file after confirmed DB insert
|
||||
#[tauri::command]
|
||||
fn delete_points_file(app: tauri::AppHandle, filename: String) -> Result<String, String> {
|
||||
let data_dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
|
||||
let app_root = data_dir.parent().and_then(|p| p.parent()).map(|p| p.to_path_buf()).unwrap_or(data_dir);
|
||||
let app_root = native_root(&app);
|
||||
let path = app_root.join(&filename);
|
||||
if path.exists() && filename.starts_with("gps_points_") && filename.ends_with(".jsonl") {
|
||||
std::fs::remove_file(&path).map_err(|e| e.to_string())?;
|
||||
|
|
@ -63,6 +126,71 @@ fn delete_points_file(app: tauri::AppHandle, filename: String) -> Result<String,
|
|||
}
|
||||
}
|
||||
|
||||
// Ask Kotlin to show the SAF "open document" picker. Kotlin copies the chosen
|
||||
// file into the app dir and writes its path to open_file_result.txt.
|
||||
#[tauri::command]
|
||||
fn pick_file_open(app: tauri::AppHandle, mime: String) -> Result<String, String> {
|
||||
let data_dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
|
||||
std::fs::create_dir_all(&data_dir).ok();
|
||||
let m = if mime.is_empty() { "*/*".to_string() } else { mime };
|
||||
for dir in [Some(data_dir.clone()), data_dir.parent().and_then(|p| p.parent()).map(|p| p.to_path_buf())] {
|
||||
if let Some(d) = dir {
|
||||
let _ = std::fs::write(d.join("open_file_result.txt"), "");
|
||||
let _ = std::fs::write(d.join("gps_command.txt"), format!("open:{}", m));
|
||||
}
|
||||
}
|
||||
Ok("ok".to_string())
|
||||
}
|
||||
|
||||
// Poll the result of pick_file_open: "" while the picker is open, otherwise the
|
||||
// copied file's path, or "CANCELLED" / "ERROR:...".
|
||||
#[tauri::command]
|
||||
fn get_picked_file(app: tauri::AppHandle) -> Result<String, String> {
|
||||
let app_root = native_root(&app);
|
||||
let p = app_root.join("open_file_result.txt");
|
||||
if p.exists() {
|
||||
Ok(std::fs::read_to_string(&p).unwrap_or_default().trim().to_string())
|
||||
} else {
|
||||
Ok(String::new())
|
||||
}
|
||||
}
|
||||
|
||||
// List all native GPS point files (gps_points_*.jsonl) in the app root.
|
||||
// Used by on-device track recovery: the foreground service writes every point
|
||||
// here regardless of screen state, so these files can rebuild a session's DB
|
||||
// rows if the JS import ever falls behind.
|
||||
#[tauri::command]
|
||||
fn list_points_files(app: tauri::AppHandle) -> Result<String, String> {
|
||||
let app_root = native_root(&app);
|
||||
let mut names: Vec<String> = Vec::new();
|
||||
if let Ok(entries) = std::fs::read_dir(&app_root) {
|
||||
for e in entries.flatten() {
|
||||
if let Some(n) = e.file_name().to_str() {
|
||||
if n.starts_with("gps_points_") && n.ends_with(".jsonl") {
|
||||
names.push(n.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
names.sort();
|
||||
Ok(serde_json::to_string(&names).unwrap_or_else(|_| "[]".to_string()))
|
||||
}
|
||||
|
||||
// Read a specific native points file by name (validated, no path traversal).
|
||||
#[tauri::command]
|
||||
fn read_points_file(app: tauri::AppHandle, filename: String) -> Result<String, String> {
|
||||
if !(filename.starts_with("gps_points_") && filename.ends_with(".jsonl")) || filename.contains('/') {
|
||||
return Err("bad filename".to_string());
|
||||
}
|
||||
let app_root = native_root(&app);
|
||||
let path = app_root.join(&filename);
|
||||
if path.exists() {
|
||||
std::fs::read_to_string(&path).map_err(|e| e.to_string())
|
||||
} else {
|
||||
Ok(String::new())
|
||||
}
|
||||
}
|
||||
|
||||
// Download binary file from URL to local path (for tile DB import from router)
|
||||
// read_to_vec() has a 10MB limit, so use as_reader() for unlimited binary reads
|
||||
#[tauri::command]
|
||||
|
|
@ -229,8 +357,7 @@ fn gps_service_command(app: tauri::AppHandle, cmd: String) -> Result<String, Str
|
|||
// Read GPS points from service's JSONL file
|
||||
#[tauri::command]
|
||||
fn gps_service_read_points(app: tauri::AppHandle) -> Result<String, String> {
|
||||
let data_dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
|
||||
let app_root = data_dir.parent().and_then(|p| p.parent()).map(|p| p.to_path_buf()).unwrap_or(data_dir.clone());
|
||||
let app_root = native_root(&app);
|
||||
// gps_live_points.jsonl now contains the FILENAME of the active points file
|
||||
let ptr_path = app_root.join("gps_live_points.jsonl");
|
||||
let pts_file = if ptr_path.exists() {
|
||||
|
|
@ -738,7 +865,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, rebuild_roads, get_road_points, get_app_info, gps_service_command, gps_service_read_points, save_file, get_update_info, http_request, ble::ble_scan, ble::ble_scan_results, ble::ble_connect, ble::ble_disconnect, ble::ble_get_hr, ble::ble_is_connected, ble::ble_check_bluetooth, ble::ble_save_device, ble::ble_clear_hr])
|
||||
.invoke_handler(tauri::generate_handler![get_safe_insets, import_seed_products, import_tiles, download_file, get_points_filename, delete_points_file, gps_set_session_id, list_points_files, read_points_file, pick_file_open, get_picked_file, debug_native_paths, rebuild_roads, get_road_points, get_app_info, gps_service_command, gps_service_read_points, save_file, get_update_info, http_request, 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])
|
||||
.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.366",
|
||||
"version": "0.1.382",
|
||||
"identifier": "dev.baldav.app",
|
||||
"build": {
|
||||
"frontendDist": "../frontend"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue