GPS accuracy-gated points + bearing; fix startup UI freeze

- Native: hard-reject fixes coarser than the Min-accuracy setting (drops
  coarse cell ~800m that stuck the track); remove low-accuracy and adaptive
  relaxation branches so only accurate fixes are recorded, honest gaps otherwise
- Store bearing_deg per point (gps_points migration + addGpsPoint)
- Default Min-accuracy 20 -> 50m (one-time migration, respects user override)
- Fix: http_request was synchronous with no timeout; an unreachable host
  (LAN sync on startup) blocked the whole UI ~30s. Now async + spawn_blocking
  + connect(4s)/global(12s) timeouts, fails in background without freezing
- Bundled prior work: course-up map orientation, compass calibration,
  online elevation refine (open-elevation), distance gap-gate fix
This commit is contained in:
4aiman 2026-07-07 00:28:27 +03:00
commit 2051a604e1
12 changed files with 717 additions and 267 deletions

View file

@ -5,7 +5,7 @@
## Возможности
- **Дневник питания** — 24400+ продуктов (Магнит, 5ка, Health-Diet.ru, Ашан, Макдоналдс, KFC, Додо Пицца, KDV, ИОН, Гриднев). 37 нутриентов (витамины, минералы, omega-3/6, вода, клетчатка). Рецепты с термообработкой. Формулы порций (4*30, 300+50).
- **GPS трекинг** — запись маршрутов на велосипеде/пешком в фоне (foreground service, точки пишутся при выключенном экране; один файл на сессию, переживает START_STICKY-перезапуск ОС). Оффлайн карты (Leaflet + IndexedDB кэш). Импорт GPX/FTB (FitoTrack). Экспорт GPX. Фильтр altitude outlier'ов и GPS-залипаний. Вращение карты по компасу (leaflet-rotate, EMA-сглаживание, gimbal lock компенсация). Ландшафтный режим (CSS rotation + панель справа, 3 режима: off/auto/lock). Dead reckoning при потере GPS (гироскоп + акселерометр). Placeholder до GPS (пиксельарт). Карта проезженных дорог (дедупликация 2м, вело/пешие). Импорт тайлов по регионам (SQLite, роутер LAN).
- **GPS трекинг** — запись маршрутов на велосипеде/пешком в фоне (foreground service, точки пишутся при выключенном экране; один файл на сессию, переживает START_STICKY-перезапуск ОС). Оффлайн карты (Leaflet + IndexedDB кэш). Импорт GPX/FTB (FitoTrack). Экспорт GPX. Фильтр altitude outlier'ов и GPS-залипаний. Отбор точек по точности (жёсткий порог по настройке «Мин. точность» — рубит грубые сотовые фиксы ~800м, оставляет слабый но точный GPS; где сигнала нет — честный разрыв). Направление (bearing) каждой точки пишется в БД. Вращение карты по компасу (leaflet-rotate, EMA-сглаживание, gimbal lock компенсация). Ландшафтный режим (CSS rotation + панель справа, 3 режима: off/auto/lock). Dead reckoning при потере GPS (гироскоп + акселерометр). Placeholder до GPS (пиксельарт). Карта проезженных дорог (дедупликация 2м, вело/пешие). Импорт тайлов по регионам (SQLite, роутер LAN).
- **BLE пульсометр** — подключение к FitPro браслетам и стандартным HR датчикам (0x180D). ЧСС, SpO2, давление в реальном времени при записи трека.
- **Расчёт ккал** — физическая модель для велосипеда (мощность из gradient + Crr + CdA). MET-таблица для ходьбы/бега и тренировок (приседания 6.0, скакалка 12.3, растяжки 2.3, бассейн 8.0). Профили велосипедов. BMR по Mifflin-St Jeor с коэффициентом активности.
- **Тренировки** — приседания (подходы x повторения), скакалка, растяжки, бассейн (дистанция). Автоматический расчёт ккал по MET и весу.

View file

