OTA updates, BLE HR, water tracking, GPS altitude filter, BMR fix, nutrient units

OTA: VPS nginx endpoint, build.sh -u upload flag, Kotlin check + browser download
BLE HR: FitPro protocol (PAIR/CCCD/HR_SWITCH/REALTIME/MEASURE), GATT queue,
  ACK responses, auto-detect device type, auto-start with GPS, re-trigger 25s
  Kotlin BleHrService, btleplug for desktop, file IPC for Android
Water: products.water_pct column, getDailyWaterMl(), progress bar on today screen
GPS altitude: outlier spike filter (>4m), stuck run interpolation (5+ identical),
  configurable smoothing (altSmoothHalf setting), filtered raw for kcal calc
BMR: removed broken estimateRealBmr, added activity level dropdown (1.2-1.725),
  fixed 7-day trend using dailyBase instead of raw bmr
Nutrients: ginseng/resveratrol/coq10 columns, fixed folate/selenium units (removed
  mult:1000), iodine/chromium input in mcg, addSupplement now inserts all 41 columns
UI: parseAmount() for 4*30 expressions, recipe portion amount field, nutrient
  reserves strip, GPS sessions in today view, pan on charts, showToast(),
  HR norms in settings, HR on recording screen, HR chart in track details
Scrapers: 5ka Load JSON button, magnit_scraper.user.js (NUXT_DATA parsing)
This commit is contained in:
4aiman 2026-06-07 01:36:38 +03:00
commit 7c93bb75c3
14 changed files with 2545 additions and 181 deletions

View file

@ -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;

View file

@ -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; },
};

View file

@ -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")) {

View file

@ -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");

File diff suppressed because it is too large Load diff

360
src-tauri/Cargo.lock generated
View file

