diff --git a/.gitignore b/.gitignore index e4b6398..baa8599 100644 --- a/.gitignore +++ b/.gitignore @@ -30,5 +30,9 @@ chzn_session.json binaries/ test.exe +# Build script holds a deploy secret (router password); build counter is local state +build.sh +BUILD_NUMBER + # Reference material kept locally, not in the repo *.pdf diff --git a/PROJECT_MAP.md b/PROJECT_MAP.md index 3f87437..006451f 100644 --- a/PROJECT_MAP.md +++ b/PROJECT_MAP.md @@ -22,7 +22,7 @@ | Файл | Строк | Назначение | |------|-------|------------| | `js/main.js` | ~5900 | Экраны, навигация, модалки, графики, нутриенты, HR UI, компас (EMA + gimbal lock), ландшафт (3 режима), дороги. `appConfirm`/`appPrompt` (in-app диалоги вместо нативных, в стеке окон). Страница «Сохранённые маршруты», оверлей маршрута на записи и просмотре (`whenMapReady` — await готовности карты, не таймаут). Панель де-джиттера на детали трека (ползунки окно/итерации, живое превью, сохранить/откат, `reopenDetail` — переоткрытие детали без наложения оверлеев). `showRouteDetail` — предпросмотр маршрута (карта+профиль высоты+тап-высоты+фуллскрин), `showRouteEditor` — создание/правка маршрута (тап карты=точка в конец, тап линии=вставка в сегмент, тащить=двигать, тап точки=удалить; `makeMarker` возвращает маркер для push/splice). `appConfirm`/`appPrompt`/`appAlert` — in-app диалоги (нативных `alert/prompt/confirm` не осталось; ` + `; @@ -2778,16 +2874,98 @@ async function renderGpsScreen(container) { GPS.recording.gearCount = bike.gear_count; } } - await GPS.startRecording(type, wkg); + // Nudge BT (for the strap) / location (for the track) on if off. Non-blocking to the user's + // choice: they can decline, and the two "Не спрашивать" settings suppress each prompt. + await ensureSystemReady(!!state.profile?.ble_device_addr, type !== "workout"); + + // Workout (HR-only): pass the profile so finish can compute kcal from HR (Keytel). + let hrProfile = type === "workout" ? { + weightKg: wkg, + age: BMR.getAge(parseInt(state.profile?.birth_year) || 1988), + sex: state.profile?.sex || "m", + heightCm: parseFloat(state.profile?.height_cm) || 178, + } : null; + await GPS.startRecording(type, wkg, hrProfile); renderScreen("recording"); } }; - // Session click → detail view. Keep sid as a STRING (numeric id from the - // SQL plugin is corrupted off-by-1 for large i64 values). - let bindSessionClick = (item) => { item.onclick = () => showSessionDetail(item.dataset.sid); }; + // Session click → detail view; LONG-PRESS → multi-select for merging tracks. Keep sid a + // STRING (numeric id from the SQL plugin is corrupted off-by-1 for large i64 values). + let selMode = false, selected = new Set(); + let selBar = container.querySelector("#gps-sel-bar"); + let selCount = container.querySelector("#gps-sel-count"); + let selMergeBtn = container.querySelector("#gps-sel-merge"); + let refreshSel = () => { + container.querySelectorAll(".gps-session-item").forEach(it => it.classList.toggle("selected", selected.has(it.dataset.sid))); + if (selBar) selBar.hidden = !selMode; + if (selCount) selCount.textContent = "Выбрано " + selected.size; + if (selMergeBtn) selMergeBtn.disabled = selected.size < 2; + }; + let exitSel = () => { selMode = false; selected.clear(); refreshSel(); }; + let toggleSel = (sid) => { + if (selected.has(sid)) selected.delete(sid); else selected.add(sid); + if (selected.size === 0) { exitSel(); return; } + refreshSel(); + }; + let enterSel = (sid) => { selMode = true; selected.add(sid); refreshSel(); }; + + // Long-press: hold ~500 ms without moving. Works for touch and (desktop) mouse; the + // click that follows is suppressed so a long-press doesn't also open the detail. + let attachLongPress = (item) => { + let timer = null, longFired = false; + let cancel = () => { if (timer) { clearTimeout(timer); timer = null; } }; + let start = () => { longFired = false; cancel(); timer = setTimeout(() => { timer = null; longFired = true; if (!selMode) enterSel(item.dataset.sid); else toggleSel(item.dataset.sid); }, 500); }; + item.addEventListener("touchstart", start, { passive: true }); + item.addEventListener("touchmove", cancel, { passive: true }); + item.addEventListener("touchend", cancel); + item.addEventListener("mousedown", start); + item.addEventListener("mouseup", cancel); + item.addEventListener("mouseleave", cancel); + item.onclick = () => { if (longFired) { longFired = false; return; } if (selMode) toggleSel(item.dataset.sid); else showSessionDetail(item.dataset.sid); }; + }; + let bindSessionClick = attachLongPress; container.querySelectorAll(".gps-session-item").forEach(bindSessionClick); + let sessTs = (s, which) => new Date((s.date || "1970-01-01") + "T" + ((which === "end" ? s.end_time : s.start_time) || "00:00:00")); + if (container.querySelector("#gps-sel-cancel")) container.querySelector("#gps-sel-cancel").onclick = () => exitSel(); + if (selMergeBtn) selMergeBtn.onclick = async () => { + if (selected.size < 2) return; + let ids = [...selected]; + let sess = []; + for (let id of ids) { let s = await DB.getGpsSession(id); if (s) sess.push(s); } + if (sess.length < 2) return; + sess.sort((a, b) => sessTs(a) - sessTs(b)); + // Different bikes must not be merged: gear/weight differ, so power and cadence would be + // computed wrong for the mixed track. Refuse and tell the rider how to proceed. + let bikeSet = new Set(sess.map(s => s.bike_id ?? "none")); + if (bikeSet.size > 1) { + await appAlert("Велосипеды у выбранных треков разные - склеить нельзя: передача и вес отличаются, мощность и каденс посчитались бы неверно.\n\nЕсли это на самом деле один велосипед, задайте его всем трекам одинаково (в детали трека: «Велосипед», затем «Пересчитать») и склеивайте снова."); + return; + } + let gapWarn = false; + for (let i = 1; i < sess.length; i++) { + let gapMin = (sessTs(sess[i]) - sessTs(sess[i - 1], "end")) / 60000; + if (gapMin > 5) gapWarn = true; + } + let msg = "Объединить " + sess.length + " тренировки в одну?" + + (gapWarn ? "\n\nВнимание: между треками разрыв больше 5 минут - точки лягут с пропусками." : "") + + "\n\nОбратимо: исходные тренировки помечаются в точках (можно будет разделить позже)."; + if (!(await appConfirm(msg, "Объединить"))) return; + try { + selMergeBtn.disabled = true; selMergeBtn.textContent = "Объединяю..."; + let targetId = await DB.mergeGpsSessions(ids); + if (targetId) await GPS.recalculateSession(targetId, { noDelete: true }); + showToast("Объединено в одну тренировку"); + exitSel(); + renderScreen("gps"); + } catch (e) { + console.error("[merge]", e); + showToast("Ошибка объединения: " + (e.message || e)); + selMergeBtn.disabled = false; selMergeBtn.textContent = "Объединить"; + } + }; + // Pagination: "Показать ещё" loads the next page from the DB and appends it. let sessOffset = sessions.length; let moreBtn = container.querySelector("#gps-more"); @@ -3072,7 +3250,7 @@ async function showAddGoalMarkerModal() { // Draw manual route on map // --------------------------------------------------------------------------- -async function fetchElevations(points) { +async function fetchElevations(points, onProgress) { // Terrain elevation, tried in order until one answers a batch: // 1. GPXZ (if a key is set) — Copernicus GLO-30, best for grades (abs <4m). // 2. open-elevation.com (SRTM, reachable in RU). @@ -3124,6 +3302,7 @@ async function fetchElevations(points) { } catch (e) { console.log("[opentopodata] batch fail:", e.message); } } if (got) for (let j = 0; j < got.length; j++) elevations[i + j] = got[j]; + if (onProgress) try { onProgress(Math.min(i + 100, points.length), points.length); } catch {} } return elevations.some(x => x != null) ? elevations : null; } @@ -3691,7 +3870,7 @@ async function showRouteDetail(container, routeId) { if (mapEl) { void mapEl.offsetHeight; // force layout so Leaflet reads the real size L.Icon.Default.imagePath = "lib/"; - let map = L.map(mapEl, { attributionControl: false, zoomControl: false }); + let map = L.map(mapEl, { preferCanvas: true, attributionControl: false, zoomControl: false }); // canvas: slope-coloured route vectors vs SVG compositor tile-memory limit map.setView([pts[0].lat, pts[0].lon], 13); // give the map a view NOW so vector layers render (full-page timing) new (TileCache.createLayer())({ maxZoom: 19 }).addTo(map); let routeLayer = drawRoutePolyline(map, pts, route.color, mode); @@ -4286,6 +4465,115 @@ function posPuckIcon(fill, withTriangle) { }); } +// HR-only "workout" session detail: no map — HR chart + load (kcal) + avg/max. +function renderWorkoutDetail(session) { + let samples = []; + try { samples = JSON.parse(session.hr_samples || "[]"); } catch {} + let hrs = samples.map(s => s[1]); + let durSec = samples.length ? samples[samples.length - 1][0] : 0; + let avg = hrs.length ? Math.round(hrs.reduce((a, b) => a + b, 0) / hrs.length) : null; + let max = hrs.length ? Math.max(...hrs) : null; + let kcal = Math.round(session.total_kcal || 0); + let html = ` +
Зарядка · ${session.date} ${session.start_time ?? ""}
+
+
${GPS.formatDuration(durSec)}Время
+
${kcal} ккалНагрузка
+
+
+
${avg ?? "—"}Средний пульс
+
${max ?? "—"}Макс пульс
+
+
+
Пульс
+
+
+ `; + showModal(html, () => {}, (overlay) => { + let ctx = overlay.querySelector("#wk-hr-chart"); + if (ctx && samples.length) { + new Chart(ctx, { + type: "line", + data: { labels: samples.map(s => Math.round(s[0] / 60) + "м"), + datasets: [{ data: hrs, borderWidth: 2, pointRadius: 0, fill: false, tension: 0.3, + segment: { borderColor: c => hrZoneColor(c.p1.parsed.y) } }] }, + options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } }, scales: { x: { display: false } } }, + }); + } + let del = overlay.querySelector("#wk-del"); + if (del) del.onclick = async () => { + if (!(await appConfirm("Удалить эту зарядку?", "Удалить"))) return; + await DB.deleteGpsSession(session.id); + if (winStack.length > 0) winStack.pop().close(); + _skipPops = 1; history.back(); + renderScreen("gps"); + }; + }); +} + +// HR-only "workout" recording screen (зарядка): no map/GPS, live heart rate + timer + kcal. +function renderWorkoutInline(container) { + container.innerHTML = ` +
+
--
+
уд/мин
+
+
0:00Время
+
0ккал
+
--средний
+
--макс
+
+
Подключите пульсометр
+ + +
`; + + let hrEl = container.querySelector("#wk-hr"), timeEl = container.querySelector("#wk-time"); + let kcalEl = container.querySelector("#wk-kcal"), avgEl = container.querySelector("#wk-avg"); + let maxEl = container.querySelector("#wk-max"), statusEl = container.querySelector("#wk-status"); + + GPS.recording.onHrUpdate = (reading) => { + if (hrEl) hrEl.textContent = reading.hr; + if (statusEl) statusEl.textContent = "Пульсометр на связи"; + }; + + let tick = () => { + if (!GPS.recording.active) { clearInterval(window._wkTimer); return; } + let sec = GPS.recording.startTime ? Math.round((Date.now() - GPS.recording.startTime.getTime()) / 1000) : 0; + if (timeEl) timeEl.textContent = GPS.formatDuration(sec); + let samples = GPS.recording.hrSamples || [], hrs = samples.map(s => s[1]); + if (kcalEl) kcalEl.textContent = GPS.hrKcalNet(samples, GPS.recording.hrProfile); + if (avgEl) avgEl.textContent = hrs.length ? Math.round(hrs.reduce((a, b) => a + b, 0) / hrs.length) : "--"; + if (maxEl) maxEl.textContent = hrs.length ? Math.max(...hrs) : "--"; + if (GPS.recording.currentHr && hrEl) hrEl.textContent = GPS.recording.currentHr; + if (statusEl && GPS.recording.currentHr) { + let b = GPS.recording.currentBattery; + statusEl.textContent = "Пульсометр на связи" + (b != null ? ` · заряд ${b}%` : ""); + } + }; + clearInterval(window._wkTimer); + window._wkTimer = setInterval(tick, 1000); + tick(); + + container.querySelector("#wk-connect").onclick = async () => { + let addr = state.profile?.ble_device_addr; + if (!addr) { showToast("Сначала привяжите датчик: Настройки -> Пульсометр -> Поиск"); return; } + let bt = "on"; try { bt = await window.__TAURI__.core.invoke("ble_check_bluetooth"); } catch {} + if (bt === "off") { try { await window.__TAURI__.core.invoke("ble_request_enable"); } catch {} showToast("Включите Bluetooth"); return; } + if (statusEl) statusEl.textContent = "Подключение..."; + try { await window.__TAURI__.core.invoke("ble_connect", { address: addr, deviceType: state.profile?.ble_device_type || "hr_standard" }); } catch (e) { showToast("Ошибка: " + (e.message || e)); } + }; + + container.querySelector("#wk-stop").onclick = async () => { + clearInterval(window._wkTimer); + let res = await GPS.stopRecording(); + if (res && res.sessionId) { + showToast(`Зарядка: ${res.kcal} ккал, ${GPS.formatDuration(res.durationSec)}, пульс ${res.avgHr ?? "--"}/${res.maxHr ?? "--"}`); + showSessionDetail(res.sessionId); + } else { renderScreen("gps"); } + }; +} + async function renderRecordingInline(container) { if (!GPS.recording.active) { renderScreen("gps"); return; } @@ -4296,30 +4584,29 @@ async function renderRecordingInline(container) { }).catch(() => {}); } + // HR-only workout (зарядка): no map, no GPS — just heart rate, timer, live kcal. + if (GPS.recording.activityType === "workout") { renderWorkoutInline(container); return; } + container.innerHTML = `
+
+ 0:00 + — + ${new Date().toTimeString().slice(0,5)} +
${GPS.recording.state === "waiting_gps" ? '
Поиск GPS...
' : ''}
-
+
--Пульс
0 мДистанция
0км/ч
-
0:00Время
—Высота
-
${new Date().toTimeString().slice(0,5)}Часы
-
—t°C
-
${GPS.recording.state === "waiting_gps" ? "Поиск GPS..." : GPS.recording.state === "ready" ? "Начните движение..." : ""}
- +
@@ -4335,7 +4622,11 @@ async function renderRecordingInline(container) { let lastPt = GPS.recording.lastMovingPoint; let initLat = lastPt?.lat || parseFloat(state.profile.last_lat) || 47.47; let initLon = lastPt?.lon || parseFloat(state.profile.last_lon) || 40.07; - let map = L.map(mapEl, { attributionControl: false, scrollWheelZoom: false, rotate: true, bearing: 0, compassBearing: false }).setView([initLat, initLon], 15); + // Remember the recording map's zoom across screen switches (persist on zoom, restore here). + let recZoom = parseInt(localStorage.getItem("baldav_rec_zoom"), 10); + if (!(recZoom >= 1 && recZoom <= 19)) recZoom = 15; + let map = L.map(mapEl, { attributionControl: false, scrollWheelZoom: false, rotate: true, bearing: 0, compassBearing: false }).setView([initLat, initLon], recZoom); + map.on("zoomend", () => { try { localStorage.setItem("baldav_rec_zoom", String(map.getZoom())); } catch {} }); mapEl.addEventListener("wheel", (e) => { if (e.ctrlKey) { e.preventDefault(); map.setZoom(map.getZoom() + (e.deltaY < 0 ? 1 : -1)); } }, { passive: false }); @@ -4353,6 +4644,7 @@ async function renderRecordingInline(container) { // BOTH directions (orange = compass, green = GPS course) for visual comparison. let rotOffset = 0; // 0/90/180/270 — whole-screen rotation let compassFrozen = false; // X button: freeze current heading + let followMode = true; // ⊕ button: keep the map centred on the current position let frozenHeading = 0; // heading captured at the moment of freezing let smoothedHeading = null; // EMA of calibrated compass heading (deg, 0=N, CW) let courseHeading = null; // EMA of GPS course over ground (deg, 0=N, CW) @@ -4518,6 +4810,26 @@ async function renderRecordingInline(container) { }; btnWrap.appendChild(freezeBtn); + // Follow button — ⊕: toggle whether the map keeps re-centring on the current position. + // Off = free pan (look around when lost / off-route); tapping it back on recentres now. + let followBtn = document.createElement("button"); + followBtn.className = "rec-map-btn rec-follow-btn active"; + followBtn.textContent = "⊕"; // ⊕ + followBtn.title = "Следить за позицией"; + followBtn.onclick = () => { + followMode = !followMode; + followBtn.classList.toggle("active", followMode); + if (followMode) { + // Re-enabling: jump straight back to where we are now. + let pts = GPS.recording.points; + if (pts && pts.length) { + let last = pts.slice().sort((a, b) => new Date(a.ts) - new Date(b.ts))[pts.length - 1]; + if (last) map.panTo([last.lat, last.lon]); + } + } + }; + btnWrap.appendChild(followBtn); + // Roads toggle button let roadsBtn = document.createElement("button"); roadsBtn.className = "rec-map-btn rec-roads-toggle"; @@ -4595,7 +4907,7 @@ async function renderRecordingInline(container) { async function refreshRecNogos() { recNogoLayer = await drawNogoAreas(map, recNogoLayer, refreshRecNogos); } refreshRecNogos(); let recNogoBtn = document.createElement("button"); - recNogoBtn.className = "rec-map-btn"; + recNogoBtn.className = "rec-map-btn rec-nogo-btn"; recNogoBtn.textContent = "⛔"; recNogoBtn.title = "Здесь нет проезда (отметить текущее место)"; recNogoBtn.onclick = async () => { @@ -4612,7 +4924,7 @@ async function renderRecordingInline(container) { // route or the recording. Tap again to rebuild from the new position; long-press to clear. let rerouteLayer = null; let rerouteBtn = document.createElement("button"); - rerouteBtn.className = "rec-map-btn"; + rerouteBtn.className = "rec-map-btn rec-reroute-btn"; rerouteBtn.textContent = "↪"; rerouteBtn.title = "Объехать: вернуться на маршрут за непроезжим местом"; rerouteBtn.onclick = async () => { @@ -4695,7 +5007,7 @@ async function renderRecordingInline(container) { if (ph) ph.remove(); statusEl.textContent = "GPS OK. Начните движение..."; statusEl.style.color = "#03dac6"; - map.setView([p.lat, p.lon], 16); + if (followMode) map.setView([p.lat, p.lon], 16); } else if (state === "recording") { statusEl.textContent = ""; statusEl.style.color = ""; @@ -4710,7 +5022,7 @@ async function renderRecordingInline(container) { let spdEl = document.querySelector("#rec-speed"); let timeEl = document.querySelector("#rec-time"); let elevEl = document.querySelector("#rec-elev"); - let statusEl = document.querySelector("#rec-status"); + let statusEl = { style: {} }; // status line removed from the UI; keep a no-op sink so the existing statusEl.* writes don't crash // Speed buffer for median let speedBuf = []; @@ -4721,7 +5033,7 @@ async function renderRecordingInline(container) { let ll = [p.lat, p.lon]; borderLine.addLatLng(ll); trailLine.addLatLng(ll); - map.panTo(ll); + if (followMode) map.panTo(ll); // Save position for next session start (avoid black screen) DB.setProfileValue("last_lat", String(p.lat)); DB.setProfileValue("last_lon", String(p.lon)); @@ -4738,7 +5050,7 @@ async function renderRecordingInline(container) { if (recState === "waiting_gps") { statusEl.textContent = "Поиск GPS..."; statusEl.style.color = "#ffb74d"; - map.setView([p.lat, p.lon], 15); + if (followMode) map.setView([p.lat, p.lon], 15); return; } if (recState === "ready") { @@ -4773,19 +5085,13 @@ async function renderRecordingInline(container) { // HR update from BLE GPS.recording.onHrUpdate = (reading) => { - let row = document.querySelector("#rec-hr-row"); - if (!row) return; - row.style.display = "flex"; - let hrEl = document.querySelector("#rec-hr-val"); - if (hrEl) { - hrEl.textContent = reading.hr; - hrEl.className = "rec-hr-val " + hrZoneClass(reading.hr); - } - let spo2El = document.querySelector("#rec-spo2"); - if (spo2El) spo2El.textContent = reading.spo2 ? `SpO2: ${reading.spo2}%` : ""; - let bpEl = document.querySelector("#rec-bp"); - if (bpEl) bpEl.textContent = (reading.bp_sys && reading.bp_dia) ? `${reading.bp_sys}/${reading.bp_dia}` : ""; + let hrEl = document.querySelector("#rec-hr-val"); // HR stat in the bottom row + if (hrEl) { hrEl.textContent = reading.hr; hrEl.className = "rec-val " + hrZoneClass(reading.hr); } + let hrNum = document.querySelector("#rec-hr-num"); // live HR on the toggle button itself + if (hrNum) hrNum.textContent = reading.hr; + setHrBatteryFill(document.querySelector("#rec-hr-toggle"), GPS.recording.currentBattery); // strap charge }; + setHrBatteryFill(container.querySelector("#rec-hr-toggle"), GPS.recording.currentBattery); // on render, if already known // Timer — excludes pauses, starts only when recording let timer = setInterval(() => { @@ -4834,11 +5140,15 @@ async function renderRecordingInline(container) { // Headwind label toggle: tap to open a "wind in the face" interval, tap to close. // Marks are ground truth for validating the from-track wind estimate — not kcal. - let windBtn = container.querySelector("#rec-wind"); - if (windBtn) windBtn.onclick = () => { - let active = GPS.toggleWindMark(); - windBtn.style.background = active ? "#ffb74d" : ""; - windBtn.style.color = active ? "#1a1a1a" : ""; + // HR on/off toggle (replaces the old "Ветер" button). ON connects the paired strap + polls; + // OFF disconnects. Grey = off, coloured = on. + let hrToggle = container.querySelector("#rec-hr-toggle"); + if (hrToggle) hrToggle.onclick = async () => { + hrToggle.disabled = true; + await GPS.setHrOn(!GPS.recording.hrOn, true); // user tap → may ask OS to enable BT (once) + hrToggle.classList.toggle("active", !!GPS.recording.hrOn); + if (!GPS.recording.hrOn) { let n = container.querySelector("#rec-hr-num"); if (n) n.textContent = ""; setHrBatteryFill(hrToggle, null); } // off -> icon only, no fill + hrToggle.disabled = false; }; // Pause button @@ -4886,7 +5196,7 @@ async function renderRecordingInline(container) { trailLine.setLatLngs(coords); if (coords.length) { let last = coords[coords.length - 1]; - map.panTo(last); + if (followMode) map.panTo(last); updateCourse(sorted[sorted.length - 1]); // course from the last point object (has bearing) updateMe(last); } @@ -5069,12 +5379,17 @@ function showMapDownloadModal() { // --------------------------------------------------------------------------- let _sessionDetailRender = 0; +// Remember each track's last map view (centre+zoom) so a recompute/refine reopen — or coming +// back to a track you looked at — restores where you were instead of re-fitting the whole track. +let _sdMapView = {}; async function showSessionDetail(sessionId) { let renderToken = ++_sessionDetailRender; try { console.log("[SD] loading session", 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; } console.log("[SD] loading points"); let rawPoints = await DB.getGpsPoints(sessionId); console.log("[SD] got", rawPoints.length, "raw points"); @@ -5094,7 +5409,7 @@ async function showSessionDetail(sessionId) { elevGain: Math.round(session.elevation_gain_m || 0), elevLoss: Math.round(session.elevation_loss_m || 0), duration: 0, - } : GPS.computeStats(points, state.latestWeight?.weight_kg ?? state.profile?.weight_kg ?? 80, { pauses: session.pauses || [] }); + } : GPS.computeStats(points, session.rider_weight_kg ?? state.latestWeight?.weight_kg ?? state.profile?.weight_kg ?? 80, { pauses: session.pauses || [] }); // Manual-pause total (ms) to exclude from displayed duration let pauseMs = (session.pauses || []).reduce((a, z) => a + Math.max(0, (z.endTs || 0) - (z.startTs || 0)), 0); // Compute duration from points regardless (minus manual pauses) @@ -5103,7 +5418,7 @@ async function showSessionDetail(sessionId) { } // Always compute segments from points (needed for the Segments section) if (!stats.segments && points.length >= 3) { - let fullStats = GPS.computeStats(points, state.latestWeight?.weight_kg ?? state.profile?.weight_kg ?? 80, { pauses: session.pauses || [] }); + let fullStats = GPS.computeStats(points, session.rider_weight_kg ?? state.latestWeight?.weight_kg ?? state.profile?.weight_kg ?? 80, { pauses: session.pauses || [] }); stats.segments = fullStats.segments; } @@ -5153,6 +5468,21 @@ async function showSessionDetail(sessionId) { : "—"; let kcalMin = stats.duration > 0 ? (stats.kcal / (stats.duration / 60)).toFixed(1) : "—"; + // HR-based kcal "what if" (Keytel) — comparison ONLY. The activity balance keeps the physics/MET + // number (don't disturb the established diet); here we just show what HR would give, if present. + let _t0hr = points.length ? new Date(points[0].ts).getTime() : 0; + let hrSamples = points.filter(p => p.heart_rate > 0).map(p => [(new Date(p.ts).getTime() - _t0hr) / 1000, p.heart_rate]); + let hasHr = hrSamples.length > 2; + let hrProfile = { + weightKg: session.rider_weight_kg ?? state.latestWeight?.weight_kg ?? state.profile?.weight_kg ?? 75, + age: BMR.getAge(parseInt(state.profile?.birth_year) || 1988), + sex: state.profile?.sex || "m", + heightCm: parseFloat(state.profile?.height_cm) || 178, + }; + let hrKcal = hasHr ? GPS.hrKcalNet(hrSamples, hrProfile) : 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; + showModal(`

${actName}

ID: ${session.id}
@@ -5178,27 +5508,29 @@ async function showSessionDetail(sessionId) {
-
-
Скорость
+
+ Скорость
${stats.avgSpeed} км/чСредняя
${stats.maxSpeed} км/чМакс.
-
+
Калории
-
${session.total_kcal ?? stats.kcal} ккалВсего
+
${session.total_kcal ?? stats.kcal} ккал${hasHr ? "Физика (в баланс)" : "Всего"}
${kcalMin} ккал/минРасход
+ ${hasHr ? `
${hrKcal} ккалПо ЧСС (сравнение)
+
В баланс идёт «физика»; цифра по ЧСС (Keytel) - для сравнения.
` : ""}
${elevations.length > 0 ? ` -
-
Высота
+
+ Высота
${minElev} мМин.
${maxElev} мМакс.
@@ -5208,8 +5540,17 @@ async function showSessionDetail(sessionId) {
${stats.elevLoss} мСпуск
- -
` : ""} + ` : ""} + + ${hasHr ? ` +
+ Пульс +
+
${hrAvg}Средний
+
${hrMax}Макс.
+
+
+
` : ""} ${(stats.segments && stats.segments.length > 1) ? `
@@ -5231,6 +5572,12 @@ async function showSessionDetail(sessionId) {
` : ""} + ${session.activity_type === 'bike' ? ` +
+ Разбор повторов +
Считаю...
+
` : ""} + +
+
+
Резервная копия всех данных
+
Экспорт всей базы в файл и восстановление из него - чтобы переносить наработки между версиями (debug/release) без потерь. Импорт заменяет ВСЁ и перезапускает приложение.
+
+
+ + +
+
Очистить продукты без магазина
@@ -6901,10 +7374,21 @@ async function renderSettings(container) {
${state.profile.ble_device_addr ? `` : ""} + ${state.profile.ble_device_addr ? `` : ""}
+ +
@@ -7093,6 +7577,38 @@ async function renderSettings(container) { let sbgClear = container.querySelector("#s-share-bg-clear"); if (sbgClear) sbgClear.onclick = () => { localStorage.removeItem("baldav_share_bg"); showToast("Подложка сброшена"); }; + // Full-DB backup: export the whole database to a file (SAF save), and restore from one. + let expDbBtn = container.querySelector("#s-export-db"); + if (expDbBtn) expDbBtn.onclick = async () => { + let prev = expDbBtn.textContent; + expDbBtn.disabled = true; expDbBtn.textContent = "Готовлю..."; + try { + let filename = "baldav-backup-" + new Date().toISOString().slice(0, 10) + ".db"; + let res = await window.__TAURI__.core.invoke("export_db", { filename }); + let size = 0; try { size = JSON.parse(res).size || 0; } catch {} + showToast("Копия готова (" + (size / 1048576).toFixed(1) + " МБ) - выберите, куда сохранить"); + } catch (e) { appAlert("Экспорт не удался: " + (e.message || e)); } + expDbBtn.disabled = false; expDbBtn.textContent = prev; + }; + let impDbBtn = container.querySelector("#s-import-db"); + if (impDbBtn) impDbBtn.onclick = async () => { + if (!/Android/i.test(navigator.userAgent)) { appAlert("Импорт базы доступен только на устройстве."); return; } + if (!(await appConfirm("Импорт ЗАМЕНИТ все текущие данные (треки, питание, профиль, велосипеды) данными из файла. Несохранённое пропадёт.\n\nСделайте экспорт текущей базы, если она нужна. Продолжить?", "Выбрать файл"))) return; + let prev = impDbBtn.textContent; + impDbBtn.disabled = true; impDbBtn.textContent = "Выбор..."; + try { + let path = await pickLocalFile("*/*"); + if (!path) { showToast("Файл не выбран"); return; } + impDbBtn.textContent = "Проверка..."; + await window.__TAURI__.core.invoke("import_db"); // validates picked_import.db + stages it + // No auto-restart: on a single-activity app the process-restart is unreliable (Android 12+ + // blocks background activity starts). The staged DB is swapped on the next launch, so ask + // the user to fully reopen the app — reliable, and they confirmed it works. + await appAlert("База проверена и готова. Чтобы применить восстановление:\n\n1. Полностью закройте приложение (смахните его из списка недавних).\n2. Откройте снова.\n\nПосле этого данные будут заменены восстановленными."); + } catch (e) { appAlert("Импорт не удался: " + (e.message || e)); } + impDbBtn.disabled = false; impDbBtn.textContent = prev; + }; + // 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 () => { @@ -7267,6 +7783,51 @@ async function renderSettings(container) { }; } + // Manual connect to the already-paired band. Works any time, including during a ride. + // Nothing auto-connects anymore (battery), so this is how HR starts flowing into the track. + let bleConnectBtn = container.querySelector("#s-ble-connect"); + if (bleConnectBtn) bleConnectBtn.onclick = async () => { + let addr = state.profile.ble_device_addr, type = state.profile.ble_device_type || "hr_standard"; + if (!addr) return; + let statusEl = container.querySelector("#s-ble-status"); + let bt = "on"; + try { bt = await window.__TAURI__.core.invoke("ble_check_bluetooth"); } catch {} + if (bt === "off") { + try { await window.__TAURI__.core.invoke("ble_request_enable"); } catch {} + showToast("Включите Bluetooth, затем нажмите «Подключить»"); + return; + } + if (bt === "no_adapter") { showToast("Bluetooth недоступен"); return; } + bleConnectBtn.disabled = true; bleConnectBtn.textContent = "..."; + if (statusEl) statusEl.textContent = "Подключение..."; + try { + await window.__TAURI__.core.invoke("ble_connect", { address: addr, deviceType: type }); + // If a ride is recording, make sure the HR poll runs so HR starts landing in the track. + if (GPS.recording.active && !GPS.recording.hrPollInterval) { try { await GPS._startHrMonitor(); } catch {} } + // Poll briefly (Kotlin scans+connects in the background) for a connection or first reading. + let ok = false; + for (let i = 0; i < 6; i++) { + await new Promise(r => setTimeout(r, 1000)); + try { + if (await window.__TAURI__.core.invoke("ble_is_connected")) { ok = true; break; } + let rd = await window.__TAURI__.core.invoke("ble_get_hr"); + if (rd && rd.hr > 0) { ok = true; break; } + } catch {} + } + if (statusEl) statusEl.textContent = ok ? addr + " (подключён)" : "браслет не найден"; + showToast(ok ? "Пульсометр подключён" : "Браслет не найден - включите его рядом"); + } catch (e) { + if (statusEl) statusEl.textContent = "ошибка подключения"; + showToast("Ошибка подключения: " + (e.message || e)); + } + bleConnectBtn.disabled = false; bleConnectBtn.textContent = "Подключить"; + }; + + let dontAskBt = container.querySelector("#s-dontask-bt"); + if (dontAskBt) dontAskBt.onchange = () => { try { localStorage.setItem("baldav_dontask_bt", dontAskBt.checked ? "1" : "0"); } catch {} }; + let dontAskGps = container.querySelector("#s-dontask-gps"); + if (dontAskGps) dontAskGps.onchange = () => { try { localStorage.setItem("baldav_dontask_gps", dontAskGps.checked ? "1" : "0"); } catch {} }; + let bleScanBtn = container.querySelector("#s-ble-scan"); let bleResults = container.querySelector("#s-ble-results"); if (bleScanBtn) { @@ -8110,7 +8671,7 @@ async function showRoadsMap() { let mapEl = document.querySelector("#roads-map"); L.Icon.Default.imagePath = "lib/"; - let map = L.map(mapEl, { attributionControl: false }).setView([47.42, 40.08], 13); + 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); @@ -8124,15 +8685,28 @@ async function showRoadsMap() { let rebuildBtn = document.querySelector("#roads-rebuild"); async function doRebuild() { rebuildBtn.disabled = true; - rebuildBtn.textContent = "..."; + rebuildBtn.textContent = "Построение..."; + setBtnProgress(rebuildBtn, 0); infoEl.textContent = "Построение (может занять минуту)..."; + // Rust emits roads_progress [current,total] as it scans points → fill the button by %. + let unlisten = null; try { + try { + unlisten = await window.__TAURI__.event.listen("roads_progress", (ev) => { + let cur = ev.payload?.[0] ?? 0, tot = ev.payload?.[1] ?? 0; + if (tot > 0) { setBtnProgress(rebuildBtn, cur / tot * 100); infoEl.textContent = `Построение: ${cur} / ${tot}`; } + }); + } catch {} let count = await DB.rebuildRoads(); + if (unlisten) unlisten(); + setBtnProgress(rebuildBtn, null); infoEl.textContent = `${count} точек`; rebuildBtn.textContent = "Перестроить"; rebuildBtn.disabled = false; showRoadsMap(); } catch (e) { + if (unlisten) unlisten(); + setBtnProgress(rebuildBtn, null); infoEl.textContent = "Ошибка: " + e; rebuildBtn.textContent = "Перестроить"; rebuildBtn.disabled = false; @@ -8178,7 +8752,7 @@ async function showRoadsMap() { let allCoords = []; for (let s of segments) { let color = roadColor(s.atype); - L.polyline(s.coords, { color, weight: 4, opacity: 1 }).addTo(map); + 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); } diff --git a/frontend/js/poi.js b/frontend/js/poi.js index 6caa6d0..51497bd 100644 --- a/frontend/js/poi.js +++ b/frontend/js/poi.js @@ -49,7 +49,11 @@ function render() { group.clearLayers(); - if (!shown || map.getZoom() < MINZOOM || !POI._data) return; + let z = map.getZoom(); + if (!shown || z < MINZOOM || !POI._data) return; + // Fewer markers the further you zoom out: a wide view spans too many POIs to read (and to + // draw). Scale the cap with zoom - a handful near MINZOOM, the full CAP once zoomed in. + let cap = z >= 16 ? CAP : z >= 15 ? 200 : z >= 14 ? 80 : 30; let b = map.getBounds(), n = 0; for (let i = 0; i < POI._data.length; i++) { let p = POI._data[i]; @@ -59,7 +63,7 @@ let c = CATS[p.c] || { l: p.c }; m.bindPopup((p.n ? "" + p.n + "
" : "") + c.l); group.addLayer(m); - if (++n >= CAP) break; + if (++n >= cap) break; } } map.on("moveend zoomend", render); diff --git a/frontend/js/tilecache.js b/frontend/js/tilecache.js index fb07f84..69ec187 100644 --- a/frontend/js/tilecache.js +++ b/frontend/js/tilecache.js @@ -127,6 +127,13 @@ TileCache.downloadArea = async function(bounds, zoomMin, zoomMax, onProgress) { // Custom Leaflet TileLayer that checks IndexedDB first TileCache.createLayer = function() { return L.TileLayer.extend({ + options: { + // Keep fewer off-screen tile rings and don't reload tiles mid-zoom: on a high-DPI + // screen the compositor's raster-tile budget (cc/tiles "tile memory limits exceeded") + // is filled by the map tiles themselves, so trimming retained tiles keeps content drawing. + keepBuffer: 1, // default 2 + updateWhenZooming: false, // defer tile loads until the zoom gesture settles + }, createTile: function(coords, done) { const tile = document.createElement("img"); const key = `${coords.z}/${coords.x}/${coords.y}`; diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 9601b68..c5a960e 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -139,7 +139,7 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "baldav" -version = "0.1.457" +version = "0.1.496" dependencies = [ "base64 0.22.1", "btleplug", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 24e8bc2..e83bd7f 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "baldav" -version = "0.1.457" +version = "0.1.496" 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 ac1ccb5..fb9ec27 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 @@ -7,10 +7,15 @@ import android.app.PendingIntent import android.app.Service import android.bluetooth.* import android.bluetooth.le.* +import android.content.BroadcastReceiver +import android.content.Context import android.content.Intent +import android.content.IntentFilter import android.content.pm.PackageManager import android.os.Build +import android.os.Handler import android.os.IBinder +import android.os.Looper import android.os.ParcelUuid import android.util.Log import java.io.File @@ -31,6 +36,9 @@ class BleHrService : Service() { val HR_SERVICE_UUID: UUID = UUID.fromString("0000180d-0000-1000-8000-00805f9b34fb") val HR_MEASUREMENT_UUID: UUID = UUID.fromString("00002a37-0000-1000-8000-00805f9b34fb") val CCCD_UUID: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb") + // Standard Battery Service (strap charge) + val BATTERY_SERVICE: UUID = UUID.fromString("0000180f-0000-1000-8000-00805f9b34fb") + val BATTERY_LEVEL: UUID = UUID.fromString("00002a19-0000-1000-8000-00805f9b34fb") // FitPro Nordic UART val FITPRO_SERVICE: UUID = UUID.fromString("6e400001-b5a3-f393-e0a9-e50e24dcca9d") @@ -51,15 +59,40 @@ class BleHrService : Service() { private var gatt: BluetoothGatt? = null private var deviceType = "hr_standard" private var dataFile: File? = null + private var lastBattery: Int = -1 // strap charge %, -1 = unknown private var scanResultsFile: File? = null private var fitproWriteChar: BluetoothGattCharacteristic? = null private var fitproGatt: BluetoothGatt? = null + // One handler for all postDelayed work, so teardown can cancel every pending callback at once + // (a stray callback touching a GATT/scanner after the adapter is off crashes the native BT stack). + private val handler = Handler(Looper.getMainLooper()) + + // When the user turns Bluetooth off, stop touching GATT immediately and shut the service down. + private val btStateReceiver = object : BroadcastReceiver() { + override fun onReceive(ctx: Context?, intent: Intent?) { + if (intent?.action != BluetoothAdapter.ACTION_STATE_CHANGED) return + val state = intent.getIntExtra(BluetoothAdapter.EXTRA_STATE, BluetoothAdapter.ERROR) + if (state == BluetoothAdapter.STATE_TURNING_OFF || state == BluetoothAdapter.STATE_OFF) { + Log.d(TAG, "Bluetooth turning off - stopping BLE service") + handler.removeCallbacksAndMessages(null) + disconnect() + writeData("""{"status":"bt_off","ts":${System.currentTimeMillis()/1000}}""") + stopSelf() + } + } + } + + private fun adapter(): BluetoothAdapter? = + (getSystemService(BLUETOOTH_SERVICE) as? android.bluetooth.BluetoothManager)?.adapter + override fun onBind(intent: Intent?): IBinder? = null override fun onCreate() { super.onCreate() createNotificationChannel() + try { registerReceiver(btStateReceiver, IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED)) } + catch (e: Exception) { Log.e(TAG, "register BT receiver: ${e.message}") } } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { @@ -72,7 +105,7 @@ class BleHrService : Service() { ACTION_SCAN -> { startForeground(NOTIFICATION_ID, buildNotification("Поиск BLE...")) scanDevices() - return START_STICKY + return START_NOT_STICKY // don't let Android restart us into an empty null-intent churn } else -> { val address = intent?.getStringExtra(EXTRA_ADDRESS) @@ -84,7 +117,7 @@ class BleHrService : Service() { dataFile = File(appDataDir, "ble_hr_data.json") connectDevice(address) } - return START_STICKY + return START_NOT_STICKY // connect is user-initiated; no sticky restart } } } @@ -134,10 +167,10 @@ class BleHrService : Service() { } } - scanner.startScan(callback) + try { scanner.startScan(callback) } catch (e: Exception) { Log.e(TAG, "startScan: ${e.message}"); writeScanResults("[]"); stopSelf(); return } // Stop after 5 seconds - android.os.Handler(mainLooper).postDelayed({ - scanner.stopScan(callback) + handler.postDelayed({ + try { scanner.stopScan(callback) } catch (e: Exception) { Log.e(TAG, "stopScan: ${e.message}") } writeScanResults("[${results.joinToString(",")}]") Log.d(TAG, "Scan complete: ${results.size} devices") // Don't stopSelf() — connect intent may follow immediately @@ -170,8 +203,9 @@ class BleHrService : Service() { override fun onScanResult(callbackType: Int, result: ScanResult) { if (result.device.address == address) { Log.d(TAG, "Found target device in scan, connecting...") - scanner.stopScan(this) - gatt = result.device.connectGatt(this@BleHrService, false, gattCallback, BluetoothDevice.TRANSPORT_LE) + try { scanner.stopScan(this) } catch (e: Exception) { Log.e(TAG, "stopScan: ${e.message}") } + try { gatt = result.device.connectGatt(this@BleHrService, false, gattCallback, BluetoothDevice.TRANSPORT_LE) } + catch (e: Exception) { Log.e(TAG, "connectGatt: ${e.message}") } } } override fun onScanFailed(errorCode: Int) { @@ -179,21 +213,28 @@ class BleHrService : Service() { directConnect(adapter, address) } } - scanner.startScan(scanCallback) + try { scanner.startScan(scanCallback) } catch (e: Exception) { Log.e(TAG, "startScan: ${e.message}"); stopSelf(); return } // Timeout: if device not found in 5 seconds, try direct connect - android.os.Handler(mainLooper).postDelayed({ - scanner.stopScan(scanCallback) + handler.postDelayed({ + try { scanner.stopScan(scanCallback) } catch (e: Exception) { Log.e(TAG, "stopScan: ${e.message}") } if (gatt == null) { - Log.d(TAG, "Scan timeout, trying direct connect to $address") - directConnect(adapter, address) + // Not seen in the scan → the band isn't nearby/on. Do NOT hold a GATT connect to + // an absent device (that keeps the radio busy and drains the battery). Give up; + // the user re-taps "Подключить" once the band is on. + Log.d(TAG, "Scan timeout, device $address not found - stopping") + writeData("""{"status":"not_found","ts":${System.currentTimeMillis()/1000}}""") + stopSelf() } }, 5000) } private fun directConnect(adapter: BluetoothAdapter, address: String) { - val device = adapter.getRemoteDevice(address) - Log.d(TAG, "Direct connecting to $address ($deviceType)") - gatt = device.connectGatt(this, false, gattCallback, BluetoothDevice.TRANSPORT_LE) + if (!adapter.isEnabled) { Log.e(TAG, "directConnect: adapter off"); stopSelf(); return } + try { + val device = adapter.getRemoteDevice(address) + Log.d(TAG, "Direct connecting to $address ($deviceType)") + gatt = device.connectGatt(this, false, gattCallback, BluetoothDevice.TRANSPORT_LE) + } catch (e: Exception) { Log.e(TAG, "directConnect: ${e.message}"); stopSelf() } } private val gattCallback = object : BluetoothGattCallback() { @@ -201,7 +242,7 @@ class BleHrService : Service() { if (newState == BluetoothProfile.STATE_CONNECTED) { Log.d(TAG, "Connected, discovering services...") updateNotification("Подключён, поиск сервисов...") - g.discoverServices() + try { g.discoverServices() } catch (e: Exception) { Log.e(TAG, "discoverServices: ${e.message}") } } else if (newState == BluetoothProfile.STATE_DISCONNECTED) { Log.d(TAG, "Disconnected") isRunning = false @@ -256,6 +297,15 @@ class BleHrService : Service() { override fun onCharacteristicChanged(g: BluetoothGatt, char: BluetoothGattCharacteristic) { onCharacteristicChanged(g, char, char.value) } + // Battery level read (0x2A19). Two overloads: API 33+ passes the value, older reads char.value. + override fun onCharacteristicRead(g: BluetoothGatt, char: BluetoothGattCharacteristic, value: ByteArray, status: Int) { + if (char.uuid == BATTERY_LEVEL && value.isNotEmpty()) { lastBattery = value[0].toInt() and 0xFF; Log.d(TAG, "Battery: $lastBattery%") } + } + @Deprecated("Deprecated in API 33") + override fun onCharacteristicRead(g: BluetoothGatt, char: BluetoothGattCharacteristic, status: Int) { + val v = char.value + if (char.uuid == BATTERY_LEVEL && v != null && v.isNotEmpty()) { lastBattery = v[0].toInt() and 0xFF; Log.d(TAG, "Battery: $lastBattery%") } + } override fun onDescriptorWrite(g: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int) { Log.d(TAG, "onDescriptorWrite status=$status") @@ -273,13 +323,32 @@ class BleHrService : Service() { if (service == null) { Log.e(TAG, "HR service not found"); return } val char = service.getCharacteristic(HR_MEASUREMENT_UUID) if (char == null) { Log.e(TAG, "HR characteristic not found"); return } - g.setCharacteristicNotification(char, true) - val desc = char.getDescriptor(CCCD_UUID) - if (desc != null) { - desc.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE - g.writeDescriptor(desc) - } + try { + g.setCharacteristicNotification(char, true) + val desc = char.getDescriptor(CCCD_UUID) + if (desc != null) { + desc.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE + g.writeDescriptor(desc) + } + } catch (e: Exception) { Log.e(TAG, "HR notify setup: ${e.message}"); return } Log.d(TAG, "Standard HR setup complete") + // Strap battery (0x180F/0x2A19): read once after the HR subscribe settles, then every 60s. + val readBat = object : Runnable { + override fun run() { + if (!isRunning) return + readBattery(g) + handler.postDelayed(this, 60000) + } + } + handler.postDelayed(readBat, 2500) + } + + private fun readBattery(g: BluetoothGatt) { + try { + val bs = g.getService(BATTERY_SERVICE) ?: return + val bc = bs.getCharacteristic(BATTERY_LEVEL) ?: return + g.readCharacteristic(bc) + } catch (e: Exception) { Log.e(TAG, "readBattery: ${e.message}") } } private val gattQueue = mutableListOf<() -> Unit>() @@ -298,7 +367,7 @@ class BleHrService : Service() { fun onGattOperationComplete() { gattBusy = false - android.os.Handler(mainLooper).postDelayed({ processGattQueue() }, 100) + handler.postDelayed({ processGattQueue() }, 100) } private fun setupFitPro(g: BluetoothGatt) { @@ -311,16 +380,18 @@ class BleHrService : Service() { // Queue all operations: enable notify → PAIR → HR_SWITCH → REALTIME → MEASURE enqueueGatt { - g.setCharacteristicNotification(notifyChar, true) - val desc = notifyChar.getDescriptor(CCCD_UUID) - if (desc != null) { - desc.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE - g.writeDescriptor(desc) - Log.d(TAG, "FitPro: writing CCCD descriptor") - } else { - Log.d(TAG, "FitPro: no CCCD, skipping") - onGattOperationComplete() - } + try { + g.setCharacteristicNotification(notifyChar, true) + val desc = notifyChar.getDescriptor(CCCD_UUID) + if (desc != null) { + desc.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE + g.writeDescriptor(desc) + Log.d(TAG, "FitPro: writing CCCD descriptor") + } else { + Log.d(TAG, "FitPro: no CCCD, skipping") + onGattOperationComplete() + } + } catch (e: Exception) { Log.e(TAG, "FitPro notify setup: ${e.message}"); onGattOperationComplete() } } enqueueGatt { Log.d(TAG, "FitPro: PAIR"); writeCommand(g, writeChar, FITPRO_PAIR) } enqueueGatt { Log.d(TAG, "FitPro: HR_SWITCH"); writeCommand(g, writeChar, FITPRO_HR_SWITCH_ON) } @@ -333,37 +404,36 @@ class BleHrService : Service() { // Re-trigger measurement every 25 seconds fitproWriteChar = writeChar fitproGatt = g - android.os.Handler(mainLooper).also { h -> - val retrigger = object : Runnable { - override fun run() { - if (!isRunning) return - Log.d(TAG, "FitPro: re-trigger measurement") - fitproGatt?.let { gg -> fitproWriteChar?.let { wc -> - writeCommand(gg, wc, FITPRO_MEASURE_START) - android.os.Handler(mainLooper).postDelayed({ - writeCommand(gg, wc, FITPRO_MEASURE_OLD) - }, 300) - }} - h.postDelayed(this, 25000) - } + val retrigger = object : Runnable { + override fun run() { + if (!isRunning) return + Log.d(TAG, "FitPro: re-trigger measurement") + fitproGatt?.let { gg -> fitproWriteChar?.let { wc -> + writeCommand(gg, wc, FITPRO_MEASURE_START) + handler.postDelayed({ + writeCommand(gg, wc, FITPRO_MEASURE_OLD) + }, 300) + }} + handler.postDelayed(this, 25000) } - h.postDelayed(retrigger, 25000) } + handler.postDelayed(retrigger, 25000) } private fun writeCommand(g: BluetoothGatt, char: BluetoothGattCharacteristic, data: ByteArray) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - g.writeCharacteristic(char, data, BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE) - // NO_RESPONSE may not trigger onCharacteristicWrite, so auto-complete after 200ms - android.os.Handler(mainLooper).postDelayed({ onGattOperationComplete() }, 200) - } else { - @Suppress("DEPRECATION") - char.value = data - char.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE - @Suppress("DEPRECATION") - g.writeCharacteristic(char) - android.os.Handler(mainLooper).postDelayed({ onGattOperationComplete() }, 200) - } + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + g.writeCharacteristic(char, data, BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE) + } else { + @Suppress("DEPRECATION") + char.value = data + char.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE + @Suppress("DEPRECATION") + g.writeCharacteristic(char) + } + } catch (e: Exception) { Log.e(TAG, "writeCommand: ${e.message}") } + // NO_RESPONSE may not trigger onCharacteristicWrite, so auto-complete after 200ms + handler.postDelayed({ onGattOperationComplete() }, 200) } private fun parseStandardHr(data: ByteArray) { @@ -378,8 +448,9 @@ class BleHrService : Service() { } if (hr <= 0) return val ts = System.currentTimeMillis() / 1000 - writeData("""{"hr":$hr,"ts":$ts}""") - Log.d(TAG, "HR: $hr") + val batJson = if (lastBattery in 0..100) ""","bat":$lastBattery""" else "" + writeData("""{"hr":$hr,"ts":$ts$batJson}""") + Log.d(TAG, "HR: $hr bat: $lastBattery") } private fun parseFitProData(data: ByteArray) { @@ -430,10 +501,13 @@ class BleHrService : Service() { } private fun disconnect() { - gatt?.disconnect() - gatt?.close() - gatt = null isRunning = false + handler.removeCallbacksAndMessages(null) // no stray callback may touch GATT after this + try { gatt?.disconnect() } catch (e: Exception) { Log.e(TAG, "gatt.disconnect: ${e.message}") } + try { gatt?.close() } catch (e: Exception) { Log.e(TAG, "gatt.close: ${e.message}") } + gatt = null + fitproGatt = null + fitproWriteChar = null } private fun createNotificationChannel() { @@ -462,6 +536,7 @@ class BleHrService : Service() { } override fun onDestroy() { + try { unregisterReceiver(btStateReceiver) } catch (e: Exception) { /* not registered / already gone */ } disconnect() super.onDestroy() } diff --git a/src-tauri/gen/android/app/src/main/java/dev/baldav/app/MainActivity.kt b/src-tauri/gen/android/app/src/main/java/dev/baldav/app/MainActivity.kt index f69328f..caf488c 100644 --- a/src-tauri/gen/android/app/src/main/java/dev/baldav/app/MainActivity.kt +++ b/src-tauri/gen/android/app/src/main/java/dev/baldav/app/MainActivity.kt @@ -25,6 +25,7 @@ class MainActivity : TauriActivity() { private var commandObserver: FileObserver? = null private var pendingSaveContent: String? = null private var pendingImageBytes: ByteArray? = null + private var pendingDbFile: File? = null private var backWebView: WebView? = null // Disable WryActivity's generated back handler: its mWebView.goBack() does NOT @@ -78,6 +79,22 @@ class MainActivity : TauriActivity() { pendingImageBytes = null } + // SAF "create document" for the full DB backup — streams the staged file (can be tens of MB) + // straight to the picked location instead of loading it into memory. + private val saveDbLauncher = registerForActivityResult( + ActivityResultContracts.CreateDocument("application/octet-stream") + ) { uri: Uri? -> + val src = pendingDbFile + if (uri != null && src != null && src.exists()) { + try { + contentResolver.openOutputStream(uri)?.use { out -> src.inputStream().use { it.copyTo(out, 1 shl 20) } } + Log.d("BalDav", "DB exported to $uri (${src.length()} bytes)") + src.delete() + } catch (e: Exception) { Log.e("BalDav", "Failed to export DB", e) } + } + pendingDbFile = null + } + // Share a PNG via the system chooser (write to cache, FileProvider content:// URI, ACTION_SEND). private fun shareImage(bytes: ByteArray, filename: String) { try { @@ -127,6 +144,22 @@ class MainActivity : TauriActivity() { val appDataDir = filesDir.parentFile ?: filesDir Log.d("BalDav", "App data dir: ${appDataDir.absolutePath}") + // Restore-on-launch: a full-DB import stages pending_restore.db; swap it onto baldav.db NOW, + // before the app opens the DB (tauri-plugin-sql opens it later, when db.js runs). Then clear + // any stale journal so the restored file is the single source of truth. + try { + val pending = File(appDataDir, "pending_restore.db") + if (pending.exists() && pending.length() > 0) { + val dbFile = File(appDataDir, "baldav.db") + pending.inputStream().use { i -> dbFile.outputStream().use { o -> i.copyTo(o, 1 shl 20) } } + File(appDataDir, "baldav.db-journal").delete() + File(appDataDir, "baldav.db-wal").delete() + File(appDataDir, "baldav.db-shm").delete() + pending.delete() + Log.d("BalDav", "Restored DB from pending_restore.db (${dbFile.length()} bytes)") + } + } catch (e: Exception) { Log.e("BalDav", "DB restore failed", e) } + // Create command files if not exist (FileObserver needs them) val cmdFile = File(appDataDir, "gps_command.txt") if (!cmdFile.exists()) cmdFile.createNewFile() @@ -474,6 +507,26 @@ class MainActivity : TauriActivity() { intent.action = BleHrService.ACTION_STOP startService(intent) } + cmd == "ble_request_enable" -> { + Log.d("BalDav", "Requesting Bluetooth enable") + if (ensureBlePermissions()) { + try { startActivity(Intent(android.bluetooth.BluetoothAdapter.ACTION_REQUEST_ENABLE)) } + catch (e: Exception) { Log.e("BalDav", "BT enable request failed: ${e.message}") } + } + } + cmd == "check_location" -> { + val lm = getSystemService(LOCATION_SERVICE) as? LocationManager + val enabled = when { + lm == null -> false + Build.VERSION.SDK_INT >= Build.VERSION_CODES.P -> lm.isLocationEnabled + else -> lm.isProviderEnabled(LocationManager.GPS_PROVIDER) || lm.isProviderEnabled(LocationManager.NETWORK_PROVIDER) + } + File(appDataDir, "location_state.json").writeText("""{"enabled":$enabled}""") + } + cmd == "open_location_settings" -> { + try { startActivity(Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS)) } + catch (e: Exception) { Log.e("BalDav", "open location settings: ${e.message}") } + } cmd == "check_update" -> { checkForUpdate(appDataDir) } @@ -515,6 +568,33 @@ class MainActivity : TauriActivity() { } } } + cmd.startsWith("exportdb:") -> { + // exportdb: — SAF-save the DB copy Rust wrote to export_db.tmp. + val filename = cmd.substringAfter("exportdb:").ifBlank { "baldav-backup.db" } + val f = File(appDataDir, "export_db.tmp") + if (f.exists()) { pendingDbFile = f; saveDbLauncher.launch(filename) } + else Log.e("BalDav", "exportdb: export_db.tmp missing") + } + cmd == "restartapp" -> { + // Relaunch the process so the staged DB (pending_restore.db) is swapped in onCreate. + // startActivity()+exit() races (the new activity doesn't come up before the process + // dies — measured: app just closed, no restart). Schedule the relaunch via AlarmManager + // so it fires AFTER this process is gone (the ProcessPhoenix approach). + Log.d("BalDav", "Restarting app (DB restore)") + try { + val intent = packageManager.getLaunchIntentForPackage(packageName) + if (intent != null) { + intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK) + val piFlags = android.app.PendingIntent.FLAG_CANCEL_CURRENT or + (if (Build.VERSION.SDK_INT >= 23) android.app.PendingIntent.FLAG_IMMUTABLE else 0) + val pi = android.app.PendingIntent.getActivity(this, 0, intent, piFlags) + val am = getSystemService(ALARM_SERVICE) as android.app.AlarmManager + am.set(android.app.AlarmManager.RTC, System.currentTimeMillis() + 300, pi) + } + } catch (e: Exception) { Log.e("BalDav", "restart schedule failed", e) } + finishAffinity() + Runtime.getRuntime().exit(0) + } } } diff --git a/src-tauri/src/ble.rs b/src-tauri/src/ble.rs index 6148583..d20d531 100644 --- a/src-tauri/src/ble.rs +++ b/src-tauri/src/ble.rs @@ -25,6 +25,8 @@ pub struct HrReading { pub timestamp: u64, #[serde(default)] pub device: String, + #[serde(alias = "bat", default)] + pub battery: Option, } // Shared state for BLE operations @@ -438,6 +440,21 @@ pub async fn ble_disconnect( } } +// Ask the OS to turn Bluetooth on (system ACTION_REQUEST_ENABLE dialog). BLUETOOTH_CONNECT is +// already granted, so no extra permission. Called when the user taps "Подключить" with BT off. +#[tauri::command] +pub async fn ble_request_enable( + app: tauri::AppHandle, +) -> Result { + #[cfg(not(target_os = "android"))] + { let _ = &app; Ok("desktop: no-op".to_string()) } + #[cfg(target_os = "android")] + { + android::send_command(&app, "ble_request_enable")?; + Ok("Requesting enable".to_string()) + } +} + #[tauri::command] pub fn ble_get_hr( app: tauri::AppHandle, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9466747..7a8be8c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2,6 +2,8 @@ use tauri_plugin_sql::{Migration, MigrationKind}; #[allow(unused_imports)] use tauri::Manager; +#[allow(unused_imports)] +use tauri::Emitter; // app.emit(...) for progress events (Tauri v2) use serde::Serialize; #[allow(unused, dead_code)] @@ -226,6 +228,14 @@ fn read_points_file(app: tauri::AppHandle, filename: String) -> Result Result { + if name != "location_state.json" { return Err("bad name".to_string()); } + let p = native_root(&app).join(&name); + if p.exists() { std::fs::read_to_string(&p).map_err(|e| e.to_string()) } else { Ok(String::new()) } +} + // Download binary file from URL to local path (for tile DB import from router) // read_to_vec() has a 10MB limit, so use as_reader() for unlimited binary reads #[tauri::command] @@ -284,8 +294,12 @@ fn rebuild_roads(app: tauri::AppHandle, min_speed_ms: f64) -> Result Result Result { Ok(base64::engine::general_purpose::STANDARD.encode(&bytes)) } +// App root (holds baldav.db + point files + the command files Kotlin watches) and the DB path. +// MUST use native_root(): app_data_dir() nesting on Android is unreliable, so any ad-hoc +// app_data_dir().parent().parent() / db.parent() is wrong (measured: it lands in the +// unwritable /data/user and VACUUM INTO fails). native_root() scans candidates and returns the +// real writable app root that Kotlin's filesDir.parentFile also uses. Same as save points do. +fn db_and_root(app: &tauri::AppHandle) -> Result<(std::path::PathBuf, std::path::PathBuf), String> { + let root = native_root(app); + let db = root.join("baldav.db"); + Ok((db, root)) +} + +// Full backup: VACUUM the live DB into a clean copy in the app root, then tell Kotlin to +// SAF-save it as `filename`. VACUUM INTO is consistent even while the app holds the DB open. +#[tauri::command] +fn export_db(app: tauri::AppHandle, filename: String) -> Result { + let (db_path, root) = db_and_root(&app)?; // root = native_root: the writable app dir Kotlin watches + if !db_path.exists() { return Err("baldav.db не найдена".into()); } + let out = root.join("export_db.tmp"); + let _ = std::fs::remove_file(&out); + { + let conn = rusqlite::Connection::open(&db_path).map_err(|e| format!("open: {e}"))?; + conn.execute("VACUUM INTO ?1", rusqlite::params![out.to_str().unwrap_or("")]).map_err(|e| format!("vacuum: {e}"))?; + } + let size = std::fs::metadata(&out).map(|m| m.len()).unwrap_or(0); + let _ = std::fs::write(root.join("gps_command.txt"), format!("exportdb:{}", filename)); + Ok(format!("{{\"size\":{}}}", size)) +} + +// Full restore: validate the picked DB (picked_import.db) and stage it as pending_restore.db. +// Kotlin swaps it onto baldav.db on the next launch, BEFORE the app opens the DB. Returns ok; +// caller then restarts the app. +#[tauri::command] +fn import_db(app: tauri::AppHandle) -> Result { + let (_db, root) = db_and_root(&app)?; // root = native_root: Kotlin wrote picked_import.db here + let picked = root.join("picked_import.db"); + if !picked.exists() { return Err("файл не выбран".into()); } + { + let conn = rusqlite::Connection::open(&picked).map_err(|e| format!("не открыть файл: {e}"))?; + let ok: String = conn.query_row("PRAGMA quick_check", [], |r| r.get(0)).map_err(|e| format!("проверка: {e}"))?; + if ok != "ok" { return Err(format!("база повреждена: {ok}")); } + let has: i64 = conn.query_row( + "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name IN ('gps_sessions','products','profile')", + [], |r| r.get(0)).map_err(|e| format!("схема: {e}"))?; + if has < 1 { return Err("это не похоже на базу BalDav".into()); } + } + let pending = root.join("pending_restore.db"); + std::fs::copy(&picked, &pending).map_err(|e| format!("подготовка: {e}"))?; + let _ = std::fs::remove_file(&picked); + Ok("staged".into()) +} + +// Ask Kotlin to restart the app process (used after import_db to load the restored DB). +#[tauri::command] +fn restart_app(app: tauri::AppHandle) -> Result { + let (_db, root) = db_and_root(&app)?; // root = native_root, the dir Kotlin's FileObserver watches + let _ = std::fs::write(root.join("gps_command.txt"), "restartapp"); + Ok("ok".into()) +} + // Read tiles from a SQLite file in batches for import into IndexedDB #[tauri::command] fn import_tiles(path: String, offset: u32, batch_size: u32) -> Result { @@ -1088,7 +1162,7 @@ pub fn run() { .add_migrations("sqlite:baldav.db", migrations) .build(), ) - .invoke_handler(tauri::generate_handler![get_safe_insets, import_seed_products, import_tiles, download_file, get_points_filename, delete_points_file, gps_set_session_id, list_points_files, read_points_file, pick_file_open, get_picked_file, debug_native_paths, rebuild_roads, get_road_points, get_ride_graph, get_app_info, gps_service_command, gps_service_read_points, brouter_route, brouter_download_tile, brouter_download_status, brouter_list_tiles, save_file, export_image, read_file_base64, get_update_info, http_request, chzn_http, ble::ble_scan, ble::ble_scan_results, ble::ble_connect, ble::ble_disconnect, ble::ble_get_hr, ble::ble_is_connected, ble::ble_check_bluetooth, ble::ble_save_device, ble::ble_clear_hr]) + .invoke_handler(tauri::generate_handler![get_safe_insets, import_seed_products, import_tiles, download_file, get_points_filename, delete_points_file, gps_set_session_id, list_points_files, read_points_file, pick_file_open, get_picked_file, debug_native_paths, rebuild_roads, get_road_points, get_ride_graph, get_app_info, gps_service_command, gps_service_read_points, brouter_route, brouter_download_tile, brouter_download_status, brouter_list_tiles, save_file, export_image, read_file_base64, export_db, import_db, restart_app, get_update_info, http_request, chzn_http, ble::ble_scan, ble::ble_scan_results, ble::ble_connect, ble::ble_disconnect, ble::ble_get_hr, ble::ble_is_connected, ble::ble_check_bluetooth, ble::ble_save_device, ble::ble_clear_hr, ble::ble_request_enable, read_state_file]) .run(tauri::generate_context!()) .expect("error while running tauri application"); } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 44ddaed..d021293 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.457", + "version": "0.1.496", "identifier": "dev.baldav.app", "build": { "frontendDist": "../frontend" diff --git a/tools/hr_log.py b/tools/hr_log.py new file mode 100644 index 0000000..d810183 --- /dev/null +++ b/tools/hr_log.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +"""Фоновый логгер ЧСС с BLE HR-датчика (стандарт 0x180D / 0x2A37, напр. Coospo H808S). +Пишет строки 'epoch,bpm' в указанный файл, пока процесс не убьют. Авто-переподключение +при обрыве. Спаривание не требуется. + +Использование: python3 tools/hr_log.py +""" +import asyncio, sys, time +from bleak import BleakClient, BleakScanner + +ADDR = sys.argv[1] +LOG = sys.argv[2] +HR_CHR = "00002a37-0000-1000-8000-00805f9b34fb" + + +def make_cb(logf): + def cb(_, data: bytearray): + flags = data[0] + bpm = int.from_bytes(data[1:3], "little") if flags & 1 else data[1] + logf.write(f"{time.time():.1f},{bpm}\n") + logf.flush() + return cb + + +async def main(): + logf = open(LOG, "a") + logf.write(f"# {time.time():.1f} start addr={ADDR}\n") + logf.flush() + while True: + try: + dev = await BleakScanner.find_device_by_address(ADDR, timeout=15.0) + if dev is None: + logf.write(f"# {time.time():.1f} not_found, retry\n") + logf.flush() + await asyncio.sleep(3) + continue + async with BleakClient(dev) as cli: + logf.write(f"# {time.time():.1f} connected\n") + logf.flush() + await cli.start_notify(HR_CHR, make_cb(logf)) + while cli.is_connected: + await asyncio.sleep(1) + logf.write(f"# {time.time():.1f} disconnected, reconnect\n") + logf.flush() + except Exception as e: + logf.write(f"# {time.time():.1f} err {e}\n") + logf.flush() + await asyncio.sleep(3) + + +asyncio.run(main())