@ -1098,8 +1098,30 @@ select {
background: rgba(187, 134, 252, 0.4);
border-color: var(--primary);
}
.rec-orient-lock { right: 8px; bottom: 48px; }
.rec-roads-toggle { right: 8px; bottom: 86px; }
.rec-rotate-btn { right: 8px; bottom: 48px; font-size: 18px; transition: transform 0.15s; }
.rec-freeze-btn { right: 8px; bottom: 86px; }
.rec-roads-toggle { right: 8px; bottom: 124px; }
.rec-mode-btn { right: 8px; bottom: 162px; font-size: 11px; }
/* Always-on compass needle: red half = North, grey = South. Rotates to point to real north.
Top-RIGHT (zoom controls are top-left), below the status-bar safe area. */
.rec-compass {
position: absolute; top: calc(8px + var(--safe-area-top, 0px)); right: 8px; z-index: 1001;
width: 40px; height: 40px; border-radius: 50%;
background: rgba(20,20,20,0.8); border: 1px solid var(--border);
cursor: pointer;
}
/* Keep Leaflet zoom controls below the status bar on the recording map. */
.rec-map-wrap .leaflet-top { margin-top: var(--safe-area-top, 0px); }
.rec-compass-arrow { position: absolute; inset: 0; transition: transform 0.12s linear; }
.rec-compass-arrow::before {
content: "N"; position: absolute; top: 1px; left: 50%; transform: translateX(-50%);
color: #f44336; font: 700 11px sans-serif;
}
.rec-compass-arrow::after {
content: ""; position: absolute; left: 50%; top: 14px; bottom: 6px; width: 3px; margin-left: -1.5px;
background: linear-gradient(to bottom, #f44336 0 55%, #9e9e9e 55% 100%);
border-radius: 2px;
}
.gps-placeholder {
position: absolute;
inset: 0;
@ -1117,10 +1139,11 @@ select {
font-size: 18px;
}
/* Landscape: CSS rotation compensates physical rotation.
User sees element coords directly — so flex-direction:row = panel on the RIGHT. */
.rec-screen.landscape-right,
.rec-screen.landscape-left {
/* Manual screen rotation (rotate button). 90/270 = landscape (swap dims, panel to side).
180 is intentionally not used — it would flip the panel text upside down.
Map compensates via setBearing(heading - rotOffset). */
.rec-screen.rot-90,
.rec-screen.rot-270 {
position: fixed;
width: 100vh;
height: 100vw;
@ -1129,18 +1152,12 @@ select {
z-index: 9999;
flex-direction: row;
}
.rec-screen.landscape-right {
transform: rotate(-90deg);
}
.rec-screen.landscape-left {
transform: rotate(90deg);
}
.rec-screen.landscape-right .rec-map-wrap,
.rec-screen.landscape-left .rec-map-wrap {
min-height: 0;
}
.rec-screen.landscape-right .rec-panel,
.rec-screen.landscape-left .rec-panel {
.rec-screen.rot-90 { transform: rotate(90deg); }
.rec-screen.rot-270 { transform: rotate(270deg); }
.rec-screen.rot-90 .rec-map-wrap,
.rec-screen.rot-270 .rec-map-wrap { min-height: 0; }
.rec-screen.rot-90 .rec-panel,
.rec-screen.rot-270 .rec-panel {
width: 200px;
flex-shrink: 0;
display: flex;
@ -1148,21 +1165,17 @@ select {
justify-content: space-evenly;
border-left: 1px solid var(--border);
}
.rec-screen.landscape-right .rec-stats,
.rec-screen.landscape-left .rec-stats {
.rec-screen.rot-90 .rec-stats,
.rec-screen.rot-270 .rec-stats {
flex-direction: column;
border-top: none;
padding: 8px;
gap: 2px;
}
.rec-screen.landscape-right .rec-bottom .rec-status,
.rec-screen.landscape-left .rec-bottom .rec-status {
display: none;
}
.rec-screen.landscape-right .rec-hr-row,
.rec-screen.landscape-left .rec-hr-row {
display: none;
}
.rec-screen.rot-90 .rec-bottom .rec-status,
.rec-screen.rot-270 .rec-bottom .rec-status { display: none; }
.rec-screen.rot-90 .rec-hr-row,
.rec-screen.rot-270 .rec-hr-row { display: none; }
.rec-stats {
display: flex;

View file

@ -702,6 +702,7 @@ async function addGpsPoint(sessionId, ts, lat, lon, altGps, speedMs, seq, bleDat
["bp_sys", bleData?.bp_sys], ["bp_dia", bleData?.bp_dia],
["accel_rms", sensorData?.accel_rms], ["accel_max", sensorData?.accel_max],
["gyro_pitch", sensorData?.gyro_pitch], ["gyro_yaw", sensorData?.gyro_yaw],
["bearing_deg", sensorData?.bearing],
];
for (const [col, val] of extra) {
if (_gpsPointCols.has(col)) { cols.push(col); vals.push(val ?? null); }
@ -849,6 +850,19 @@ async function updateSessionBike(sessionId, bikeId) {
await execute("UPDATE gps_sessions SET bike_id = ?1 WHERE id = ?2", [bikeId, sessionId]);
}
// Bulk-set terrain altitude (altitude_srtm_m) for points by id. rows: [{id, srtm}].
// Batched CASE update — values are numbers (ids small, no i64 corruption), safe to inline.
async function updatePointsSrtm(rows) {
const BATCH = 200;
for (let i = 0; i < rows.length; i += BATCH) {
const batch = rows.slice(i, i + BATCH).filter(r => r.id != null && r.srtm != null && isFinite(r.srtm));
if (!batch.length) continue;
const cases = batch.map(r => `WHEN ${parseInt(r.id, 10)} THEN ${Number(r.srtm)}`).join(" ");
const ids = batch.map(r => parseInt(r.id, 10)).join(",");
await execute(`UPDATE gps_points SET altitude_srtm_m = CASE id ${cases} END WHERE id IN (${ids})`, []);
}
}
// ---------------------------------------------------------------------------
// Bike maintenance log
// ---------------------------------------------------------------------------
@ -1046,6 +1060,7 @@ window.DB.saveBike = saveBike;
window.DB.updateBike = updateBike;
window.DB.deleteBike = deleteBike;
window.DB.updateSessionBike = updateSessionBike;
window.DB.updatePointsSrtm = updatePointsSrtm;
window.DB.getBikeMaintenanceLog = getBikeMaintenanceLog;
window.DB.addBikeMaintenance = addBikeMaintenance;
window.DB.deleteBikeMaintenance = deleteBikeMaintenance;
@ -1232,6 +1247,7 @@ async function runMigrations() {
if (!gpsCols.includes("accel_max")) await execute("ALTER TABLE gps_points ADD COLUMN accel_max REAL");
if (!gpsCols.includes("gyro_pitch")) await execute("ALTER TABLE gps_points ADD COLUMN gyro_pitch REAL");
if (!gpsCols.includes("gyro_yaw")) await execute("ALTER TABLE gps_points ADD COLUMN gyro_yaw REAL");
if (!gpsCols.includes("bearing_deg")) await execute("ALTER TABLE gps_points ADD COLUMN bearing_deg REAL");
// --- Cooking methods table ---
await execute(`CREATE TABLE IF NOT EXISTS cooking_methods (

View file

@ -7,7 +7,7 @@ const GPS = {};
// Configurable thresholds (can be changed in settings)
GPS.config = {
minSpeedMs: 0.42, // 1.5 km/h — below this = stationary
minAccuracyM: 20, // skip fixes less accurate than this
minAccuracyM: 50, // hard gate: reject fixes coarser than this (cell/wifi ~800m). Weak but real GPS (<=50m) kept.
minDistanceM: 1, // skip points closer than this
maxSpeedKmh: 60, // above this = GPS glitch, skip segment
gapThresholdSec: 30, // gap longer than this = new track segment
@ -20,11 +20,14 @@ GPS.config = {
bikeAvgSpeedKmh: 17, // средняя скорость вело (для ручных маршрутов)
walkAvgSpeedKmh: 5, // средняя скорость ходьбы
runAvgSpeedKmh: 10, // средняя скорость бега
useFusedLocation: true, // fused-провайдер (Google Play services) — как Яндекс/Google Maps.
// raw GPS_PROVIDER на устройстве приложению не отдаёт фиксы; fused смешивает GPS+ГЛОНАСС+сеть.
autoFinishIdleMin: 15, // авто-завершить тренировку после N мин без движения (забыл «Конец»)
};
// Load saved config
try {
const saved = localStorage.getItem("baldav_gps_config");
let saved = localStorage.getItem("baldav_gps_config");
if (saved) Object.assign(GPS.config, JSON.parse(saved));
} catch {}
@ -32,8 +35,47 @@ GPS.saveConfig = function() {
localStorage.setItem("baldav_gps_config", JSON.stringify(GPS.config));
};
// One-time migration: raw GPS_PROVIDER doesn't deliver on this device, so default
// tracking to fused (like Yandex/Google Maps). Runs once, then respects the toggle.
try {
if (localStorage.getItem("baldav_fused_migrated") !== "1") {
GPS.config.useFusedLocation = true;
localStorage.setItem("baldav_fused_migrated", "1");
GPS.saveConfig();
}
} catch {}
// One-time migration: accuracy gate is now a HARD reject (coarse cell/wifi fixes
// "sit at the tower"). Bump the old default 20m -> 50m so weak but real GPS survives.
// Only touch the value if it's still the old default; respect a user-set threshold.
try {
if (localStorage.getItem("baldav_minacc50_migrated") !== "1") {
if (GPS.config.minAccuracyM <= 20) GPS.config.minAccuracyM = 50;
localStorage.setItem("baldav_minacc50_migrated", "1");
GPS.saveConfig();
}
} catch {}
// Haversine distance (meters)
// ---------------------------------------------------------------------------
// Compass bearing in degrees (0=N, clockwise) from point 1 to point 2.
GPS.bearing = function(lat1, lon1, lat2, lon2) {
let toRad = d => d * Math.PI / 180;
let f1 = toRad(lat1), f2 = toRad(lat2), dl = toRad(lon2 - lon1);
let y = Math.sin(dl) * Math.cos(f2);
let x = Math.cos(f1) * Math.sin(f2) - Math.sin(f1) * Math.cos(f2) * Math.cos(dl);
return (Math.atan2(y, x) * 180 / Math.PI + 360) % 360;
};
// Point [lat, lon] at `meters` distance along `bearingDeg` (0=N, CW) from a start point.
GPS.destination = function(lat, lon, bearingDeg, meters) {
let R = 6371000, d = meters / R, br = bearingDeg * Math.PI / 180;
let f1 = lat * Math.PI / 180, l1 = lon * Math.PI / 180;
let f2 = Math.asin(Math.sin(f1) * Math.cos(d) + Math.cos(f1) * Math.sin(d) * Math.cos(br));
let l2 = l1 + Math.atan2(Math.sin(br) * Math.sin(d) * Math.cos(f1), Math.cos(d) - Math.sin(f1) * Math.sin(f2));
return [f2 * 180 / Math.PI, ((l2 * 180 / Math.PI) + 540) % 360 - 180];
};
GPS.distance = function(lat1, lon1, lat2, lon2) {
const R = 6371000;
const toRad = d => d * Math.PI / 180;
@ -61,7 +103,9 @@ GPS.computeStats = function(points, weightKg, opts) {
// Step 1: Extract raw altitudes and filter outliers.
// An outlier is a point that jumps far from its neighbors and returns back —
// e.g. [45, 45, 52, 45, 45] — the 52 is a GPS altitude glitch.
const rawAlt = sorted.map(p => p.altitude ?? p.altitude_gps_m ?? null);
// Prefer terrain (SRTM/open-elevation) altitude when available — GPS altitude is noisy
// and the stuck-filter destroys its real signal. Terrain is clean and accurate for slope.
const rawAlt = sorted.map(p => p.altitude_srtm_m ?? p.altitude ?? p.altitude_gps_m ?? null);
const filteredAlt = rawAlt.slice();
for (let i = 1; i < filteredAlt.length - 1; i++) {
if (filteredAlt[i] == null) continue;
@ -142,36 +186,39 @@ GPS.computeStats = function(points, weightKg, opts) {
const gearRatio = hasGearData ? opts.chainringTeeth / opts.cogTeeth : 0;
for (let i = 1; i < sorted.length; i++) {
const p = sorted[i], prev = sorted[i - 1];
const d = GPS.distance(prev.lat, prev.lon, p.lat, p.lon);
let p = sorted[i], prev = sorted[i - 1];
let d = GPS.distance(prev.lat, prev.lon, p.lat, p.lon);
let dt = (new Date(p.ts) - new Date(prev.ts)) / 1000;
let v = dt > 0 ? d / dt : 0; // m/s
const dt = (new Date(p.ts) - new Date(prev.ts)) / 1000;
let isEstimated = (sorted[i].accuracy >= 999) || sorted[i].estimated;
let segMaxMs = isBike ? (GPS.config.maxSpeedKmh / 3.6) : (GPS.config.walkMaxSpeedKmh / 3.6);
// Count a REAL segment: not a dead-reckoning guess, not a teleport glitch. We do NOT
// drop long-gap segments here — if GPS was lost briefly, the straight line is a fair
// lower-bound of real travel (implied speed v<=segMaxMs already rejects teleports).
// DR fakes are excluded via !isEstimated, so no gap-time gate is needed.
let validSeg = !isEstimated && dt > 0 && v <= segMaxMs;
distance += d;
const v = dt > 0 ? d / dt : 0; // m/s
speeds.push(v);
// Elevation: accumulate from smoothed altitude with 1m dead zone.
// Use consecutive smoothed points — the 50m window gradient is only for kcal.
if (smoothedAlt[i] != null && smoothedAlt[i - 1] != null) {
const diff = smoothedAlt[i] - smoothedAlt[i - 1];
if (diff > 1) elevGain += diff;
else if (diff < -1) elevLoss += Math.abs(diff);
if (validSeg) {
distance += d;
speeds.push(v);
// Elevation: accumulate from smoothed altitude with 1m dead zone.
if (smoothedAlt[i] != null && smoothedAlt[i - 1] != null) {
let diff = smoothedAlt[i] - smoothedAlt[i - 1];
if (diff > 1) elevGain += diff;
else if (diff < -1) elevLoss += Math.abs(diff);
}
}
// Kcal calculation per segment
const isEstimated = (sorted[i].accuracy >= 999) || sorted[i].estimated;
if (isEstimated) {
// Estimated point (GPS dead, accel confirmed movement): use walking MET
const walkMet = 3.5; // moderate walking ~4-5 km/h
// Estimated point (GPS dead, accel confirmed movement): use walking MET.
// Time-based only — no distance (dead-reckoning drift is not real travel).
let walkMet = 3.5; // moderate walking ~4-5 km/h
totalKcal += (dt / 60) * (walkMet * 3.5 * wKg) / 200;
continue;
}
if (dt <= 0) continue;
// Skip if speed is unreasonable (teleportation/glitch)
const segMaxMs = isBike ? (GPS.config.maxSpeedKmh / 3.6) : (GPS.config.walkMaxSpeedKmh / 3.6);
if (v > segMaxMs) continue;
if (!validSeg) continue;
if (v < 0.3) {
// Standing still: only basal metabolic rate
@ -397,7 +444,8 @@ GPS._startNativeService = async function() {
const isWalk = type === "walk" || type === "run";
const minSpd = isWalk ? GPS.config.walkMinSpeedMs : GPS.config.minSpeedMs;
const maxSpd = isWalk ? (GPS.config.walkMaxSpeedKmh / 3.6) : (GPS.config.maxSpeedKmh / 3.6);
const cmd = `start:${minSpd}:${GPS.config.minAccuracyM}:${GPS.config.minDistanceM}:${maxSpd}`;
let fused = GPS.config.useFusedLocation ? 1 : 0;
let cmd = `start:${minSpd}:${GPS.config.minAccuracyM}:${GPS.config.minDistanceM}:${maxSpd}:${fused}`;
await window.__TAURI__.core.invoke("gps_service_command", { cmd });
console.log("[GPS] Native service started with config:", cmd);
GPS.recording.nativeService = true;
@ -413,6 +461,14 @@ GPS._stopNativeService = async function() {
} catch {}
};
// Ask Android for background location ("Allow all the time") — required to record
// while the screen is off. On Android 11+ this opens the system location settings.
GPS.requestBackgroundLocation = async function() {
try {
await window.__TAURI__.core.invoke("gps_service_command", { cmd: "reqbgloc" });
} catch (e) { console.log("[GPS] reqbgloc:", e); }
};
// BLE HR monitor — tied to GPS session (start→stop), but NOT paused when GPS pauses.
// HR keeps measuring even when rider is stationary.
GPS._startHrMonitor = async function() {
@ -474,6 +530,7 @@ GPS._readNativePoints = async function() {
points.push({
lat: d.lat, lon: d.lon, altitude: d.alt,
speed_ms: d.speed, accuracy: d.acc,
bearing: (d.bearing != null && d.bearing >= 0) ? d.bearing : null, // GPS course over ground
ts: new Date(d.ts).toISOString(),
accel_rms: d.accel_rms, accel_max: d.accel_max,
gyro_pitch: d.gyro_pitch, gyro_yaw: d.gyro_yaw,
@ -503,9 +560,13 @@ GPS._processIncomingPoint = async function(p) {
return;
}
// Determine if moving
const speed = p.speed_ms != null ? p.speed_ms : 0;
let isMoving = speed > GPS.config.minSpeedMs;
// Determine if moving — same per-activity threshold as the native service
// (walk/run → walkMinSpeedMs, else minSpeedMs). 0 means "always moving":
// no auto-pause, no sticking on "начните движение".
let speed = p.speed_ms != null ? p.speed_ms : 0;
let mvType = GPS.recording.activityType;
let effMinSpeed = (mvType === "walk" || mvType === "run") ? GPS.config.walkMinSpeedMs : GPS.config.minSpeedMs;
let isMoving = effMinSpeed <= 0 ? true : speed > effMinSpeed;
if (!isMoving && GPS.recording.lastMovingPoint) {
const d = GPS.distance(GPS.recording.lastMovingPoint.lat, GPS.recording.lastMovingPoint.lon, p.lat, p.lon);
const dt = (new Date(p.ts) - new Date(GPS.recording.lastMovingPoint.ts)) / 1000;
@ -627,6 +688,17 @@ GPS.resumeRecording = function() {
if (GPS.recording.onStateChange) GPS.recording.onStateChange("manual_resumed");
};
// True when an active session has had no movement for autoFinishIdleMin minutes —
// used to auto-finish a session the user forgot to stop, so the next outing is separate.
GPS.shouldAutoFinish = function() {
if (!GPS.recording.active || GPS.recording.manualPaused) return false;
if (!GPS.recording.sessionId) return false; // no session yet (no movement)
let last = GPS.recording.lastMovingPoint;
if (!last) return false;
let idleMs = Date.now() - new Date(last.ts).getTime();
return idleMs > (GPS.config.autoFinishIdleMin || 15) * 60000;
};
// Recover orphaned recording session on app startup
GPS.recoverSession = async function() {
const saved = localStorage.getItem("baldav_gps_active");
@ -746,6 +818,7 @@ GPS.startRecording = async function(activityType, weightKg) {
altitude: pos.coords.altitude,
speed_ms: pos.coords.speed,
accuracy: pos.coords.accuracy,
bearing: (pos.coords.heading != null && !isNaN(pos.coords.heading)) ? pos.coords.heading : null,
ts: new Date(pos.timestamp).toISOString(),
});
},

View file

@ -2618,25 +2618,34 @@ async function showAddGoalMarkerModal() {
// ---------------------------------------------------------------------------
async function fetchElevations(points) {
try {
// Batch up to 100 points per request
let elevations = new Array(points.length).fill(null);
for (let i = 0; i < points.length; i += 100) {
let batch = points.slice(i, i + 100);
let lats = batch.map(p => p.lat.toFixed(5)).join(",");
let lons = batch.map(p => p.lon.toFixed(5)).join(",");
let resp = await fetch(`https://api.open-meteo.com/v1/elevation?latitude=${lats}&longitude=${lons}`);
if (!resp.ok) continue;
let data = await resp.json();
if (data.elevation) {
for (let j = 0; j < data.elevation.length; j++) elevations[i + j] = data.elevation[j];
// Terrain elevation. Primary: open-elevation.com (reachable in RU). Fallback: open-meteo
// (blocked by RU carriers on this device, kept in case it opens up). Batches of 100.
let elevations = new Array(points.length).fill(null);
for (let i = 0; i < points.length; i += 100) {
let batch = points.slice(i, i + 100);
let got = null;
try {
let resp = await fetch("https://api.open-elevation.com/api/v1/lookup", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ locations: batch.map(p => ({ latitude: p.lat, longitude: p.lon })) }),
});
if (resp.ok) {
let d = await resp.json();
if (d.results) got = d.results.map(r => r.elevation);
}
} catch (e) { console.log("[open-elevation] batch fail:", e.message); }
if (!got) {
try {
let lats = batch.map(p => p.lat.toFixed(5)).join(",");
let lons = batch.map(p => p.lon.toFixed(5)).join(",");
let resp = await fetch(`https://api.open-meteo.com/v1/elevation?latitude=${lats}&longitude=${lons}`);
if (resp.ok) { let d = await resp.json(); if (d.elevation) got = d.elevation; }
} catch (e) { console.log("[open-meteo] batch fail:", e.message); }
}
return elevations;
} catch (e) {
console.log("[Elevation API] failed:", e.message);
return null;
if (got) for (let j = 0; j < got.length; j++) elevations[i + j] = got[j];
}
return elevations.some(x => x != null) ? elevations : null;
}
function showDrawRouteScreen(container) {
@ -2858,33 +2867,154 @@ function renderRecordingInline(container) {
let borderLine = L.polyline([], { color: "#ffffff", weight: 8, opacity: 0.8 }).addTo(map);
let trailLine = L.polyline([], { color: "#bb86fc", weight: 5 }).addTo(map);
// Compass heading + landscape detection from device orientation
let currentLandscape = null; // null | 'right' | 'left'
let orientMode = "off"; // "off" | "auto" | "lock"
let lastBeta = 0;
let lastGamma = 0;
let smoothedHeading = null;
let recScreen = document.querySelector('.rec-screen');
// Map orientation: a mode button picks the source — magnetic compass or GPS course.
// Manual 90° steps (↑) rotate the whole screen on top. Two on-map arrows always show
// 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 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)
let prevCoursePt = null; // for the computed-bearing fallback
let lastPos = null; // last position, for drawing the direction arrows
let orientMode = localStorage.getItem("baldav_orient_mode") || "compass"; // "compass" | "course"
// Compass calibration: magnetometer is offset ~53° here; tap the compass while pointing
// north to subtract the offset (deviceorientationabsolute alpha is CW → raw = alpha).
let northOffset = parseFloat(localStorage.getItem("baldav_north_offset2")) || 0;
let lastRawHeading = 0; // uncalibrated heading, kept for the calibration tap
let recScreen = container.querySelector(".rec-screen");
let btnWrap = mapEl.parentElement;
// Orient button — 3 states: X (off) → A (auto) → L (lock)
let lockBtn = document.createElement("button");
lockBtn.className = "rec-map-btn rec-orient-lock";
lockBtn.textContent = "X";
lockBtn.onclick = () => {
if (orientMode === "off") { orientMode = "auto"; lockBtn.textContent = "A"; lockBtn.classList.remove("active"); }
else if (orientMode === "auto") { orientMode = "lock"; lockBtn.textContent = "L"; lockBtn.classList.add("active"); }
else { orientMode = "off"; lockBtn.textContent = "X"; lockBtn.classList.remove("active");
// Reset landscape and bearing
currentLandscape = null;
if (recScreen) recScreen.classList.remove('landscape-right', 'landscape-left');
let nav = document.querySelector('.bottom-nav');
if (nav) nav.style.display = '';
map.setBearing(0);
setTimeout(() => map.invalidateSize(), 100);
}
// Always-on compass needle showing where magnetic NORTH is. Tap = calibrate north.
let compassEl = document.createElement("div");
compassEl.className = "rec-compass";
compassEl.innerHTML = '<div class="rec-compass-arrow"></div>';
compassEl.title = "Тап = выставить север по текущему направлению";
compassEl.onclick = () => {
northOffset = lastRawHeading;
localStorage.setItem("baldav_north_offset2", String(northOffset));
smoothedHeading = 0; // snap to north-up immediately
applyBearing();
if (typeof showToast === "function") showToast("Север откалиброван");
};
btnWrap.appendChild(lockBtn);
btnWrap.appendChild(compassEl);
let compassArrow = compassEl.firstChild;
function updateNorthArrow(bearing) {
compassArrow.style.transform = `rotate(${-bearing}deg)`;
}
// Mode button: compass ("К") ↔ GPS course ("GPS").
let modeBtn = document.createElement("button");
modeBtn.className = "rec-map-btn rec-mode-btn";
modeBtn.textContent = orientMode === "course" ? "GPS" : "К";
modeBtn.title = "Поворот карты: компас / GPS-курс";
modeBtn.onclick = () => {
orientMode = orientMode === "compass" ? "course" : "compass";
localStorage.setItem("baldav_orient_mode", orientMode);
modeBtn.textContent = orientMode === "course" ? "GPS" : "К";
applyBearing();
};
btnWrap.appendChild(modeBtn);
// Two direction arrows drawn on the map from the current position (always visible):
// orange = compass, green = GPS course. Geo-anchored, so they turn with the map.
let compassDirLine = L.polyline([], { color: "#ff9800", weight: 4, opacity: 0.9, dashArray: "6,6" }).addTo(map);
let compassDirTip = L.circleMarker([0, 0], { radius: 5, color: "#ff9800", fillColor: "#ff9800", fillOpacity: 1, weight: 0 }).addTo(map);
let courseDirLine = L.polyline([], { color: "#4caf50", weight: 5, opacity: 0.95 }).addTo(map);
let courseDirTip = L.circleMarker([0, 0], { radius: 6, color: "#4caf50", fillColor: "#4caf50", fillOpacity: 1, weight: 0 }).addTo(map);
function updateArrows() {
if (!lastPos) return;
// Both arrows point the TRUE geographic direction (CW from north).
if (smoothedHeading != null) {
let end = GPS.destination(lastPos.lat, lastPos.lon, smoothedHeading, 40);
compassDirLine.setLatLngs([[lastPos.lat, lastPos.lon], end]);
compassDirTip.setLatLng(end);
}
if (courseHeading != null) {
let end = GPS.destination(lastPos.lat, lastPos.lon, courseHeading, 70); // travel direction (GPS)
courseDirLine.setLatLngs([[lastPos.lat, lastPos.lon], end]);
courseDirTip.setLatLng(end);
}
}
// Base heading = chosen source. GPS mode rotates by course when we have it, else falls
// back to the compass so the map ALWAYS rotates to a known-good heading (never static).
function baseHeading() {
// Course-up: setBearing puts geo-dir (360-theta) up and net rotation = h, so to put
// courseHeading at the top we need h = 360 - courseHeading (derived from leaflet-rotate).
if (orientMode === "course" && courseHeading != null) return (360 - courseHeading + 360) % 360;
return smoothedHeading != null ? smoothedHeading : 0;
}
function applyBearing() {
let h = compassFrozen ? frozenHeading : baseHeading();
let bearing = h - rotOffset;
map.setBearing(bearing);
updateNorthArrow(bearing);
}
// Update GPS course from a point. Prefer the chip's course-over-ground (p.bearing);
// fall back to bearing between positions ≥15 m apart (long baseline = less jitter).
function updateCourse(p) {
if (!p) return;
lastPos = { lat: p.lat, lon: p.lon };
let b = null;
if (p.bearing != null && (p.speed_ms == null || p.speed_ms > 1.0)) {
b = p.bearing;
} else if (prevCoursePt && GPS.distance(prevCoursePt.lat, prevCoursePt.lon, p.lat, p.lon) >= 15) {
b = GPS.bearing(prevCoursePt.lat, prevCoursePt.lon, p.lat, p.lon);
prevCoursePt = { lat: p.lat, lon: p.lon };
}
if (!prevCoursePt) prevCoursePt = { lat: p.lat, lon: p.lon };
if (b != null) {
if (courseHeading === null) courseHeading = b;
else {
let diff = b - courseHeading;
if (diff > 180) diff -= 360;
if (diff < -180) diff += 360;
courseHeading = ((courseHeading + diff * 0.4) % 360 + 360) % 360;
}
if (orientMode === "course" && !compassFrozen) applyBearing();
}
updateArrows();
}
function applyScreenRotation() {
if (recScreen) {
recScreen.classList.remove("rot-90", "rot-270");
if (rotOffset) recScreen.classList.add("rot-" + rotOffset);
}
let nav = document.querySelector(".bottom-nav");
if (nav) nav.style.display = rotOffset ? "none" : "";
map.invalidateSize();
}
// Rotation button — 4 states, each tap +90°. Arrow shows chosen "up".
let rotBtn = document.createElement("button");
rotBtn.className = "rec-map-btn rec-rotate-btn";
rotBtn.innerHTML = "&#8593;"; // ↑
rotBtn.title = "Повернуть карту на 90°";
rotBtn.onclick = () => {
// 3 readable states: portrait + both landscapes. 180° is skipped —
// it would flip the panel text upside down ("низ" would end up on top).
rotOffset = rotOffset === 0 ? 90 : (rotOffset === 90 ? 270 : 0);
applyScreenRotation();
applyBearing();
};
btnWrap.appendChild(rotBtn);
// Freeze button — X: lock/unlock compass following (does NOT rotate)
let freezeBtn = document.createElement("button");
freezeBtn.className = "rec-map-btn rec-freeze-btn";
freezeBtn.textContent = "X";
freezeBtn.title = "Заморозить поворот карты";
freezeBtn.onclick = () => {
compassFrozen = !compassFrozen;
if (compassFrozen) frozenHeading = baseHeading();
freezeBtn.classList.toggle("active", compassFrozen);
applyBearing();
};
btnWrap.appendChild(freezeBtn);
// Roads toggle button
let roadsBtn = document.createElement("button");
@ -2928,89 +3058,41 @@ function renderRecordingInline(container) {
if (localStorage.getItem("baldav_show_roads") === "1") roadsBtn.click();
function handleOrientation(e) {
if (orientMode === "off") return; // no compass, no landscape
let heading = e.webkitCompassHeading || e.alpha || 0;
let beta = e.beta || 0;
let gamma = e.gamma || 0;
lastBeta = beta;
lastGamma = gamma;
// Gimbal lock compensation: when phone tilts past horizontal,
// browser flips alpha by 180° (Euler angle discontinuity).
// Detect by checking if phone is "face-down" and compensate.
if (currentLandscape !== null && Math.abs(beta) > 90) {
heading = heading + 180;
}
if (currentLandscape === null && Math.abs(gamma) > 90) {
heading = heading + 180;
}
// Landscape detection with hysteresis: enter at 75, exit at 25
if (orientMode === "auto") {
let enterThreshold = 75;
let exitThreshold = 25;
let newLandscape = currentLandscape;
if (currentLandscape === null) {
if (gamma > enterThreshold) newLandscape = 'right';
else if (gamma < -enterThreshold) newLandscape = 'left';
} else if (currentLandscape === 'right') {
if (gamma < exitThreshold) newLandscape = null;
} else if (currentLandscape === 'left') {
if (gamma > -exitThreshold) newLandscape = null;
}
if (newLandscape !== currentLandscape) {
currentLandscape = newLandscape;
if (recScreen) {
recScreen.classList.remove('landscape-right', 'landscape-left');
if (newLandscape) recScreen.classList.add('landscape-' + newLandscape);
}
let nav = document.querySelector('.bottom-nav');
if (nav) nav.style.display = newLandscape ? 'none' : '';
setTimeout(() => map.invalidateSize(), 100);
}
}
// Debug: show all 3 axes on map overlay
let dbgEl = document.querySelector("#rec-orient-debug");
if (!dbgEl) {
dbgEl = document.createElement("div");
dbgEl.id = "rec-orient-debug";
dbgEl.style.cssText = "position:absolute;bottom:4px;left:4px;z-index:1001;background:rgba(0,0,0,0.7);color:#0f0;font:11px monospace;padding:4px 6px;border-radius:4px;pointer-events:none;";
mapEl.parentElement.appendChild(dbgEl);
}
dbgEl.textContent = `a:${Math.round(e.alpha||0)} b:${Math.round(e.beta||0)} g:${Math.round(e.gamma||0)} h:${Math.round(heading)} m:${orientMode}`;
// Smooth heading with EMA (exponential moving average) — reduces jitter from bumps
// Factor 0.3 = responsive but smooth (~3 samples to settle, 300ms at 10Hz)
let finalHeading = heading;
if (smoothedHeading === null) {
smoothedHeading = finalHeading;
} else {
let diff = finalHeading - smoothedHeading;
let raw;
if (e.webkitCompassHeading != null) raw = e.webkitCompassHeading; // iOS: true heading
else if (e.alpha != null) raw = e.alpha % 360; // this device: alpha is CW from N (not CCW) — no inversion
else return;
lastRawHeading = raw; // for the calibration tap
let heading = (raw - northOffset + 360) % 360; // subtract the fixed magnetometer offset
// EMA smoothing to reduce needle jitter (factor 0.3).
if (smoothedHeading === null) smoothedHeading = heading;
else {
let diff = heading - smoothedHeading;
if (diff > 180) diff -= 360;
if (diff < -180) diff += 360;
smoothedHeading = (smoothedHeading + diff * 0.3) % 360;
if (smoothedHeading < 0) smoothedHeading += 360;
finalHeading = smoothedHeading;
smoothedHeading = (smoothedHeading + diff * 0.3 + 360) % 360;
}
// Map bearing always updates in auto and lock modes
let bearingOffset = 0;
if (currentLandscape === 'right') bearingOffset = 90;
else if (currentLandscape === 'left') bearingOffset = -90;
map.setBearing(finalHeading + bearingOffset);
// DEBUG overlay: raw sensor values on screen (to verify calibration).
let dbg = document.getElementById("orient-dbg");
if (!dbg) {
dbg = document.createElement("div");
dbg.id = "orient-dbg";
dbg.style.cssText = "position:absolute;bottom:4px;left:4px;z-index:1002;background:rgba(0,0,0,0.85);color:#0f0;font:11px monospace;padding:3px 5px;border-radius:4px;pointer-events:none;white-space:nowrap;";
mapEl.parentElement.appendChild(dbg);
}
dbg.textContent = "a=" + Math.round(e.alpha || 0) + " raw=" + Math.round(raw) + " off=" + Math.round(northOffset) + " h=" + Math.round(heading) + " sm=" + Math.round(smoothedHeading);
updateArrows(); // keep the orange compass arrow live even in GPS mode
if (!compassFrozen) applyBearing();
}
let throttledOrientation = L.Util.throttle(handleOrientation, 100);
let orientEvent = 'ondeviceorientationabsolute' in window
? 'deviceorientationabsolute' : 'deviceorientation';
let orientEvent = 'ondeviceorientationabsolute' in window ? 'deviceorientationabsolute' : 'deviceorientation';
window.addEventListener(orientEvent, throttledOrientation);
applyBearing(); // initial: north-up until first compass reading
GPS.recording._compassCleanup = () => {
window.removeEventListener(orientEvent, throttledOrientation);
if (recScreen) recScreen.classList.remove('landscape-right', 'landscape-left');
let nav = document.querySelector('.bottom-nav');
if (nav) nav.style.display = '';
if (recScreen) recScreen.classList.remove("rot-90", "rot-270");
let nav = document.querySelector(".bottom-nav");
if (nav) nav.style.display = "";
};
let posMarker = null;
@ -3075,6 +3157,9 @@ function renderRecordingInline(container) {
return;
}
// Orient map by direction of movement (GPS course) + update direction arrows
updateCourse(p);
// Speed
let curSpeed = (p.speed_ms != null && p.speed_ms >= 0) ? p.speed_ms * 3.6 : 0;
speedBuf.push(curSpeed);
@ -3206,6 +3291,7 @@ function renderRecordingInline(container) {
map.panTo(last);
if (posMarker) map.removeLayer(posMarker);
posMarker = L.circleMarker(last, { radius: 6, color: "#4285f4", fillColor: "#4285f4", fillOpacity: 1, weight: 2 }).addTo(map);
updateCourse(sorted[sorted.length - 1]); // course from the last point object (has bearing)
}
let stats = GPS.computeStats(GPS.recording.points, GPS.recording.weightKg, GPS.getRecordingOpts());
if (distEl) distEl.textContent = GPS.formatDistance(stats.distance);
@ -3244,9 +3330,17 @@ function setupLiveUpdates(container) {
if (spdEl) spdEl.textContent = stats.avgSpeed + " км/ч";
};
// Timer for duration display
timer = setInterval(() => {
// Timer for duration display + auto-finish watchdog for a forgotten session
timer = setInterval(async () => {
if (!GPS.recording.active) { clearInterval(timer); return; }
if (GPS.shouldAutoFinish()) {
clearInterval(timer);
if (GPS.recording._compassCleanup) GPS.recording._compassCleanup();
await GPS.stopRecording();
showToast("Тренировка завершена автоматически — нет движения");
renderScreen("gps");
return;
}
let sec = Math.round((Date.now() - GPS.recording.startTime.getTime()) / 1000);
if (timeEl) timeEl.textContent = GPS.formatDuration(sec);
}, 1000);
@ -3524,6 +3618,7 @@ async function showSessionDetail(sessionId) {
${session.activity_type === 'bike' ? `<div class="sd-section"><div class="sd-row"><div class="form-group" style="width:100%;margin:0"><label>Велосипед</label><select id="sd-bike-select" style="width:100%;padding:8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--on-bg)"><option value="">-- Нет --</option></select></div></div></div>` : ''}
<button id="sd-compare" class="btn-secondary full-width" style="margin-top:16px">Сравнить с...</button>
<button id="sd-recalc" class="btn-secondary full-width" style="margin-top:8px">Пересчитать</button>
<button id="sd-refine" class="btn-secondary full-width" style="margin-top:8px">Уточнить высоты (онлайн)</button>
<button id="sd-export" class="btn-secondary full-width" style="margin-top:8px">Экспорт GPX</button>
<button id="sd-delete" class="btn-secondary full-width" style="margin-top:8px;color:#cf6679">Удалить тренировку</button>
`, () => {});
@ -3555,6 +3650,7 @@ async function showSessionDetail(sessionId) {
// Manual routes: no splitting — points are connected as-is (synthetic timestamps have large gaps)
let isManualRoute = session.source === "manual";
let gapSec = GPS.config.gapThresholdSec ?? 30;
let maxGapConnectSec = 120; // beyond this, never draw a connecting line — split instead
let segments = []; // array of arrays of [lat, lon]
let gapLines = []; // dashed lines for accel-confirmed gaps
let curSeg = [[points[0].lat, points[0].lon]];
@ -3564,7 +3660,10 @@ async function showSessionDetail(sessionId) {
if (gap > gapSec) {
let accelBefore = points[i-1].accel_rms;
let accelAfter = points[i].accel_rms;
if (accelBefore != null && accelAfter != null && accelBefore > 2 && accelAfter > 2) {
// Only bridge a SHORT gap (movement confirmed by accel) with a dashed line.
// A long gap is not real travel — split with no connecting line, so we never
// draw a straight line across half the city.
if (gap <= maxGapConnectSec && accelBefore != null && accelAfter != null && accelBefore > 2 && accelAfter > 2) {
curSeg.push([points[i].lat, points[i].lon]);
gapLines.push([[points[i-1].lat, points[i-1].lon], [points[i].lat, points[i].lon]]);
} else {
@ -3929,6 +4028,47 @@ async function showSessionDetail(sessionId) {
}
};
let refineBtn = document.querySelector("#sd-refine");
if (refineBtn) refineBtn.onclick = async () => {
let reset = (t) => { refineBtn.textContent = t; refineBtn.disabled = false; };
refineBtn.disabled = true;
refineBtn.textContent = "Загрузка высот...";
try {
let pts = await DB.getGpsPoints(sessionId);
if (pts.length < 2) return reset("Уточнить высоты (онлайн)");
// Sample to ~300 points to limit API load; terrain is smooth → interpolate the rest.
let step = Math.max(1, Math.ceil(pts.length / 300));
let sampleIdx = [];
for (let i = 0; i < pts.length; i += step) sampleIdx.push(i);
if (sampleIdx[sampleIdx.length - 1] !== pts.length - 1) sampleIdx.push(pts.length - 1);
let terr = await fetchElevations(sampleIdx.map(i => pts[i]));
if (!terr || !terr.some(x => x != null)) {
if (typeof showToast === "function") showToast("Сервис высот недоступен");
return reset("Уточнить высоты (онлайн)");
}
// Interpolate terrain to every point (linear by index between samples).
let alt = new Array(pts.length).fill(null);
for (let s = 0; s < sampleIdx.length - 1; s++) {
let i0 = sampleIdx[s], i1 = sampleIdx[s + 1], a0 = terr[s], a1 = terr[s + 1];
if (a0 == null || a1 == null) continue;
for (let i = i0; i <= i1; i++) alt[i] = a0 + (a1 - a0) * (i1 > i0 ? (i - i0) / (i1 - i0) : 0);
}
let rows = pts.map((p, i) => ({ id: p.id, srtm: alt[i] })).filter(r => r.srtm != null);
refineBtn.textContent = "Сохранение...";
await DB.updatePointsSrtm(rows);
await GPS.recalculateSession(sessionId);
if (typeof showToast === "function") showToast("Высоты уточнены по " + rows.length + " точкам");
if (winStack.length > 0) winStack.pop().close();
_skipPops = 1;
history.back();
showSessionDetail(sessionId);
} catch (e) {
console.log("[refine] fail:", e.message);
if (typeof showToast === "function") showToast("Ошибка высот: " + e.message);
reset("Уточнить высоты (онлайн)");
}
};
let del = document.querySelector("#sd-delete");
if (del) del.onclick = async () => {
await DB.deleteGpsSession(sessionId);
@ -4560,7 +4700,7 @@ async function renderSettings(container) {
<div class="settings-item">
<div class="settings-item-left">
<div class="settings-item-title">Макс. погрешность GPS (м)</div>
<div class="settings-item-desc">Хуже этого = пропуск (авто x2 при плохом сигнале)</div>
<div class="settings-item-desc">Жёсткий порог: хуже этого = пропуск (рубит сотовые ~800м, оставляет слабый GPS). 50 = разумно.</div>
</div>
<input id="s-min-acc" type="number" step="1" value="${GPS.config.minAccuracyM}" style="width:60px;text-align:right;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--on-bg);padding:4px 6px;">
</div>
@ -4631,6 +4771,13 @@ async function renderSettings(container) {
</div>
<input id="s-walk-min-speed" type="number" step="0.1" value="${(GPS.config.walkMinSpeedMs * 3.6).toFixed(1)}" style="width:60px;text-align:right;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--on-bg);padding:4px 6px;">
</div>
<div class="settings-item">
<div class="settings-item-left">
<div class="settings-item-title">Fused-локация (Google Play)</div>
<div class="settings-item-desc">Альтернатива raw GPS. Иногда стабильнее при выкл. экране. Требует сервисы Google.</div>
</div>
<input id="s-use-fused" type="checkbox" ${GPS.config.useFusedLocation ? "checked" : ""} style="width:20px;height:20px;">
</div>
</div>
<div class="settings-section">
@ -4652,6 +4799,13 @@ async function renderSettings(container) {
</div>
<button class="settings-toggle" id="s-gps-toggle"></button>
</div>
<div class="settings-item" id="s-bgloc-item" style="cursor:pointer">
<div class="settings-item-left">
<div class="settings-item-title">Фоновая геолокация</div>
<div class="settings-item-desc">Для записи при выключенном экране. Нужно выбрать «Разрешать всегда».</div>
</div>
<button class="btn-secondary" id="s-bgloc-btn">Разрешить</button>
</div>
</div>
<div class="settings-section">
@ -4877,6 +5031,8 @@ async function renderSettings(container) {
GPS.config.altSmoothHalf = parseInt(container.querySelector("#s-alt-smooth").value) || 5;
GPS.config.walkMaxSpeedKmh = parseFloat(container.querySelector("#s-walk-max-speed").value) || 12;
GPS.config.walkMinSpeedMs = parseFloat(container.querySelector("#s-walk-min-speed").value) / 3.6;
let fusedEl = container.querySelector("#s-use-fused");
if (fusedEl) GPS.config.useFusedLocation = fusedEl.checked;
GPS.saveConfig();
};
}
@ -4943,6 +5099,12 @@ async function renderSettings(container) {
);
};
let bgLocBtn = container.querySelector("#s-bgloc-btn");
if (bgLocBtn) bgLocBtn.onclick = async () => {
await GPS.requestBackgroundLocation();
showToast("Настройки → Разрешения → Геоданные → «Разрешать всегда»");
};
// BLE HR device scan and bind
let bleUnbindBtn = container.querySelector("#s-ble-unbind");
if (bleUnbindBtn) {

2
src-tauri/Cargo.lock generated
View file

@ -139,7 +139,7 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "baldav"
version = "0.1.321"
version = "0.1.361"
dependencies = [
"base64 0.22.1",
"btleplug",

View file

@ -1,6 +1,6 @@
[package]
name = "baldav"
version = "0.1.321"
version = "0.1.361"
edition = "2021"
[lib]

View file

@ -3,6 +3,7 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />

View file

@ -13,6 +13,12 @@ import android.os.Build
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import com.google.android.gms.location.FusedLocationProviderClient
import com.google.android.gms.location.LocationCallback
import com.google.android.gms.location.LocationRequest
import com.google.android.gms.location.LocationResult
import com.google.android.gms.location.LocationServices
import com.google.android.gms.location.Priority
import android.os.PowerManager
import android.hardware.Sensor
import android.hardware.SensorEvent
@ -34,6 +40,7 @@ class GpsRecorderService : Service(), LocationListener, SensorEventListener {
const val EXTRA_MAX_ACC = "max_acc"
const val EXTRA_MIN_DIST = "min_dist"
const val EXTRA_MAX_SPEED = "max_speed"
const val EXTRA_USE_FUSED = "use_fused"
var isRunning = false
private set
@ -59,8 +66,16 @@ class GpsRecorderService : Service(), LocationListener, SensorEventListener {
private val pointBuffer = mutableListOf<String>()
private val bufferLock = Object()
// GPS/sensor callbacks and flushing run on the MAIN looper. Delivering location to a
// background HandlerThread caused the OS to throttle updates to zero when the app was
// not visible (screen off) — main-thread delivery is what worked historically.
private val flushHandler = Handler(Looper.getMainLooper())
// Optional fused location provider (Google Play services) — opt-in via settings.
private var useFused = false
private var fusedClient: FusedLocationProviderClient? = null
private var fusedCallback: LocationCallback? = null
// Raw sensor file (50Hz for cadence analysis)
private var rawSensorFile: File? = null
private val rawSensorBuffer = mutableListOf<String>()
@ -90,7 +105,9 @@ class GpsRecorderService : Service(), LocationListener, SensorEventListener {
maxAccuracyM = intent?.getFloatExtra(EXTRA_MAX_ACC, 20f) ?: 20f
minDistanceM = intent?.getFloatExtra(EXTRA_MIN_DIST, 1f) ?: 1f
maxSpeedMs = intent?.getFloatExtra(EXTRA_MAX_SPEED, 16.67f) ?: 16.67f
Log.d(TAG, "Starting GPS recording: minSpeed=${minSpeedMs}m/s maxAcc=${maxAccuracyM}m minDist=${minDistanceM}m maxSpeed=${maxSpeedMs}m/s")
// Preserve the fused choice across OS (START_STICKY, null intent) restarts.
if (intent != null) useFused = intent.getBooleanExtra(EXTRA_USE_FUSED, false)
Log.d(TAG, "Starting GPS recording: minSpeed=${minSpeedMs}m/s maxAcc=${maxAccuracyM}m minDist=${minDistanceM}m maxSpeed=${maxSpeedMs}m/s useFused=$useFused")
isRunning = true
// Points file in app root data dir (same as Rust app_data_dir)
@ -143,23 +160,32 @@ class GpsRecorderService : Service(), LocationListener, SensorEventListener {
startForeground(NOTIFICATION_ID, notification)
// WakeLock
// WakeLock — force-held for the whole session so the CPU keeps delivering
// GPS while the screen is off. Non-ref-counted + re-acquired if released.
val pm = getSystemService(POWER_SERVICE) as PowerManager
wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "baldav:gps")
wakeLock?.acquire(4 * 60 * 60 * 1000L) // 4 hours max
if (wakeLock == null) {
wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "baldav:gps").apply {
setReferenceCounted(false)
}
}
if (wakeLock?.isHeld != true) {
wakeLock?.acquire(6 * 60 * 60 * 1000L) // 6 hours max
Log.d(TAG, "WakeLock acquired (held=${wakeLock?.isHeld})")
}
// Start GPS
// Start GPS — callbacks delivered on the MAIN looper (this is what historically
// kept working in the background; a background thread got throttled to zero).
locationManager = getSystemService(LOCATION_SERVICE) as LocationManager
try {
val gpsEnabled = locationManager?.isProviderEnabled(LocationManager.GPS_PROVIDER) ?: false
Log.d(TAG, "GPS provider enabled: $gpsEnabled")
locationManager?.requestLocationUpdates(
LocationManager.GPS_PROVIDER,
1000L, // min time 1s
0f, // min distance 0m
this
)
Log.d(TAG, "Location updates registered successfully")
if (useFused) {
// Fused provider (Google Play services) — opt-in. Falls back to raw GPS.
if (!startFusedUpdates(Looper.getMainLooper())) {
Log.w(TAG, "Fused location unavailable — falling back to raw GPS_PROVIDER")
startRawGpsUpdates()
}
} else {
startRawGpsUpdates()
}
// Register accelerometer + gyroscope (non-critical — if missing, GPS still works)
try {
@ -183,6 +209,15 @@ class GpsRecorderService : Service(), LocationListener, SensorEventListener {
flushHandler.postDelayed({
flushBuffer()
flushRawSensors()
// Heartbeat: proves the loop is alive and shows GPS freshness.
val ageMs = if (lastTime > 0) System.currentTimeMillis() - lastTime else -1
val bufSize = synchronized(bufferLock) { pointBuffer.size }
Log.d(TAG, "Heartbeat: held=${wakeLock?.isHeld} buf=$bufSize lastFixAgeMs=$ageMs pts=$pointCount")
// Re-acquire the wakelock if the system released it under us.
if (isRunning && wakeLock?.isHeld != true) {
wakeLock?.acquire(6 * 60 * 60 * 1000L)
Log.w(TAG, "WakeLock was released — re-acquired")
}
if (isRunning) scheduleFlush()
}, 10000)
}
@ -240,6 +275,7 @@ class GpsRecorderService : Service(), LocationListener, SensorEventListener {
private var lastLat = 0.0
private var lastLon = 0.0
private var lastTime = 0L
private var lastGpsFixTime = 0L // wall-clock of last real GPS fix — to prefer GPS over network
private var pointCount = 0
@ -312,76 +348,115 @@ class GpsRecorderService : Service(), LocationListener, SensorEventListener {
}
private var lastGoodPointTime = 0L
private var lastLowAccTime = 0L
private val recentAccuracies = mutableListOf<Float>() // last 10 accuracy values
private var adaptiveMaxAccuracy = 0f // 0 = use configured value
private fun startRawGpsUpdates() {
val gpsEnabled = locationManager?.isProviderEnabled(LocationManager.GPS_PROVIDER) ?: false
Log.d(TAG, "GPS provider enabled: $gpsEnabled")
// 4-arg overload → callbacks delivered on the calling (main) thread's looper.
locationManager?.requestLocationUpdates(
LocationManager.GPS_PROVIDER,
1000L, // min time 1s
0f, // min distance 0m
this
)
// Also NETWORK (wifi/cell): keeps tracking alive when raw GPS is silent
// (indoors/weak sky). Coarse, so processLocation prefers GPS when it's fresh.
if (locationManager?.isProviderEnabled(LocationManager.NETWORK_PROVIDER) == true) {
locationManager?.requestLocationUpdates(
LocationManager.NETWORK_PROVIDER, 1000L, 0f, this
)
Log.d(TAG, "Network provider updates registered")
}
Log.d(TAG, "Location updates registered")
}
// Returns false if the fused provider can't be started (e.g. no Play services),
// so the caller can fall back to raw GPS. SecurityException (missing permission)
// is rethrown so the outer handler stops the service, matching the raw path.
private fun startFusedUpdates(looper: Looper): Boolean {
return try {
val client = LocationServices.getFusedLocationProviderClient(this)
val req = LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, 1000L)
.setMinUpdateIntervalMillis(1000L)
.setMinUpdateDistanceMeters(0f)
.build()
val cb = object : LocationCallback() {
override fun onLocationResult(result: LocationResult) {
for (loc in result.locations) processLocation(loc)
}
}
client.requestLocationUpdates(req, cb, looper)
fusedClient = client
fusedCallback = cb
Log.d(TAG, "Fused location updates registered")
true
} catch (e: SecurityException) {
throw e
} catch (e: Throwable) {
Log.w(TAG, "Fused start failed: ${e.message}")
false
}
}
override fun onLocationChanged(location: Location) {
Log.v(TAG, "Location: acc=${location.accuracy} spd=${location.speed} lat=${location.latitude}")
processLocation(location)
}
private fun processLocation(location: Location) {
Log.v(TAG, "Location: prov=${location.provider} acc=${location.accuracy} spd=${location.speed} lat=${location.latitude}")
// Hard accuracy gate: choose the MORE ACCURATE source. Reject coarse fixes
// (cell/wifi ~500-800m that "sit at the tower" and jump). Keep only fixes at
// least as accurate as the "Min. accuracy" setting — weak but real GPS (up to
// the threshold) is kept, coarse network fixes are dropped as honest gaps.
if (location.accuracy > maxAccuracyM) {
Log.d(TAG, "Rejected coarse fix: prov=${location.provider} acc=${location.accuracy} > ${maxAccuracyM}")
return
}
// Prefer GPS: while real GPS is delivering, drop coarse NETWORK fixes so they
// don't jitter the track. When GPS goes silent, network fills in.
val nowRt = System.currentTimeMillis()
if (location.provider == LocationManager.NETWORK_PROVIDER) {
if (nowRt - lastGpsFixTime < 20000) return
} else if (location.provider == LocationManager.GPS_PROVIDER) {
lastGpsFixTime = nowRt
}
val now = location.time
val sensorData = consumeSensorData()
// Adaptive accuracy threshold: if signal is consistently bad, relax the limit
recentAccuracies.add(location.accuracy)
if (recentAccuracies.size > 10) recentAccuracies.removeAt(0)
val effectiveMaxAcc = if (recentAccuracies.size >= 5) {
val median = recentAccuracies.sorted()[recentAccuracies.size / 2]
if (median > maxAccuracyM) {
// Bad signal: use 2x configured threshold
val adapted = maxAccuracyM * 2f
if (adaptiveMaxAccuracy == 0f) Log.d(TAG, "Bad GPS signal, relaxing accuracy to ${adapted}m")
adaptiveMaxAccuracy = adapted
adapted
} else {
if (adaptiveMaxAccuracy > 0f) Log.d(TAG, "GPS signal OK, restoring accuracy to ${maxAccuracyM}m")
adaptiveMaxAccuracy = 0f
maxAccuracyM
}
} else maxAccuracyM
// Speed sanity + min-distance filtering (all fixes here already pass the gate)
if (location.speed > maxSpeedMs) return
if (location.speed < minSpeedMs) {
// Speed too low but check if accel shows movement — keep the point anyway
// (GPS speed unreliable in bad weather, accel is truth)
val rmsMatch = Regex(""""accel_rms":(\d+\.?\d*)""").find(sensorData)
val rms = rmsMatch?.groupValues?.get(1)?.toFloatOrNull() ?: 0f
if (rms < 2f) return // truly stationary
}
if (lastTime > 0) {
val results = FloatArray(1)
Location.distanceBetween(lastLat, lastLon, location.latitude, location.longitude, results)
if (results[0] < minDistanceM) return
}
// Good accuracy: normal filtering
if (location.accuracy <= effectiveMaxAcc) {
if (location.speed > maxSpeedMs) return
if (location.speed < minSpeedMs) {
// Speed too low but check if accel shows movement — keep the point anyway
// (GPS speed unreliable in bad weather, accel is truth)
val hasAccel = sensorData.contains("accel_rms")
val rmsMatch = Regex(""""accel_rms":(\d+\.?\d*)""").find(sensorData)
val rms = rmsMatch?.groupValues?.get(1)?.toFloatOrNull() ?: 0f
if (rms < 2f) return // truly stationary
}
if (lastTime > 0) {
val results = FloatArray(1)
Location.distanceBetween(lastLat, lastLon, location.latitude, location.longitude, results)
if (results[0] < minDistanceM) return
}
lastLat = location.latitude
lastLon = location.longitude
lastTime = now
lastGoodPointTime = now
// Calibrate DR speed from GPS
if (location.speed > 0.5f && location.speed < 10f) {
avgWalkSpeedMs = avgWalkSpeedMs * 0.8f + location.speed * 0.2f // EMA
}
// Reset DR heading from GPS bearing if available
if (location.hasBearing()) {
drHeading = Math.toRadians(location.bearing.toDouble())
}
} else {
// Low accuracy: accept at most 1 point per 30 seconds (keeps track alive)
if (now - lastLowAccTime < 30000) return
lastLowAccTime = now
// Use coordinates but mark as low accuracy
lastLat = location.latitude
lastLon = location.longitude
lastTime = now
Log.d(TAG, "Low accuracy point: acc=${location.accuracy}")
lastLat = location.latitude
lastLon = location.longitude
lastTime = now
lastGoodPointTime = now
// Calibrate DR speed from GPS
if (location.speed > 0.5f && location.speed < 10f) {
avgWalkSpeedMs = avgWalkSpeedMs * 0.8f + location.speed * 0.2f // EMA
}
// Reset DR heading from GPS bearing if available
if (location.hasBearing()) {
drHeading = Math.toRadians(location.bearing.toDouble())
}
pointCount++
val line = """{"lat":${location.latitude},"lon":${location.longitude},"alt":${location.altitude},"speed":${location.speed},"acc":${location.accuracy},"ts":${now}${sensorData}}"""
val bearing = if (location.hasBearing()) location.bearing else -1f
val line = """{"lat":${location.latitude},"lon":${location.longitude},"alt":${location.altitude},"speed":${location.speed},"acc":${location.accuracy},"bearing":${bearing},"ts":${now}${sensorData}}"""
synchronized(bufferLock) {
pointBuffer.add(line)
}
@ -433,6 +508,9 @@ class GpsRecorderService : Service(), LocationListener, SensorEventListener {
flushBuffer() // flush remaining points
flushRawSensors() // flush remaining raw sensor data
locationManager?.removeUpdates(this)
fusedCallback?.let { cb -> try { fusedClient?.removeLocationUpdates(cb) } catch (_: Throwable) {} }
fusedClient = null
fusedCallback = null
sensorManager?.unregisterListener(this)
wakeLock?.let { if (it.isHeld) it.release() }
stopForeground(true)

View file

@ -2,8 +2,12 @@ package dev.baldav.app
import android.content.Intent
import android.content.pm.PackageManager
import android.location.Location
import android.location.LocationListener
import android.location.LocationManager
import android.net.Uri
import android.os.Build
import android.provider.Settings
import android.os.Bundle
import android.os.FileObserver
import android.util.Log
@ -52,6 +56,10 @@ class MainActivity : TauriActivity() {
processBleCommand(appDataDir)
Log.d("BalDav", "Command watcher started on ${appDataDir.absolutePath}")
// Diagnostic HTTP endpoint on localhost — GET /gps makes the app request a live
// GPS fix and returns it. Reachable from a host via `adb forward tcp:8787 tcp:8787`.
startProbeServer()
// Always copy seed DB from assets — ensures APK updates deliver new products.
try {
val seedDest = File(filesDir, "products_seed.db")
@ -122,12 +130,14 @@ class MainActivity : TauriActivity() {
val maxAcc = parts.getOrNull(2)?.toFloatOrNull() ?: 20f
val minDist = parts.getOrNull(3)?.toFloatOrNull() ?: 1f
val maxSpeed = parts.getOrNull(4)?.toFloatOrNull() ?: 16.67f
Log.d("BalDav", "Starting GPS service: speed=$minSpeed acc=$maxAcc dist=$minDist maxSpeed=$maxSpeed")
val useFused = parts.getOrNull(5) == "1"
Log.d("BalDav", "Starting GPS service: speed=$minSpeed acc=$maxAcc dist=$minDist maxSpeed=$maxSpeed useFused=$useFused")
val intent = Intent(this, GpsRecorderService::class.java).apply {
putExtra(GpsRecorderService.EXTRA_MIN_SPEED, minSpeed)
putExtra(GpsRecorderService.EXTRA_MAX_ACC, maxAcc)
putExtra(GpsRecorderService.EXTRA_MIN_DIST, minDist)
putExtra(GpsRecorderService.EXTRA_MAX_SPEED, maxSpeed)
putExtra(GpsRecorderService.EXTRA_USE_FUSED, useFused)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
startForegroundService(intent)
@ -153,6 +163,9 @@ class MainActivity : TauriActivity() {
// Allow screen to turn off
window.clearFlags(android.view.WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
}
cmd == "reqbgloc" -> {
requestBackgroundLocation()
}
cmd == "ble_scan" -> {
Log.d("BalDav", "Starting BLE scan")
writeBtState(appDataDir)
@ -297,6 +310,88 @@ class MainActivity : TauriActivity() {
}
}
// Localhost HTTP server for on-demand GPS probing. GET /gps → request a live fix
// from GPS + NETWORK, wait up to 20s, return JSON. Reach it via adb forward + curl.
private var probeServer: Thread? = null
private fun startProbeServer() {
if (probeServer != null) return
probeServer = Thread {
try {
val server = java.net.ServerSocket()
server.reuseAddress = true
server.bind(java.net.InetSocketAddress("0.0.0.0", 8787))
Log.d("BalDav", "Probe HTTP server on 0.0.0.0:8787")
while (true) {
val sock = server.accept()
try {
val reqLine = sock.getInputStream().bufferedReader().readLine() ?: ""
val json = if (reqLine.contains("/gps")) probeGpsBlocking() else """{"error":"use GET /gps"}"""
val out = sock.getOutputStream().bufferedWriter()
out.write("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nConnection: close\r\nContent-Length: ${json.toByteArray().size}\r\n\r\n")
out.write(json); out.flush()
} catch (e: Exception) {
Log.w("BalDav", "probe req: ${e.message}")
} finally { try { sock.close() } catch (_: Exception) {} }
}
} catch (e: Exception) { Log.e("BalDav", "Probe server failed: ${e.message}") }
}.apply { isDaemon = true; start() }
}
// Requests a fresh fix from GPS and NETWORK, blocks up to 20s for the GPS one,
// then returns fresh + last-known for both providers.
private fun probeGpsBlocking(): String {
val lm = getSystemService(LOCATION_SERVICE) as LocationManager
val names = listOf(LocationManager.GPS_PROVIDER, LocationManager.NETWORK_PROVIDER)
val fresh = java.util.concurrent.ConcurrentHashMap<String, Location>()
val gotGps = java.util.concurrent.CountDownLatch(1)
val listeners = HashMap<String, LocationListener>()
val handler = android.os.Handler(mainLooper)
for (n in names) {
if (!(try { lm.isProviderEnabled(n) } catch (e: Exception) { false })) continue
val l = object : LocationListener {
override fun onLocationChanged(loc: Location) {
fresh[n] = loc
if (n == LocationManager.GPS_PROVIDER) gotGps.countDown()
}
override fun onProviderDisabled(p: String) {}
override fun onProviderEnabled(p: String) {}
override fun onStatusChanged(p: String?, s: Int, e: android.os.Bundle?) {}
}
listeners[n] = l
handler.post { try { lm.requestLocationUpdates(n, 0L, 0f, l, mainLooper) } catch (e: SecurityException) {} }
}
try { gotGps.await(20, java.util.concurrent.TimeUnit.SECONDS) } catch (_: Exception) {}
handler.post { for (l in listeners.values) try { lm.removeUpdates(l) } catch (_: Exception) {} }
fun j(l: Location?): String =
if (l == null) "null"
else """{"lat":${l.latitude},"lon":${l.longitude},"acc":${l.accuracy},"ageMs":${System.currentTimeMillis() - l.time}}"""
val gpsLast = try { lm.getLastKnownLocation(LocationManager.GPS_PROVIDER) } catch (e: SecurityException) { null }
val netLast = try { lm.getLastKnownLocation(LocationManager.NETWORK_PROVIDER) } catch (e: SecurityException) { null }
return """{"gpsFresh":${j(fresh[LocationManager.GPS_PROVIDER])},"networkFresh":${j(fresh[LocationManager.NETWORK_PROVIDER])},"gpsLast":${j(gpsLast)},"networkLast":${j(netLast)},"ts":${System.currentTimeMillis()}}"""
}
// Open this app's system settings page so the user can set location to
// "Allow all the time" (background location can only be granted there on Android 11+).
// Always opens — predictable regardless of the current grant state.
private fun requestBackgroundLocation() {
// Android 10: a runtime dialog for background location still works.
if (Build.VERSION.SDK_INT == Build.VERSION_CODES.Q &&
checkSelfPermission(android.Manifest.permission.ACCESS_BACKGROUND_LOCATION) != PackageManager.PERMISSION_GRANTED &&
checkSelfPermission(android.Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED) {
requestPermissions(arrayOf(android.Manifest.permission.ACCESS_BACKGROUND_LOCATION), 4242)
return
}
try {
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
Uri.fromParts("package", packageName, null))
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
startActivity(intent)
Log.d("BalDav", "Opened app settings for background location")
} catch (e: Exception) {
Log.e("BalDav", "Failed to open app settings: ${e.message}")
}
}
private fun ensureBlePermissions(): Boolean {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val needed = mutableListOf<String>()

View file

@ -7,20 +7,32 @@ use serde::Serialize;
#[allow(unused, dead_code)]
mod ble;
// Simple HTTP proxy for sync requests (WebView blocks mixed http/https content)
// Simple HTTP proxy for sync requests (WebView blocks mixed http/https content).
// Runs the blocking ureq call on a worker thread (spawn_blocking) with short
// timeouts, so an unreachable host (e.g. router 192.168.1.1 when off home wifi)
// never freezes the UI — it just fails after a few seconds in the background.
#[tauri::command]
fn http_request(url: String, method: String, body: Option<String>) -> Result<String, String> {
let mut resp = match method.to_uppercase().as_str() {
"POST" => {
let b = body.unwrap_or_default();
ureq::post(&url)
.header("Content-Type", "application/json")
.send(b.as_bytes())
.map_err(|e| e.to_string())?
}
_ => ureq::get(&url).call().map_err(|e| e.to_string())?,
};
resp.body_mut().read_to_string().map_err(|e| e.to_string())
async fn http_request(url: String, method: String, body: Option<String>) -> Result<String, String> {
tauri::async_runtime::spawn_blocking(move || -> Result<String, String> {
let agent: ureq::Agent = ureq::Agent::config_builder()
.timeout_connect(Some(std::time::Duration::from_secs(4)))
.timeout_global(Some(std::time::Duration::from_secs(12)))
.build()
.into();
let mut resp = match method.to_uppercase().as_str() {
"POST" => {
let b = body.unwrap_or_default();
agent.post(&url)
.header("Content-Type", "application/json")
.send(b.as_bytes())
.map_err(|e| e.to_string())?
}
_ => agent.get(&url).call().map_err(|e| e.to_string())?,
};
resp.body_mut().read_to_string().map_err(|e| e.to_string())
})
.await
.map_err(|e| e.to_string())?
}
// Get the active points file name from gps_live_points.jsonl pointer

View file

@ -1,7 +1,7 @@
{
"$schema": "https://raw.githubusercontent.com/nicegui/tauri-v2/refs/heads/main/tooling/cli/schema.json",
"productName": "BalDav",
"version": "0.1.321",
"version": "0.1.361",
"identifier": "dev.baldav.app",
"build": {
"frontendDist": "../frontend"