@ -82,6 +82,17 @@ version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
[[package]]
name = "async-trait"
version = "0.1.89"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "atk"
version = "0.18.2"
@ -128,14 +139,19 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "baldav"
version = "0.1.74"
version = "0.1.224"
dependencies = [
"btleplug",
"lazy_static",
"rusqlite",
"serde",
"serde_json",
"tauri",
"tauri-build",
"tauri-plugin-sql",
"tokio",
"tokio-stream",
"uuid",
]
[[package]]
@ -207,13 +223,51 @@ dependencies = [
"generic-array",
]
[[package]]
name = "block2"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f"
dependencies = [
"objc2 0.5.2",
]
[[package]]
name = "block2"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5"
dependencies = [
"objc2",
"objc2 0.6.4",
]
[[package]]
name = "bluez-async"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84ae4213cc2a8dc663acecac67bbdad05142be4d8ef372b6903abf878b0c690a"
dependencies = [
"bitflags 2.11.1",
"bluez-generated",
"dbus",
"dbus-tokio",
"futures",
"itertools",
"log",
"serde",
"serde-xml-rs",
"thiserror 2.0.18",
"tokio",
"uuid",
]
[[package]]
name = "bluez-generated"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9676783265eadd6f11829982792c6f303f3854d014edfba384685dcf237dd062"
dependencies = [
"dbus",
]
[[package]]
@ -270,6 +324,36 @@ dependencies = [
"tinyvec",
]
[[package]]
name = "btleplug"
version = "0.11.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c9a11621cb2c8c024e444734292482b1ad86fb50ded066cf46252e46643c8748"
dependencies = [
"async-trait",
"bitflags 2.11.1",
"bluez-async",
"dashmap 6.2.1",
"dbus",
"futures",
"jni 0.19.0",
"jni-utils",
"log",
"objc2 0.5.2",
"objc2-core-bluetooth",
"objc2-foundation 0.2.2",
"once_cell",
"serde",
"serde_bytes",
"static_assertions",
"thiserror 2.0.18",
"tokio",
"tokio-stream",
"uuid",
"windows",
"windows-future",
]
[[package]]
name = "bumpalo"
version = "3.20.2"
@ -662,17 +746,57 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "dashmap"
version = "5.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856"
dependencies = [
"cfg-if",
"hashbrown 0.14.5",
"lock_api",
"once_cell",
"parking_lot_core",
]
[[package]]
name = "dashmap"
version = "6.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c"
dependencies = [
"cfg-if",
"crossbeam-utils",
"hashbrown 0.14.5",
"lock_api",
"once_cell",
"parking_lot_core",
]
[[package]]
name = "dbus"
version = "0.9.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b942602992bb7acfd1f51c49811c58a610ef9181b6e66f3e519d79b540a3bf73"
dependencies = [
"futures-channel",
"futures-util",
"libc",
"libdbus-sys",
"windows-sys 0.61.2",
]
[[package]]
name = "dbus-tokio"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "007688d459bc677131c063a3a77fb899526e17b7980f390b69644bdbc41fad13"
dependencies = [
"dbus",
"libc",
"tokio",
]
[[package]]
name = "der"
version = "0.7.10"
@ -755,9 +879,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
dependencies = [
"bitflags 2.11.1",
"block2",
"block2 0.6.2",
"libc",
"objc2",
"objc2 0.6.4",
]
[[package]]
@ -1068,6 +1192,21 @@ version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
[[package]]
name = "futures"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d"
dependencies = [
"futures-channel",
"futures-core",
"futures-executor",
"futures-io",
"futures-sink",
"futures-task",
"futures-util",
]
[[package]]
name = "futures-channel"
version = "0.3.32"
@ -1141,6 +1280,7 @@ version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
dependencies = [
"futures-channel",
"futures-core",
"futures-io",
"futures-macro",
@ -1811,6 +1951,15 @@ version = "2.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
[[package]]
name = "itertools"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
dependencies = [
"either",
]
[[package]]
name = "itoa"
version = "1.0.18"
@ -1840,6 +1989,20 @@ dependencies = [
"system-deps",
]
[[package]]
name = "jni"
version = "0.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6df18c2e3db7e453d3c6ac5b3e9d5182664d28788126d39b91f2d1e22b017ec"
dependencies = [
"cesu8",
"combine",
"jni-sys 0.3.1",
"log",
"thiserror 1.0.69",
"walkdir",
]
[[package]]
name = "jni"
version = "0.21.1"
@ -1884,6 +2047,21 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "jni-utils"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "259e9f2c3ead61de911f147000660511f07ab00adeed1d84f5ac4d0386e7a6c4"
dependencies = [
"dashmap 5.5.3",
"futures",
"jni 0.19.0",
"log",
"once_cell",
"static_assertions",
"uuid",
]
[[package]]
name = "js-sys"
version = "0.3.98"
@ -2128,10 +2306,10 @@ dependencies = [
"dpi",
"gtk",
"keyboard-types",
"objc2",
"objc2 0.6.4",
"objc2-app-kit",
"objc2-core-foundation",
"objc2-foundation",
"objc2-foundation 0.3.2",
"once_cell",
"png 0.18.1",
"serde",
@ -2253,6 +2431,22 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "objc-sys"
version = "0.3.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310"
[[package]]
name = "objc2"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804"
dependencies = [
"objc-sys",
"objc2-encode",
]
[[package]]
name = "objc2"
version = "0.6.4"
@ -2270,10 +2464,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
dependencies = [
"bitflags 2.11.1",
"block2",
"objc2",
"block2 0.6.2",
"objc2 0.6.4",
"objc2-core-foundation",
"objc2-foundation",
"objc2-foundation 0.3.2",
]
[[package]]
@ -2283,8 +2477,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c"
dependencies = [
"bitflags 2.11.1",
"objc2",
"objc2-foundation",
"objc2 0.6.4",
"objc2-foundation 0.3.2",
]
[[package]]
name = "objc2-core-bluetooth"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a644b62ffb826a5277f536cf0f701493de420b13d40e700c452c36567771111"
dependencies = [
"bitflags 2.11.1",
"objc2 0.5.2",
"objc2-foundation 0.2.2",
]
[[package]]
@ -2293,8 +2498,8 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa"
dependencies = [
"objc2",
"objc2-foundation",
"objc2 0.6.4",
"objc2-foundation 0.3.2",
]
[[package]]
@ -2305,7 +2510,7 @@ checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
dependencies = [
"bitflags 2.11.1",
"dispatch2",
"objc2",
"objc2 0.6.4",
]
[[package]]
@ -2316,7 +2521,7 @@ checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807"
dependencies = [
"bitflags 2.11.1",
"dispatch2",
"objc2",
"objc2 0.6.4",
"objc2-core-foundation",
"objc2-io-surface",
]
@ -2327,8 +2532,8 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006"
dependencies = [
"objc2",
"objc2-foundation",
"objc2 0.6.4",
"objc2-foundation 0.3.2",
]
[[package]]
@ -2337,8 +2542,8 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009"
dependencies = [
"objc2",
"objc2-foundation",
"objc2 0.6.4",
"objc2-foundation 0.3.2",
]
[[package]]
@ -2348,7 +2553,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d"
dependencies = [
"bitflags 2.11.1",
"objc2",
"objc2 0.6.4",
"objc2-core-foundation",
"objc2-core-graphics",
]
@ -2368,6 +2573,18 @@ dependencies = [
"cc",
]
[[package]]
name = "objc2-foundation"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8"
dependencies = [
"bitflags 2.11.1",
"block2 0.5.1",
"libc",
"objc2 0.5.2",
]
[[package]]
name = "objc2-foundation"
version = "0.3.2"
@ -2375,8 +2592,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [
"bitflags 2.11.1",
"block2",
"objc2",
"block2 0.6.2",
"objc2 0.6.4",
"objc2-core-foundation",
]
@ -2387,7 +2604,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d"
dependencies = [
"bitflags 2.11.1",
"objc2",
"objc2 0.6.4",
"objc2-core-foundation",
]
@ -2398,9 +2615,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f"
dependencies = [
"bitflags 2.11.1",
"objc2",
"objc2 0.6.4",
"objc2-core-foundation",
"objc2-foundation",
"objc2-foundation 0.3.2",
]
[[package]]
@ -2410,8 +2627,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22"
dependencies = [
"bitflags 2.11.1",
"block2",
"objc2",
"block2 0.6.2",
"objc2 0.6.4",
"objc2-cloud-kit",
"objc2-core-data",
"objc2-core-foundation",
@ -2419,7 +2636,7 @@ dependencies = [
"objc2-core-image",
"objc2-core-location",
"objc2-core-text",
"objc2-foundation",
"objc2-foundation 0.3.2",
"objc2-quartz-core",
"objc2-user-notifications",
]
@ -2430,8 +2647,8 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e"
dependencies = [
"objc2",
"objc2-foundation",
"objc2 0.6.4",
"objc2-foundation 0.3.2",
]
[[package]]
@ -2441,11 +2658,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f"
dependencies = [
"bitflags 2.11.1",
"block2",
"objc2",
"block2 0.6.2",
"objc2 0.6.4",
"objc2-app-kit",
"objc2-core-foundation",
"objc2-foundation",
"objc2-foundation 0.3.2",
]
[[package]]
@ -3224,6 +3441,28 @@ dependencies = [
"typeid",
]
[[package]]
name = "serde-xml-rs"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc2215ce3e6a77550b80a1c37251b7d294febaf42e36e21b7b411e0bf54d540d"
dependencies = [
"log",
"serde",
"thiserror 2.0.18",
"xml",
]
[[package]]
name = "serde_bytes"
version = "0.11.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8"
dependencies = [
"serde",
"serde_core",
]
[[package]]
name = "serde_core"
version = "1.0.228"
@ -3462,10 +3701,10 @@ dependencies = [
"bytemuck",
"js-sys",
"ndk",
"objc2",
"objc2 0.6.4",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-foundation",
"objc2-foundation 0.3.2",
"objc2-quartz-core",
"raw-window-handle",
"redox_syscall 0.5.18",
@ -3741,6 +3980,12 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "static_assertions"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
[[package]]
name = "string_cache"
version = "0.9.0"
@ -3861,7 +4106,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a33f7f9e486ade65fcf1e45c440f9236c904f5c1002cdc7fc6ae582777345ce4"
dependencies = [
"bitflags 2.11.1",
"block2",
"block2 0.6.2",
"core-foundation",
"core-graphics",
"crossbeam-channel",
@ -3872,14 +4117,14 @@ dependencies = [
"gdkwayland-sys",
"gdkx11-sys",
"gtk",
"jni",
"jni 0.21.1",
"libc",
"log",
"ndk",
"ndk-sys",
"objc2",
"objc2 0.6.4",
"objc2-app-kit",
"objc2-foundation",
"objc2-foundation 0.3.2",
"objc2-ui-kit",
"once_cell",
"parking_lot",
@ -3934,14 +4179,14 @@ dependencies = [
"gtk",
"heck 0.5.0",
"http",
"jni",
"jni 0.21.1",
"libc",
"log",
"mime",
"muda",
"objc2",
"objc2 0.6.4",
"objc2-app-kit",
"objc2-foundation",
"objc2-foundation 0.3.2",
"objc2-ui-kit",
"objc2-web-kit",
"percent-encoding",
@ -4077,8 +4322,8 @@ dependencies = [
"dpi",
"gtk",
"http",
"jni",
"objc2",
"jni 0.21.1",
"objc2 0.6.4",
"objc2-ui-kit",
"objc2-web-kit",
"raw-window-handle",
@ -4100,9 +4345,9 @@ checksum = "a3989df2ae1c476404fe0a2e8ffc4cfbde97e51efd613c2bb5355fbc9ab52cf0"
dependencies = [
"gtk",
"http",
"jni",
"jni 0.21.1",
"log",
"objc2",
"objc2 0.6.4",
"objc2-app-kit",
"once_cell",
"percent-encoding",
@ -4309,6 +4554,7 @@ dependencies = [
"futures-core",
"pin-project-lite",
"tokio",
"tokio-util",
]
[[package]]
@ -4531,11 +4777,11 @@ dependencies = [
"dirs",
"libappindicator",
"muda",
"objc2",
"objc2 0.6.4",
"objc2-app-kit",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-foundation",
"objc2-foundation 0.3.2",
"once_cell",
"png 0.18.1",
"serde",
@ -5035,10 +5281,10 @@ version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9bec5a31f3f9362f2258fd0e9c9dd61a9ca432e7306cc78c444258f0dce9a9c"
dependencies = [
"objc2",
"objc2 0.6.4",
"objc2-app-kit",
"objc2-core-foundation",
"objc2-foundation",
"objc2-foundation 0.3.2",
"raw-window-handle",
"windows-sys 0.59.0",
"windows-version",
@ -5556,7 +5802,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "186f9871daa55fd9c016578b810d149de58367113db7fb72b462d2323ce19514"
dependencies = [
"base64 0.22.1",
"block2",
"block2 0.6.2",
"cookie",
"crossbeam-channel",
"dirs",
@ -5567,13 +5813,13 @@ dependencies = [
"gtk",
"http",
"javascriptcore-rs",
"jni",
"jni 0.21.1",
"libc",
"ndk",
"objc2",
"objc2 0.6.4",
"objc2-app-kit",
"objc2-core-foundation",
"objc2-foundation",
"objc2-foundation 0.3.2",
"objc2-ui-kit",
"objc2-web-kit",
"once_cell",
@ -5623,6 +5869,12 @@ dependencies = [
"pkg-config",
]
[[package]]
name = "xml"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "636f85e5ca6488e96401b61eb7de54f4e44755c988af0f52cf90230c312a1a89"
[[package]]
name = "yoke"
version = "0.8.2"

View file

@ -1,6 +1,6 @@
[package]
name = "baldav"
version = "0.1.74"
version = "0.1.224"
edition = "2021"
[lib]
@ -16,3 +16,11 @@ tauri-plugin-sql = { version = "2", features = ["sqlite"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
rusqlite = { version = "0.31", features = ["bundled"] }
lazy_static = "1"
[target.'cfg(not(target_os = "android"))'.dependencies]
btleplug = { version = "0.11", features = ["serde"] }
uuid = "1"
tokio = { version = "1", features = ["rt-multi-thread", "sync", "time"] }
tokio-stream = "0.1"

View file

@ -0,0 +1,56 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" android:usesPermissionFlags="neverForLocation" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<!-- AndroidTV support -->
<uses-feature android:name="android.software.leanback" android:required="false" />
<application
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:theme="@style/Theme.baldav"
android:usesCleartextTraffic="${usesCleartextTraffic}">
<activity
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode"
android:launchMode="singleTask"
android:label="@string/main_activity_title"
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<!-- AndroidTV support -->
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
</activity>
<service
android:name=".GpsRecorderService"
android:foregroundServiceType="location"
android:exported="false" />
<service
android:name=".BleHrService"
android:foregroundServiceType="connectedDevice"
android:exported="false" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>

View file

@ -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<String>()
val seenAddresses = mutableSetOf<String>()
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()
}
}

View file

@ -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<String>()
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()

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<external-path name="my_images" path="." />
<cache-path name="my_cache_images" path="." />
<files-path name="apk_updates" path="." />
</paths>

499
src-tauri/src/ble.rs Normal file
View file

@ -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<u8>,
#[serde(default)]
pub bp_sys: Option<u8>,
#[serde(default)]
pub bp_dia: Option<u8>,
#[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<Mutex<BleState>> = Arc::new(Mutex::new(BleState::default()));
}
#[cfg(not(target_os = "android"))]
#[derive(Default)]
struct BleState {
scan_results: Vec<BleDevice>,
connected_device: Option<String>,
latest_hr: Option<HrReading>,
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<Adapter, String> {
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<Vec<BleDevice>, 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<String, String> {
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 = &notification.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<HrReading> {
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<HrReading> {
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<String, String> {
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<HrReading> {
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<PathBuf, String> {
app.path().app_data_dir().map_err(|e: tauri::Error| e.to_string())
}
fn app_root(data_dir: &std::path::Path) -> Option<PathBuf> {
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<String> {
let data_dir = get_data_dir(app).ok()?;
let paths: Vec<PathBuf> = 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<Vec<BleDevice>, String> {
if let Some(s) = read_file(app, "ble_scan_results.json") {
if let Ok(devices) = serde_json::from_str::<Vec<BleDevice>>(&s) {
return Ok(devices);
}
}
Ok(vec![])
}
pub fn read_hr_data(app: &tauri::AppHandle) -> Result<Option<HrReading>, String> {
if let Some(s) = read_file(app, "ble_hr_data.json") {
if let Ok(r) = serde_json::from_str::<HrReading>(&s) {
if r.hr > 0 { return Ok(Some(r)); }
}
}
Ok(None)
}
}
// --- Tauri commands ---
#[tauri::command]
pub async fn ble_scan(
app: tauri::AppHandle,
duration_secs: Option<u64>,
) -> Result<Vec<BleDevice>, String> {
#[cfg(not(target_os = "android"))]
{ let _ = &app; desktop::scan_devices(duration_secs.unwrap_or(5)).await }
#[cfg(target_os = "android")]
{
let _ = duration_secs;
android::send_command(&app, "ble_scan")?;
// Return immediately — JS will call ble_scan_results after delay
Ok(vec![])
}
}
#[tauri::command]
pub fn ble_scan_results(app: tauri::AppHandle) -> Result<Vec<BleDevice>, String> {
#[cfg(not(target_os = "android"))]
{ let _ = &app; Ok(vec![]) } // desktop returns results directly from ble_scan
#[cfg(target_os = "android")]
{ android::read_scan_results(&app) }
}
#[tauri::command]
pub async fn ble_connect(
app: tauri::AppHandle,
address: String,
device_type: String,
) -> Result<String, String> {
#[cfg(not(target_os = "android"))]
{ let _ = &app; desktop::connect_device(&address, &device_type).await }
#[cfg(target_os = "android")]
{
android::send_command(&app, &format!("ble_connect:{}:{}", address, device_type))?;
Ok(format!("Connecting to {}", address))
}
}
#[tauri::command]
pub async fn ble_disconnect(
app: tauri::AppHandle,
) -> Result<String, String> {
#[cfg(not(target_os = "android"))]
{ let _ = &app; desktop::disconnect_device().await }
#[cfg(target_os = "android")]
{
android::send_command(&app, "ble_disconnect")?;
Ok("Disconnecting".to_string())
}
}
#[tauri::command]
pub fn ble_get_hr(
app: tauri::AppHandle,
) -> Result<Option<HrReading>, String> {
#[cfg(not(target_os = "android"))]
{ let _ = &app; Ok(desktop::get_latest_hr()) }
#[cfg(target_os = "android")]
{ android::read_hr_data(&app) }
}
#[tauri::command]
pub fn ble_is_connected(
app: tauri::AppHandle,
) -> Result<bool, String> {
#[cfg(not(target_os = "android"))]
{ let _ = &app; Ok(desktop::is_connected()) }
#[cfg(target_os = "android")]
{
match android::read_hr_data(&app) {
Ok(Some(r)) => {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs();
Ok(now - r.timestamp < 30)
}
_ => Ok(false)
}
}
}
#[tauri::command]
pub fn ble_save_device(app: tauri::AppHandle, address: String, device_type: String) -> Result<(), String> {
use tauri::Manager;
let data_dir = app.path().app_data_dir().map_err(|e: tauri::Error| e.to_string())?;
let json = format!(r#"{{"addr":"{}","type":"{}"}}"#, address, device_type);
// Write to app_data_dir
std::fs::write(data_dir.join("ble_device.json"), &json).ok();
// Write to app root (Android: Kotlin reads from here)
if let Some(root) = data_dir.parent().and_then(|p| p.parent()) {
std::fs::write(root.join("ble_device.json"), &json).ok();
}
Ok(())
}
#[tauri::command]
pub fn ble_check_bluetooth(
app: tauri::AppHandle,
) -> Result<String, String> {
#[cfg(not(target_os = "android"))]
{ let _ = &app; Ok("on".to_string()) } // desktop: assume BT available
#[cfg(target_os = "android")]
{
// Read bt_state.txt written by Kotlin
if let Some(s) = android::read_file(&app, "bt_state.txt") {
Ok(s.trim().to_string())
} else {
Ok("unknown".to_string())
}
}
}

View file

@ -4,6 +4,9 @@ use tauri_plugin_sql::{Migration, MigrationKind};
use tauri::Manager;
use serde::Serialize;
#[allow(unused, dead_code)]
mod ble;
#[derive(Serialize)]
struct AppInfo {
version: String,
@ -20,6 +23,25 @@ fn get_app_info() -> AppInfo {
}
}
// Read update check result written by Kotlin (update_info.json)
#[tauri::command]
fn get_update_info(app: tauri::AppHandle) -> Result<String, String> {
let data_dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
let paths = [
data_dir.join("update_info.json"),
data_dir.parent().and_then(|p| p.parent()).map(|r| r.join("update_info.json")).unwrap_or_default(),
];
for p in &paths {
if p.exists() {
if let Ok(s) = std::fs::read_to_string(p) {
let s = s.trim().to_string();
if !s.is_empty() { return Ok(s); }
}
}
}
Ok("{}".to_string())
}
// GPS service control — writes command file, Kotlin reads and starts/stops service
#[tauri::command]
fn gps_service_command(app: tauri::AppHandle, cmd: String) -> Result<String, String> {
@ -511,7 +533,7 @@ pub fn run() {
.add_migrations("sqlite:baldav.db", migrations)
.build(),
)
.invoke_handler(tauri::generate_handler![get_safe_insets, import_seed_products, get_app_info, gps_service_command, gps_service_read_points, save_file])
.invoke_handler(tauri::generate_handler![get_safe_insets, import_seed_products, get_app_info, gps_service_command, gps_service_read_points, save_file, get_update_info, ble::ble_scan, ble::ble_scan_results, ble::ble_connect, ble::ble_disconnect, ble::ble_get_hr, ble::ble_is_connected, ble::ble_check_bluetooth, ble::ble_save_device])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

View file

@ -1,13 +1,16 @@
{
"$schema": "https://raw.githubusercontent.com/nicegui/tauri-v2/refs/heads/main/tooling/cli/schema.json",
"productName": "BalDav",
"version": "0.1.74",
"version": "0.1.224",
"identifier": "dev.baldav.app",
"build": {
"frontendDist": "../frontend"
},
"app": {
"withGlobalTauri": true,
"security": {
"csp": "default-src 'self' 'unsafe-inline' 'unsafe-eval'; connect-src 'self' https://4aiman.top https://*.tile.openstreetmap.org https://*.tile.thunderforest.com; img-src 'self' blob: data: https://*.tile.openstreetmap.org https://*.tile.thunderforest.com"
},
"windows": [
{
"title": "BalDav",