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:
parent
5d6025ea9c
commit
2051a604e1
12 changed files with 717 additions and 267 deletions
|
|
@ -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 и весу.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
});
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 = "↑"; // ↑
|
||||
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
2
src-tauri/Cargo.lock
generated
|
|
@ -139,7 +139,7 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
|||
|
||||
[[package]]
|
||||
name = "baldav"
|
||||
version = "0.1.321"
|
||||
version = "0.1.361"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"btleplug",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "baldav"
|
||||
version = "0.1.321"
|
||||
version = "0.1.361"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue