diff --git a/PROJECT_MAP.md b/PROJECT_MAP.md index 006451f..862a2c3 100644 --- a/PROJECT_MAP.md +++ b/PROJECT_MAP.md @@ -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` не осталось; ` + @@ -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(`

Сравнить с...

@@ -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) {
Цвет дорог: бег
+
+
Толщина дорог по проездам
Чаще проезжал - толще линия (x1..x3, +0.2 за проезд)
+ +
Fused-локация (Google Play)
@@ -7389,6 +7469,13 @@ async function renderSettings(container) {
Не предлагать включить GPS при старте записи трека
+
+
+
Ремень ЧСС: наработка
+
—
+
+ +
@@ -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() {
+
+ +
Часть съеденного тратится на переваривание. Расход масштабируется от еды; на поддержке баланс не меняется.
+
@@ -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) { diff --git a/frontend/js/tilecache.js b/frontend/js/tilecache.js index 69ec187..a20b181 100644 --- a/frontend/js/tilecache.js +++ b/frontend/js/tilecache.js @@ -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); diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index c5a960e..72e4f31 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -139,7 +139,7 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "baldav" -version = "0.1.496" +version = "0.1.513" dependencies = [ "base64 0.22.1", "btleplug", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index e83bd7f..c245a1a 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "baldav" -version = "0.1.496" +version = "0.1.513" edition = "2021" [lib] diff --git a/src-tauri/gen/android/app/src/main/java/dev/baldav/app/BleHrService.kt b/src-tauri/gen/android/app/src/main/java/dev/baldav/app/BleHrService.kt index fb9ec27..401e8e2 100644 --- a/src-tauri/gen/android/app/src/main/java/dev/baldav/app/BleHrService.kt +++ b/src-tauri/gen/android/app/src/main/java/dev/baldav/app/BleHrService.kt @@ -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() diff --git a/src-tauri/icons/icon.ico b/src-tauri/icons/icon.ico new file mode 100644 index 0000000..5525e1c Binary files /dev/null and b/src-tauri/icons/icon.ico differ diff --git a/src-tauri/src/ble.rs b/src-tauri/src/ble.rs index d20d531..1e33525 100644 --- a/src-tauri/src/ble.rs +++ b/src-tauri/src/ble.rs @@ -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 { + #[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 { + #[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"))] { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7a8be8c..08cd2b7 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -265,59 +265,72 @@ fn rebuild_roads(app: tauri::AppHandle, min_speed_ms: f64) -> Result= ?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"); } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index d021293..4c43581 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -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"