From 7c93bb75c3f92bb1b476381d26c0753f9369222b Mon Sep 17 00:00:00 2001 From: 4aiman Date: Sun, 7 Jun 2026 01:36:38 +0300 Subject: [PATCH] OTA updates, BLE HR, water tracking, GPS altitude filter, BMR fix, nutrient units OTA: VPS nginx endpoint, build.sh -u upload flag, Kotlin check + browser download BLE HR: FitPro protocol (PAIR/CCCD/HR_SWITCH/REALTIME/MEASURE), GATT queue, ACK responses, auto-detect device type, auto-start with GPS, re-trigger 25s Kotlin BleHrService, btleplug for desktop, file IPC for Android Water: products.water_pct column, getDailyWaterMl(), progress bar on today screen GPS altitude: outlier spike filter (>4m), stuck run interpolation (5+ identical), configurable smoothing (altSmoothHalf setting), filtered raw for kcal calc BMR: removed broken estimateRealBmr, added activity level dropdown (1.2-1.725), fixed 7-day trend using dailyBase instead of raw bmr Nutrients: ginseng/resveratrol/coq10 columns, fixed folate/selenium units (removed mult:1000), iodine/chromium input in mcg, addSupplement now inserts all 41 columns UI: parseAmount() for 4*30 expressions, recipe portion amount field, nutrient reserves strip, GPS sessions in today view, pan on charts, showToast(), HR norms in settings, HR on recording screen, HR chart in track details Scrapers: 5ka Load JSON button, magnit_scraper.user.js (NUXT_DATA parsing) --- frontend/css/styles.css | 17 + frontend/js/bmr.js | 34 +- frontend/js/db.js | 199 ++++- frontend/js/gps.js | 166 ++++- frontend/js/main.js | 683 ++++++++++++++++-- src-tauri/Cargo.lock | 360 +++++++-- src-tauri/Cargo.toml | 10 +- .../android/app/src/main/AndroidManifest.xml | 56 ++ .../main/java/dev/baldav/app/BleHrService.kt | 468 ++++++++++++ .../main/java/dev/baldav/app/MainActivity.kt | 199 ++++- .../app/src/main/res/xml/file_paths.xml | 6 + src-tauri/src/ble.rs | 499 +++++++++++++ src-tauri/src/lib.rs | 24 +- src-tauri/tauri.conf.json | 5 +- 14 files changed, 2545 insertions(+), 181 deletions(-) create mode 100644 src-tauri/gen/android/app/src/main/AndroidManifest.xml create mode 100644 src-tauri/gen/android/app/src/main/java/dev/baldav/app/BleHrService.kt create mode 100644 src-tauri/gen/android/app/src/main/res/xml/file_paths.xml create mode 100644 src-tauri/src/ble.rs diff --git a/frontend/css/styles.css b/frontend/css/styles.css index 00a1632..b15b6da 100644 --- a/frontend/css/styles.css +++ b/frontend/css/styles.css @@ -1090,6 +1090,15 @@ select { text-transform: uppercase; } +.rec-hr-row { display:flex; align-items:baseline; justify-content:center; gap:8px; padding:6px 8px; background:var(--surface); border-top:1px solid var(--border); } +.rec-hr-val { font-size:42px; font-weight:700; } +.rec-hr-unit { font-size:14px; color:var(--muted); } +.rec-hr-extra { font-size:14px; color:var(--muted); margin-left:12px; } +.hr-zone-green { color:#81c784; } +.hr-zone-yellow { color:#ffb74d; } +.hr-zone-orange { color:#ff8a65; } +.hr-zone-red { color:#ef5350; } + .rec-bottom { display: flex; align-items: center; @@ -1539,6 +1548,14 @@ select { margin-left: 0; transition: width 0.3s; } +.drink-bar-wrap { padding: 6px 14px; display: flex; align-items: center; gap: 8px; } +.drink-label { font-size: 12px; color: var(--muted); white-space: nowrap; } +.drink-bar { flex: 1; height: 6px; background: var(--surface); border-radius: 3px; overflow: hidden; } +.drink-bar-fill { height: 100%; border-radius: 3px; transition: width 0.3s; } +.drink-bar-fill.low { background: #cf6679; } +.drink-bar-fill.mid { background: #ffb74d; } +.drink-bar-fill.ok { background: #81c784; } + .seed-count { color: var(--muted); font-size: 13px; diff --git a/frontend/js/bmr.js b/frontend/js/bmr.js index b580627..ea8e869 100644 --- a/frontend/js/bmr.js +++ b/frontend/js/bmr.js @@ -11,22 +11,6 @@ function getAge(birthYear) { return new Date().getFullYear() - birthYear; } -// Estimate real daily expenditure from weight change + food intake. -// weights: [{date, weight_kg}], foodTotals: [{date, kcal}] -// Returns kcal/day or null if insufficient data. -// Formula: realExpenditure = (totalIntake + weightDelta_kg * 7700) / days -function estimateRealBmr(weights, foodTotals) { - if (!weights || weights.length < 2 || !foodTotals || foodTotals.length < 3) return null; - const firstW = weights[0]; - const lastW = weights[weights.length - 1]; - const days = Math.round((new Date(lastW.date) - new Date(firstW.date)) / 86400000); - if (days < 3) return null; - const dW = parseFloat(lastW.weight_kg) - parseFloat(firstW.weight_kg); // negative = lost weight - const totalIntake = foodTotals.reduce((s, d) => s + (d.kcal ?? 0), 0); - const realExpend = (totalIntake + dW * 7700) / days; - return realExpend > 500 ? Math.round(realExpend) : null; // sanity: < 500 kcal/day is wrong data -} - // RDI loaded from DB (rdi_standards + rdi_values tables). // Fallback hardcoded RU values used until DB is ready. let RDI_TYPES = {}; // { nutrient_key: "rdi" | "aup" } @@ -63,7 +47,23 @@ async function loadRDI(standardCode) { } catch (e) { console.error("[BMR] loadRDI error:", e); } } -window.BMR = { calculateBmr, getAge, estimateRealBmr, loadRDI, +// Nutrient half-life data loaded from DB +let HALFLIFE = {}; // { nutrient_key: halflife_avg_days } + +async function loadHalflife() { + try { + const rows = await window.__TAURI__.core.invoke("plugin:sql|select", { + db: "sqlite:baldav.db", + query: "SELECT nutrient_key, halflife_avg_days FROM nutrient_halflife", + values: [], + }); + HALFLIFE = {}; + for (const r of rows) HALFLIFE[r.nutrient_key] = r.halflife_avg_days; + } catch (e) { console.error("[BMR] loadHalflife error:", e); } +} + +window.BMR = { calculateBmr, getAge, loadRDI, loadHalflife, get RDI() { return RDI; }, get RDI_TYPES() { return RDI_TYPES; }, + get HALFLIFE() { return HALFLIFE; }, }; diff --git a/frontend/js/db.js b/frontend/js/db.js index ed75436..5825623 100644 --- a/frontend/js/db.js +++ b/frontend/js/db.js @@ -215,7 +215,7 @@ async function getRecipeIngredients(recipeId) { 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, - cm.name as cooking_method_name + p.water_pct, cm.name as cooking_method_name FROM recipe_ingredients ri LEFT JOIN products p ON ri.product_id = p.id AND ri.product_id > 0 LEFT JOIN cooking_methods cm ON ri.cooking_method_id = cm.id @@ -293,7 +293,7 @@ async function calculateRecipeNutrition(recipeId) { "selenium_mcg", "potassium_mg", "sodium_mg", ]; - const totals = { kcal: 0, protein: 0, fat: 0, carbs: 0, fiber: 0 }; + const totals = { kcal: 0, protein: 0, fat: 0, carbs: 0, fiber: 0, water_ml: 0 }; for (const mk of microKeys) totals[mk] = 0; for (const ing of ingredients) { @@ -309,6 +309,7 @@ async function calculateRecipeNutrition(recipeId) { fiber: (ing.fiber_per_100g ?? 0) * factor, }; for (const mk of microKeys) raw[mk] = (ing[mk] ?? 0) * factor; + raw.water_ml = (ing.water_pct ?? 0) * (ing.amount_g || 0) / 100; // Apply cooking loss: use ingredient's method, or recipe's default const cmId = ing.cooking_method_id || recipe.cooking_method_id; @@ -346,19 +347,27 @@ async function calculateRecipeNutrition(recipeId) { for (const mk of microKeys) { result[mk] = Math.round(totals[mk] / servings * 100) / 100; } + result.water_ml = Math.round(totals.water_ml / servings); return result; } // Log a recipe as a food entry — stores calculated nutrition per serving -async function addFoodFromRecipe(date, time, recipeId) { +async function addFoodFromRecipe(date, time, recipeId, customAmountG) { const nutr = await calculateRecipeNutrition(recipeId); if (!nutr) return; + const servingG = Math.round(nutr.totalWeight / nutr.servings); + const amountG = customAmountG ?? servingG; + const scale = servingG > 0 ? amountG / servingG : 1; await execute( `INSERT INTO food_log (date, time, recipe_id, amount_g, kcal, protein_g, fat_g, carbs_g, fiber_g, note) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)`, - [date, time, recipeId, Math.round(nutr.totalWeight / nutr.servings), - nutr.kcal, nutr.protein, nutr.fat, nutr.carbs, nutr.fiber, null] + [date, time, recipeId, amountG, + Math.round(nutr.kcal * scale), + Math.round(nutr.protein * scale * 10) / 10, + Math.round(nutr.fat * scale * 10) / 10, + Math.round(nutr.carbs * scale * 10) / 10, + Math.round(nutr.fiber * scale * 10) / 10, null] ); } @@ -418,14 +427,16 @@ async function addFoodEntry(date, time, productId, amountG, note, unitId, unitAm } async function updateFoodEntry(id, time, amountG, note, kcalOverride, unitId, unitAmount) { - // Recalculate nutrition from product if linked - const rows = await select("SELECT product_id FROM food_log WHERE id = ?1", [id]); + // Recalculate nutrition from product or recipe + const rows = await select( + "SELECT product_id, recipe_id, amount_g, kcal, protein_g, fat_g, carbs_g, fiber_g FROM food_log WHERE id = ?1", [id]); if (!rows.length) return; - const productId = rows[0].product_id; + const row = rows[0]; let kcal = kcalOverride, protein = null, fat = null, carbs = null, fiber = null; - if (productId && kcalOverride == null) { - const p = await getProduct(productId); + if (row.product_id && kcalOverride == null) { + // Product: recalculate from kcal_per_100g + const p = await getProduct(row.product_id); if (p && p.kcal_per_100g != null) { const f = amountG / 100; kcal = Math.round((p.kcal_per_100g ?? 0) * f); @@ -434,6 +445,14 @@ async function updateFoodEntry(id, time, amountG, note, kcalOverride, unitId, un carbs = Math.round((p.carbs_per_100g ?? 0) * f * 10) / 10; fiber = Math.round((p.fiber_per_100g ?? 0) * f * 10) / 10; } + } else if (row.recipe_id && kcalOverride == null && row.amount_g > 0) { + // Recipe: scale proportionally to amount change + const scale = amountG / row.amount_g; + kcal = Math.round((row.kcal ?? 0) * scale); + protein = Math.round((row.protein_g ?? 0) * scale * 10) / 10; + fat = Math.round((row.fat_g ?? 0) * scale * 10) / 10; + carbs = Math.round((row.carbs_g ?? 0) * scale * 10) / 10; + fiber = Math.round((row.fiber_g ?? 0) * scale * 10) / 10; } await execute( `UPDATE food_log SET time=?1, amount_g=?2, note=?3, @@ -477,23 +496,23 @@ async function getSupplements() { } async function addSupplement(s) { + const cols = [ + "name", "daily_dose_g", + "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_b5_mg", "vit_b6_mg", + "vit_b7_mcg", "vit_b12_mcg", "folate_mcg", + "calcium_mg", "iron_mg", "magnesium_mg", "zinc_mg", "copper_mg", + "selenium_mcg", "potassium_mg", "sodium_mg", "phosphorus_mg", + "iodine_mg", "chromium_mg", "manganese_mg", "molybdenum_mcg", + "silicon_mg", "vanadium_mg", "lutein_mg", "lycopene_mg", "carotene_mcg", + "fish_oil_mg", "omega3_mg", "epa_mg", "dha_mg", "omega6_mg", "omega9_mg", + "ginseng_mg", "resveratrol_mg", "coq10_mg", + ]; + const placeholders = cols.map((_, i) => `?${i + 1}`).join(","); + const vals = cols.map(c => s[c] ?? null); const r = await execute( - `INSERT INTO supplements - (name, daily_dose_g, 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) - VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16,?17,?18,?19,?20,?21)`, - [ - s.name, s.daily_dose_g ?? null, - s.vit_a_mcg ?? null, s.vit_c_mg ?? null, s.vit_d_mcg ?? null, - s.vit_e_mg ?? null, s.vit_k_mcg ?? null, - s.vit_b1_mg ?? null, s.vit_b2_mg ?? null, s.vit_b3_mg ?? null, - s.vit_b6_mg ?? null, s.vit_b12_mcg ?? null, s.folate_mcg ?? null, - s.calcium_mg ?? null, s.iron_mg ?? null, s.magnesium_mg ?? null, - s.zinc_mg ?? null, s.copper_mg ?? null, s.selenium_mcg ?? null, - s.potassium_mg ?? null, s.sodium_mg ?? null, - ] + `INSERT INTO supplements (${cols.join(",")}) VALUES (${placeholders})`, + vals ); return r.lastInsertId; } @@ -656,11 +675,12 @@ async function createGpsSession(date, startTime, activityType) { return id; } -async function addGpsPoint(sessionId, ts, lat, lon, altGps, speedMs, seq) { +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) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)`, - [sessionId, ts, lat, lon, altGps ?? null, speedMs ?? null, seq ?? null] + `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] ); } @@ -680,6 +700,13 @@ async function getGpsSessions(limit) { ); } +async function getGpsSessionsForDate(date) { + return select( + "SELECT * FROM gps_sessions WHERE date = ?1 ORDER BY start_time ASC", + [date] + ); +} + async function getGpsSession(id) { const rows = await select("SELECT * FROM gps_sessions WHERE id = ?1", [id]); return rows.length ? rows[0] : null; @@ -957,6 +984,7 @@ window.DB.createGpsSession = createGpsSession; window.DB.addGpsPoint = addGpsPoint; window.DB.finishGpsSession = finishGpsSession; window.DB.getGpsSessions = getGpsSessions; +window.DB.getGpsSessionsForDate = getGpsSessionsForDate; window.DB.getGpsSession = getGpsSession; window.DB.getGpsPoints = getGpsPoints; window.DB.deleteGpsSession = deleteGpsSession; @@ -979,6 +1007,74 @@ window.DB.deleteGoalMarker = deleteGoalMarker; window.DB.getFrequentFoods = getFrequentFoods; window.DB.getGoalProgress = getGoalProgress; +// --------------------------------------------------------------------------- +// Nutrient reserves (accumulated body stores) +// --------------------------------------------------------------------------- + +async function getReserves(date) { + const rows = await select("SELECT reserves FROM nutrient_reserves WHERE date = ?1", [date]); + if (!rows.length || !rows[0].reserves) return {}; + try { return JSON.parse(rows[0].reserves); } catch { return {}; } +} + +async function saveReserves(date, reserves) { + await execute( + `INSERT INTO nutrient_reserves (date, reserves) VALUES (?1, ?2) + ON CONFLICT(date) DO UPDATE SET reserves = ?2`, + [date, JSON.stringify(reserves)] + ); +} + +async function getAllReservesDates() { + return select("SELECT date FROM nutrient_reserves ORDER BY date"); +} + +async function getDaysWithFood() { + return select( + `SELECT DISTINCT date FROM ( + SELECT date FROM food_log + UNION SELECT date FROM supplement_log + ) ORDER BY date` + ); +} + +async function getDailyWaterMl(date) { + // From products with water_pct + const prodRows = await select( + `SELECT COALESCE(SUM(fl.amount_g * p.water_pct / 100.0), 0) as ml + FROM food_log fl JOIN products p ON fl.product_id = p.id + WHERE fl.date = ?1 AND p.water_pct IS NOT NULL AND p.water_pct > 0`, + [date] + ); + let total = prodRows[0]?.ml || 0; + // From recipes (water_ml in cached_nutrients) + 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 + FROM food_log fl JOIN recipes r ON fl.recipe_id = r.id + WHERE fl.date = ?1 AND r.cached_nutrients IS NOT NULL`, + [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; + } + } catch {} + } + return Math.round(total); +} + +window.DB.getReserves = getReserves; +window.DB.saveReserves = saveReserves; +window.DB.getAllReservesDates = getAllReservesDates; +window.DB.getDaysWithFood = getDaysWithFood; +window.DB.getDailyWaterMl = getDailyWaterMl; + // JS-side migrations — safe for any DB state (checks before altering) async function runMigrations() { // Batch column checks — one PRAGMA per table instead of N separate IPC calls @@ -988,9 +1084,17 @@ async function runMigrations() { await addProdCol("name_lower", "TEXT"); await addProdCol("nutrients_guessed", "INTEGER DEFAULT 0"); await addProdCol("user_edited", "INTEGER DEFAULT 0"); + await addProdCol("ginseng_mg", "REAL"); + await addProdCol("resveratrol_mg", "REAL"); + await addProdCol("coq10_mg", "REAL"); + + // New supplement columns (ginseng, resveratrol, coq10) — added after existing suppCols migration below const gpsCols = (await select("PRAGMA table_info(gps_points)")).map(r => r.name); if (!gpsCols.includes("seq")) await execute("ALTER TABLE gps_points ADD COLUMN seq INTEGER"); + 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"); // --- Cooking methods table --- await execute(`CREATE TABLE IF NOT EXISTS cooking_methods ( @@ -1160,6 +1264,9 @@ async function runMigrations() { await addSuppCol("dha_mg", "REAL"); // DHA (докозагексаеновая) await addSuppCol("omega6_mg", "REAL"); // омега-6 await addSuppCol("omega9_mg", "REAL"); // омега-9 + await addSuppCol("ginseng_mg", "REAL"); // женьшень + await addSuppCol("resveratrol_mg", "REAL"); // ресвератрол + await addSuppCol("coq10_mg", "REAL"); // коэнзим Q10 // Product dedup handled in Rust import_seed_products (UNIQUE INDEX on name_lower+source+store_id) @@ -1299,6 +1406,38 @@ async function runMigrations() { console.log("[db.js] Seeded nutrient half-life data (32 nutrients)"); } + // Nutrient reserves table — accumulated body stores per day + await execute(`CREATE TABLE IF NOT EXISTS nutrient_reserves ( + date TEXT PRIMARY KEY, + reserves TEXT + )`); + + // Water content column for hydration tracking + if (!productCols.includes("water_pct")) { + await execute("ALTER TABLE products ADD COLUMN water_pct REAL"); + // Seed water_pct for known liquid products by name pattern + const waterPatterns = [ + [100, ["% вода %", "% вода", "вода %", "вода питьевая%", "вода минеральная%", "вода газированная%"]], + [99, ["%чай %", "%чай", "чай %"]], + [99, ["%кофе %", "%кофе", "кофе %"]], + [87, ["%молоко %", "%молоко", "молоко %", "%кефир%", "%ряженка%", "%простокваша%", "%ацидофилин%"]], + [88, ["%сок %", "%сок", "сок %", "%нектар%"]], + [85, ["%компот%", "%морс%", "%кисель%", "%какао%напит%"]], + [90, ["%суп %", "%суп", "суп %", "%бульон%", "%борщ%", "%щи %", "%солянка%", "%рассольник%", "%уха %", "%окрошка%"]], + [89, ["%газировка%", "%кола %", "%кола", "%фанта%", "%спрайт%", "%лимонад%", "%тархун%", "%байкал%", "%напиток газ%"]], + [92, ["%пиво %", "%пиво", "пиво %", "%пивн%"]], + [93, ["%квас%"]], + [80, ["%смузи%"]], + ]; + for (const [pct, patterns] of waterPatterns) { + for (const p of patterns) { + await execute(`UPDATE products SET water_pct = ?1 WHERE water_pct IS NULL AND name_lower LIKE ?2`, [pct, p]); + } + } + const waterCount = (await select("SELECT COUNT(*) as cnt FROM products WHERE water_pct IS NOT NULL"))[0].cnt; + console.log(`[db.js] Seeded water_pct for ${waterCount} products`); + } + // Recipe cached nutrients columns const recipeCols2 = (await select("PRAGMA table_info(recipes)")).map(r => r.name); if (!recipeCols2.includes("cached_nutrients")) { diff --git a/frontend/js/gps.js b/frontend/js/gps.js index 89ed108..0ae6ee9 100644 --- a/frontend/js/gps.js +++ b/frontend/js/gps.js @@ -14,6 +14,7 @@ GPS.config = { airDrag: 0.6, // аэродинамическое сопротивление (CdA, м2). Город=0.6, спорт=0.4 airDensity: 1.225, // плотность воздуха (кг/м3). Уровень моря=1.225, горы=1.0 pedalEfficiency: 25, // КПД педалирования (%). Человек ~25% + altSmoothHalf: 2, // полуокно сглаживания высоты (точек). 2 = 5pt, 5 = 11pt, 7 = 15pt }; // Load saved config @@ -52,13 +53,53 @@ GPS.computeStats = function(points, weightKg, opts) { const speeds = []; // m/s per segment // 5-point moving average on altitude to kill GPS jitter (typically 5-15m noise) - const smoothedAlt = sorted.map((p, i) => { - const alt = p.altitude ?? p.altitude_gps_m; + // 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); + const filteredAlt = rawAlt.slice(); + for (let i = 1; i < filteredAlt.length - 1; i++) { + if (filteredAlt[i] == null) continue; + const prev = filteredAlt[i - 1], next = filteredAlt[i + 1]; + if (prev == null || next == null) continue; + const jumpFromPrev = Math.abs(filteredAlt[i] - prev); + const jumpFromNext = Math.abs(filteredAlt[i] - next); + const neighborDiff = Math.abs(next - prev); + // If this point jumps >4m from BOTH neighbors, but neighbors are close to each other — outlier + if (jumpFromPrev > 4 && jumpFromNext > 4 && neighborDiff < 3) { + filteredAlt[i] = (prev + next) / 2; + } + } + + // Step 1b: Fix "stuck" altitude — same value repeated 5+ times = GPS frozen, + // replace with linear interpolation between surrounding real values + { + let i = 0; + while (i < filteredAlt.length) { + if (filteredAlt[i] == null) { i++; continue; } + let j = i + 1; + while (j < filteredAlt.length && filteredAlt[j] != null + && Math.abs(filteredAlt[j] - filteredAlt[i]) < 0.2) j++; + if (j - i >= 5) { + // Stuck run from i to j-1. Interpolate using values before i and after j-1 + const before = i > 0 ? filteredAlt[i - 1] : filteredAlt[i]; + const after = j < filteredAlt.length ? filteredAlt[j] : filteredAlt[j - 1]; + for (let k = i; k < j; k++) { + const t = (k - i + 1) / (j - i + 1); + filteredAlt[k] = before + (after - before) * t; + } + } + i = j; + } + } + + // Step 2: Smooth filtered altitude + const SMOOTH_HALF = GPS.config.altSmoothHalf ?? 2; + const smoothedAlt = filteredAlt.map((alt, i) => { if (alt == null) return null; let sum = 0, cnt = 0; - for (let j = Math.max(0, i - 2); j <= Math.min(sorted.length - 1, i + 2); j++) { - const a = sorted[j].altitude ?? sorted[j].altitude_gps_m; - if (a != null) { sum += a; cnt++; } + for (let j = Math.max(0, i - SMOOTH_HALF); j <= Math.min(filteredAlt.length - 1, i + SMOOTH_HALF); j++) { + if (filteredAlt[j] != null) { sum += filteredAlt[j]; cnt++; } } return cnt > 0 ? sum / cnt : alt; }); @@ -98,7 +139,8 @@ GPS.computeStats = function(points, weightKg, opts) { const v = dt > 0 ? d / dt : 0; // m/s speeds.push(v); - // Elevation from smoothed altitude, with 1m dead zone + // 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; @@ -115,11 +157,11 @@ GPS.computeStats = function(points, weightKg, opts) { } if (isBike) { - // Gradient from RAW altitude (smoothed altitude kills real slopes and underestimates kcal) - const rawAlt = sorted[i].altitude ?? sorted[i].altitude_gps_m ?? 0; - const rawAltPrev = sorted[i-1].altitude ?? sorted[i-1].altitude_gps_m ?? 0; - const altDiff = rawAlt - rawAltPrev; - const gradient = d > 2 ? Math.max(-0.5, Math.min(0.5, altDiff / d)) : 0; // clamp to +-50% + // Gradient from filtered (outlier-removed) raw altitude + const altHere = filteredAlt[i] ?? 0; + const altPrev = filteredAlt[i-1] ?? 0; + const altDiff = altHere - altPrev; + const gradient = d > 2 ? Math.max(-0.5, Math.min(0.5, altDiff / d)) : 0; const sinG = gradient; const cosG = Math.sqrt(1 - sinG * sinG); @@ -311,6 +353,9 @@ GPS.recording = { points: [], startTime: null, activityType: "bike", + currentHr: null, + currentBleData: null, + hrPollInterval: null, }; // Recording states: waiting_gps → ready → recording (auto on first movement) @@ -335,6 +380,51 @@ GPS._stopNativeService = async function() { } catch {} }; +// 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() { + if (GPS.recording.hrPollInterval) return; + try { + const addr = await DB.getProfileValue("ble_device_addr"); + const type = await DB.getProfileValue("ble_device_type"); + if (!addr || !type) { console.log("[HR] No paired device"); return; } + + const connected = await window.__TAURI__.core.invoke("ble_is_connected"); + if (!connected) { + console.log("[HR] Connecting to", addr, type); + // Just send connect — Kotlin handles scan+connect internally + try { + await window.__TAURI__.core.invoke("ble_connect", { address: addr, deviceType: type }); + } catch (e) { + console.log("[HR] Connect failed:", e); + } + } + // Start polling HR regardless — data will appear when device connects + console.log("[HR] Starting HR poll"); + GPS.recording.hrPollInterval = setInterval(async () => { + try { + const reading = await window.__TAURI__.core.invoke("ble_get_hr"); + if (reading && reading.hr > 0) { + GPS.recording.currentHr = reading.hr; + GPS.recording.currentBleData = reading; + if (GPS.recording.onHrUpdate) GPS.recording.onHrUpdate(reading); + } + } catch {} + }, 2000); + } catch (e) { + console.log("[HR] Monitor start failed:", e); + } +}; + +GPS._stopHrMonitor = function() { + if (GPS.recording.hrPollInterval) { + clearInterval(GPS.recording.hrPollInterval); + GPS.recording.hrPollInterval = null; + } + GPS.recording.currentHr = null; + GPS.recording.currentBleData = null; +}; + // Read points from native service file GPS._readNativePoints = async function() { try { @@ -404,11 +494,15 @@ GPS._processIncomingPoint = async function(p) { return; } - // Auto-pause detection + // Auto-pause detection — still save HR data during pause if (!isMoving) { if (!GPS.recording.pauseStart) { GPS.recording.pauseStart = new Date(p.ts); } + // 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); + } if (GPS.recording.onPoint) GPS.recording.onPoint(p); return; } @@ -426,7 +520,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); + await DB.addGpsPoint(GPS.recording.sessionId, p.ts, p.lat, p.lon, p.altitude, p.speed_ms, GPS.recording._pointSeq, GPS.recording.currentBleData); } if (GPS.recording.onPoint) GPS.recording.onPoint(p); }; @@ -560,6 +654,9 @@ GPS.startRecording = async function(activityType, weightKg) { // Start native foreground service (Android) await GPS._startNativeService(); + // Start BLE HR monitor (auto-connect to paired device) + try { await GPS._startHrMonitor(); } catch (e) { console.error("[HR] start error:", e); } + // Poll native service output file if (GPS.recording.nativeService) { let polling = false; @@ -617,6 +714,7 @@ GPS.stopRecording = async function() { GPS.recording.active = false; if (GPS.recording._pollTimer) clearInterval(GPS.recording._pollTimer); + GPS._stopHrMonitor(); if (GPS.recording.watchId != null) { navigator.geolocation.clearWatch(GPS.recording.watchId); @@ -631,7 +729,7 @@ GPS.stopRecording = async function() { for (let i = GPS.recording._lastNativeCount; i < nativePoints.length; i++) { const p = nativePoints[i]; GPS.recording.points.push(p); - await DB.addGpsPoint(GPS.recording.sessionId, p.ts, p.lat, p.lon, p.altitude, p.speed_ms); + await DB.addGpsPoint(GPS.recording.sessionId, p.ts, p.lat, p.lon, p.altitude, p.speed_ms, null, null); } } await GPS._stopNativeService(); @@ -1044,5 +1142,45 @@ GPS.getRecordingOpts = function() { }; }; +// Filter outliers and smooth altitude array for chart display +GPS.filterAndSmoothAlt = function(rawAlts) { + const f = rawAlts.slice(); + // Outlier spikes + for (let i = 1; i < f.length - 1; i++) { + if (f[i] == null) continue; + const prev = f[i-1], next = f[i+1]; + if (prev == null || next == null) continue; + if (Math.abs(f[i] - prev) > 4 && Math.abs(f[i] - next) > 4 && Math.abs(next - prev) < 3) { + f[i] = (prev + next) / 2; + } + } + // Stuck runs: same value 5+ times = GPS frozen → interpolate + let i = 0; + while (i < f.length) { + if (f[i] == null) { i++; continue; } + let j = i + 1; + while (j < f.length && f[j] != null && Math.abs(f[j] - f[i]) < 0.2) j++; + if (j - i >= 5) { + const before = i > 0 ? f[i - 1] : f[i]; + const after = j < f.length ? f[j] : f[j - 1]; + for (let k = i; k < j; k++) { + const t = (k - i + 1) / (j - i + 1); + f[k] = before + (after - before) * t; + } + } + i = j; + } + // Smooth + const half = GPS.config.altSmoothHalf ?? 2; + return f.map((alt, idx) => { + if (alt == null) return null; + let sum = 0, cnt = 0; + for (let j = Math.max(0, idx - half); j <= Math.min(f.length - 1, idx + half); j++) { + if (f[j] != null) { sum += f[j]; cnt++; } + } + return cnt > 0 ? sum / cnt : alt; + }); +}; + window.GPS = GPS; console.log("[gps.js] window.GPS ready"); diff --git a/frontend/js/main.js b/frontend/js/main.js index 235984b..4a39bb8 100644 --- a/frontend/js/main.js +++ b/frontend/js/main.js @@ -3,13 +3,61 @@ const app = document.getElementById("app"); +const UPDATE_BASE_URL = "https://4aiman.top/baldav"; + let state = { screen: "today", date: todayIso(), profile: {}, latestWeight: null, + updateAvailable: null, // { remoteBuild: number } or null }; +// Parse amount string: supports "4*30" (=120), "2*150" (=300), "300+50" (=350), plain "250" +function parseAmount(str) { + if (!str) return NaN; + const s = str.toString().trim().replace(/,/g, ".").replace(/х/gi, "*").replace(/x/gi, "*"); + if (s.includes("*")) { + const parts = s.split("*").map(Number); + if (parts.length === 2 && parts.every(n => !isNaN(n))) return parts[0] * parts[1]; + return NaN; + } + if (s.includes("+")) { + const parts = s.split("+").map(Number); + if (parts.every(n => !isNaN(n))) return parts.reduce((a, b) => a + b, 0); + return NaN; + } + return parseFloat(s); +} + +// Check for app updates by comparing BUILD_NUMBER with remote +async function checkForUpdate() { + try { + // Kotlin checks BUILD_NUMBER via native HTTP (works through VPN/proxy) + // and writes result to update_info.json, Rust reads it + 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}`); + } else { + state.updateAvailable = null; + } + } catch (e) { + console.log("[Update] check failed:", e.message || e); + } +} + +async function startUpdate() { + if (!state.updateAvailable) return; + const url = UPDATE_BASE_URL + "/baldav.apk"; + try { + await window.__TAURI__.core.invoke("gps_service_command", { cmd: "open_url:" + url }); + } catch (e) { + console.error("[Update] open browser failed:", e); + } +} + // Navigation history stack — survives in-memory across screen changes. // When app is restored from background, we replay the stack into browser history. const NAV_MAX = 20; @@ -75,10 +123,11 @@ async function init() { // Recover any orphaned GPS recording session try { await GPS.recoverSession(); } catch (e) { /* ignore */ } - // Load RDI standard from profile (default: RU) + // Load RDI standard and half-life data from profile (default: RU) try { const rdiCode = (await DB.getProfileValue("rdi_standard")) || "RU"; await BMR.loadRDI(rdiCode); + await BMR.loadHalflife(); } catch (e) { /* RDI stays at fallback RU values */ } // Import seed products if needed — show progress only if actually importing @@ -110,6 +159,9 @@ async function init() { state.profile = await DB.getFullProfile(); state.latestWeight = await DB.getLatestWeight(); + // Migrate reserves for historical days (one-time) + try { await migrateReserves(); } catch (e) { console.error("[Reserves]", e); } + // Seed browser history from navStack so back gesture works after restart history.replaceState({ screen: navStack[0] }, ""); for (let i = 1; i < navStack.length; i++) { @@ -120,6 +172,9 @@ async function init() { // Restore last screen (fromPopstate=true to avoid duplicate navStack push) renderScreen(navStack[navStack.length - 1], true); + + // Check for updates in background (non-blocking) + checkForUpdate(); } // --------------------------------------------------------------------------- @@ -204,9 +259,9 @@ function buildNav(active) { async function renderToday(container) { container.innerHTML = `
Загрузка...
`; - let weights = [], food = [], activities = [], sleep = [], foodTotals = {}, actTotals = {}, suppLog = [], suppTotals = {}; + let weights = [], food = [], activities = [], sleep = [], foodTotals = {}, actTotals = {}, suppLog = [], suppTotals = {}, gpsSessions = []; try { - [weights, food, activities, sleep, foodTotals, actTotals, suppLog, suppTotals] = await Promise.all([ + [weights, food, activities, sleep, foodTotals, actTotals, suppLog, suppTotals, gpsSessions] = await Promise.all([ DB.getWeightsForDate(state.date), DB.getFoodForDate(state.date), DB.getActivitiesForDate(state.date), @@ -215,6 +270,7 @@ async function renderToday(container) { DB.getActivityTotalsForDate(state.date), DB.getSupplementLogForDate(state.date), DB.getSupplementTotalsForDate(state.date), + DB.getGpsSessionsForDate(state.date), ]); } catch (e) { console.error("[renderToday] DB error:", e); @@ -222,18 +278,12 @@ async function renderToday(container) { return; } - let bmr = calcBmr(weights); - // Try to estimate real BMR from actual weight changes + food data (14 day window) - try { - const recentWeights = await DB.getRecentWeights(30); - const recentFood = await DB.getDailyFoodTotals(30); - const realBmr = BMR.estimateRealBmr(recentWeights, recentFood); - if (realBmr) bmr = realBmr; - } catch {} + const bmr = calcBmr(weights); + const actLevel = parseFloat(state.profile.activity_level) || 1.2; + const dailyBase = bmr ? Math.round(bmr * actLevel) : null; const intake = Math.round(foodTotals.kcal ?? 0); const actKcal = Math.round(actTotals.kcal ?? 0); - // Show expenditure even without BMR if there are activities - const expend = bmr != null ? bmr + actKcal : actKcal > 0 ? actKcal : null; + const expend = dailyBase != null ? dailyBase + actKcal : actKcal > 0 ? actKcal : null; const balance = expend != null ? intake - expend : null; container.innerHTML = `
@@ -259,7 +309,10 @@ async function renderToday(container) {
-
+
+ +
+

Вес

@@ -317,6 +370,17 @@ async function renderToday(container) {

Активность

    + ${gpsSessions.map(s => { + const dist = s.total_distance_m ? (s.total_distance_m / 1000).toFixed(1) + " км" : ""; + const kcal = s.total_kcal ? Math.round(s.total_kcal) + " кк" : ""; + const dur = s.end_time && s.start_time ? s.start_time + "–" + s.end_time : s.start_time || ""; + return `
  • + ${dur} + ${dist}${dist && kcal ? " / " : ""}${kcal} + ${s.elevation_gain_m ? `+${Math.round(s.elevation_gain_m)}м` : ""} + ${s.avg_speed_kmh ? s.avg_speed_kmh.toFixed(1) + " км/ч" : ""} +
  • `; + }).join("")} ${activities.map(a => `
  • ${a.time ?? ""} @@ -325,7 +389,8 @@ async function renderToday(container) { ${a.kcal != null ? "−" + Math.round(a.kcal) + " кк" : ""}
  • ` - ).join("") || `
  • нет данных
  • `} + ).join("")} + ${!gpsSessions.length && !activities.length ? `
  • нет данных
  • ` : ""}
@@ -398,7 +463,7 @@ async function renderToday(container) { (async () => { try { const trendEl = container.querySelector("#balance-trend"); - if (!trendEl || bmr == null) return; + if (!trendEl || dailyBase == null) return; const [food7, act7] = await Promise.all([ DB.getDailyFoodTotals(7), DB.getDailyActivityTotals(7), @@ -413,7 +478,7 @@ async function renderToday(container) { for (const d of food7) { const dayIntake = foodMap[d.date] ?? 0; const dayAct = actMap[d.date] ?? 0; - const dayBalance = dayIntake - (bmr + dayAct); + const dayBalance = dayIntake - (dailyBase + dayAct); balances.push(dayBalance); } if (balances.length === 0) return; @@ -425,14 +490,67 @@ async function renderToday(container) { } catch (e) { console.error("[7d trend]", e); } })(); + // Fill drink bar + const drinkWrap = container.querySelector("#drink-bar-wrap"); + if (drinkWrap) { + (async () => { + try { + const waterMl = await DB.getDailyWaterMl(state.date); + const norm = parseInt(state.profile.daily_water_ml) || 2000; + const pct = Math.min(100, Math.round(waterMl / norm * 100)); + const cls = pct < 30 ? "low" : pct < 70 ? "mid" : "ok"; + drinkWrap.innerHTML = ` + ${waterMl} / ${norm} мл +
`; + } catch (e) { console.error("[DrinkBar]", e); } + })(); + } + // Fill nutrient strip (top of page, sorted by deficiency) const dailyNutEl = container.querySelector("#daily-nutrients"); if (dailyNutEl) { const nutrients = calcDailyNutrients(food, suppTotals); - dailyNutEl.innerHTML = renderNutrientChips(nutrients) + dailyNutEl.innerHTML = renderNutrientChips(nutrients, "Приход") || `
нет данных о нутриентах
`; - // Tap to expand/collapse - dailyNutEl.onclick = () => dailyNutEl.classList.toggle("nutrient-collapsed"); + dailyNutEl.onclick = () => { + dailyNutEl.classList.toggle("nutrient-collapsed"); + if (dailyNutEl.classList.contains("nutrient-collapsed")) localStorage.removeItem("nut_intake_open"); + else localStorage.setItem("nut_intake_open", "1"); + }; + } + + // Fill reserves strip (accumulated body stores from previous days) + const reservesEl = container.querySelector("#daily-reserves"); + if (reservesEl) { + (async () => { + try { + // Calculate and save today's reserves + const reserves = await calcReservesForDate(state.date); + if (reserves && Object.keys(reserves).length > 0) { + await DB.saveReserves(state.date, reserves); + const RDI = BMR.RDI; + const chips = []; + for (const [key, rdi] of Object.entries(RDI)) { + const amount = reserves[key] || 0; + if (amount <= 0) continue; + const pct = Math.round(amount / rdi * 100); + if (pct < 1) continue; + const label = NUTRIENT_LABELS[key] || key; + const normType = BMR.RDI_TYPES[key] || "rdi"; + chips.push({ pct, label, normType }); + } + chips.sort((a, b) => a.pct - b.pct); + reservesEl.innerHTML = renderNutrientChips(chips, "Запасы") || ""; + } else { + reservesEl.innerHTML = ""; + } + } catch (e) { console.error("[Reserves] render:", e); } + })(); + reservesEl.onclick = () => { + reservesEl.classList.toggle("nutrient-collapsed"); + if (reservesEl.classList.contains("nutrient-collapsed")) localStorage.removeItem("nut_reserves_open"); + else localStorage.setItem("nut_reserves_open", "1"); + }; } container.querySelector("#add-weight-btn").onclick = () => showWeightModal(); @@ -442,6 +560,19 @@ async function renderToday(container) { container.querySelector("#add-supp-btn").onclick = () => showLogSupplementModal(); container.querySelector("#add-sleep-btn").onclick = () => showSleepModal(sleep); + // GPS session clicks → open track details + container.querySelectorAll("[data-gps-session]").forEach(el => { + el.onclick = () => { + const sid = el.dataset.gpsSession; + renderScreen("recording"); + // After rendering, trigger session detail view + setTimeout(() => { + const sessionEl = document.querySelector(`[data-session-id="${sid}"]`); + if (sessionEl) sessionEl.click(); + }, 300); + }; + }); + // Stats charts section const statsContainer = container.querySelector("#stats-charts"); const statsPeriod = container.querySelector(".stats-period"); @@ -919,7 +1050,7 @@ function showFoodModal() {
- +
@@ -927,7 +1058,7 @@ function showFoodModal() {
`, async overlay => { const time = overlay.querySelector("#m-time").value; - const rawAmount = parseFloat(overlay.querySelector("#m-amount").value) || 100; + const rawAmount = parseAmount(overlay.querySelector("#m-amount").value) || 100; if (selectedProduct) { let amountG = rawAmount; @@ -1050,9 +1181,9 @@ function showEditFoodModal(entry) {
- +
- ${!entry.product_id ? ` + ${!entry.product_id && !entry.recipe_id ? `
@@ -1066,7 +1197,7 @@ function showEditFoodModal(entry) {
`, async overlay => { - const amount = parseFloat(overlay.querySelector("#m-amount").value); + const amount = parseAmount(overlay.querySelector("#m-amount").value); const time = overlay.querySelector("#m-time").value; const note = overlay.querySelector("#m-note")?.value.trim() || null; if (isNaN(amount) || amount <= 0) return false; @@ -1348,7 +1479,7 @@ async function renderProducts(container) {

Добавить в дневник

- +
@@ -1359,7 +1490,7 @@ async function renderProducts(container) {
`, async overlay => { - const amount = parseFloat(overlay.querySelector("#m-amount").value); + const amount = parseAmount(overlay.querySelector("#m-amount").value); const time = overlay.querySelector("#m-time").value; const note = overlay.querySelector("#m-note").value.trim() || null; if (isNaN(amount) || amount <= 0) return false; @@ -1633,7 +1764,12 @@ async function showRecipeDetailModal(recipeId) { })()} ` : ""} - +
+ + + ${nutr ? `1 порция = ${Math.round(nutr.totalWeight / (recipe.servings || 1))} г` : ""} +
+ @@ -1647,7 +1783,9 @@ async function showRecipeDetailModal(recipeId) { const delBtn = overlay.querySelector("#rd-delete"); logBtn.onclick = async () => { - await DB.addFoodFromRecipe(state.date, nowTime(), recipeId); + const amountG = parseAmount(overlay.querySelector("#rd-amount").value); + if (isNaN(amountG) || amountG <= 0) return; + await DB.addFoodFromRecipe(state.date, nowTime(), recipeId, amountG); if (winStack.length > 0) winStack.pop().close(); _skipPops = 1; history.back(); @@ -1966,6 +2104,11 @@ function showFoodFromRecipeModal() {

Записать рецепт

Загрузка...
+ + + +
+
@@ -1974,7 +2117,9 @@ function showFoodFromRecipeModal() { if (!selected) return false; const rid = parseInt(selected.dataset.rid); const time = overlay.querySelector("#rfr-time").value; - await DB.addFoodFromRecipe(state.date, time, rid); + const amountG = parseAmount(overlay.querySelector("#rfr-amount").value); + if (isNaN(amountG) || amountG <= 0) return false; + await DB.addFoodFromRecipe(state.date, time, rid, amountG); }); // Load recipes into the list @@ -2002,10 +2147,18 @@ function showFoodFromRecipeModal() { ` ).join(""); + const amountInput = overlay.querySelector("#rfr-amount"); + const hintEl = overlay.querySelector("#rfr-hint"); listEl.querySelectorAll(".recipe-pick-item").forEach(item => { item.onclick = () => { listEl.querySelectorAll(".recipe-pick-item").forEach(i => i.classList.remove("selected")); item.classList.add("selected"); + const r = items.find(x => x.id === parseInt(item.dataset.rid)); + if (r && r.nutr) { + const servingG = Math.round(r.nutr.totalWeight / (r.servings || 1)); + amountInput.value = servingG; + hintEl.textContent = `1 порция = ${servingG} г, ${r.nutr.kcal} кк`; + } }; }); })(); @@ -2021,6 +2174,19 @@ let gpsTrailLine = null; let gpsMarker = null; async function renderGpsScreen(container) { + // Check Bluetooth state if BLE device is paired + const bleAddr = await DB.getProfileValue("ble_device_addr"); + if (bleAddr) { + try { + const btState = await window.__TAURI__.core.invoke("ble_check_bluetooth"); + if (btState === "off") { + showToast("Bluetooth выключен"); + } else if (btState === "no_adapter") { + showToast("Bluetooth недоступен"); + } + } catch {} + } + const sessions = await DB.getGpsSessions(20); const isRec = GPS.recording.active; @@ -2309,6 +2475,12 @@ function renderRecordingInline(container) { container.innerHTML = `
+
0 мДистанция
0км/ч
@@ -2415,6 +2587,22 @@ function renderRecordingInline(container) { } }; + // HR update from BLE + GPS.recording.onHrUpdate = (reading) => { + const row = document.querySelector("#rec-hr-row"); + if (!row) return; + row.style.display = "flex"; + const hrEl = document.querySelector("#rec-hr-val"); + if (hrEl) { + hrEl.textContent = reading.hr; + hrEl.className = "rec-hr-val " + hrZoneClass(reading.hr); + } + const spo2El = document.querySelector("#rec-spo2"); + if (spo2El) spo2El.textContent = reading.spo2 ? `SpO2: ${reading.spo2}%` : ""; + const bpEl = document.querySelector("#rec-bp"); + if (bpEl) bpEl.textContent = (reading.bp_sys && reading.bp_dia) ? `${reading.bp_sys}/${reading.bp_dia}` : ""; + }; + // Timer — excludes pauses, starts only when recording const timer = setInterval(() => { if (!GPS.recording.active) { clearInterval(timer); return; } @@ -2681,7 +2869,7 @@ async function showSessionDetail(sessionId) { const graphMaxSpeed = smoothed.length ? Math.max(...smoothed) : 0; // Elevation data - const elevations = points.filter(p => p.altitude_gps_m != null).map(p => p.altitude_gps_m); + const elevations = GPS.filterAndSmoothAlt(points.map(p => p.altitude_gps_m)); const minElev = elevations.length ? Math.round(Math.min(...elevations)) : null; const maxElev = elevations.length ? Math.round(Math.max(...elevations)) : null; @@ -2752,6 +2940,7 @@ async function showSessionDetail(sessionId) {
${stats.elevLoss} мСпуск
+
` : ""} ${(stats.segments && stats.segments.length > 1) ? ` @@ -2845,7 +3034,11 @@ async function showSessionDetail(sessionId) { const alt = p.altitude_gps_m ?? p.altitude; const spd = p.speed_ms != null ? (p.speed_ms * 3.6).toFixed(1) + " km/h" : ""; const time = p.ts.split("T")[1].slice(0, 8); - const info = `#${i} ${time}${alt != null ? " | " + Math.round(alt) + "m" : ""}${spd ? " | " + spd : ""}`; + const hr = p.heart_rate ? `${p.heart_rate} bpm` : ""; + const spo2 = p.spo2 ? `SpO2:${p.spo2}%` : ""; + const bp = (p.bp_sys && p.bp_dia) ? `${p.bp_sys}/${p.bp_dia}` : ""; + const bleInfo = [hr, spo2, bp].filter(Boolean).join(" "); + const info = `#${i} ${time}${alt != null ? " | " + Math.round(alt) + "m" : ""}${spd ? " | " + spd : ""}${bleInfo ? " | " + bleInfo : ""}`; L.circleMarker([p.lat, p.lon], { radius: 8, weight: 0, fillOpacity: 0, interactive: true, }).bindPopup(info, { closeButton: false, className: "point-popup" }).addTo(map); @@ -2978,7 +3171,7 @@ async function showSessionDetail(sessionId) { return wrap && wrap.classList.contains("chart-full"); }, }, - pan: { enabled: false }, + pan: { enabled: true, mode: "x" }, }, }, scales: { @@ -3028,7 +3221,7 @@ async function showSessionDetail(sessionId) { return wrap && wrap.classList.contains("chart-full"); }, }, - pan: { enabled: false }, + pan: { enabled: true, mode: "x" }, }, }, scales: { @@ -3041,6 +3234,67 @@ async function showSessionDetail(sessionId) { }); }, 400); + // Render HR chart + setTimeout(() => { + 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"); + if (wrap) wrap.style.display = ""; + const ctx = document.querySelector("#sd-hr-chart"); + if (!ctx) return; + const dsHr = downsample(hrData, MAX_CHART_PTS); + const dsHrLabels = downsample( + points.map(p => ((new Date(p.ts).getTime() - t0) / 60000).toFixed(1)), + MAX_CHART_PTS + ); + new Chart(ctx, { + type: "line", + data: { + labels: dsHrLabels, + datasets: [{ + data: dsHr, + borderWidth: 2, + pointRadius: 0, + fill: false, + tension: 0.3, + spanGaps: false, + segment: { + borderColor: function(ctx2) { + const v = ctx2.p1.parsed.y; + if (v == null || v === 0) return "rgba(100,181,246,0.3)"; + return hrZoneColor(v); + } + }, + }], + }, + options: { + responsive: true, maintainAspectRatio: false, + plugins: { + legend: { display: false }, + zoom: { + zoom: { + wheel: { enabled: true, modifierKey: "ctrl", speed: 0.04 }, + pinch: { enabled: true }, + mode: "x", + onZoomStart: ({chart, event}) => { + if (event && event.type === "wheel") return true; + const w = chart.canvas.closest(".chart-wrap"); + return w && w.classList.contains("chart-full"); + }, + }, + pan: { enabled: true, mode: "x" }, + }, + }, + scales: { + x: { display: true, title: { display: true, text: "мин", color: "#888" }, + ticks: { color: "#888", maxTicksLimit: 8 }, grid: { color: "#333" } }, + y: { title: { display: true, text: "bpm", color: "#888" }, + ticks: { color: "#888" }, grid: { color: "#333" } }, + }, + }, + }); + }, 500); + // Delete button setTimeout(() => { const exp = document.querySelector("#sd-export"); @@ -3492,19 +3746,19 @@ const MICRO_FIELDS = [ { key: "vit_b5_mg", label: "B5 / пантотенат (мг)" }, { key: "vit_b6_mg", label: "B6 / пиридоксин (мг)" }, { key: "vit_b7_mcg", label: "B7 / биотин (мкг)" }, - { key: "folate_mcg", label: "B9 / фолиевая (мг)", mult: 1000 }, + { key: "folate_mcg", label: "B9 / фолиевая (мкг)" }, { key: "vit_b12_mcg", label: "B12 / цианокобаламин (мкг)" }, { key: "calcium_mg", label: "Кальций / Ca (мг)" }, { key: "magnesium_mg", label: "Магний / Mg (мг)" }, { key: "iron_mg", label: "Железо / Fe (мг)" }, { key: "zinc_mg", label: "Цинк / Zn (мг)" }, { key: "copper_mg", label: "Медь / Cu (мг)" }, - { key: "selenium_mcg", label: "Селен / Se (мг)", mult: 1000 }, + { key: "selenium_mcg", label: "Селен / Se (мкг)" }, { key: "potassium_mg", label: "Калий / K (мг)" }, { key: "sodium_mg", label: "Натрий / Na (мг)" }, { key: "phosphorus_mg", label: "Фосфор / P (мг)" }, - { key: "iodine_mg", label: "Йод / I (мг)" }, - { key: "chromium_mg", label: "Хром / Cr (мг)" }, + { key: "iodine_mg", label: "Йод / I (мкг)", mult: 0.001 }, + { key: "chromium_mg", label: "Хром / Cr (мкг)", mult: 0.001 }, { key: "manganese_mg", label: "Марганец / Mn (мг)" }, { key: "molybdenum_mcg", label: "Молибден / Mo (мкг)" }, { key: "fish_oil_mg", label: "Рыбий жир (мг)" }, @@ -3517,6 +3771,9 @@ const MICRO_FIELDS = [ { key: "vanadium_mg", label: "Ванадий / V (мг)" }, { key: "lutein_mg", label: "Лютеин (мг)" }, { key: "lycopene_mg", label: "Ликопин (мг)" }, + { key: "ginseng_mg", label: "Женьшень / Panax (мг)" }, + { key: "resveratrol_mg", label: "Ресвератрол (мг)" }, + { key: "coq10_mg", label: "Коэнзим Q10 (мг)" }, ]; // Subset of MICRO_FIELDS that exist as columns in the products table @@ -3525,6 +3782,7 @@ const PRODUCT_MICRO_FIELDS = MICRO_FIELDS.filter(f => [ "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", + "ginseng_mg", "resveratrol_mg", "coq10_mg", ].includes(f.key)); async function renderSupplements(container) { @@ -3773,6 +4031,13 @@ async function renderSettings(container) { +
+
+
Сглаживание высоты
+
Полуокно (5=11 точек, 7=15)
+
+ +
@@ -3796,6 +4061,50 @@ async function renderSettings(container) {
+
+
Нормы ЧСС и давления
+
+
+
ЧСС покой (мин / макс)
+
+
+ + +
+
+
+
+
ЧСС макс
+
По умолчанию 220 - возраст
+
+ +
+
+
+
Давление норма (сис / диа)
+
+
+ + +
+
+
+ +
+
Пульсометр (BLE)
+
+
+
${state.profile.ble_device_name || "Не привязан"}
+
${state.profile.ble_device_addr ? state.profile.ble_device_addr : "Нажмите для поиска"}
+
+
+ ${state.profile.ble_device_addr ? `` : ""} + +
+
+
+
+
О приложении
@@ -3803,6 +4112,7 @@ async function renderSettings(container) {
BalDav
...
+
`; @@ -3830,7 +4140,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"]; + 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"]; for (const id of gpsInputs) { const el = container.querySelector("#" + id); if (el) el.onchange = () => { @@ -3842,10 +4152,20 @@ async function renderSettings(container) { GPS.config.airDrag = parseFloat(container.querySelector("#s-air-drag").value) || 0.6; 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.saveConfig(); }; } + // HR/BP norms — save on change + for (const [id, key] of [["s-hr-rest-min","hr_rest_min"],["s-hr-rest-max","hr_rest_max"],["s-hr-max","hr_max"],["s-bp-sys","bp_sys_norm"],["s-bp-dia","bp_dia_norm"]]) { + const el = container.querySelector("#" + id); + if (el) el.onchange = async () => { + await DB.setProfileValue(key, el.value); + state.profile[key] = el.value; + }; + } + // Bikes list async function loadBikesList() { const bikes = await DB.getBikes(); @@ -3899,13 +4219,127 @@ async function renderSettings(container) { ); }; - // App version info + // BLE HR device scan and bind + const bleUnbindBtn = container.querySelector("#s-ble-unbind"); + if (bleUnbindBtn) { + bleUnbindBtn.onclick = async () => { + await DB.setProfileValue("ble_device_addr", ""); + await DB.setProfileValue("ble_device_type", ""); + await DB.setProfileValue("ble_device_name", ""); + state.profile.ble_device_addr = ""; + state.profile.ble_device_type = ""; + state.profile.ble_device_name = ""; + try { await window.__TAURI__.core.invoke("ble_save_device", { address: "", deviceType: "" }); } catch {} + try { await window.__TAURI__.core.invoke("ble_disconnect"); } catch {} + container.querySelector("#s-ble-name").textContent = "Не привязан"; + container.querySelector("#s-ble-status").textContent = "Нажмите для поиска"; + bleUnbindBtn.remove(); + }; + } + + const bleScanBtn = container.querySelector("#s-ble-scan"); + const bleResults = container.querySelector("#s-ble-results"); + if (bleScanBtn) { + bleScanBtn.onclick = async () => { + bleScanBtn.textContent = "Поиск..."; + bleScanBtn.disabled = true; + bleResults.innerHTML = `
Сканирование 5 сек...
`; + try { + // Trigger scan (Android returns empty immediately, Kotlin scans in background) + await window.__TAURI__.core.invoke("ble_scan", { durationSecs: 5 }); + // Wait for Kotlin scan to complete, then read results + await new Promise(r => setTimeout(r, 6000)); + let devices = await window.__TAURI__.core.invoke("ble_scan_results"); + if (!devices || !devices.length) { + bleResults.innerHTML = `
Устройства не найдены
`; + } else { + bleResults.innerHTML = devices.map(d => + `
+
+
${esc(d.name)}
+
${d.address} (${d.device_type}) ${d.rssi}dBm
+
+ › +
` + ).join(""); + bleResults.querySelectorAll("[data-ble-addr]").forEach(item => { + item.onclick = async () => { + const addr = item.dataset.bleAddr; + const type = item.dataset.bleType; + const name = item.dataset.bleName; + item.querySelector(".settings-item-desc").textContent = "Подключение..."; + try { + await window.__TAURI__.core.invoke("ble_connect", { address: addr, deviceType: type }); + await DB.setProfileValue("ble_device_addr", addr); + await DB.setProfileValue("ble_device_type", type); + await DB.setProfileValue("ble_device_name", name); + // Write file for Kotlin to read at GPS start + try { await window.__TAURI__.core.invoke("ble_save_device", { address: addr, deviceType: type }); } catch {} + state.profile.ble_device_addr = addr; + state.profile.ble_device_type = type; + state.profile.ble_device_name = name; + container.querySelector("#s-ble-name").textContent = name; + container.querySelector("#s-ble-status").textContent = addr + " (подключён)"; + bleResults.innerHTML = ""; + } catch (e) { + item.querySelector(".settings-item-desc").textContent = "Ошибка: " + e; + } + }; + }); + } + } catch (e) { + bleResults.innerHTML = `
${e}
`; + } + bleScanBtn.textContent = "Поиск"; + bleScanBtn.disabled = false; + }; + } + + // App version info + update check try { const info = await window.__TAURI__.core.invoke("get_app_info"); container.querySelector("#s-version").textContent = `v${info.version} (${info.git_hash}) ${info.build_date}`; } catch { container.querySelector("#s-version").textContent = "dev"; } + + // Check for updates: trigger Kotlin re-check, poll until result changes + const updateArea = container.querySelector("#s-update-area"); + if (updateArea) { + updateArea.innerHTML = `проверка...`; + let oldTs = 0; + try { + const old = JSON.parse(await window.__TAURI__.core.invoke("get_update_info")); + oldTs = old.ts || 0; + } catch {} + try { + await window.__TAURI__.core.invoke("gps_service_command", { cmd: "check_update" }); + } catch {} + // Poll until Kotlin writes new result (ts changes) + for (let i = 0; i < 30; i++) { + await new Promise(r => setTimeout(r, 200)); + try { + const raw = await window.__TAURI__.core.invoke("get_update_info"); + const info = JSON.parse(raw); + if (info.ts && info.ts !== oldTs) { + await checkForUpdate(); + break; + } + } catch {} + } + if (state.updateAvailable) { + showUpdateButton(updateArea); + } else { + updateArea.innerHTML = ""; + } + } +} + +function showUpdateButton(container) { + container.innerHTML = ``; + container.querySelector("#s-update-btn").onclick = () => startUpdate(); } function showProfileModal() { @@ -3934,46 +4368,74 @@ function showProfileModal() {
- - + +
...
- - + + +
+
+ +
`, async overlay => { const weight = parseFloat(overlay.querySelector("#m-weight").value) || null; const height = parseInt(overlay.querySelector("#m-height").value); const year = parseInt(overlay.querySelector("#m-year").value); - const bFormula = parseInt(overlay.querySelector("#m-bmr-formula").value) || null; - const bEst = parseInt(overlay.querySelector("#m-bmr-est").value) || null; if (isNaN(height) || isNaN(year)) return false; if (weight) await DB.setProfileValue("weight_kg", weight); await DB.setProfileValue("height_cm", height); await DB.setProfileValue("birth_year", year); await DB.setProfileValue("sex", selectedSex); - if (bFormula) await DB.setProfileValue("bmr_formula", bFormula); - if (bEst) await DB.setProfileValue("bmr_estimated", bEst); + const activityLevel = overlay.querySelector("#m-activity-level").value; + await DB.setProfileValue("activity_level", activityLevel); + const waterNorm = parseInt(overlay.querySelector("#m-water-norm").value) || 2000; + await DB.setProfileValue("daily_water_ml", waterNorm); state.profile = { ...state.profile, weight_kg: weight ? weight : state.profile.weight_kg, height_cm: String(height), birth_year: String(year), sex: selectedSex, - bmr_formula: bFormula ? String(bFormula) : null, - bmr_estimated: bEst ? String(bEst) : null }; + activity_level: activityLevel }; }); - // Sex chip handler inside modal + // Initialize modal controls setTimeout(() => { const overlay = [...document.querySelectorAll(".modal-overlay")].pop(); if (!overlay) return; + // Sex chips overlay.querySelectorAll("[data-sex]").forEach(btn => { btn.onclick = () => { selectedSex = btn.dataset.sex; overlay.querySelectorAll("[data-sex]").forEach(b => b.classList.remove("selected")); btn.classList.add("selected"); + updateBmrDisplay(); }; }); + // Activity level dropdown + const actSelect = overlay.querySelector("#m-activity-level"); + if (actSelect) actSelect.value = p.activity_level || "1.2"; + // BMR display + function updateBmrDisplay() { + const w = parseFloat(overlay.querySelector("#m-weight").value) || parseFloat(p.weight_kg) || 80; + const h = parseInt(overlay.querySelector("#m-height").value) || parseInt(p.height_cm) || 178; + const y = parseInt(overlay.querySelector("#m-year").value) || parseInt(p.birth_year) || 1988; + const bmrVal = BMR.calculateBmr(w, h, BMR.getAge(y), selectedSex); + const el = overlay.querySelector("#m-bmr-display"); + if (el) el.textContent = bmrVal + " ккал/день"; + } + updateBmrDisplay(); + // Recalculate BMR when weight/height/year change + for (const id of ["#m-weight", "#m-height", "#m-year"]) { + const el = overlay.querySelector(id); + if (el) el.oninput = updateBmrDisplay; + } }, 100); } @@ -4183,6 +4645,9 @@ const NUTRIENT_LABELS = { molybdenum_mcg: "Mo", manganese_mg: "Mn", copper_mg: "Cu", chromium_mg: "Cr", phosphorus_mg: "P", potassium_mg: "K+", sodium_mg: "Na", + omega3_mg: "O-3", epa_mg: "EPA", dha_mg: "DHA", + lutein_mg: "Lut", lycopene_mg: "Lik", silicon_mg: "Si", vanadium_mg: "V", + ginseng_mg: "Ginseng", resveratrol_mg: "Resv", coq10_mg: "CoQ10", // carotene_mcg not shown separately — auto-converted to vit A }; @@ -4295,10 +4760,85 @@ function calcDailyNutrients(food, suppTotals) { return result; } +// Calculate today's reserves = (yesterday_reserves + yesterday_intake) * decay +// decay = 0.5^(1/halflife_days) per nutrient +async function calcReservesForDate(date) { + const HL = BMR.HALFLIFE; + const RDI = BMR.RDI; + if (!HL || !Object.keys(HL).length) return null; + + const yesterday = shiftDate(date, -1); + const prevReserves = await DB.getReserves(yesterday); + + // Get yesterday's intake (food + supplements) + const food = await DB.getFoodForDate(yesterday); + const suppTotals = await DB.getSupplementTotalsForDate(yesterday); + const intake = calcDailyNutrientTotals(food, suppTotals); + + // Apply decay: reserves = (prev_reserves + intake) * 0.5^(1/hl) + const reserves = {}; + for (const key of Object.keys(RDI)) { + const hl = HL[key]; + if (!hl || hl <= 0) continue; + const prev = (prevReserves[key] || 0) + (intake[key] || 0); + if (prev <= 0) continue; + const decayed = prev * Math.pow(0.5, 1 / hl); + if (decayed >= 0.001) reserves[key] = Math.round(decayed * 1000) / 1000; + } + return reserves; +} + +// Raw totals (no RDI %, just amounts) — reused by reserves calc +function calcDailyNutrientTotals(food, suppTotals) { + const RDI = BMR.RDI; + const totals = {}; + for (const f of food) { + if (f.product_id) { + const factor = (f.amount_g || 100) / 100; + for (const key of Object.keys(RDI)) { + if (f[key]) totals[key] = (totals[key] || 0) + f[key] * factor; + } + if (f.carotene_mcg) totals.vit_a_mcg = (totals.vit_a_mcg || 0) + (f.carotene_mcg * factor) / 12; + } + if (f.recipe_id && f.cached_nutrients) { + try { + const rn = typeof f.cached_nutrients === "string" ? JSON.parse(f.cached_nutrients) : f.cached_nutrients; + for (const key of Object.keys(RDI)) { + if (rn[key]) totals[key] = (totals[key] || 0) + rn[key]; + } + } catch {} + } + } + if (suppTotals) { + for (const key of Object.keys(RDI)) { + if (suppTotals[key]) totals[key] = (totals[key] || 0) + suppTotals[key]; + } + if (suppTotals.carotene_mcg) totals.vit_a_mcg = (totals.vit_a_mcg || 0) + suppTotals.carotene_mcg / 12; + } + return totals; +} + +// One-time migration: fill nutrient_reserves for all historical days +async function migrateReserves() { + const existing = await DB.getAllReservesDates(); + if (existing.length > 0) return; // already migrated + const days = await DB.getDaysWithFood(); + if (!days.length) return; + console.log(`[Reserves] migrating ${days.length} days...`); + for (const row of days) { + const reserves = await calcReservesForDate(row.date); + if (reserves && Object.keys(reserves).length > 0) { + await DB.saveReserves(row.date, reserves); + } + } + console.log("[Reserves] migration done"); +} + // Render nutrient chips from an array of {label, pct} objects -function renderNutrientChips(nutrients) { +function renderNutrientChips(nutrients, title) { if (!nutrients || nutrients.length === 0) return ""; - return nutrients.map(n => { + const header = title ? `
${title}
` : ""; + return header + nutrients.map(n => { const cls = n.pct < 30 ? "nut-low" : n.pct < 80 ? "nut-mid" : "nut-ok"; const star = n.normType === "aup" ? "*" : ""; return `
${n.label}${star} ${n.pct}%
`; @@ -4358,21 +4898,37 @@ function createProductSearch(parentEl, onSelect, opts) { function calcBmr(weights) { const p = state.profile; - if (p.bmr_estimated) return parseInt(p.bmr_estimated); - // Need height + birth_year for formula const height = parseInt(p.height_cm); const birthYear = parseInt(p.birth_year); - if (!height || !birthYear) return parseInt(p.bmr_formula) || null; - // Weight: today's weighing → latest weighing → profile weight + if (!height || !birthYear) return null; const kg = parseFloat( (weights.length ? weights[weights.length - 1].weight_kg : null) ?? state.latestWeight?.weight_kg ?? p.weight_kg ); - if (!kg) return parseInt(p.bmr_formula) || null; + if (!kg) return null; return BMR.calculateBmr(kg, height, BMR.getAge(birthYear), p.sex ?? "m"); } +function hrZoneClass(hr) { + const p = state.profile; + const age = BMR.getAge(parseInt(p.birth_year) || 1988); + const maxHr = parseInt(p.hr_max) || (220 - age); + const restMax = parseInt(p.hr_rest_max) || 100; + if (hr <= restMax) return "hr-zone-green"; + if (hr <= 140) return "hr-zone-yellow"; + if (hr <= maxHr * 0.85) return "hr-zone-orange"; + return "hr-zone-red"; +} + +function hrZoneColor(hr) { + const cls = hrZoneClass(hr); + if (cls === "hr-zone-green") return "#81c784"; + if (cls === "hr-zone-yellow") return "#ffb74d"; + if (cls === "hr-zone-orange") return "#ff8a65"; + return "#ef5350"; +} + function balanceClass(balance) { if (balance == null) return ""; return balance > 100 ? "surplus" : balance < -100 ? "deficit" : "balanced"; @@ -4561,6 +5117,17 @@ function sourceBadge(source) { return `${label}`; } +function showToast(msg, duration) { + const el = document.createElement("div"); + el.textContent = msg; + el.style.cssText = "position:fixed;bottom:80px;left:50%;transform:translateX(-50%);background:#333;color:#fff;padding:10px 20px;border-radius:8px;font-size:14px;z-index:99999;opacity:0;transition:opacity 0.3s"; + document.body.appendChild(el); + requestAnimationFrame(() => el.style.opacity = "1"); + const hide = () => { el.style.opacity = "0"; setTimeout(() => el.remove(), 300); }; + el.onclick = hide; + setTimeout(hide, duration || 6000); +} + function esc(str) { return String(str ?? "") .replace(/&/g, "&").replace(/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src-tauri/gen/android/app/src/main/java/dev/baldav/app/BleHrService.kt b/src-tauri/gen/android/app/src/main/java/dev/baldav/app/BleHrService.kt new file mode 100644 index 0000000..ac1ccb5 --- /dev/null +++ b/src-tauri/gen/android/app/src/main/java/dev/baldav/app/BleHrService.kt @@ -0,0 +1,468 @@ +package dev.baldav.app + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.bluetooth.* +import android.bluetooth.le.* +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Build +import android.os.IBinder +import android.os.ParcelUuid +import android.util.Log +import java.io.File +import java.util.UUID + +class BleHrService : Service() { + + companion object { + const val TAG = "BleHR" + const val CHANNEL_ID = "baldav_ble" + const val NOTIFICATION_ID = 2 + const val ACTION_STOP = "dev.baldav.app.STOP_BLE" + const val EXTRA_ADDRESS = "address" + const val EXTRA_DEVICE_TYPE = "device_type" + const val ACTION_SCAN = "dev.baldav.app.SCAN_BLE" + + // Standard BLE Heart Rate + val HR_SERVICE_UUID: UUID = UUID.fromString("0000180d-0000-1000-8000-00805f9b34fb") + val HR_MEASUREMENT_UUID: UUID = UUID.fromString("00002a37-0000-1000-8000-00805f9b34fb") + val CCCD_UUID: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb") + + // FitPro Nordic UART + val FITPRO_SERVICE: UUID = UUID.fromString("6e400001-b5a3-f393-e0a9-e50e24dcca9d") + val FITPRO_WRITE: UUID = UUID.fromString("6e400002-b5a3-f393-e0a9-e50e24dcca9d") + val FITPRO_NOTIFY: UUID = UUID.fromString("6e400003-b5a3-f393-e0a9-e50e24dcca9d") + + // FitPro protocol commands + val FITPRO_PAIR = byteArrayOf(0xCD.toByte(), 0, 6, 0x12, 1, 0x0A, 0, 1, 2) + val FITPRO_HR_SWITCH_ON = byteArrayOf(0xCD.toByte(), 0, 6, 0x12, 1, 0x0D, 0, 1, 1) + val FITPRO_MEASURE_START = byteArrayOf(0xCD.toByte(), 0, 7, 0x12, 1, 0x24, 0, 2, 0, 1) + val FITPRO_REALTIME_ON = byteArrayOf(0xCD.toByte(), 0, 6, 0x15, 1, 6, 0, 1, 1) + val FITPRO_MEASURE_OLD = byteArrayOf(0xCD.toByte(), 0, 6, 0x12, 1, 0x18, 0, 1, 1) // key=24 (old MEASURE) + + var isRunning = false + private set + } + + private var gatt: BluetoothGatt? = null + private var deviceType = "hr_standard" + private var dataFile: File? = null + private var scanResultsFile: File? = null + private var fitproWriteChar: BluetoothGattCharacteristic? = null + private var fitproGatt: BluetoothGatt? = null + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onCreate() { + super.onCreate() + createNotificationChannel() + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + when (intent?.action) { + ACTION_STOP -> { + disconnect() + stopSelf() + return START_NOT_STICKY + } + ACTION_SCAN -> { + startForeground(NOTIFICATION_ID, buildNotification("Поиск BLE...")) + scanDevices() + return START_STICKY + } + else -> { + val address = intent?.getStringExtra(EXTRA_ADDRESS) + deviceType = intent?.getStringExtra(EXTRA_DEVICE_TYPE) ?: "hr_standard" + if (address != null) { + startForeground(NOTIFICATION_ID, buildNotification("Подключение...")) + isRunning = true + val appDataDir = filesDir.parentFile ?: filesDir + dataFile = File(appDataDir, "ble_hr_data.json") + connectDevice(address) + } + return START_STICKY + } + } + } + + private fun scanDevices() { + val adapter = (getSystemService(BLUETOOTH_SERVICE) as? android.bluetooth.BluetoothManager)?.adapter + if (adapter == null || !adapter.isEnabled) { + Log.e(TAG, "Bluetooth not available") + writeScanResults("[]") + stopSelf() + return + } + val scanner = adapter.bluetoothLeScanner + if (scanner == null) { + Log.e(TAG, "BLE scanner not available") + writeScanResults("[]") + stopSelf() + return + } + + val results = mutableListOf() + val seenAddresses = mutableSetOf() + + val callback = object : ScanCallback() { + override fun onScanResult(callbackType: Int, result: ScanResult) { + val device = result.device + val name = device.name ?: return + val addr = device.address + if (seenAddresses.contains(addr)) return + seenAddresses.add(addr) + + val services = result.scanRecord?.serviceUuids?.map { it.uuid } ?: emptyList() + val type = when { + services.contains(HR_SERVICE_UUID) -> "hr_standard" + services.contains(FITPRO_SERVICE) -> "fitpro" + name.lowercase().let { it.contains("fitpro") || it.contains("band") || it.contains("m7") || it.contains("m8") } -> "fitpro" + name.lowercase().let { it.contains("coospo") || it.contains("polar") || it.contains("garmin") || it.contains("hr") } -> "hr_standard" + else -> "unknown" + } + val rssi = result.rssi + results.add("""{"name":"${name.replace("\"", "\\\"")}","address":"$addr","device_type":"$type","rssi":$rssi}""") + Log.d(TAG, "Found: $name ($addr) type=$type rssi=$rssi") + } + + override fun onScanFailed(errorCode: Int) { + Log.e(TAG, "Scan failed: $errorCode") + } + } + + scanner.startScan(callback) + // Stop after 5 seconds + android.os.Handler(mainLooper).postDelayed({ + scanner.stopScan(callback) + writeScanResults("[${results.joinToString(",")}]") + Log.d(TAG, "Scan complete: ${results.size} devices") + // Don't stopSelf() — connect intent may follow immediately + stopForeground(STOP_FOREGROUND_REMOVE) + }, 5000) + } + + private fun writeScanResults(json: String) { + val appDataDir = filesDir.parentFile ?: filesDir + scanResultsFile = File(appDataDir, "ble_scan_results.json") + scanResultsFile?.writeText(json) + } + + private fun connectDevice(address: String) { + val adapter = (getSystemService(BLUETOOTH_SERVICE) as? android.bluetooth.BluetoothManager)?.adapter + if (adapter == null || !adapter.isEnabled) { + Log.e(TAG, "Bluetooth not available") + stopSelf() + return + } + // Quick scan first to populate BLE cache (avoids status=133 GATT_ERROR) + val scanner = adapter.bluetoothLeScanner + if (scanner == null) { + Log.e(TAG, "BLE scanner not available, trying direct connect") + directConnect(adapter, address) + return + } + Log.d(TAG, "Quick scan before connecting to $address") + val scanCallback = object : ScanCallback() { + override fun onScanResult(callbackType: Int, result: ScanResult) { + if (result.device.address == address) { + Log.d(TAG, "Found target device in scan, connecting...") + scanner.stopScan(this) + gatt = result.device.connectGatt(this@BleHrService, false, gattCallback, BluetoothDevice.TRANSPORT_LE) + } + } + override fun onScanFailed(errorCode: Int) { + Log.e(TAG, "Quick scan failed: $errorCode, trying direct connect") + directConnect(adapter, address) + } + } + scanner.startScan(scanCallback) + // Timeout: if device not found in 5 seconds, try direct connect + android.os.Handler(mainLooper).postDelayed({ + scanner.stopScan(scanCallback) + if (gatt == null) { + Log.d(TAG, "Scan timeout, trying direct connect to $address") + directConnect(adapter, address) + } + }, 5000) + } + + private fun directConnect(adapter: BluetoothAdapter, address: String) { + val device = adapter.getRemoteDevice(address) + Log.d(TAG, "Direct connecting to $address ($deviceType)") + gatt = device.connectGatt(this, false, gattCallback, BluetoothDevice.TRANSPORT_LE) + } + + private val gattCallback = object : BluetoothGattCallback() { + override fun onConnectionStateChange(g: BluetoothGatt, status: Int, newState: Int) { + if (newState == BluetoothProfile.STATE_CONNECTED) { + Log.d(TAG, "Connected, discovering services...") + updateNotification("Подключён, поиск сервисов...") + g.discoverServices() + } else if (newState == BluetoothProfile.STATE_DISCONNECTED) { + Log.d(TAG, "Disconnected") + isRunning = false + writeData("""{"status":"disconnected","ts":${System.currentTimeMillis()/1000}}""") + stopSelf() + } + } + + override fun onServicesDiscovered(g: BluetoothGatt, status: Int) { + if (status != BluetoothGatt.GATT_SUCCESS) { + Log.e(TAG, "Service discovery failed: $status") + return + } + // Auto-detect type from available services if unknown + if (deviceType == "unknown") { + deviceType = when { + g.getService(HR_SERVICE_UUID) != null -> "hr_standard" + g.getService(FITPRO_SERVICE) != null -> "fitpro" + else -> "fitpro" // fallback: try FitPro (Nordic UART is common for cheap bracelets) + } + Log.d(TAG, "Auto-detected device type: $deviceType") + } + Log.d(TAG, "Services discovered, setting up for $deviceType") + when (deviceType) { + "hr_standard" -> setupStandardHr(g) + "fitpro" -> setupFitPro(g) + } + updateNotification("Пульсометр подключён") + } + + override fun onCharacteristicChanged(g: BluetoothGatt, char: BluetoothGattCharacteristic, value: ByteArray) { + Log.d(TAG, "Data received (${value.size} bytes): ${value.joinToString(" ") { "%02X".format(it) }}") + when (deviceType) { + "hr_standard" -> parseStandardHr(value) + "fitpro" -> { + // Send ACK for data packets (0xCD) — required by FitPro protocol + if (value.isNotEmpty() && value[0] == 0xCD.toByte() && value.size >= 4) { + val cmdId = value[3] + val totalLen = value.size + val ack = byteArrayOf(0xDC.toByte(), 0, 5, cmdId, + 1, ((totalLen shr 8) and 0xFF).toByte(), (totalLen and 0xFF).toByte(), 1) + fitproWriteChar?.let { wc -> fitproGatt?.let { gg -> + writeCommand(gg, wc, ack) + }} + } + parseFitProData(value) + } + } + } + + @Deprecated("Deprecated in API 33") + override fun onCharacteristicChanged(g: BluetoothGatt, char: BluetoothGattCharacteristic) { + onCharacteristicChanged(g, char, char.value) + } + + override fun onDescriptorWrite(g: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int) { + Log.d(TAG, "onDescriptorWrite status=$status") + onGattOperationComplete() + } + + override fun onCharacteristicWrite(g: BluetoothGatt, char: BluetoothGattCharacteristic, status: Int) { + Log.d(TAG, "onCharacteristicWrite status=$status") + onGattOperationComplete() + } + } + + private fun setupStandardHr(g: BluetoothGatt) { + val service = g.getService(HR_SERVICE_UUID) + if (service == null) { Log.e(TAG, "HR service not found"); return } + val char = service.getCharacteristic(HR_MEASUREMENT_UUID) + if (char == null) { Log.e(TAG, "HR characteristic not found"); return } + g.setCharacteristicNotification(char, true) + val desc = char.getDescriptor(CCCD_UUID) + if (desc != null) { + desc.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE + g.writeDescriptor(desc) + } + Log.d(TAG, "Standard HR setup complete") + } + + private val gattQueue = mutableListOf<() -> Unit>() + private var gattBusy = false + + private fun enqueueGatt(op: () -> Unit) { + gattQueue.add(op) + processGattQueue() + } + + private fun processGattQueue() { + if (gattBusy || gattQueue.isEmpty()) return + gattBusy = true + gattQueue.removeAt(0).invoke() + } + + fun onGattOperationComplete() { + gattBusy = false + android.os.Handler(mainLooper).postDelayed({ processGattQueue() }, 100) + } + + private fun setupFitPro(g: BluetoothGatt) { + val service = g.getService(FITPRO_SERVICE) + if (service == null) { Log.e(TAG, "FitPro service not found"); return } + val notifyChar = service.getCharacteristic(FITPRO_NOTIFY) + if (notifyChar == null) { Log.e(TAG, "FitPro notify char not found"); return } + val writeChar = service.getCharacteristic(FITPRO_WRITE) + if (writeChar == null) { Log.e(TAG, "FitPro write char not found"); return } + + // Queue all operations: enable notify → PAIR → HR_SWITCH → REALTIME → MEASURE + enqueueGatt { + g.setCharacteristicNotification(notifyChar, true) + val desc = notifyChar.getDescriptor(CCCD_UUID) + if (desc != null) { + desc.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE + g.writeDescriptor(desc) + Log.d(TAG, "FitPro: writing CCCD descriptor") + } else { + Log.d(TAG, "FitPro: no CCCD, skipping") + onGattOperationComplete() + } + } + enqueueGatt { Log.d(TAG, "FitPro: PAIR"); writeCommand(g, writeChar, FITPRO_PAIR) } + enqueueGatt { Log.d(TAG, "FitPro: HR_SWITCH"); writeCommand(g, writeChar, FITPRO_HR_SWITCH_ON) } + enqueueGatt { Log.d(TAG, "FitPro: REALTIME"); writeCommand(g, writeChar, FITPRO_REALTIME_ON) } + enqueueGatt { Log.d(TAG, "FitPro: MEASURE2 (key=36)"); writeCommand(g, writeChar, FITPRO_MEASURE_START) } + // Also send old-style MEASURE (key=24) — some firmware versions need this + enqueueGatt { Log.d(TAG, "FitPro: MEASURE (key=24)"); writeCommand(g, writeChar, FITPRO_MEASURE_OLD) } + Log.d(TAG, "FitPro setup sequence queued (${gattQueue.size} ops)") + + // Re-trigger measurement every 25 seconds + fitproWriteChar = writeChar + fitproGatt = g + android.os.Handler(mainLooper).also { h -> + val retrigger = object : Runnable { + override fun run() { + if (!isRunning) return + Log.d(TAG, "FitPro: re-trigger measurement") + fitproGatt?.let { gg -> fitproWriteChar?.let { wc -> + writeCommand(gg, wc, FITPRO_MEASURE_START) + android.os.Handler(mainLooper).postDelayed({ + writeCommand(gg, wc, FITPRO_MEASURE_OLD) + }, 300) + }} + h.postDelayed(this, 25000) + } + } + h.postDelayed(retrigger, 25000) + } + } + + private fun writeCommand(g: BluetoothGatt, char: BluetoothGattCharacteristic, data: ByteArray) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + g.writeCharacteristic(char, data, BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE) + // NO_RESPONSE may not trigger onCharacteristicWrite, so auto-complete after 200ms + android.os.Handler(mainLooper).postDelayed({ onGattOperationComplete() }, 200) + } else { + @Suppress("DEPRECATION") + char.value = data + char.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE + @Suppress("DEPRECATION") + g.writeCharacteristic(char) + android.os.Handler(mainLooper).postDelayed({ onGattOperationComplete() }, 200) + } + } + + private fun parseStandardHr(data: ByteArray) { + if (data.isEmpty()) return + val flags = data[0].toInt() and 0xFF + val hr = if (flags and 0x01 == 0) { + if (data.size < 2) return + data[1].toInt() and 0xFF + } else { + if (data.size < 3) return + (data[1].toInt() and 0xFF) or ((data[2].toInt() and 0xFF) shl 8) + } + if (hr <= 0) return + val ts = System.currentTimeMillis() / 1000 + writeData("""{"hr":$hr,"ts":$ts}""") + Log.d(TAG, "HR: $hr") + } + + private fun parseFitProData(data: ByteArray) { + if (data.size < 8 || data[0] != 0xCD.toByte()) return + val cmdId = data[3].toInt() and 0xFF + val keyId = data[5].toInt() and 0xFF + + // Measure Day Data (cmd=21, key=14): HR, BP_dia, BP_sys, SpO2 + if (cmdId == 21 && keyId == 14 && data.size >= 20) { + val hr = data[16].toInt() and 0xFF + val bpDia = data[17].toInt() and 0xFF + val bpSys = data[18].toInt() and 0xFF + val spo2 = data[19].toInt() and 0xFF + if (hr in 31..219) { + val ts = System.currentTimeMillis() / 1000 + val json = buildString { + append("""{"hr":$hr,"ts":$ts""") + if (bpDia > 0) append(""","bp_dia":$bpDia""") + if (bpSys > 0) append(""","bp_sys":$bpSys""") + if (spo2 in 1..100) append(""","spo2":$spo2""") + append("}") + } + writeData(json) + Log.d(TAG, "FitPro: HR=$hr BP=$bpSys/$bpDia SpO2=$spo2") + } + } + + // Realtime data (cmd=21, key=11) + if (cmdId == 21 && keyId == 11 && data.size >= 12) { + for (i in 8 until data.size) { + val b = data[i].toInt() and 0xFF + if (b in 41..199) { + val ts = System.currentTimeMillis() / 1000 + writeData("""{"hr":$b,"ts":$ts}""") + Log.d(TAG, "FitPro realtime HR: $b") + break + } + } + } + } + + private fun writeData(json: String) { + try { + dataFile?.writeText(json) + } catch (e: Exception) { + Log.e(TAG, "Write data failed", e) + } + } + + private fun disconnect() { + gatt?.disconnect() + gatt?.close() + gatt = null + isRunning = false + } + + private fun createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel(CHANNEL_ID, "BLE HR Monitor", NotificationManager.IMPORTANCE_LOW) + channel.description = "Heart rate monitoring" + getSystemService(NotificationManager::class.java).createNotificationChannel(channel) + } + } + + private fun buildNotification(text: String): Notification { + val intent = packageManager.getLaunchIntentForPackage(packageName) + val pi = PendingIntent.getActivity(this, 0, intent, PendingIntent.FLAG_IMMUTABLE) + return Notification.Builder(this, CHANNEL_ID) + .setContentTitle("BalDav HR") + .setContentText(text) + .setSmallIcon(android.R.drawable.ic_menu_compass) + .setContentIntent(pi) + .setOngoing(true) + .build() + } + + private fun updateNotification(text: String) { + val nm = getSystemService(NotificationManager::class.java) + nm.notify(NOTIFICATION_ID, buildNotification(text)) + } + + override fun onDestroy() { + disconnect() + super.onDestroy() + } +} diff --git a/src-tauri/gen/android/app/src/main/java/dev/baldav/app/MainActivity.kt b/src-tauri/gen/android/app/src/main/java/dev/baldav/app/MainActivity.kt index d10f11d..0492fe3 100644 --- a/src-tauri/gen/android/app/src/main/java/dev/baldav/app/MainActivity.kt +++ b/src-tauri/gen/android/app/src/main/java/dev/baldav/app/MainActivity.kt @@ -1,7 +1,7 @@ package dev.baldav.app -import android.app.Activity import android.content.Intent +import android.content.pm.PackageManager import android.net.Uri import android.os.Build import android.os.Bundle @@ -12,6 +12,8 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat import java.io.File +import java.net.URL +import javax.net.ssl.HttpsURLConnection class MainActivity : TauriActivity() { private var commandObserver: FileObserver? = null @@ -39,12 +41,15 @@ class MainActivity : TauriActivity() { val appDataDir = filesDir.parentFile ?: filesDir Log.d("BalDav", "App data dir: ${appDataDir.absolutePath}") - // Create command file if not exists (FileObserver needs it) + // Create command files if not exist (FileObserver needs them) val cmdFile = File(appDataDir, "gps_command.txt") if (!cmdFile.exists()) cmdFile.createNewFile() + val bleCmdFile = File(appDataDir, "ble_command.txt") + if (!bleCmdFile.exists()) bleCmdFile.createNewFile() setupCommandWatcher(appDataDir) processGpsCommand(appDataDir) + processBleCommand(appDataDir) Log.d("BalDav", "Command watcher started on ${appDataDir.absolutePath}") // Always copy seed DB from assets — ensures APK updates deliver new products. @@ -60,6 +65,12 @@ class MainActivity : TauriActivity() { Log.e("BalDav", "Failed to copy seed DB", e) } + // Check for updates in background + checkForUpdate(appDataDir) + + // Write Bluetooth state for JS to read + writeBtState(appDataDir) + val decorView = window.decorView ViewCompat.setOnApplyWindowInsetsListener(decorView) { view, insets -> val statusBar = insets.getInsets(WindowInsetsCompat.Type.statusBars()) @@ -84,8 +95,10 @@ class MainActivity : TauriActivity() { commandObserver = object : FileObserver(appDataDir.absolutePath, CREATE or CLOSE_WRITE or MODIFY or MOVED_TO) { override fun onEvent(event: Int, path: String?) { if (path == "gps_command.txt") { - Log.d("BalDav", "Command file event: $event") - runOnUiThread { processGpsCommand(appDataDir) } + runOnUiThread { + processGpsCommand(appDataDir) + processBleCommand(appDataDir) + } } } } @@ -100,7 +113,7 @@ class MainActivity : TauriActivity() { if (cmd.isEmpty()) return cmdFile.writeText("") // Clear but don't delete (FileObserver needs the file) - Log.d("BalDav", "Processing GPS command: $cmd") + Log.d("BalDav", "Processing command: $cmd") when { cmd.startsWith("start") -> { @@ -123,15 +136,60 @@ class MainActivity : TauriActivity() { } // Keep screen on while recording window.addFlags(android.view.WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + // Auto-start BLE HR if device is paired + autoStartBle(appDataDir) } cmd == "stop" -> { Log.d("BalDav", "Stopping GPS service") val intent = Intent(this, GpsRecorderService::class.java) intent.action = GpsRecorderService.ACTION_STOP startService(intent) + // Stop BLE HR + try { + val bleIntent = Intent(this, BleHrService::class.java) + bleIntent.action = BleHrService.ACTION_STOP + startService(bleIntent) + } catch (_: Exception) {} // Allow screen to turn off window.clearFlags(android.view.WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) } + cmd == "ble_scan" -> { + Log.d("BalDav", "Starting BLE scan") + writeBtState(appDataDir) + if (ensureBlePermissions()) { + val intent = Intent(this, BleHrService::class.java) + intent.action = BleHrService.ACTION_SCAN + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) startForegroundService(intent) else startService(intent) + } + } + cmd.startsWith("ble_connect:") -> { + val payload = cmd.substringAfter("ble_connect:") + val lastColon = payload.lastIndexOf(":") + val address = if (lastColon > 0) payload.substring(0, lastColon) else payload + val type = if (lastColon > 0) payload.substring(lastColon + 1) else "hr_standard" + Log.d("BalDav", "BLE connect: $address ($type)") + if (ensureBlePermissions()) { + val intent = Intent(this, BleHrService::class.java).apply { + putExtra(BleHrService.EXTRA_ADDRESS, address) + putExtra(BleHrService.EXTRA_DEVICE_TYPE, type) + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) startForegroundService(intent) else startService(intent) + } + } + cmd == "ble_disconnect" -> { + Log.d("BalDav", "BLE disconnect") + val intent = Intent(this, BleHrService::class.java) + intent.action = BleHrService.ACTION_STOP + startService(intent) + } + cmd == "check_update" -> { + checkForUpdate(appDataDir) + } + cmd.startsWith("open_url:") -> { + val url = cmd.substringAfter("open_url:") + Log.d("BalDav", "Opening URL in browser: $url") + startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) + } cmd.startsWith("save:") -> { // save:filename.gpx — read content from save_content.txt, show SAF picker val filename = cmd.substringAfter("save:") @@ -146,6 +204,137 @@ class MainActivity : TauriActivity() { } } + private fun processBleCommand(appDataDir: File) { + val cmdFile = File(appDataDir, "ble_command.txt") + if (!cmdFile.exists()) return + val cmd = cmdFile.readText().trim() + if (cmd.isEmpty()) return + cmdFile.writeText("") + + Log.d("BalDav", "Processing BLE command: $cmd") + + when { + cmd == "ble_scan" -> { + writeBtState(appDataDir) + if (ensureBlePermissions()) { + val intent = Intent(this, BleHrService::class.java) + intent.action = BleHrService.ACTION_SCAN + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) startForegroundService(intent) else startService(intent) + } + } + cmd.startsWith("ble_connect:") -> { + val payload = cmd.substringAfter("ble_connect:") + val lastColon = payload.lastIndexOf(":") + val address = if (lastColon > 0) payload.substring(0, lastColon) else payload + val type = if (lastColon > 0) payload.substring(lastColon + 1) else "hr_standard" + Log.d("BalDav", "BLE connect: $address ($type)") + if (ensureBlePermissions()) { + val intent = Intent(this, BleHrService::class.java).apply { + putExtra(BleHrService.EXTRA_ADDRESS, address) + putExtra(BleHrService.EXTRA_DEVICE_TYPE, type) + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) startForegroundService(intent) else startService(intent) + } + } + cmd == "ble_disconnect" -> { + val intent = Intent(this, BleHrService::class.java) + intent.action = BleHrService.ACTION_STOP + startService(intent) + } + } + } + + private fun autoStartBle(appDataDir: File) { + try { + // Read paired BLE device from profile in DB + val bleAddr = File(appDataDir, "ble_device.txt") + // JS saves ble_device_addr in profile (SQLite), but we can't read SQLite here easily. + // Instead, read from a simple file that JS writes when device is paired. + // Fallback: check if ble_hr_data.json has recent data (device was connected before) + val profileDb = File(appDataDir.parentFile ?: appDataDir, "baldav.db") + if (!profileDb.exists()) return + + // Use a simpler approach: read ble_device.json written by JS at pairing time + val deviceFile = File(appDataDir, "ble_device.json") + if (!deviceFile.exists()) { + Log.d("BalDav", "No paired BLE device file") + return + } + val json = deviceFile.readText().trim() + if (json.isEmpty()) return + + // Parse {"addr":"XX:XX:XX:XX:XX:XX","type":"unknown"} + val addrMatch = Regex(""""addr"\s*:\s*"([^"]+)"""").find(json) + val typeMatch = Regex(""""type"\s*:\s*"([^"]+)"""").find(json) + val addr = addrMatch?.groupValues?.get(1) ?: return + val type = typeMatch?.groupValues?.get(1) ?: "unknown" + + Log.d("BalDav", "Auto-starting BLE HR: $addr ($type)") + if (ensureBlePermissions()) { + val intent = Intent(this, BleHrService::class.java).apply { + putExtra(BleHrService.EXTRA_ADDRESS, addr) + putExtra(BleHrService.EXTRA_DEVICE_TYPE, type) + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) startForegroundService(intent) else startService(intent) + } + } catch (e: Exception) { + Log.e("BalDav", "autoStartBle failed", e) + } + } + + private fun writeBtState(appDataDir: File) { + try { + val btManager = getSystemService(BLUETOOTH_SERVICE) as? android.bluetooth.BluetoothManager + val adapter = btManager?.adapter + val state = when { + adapter == null -> "no_adapter" + !adapter.isEnabled -> "off" + else -> "on" + } + File(appDataDir, "bt_state.txt").writeText(state) + } catch (e: Exception) { + Log.e("BalDav", "writeBtState failed", e) + } + } + + private fun ensureBlePermissions(): Boolean { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + val needed = mutableListOf() + if (checkSelfPermission(android.Manifest.permission.BLUETOOTH_SCAN) != PackageManager.PERMISSION_GRANTED) + needed.add(android.Manifest.permission.BLUETOOTH_SCAN) + if (checkSelfPermission(android.Manifest.permission.BLUETOOTH_CONNECT) != PackageManager.PERMISSION_GRANTED) + needed.add(android.Manifest.permission.BLUETOOTH_CONNECT) + if (needed.isNotEmpty()) { + requestPermissions(needed.toTypedArray(), 1001) + return false + } + } + return true + } + + private fun checkForUpdate(appDataDir: File) { + Thread { + 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() + 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") + } catch (e: Exception) { + Log.d("BalDav", "Update check failed: ${e.message}") + val infoFile = File(appDataDir, "update_info.json") + val ts = System.currentTimeMillis() + infoFile.writeText("""{"error":"${e.message}","ts":$ts}""") + } + }.start() + } + override fun onDestroy() { commandObserver?.stopWatching() super.onDestroy() diff --git a/src-tauri/gen/android/app/src/main/res/xml/file_paths.xml b/src-tauri/gen/android/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..9113535 --- /dev/null +++ b/src-tauri/gen/android/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/src-tauri/src/ble.rs b/src-tauri/src/ble.rs new file mode 100644 index 0000000..b26ceaa --- /dev/null +++ b/src-tauri/src/ble.rs @@ -0,0 +1,499 @@ +// BLE Heart Rate module — desktop (btleplug) + Android (Kotlin service + file IPC). + +use serde::{Serialize, Deserialize}; +#[cfg(not(target_os = "android"))] +use std::sync::{Arc, Mutex}; + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct BleDevice { + pub name: String, + pub address: String, + pub device_type: String, // "hr_standard", "fitpro", "unknown" + pub rssi: i32, +} + +#[derive(Serialize, Deserialize, Clone, Debug, Default)] +pub struct HrReading { + pub hr: u16, + #[serde(default)] + pub spo2: Option, + #[serde(default)] + pub bp_sys: Option, + #[serde(default)] + pub bp_dia: Option, + #[serde(alias = "ts", default)] + pub timestamp: u64, + #[serde(default)] + pub device: String, +} + +// Shared state for BLE operations +#[cfg(not(target_os = "android"))] +lazy_static::lazy_static! { + static ref BLE_STATE: Arc> = Arc::new(Mutex::new(BleState::default())); +} + +#[cfg(not(target_os = "android"))] +#[derive(Default)] +struct BleState { + scan_results: Vec, + connected_device: Option, + latest_hr: Option, + scanning: bool, + connected: bool, +} + +// Standard BLE Heart Rate Service UUID +const HR_SERVICE_UUID: &str = "0000180d-0000-1000-8000-00805f9b34fb"; +const HR_MEASUREMENT_UUID: &str = "00002a37-0000-1000-8000-00805f9b34fb"; + +// FitPro Nordic UART UUIDs +const FITPRO_SERVICE: &str = "6e400001-b5a3-f393-e0a9-e50e24dcca9d"; +const FITPRO_WRITE: &str = "6e400002-b5a3-f393-e0a9-e50e24dcca9d"; +const FITPRO_NOTIFY: &str = "6e400003-b5a3-f393-e0a9-e50e24dcca9d"; + +// FitPro protocol packets +const FITPRO_PAIR: &[u8] = &[0xCD, 0x00, 0x06, 0x12, 0x01, 0x0A, 0x00, 0x01, 0x02]; +const FITPRO_HR_SWITCH_ON: &[u8] = &[0xCD, 0x00, 0x06, 0x12, 0x01, 0x0D, 0x00, 0x01, 0x01]; +const FITPRO_MEASURE_START: &[u8] = &[0xCD, 0x00, 0x07, 0x12, 0x01, 0x24, 0x00, 0x02, 0x00, 0x01]; +const FITPRO_REALTIME_ON: &[u8] = &[0xCD, 0x00, 0x06, 0x15, 0x01, 0x06, 0x00, 0x01, 0x01]; + +#[cfg(not(target_os = "android"))] +mod desktop { + use super::*; + use btleplug::api::{ + Central, Manager as _, Peripheral as _, ScanFilter, CharPropFlags, WriteType, + }; + use btleplug::platform::{Manager, Peripheral, Adapter}; + use std::time::Duration; + use uuid::Uuid; + + fn uuid(s: &str) -> Uuid { + Uuid::parse_str(s).unwrap() + } + + async fn get_adapter() -> Result { + let manager = Manager::new().await.map_err(|e| format!("BLE manager: {}", e))?; + let adapters = manager.adapters().await.map_err(|e| format!("BLE adapters: {}", e))?; + adapters.into_iter().next().ok_or_else(|| "No BLE adapter found".to_string()) + } + + fn detect_device_type(name: &str, services: &[Uuid]) -> String { + // Check for standard HR service + if services.contains(&uuid(HR_SERVICE_UUID)) { + return "hr_standard".to_string(); + } + // Check for FitPro Nordic UART + if services.contains(&uuid(FITPRO_SERVICE)) { + return "fitpro".to_string(); + } + // Heuristic by name + let lower = name.to_lowercase(); + if lower.contains("fitpro") || lower.contains("band") || lower.contains("m7") || lower.contains("m8") { + return "fitpro".to_string(); + } + if lower.contains("coospo") || lower.contains("polar") || lower.contains("garmin") + || lower.contains("wahoo") || lower.contains("hr") { + return "hr_standard".to_string(); + } + "unknown".to_string() + } + + pub async fn scan_devices(duration_secs: u64) -> Result, String> { + { + let mut state = BLE_STATE.lock().unwrap(); + state.scanning = true; + state.scan_results.clear(); + } + + let adapter = get_adapter().await?; + adapter.start_scan(ScanFilter::default()) + .await.map_err(|e| format!("Scan start: {}", e))?; + + tokio::time::sleep(Duration::from_secs(duration_secs)).await; + + adapter.stop_scan().await.map_err(|e| format!("Scan stop: {}", e))?; + + let peripherals = adapter.peripherals().await.map_err(|e| format!("Peripherals: {}", e))?; + let mut devices = Vec::new(); + + for p in peripherals { + if let Ok(Some(props)) = p.properties().await { + let name = props.local_name.unwrap_or_default(); + if name.is_empty() { continue; } + let services = props.services; + let device_type = detect_device_type(&name, &services); + let rssi = props.rssi.unwrap_or(0) as i32; + let address = p.address().to_string(); + devices.push(BleDevice { name, address, device_type, rssi }); + } + } + + { + let mut state = BLE_STATE.lock().unwrap(); + state.scan_results = devices.clone(); + state.scanning = false; + } + + Ok(devices) + } + + pub async fn connect_device(address: &str, device_type: &str) -> Result { + let adapter = get_adapter().await?; + let peripherals = adapter.peripherals().await.map_err(|e| format!("{}", e))?; + + let peripheral = peripherals.into_iter() + .find(|p| p.address().to_string() == address) + .ok_or_else(|| format!("Device {} not found. Run scan first.", address))?; + + peripheral.connect().await.map_err(|e| format!("Connect: {}", e))?; + peripheral.discover_services().await.map_err(|e| format!("Discover: {}", e))?; + + match device_type { + "hr_standard" => setup_standard_hr(&peripheral).await?, + "fitpro" => setup_fitpro(&peripheral).await?, + _ => return Err(format!("Unknown device type: {}", device_type)), + } + + { + let mut state = BLE_STATE.lock().unwrap(); + state.connected = true; + state.connected_device = Some(address.to_string()); + } + + // Spawn background task to read notifications + let dev_type = device_type.to_string(); + tokio::spawn(async move { + read_notifications(peripheral, &dev_type).await; + }); + + Ok(format!("Connected to {}", address)) + } + + async fn setup_standard_hr(peripheral: &Peripheral) -> Result<(), String> { + let chars = peripheral.characteristics(); + let hr_char = chars.iter() + .find(|c| c.uuid == uuid(HR_MEASUREMENT_UUID) && c.properties.contains(CharPropFlags::NOTIFY)) + .ok_or("HR measurement characteristic not found")?; + peripheral.subscribe(hr_char).await.map_err(|e| format!("Subscribe HR: {}", e))?; + Ok(()) + } + + async fn setup_fitpro(peripheral: &Peripheral) -> Result<(), String> { + let chars = peripheral.characteristics(); + let write_char = chars.iter() + .find(|c| c.uuid == uuid(FITPRO_WRITE)) + .ok_or("FitPro write characteristic not found")?.clone(); + let notify_char = chars.iter() + .find(|c| c.uuid == uuid(FITPRO_NOTIFY)) + .ok_or("FitPro notify characteristic not found")?; + + // Subscribe to notifications first + peripheral.subscribe(notify_char).await.map_err(|e| format!("Subscribe: {}", e))?; + tokio::time::sleep(Duration::from_millis(500)).await; + + // Send PAIR command + peripheral.write(&write_char, FITPRO_PAIR, WriteType::WithoutResponse) + .await.map_err(|e| format!("PAIR: {}", e))?; + tokio::time::sleep(Duration::from_millis(1000)).await; + + // Enable HR switch + peripheral.write(&write_char, FITPRO_HR_SWITCH_ON, WriteType::WithoutResponse) + .await.map_err(|e| format!("HR switch: {}", e))?; + tokio::time::sleep(Duration::from_millis(500)).await; + + // Enable realtime data + peripheral.write(&write_char, FITPRO_REALTIME_ON, WriteType::WithoutResponse) + .await.map_err(|e| format!("Realtime: {}", e))?; + tokio::time::sleep(Duration::from_millis(500)).await; + + // Start measurement + peripheral.write(&write_char, FITPRO_MEASURE_START, WriteType::WithoutResponse) + .await.map_err(|e| format!("Measure: {}", e))?; + + Ok(()) + } + + async fn read_notifications(peripheral: Peripheral, device_type: &str) { + use btleplug::api::Peripheral as _; + use tokio_stream::StreamExt; + + let mut stream = match peripheral.notifications().await { + Ok(s) => s, + Err(e) => { eprintln!("[BLE] notification stream error: {}", e); return; } + }; + + while let Some(notification) = stream.next().await { + let data = ¬ification.value; + let reading = match device_type { + "hr_standard" => parse_standard_hr(data), + "fitpro" => parse_fitpro_reading(data), + _ => None, + }; + if let Some(mut r) = reading { + r.timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default().as_secs(); + r.device = device_type.to_string(); + let mut state = BLE_STATE.lock().unwrap(); + state.latest_hr = Some(r); + } + } + + // Stream ended — device disconnected + let mut state = BLE_STATE.lock().unwrap(); + state.connected = false; + state.connected_device = None; + } + + fn parse_standard_hr(data: &[u8]) -> Option { + if data.is_empty() { return None; } + let flags = data[0]; + let hr = if flags & 0x01 == 0 { + *data.get(1)? as u16 + } else { + if data.len() < 3 { return None; } + u16::from_le_bytes([data[1], data[2]]) + }; + if hr == 0 { return None; } + Some(HrReading { hr, ..Default::default() }) + } + + fn parse_fitpro_reading(data: &[u8]) -> Option { + if data.len() < 8 || data[0] != 0xCD { return None; } + let cmd_id = data[3]; + let key_id = data[5]; + + // Measure Day Data (cmd=21, key=14): [date 2b][?? 2b][ts 4b][HR][BP_dia][BP_sys][SpO2] + if cmd_id == 21 && key_id == 14 && data.len() >= 20 { + let hr = data[16]; + let bp_dia = data[17]; + let bp_sys = data[18]; + let spo2 = data[19]; + if hr > 30 && hr < 220 { + return Some(HrReading { + hr: hr as u16, + bp_dia: if bp_dia > 0 { Some(bp_dia) } else { None }, + bp_sys: if bp_sys > 0 { Some(bp_sys) } else { None }, + spo2: if spo2 > 0 && spo2 <= 100 { Some(spo2) } else { None }, + ..Default::default() + }); + } + } + + // Realtime data (cmd=21, key=11) + if cmd_id == 21 && key_id == 11 && data.len() >= 12 { + for &byte in &data[8..] { + if byte > 40 && byte < 200 { + return Some(HrReading { hr: byte as u16, ..Default::default() }); + } + } + } + + None + } + + pub async fn disconnect_device() -> Result { + let address = { + let state = BLE_STATE.lock().unwrap(); + state.connected_device.clone() + }; + if let Some(addr) = address { + let adapter = get_adapter().await?; + let peripherals = adapter.peripherals().await.map_err(|e| format!("{}", e))?; + if let Some(p) = peripherals.into_iter().find(|p| p.address().to_string() == addr) { + let _ = p.disconnect().await; + } + } + let mut state = BLE_STATE.lock().unwrap(); + state.connected = false; + state.connected_device = None; + state.latest_hr = None; + Ok("Disconnected".to_string()) + } + + pub fn get_latest_hr() -> Option { + BLE_STATE.lock().unwrap().latest_hr.clone() + } + + pub fn is_connected() -> bool { + BLE_STATE.lock().unwrap().connected + } +} + +// --- Tauri commands --- + +// --- Android: BLE via Kotlin service + file IPC --- +#[cfg(target_os = "android")] +mod android { + use super::*; + use tauri::Manager; + use std::path::PathBuf; + + fn get_data_dir(app: &tauri::AppHandle) -> Result { + app.path().app_data_dir().map_err(|e: tauri::Error| e.to_string()) + } + + fn app_root(data_dir: &std::path::Path) -> Option { + data_dir.parent().and_then(|p| p.parent()).map(|r| r.to_path_buf()) + } + + pub fn send_command(app: &tauri::AppHandle, cmd: &str) -> Result<(), String> { + let data_dir = get_data_dir(app)?; + // Use gps_command.txt — the only file that FileObserver reliably watches + let cmd_path = data_dir.join("gps_command.txt"); + std::fs::write(&cmd_path, cmd).map_err(|e| e.to_string())?; + if let Some(root) = app_root(&data_dir) { + let _ = std::fs::write(root.join("gps_command.txt"), cmd); + } + Ok(()) + } + + pub fn read_file(app: &tauri::AppHandle, filename: &str) -> Option { + let data_dir = get_data_dir(app).ok()?; + let paths: Vec = vec![ + data_dir.join(filename), + app_root(&data_dir).map(|r| r.join(filename)).unwrap_or_default(), + ]; + for p in &paths { + if p.exists() { + if let Ok(s) = std::fs::read_to_string(p) { + if !s.trim().is_empty() { return Some(s); } + } + } + } + None + } + + pub fn read_scan_results(app: &tauri::AppHandle) -> Result, String> { + if let Some(s) = read_file(app, "ble_scan_results.json") { + if let Ok(devices) = serde_json::from_str::>(&s) { + return Ok(devices); + } + } + Ok(vec![]) + } + + pub fn read_hr_data(app: &tauri::AppHandle) -> Result, String> { + if let Some(s) = read_file(app, "ble_hr_data.json") { + if let Ok(r) = serde_json::from_str::(&s) { + if r.hr > 0 { return Ok(Some(r)); } + } + } + Ok(None) + } +} + +// --- Tauri commands --- + +#[tauri::command] +pub async fn ble_scan( + app: tauri::AppHandle, + duration_secs: Option, +) -> Result, String> { + #[cfg(not(target_os = "android"))] + { let _ = &app; desktop::scan_devices(duration_secs.unwrap_or(5)).await } + #[cfg(target_os = "android")] + { + let _ = duration_secs; + android::send_command(&app, "ble_scan")?; + // Return immediately — JS will call ble_scan_results after delay + Ok(vec![]) + } +} + +#[tauri::command] +pub fn ble_scan_results(app: tauri::AppHandle) -> Result, String> { + #[cfg(not(target_os = "android"))] + { let _ = &app; Ok(vec![]) } // desktop returns results directly from ble_scan + #[cfg(target_os = "android")] + { android::read_scan_results(&app) } +} + +#[tauri::command] +pub async fn ble_connect( + app: tauri::AppHandle, + address: String, + device_type: String, +) -> Result { + #[cfg(not(target_os = "android"))] + { let _ = &app; desktop::connect_device(&address, &device_type).await } + #[cfg(target_os = "android")] + { + android::send_command(&app, &format!("ble_connect:{}:{}", address, device_type))?; + Ok(format!("Connecting to {}", address)) + } +} + +#[tauri::command] +pub async fn ble_disconnect( + app: tauri::AppHandle, +) -> Result { + #[cfg(not(target_os = "android"))] + { let _ = &app; desktop::disconnect_device().await } + #[cfg(target_os = "android")] + { + android::send_command(&app, "ble_disconnect")?; + Ok("Disconnecting".to_string()) + } +} + +#[tauri::command] +pub fn ble_get_hr( + app: tauri::AppHandle, +) -> Result, String> { + #[cfg(not(target_os = "android"))] + { let _ = &app; Ok(desktop::get_latest_hr()) } + #[cfg(target_os = "android")] + { android::read_hr_data(&app) } +} + +#[tauri::command] +pub fn ble_is_connected( + app: tauri::AppHandle, +) -> Result { + #[cfg(not(target_os = "android"))] + { let _ = &app; Ok(desktop::is_connected()) } + #[cfg(target_os = "android")] + { + match android::read_hr_data(&app) { + Ok(Some(r)) => { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs(); + Ok(now - r.timestamp < 30) + } + _ => Ok(false) + } + } +} + +#[tauri::command] +pub fn ble_save_device(app: tauri::AppHandle, address: String, device_type: String) -> Result<(), String> { + use tauri::Manager; + let data_dir = app.path().app_data_dir().map_err(|e: tauri::Error| e.to_string())?; + let json = format!(r#"{{"addr":"{}","type":"{}"}}"#, address, device_type); + // Write to app_data_dir + std::fs::write(data_dir.join("ble_device.json"), &json).ok(); + // Write to app root (Android: Kotlin reads from here) + if let Some(root) = data_dir.parent().and_then(|p| p.parent()) { + std::fs::write(root.join("ble_device.json"), &json).ok(); + } + Ok(()) +} + +#[tauri::command] +pub fn ble_check_bluetooth( + app: tauri::AppHandle, +) -> Result { + #[cfg(not(target_os = "android"))] + { let _ = &app; Ok("on".to_string()) } // desktop: assume BT available + #[cfg(target_os = "android")] + { + // Read bt_state.txt written by Kotlin + if let Some(s) = android::read_file(&app, "bt_state.txt") { + Ok(s.trim().to_string()) + } else { + Ok("unknown".to_string()) + } + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f8e2a66..8e497a5 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -4,6 +4,9 @@ use tauri_plugin_sql::{Migration, MigrationKind}; use tauri::Manager; use serde::Serialize; +#[allow(unused, dead_code)] +mod ble; + #[derive(Serialize)] struct AppInfo { version: String, @@ -20,6 +23,25 @@ fn get_app_info() -> AppInfo { } } +// Read update check result written by Kotlin (update_info.json) +#[tauri::command] +fn get_update_info(app: tauri::AppHandle) -> Result { + let data_dir = app.path().app_data_dir().map_err(|e| e.to_string())?; + let paths = [ + data_dir.join("update_info.json"), + data_dir.parent().and_then(|p| p.parent()).map(|r| r.join("update_info.json")).unwrap_or_default(), + ]; + for p in &paths { + if p.exists() { + if let Ok(s) = std::fs::read_to_string(p) { + let s = s.trim().to_string(); + if !s.is_empty() { return Ok(s); } + } + } + } + Ok("{}".to_string()) +} + // GPS service control — writes command file, Kotlin reads and starts/stops service #[tauri::command] fn gps_service_command(app: tauri::AppHandle, cmd: String) -> Result { @@ -511,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]) + .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]) .run(tauri::generate_context!()) .expect("error while running tauri application"); } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 721e6a3..c586f74 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,13 +1,16 @@ { "$schema": "https://raw.githubusercontent.com/nicegui/tauri-v2/refs/heads/main/tooling/cli/schema.json", "productName": "BalDav", - "version": "0.1.74", + "version": "0.1.224", "identifier": "dev.baldav.app", "build": { "frontendDist": "../frontend" }, "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" + }, "windows": [ { "title": "BalDav",