Sensors, walk limits, GPS fixes, water UI, manual routes, Gridnev products, OTA router
GPS altitude: accel-based glitch filter (accel=0 + speed = GPS drift), altitude jump >100m filter, timestamp sanity (>24h = remove). Per-point Crr from accel vibration (smooth/mixed/gravel). Gap interpolation on map with dashed lines when accel confirms movement. Walk/run separate speed limits (configurable). Adaptive GPS accuracy (auto x2 when signal bad). Dead reckoning points when GPS silent but accel shows movement. Locale.US fix for sensor JSON. Sensors: accelerometer + gyroscope in GpsRecorderService (RMS, max, pitch, yaw). Columns in gps_points. Dynamic addGpsPoint INSERT (safe if columns missing). Manual routes: draw on map by tapping points, elevation from past tracks + open-meteo API, realistic timestamps by activity speed. Manual sessions skip glitch filtering on recalculate. Speed from GPS.config not hardcoded. Water tracking: water_pct field in product add/edit forms, searchProducts returns water_pct, "ml" label for liquids, recipe water from ingredients. BMR: moving average weight (7 days), fixed charts using dailyBase not raw bmr. BLE: ble_clear_hr on stop, recovery toast, serde alias for ts/timestamp. Products: 217 Gridnev products (bakery, desserts, bread, ready meals). Magnit scraper fix (NUXT_DATA name/value pairs, not indices). Supplements addSupplement with all 41 columns. Nutrient fallback RDI includes omega/EPA/DHA. Export/import: custom products + recipes + supplements in single JSON bundle. Export all or single product. Dedup on import by name_lower + source. OTA: router upload (LAN, always), VPS upload (-u flag). Kotlin checks router first (2s timeout), then VPS. Update URL from update_info.json. Session detail: renderToken prevents stale setTimeout callbacks after recalculate. WAL disabled (PRAGMA journal_mode=DELETE).
This commit is contained in:
parent
926fd70c88
commit
19282ded78
12 changed files with 819 additions and 93 deletions
Binary file not shown.
|
|
@ -21,6 +21,8 @@ let RDI = {
|
|||
zinc_mg: 15, iron_mg: 14, iodine_mg: 0.15, selenium_mcg: 70, molybdenum_mcg: 70,
|
||||
manganese_mg: 2, copper_mg: 1, chromium_mg: 0.05, phosphorus_mg: 800,
|
||||
potassium_mg: 3500, sodium_mg: 2400,
|
||||
omega3_mg: 1000, epa_mg: 250, dha_mg: 250,
|
||||
lutein_mg: 5, lycopene_mg: 5, silicon_mg: 30, vanadium_mg: 0.04,
|
||||
};
|
||||
|
||||
// Load RDI from DB for selected standard. Called after DB ready.
|
||||
|
|
|
|||
|
|
@ -110,7 +110,7 @@ async function searchProducts(query, limit) {
|
|||
return select(
|
||||
`SELECT id, name, brand, category, weight_g, kcal_per_100g,
|
||||
protein_per_100g, fat_per_100g, carbs_per_100g, fiber_per_100g,
|
||||
composition, nutrients_guessed, source,
|
||||
composition, nutrients_guessed, source, water_pct,
|
||||
vit_a_mcg, vit_c_mg, vit_b1_mg, vit_b2_mg, vit_b3_mg, vit_e_mg,
|
||||
calcium_mg, iron_mg, magnesium_mg, potassium_mg, sodium_mg, phosphorus_mg,
|
||||
carotene_mcg, selenium_mcg, zinc_mg, copper_mg, folate_mcg,
|
||||
|
|
@ -140,8 +140,8 @@ async function addProduct(p) {
|
|||
vit_a_mcg, vit_c_mg, vit_d_mcg, vit_e_mg, vit_k_mcg,
|
||||
vit_b1_mg, vit_b2_mg, vit_b3_mg, vit_b6_mg, vit_b12_mcg, folate_mcg,
|
||||
calcium_mg, iron_mg, magnesium_mg, zinc_mg, copper_mg,
|
||||
selenium_mcg, potassium_mg, sodium_mg, phosphorus_mg, carotene_mcg, source)
|
||||
VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16,?17,?18,?19,?20,?21,?22,?23,?24,?25,?26,?27,?28,?29,?30,?31,?32)`,
|
||||
selenium_mcg, potassium_mg, sodium_mg, phosphorus_mg, carotene_mcg, source, water_pct)
|
||||
VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16,?17,?18,?19,?20,?21,?22,?23,?24,?25,?26,?27,?28,?29,?30,?31,?32,?33)`,
|
||||
[
|
||||
p.name, p.name?.toLowerCase() ?? null,
|
||||
p.brand ?? null, p.category ?? null, p.weight_g ?? null,
|
||||
|
|
@ -155,7 +155,7 @@ async function addProduct(p) {
|
|||
p.zinc_mg ?? null, p.copper_mg ?? null,
|
||||
p.selenium_mcg ?? null, p.potassium_mg ?? null, p.sodium_mg ?? null,
|
||||
p.phosphorus_mg ?? null, p.carotene_mcg ?? null,
|
||||
p.source ?? "manual",
|
||||
p.source ?? "manual", p.water_pct ?? null,
|
||||
]
|
||||
);
|
||||
const rows = await select("SELECT id FROM products ORDER BY id DESC LIMIT 1");
|
||||
|
|
@ -384,7 +384,7 @@ async function getFoodForDate(date) {
|
|||
p.vit_b12_mcg, p.folate_mcg,
|
||||
p.calcium_mg, p.iron_mg, p.magnesium_mg, p.zinc_mg, p.copper_mg,
|
||||
p.selenium_mcg, p.potassium_mg, p.sodium_mg,
|
||||
p.phosphorus_mg, p.carotene_mcg,
|
||||
p.phosphorus_mg, p.carotene_mcg, p.water_pct,
|
||||
r.name as recipe_name, r.cached_nutrients,
|
||||
su.name as unit_name, su.name_short as unit_short
|
||||
FROM food_log fl
|
||||
|
|
@ -675,13 +675,31 @@ async function createGpsSession(date, startTime, activityType) {
|
|||
return id;
|
||||
}
|
||||
|
||||
async function addGpsPoint(sessionId, ts, lat, lon, altGps, speedMs, seq, bleData) {
|
||||
await execute(
|
||||
`INSERT INTO gps_points (session_id, ts, lat, lon, altitude_gps_m, speed_ms, seq, heart_rate, spo2, bp_sys, bp_dia)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)`,
|
||||
[sessionId, ts, lat, lon, altGps ?? null, speedMs ?? null, seq ?? null,
|
||||
bleData?.hr ?? null, bleData?.spo2 ?? null, bleData?.bp_sys ?? null, bleData?.bp_dia ?? null]
|
||||
);
|
||||
// Column list cached after first call (avoids PRAGMA per insert)
|
||||
let _gpsPointCols = null;
|
||||
|
||||
async function addGpsPoint(sessionId, ts, lat, lon, altGps, speedMs, seq, bleData, sensorData) {
|
||||
// Build INSERT dynamically based on existing columns (avoids crash if migration hasn't run)
|
||||
if (!_gpsPointCols) {
|
||||
try {
|
||||
const info = await select("PRAGMA table_info(gps_points)");
|
||||
_gpsPointCols = new Set(info.map(r => r.name));
|
||||
} catch { _gpsPointCols = new Set(["session_id","ts","lat","lon","altitude_gps_m","speed_ms","seq"]); }
|
||||
}
|
||||
const cols = ["session_id","ts","lat","lon","altitude_gps_m","speed_ms","seq"];
|
||||
const vals = [sessionId, ts, lat, lon, altGps ?? null, speedMs ?? null, seq ?? null];
|
||||
|
||||
const extra = [
|
||||
["heart_rate", bleData?.hr], ["spo2", bleData?.spo2],
|
||||
["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],
|
||||
];
|
||||
for (const [col, val] of extra) {
|
||||
if (_gpsPointCols.has(col)) { cols.push(col); vals.push(val ?? null); }
|
||||
}
|
||||
const ph = cols.map((_, i) => `?${i + 1}`).join(",");
|
||||
await execute(`INSERT INTO gps_points (${cols.join(",")}) VALUES (${ph})`, vals);
|
||||
}
|
||||
|
||||
async function finishGpsSession(id, endTime, distM, kcal, avgSpd, maxSpd, elevGain, elevLoss) {
|
||||
|
|
@ -934,6 +952,11 @@ window.DB.getSleepForDate = getSleepForDate;
|
|||
window.DB.saveSleep = saveSleep;
|
||||
window.DB.searchProducts = searchProducts;
|
||||
window.DB.getProduct = getProduct;
|
||||
|
||||
async function getManualProducts() {
|
||||
return select("SELECT * FROM products WHERE source = 'manual' ORDER BY name");
|
||||
}
|
||||
window.DB.getManualProducts = getManualProducts;
|
||||
window.DB.addProduct = addProduct;
|
||||
window.DB.deleteProduct = deleteProduct;
|
||||
window.DB.getProductRefs = getProductRefs;
|
||||
|
|
@ -989,6 +1012,19 @@ window.DB.getGpsSession = getGpsSession;
|
|||
window.DB.getGpsPoints = getGpsPoints;
|
||||
window.DB.deleteGpsSession = deleteGpsSession;
|
||||
window.DB.importGpxSession = importGpxSession;
|
||||
|
||||
async function findNearestAltitude(lat, lon, radiusM) {
|
||||
const dLat = radiusM / 111000;
|
||||
const dLon = radiusM / (111000 * Math.cos(lat * Math.PI / 180));
|
||||
const rows = await select(`
|
||||
SELECT altitude_gps_m FROM gps_points
|
||||
WHERE altitude_gps_m IS NOT NULL
|
||||
AND lat BETWEEN ?1 AND ?2 AND lon BETWEEN ?3 AND ?4
|
||||
ORDER BY (lat - ?5)*(lat - ?5) + (lon - ?6)*(lon - ?6)
|
||||
LIMIT 1`, [lat - dLat, lat + dLat, lon - dLon, lon + dLon, lat, lon]);
|
||||
return rows[0]?.altitude_gps_m ?? null;
|
||||
}
|
||||
window.DB.findNearestAltitude = findNearestAltitude;
|
||||
window.DB.getBikes = getBikes;
|
||||
window.DB.getBike = getBike;
|
||||
window.DB.getActiveBike = getActiveBike;
|
||||
|
|
@ -1047,23 +1083,31 @@ async function getDailyWaterMl(date) {
|
|||
[date]
|
||||
);
|
||||
let total = prodRows[0]?.ml || 0;
|
||||
// From recipes (water_ml in cached_nutrients)
|
||||
// From recipes — calculate water from ingredients on-the-fly (cache may be stale)
|
||||
const recipeRows = await select(
|
||||
`SELECT fl.amount_g, r.cached_nutrients,
|
||||
(SELECT SUM(ri.amount_g) FROM recipe_ingredients ri WHERE ri.recipe_id = r.id) as recipe_weight,
|
||||
r.servings
|
||||
`SELECT fl.amount_g, fl.recipe_id, r.servings,
|
||||
(SELECT SUM(ri.amount_g) FROM recipe_ingredients ri WHERE ri.recipe_id = r.id) as recipe_weight
|
||||
FROM food_log fl JOIN recipes r ON fl.recipe_id = r.id
|
||||
WHERE fl.date = ?1 AND r.cached_nutrients IS NOT NULL`,
|
||||
WHERE fl.date = ?1`,
|
||||
[date]
|
||||
);
|
||||
for (const rr of recipeRows) {
|
||||
try {
|
||||
const cn = typeof rr.cached_nutrients === "string" ? JSON.parse(rr.cached_nutrients) : rr.cached_nutrients;
|
||||
if (cn.water_ml) {
|
||||
const servingG = Math.round((rr.recipe_weight || 0) / (rr.servings || 1));
|
||||
const scale = servingG > 0 ? rr.amount_g / servingG : 1;
|
||||
total += cn.water_ml * scale;
|
||||
// Sum water from recipe ingredients
|
||||
const ings = await select(
|
||||
`SELECT ri.amount_g, COALESCE(p.water_pct, 0) as water_pct
|
||||
FROM recipe_ingredients ri LEFT JOIN products p ON ri.product_id = p.id
|
||||
WHERE ri.recipe_id = ?1`,
|
||||
[rr.recipe_id]
|
||||
);
|
||||
let recipeWaterMl = 0;
|
||||
for (const ing of ings) {
|
||||
recipeWaterMl += (ing.amount_g || 0) * (ing.water_pct || 0) / 100;
|
||||
}
|
||||
// Scale by portion: how much of the recipe was eaten
|
||||
const servingG = Math.round((rr.recipe_weight || 0) / (rr.servings || 1));
|
||||
const scale = servingG > 0 ? rr.amount_g / servingG : 1;
|
||||
total += recipeWaterMl / (rr.servings || 1) * scale;
|
||||
} catch {}
|
||||
}
|
||||
return Math.round(total);
|
||||
|
|
@ -1077,6 +1121,9 @@ window.DB.getDailyWaterMl = getDailyWaterMl;
|
|||
|
||||
// JS-side migrations — safe for any DB state (checks before altering)
|
||||
async function runMigrations() {
|
||||
// Disable WAL — all writes go directly to main DB file (no -wal/-shm files)
|
||||
await execute("PRAGMA journal_mode=DELETE");
|
||||
|
||||
// Batch column checks — one PRAGMA per table instead of N separate IPC calls
|
||||
const productCols = (await select("PRAGMA table_info(products)")).map(r => r.name);
|
||||
const addProdCol = async (col, type) => { if (!productCols.includes(col)) await execute(`ALTER TABLE products ADD COLUMN ${col} ${type}`); };
|
||||
|
|
@ -1095,6 +1142,10 @@ async function runMigrations() {
|
|||
if (!gpsCols.includes("spo2")) await execute("ALTER TABLE gps_points ADD COLUMN spo2 INTEGER");
|
||||
if (!gpsCols.includes("bp_sys")) await execute("ALTER TABLE gps_points ADD COLUMN bp_sys INTEGER");
|
||||
if (!gpsCols.includes("bp_dia")) await execute("ALTER TABLE gps_points ADD COLUMN bp_dia INTEGER");
|
||||
if (!gpsCols.includes("accel_rms")) await execute("ALTER TABLE gps_points ADD COLUMN accel_rms REAL");
|
||||
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");
|
||||
|
||||
// --- Cooking methods table ---
|
||||
await execute(`CREATE TABLE IF NOT EXISTS cooking_methods (
|
||||
|
|
|
|||
|
|
@ -15,6 +15,11 @@ GPS.config = {
|
|||
airDensity: 1.225, // плотность воздуха (кг/м3). Уровень моря=1.225, горы=1.0
|
||||
pedalEfficiency: 25, // КПД педалирования (%). Человек ~25%
|
||||
altSmoothHalf: 2, // полуокно сглаживания высоты (точек). 2 = 5pt, 5 = 11pt, 7 = 15pt
|
||||
walkMaxSpeedKmh: 12, // макс. скорость ходьбы/бега (выше = глитч GPS)
|
||||
walkMinSpeedMs: 0.3, // мин. скорость ходьбы (ниже = стоит). 0.3 м/с = 1 км/ч
|
||||
bikeAvgSpeedKmh: 17, // средняя скорость вело (для ручных маршрутов)
|
||||
walkAvgSpeedKmh: 5, // средняя скорость ходьбы
|
||||
runAvgSpeedKmh: 10, // средняя скорость бега
|
||||
};
|
||||
|
||||
// Load saved config
|
||||
|
|
@ -109,9 +114,17 @@ GPS.computeStats = function(points, weightKg, opts) {
|
|||
const CdA = GPS.config.airDrag ?? 0.6;
|
||||
const rho = GPS.config.airDensity ?? 1.225;
|
||||
const efficiency = (GPS.config.pedalEfficiency ?? 25) / 100;
|
||||
// Rolling resistance: base value from surface type, corrected by tire pressure.
|
||||
// Crr is roughly inversely proportional to sqrt(pressure). Reference: 3.0 bar.
|
||||
const baseCrr = opts?.surfaceCrr ?? 0.006;
|
||||
// Rolling resistance: base from surface type, corrected by tire pressure.
|
||||
// If accelerometer data available, estimate Crr from vibration (accel RMS):
|
||||
// RMS < 3 → smooth asphalt (0.004), 3-7 → mixed (0.006), > 7 → gravel (0.008)
|
||||
let baseCrr = opts?.surfaceCrr ?? 0.006;
|
||||
const accelVals = sorted.map(p => p.accel_rms).filter(v => v != null && v > 0);
|
||||
if (accelVals.length > 10) {
|
||||
accelVals.sort((a, b) => a - b);
|
||||
const medianAccel = accelVals[Math.floor(accelVals.length / 2)];
|
||||
const accelCrr = medianAccel < 3 ? 0.004 : medianAccel < 7 ? 0.006 : 0.008;
|
||||
baseCrr = accelCrr; // override with sensor-based estimate
|
||||
}
|
||||
const tirePressure = opts?.tirePressureBar ?? 3.0;
|
||||
const Crr = baseCrr * Math.sqrt(3.0 / tirePressure);
|
||||
const wKg = parseFloat(weightKg) || 80; // ensure numeric (profile stores strings)
|
||||
|
|
@ -148,7 +161,17 @@ GPS.computeStats = function(points, weightKg, opts) {
|
|||
}
|
||||
|
||||
// Kcal calculation per segment
|
||||
if (dt <= 0 || dt > 300) continue; // skip pauses > 5min
|
||||
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
|
||||
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 (v < 0.3) {
|
||||
// Standing still: only basal metabolic rate
|
||||
|
|
@ -165,9 +188,15 @@ GPS.computeStats = function(points, weightKg, opts) {
|
|||
const sinG = gradient;
|
||||
const cosG = Math.sqrt(1 - sinG * sinG);
|
||||
|
||||
// Per-point Crr from accelerometer (if available), else session-wide Crr
|
||||
const ptAccel = sorted[i].accel_rms;
|
||||
const ptCrr = (ptAccel != null && ptAccel > 0)
|
||||
? (ptAccel < 3 ? 0.004 : ptAccel < 7 ? 0.006 : 0.008) * Math.sqrt(3.0 / tirePressure)
|
||||
: Crr;
|
||||
|
||||
// Power required (watts): gravity + rolling resistance + aerodynamic drag
|
||||
const pGravity = mTotal * g * sinG;
|
||||
const pRolling = Crr * mTotal * g * cosG;
|
||||
const pRolling = ptCrr * mTotal * g * cosG;
|
||||
const pAero = 0.5 * CdA * rho * v * v;
|
||||
const power = v * (pGravity + pRolling + pAero);
|
||||
|
||||
|
|
@ -363,8 +392,12 @@ GPS.recording = {
|
|||
// Start native GPS service on Android (foreground service survives background)
|
||||
GPS._startNativeService = async function() {
|
||||
try {
|
||||
// Send config along with start command: start:minSpeed:maxAcc:minDist:maxSpeed
|
||||
const cmd = `start:${GPS.config.minSpeedMs}:${GPS.config.minAccuracyM}:${GPS.config.minDistanceM}:${GPS.config.maxSpeedKmh / 3.6}`;
|
||||
// Per-activity speed limits
|
||||
const type = GPS.recording.activityType;
|
||||
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}`;
|
||||
await window.__TAURI__.core.invoke("gps_service_command", { cmd });
|
||||
console.log("[GPS] Native service started with config:", cmd);
|
||||
GPS.recording.nativeService = true;
|
||||
|
|
@ -423,6 +456,8 @@ GPS._stopHrMonitor = function() {
|
|||
}
|
||||
GPS.recording.currentHr = null;
|
||||
GPS.recording.currentBleData = null;
|
||||
// Clear stale HR data file so next session doesn't show old values
|
||||
try { window.__TAURI__.core.invoke("ble_clear_hr"); } catch {}
|
||||
};
|
||||
|
||||
// Read points from native service file
|
||||
|
|
@ -440,6 +475,9 @@ GPS._readNativePoints = async function() {
|
|||
lat: d.lat, lon: d.lon, altitude: d.alt,
|
||||
speed_ms: d.speed, accuracy: d.acc,
|
||||
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,
|
||||
estimated: d.estimated || false,
|
||||
});
|
||||
} catch { /* skip partial/corrupt lines */ }
|
||||
}
|
||||
|
|
@ -501,7 +539,7 @@ GPS._processIncomingPoint = async function(p) {
|
|||
}
|
||||
// Save point with HR even during pause (no seq = excluded from distance/kcal)
|
||||
if (GPS.recording.sessionId && GPS.recording.currentBleData) {
|
||||
DB.addGpsPoint(GPS.recording.sessionId, p.ts, p.lat, p.lon, p.altitude, 0, null, GPS.recording.currentBleData);
|
||||
DB.addGpsPoint(GPS.recording.sessionId, p.ts, p.lat, p.lon, p.altitude, 0, null, GPS.recording.currentBleData, p);
|
||||
}
|
||||
if (GPS.recording.onPoint) GPS.recording.onPoint(p);
|
||||
return;
|
||||
|
|
@ -520,7 +558,7 @@ GPS._processIncomingPoint = async function(p) {
|
|||
GPS.recording.points.push(p);
|
||||
GPS.recording._pointSeq = (GPS.recording._pointSeq || 0) + 1;
|
||||
if (GPS.recording.sessionId) {
|
||||
await DB.addGpsPoint(GPS.recording.sessionId, p.ts, p.lat, p.lon, p.altitude, p.speed_ms, GPS.recording._pointSeq, GPS.recording.currentBleData);
|
||||
await DB.addGpsPoint(GPS.recording.sessionId, p.ts, p.lat, p.lon, p.altitude, p.speed_ms, GPS.recording._pointSeq, GPS.recording.currentBleData, p);
|
||||
}
|
||||
if (GPS.recording.onPoint) GPS.recording.onPoint(p);
|
||||
};
|
||||
|
|
@ -586,7 +624,7 @@ GPS.resumeRecording = function() {
|
|||
// Recover orphaned recording session on app startup
|
||||
GPS.recoverSession = async function() {
|
||||
const saved = localStorage.getItem("baldav_gps_active");
|
||||
if (!saved) return;
|
||||
if (!saved) return false;
|
||||
try {
|
||||
const s = JSON.parse(saved);
|
||||
if (!s.sessionId) { GPS._clearPersistedState(); return; }
|
||||
|
|
@ -620,9 +658,13 @@ GPS.recoverSession = async function() {
|
|||
// Store bike_id if present
|
||||
if (s.bikeId) await DB.updateSessionBike(s.sessionId, s.bikeId);
|
||||
console.log("[GPS] Recovered session", s.sessionId, "with", pts.length, "points");
|
||||
GPS._clearPersistedState();
|
||||
await GPS._stopNativeService();
|
||||
return true;
|
||||
}
|
||||
GPS._clearPersistedState();
|
||||
await GPS._stopNativeService();
|
||||
return false;
|
||||
} catch (e) {
|
||||
console.error("[GPS] Recovery error:", e);
|
||||
GPS._clearPersistedState();
|
||||
|
|
@ -1074,15 +1116,89 @@ GPS.recalculateSession = async function(sessionId) {
|
|||
if (points.length < 2) return null;
|
||||
|
||||
const invoke = (q, v) => window.__TAURI__.core.invoke("plugin:sql|execute", { db: "sqlite:baldav.db", query: q, values: v });
|
||||
|
||||
// Delete glitch points (speed > maxSpeedKmh) from DB
|
||||
const sorted = points.slice().sort((a, b) => new Date(a.ts) - new Date(b.ts));
|
||||
const maxMs = GPS.config.maxSpeedKmh / 3.6;
|
||||
|
||||
// Manual routes: skip glitch filtering (points are user-placed, not GPS).
|
||||
// Just fix timestamps and recompute stats.
|
||||
if (session.source === "manual") {
|
||||
const isWalkM = session.activity_type === "walk" || session.activity_type === "run";
|
||||
const avgSpeedMs = isWalkM
|
||||
? (session.activity_type === "run" ? GPS.config.runAvgSpeedKmh : GPS.config.walkAvgSpeedKmh) / 3.6
|
||||
: GPS.config.bikeAvgSpeedKmh / 3.6;
|
||||
let cumTime = 0;
|
||||
const baseTs = new Date(sorted[0].ts).getTime();
|
||||
for (let i = 0; i < sorted.length; i++) {
|
||||
if (i > 0) {
|
||||
const d = GPS.distance(sorted[i-1].lat, sorted[i-1].lon, sorted[i].lat, sorted[i].lon);
|
||||
cumTime += d / avgSpeedMs;
|
||||
}
|
||||
const newTs = new Date(baseTs + cumTime * 1000).toISOString();
|
||||
await invoke("UPDATE gps_points SET ts=?1, speed_ms=?2, seq=?3 WHERE session_id=?4 AND id=?5",
|
||||
[newTs, avgSpeedMs, i + 1, sessionId, sorted[i].id]);
|
||||
sorted[i].ts = newTs;
|
||||
sorted[i].speed_ms = avgSpeedMs;
|
||||
}
|
||||
// Compute stats and finish
|
||||
const latestW = await DB.getLatestWeight();
|
||||
const profile = await DB.getFullProfile();
|
||||
const weightKg = parseFloat(latestW?.weight_kg) || parseFloat(profile?.weight_kg) || 80;
|
||||
const bike = session.bike_id ? await DB.getBike(session.bike_id) : await DB.getActiveBike();
|
||||
const CRR_MAP = { asphalt: 0.004, mixed: 0.006, gravel: 0.008 };
|
||||
const stats = GPS.computeStats(sorted, weightKg, {
|
||||
bikeWeightKg: bike?.weight_kg ?? 0,
|
||||
activityType: session.activity_type ?? "bike",
|
||||
surfaceCrr: CRR_MAP[bike?.surface_type] ?? 0.006,
|
||||
tirePressureBar: bike?.tire_pressure_bar ?? 3.0,
|
||||
});
|
||||
await DB.finishGpsSession(sessionId, session.end_time ?? session.start_time,
|
||||
stats.distance, stats.kcal, stats.avgSpeed, stats.maxSpeed, stats.elevGain, stats.elevLoss);
|
||||
return stats;
|
||||
}
|
||||
|
||||
// --- Recorded sessions: glitch filtering ---
|
||||
const isWalk = session.activity_type === "walk" || session.activity_type === "run";
|
||||
const maxMs = (isWalk ? GPS.config.walkMaxSpeedKmh : GPS.config.maxSpeedKmh) / 3.6;
|
||||
|
||||
// First pass: remove points with absurd timestamps (>1 day jump)
|
||||
let deleted = 0;
|
||||
if (sorted.length >= 3) {
|
||||
const sessionStart = new Date(sorted[0].ts).getTime();
|
||||
const sessionEnd = new Date(sorted[sorted.length - 1].ts).getTime();
|
||||
const maxDuration = 24 * 3600 * 1000; // 24 hours max session
|
||||
for (let i = sorted.length - 1; i >= 0; i--) {
|
||||
const t = new Date(sorted[i].ts).getTime();
|
||||
if (t - sessionStart > maxDuration || t < sessionStart - 60000) {
|
||||
await invoke("DELETE FROM gps_points WHERE session_id = ?1 AND ts = ?2", [sessionId, sorted[i].ts]);
|
||||
sorted.splice(i, 1);
|
||||
deleted++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: speed + accel + altitude filter
|
||||
for (let i = 1; i < sorted.length; i++) {
|
||||
const d = GPS.distance(sorted[i-1].lat, sorted[i-1].lon, sorted[i].lat, sorted[i].lon);
|
||||
const dt = (new Date(sorted[i].ts) - new Date(sorted[i-1].ts)) / 1000;
|
||||
if (dt > 0 && d / dt > maxMs) {
|
||||
const speedMs = dt > 0 ? d / dt : 0;
|
||||
const accel = sorted[i].accel_rms;
|
||||
|
||||
// Altitude jump: >100m between consecutive points = impossible
|
||||
const alt = sorted[i].altitude_gps_m;
|
||||
const prevAlt = sorted[i-1].altitude_gps_m;
|
||||
const altJump = (alt != null && prevAlt != null) ? Math.abs(alt - prevAlt) : 0;
|
||||
|
||||
// Glitch detection:
|
||||
// 1. Speed exceeds max for activity type
|
||||
// 2. Accelerometer = 0 but GPS shows movement (phone was still → GPS drift)
|
||||
// 3. High speed + normal walking accel (can't walk at 40 km/h)
|
||||
// 4. Altitude jumps >100m in one step (impossible on foot or bike)
|
||||
const isGlitch =
|
||||
(dt > 0 && speedMs > maxMs) ||
|
||||
(accel != null && accel === 0 && speedMs > 3) ||
|
||||
(isWalk && accel != null && accel < 15 && speedMs > maxMs * 0.8) ||
|
||||
(altJump > 100);
|
||||
|
||||
if (isGlitch) {
|
||||
await invoke("DELETE FROM gps_points WHERE session_id = ?1 AND ts = ?2", [sessionId, sorted[i].ts]);
|
||||
sorted.splice(i, 1);
|
||||
i--;
|
||||
|
|
@ -1091,10 +1207,60 @@ GPS.recalculateSession = async function(sessionId) {
|
|||
}
|
||||
if (deleted) console.log(`[GPS] Deleted ${deleted} glitch points from session ${sessionId}`);
|
||||
|
||||
// Re-assign seq only if points were deleted or reordered
|
||||
if (deleted > 0) {
|
||||
for (let i = 0; i < sorted.length; i++) {
|
||||
await invoke("UPDATE gps_points SET seq = ?1 WHERE session_id = ?2 AND ts = ?3", [i + 1, sessionId, sorted[i].ts]);
|
||||
// Interpolate gaps where accelerometer shows movement
|
||||
// If gap > 30s but accel before/after > 2 → person was walking/riding, GPS lost signal
|
||||
let interpolated = 0;
|
||||
for (let i = 1; i < sorted.length; i++) {
|
||||
const dt = (new Date(sorted[i].ts) - new Date(sorted[i-1].ts)) / 1000;
|
||||
if (dt < 30 || dt > 3600) continue; // only gaps 30s - 1h
|
||||
const accelBefore = sorted[i-1].accel_rms;
|
||||
const accelAfter = sorted[i].accel_rms;
|
||||
// Both ends show movement → person was moving during gap
|
||||
if (accelBefore != null && accelAfter != null && accelBefore > 2 && accelAfter > 2) {
|
||||
// Add interpolated distance to total (will be counted by computeStats naturally
|
||||
// since the gap points are adjacent and haversine gives straight-line distance)
|
||||
// Mark gap as "moving" by ensuring speed_ms is set
|
||||
interpolated++;
|
||||
}
|
||||
}
|
||||
if (interpolated) console.log(`[GPS] Found ${interpolated} gaps with movement (accel-confirmed)`);
|
||||
|
||||
// Fix manual routes: timestamps are fake (1s apart), recalculate based on realistic speed
|
||||
if (session.source === "manual" || sorted.length >= 2) {
|
||||
const t0 = new Date(sorted[0].ts).getTime();
|
||||
const tN = new Date(sorted[sorted.length - 1].ts).getTime();
|
||||
const totalTimeMs = tN - t0;
|
||||
// Compute total distance
|
||||
let totalDist = 0;
|
||||
for (let i = 1; i < sorted.length; i++) {
|
||||
totalDist += GPS.distance(sorted[i-1].lat, sorted[i-1].lon, sorted[i].lat, sorted[i].lon);
|
||||
}
|
||||
const impliedSpeedMs = totalTimeMs > 0 ? (totalDist / (totalTimeMs / 1000)) : 0;
|
||||
// If implied speed is absurd (>30 m/s for walk, >50 for bike) → fix timestamps
|
||||
const saneMaxMs = isWalk ? 5 : 15; // 18 km/h walk, 54 km/h bike
|
||||
if (impliedSpeedMs > saneMaxMs && totalDist > 100) {
|
||||
const avgSpeedMs = isWalk
|
||||
? (session.activity_type === "run" ? GPS.config.runAvgSpeedKmh : GPS.config.walkAvgSpeedKmh) / 3.6
|
||||
: GPS.config.bikeAvgSpeedKmh / 3.6;
|
||||
let cumTime = 0;
|
||||
const baseTs = new Date(sorted[0].ts).getTime();
|
||||
for (let i = 0; i < sorted.length; i++) {
|
||||
if (i > 0) {
|
||||
const d = GPS.distance(sorted[i-1].lat, sorted[i-1].lon, sorted[i].lat, sorted[i].lon);
|
||||
cumTime += d / avgSpeedMs;
|
||||
}
|
||||
const newTs = new Date(baseTs + cumTime * 1000).toISOString();
|
||||
await invoke("UPDATE gps_points SET ts = ?1, speed_ms = ?2, seq = ?3 WHERE session_id = ?4 AND ts = ?5",
|
||||
[newTs, avgSpeedMs, i + 1, sessionId, sorted[i].ts]);
|
||||
sorted[i].ts = newTs;
|
||||
sorted[i].speed_ms = avgSpeedMs;
|
||||
}
|
||||
console.log(`[GPS] Fixed manual route timestamps: ${sorted.length} pts, avg ${avgSpeedMs} m/s`);
|
||||
} else if (deleted > 0) {
|
||||
// Just re-assign seq
|
||||
for (let i = 0; i < sorted.length; i++) {
|
||||
await invoke("UPDATE gps_points SET seq = ?1 WHERE session_id = ?2 AND ts = ?3", [i + 1, sessionId, sorted[i].ts]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -38,8 +38,8 @@ async function checkForUpdate() {
|
|||
const raw = await window.__TAURI__.core.invoke("get_update_info");
|
||||
const info = JSON.parse(raw);
|
||||
if (info.remote && info.local && info.remote > info.local) {
|
||||
state.updateAvailable = { remoteBuild: info.remote, localBuild: info.local };
|
||||
console.log(`[Update] available: ${info.local} -> ${info.remote}`);
|
||||
state.updateAvailable = { remoteBuild: info.remote, localBuild: info.local, url: info.url || null };
|
||||
console.log(`[Update] available: ${info.local} -> ${info.remote} url=${info.url}`);
|
||||
} else {
|
||||
state.updateAvailable = null;
|
||||
}
|
||||
|
|
@ -50,7 +50,7 @@ async function checkForUpdate() {
|
|||
|
||||
async function startUpdate() {
|
||||
if (!state.updateAvailable) return;
|
||||
const url = UPDATE_BASE_URL + "/baldav.apk";
|
||||
const url = state.updateAvailable.url || (UPDATE_BASE_URL + "/baldav.apk");
|
||||
try {
|
||||
await window.__TAURI__.core.invoke("gps_service_command", { cmd: "open_url:" + url });
|
||||
} catch (e) {
|
||||
|
|
@ -121,7 +121,10 @@ async function init() {
|
|||
try { if (DB._migrationsReady) await DB._migrationsReady; } catch (e) { console.error("[BOOT]", e); }
|
||||
|
||||
// Recover any orphaned GPS recording session
|
||||
try { await GPS.recoverSession(); } catch (e) { /* ignore */ }
|
||||
try {
|
||||
const recovered = await GPS.recoverSession();
|
||||
if (recovered) showToast("Восстановлена незавершённая тренировка");
|
||||
} catch (e) { console.error("[Recovery]", e); }
|
||||
|
||||
// Load RDI standard and half-life data from profile (default: RU)
|
||||
try {
|
||||
|
|
@ -278,7 +281,8 @@ async function renderToday(container) {
|
|||
return;
|
||||
}
|
||||
|
||||
const bmr = calcBmr(weights);
|
||||
const recentWeightsForBmr = await DB.getRecentWeights(7);
|
||||
const bmr = calcBmr(recentWeightsForBmr);
|
||||
const actLevel = parseFloat(state.profile.activity_level) || 1.2;
|
||||
const dailyBase = bmr ? Math.round(bmr * actLevel) : null;
|
||||
const intake = Math.round(foodTotals.kcal ?? 0);
|
||||
|
|
@ -577,12 +581,12 @@ async function renderToday(container) {
|
|||
const statsContainer = container.querySelector("#stats-charts");
|
||||
const statsPeriod = container.querySelector(".stats-period");
|
||||
if (statsContainer && statsPeriod) {
|
||||
renderStatsCharts(statsContainer, 30, foodTotals, bmr);
|
||||
renderStatsCharts(statsContainer, 30, foodTotals, dailyBase);
|
||||
statsPeriod.querySelectorAll(".type-chip").forEach(chip => {
|
||||
chip.onclick = () => {
|
||||
statsPeriod.querySelectorAll(".type-chip").forEach(c => c.classList.remove("selected"));
|
||||
chip.classList.add("selected");
|
||||
renderStatsCharts(statsContainer, parseInt(chip.dataset.days), foodTotals, bmr);
|
||||
renderStatsCharts(statsContainer, parseInt(chip.dataset.days), foodTotals, dailyBase);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
@ -1144,10 +1148,11 @@ function showFoodModal() {
|
|||
unitChipsEl.style.display = "none";
|
||||
}
|
||||
|
||||
// Reset amount to grams mode
|
||||
amountLabel.textContent = "Количество (г)";
|
||||
amountInput.placeholder = "100";
|
||||
amountInput.value = "100";
|
||||
// Reset amount — show "мл" for liquids
|
||||
const isLiquid = product.water_pct && product.water_pct > 80;
|
||||
amountLabel.textContent = `Количество (${isLiquid ? "мл" : "г"})`;
|
||||
amountInput.placeholder = isLiquid ? "250" : "100";
|
||||
amountInput.value = isLiquid ? "250" : "100";
|
||||
|
||||
selectedEl.onclick = () => {
|
||||
selectedProduct = null;
|
||||
|
|
@ -1180,7 +1185,7 @@ function showEditFoodModal(entry) {
|
|||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Количество (г)</label>
|
||||
<label id="m-amount-label">Количество (${(entry.water_pct && entry.water_pct > 80) ? "мл" : "г"})</label>
|
||||
<input id="m-amount" type="text" inputmode="numeric" value="${entry.amount_g || 100}" />
|
||||
</div>
|
||||
${!entry.product_id && !entry.recipe_id ? `
|
||||
|
|
@ -1338,6 +1343,8 @@ async function renderProducts(container) {
|
|||
<div style="display:flex;gap:8px;align-items:center">
|
||||
<input id="prod-search" class="search-input" type="search" style="flex:1"
|
||||
placeholder="Название, бренд или ингредиент..." />
|
||||
<button class="add-btn" id="export-all-custom" style="font-size:12px" title="Экспорт своих продуктов и рецептов">⇩</button>
|
||||
<button class="add-btn" id="import-all-custom" style="font-size:12px" title="Импорт">⇧</button>
|
||||
<button class="add-btn" id="add-custom-product" title="Свой продукт">+</button>
|
||||
</div>
|
||||
<div id="prod-count" class="prod-count"></div>
|
||||
|
|
@ -1509,6 +1516,48 @@ async function renderProducts(container) {
|
|||
// Add custom product button
|
||||
const addCustomBtn = container.querySelector("#add-custom-product");
|
||||
if (addCustomBtn) addCustomBtn.onclick = () => showAddProductModal(() => doSearch(true));
|
||||
|
||||
// Export all custom data (manual products + recipes + supplements)
|
||||
container.querySelector("#export-all-custom")?.addEventListener("click", async () => {
|
||||
const manualProducts = await DB.getManualProducts();
|
||||
const allRecipes = await exportAllRecipes();
|
||||
const supps = await DB.getSupplements();
|
||||
const bundle = { products: manualProducts, recipes: allRecipes, supplements: supps };
|
||||
downloadFile("baldav-custom-data.json", JSON.stringify(bundle, null, 2), "application/json");
|
||||
showToast(`Экспорт: ${manualProducts.length} продуктов, ${allRecipes.length} рецептов, ${supps.length} добавок`);
|
||||
});
|
||||
|
||||
// Import custom data
|
||||
container.querySelector("#import-all-custom")?.addEventListener("click", () => {
|
||||
pickFile(".json", async (text) => {
|
||||
try {
|
||||
const data = JSON.parse(text);
|
||||
let imported = { products: 0, recipes: 0, supplements: 0 };
|
||||
if (data.products && Array.isArray(data.products)) {
|
||||
for (const p of data.products) {
|
||||
// Dedup by name_lower + source
|
||||
const existing = await DB.searchProducts(p.name_lower || p.name?.toLowerCase() || "", 1);
|
||||
if (existing.some(e => e.name_lower === (p.name_lower || p.name?.toLowerCase()) && e.source === (p.source || "manual"))) continue;
|
||||
delete p.id;
|
||||
await DB.addProduct(p);
|
||||
imported.products++;
|
||||
}
|
||||
}
|
||||
if (data.recipes && Array.isArray(data.recipes)) {
|
||||
await importRecipes(data.recipes);
|
||||
imported.recipes = data.recipes.length;
|
||||
}
|
||||
if (data.supplements && Array.isArray(data.supplements)) {
|
||||
await importSupplements(data.supplements);
|
||||
imported.supplements = data.supplements.length;
|
||||
}
|
||||
showToast(`Импорт: ${imported.products} продуктов, ${imported.recipes} рецептов, ${imported.supplements} добавок`);
|
||||
doSearch(true);
|
||||
} catch (e) {
|
||||
showToast("Ошибка импорта: " + e.message);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function showAddProductModal(onSaved) {
|
||||
|
|
@ -1543,6 +1592,10 @@ function showAddProductModal(onSaved) {
|
|||
<input id="np-carbs" type="number" inputmode="decimal" step="0.1" placeholder="48" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" style="margin-top:8px">
|
||||
<label>Содержание воды (%) <small style="color:var(--muted)">87=молоко, 99=чай, 0=твёрдое</small></label>
|
||||
<input id="np-water" type="number" inputmode="numeric" step="1" min="0" max="100" placeholder="" />
|
||||
</div>
|
||||
<p style="color:var(--muted);font-size:12px;margin:8px 0 4px">Нутриенты на 100г (опционально):</p>
|
||||
<div class="micro-grid">${microHtml}</div>`,
|
||||
async overlay => {
|
||||
|
|
@ -1554,6 +1607,7 @@ function showAddProductModal(onSaved) {
|
|||
protein_per_100g: parseFloat(overlay.querySelector("#np-protein").value) || null,
|
||||
fat_per_100g: parseFloat(overlay.querySelector("#np-fat").value) || null,
|
||||
carbs_per_100g: parseFloat(overlay.querySelector("#np-carbs").value) || null,
|
||||
water_pct: parseFloat(overlay.querySelector("#np-water").value) || null,
|
||||
source: "manual",
|
||||
};
|
||||
for (const f of PRODUCT_MICRO_FIELDS) {
|
||||
|
|
@ -1600,20 +1654,27 @@ function showEditProductModal(p, onSaved) {
|
|||
<input id="ep-carbs" type="number" inputmode="decimal" step="0.1" value="${p.carbs_per_100g ?? ""}" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" style="margin-top:8px">
|
||||
<label>Содержание воды (%) <small style="color:var(--muted)">87=молоко, 99=чай, 0=твёрдое</small></label>
|
||||
<input id="ep-water" type="number" inputmode="numeric" step="1" min="0" max="100" value="${p.water_pct ?? ""}" placeholder="" />
|
||||
</div>
|
||||
<p style="color:var(--muted);font-size:12px;margin:8px 0 4px">Нутриенты на 100г:</p>
|
||||
<div class="micro-grid">${microHtml}</div>`,
|
||||
<div class="micro-grid">${microHtml}</div>
|
||||
<button id="ep-export" class="btn-secondary full-width" style="margin-top:12px;font-size:13px">Экспорт продукта</button>`,
|
||||
async overlay => {
|
||||
const name = overlay.querySelector("#ep-name").value.trim();
|
||||
if (!name) return false;
|
||||
const sets = ["name=?1", "kcal_per_100g=?2", "protein_per_100g=?3", "fat_per_100g=?4", "carbs_per_100g=?5"];
|
||||
const waterPct = parseFloat(overlay.querySelector("#ep-water").value) || null;
|
||||
const sets = ["name=?1", "kcal_per_100g=?2", "protein_per_100g=?3", "fat_per_100g=?4", "carbs_per_100g=?5", "water_pct=?6"];
|
||||
const vals = [
|
||||
name,
|
||||
parseFloat(overlay.querySelector("#ep-kcal").value) || null,
|
||||
parseFloat(overlay.querySelector("#ep-protein").value) || null,
|
||||
parseFloat(overlay.querySelector("#ep-fat").value) || null,
|
||||
parseFloat(overlay.querySelector("#ep-carbs").value) || null,
|
||||
waterPct,
|
||||
];
|
||||
let idx = 6;
|
||||
let idx = 7;
|
||||
for (const f of PRODUCT_MICRO_FIELDS) {
|
||||
let val = parseFloat(overlay.querySelector(`#ep-${f.key}`).value) || null;
|
||||
if (val && f.mult) val *= f.mult;
|
||||
|
|
@ -1630,6 +1691,17 @@ function showEditProductModal(p, onSaved) {
|
|||
});
|
||||
if (onSaved) onSaved();
|
||||
});
|
||||
|
||||
// Export single product
|
||||
setTimeout(() => {
|
||||
const overlay = [...document.querySelectorAll(".modal-overlay")].pop();
|
||||
const expBtn = overlay?.querySelector("#ep-export");
|
||||
if (expBtn) expBtn.onclick = () => {
|
||||
const clean = { ...p };
|
||||
delete clean.id;
|
||||
downloadFile(`baldav-product-${(p.name || "").slice(0, 30)}.json`, JSON.stringify({ products: [clean] }, null, 2), "application/json");
|
||||
};
|
||||
}, 100);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -2206,6 +2278,7 @@ async function renderGpsScreen(container) {
|
|||
<div class="gps-menu-wrap">
|
||||
<button id="gps-menu-btn" class="gps-menu-btn">⋮</button>
|
||||
<div id="gps-menu" class="gps-menu hidden">
|
||||
<div class="gps-menu-item" id="gps-draw-route">Нарисовать маршрут</div>
|
||||
<div class="gps-menu-item" id="gps-download-map">Скачать карту</div>
|
||||
<div class="gps-menu-item" id="gps-import">Импорт тренировки</div>
|
||||
<div class="gps-menu-item" id="gps-clear-cache">Очистить кэш карт</div>
|
||||
|
|
@ -2281,6 +2354,8 @@ async function renderGpsScreen(container) {
|
|||
}, { once: false });
|
||||
|
||||
// Download map tiles — region picker
|
||||
container.querySelector("#gps-draw-route").onclick = () => showDrawRouteScreen(container);
|
||||
|
||||
container.querySelector("#gps-download-map").onclick = () => {
|
||||
menu.classList.add("hidden");
|
||||
showMapDownloadModal();
|
||||
|
|
@ -2462,6 +2537,190 @@ async function showAddGoalMarkerModal() {
|
|||
}, 200);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Draw manual route on map
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function fetchElevations(points) {
|
||||
try {
|
||||
// Batch up to 100 points per request
|
||||
const elevations = new Array(points.length).fill(null);
|
||||
for (let i = 0; i < points.length; i += 100) {
|
||||
const batch = points.slice(i, i + 100);
|
||||
const lats = batch.map(p => p.lat.toFixed(5)).join(",");
|
||||
const lons = batch.map(p => p.lon.toFixed(5)).join(",");
|
||||
const resp = await fetch(`https://api.open-meteo.com/v1/elevation?latitude=${lats}&longitude=${lons}`);
|
||||
if (!resp.ok) continue;
|
||||
const data = await resp.json();
|
||||
if (data.elevation) {
|
||||
for (let j = 0; j < data.elevation.length; j++) elevations[i + j] = data.elevation[j];
|
||||
}
|
||||
}
|
||||
return elevations;
|
||||
} catch (e) {
|
||||
console.log("[Elevation API] failed:", e.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function showDrawRouteScreen(container) {
|
||||
const points = []; // {lat, lon}
|
||||
let markers = [];
|
||||
let polyline = null;
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="rec-screen">
|
||||
<div id="draw-map" class="rec-map"></div>
|
||||
<div class="rec-stats">
|
||||
<div class="rec-stat"><span id="draw-dist" class="rec-val">0 м</span><span class="rec-lbl">Дистанция</span></div>
|
||||
<div class="rec-stat"><span id="draw-pts" class="rec-val">0</span><span class="rec-lbl">Точек</span></div>
|
||||
</div>
|
||||
<div class="rec-bottom">
|
||||
<select id="draw-type" style="padding:8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--on-bg)">
|
||||
<option value="bike">Вело</option>
|
||||
<option value="walk">Ходьба</option>
|
||||
<option value="run">Бег</option>
|
||||
</select>
|
||||
<button id="draw-undo" class="btn-secondary" style="padding:8px 16px">Отменить</button>
|
||||
<button id="draw-save" class="btn-primary" style="padding:8px 16px">Сохранить</button>
|
||||
<button id="draw-cancel" class="btn-secondary" style="padding:8px 12px;color:#cf6679">Отмена</button>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
setTimeout(() => {
|
||||
const mapEl = document.querySelector("#draw-map");
|
||||
if (!mapEl) return;
|
||||
|
||||
L.Icon.Default.imagePath = "lib/";
|
||||
const map = L.map(mapEl, { attributionControl: false }).setView([55.75, 37.62], 15);
|
||||
const CachedLayer = TileCache.createLayer();
|
||||
new CachedLayer({ maxZoom: 19 }).addTo(map);
|
||||
|
||||
// Try to center on last known position
|
||||
if (navigator.geolocation) {
|
||||
navigator.geolocation.getCurrentPosition(pos => {
|
||||
map.setView([pos.coords.latitude, pos.coords.longitude], 15);
|
||||
}, () => {}, { enableHighAccuracy: false, timeout: 3000, maximumAge: 60000 });
|
||||
}
|
||||
|
||||
const distEl = document.querySelector("#draw-dist");
|
||||
const ptsEl = document.querySelector("#draw-pts");
|
||||
|
||||
function updateStats() {
|
||||
let dist = 0;
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
dist += GPS.distance(points[i-1].lat, points[i-1].lon, points[i].lat, points[i].lon);
|
||||
}
|
||||
distEl.textContent = GPS.formatDistance(dist);
|
||||
ptsEl.textContent = String(points.length);
|
||||
}
|
||||
|
||||
function updatePolyline() {
|
||||
const latlngs = points.map(p => [p.lat, p.lon]);
|
||||
if (polyline) map.removeLayer(polyline);
|
||||
if (latlngs.length >= 2) {
|
||||
polyline = L.polyline(latlngs, { color: "#bb86fc", weight: 4 }).addTo(map);
|
||||
}
|
||||
}
|
||||
|
||||
// Map click → add point
|
||||
map.on("click", e => {
|
||||
const { lat, lng } = e.latlng;
|
||||
points.push({ lat, lon: lng });
|
||||
const marker = L.circleMarker([lat, lng], {
|
||||
radius: 6, color: "#bb86fc", fillColor: "#bb86fc", fillOpacity: 1, weight: 2
|
||||
}).addTo(map);
|
||||
markers.push(marker);
|
||||
updatePolyline();
|
||||
updateStats();
|
||||
});
|
||||
|
||||
// Undo
|
||||
document.querySelector("#draw-undo").onclick = () => {
|
||||
if (points.length === 0) return;
|
||||
points.pop();
|
||||
const m = markers.pop();
|
||||
if (m) map.removeLayer(m);
|
||||
updatePolyline();
|
||||
updateStats();
|
||||
};
|
||||
|
||||
// Cancel
|
||||
document.querySelector("#draw-cancel").onclick = () => renderScreen("gps");
|
||||
|
||||
// Save
|
||||
document.querySelector("#draw-save").onclick = async () => {
|
||||
if (points.length < 2) { showToast("Нужно минимум 2 точки"); return; }
|
||||
|
||||
const saveBtn = document.querySelector("#draw-save");
|
||||
saveBtn.textContent = "Сохранение...";
|
||||
saveBtn.disabled = true;
|
||||
|
||||
const activityType = document.querySelector("#draw-type").value;
|
||||
|
||||
// Get elevation for each point
|
||||
const needApi = [];
|
||||
for (const p of points) {
|
||||
const alt = await DB.findNearestAltitude(p.lat, p.lon, 50);
|
||||
if (alt != null) { p.altitude = alt; }
|
||||
else { needApi.push(p); }
|
||||
}
|
||||
if (needApi.length > 0) {
|
||||
const elevs = await fetchElevations(needApi);
|
||||
if (elevs) needApi.forEach((p, i) => { if (elevs[i] != null) p.altitude = elevs[i]; });
|
||||
}
|
||||
for (const p of points) { if (p.altitude == null) p.altitude = 0; }
|
||||
|
||||
// Build points for DB with realistic timestamps based on activity speed
|
||||
const avgSpeedMs = (activityType === "bike" ? GPS.config.bikeAvgSpeedKmh
|
||||
: activityType === "run" ? GPS.config.runAvgSpeedKmh
|
||||
: GPS.config.walkAvgSpeedKmh) / 3.6;
|
||||
const now = Date.now();
|
||||
let cumTime = 0;
|
||||
const dbPoints = points.map((p, i) => {
|
||||
if (i > 0) {
|
||||
const d = GPS.distance(points[i-1].lat, points[i-1].lon, p.lat, p.lon);
|
||||
cumTime += d / avgSpeedMs; // seconds to cover this segment
|
||||
}
|
||||
return {
|
||||
lat: p.lat, lon: p.lon,
|
||||
altitude_gps_m: p.altitude,
|
||||
speed_ms: avgSpeedMs,
|
||||
ts: new Date(now + cumTime * 1000).toISOString(),
|
||||
seq: i + 1,
|
||||
};
|
||||
});
|
||||
|
||||
// Compute stats
|
||||
const latestW = await DB.getLatestWeight();
|
||||
const profile = await DB.getFullProfile();
|
||||
const weightKg = parseFloat(latestW?.weight_kg) || parseFloat(profile?.weight_kg) || 80;
|
||||
const bike = await DB.getActiveBike();
|
||||
const opts = { activityType, bikeWeightKg: bike?.weight_kg ?? 0, surfaceCrr: bike?.surface_type };
|
||||
const stats = GPS.computeStats(dbPoints, weightKg, opts);
|
||||
|
||||
// Create session
|
||||
const date = new Date().toISOString().slice(0, 10);
|
||||
const time = new Date().toTimeString().slice(0, 5);
|
||||
const session = {
|
||||
id: now, date, start_time: time, end_time: time,
|
||||
activity_type: activityType, source: "manual",
|
||||
total_distance_m: Math.round(stats.distance),
|
||||
total_kcal: Math.round(stats.kcal),
|
||||
avg_speed_kmh: stats.avgSpeed,
|
||||
max_speed_kmh: stats.maxSpeed,
|
||||
elevation_gain_m: Math.round(stats.elevGain),
|
||||
elevation_loss_m: Math.round(stats.elevLoss),
|
||||
bike_id: bike?.id ?? null,
|
||||
};
|
||||
await DB.importGpxSession(session, dbPoints);
|
||||
|
||||
showToast(`Маршрут сохранён: ${GPS.formatDistance(stats.distance)}, ${Math.round(stats.kcal)} ккал`);
|
||||
renderScreen("gps");
|
||||
};
|
||||
}, 200);
|
||||
}
|
||||
|
||||
function renderRecordingInline(container) {
|
||||
if (!GPS.recording.active) { renderScreen("gps"); return; }
|
||||
|
||||
|
|
@ -2811,7 +3070,9 @@ function showMapDownloadModal() {
|
|||
// Session detail view
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let _sessionDetailRender = 0;
|
||||
async function showSessionDetail(sessionId) {
|
||||
const renderToken = ++_sessionDetailRender;
|
||||
try {
|
||||
console.log("[SD] loading session", sessionId);
|
||||
const session = await DB.getGpsSession(sessionId);
|
||||
|
|
@ -2983,6 +3244,7 @@ async function showSessionDetail(sessionId) {
|
|||
|
||||
// Render map
|
||||
setTimeout(() => {
|
||||
if (renderToken !== _sessionDetailRender) return;
|
||||
const mapEl = document.querySelector("#sd-map");
|
||||
if (!mapEl || !points.length) return;
|
||||
|
||||
|
|
@ -3004,16 +3266,29 @@ async function showSessionDetail(sessionId) {
|
|||
const tRange = tN - t0 || 1;
|
||||
|
||||
// Split into segments by GPS gap threshold
|
||||
// If accelerometer shows movement on both sides of gap → keep connected (don't break)
|
||||
const gapSec = GPS.config.gapThresholdSec ?? 30;
|
||||
const segments = []; // array of arrays of [lat, lon]
|
||||
const gapLines = []; // dashed lines for accel-confirmed gaps
|
||||
let curSeg = [[points[0].lat, points[0].lon]];
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
const gap = (new Date(points[i].ts) - new Date(points[i-1].ts)) / 1000;
|
||||
if (gap > gapSec) {
|
||||
segments.push(curSeg);
|
||||
curSeg = [];
|
||||
const accelBefore = points[i-1].accel_rms;
|
||||
const accelAfter = points[i].accel_rms;
|
||||
// If both sides show movement → bridge the gap with dashed line
|
||||
if (accelBefore != null && accelAfter != null && accelBefore > 2 && accelAfter > 2) {
|
||||
// Keep in same segment — person was moving, GPS just lost signal
|
||||
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 {
|
||||
segments.push(curSeg);
|
||||
curSeg = [];
|
||||
curSeg.push([points[i].lat, points[i].lon]);
|
||||
}
|
||||
} else {
|
||||
curSeg.push([points[i].lat, points[i].lon]);
|
||||
}
|
||||
curSeg.push([points[i].lat, points[i].lon]);
|
||||
}
|
||||
segments.push(curSeg);
|
||||
|
||||
|
|
@ -3028,6 +3303,11 @@ async function showSessionDetail(sessionId) {
|
|||
allCoords.push(...seg);
|
||||
});
|
||||
|
||||
// Draw dashed lines for accel-confirmed gaps (GPS lost signal but person was moving)
|
||||
for (const gl of gapLines) {
|
||||
L.polyline(gl, { color: "#bb86fc", weight: 3, opacity: 0.5, dashArray: "8, 8" }).addTo(map);
|
||||
}
|
||||
|
||||
// Invisible but tappable hit-targets on top of track
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
const p = points[i];
|
||||
|
|
@ -3136,6 +3416,7 @@ async function showSessionDetail(sessionId) {
|
|||
|
||||
// Render speed chart
|
||||
setTimeout(() => {
|
||||
if (renderToken !== _sessionDetailRender) return;
|
||||
const ctx = document.querySelector("#sd-speed-chart");
|
||||
if (!ctx || !smoothed.length) return;
|
||||
// Update maxSpeed display to match smoothed graph
|
||||
|
|
@ -3186,6 +3467,7 @@ async function showSessionDetail(sessionId) {
|
|||
|
||||
// Render elevation chart
|
||||
setTimeout(() => {
|
||||
if (renderToken !== _sessionDetailRender) return;
|
||||
const ctx = document.querySelector("#sd-elev-chart");
|
||||
if (!ctx || !elevations.length) return;
|
||||
const elevLabels = points.filter(p => p.altitude_gps_m != null)
|
||||
|
|
@ -3236,6 +3518,7 @@ async function showSessionDetail(sessionId) {
|
|||
|
||||
// Render HR chart
|
||||
setTimeout(() => {
|
||||
if (renderToken !== _sessionDetailRender) return;
|
||||
const hrData = points.map(p => p.heart_rate || null);
|
||||
if (!hrData.some(v => v)) { /* no HR data — hide chart */ return; }
|
||||
const wrap = document.querySelector("#sd-hr-wrap");
|
||||
|
|
@ -3297,6 +3580,7 @@ async function showSessionDetail(sessionId) {
|
|||
|
||||
// Delete button
|
||||
setTimeout(() => {
|
||||
if (renderToken !== _sessionDetailRender) return;
|
||||
const exp = document.querySelector("#sd-export");
|
||||
if (exp) exp.onclick = () => GPS.exportGPX(sessionId);
|
||||
|
||||
|
|
@ -3984,8 +4268,8 @@ async function renderSettings(container) {
|
|||
</div>
|
||||
<div class="settings-item">
|
||||
<div class="settings-item-left">
|
||||
<div class="settings-item-title">Макс. точность (м)</div>
|
||||
<div class="settings-item-desc">Фиксы точнее этого = пропуск</div>
|
||||
<div class="settings-item-title">Макс. погрешность GPS (м)</div>
|
||||
<div class="settings-item-desc">Хуже этого = пропуск (авто x2 при плохом сигнале)</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>
|
||||
|
|
@ -4040,6 +4324,24 @@ async function renderSettings(container) {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<div class="settings-section-title">Ходьба / Бег</div>
|
||||
<div class="settings-item">
|
||||
<div class="settings-item-left">
|
||||
<div class="settings-item-title">Макс. скорость (км/ч)</div>
|
||||
<div class="settings-item-desc">Выше = глитч GPS</div>
|
||||
</div>
|
||||
<input id="s-walk-max-speed" type="number" step="1" value="${GPS.config.walkMaxSpeedKmh}" 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">Мин. скорость (км/ч)</div>
|
||||
<div class="settings-item-desc">Ниже = стоит</div>
|
||||
</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>
|
||||
|
||||
<div class="settings-section">
|
||||
<div class="settings-section-title">Велосипеды</div>
|
||||
<div id="s-bikes-list"></div>
|
||||
|
|
@ -4140,7 +4442,7 @@ async function renderSettings(container) {
|
|||
};
|
||||
|
||||
// GPS config inputs
|
||||
const gpsInputs = ["s-min-speed", "s-min-acc", "s-min-dist", "s-max-speed", "s-gap-threshold", "s-air-drag", "s-air-density", "s-pedal-eff", "s-alt-smooth"];
|
||||
const gpsInputs = ["s-min-speed", "s-min-acc", "s-min-dist", "s-max-speed", "s-gap-threshold", "s-air-drag", "s-air-density", "s-pedal-eff", "s-alt-smooth", "s-walk-max-speed", "s-walk-min-speed"];
|
||||
for (const id of gpsInputs) {
|
||||
const el = container.querySelector("#" + id);
|
||||
if (el) el.onchange = () => {
|
||||
|
|
@ -4153,6 +4455,8 @@ async function renderSettings(container) {
|
|||
GPS.config.airDensity = parseFloat(container.querySelector("#s-air-density").value) || 1.225;
|
||||
GPS.config.pedalEfficiency = parseFloat(container.querySelector("#s-pedal-eff").value) || 25;
|
||||
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;
|
||||
GPS.saveConfig();
|
||||
};
|
||||
}
|
||||
|
|
@ -4896,16 +5200,20 @@ function createProductSearch(parentEl, onSelect, opts) {
|
|||
return { el: wrap, input, focus() { input.focus(); } };
|
||||
}
|
||||
|
||||
function calcBmr(weights) {
|
||||
function calcBmr(recentWeights) {
|
||||
const p = state.profile;
|
||||
const height = parseInt(p.height_cm);
|
||||
const birthYear = parseInt(p.birth_year);
|
||||
if (!height || !birthYear) return null;
|
||||
const kg = parseFloat(
|
||||
(weights.length ? weights[weights.length - 1].weight_kg : null)
|
||||
?? state.latestWeight?.weight_kg
|
||||
?? p.weight_kg
|
||||
);
|
||||
// Moving average weight over recent days (smooths daily fluctuations)
|
||||
let kg;
|
||||
if (recentWeights && recentWeights.length >= 2) {
|
||||
kg = recentWeights.reduce((s, w) => s + parseFloat(w.weight_kg), 0) / recentWeights.length;
|
||||
} else if (recentWeights && recentWeights.length === 1) {
|
||||
kg = parseFloat(recentWeights[0].weight_kg);
|
||||
} else {
|
||||
kg = parseFloat(state.latestWeight?.weight_kg ?? p.weight_kg);
|
||||
}
|
||||
if (!kg) return null;
|
||||
return BMR.calculateBmr(kg, height, BMR.getAge(birthYear), p.sex ?? "m");
|
||||
}
|
||||
|
|
|
|||
2
src-tauri/Cargo.lock
generated
2
src-tauri/Cargo.lock
generated
|
|
@ -139,7 +139,7 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
|||
|
||||
[[package]]
|
||||
name = "baldav"
|
||||
version = "0.1.224"
|
||||
version = "0.1.244"
|
||||
dependencies = [
|
||||
"btleplug",
|
||||
"lazy_static",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "baldav"
|
||||
version = "0.1.224"
|
||||
version = "0.1.244"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
|
|
|
|||
|
|
@ -14,11 +14,16 @@ import android.os.Handler
|
|||
import android.os.IBinder
|
||||
import android.os.Looper
|
||||
import android.os.PowerManager
|
||||
import android.hardware.Sensor
|
||||
import android.hardware.SensorEvent
|
||||
import android.hardware.SensorEventListener
|
||||
import android.hardware.SensorManager
|
||||
import android.util.Log
|
||||
import java.io.FileOutputStream
|
||||
import java.io.File
|
||||
import kotlin.math.sqrt
|
||||
|
||||
class GpsRecorderService : Service(), LocationListener {
|
||||
class GpsRecorderService : Service(), LocationListener, SensorEventListener {
|
||||
|
||||
companion object {
|
||||
const val TAG = "GpsRecorder"
|
||||
|
|
@ -43,6 +48,15 @@ class GpsRecorderService : Service(), LocationListener {
|
|||
private var minDistanceM = 1f
|
||||
private var maxSpeedMs = 16.67f // 60 km/h default
|
||||
|
||||
// Sensor data aggregated between GPS points
|
||||
private var sensorManager: SensorManager? = null
|
||||
private var accelSamples = mutableListOf<Float>() // magnitude per sample
|
||||
private var accelMax = 0f
|
||||
private var gyroPitchSum = 0.0
|
||||
private var gyroYawSum = 0.0
|
||||
private var gyroSamples = 0
|
||||
private val sensorLock = Object()
|
||||
|
||||
private val pointBuffer = mutableListOf<String>()
|
||||
private val bufferLock = Object()
|
||||
private val flushHandler = Handler(Looper.getMainLooper())
|
||||
|
|
@ -110,6 +124,23 @@ class GpsRecorderService : Service(), LocationListener {
|
|||
)
|
||||
Log.d(TAG, "Location updates registered successfully")
|
||||
|
||||
// Register accelerometer + gyroscope (non-critical — if missing, GPS still works)
|
||||
try {
|
||||
sensorManager = getSystemService(SENSOR_SERVICE) as? SensorManager
|
||||
sensorManager?.let { sm ->
|
||||
sm.getDefaultSensor(Sensor.TYPE_LINEAR_ACCELERATION)?.let {
|
||||
sm.registerListener(this, it, SensorManager.SENSOR_DELAY_GAME)
|
||||
Log.d(TAG, "Accelerometer registered")
|
||||
}
|
||||
sm.getDefaultSensor(Sensor.TYPE_GYROSCOPE)?.let {
|
||||
sm.registerListener(this, it, SensorManager.SENSOR_DELAY_GAME)
|
||||
Log.d(TAG, "Gyroscope registered")
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Sensor registration failed (non-critical): ${e.message}")
|
||||
}
|
||||
|
||||
// Flush buffer every 10 seconds
|
||||
fun scheduleFlush() {
|
||||
flushHandler.postDelayed({
|
||||
|
|
@ -118,6 +149,37 @@ class GpsRecorderService : Service(), LocationListener {
|
|||
}, 10000)
|
||||
}
|
||||
scheduleFlush()
|
||||
|
||||
// Dead reckoning: if GPS silent 60+ sec but accel shows movement → emit estimated point
|
||||
fun scheduleDeadReckoning() {
|
||||
flushHandler.postDelayed({
|
||||
if (!isRunning) return@postDelayed
|
||||
try {
|
||||
val now = System.currentTimeMillis()
|
||||
val gpsSilent = lastTime > 0 && (now - lastTime) > 60000
|
||||
if (gpsSilent && lastLat != 0.0) {
|
||||
// Check if accelerometer shows movement
|
||||
val rms: Float
|
||||
synchronized(sensorLock) {
|
||||
rms = if (accelSamples.isNotEmpty()) {
|
||||
val sumSq = accelSamples.fold(0.0) { s, v -> s + v * v }
|
||||
sqrt(sumSq / accelSamples.size).toFloat()
|
||||
} else 0f
|
||||
}
|
||||
if (rms > 2f) {
|
||||
// Person is moving but GPS is dead → write point at last known position
|
||||
val sensorData = consumeSensorData()
|
||||
val line = """{"lat":$lastLat,"lon":$lastLon,"alt":0,"speed":0,"acc":999,"ts":$now${sensorData},"estimated":true}"""
|
||||
synchronized(bufferLock) { pointBuffer.add(line) }
|
||||
pointCount++
|
||||
Log.d(TAG, "Dead reckoning point #$pointCount (GPS silent ${(now-lastTime)/1000}s, accel=$rms)")
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {}
|
||||
scheduleDeadReckoning()
|
||||
}, 30000)
|
||||
}
|
||||
scheduleDeadReckoning()
|
||||
} catch (e: SecurityException) {
|
||||
Log.e(TAG, "GPS permission denied", e)
|
||||
stopSelf()
|
||||
|
|
@ -135,27 +197,126 @@ class GpsRecorderService : Service(), LocationListener {
|
|||
|
||||
private var pointCount = 0
|
||||
|
||||
override fun onSensorChanged(event: SensorEvent?) {
|
||||
if (event == null) return
|
||||
try {
|
||||
when (event.sensor.type) {
|
||||
Sensor.TYPE_LINEAR_ACCELERATION -> {
|
||||
val mag = sqrt(event.values[0] * event.values[0] +
|
||||
event.values[1] * event.values[1] +
|
||||
event.values[2] * event.values[2])
|
||||
synchronized(sensorLock) {
|
||||
accelSamples.add(mag)
|
||||
if (mag > accelMax) accelMax = mag
|
||||
}
|
||||
}
|
||||
Sensor.TYPE_GYROSCOPE -> {
|
||||
synchronized(sensorLock) {
|
||||
gyroPitchSum += event.values[0].toDouble()
|
||||
gyroYawSum += event.values[2].toDouble()
|
||||
gyroSamples++
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// Sensor error — ignore, don't crash GPS recording
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {}
|
||||
|
||||
private fun consumeSensorData(): String {
|
||||
try { return consumeSensorDataInner() } catch (e: Exception) { return "" }
|
||||
}
|
||||
|
||||
private fun consumeSensorDataInner(): String {
|
||||
synchronized(sensorLock) {
|
||||
val rms = if (accelSamples.isNotEmpty()) {
|
||||
val sumSq = accelSamples.fold(0.0) { s, v -> s + v * v }
|
||||
sqrt(sumSq / accelSamples.size).toFloat()
|
||||
} else 0f
|
||||
val max = accelMax
|
||||
val pitch = if (gyroSamples > 0) (gyroPitchSum / gyroSamples) else 0.0
|
||||
val yaw = if (gyroSamples > 0) (gyroYawSum / gyroSamples) else 0.0
|
||||
// Reset
|
||||
accelSamples.clear()
|
||||
accelMax = 0f
|
||||
gyroPitchSum = 0.0
|
||||
gyroYawSum = 0.0
|
||||
gyroSamples = 0
|
||||
return if (rms > 0f || pitch != 0.0)
|
||||
""","accel_rms":${"%.3f".format(java.util.Locale.US, rms)},"accel_max":${"%.3f".format(java.util.Locale.US, max)},"gyro_pitch":${"%.4f".format(java.util.Locale.US, pitch)},"gyro_yaw":${"%.4f".format(java.util.Locale.US, yaw)}"""
|
||||
else ""
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
override fun onLocationChanged(location: Location) {
|
||||
Log.v(TAG, "Location: acc=${location.accuracy} spd=${location.speed} lat=${location.latitude}")
|
||||
if (location.accuracy > maxAccuracyM) return
|
||||
if (location.speed < minSpeedMs) return
|
||||
if (location.speed > maxSpeedMs) return
|
||||
if (lastTime > 0) {
|
||||
val results = FloatArray(1)
|
||||
Location.distanceBetween(lastLat, lastLon, location.latitude, location.longitude, results)
|
||||
if (results[0] < minDistanceM) return
|
||||
|
||||
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
|
||||
|
||||
// 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
|
||||
} 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 = location.time
|
||||
|
||||
pointCount++
|
||||
val line = """{"lat":${location.latitude},"lon":${location.longitude},"alt":${location.altitude},"speed":${location.speed},"acc":${location.accuracy},"ts":${location.time}}"""
|
||||
val line = """{"lat":${location.latitude},"lon":${location.longitude},"alt":${location.altitude},"speed":${location.speed},"acc":${location.accuracy},"ts":${now}${sensorData}}"""
|
||||
synchronized(bufferLock) {
|
||||
pointBuffer.add(line)
|
||||
}
|
||||
Log.d(TAG, "Buffered point #$pointCount: ${location.latitude},${location.longitude} spd=${location.speed} (buf=${pointBuffer.size})")
|
||||
Log.d(TAG, "Buffered point #$pointCount: ${location.latitude},${location.longitude} spd=${location.speed} acc=${location.accuracy} (buf=${pointBuffer.size})")
|
||||
}
|
||||
|
||||
private fun flushBuffer() {
|
||||
|
|
@ -185,6 +346,7 @@ class GpsRecorderService : Service(), LocationListener {
|
|||
flushHandler.removeCallbacksAndMessages(null)
|
||||
flushBuffer() // flush remaining points
|
||||
locationManager?.removeUpdates(this)
|
||||
sensorManager?.unregisterListener(this)
|
||||
wakeLock?.let { if (it.isHeld) it.release() }
|
||||
stopForeground(true)
|
||||
super.onDestroy()
|
||||
|
|
|
|||
|
|
@ -317,15 +317,35 @@ class MainActivity : TauriActivity() {
|
|||
try {
|
||||
val versionName = packageManager.getPackageInfo(packageName, 0).versionName ?: "0.0.0"
|
||||
val localBuild = versionName.split(".").lastOrNull()?.toIntOrNull() ?: 0
|
||||
val conn = URL("https://4aiman.top/baldav/BUILD_NUMBER").openConnection() as HttpsURLConnection
|
||||
conn.connectTimeout = 5000
|
||||
conn.readTimeout = 5000
|
||||
val remoteBuild = conn.inputStream.bufferedReader().readText().trim().toIntOrNull() ?: 0
|
||||
conn.disconnect()
|
||||
|
||||
// Try LAN router first (fast), then VPS (slow)
|
||||
var remoteBuild = 0
|
||||
var updateUrl = ""
|
||||
val sources = listOf(
|
||||
"http://192.168.1.1:88/baldav" to 2000,
|
||||
"https://4aiman.top/baldav" to 5000
|
||||
)
|
||||
for ((baseUrl, timeout) in sources) {
|
||||
try {
|
||||
val conn = URL("$baseUrl/BUILD_NUMBER").openConnection() as java.net.HttpURLConnection
|
||||
conn.connectTimeout = timeout
|
||||
conn.readTimeout = timeout
|
||||
val build = conn.inputStream.bufferedReader().readText().trim().toIntOrNull() ?: 0
|
||||
conn.disconnect()
|
||||
if (build > remoteBuild) {
|
||||
remoteBuild = build
|
||||
updateUrl = "$baseUrl/baldav.apk"
|
||||
}
|
||||
if (build > localBuild) break // found update, no need to check further
|
||||
} catch (e: Exception) {
|
||||
Log.d("BalDav", "Update check $baseUrl failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
val infoFile = File(appDataDir, "update_info.json")
|
||||
val ts = System.currentTimeMillis()
|
||||
infoFile.writeText("""{"local":$localBuild,"remote":$remoteBuild,"ts":$ts}""")
|
||||
Log.d("BalDav", "Update check: local=$localBuild remote=$remoteBuild")
|
||||
infoFile.writeText("""{"local":$localBuild,"remote":$remoteBuild,"url":"$updateUrl","ts":$ts}""")
|
||||
Log.d("BalDav", "Update check: local=$localBuild remote=$remoteBuild url=$updateUrl")
|
||||
} catch (e: Exception) {
|
||||
Log.d("BalDav", "Update check failed: ${e.message}")
|
||||
val infoFile = File(appDataDir, "update_info.json")
|
||||
|
|
|
|||
|
|
@ -467,6 +467,23 @@ pub fn ble_is_connected(
|
|||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn ble_clear_hr(app: tauri::AppHandle) -> Result<(), String> {
|
||||
use tauri::Manager;
|
||||
let data_dir = app.path().app_data_dir().map_err(|e: tauri::Error| e.to_string())?;
|
||||
// Clear HR data file so stale values don't show on next session
|
||||
let _ = std::fs::write(data_dir.join("ble_hr_data.json"), "{}");
|
||||
if let Some(root) = data_dir.parent().and_then(|p| p.parent()) {
|
||||
let _ = std::fs::write(root.join("ble_hr_data.json"), "{}");
|
||||
}
|
||||
#[cfg(not(target_os = "android"))]
|
||||
{
|
||||
let mut state = BLE_STATE.lock().unwrap();
|
||||
state.latest_hr = None;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn ble_save_device(app: tauri::AppHandle, address: String, device_type: String) -> Result<(), String> {
|
||||
use tauri::Manager;
|
||||
|
|
|
|||
|
|
@ -533,7 +533,7 @@ pub fn run() {
|
|||
.add_migrations("sqlite:baldav.db", migrations)
|
||||
.build(),
|
||||
)
|
||||
.invoke_handler(tauri::generate_handler![get_safe_insets, import_seed_products, get_app_info, gps_service_command, gps_service_read_points, save_file, get_update_info, ble::ble_scan, ble::ble_scan_results, ble::ble_connect, ble::ble_disconnect, ble::ble_get_hr, ble::ble_is_connected, ble::ble_check_bluetooth, ble::ble_save_device])
|
||||
.invoke_handler(tauri::generate_handler![get_safe_insets, import_seed_products, get_app_info, gps_service_command, gps_service_read_points, save_file, get_update_info, ble::ble_scan, ble::ble_scan_results, ble::ble_connect, ble::ble_disconnect, ble::ble_get_hr, ble::ble_is_connected, ble::ble_check_bluetooth, ble::ble_save_device, ble::ble_clear_hr])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"$schema": "https://raw.githubusercontent.com/nicegui/tauri-v2/refs/heads/main/tooling/cli/schema.json",
|
||||
"productName": "BalDav",
|
||||
"version": "0.1.224",
|
||||
"version": "0.1.244",
|
||||
"identifier": "dev.baldav.app",
|
||||
"build": {
|
||||
"frontendDist": "../frontend"
|
||||
|
|
@ -9,7 +9,7 @@
|
|||
"app": {
|
||||
"withGlobalTauri": true,
|
||||
"security": {
|
||||
"csp": "default-src 'self' 'unsafe-inline' 'unsafe-eval'; connect-src 'self' https://4aiman.top https://*.tile.openstreetmap.org https://*.tile.thunderforest.com; img-src 'self' blob: data: https://*.tile.openstreetmap.org https://*.tile.thunderforest.com"
|
||||
"csp": "default-src 'self' 'unsafe-inline' 'unsafe-eval'; connect-src 'self' https://4aiman.top https://*.tile.openstreetmap.org https://*.tile.thunderforest.com https://api.open-meteo.com; img-src 'self' blob: data: https://*.tile.openstreetmap.org https://*.tile.thunderforest.com"
|
||||
},
|
||||
"windows": [
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue