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 = `
-
+
- ${!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 = `
+
+ --
+ bpm
+
+
+
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