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:
parent
0bafb2b317
commit
7c93bb75c3
14 changed files with 2545 additions and 181 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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; },
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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")) {
|
||||
|
|
|
|||
|
|
@ -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
360
src-tauri/Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
56
src-tauri/gen/android/app/src/main/AndroidManifest.xml
Normal file
56
src-tauri/gen/android/app/src/main/AndroidManifest.xml
Normal 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>
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
499
src-tauri/src/ble.rs
Normal 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 = ¬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<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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue