${session.activity_type === "vr" ? "Руки/VR" : "Зарядка"} · ${session.date} ${session.start_time ?? ""}
${GPS.formatDuration(durSec)}Время
${kcal} ккалНагрузка
@@ -4585,7 +4630,7 @@ async function renderRecordingInline(container) {
}
// HR-only workout (зарядка): no map, no GPS — just heart rate, timer, live kcal.
- if (GPS.recording.activityType === "workout") { renderWorkoutInline(container); return; }
+ if (GPS.isHrOnly(GPS.recording.activityType)) { renderWorkoutInline(container); return; }
container.innerHTML = `
@@ -4632,7 +4677,9 @@ async function renderRecordingInline(container) {
}, { passive: false });
addZoomDisplay(map);
let CachedLayer = TileCache.createLayer();
- new CachedLayer({ maxZoom: 19 }).addTo(map);
+ let tileLayer = new CachedLayer({ maxZoom: 19 }).addTo(map);
+ // Heal blank tiles along the ridden route whenever OSM is reachable.
+ TileCache.startHealer(map, tileLayer, () => (GPS.recording.points || []).map(p => [p.lat, p.lon]));
// Force recalc after flex layout settles — fixes black screen on init
setTimeout(() => map.invalidateSize(), 200);
@@ -4855,19 +4902,13 @@ async function renderRecordingInline(container) {
let dlat = (pts[i].lat - prev.lat) * 111000;
let dlon = (pts[i].lon - prev.lon) * 111000 * Math.cos(pts[i].lat * Math.PI / 180);
if (dlat * dlat + dlon * dlon > 10000 || pts[i].atype !== prev.atype) {
- if (seg.length >= 2) {
- let color = roadColor(seg[0].atype);
- roadLayers.push(L.polyline(seg.map(p => [p.lat, p.lon]), { color, weight: 3, opacity: 0.6, pane: "roadsPane" }).addTo(map));
- }
+ if (seg.length >= 2) drawRoadSegment(map, seg, roadColor(seg[0].atype), roadLayers, { pane: "roadsPane" });
seg = [];
}
}
seg.push(pts[i]);
}
- if (seg.length >= 2) {
- let color = roadColor(seg[0].atype);
- roadLayers.push(L.polyline(seg.map(p => [p.lat, p.lon]), { color, weight: 3, opacity: 0.6, pane: "roadsPane" }).addTo(map));
- }
+ if (seg.length >= 2) drawRoadSegment(map, seg, roadColor(seg[0].atype), roadLayers, { pane: "roadsPane" });
};
btnWrap.appendChild(roadsBtn);
// Auto-load roads if was enabled
@@ -5389,7 +5430,7 @@ async function showSessionDetail(sessionId) {
let session = await DB.getGpsSession(sessionId);
if (!session) { console.error("[SD] session not found"); return; }
// HR-only workout: no GPS/map — its own detail (HR chart + load), reuse this modal path.
- if (session.activity_type === "workout") { renderWorkoutDetail(session); return; }
+ if (GPS.isHrOnly(session.activity_type)) { renderWorkoutDetail(session); return; }
console.log("[SD] loading points");
let rawPoints = await DB.getGpsPoints(sessionId);
console.log("[SD] got", rawPoints.length, "raw points");
@@ -5458,7 +5499,7 @@ async function showSessionDetail(sessionId) {
let t0 = points.length ? new Date(points[0].ts).getTime() : 0;
let timeLabels = points.slice(1).map(p => ((new Date(p.ts).getTime() - t0) / 60000).toFixed(1));
- let actNames = { bike: "Велосипед", walk: "Ходьба", run: "Бег", other: "Другое" };
+ let actNames = { bike: "Велосипед", walk: "Ходьба", run: "Бег", workout: "Зарядка", vr: "Руки/VR", other: "Другое" };
let actName = actNames[session.activity_type] || session.activity_type || "Тренировка";
let duration = session.end_time && session.start_time
? GPS.formatDuration(stats.duration)
@@ -5480,6 +5521,7 @@ async function showSessionDetail(sessionId) {
heightCm: parseFloat(state.profile?.height_cm) || 178,
};
let hrKcal = hasHr ? GPS.hrKcalNet(hrSamples, hrProfile) : null;
+ let hrKcalCalib = hasHr ? Math.round(hrKcal * (GPS.config.hrCalibFactor ?? 0.65)) : null; // грубая справочная прикидка
let hrAvg = hasHr ? Math.round(hrSamples.reduce((a, s) => a + s[1], 0) / hrSamples.length) : null;
let hrMax = hasHr ? Math.max(...hrSamples.map(s => s[1])) : null;
@@ -5524,8 +5566,9 @@ async function showSessionDetail(sessionId) {
${session.total_kcal ?? stats.kcal} ккал${hasHr ? "Физика (в баланс)" : "Всего"}
${kcalMin} ккал/минРасход
- ${hasHr ? `
${hrKcal} ккалПо ЧСС (сравнение)
-
В баланс идёт «физика»; цифра по ЧСС (Keytel) - для сравнения.
` : ""}
+ ${hasHr ? `
${hrKcal} ккалПо ЧСС (Keytel)
+
${hrKcalCalib} ккалЧСС откалибр. (грубо)
+
В баланс идёт «физика». По ЧСС (Keytel) завышает; «откалибр.» = Keytel x ${(GPS.config.hrCalibFactor ?? 0.65)} - грубая прикидка для сравнения.
` : ""}
${elevations.length > 0 ? `
@@ -5594,6 +5637,7 @@ async function showSessionDetail(sessionId) {
+
@@ -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"