ble: barometer on its own connection - simultaneous HR+baro, auto-connect, UI
Separate BleBaroService so the HR strap and the barometer each hold their own GATT link and work at the same time (one adapter, several LE connections). Verified on device against a PC BlueZ emulator of the sensor: BleHR and BleBaro deliver in parallel, no cross-talk. - BleBaroService: dedicated baro connection (scan-before-connect + direct-connect fallback, ESS subscribe, writes ble_baro_data.json). MainActivity routes baro connects to it and auto-connects both HR (ble_device.json) and baro (baro_device.json) at start. - Rust: ble_baro_disconnect, ble_save_baro_device. - Settings: a 'Датчики (BLE)' section - heart-rate and barometer as separate rows with their own slots, bind/connect/unbind, and a live pressure/temperature/altitude readout. - Tracking: altitude_baro_m stored per point, preferred in elevation stats (null without baro). - Record screen: 'Высота' from the barometer (1 Hz, dot marker), temperature from the sensor. - Fixes: direct-connect on scan timeout; Locale.US JSON (RU decimal comma broke the parse); parsing routed by characteristic (baro no longer written into the pulse); VR/HR stop returns to the tracks list instead of into details. - tools/baro-sensor/emulate_baro.py: PC BlueZ peripheral that emulates the sensor for testing. Emulator-verified only; real board still pending.
This commit is contained in:
parent
82f3dc9d4a
commit
9251c1dd90
12 changed files with 653 additions and 34 deletions
|
|
@ -1460,7 +1460,7 @@ body.dark-map .poi-badge { background: #111; border-color: #eee; }
|
|||
background: var(--surface);
|
||||
}
|
||||
.rec-topbar .rec-top { font-size: 27px; font-weight: 700; color: var(--on-surface); }
|
||||
.rec-topbar .rec-top-mid { color: var(--muted); }
|
||||
.rec-topbar .rec-top-mid { color: var(--on-surface); }
|
||||
|
||||
.rec-lbl {
|
||||
font-size: 10px;
|
||||
|
|
|
|||
|
|
@ -745,6 +745,7 @@ async function addGpsPoint(sessionId, ts, lat, lon, altGps, speedMs, seq, bleDat
|
|||
["accel_rms", sensorData?.accel_rms], ["accel_max", sensorData?.accel_max],
|
||||
["gyro_pitch", sensorData?.gyro_pitch], ["gyro_yaw", sensorData?.gyro_yaw],
|
||||
["bearing_deg", sensorData?.bearing], ["accuracy_m", sensorData?.accuracy],
|
||||
["altitude_baro_m", sensorData?.altitude_baro_m],
|
||||
];
|
||||
for (const [col, val] of extra) {
|
||||
if (_gpsPointCols.has(col)) { cols.push(col); vals.push(val ?? null); }
|
||||
|
|
@ -1676,6 +1677,7 @@ async function runMigrations() {
|
|||
if (!gpsCols.includes("gyro_yaw")) await execute("ALTER TABLE gps_points ADD COLUMN gyro_yaw REAL");
|
||||
if (!gpsCols.includes("bearing_deg")) await execute("ALTER TABLE gps_points ADD COLUMN bearing_deg REAL");
|
||||
if (!gpsCols.includes("accuracy_m")) await execute("ALTER TABLE gps_points ADD COLUMN accuracy_m REAL");
|
||||
if (!gpsCols.includes("altitude_baro_m")) await execute("ALTER TABLE gps_points ADD COLUMN altitude_baro_m REAL"); // barometric altitude from the BLE sensor (null without it)
|
||||
if (!gpsCols.includes("lat_smoothed")) await execute("ALTER TABLE gps_points ADD COLUMN lat_smoothed REAL");
|
||||
if (!gpsCols.includes("lon_smoothed")) await execute("ALTER TABLE gps_points ADD COLUMN lon_smoothed REAL");
|
||||
if (!gpsCols.includes("estimated")) await execute("ALTER TABLE gps_points ADD COLUMN estimated INTEGER DEFAULT 0"); // gap-fill (BRouter) points, reversible
|
||||
|
|
|
|||
|
|
@ -311,7 +311,8 @@ GPS.computeStats = function(points, weightKg, opts) {
|
|||
// e.g. [45, 45, 52, 45, 45] — the 52 is a GPS altitude glitch.
|
||||
// Prefer terrain (SRTM/open-elevation) altitude when available — GPS altitude is noisy
|
||||
// and the stuck-filter destroys its real signal. Terrain is clean and accurate for slope.
|
||||
const rawAlt = sorted.map(p => p.altitude_srtm_m ?? p.altitude ?? p.altitude_gps_m ?? null);
|
||||
// Prefer the barometer (best for real climbs/descents) when present, then terrain (SRTM), then GPS.
|
||||
const rawAlt = sorted.map(p => p.altitude_baro_m ?? p.altitude_srtm_m ?? 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;
|
||||
|
|
@ -1170,6 +1171,15 @@ GPS.recoverFromFiles = async function(onProgress) {
|
|||
// live marker — the same fix is never written twice with different timestamps.
|
||||
GPS._processIncomingPoint = async function(p, fromNative) {
|
||||
if (!GPS.recording.active) return;
|
||||
|
||||
// Attach barometric altitude to this point when the BLE baro sensor is live and anchored.
|
||||
// Null without a barometer, so elevation stats fall back to GPS/SRTM exactly as before.
|
||||
try {
|
||||
if (window.BARO && BARO.isLive()) {
|
||||
let ba = BARO.altitude();
|
||||
if (ba != null && isFinite(ba)) p.altitude_baro_m = ba;
|
||||
}
|
||||
} catch (e) {}
|
||||
// DB writes come only from the source of truth: native points on Android, or
|
||||
// watchPosition on desktop (no native service).
|
||||
const writeDb = !!fromNative || !GPS.recording.nativeService;
|
||||
|
|
|
|||
|
|
@ -4871,8 +4871,8 @@ function renderWorkoutInline(container) {
|
|||
let res = await GPS.stopRecording();
|
||||
if (res && res.sessionId) {
|
||||
showToast(`Зарядка: ${res.kcal} ккал, ${GPS.formatDuration(res.durationSec)}, пульс ${res.avgHr ?? "--"}/${res.maxHr ?? "--"}`);
|
||||
showSessionDetail(res.sessionId);
|
||||
} else { renderScreen("gps"); }
|
||||
}
|
||||
renderScreen("gps"); // back to the tracks list after stop, like a GPS ride (not into details)
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -5470,9 +5470,10 @@ async function renderRecordingInline(container) {
|
|||
let median = sorted[Math.floor(sorted.length / 2)] || 0;
|
||||
if (spdEl) spdEl.textContent = median.toFixed(1);
|
||||
|
||||
// Elevation
|
||||
let alt = p.altitude;
|
||||
if (alt != null && elevEl) elevEl.textContent = Math.round(alt) + " м";
|
||||
// Elevation - prefer the live barometer (smoother, tracks real climbs) over noisy GPS altitude
|
||||
let baroAlt = (window.BARO && BARO.isLive()) ? BARO.altitude() : null;
|
||||
let alt = (baroAlt != null) ? baroAlt : p.altitude;
|
||||
if (alt != null && elevEl) elevEl.textContent = Math.round(alt) + (baroAlt != null ? " м·" : " м");
|
||||
|
||||
// Pause indicator
|
||||
if (GPS.recording.pauseStart) {
|
||||
|
|
@ -5506,6 +5507,14 @@ async function renderRecordingInline(container) {
|
|||
// Update clock
|
||||
let clockEl = document.querySelector("#rec-clock");
|
||||
if (clockEl) clockEl.textContent = new Date().toTimeString().slice(0, 5);
|
||||
// Barometer readout at 1 Hz, independent of GPS fixes (so altitude/temp move smoothly).
|
||||
if (window.BARO && BARO.isLive()) {
|
||||
let ba = BARO.altitude();
|
||||
if (ba != null && elevEl) elevEl.textContent = Math.round(ba) + " м·"; // trailing dot = from the barometer
|
||||
let tc = BARO.state ? BARO.state.lastTempC : null;
|
||||
let tEl = document.querySelector("#rec-temp");
|
||||
if (tc != null && tEl) tEl.textContent = tc.toFixed(1) + "°"; // one decimal: visibly live + has the dot
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
// Fetch temperature — try multiple APIs (mobile internet blocks foreign services)
|
||||
|
|
@ -7806,18 +7815,34 @@ async function renderSettings(container) {
|
|||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<div class="settings-section-title">Пульсометр (BLE)</div>
|
||||
<div class="settings-section-title">Датчики (BLE)</div>
|
||||
<div class="settings-item" id="s-ble-device">
|
||||
<div class="settings-item-left">
|
||||
<div class="settings-item-title" id="s-ble-name">${state.profile.ble_device_name || "Не привязан"}</div>
|
||||
<div class="settings-item-desc" id="s-ble-status">${state.profile.ble_device_addr ? state.profile.ble_device_addr : "Нажмите для поиска"}</div>
|
||||
<div class="settings-item-title">Пульсометр: <span id="s-ble-name">${state.profile.ble_device_name || "не привязан"}</span></div>
|
||||
<div class="settings-item-desc" id="s-ble-status">${state.profile.ble_device_addr ? state.profile.ble_device_addr : "найдите в поиске ниже"}</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:6px">
|
||||
${state.profile.ble_device_addr ? `<button id="s-ble-unbind" style="padding:6px 12px;font-size:13px;background:#cf6679;color:#fff;border:none;border-radius:4px">Отвязать</button>` : ""}
|
||||
${state.profile.ble_device_addr ? `<button class="btn-primary" id="s-ble-connect" style="padding:6px 12px;font-size:13px">Подключить</button>` : ""}
|
||||
<button class="btn-primary" id="s-ble-scan" style="padding:6px 12px;font-size:13px">Поиск</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-item" id="s-baro-device">
|
||||
<div class="settings-item-left">
|
||||
<div class="settings-item-title">Барометр высоты: <span id="s-baro-name">${state.profile.baro_device_name || "не привязан"}</span></div>
|
||||
<div class="settings-item-desc" id="s-baro-status">${state.profile.baro_device_addr ? state.profile.baro_device_addr : "найдите в поиске ниже"}</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:6px">
|
||||
${state.profile.baro_device_addr ? `<button id="s-baro-unbind" style="padding:6px 12px;font-size:13px;background:#cf6679;color:#fff;border:none;border-radius:4px">Отвязать</button>` : ""}
|
||||
${state.profile.baro_device_addr ? `<button class="btn-primary" id="s-baro-connect" style="padding:6px 12px;font-size:13px">Подключить</button>` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-item">
|
||||
<div class="settings-item-left">
|
||||
<div class="settings-item-title">Поиск устройств</div>
|
||||
<div class="settings-item-desc">Пульсометр и барометр привяжутся каждый в свою строку. Пока работает одно BLE-подключение за раз.</div>
|
||||
</div>
|
||||
<button class="btn-primary" id="s-ble-scan" style="padding:6px 12px;font-size:13px">Поиск</button>
|
||||
</div>
|
||||
<div id="s-ble-results" style="padding:0 14px"></div>
|
||||
<label class="settings-item" style="cursor:pointer">
|
||||
<div class="settings-item-left"><div class="settings-item-title">Не спрашивать про Bluetooth</div>
|
||||
|
|
@ -8272,6 +8297,69 @@ async function renderSettings(container) {
|
|||
bleConnectBtn.disabled = false; bleConnectBtn.textContent = "Подключить";
|
||||
};
|
||||
|
||||
// --- Barometer (altitude sensor): its own bind/connect, separate from the heart-rate strap ---
|
||||
let baroReadoutOn = false;
|
||||
function startBaroReadout() {
|
||||
if (baroReadoutOn) return;
|
||||
baroReadoutOn = true;
|
||||
let anchorPa = null;
|
||||
let loop = () => {
|
||||
let st = container.querySelector("#s-baro-status");
|
||||
if (!st) { baroReadoutOn = false; return; } // left the settings screen
|
||||
let s = window.BARO ? BARO.state : null;
|
||||
let pa = s ? s.lastPa : null, tc = s ? s.lastTempC : null;
|
||||
if (pa) {
|
||||
if (anchorPa == null) anchorPa = pa;
|
||||
let rel = BARO.deltaH(anchorPa, pa, tc);
|
||||
st.textContent = `${(pa / 100).toFixed(1)} гПа${tc != null ? " · " + tc.toFixed(1) + " C" : ""} · высота ${rel >= 0 ? "+" : ""}${rel.toFixed(1)} м (отн. подключения)`;
|
||||
} else {
|
||||
st.textContent = "подключение, ждём данные...";
|
||||
}
|
||||
if (baroReadoutOn) setTimeout(loop, 2000);
|
||||
};
|
||||
loop();
|
||||
}
|
||||
|
||||
let baroUnbindBtn = container.querySelector("#s-baro-unbind");
|
||||
if (baroUnbindBtn) baroUnbindBtn.onclick = async () => {
|
||||
await DB.setProfileValue("baro_device_addr", "");
|
||||
await DB.setProfileValue("baro_device_name", "");
|
||||
state.profile.baro_device_addr = "";
|
||||
state.profile.baro_device_name = "";
|
||||
try { if (window.BARO) { BARO.stop(); BARO.reset(); } } catch {}
|
||||
baroReadoutOn = false;
|
||||
try { await window.__TAURI__.core.invoke("ble_save_baro_device", { address: "" }); } catch {}
|
||||
try { await window.__TAURI__.core.invoke("ble_baro_disconnect"); } catch {}
|
||||
let nm = container.querySelector("#s-baro-name"); if (nm) nm.textContent = "не привязан";
|
||||
let st = container.querySelector("#s-baro-status"); if (st) st.textContent = "найдите в поиске ниже";
|
||||
baroUnbindBtn.remove();
|
||||
};
|
||||
|
||||
let baroConnectBtn = container.querySelector("#s-baro-connect");
|
||||
if (baroConnectBtn) baroConnectBtn.onclick = async () => {
|
||||
let addr = state.profile.baro_device_addr;
|
||||
if (!addr) return;
|
||||
let bt = "on"; try { bt = await window.__TAURI__.core.invoke("ble_check_bluetooth"); } catch {}
|
||||
if (bt === "off") { try { await window.__TAURI__.core.invoke("ble_request_enable"); } catch {} showToast("Включите Bluetooth, затем «Подключить»"); return; }
|
||||
if (bt === "no_adapter") { showToast("Bluetooth недоступен"); return; }
|
||||
baroConnectBtn.disabled = true; baroConnectBtn.textContent = "...";
|
||||
let st = container.querySelector("#s-baro-status"); if (st) st.textContent = "Подключение...";
|
||||
try {
|
||||
await window.__TAURI__.core.invoke("ble_connect", { address: addr, deviceType: "baro" });
|
||||
try { await window.__TAURI__.core.invoke("ble_save_baro_device", { address: addr }); } catch {}
|
||||
try { if (window.BARO) { BARO.reset(); BARO.start(); } } catch {}
|
||||
startBaroReadout();
|
||||
showToast("Барометр: подключение...");
|
||||
} catch (e) {
|
||||
if (st) st.textContent = "ошибка подключения";
|
||||
showToast("Ошибка: " + (e.message || e));
|
||||
}
|
||||
baroConnectBtn.disabled = false; baroConnectBtn.textContent = "Подключить";
|
||||
};
|
||||
|
||||
// already-bound barometer that is still polling: resume its live readout on opening settings
|
||||
if (state.profile.baro_device_addr && window.BARO && BARO.state && BARO.state.polling) startBaroReadout();
|
||||
|
||||
let dontAskBt = container.querySelector("#s-dontask-bt");
|
||||
if (dontAskBt) dontAskBt.onchange = () => { try { localStorage.setItem("baldav_dontask_bt", dontAskBt.checked ? "1" : "0"); } catch {} };
|
||||
let dontAskGps = container.querySelector("#s-dontask-gps");
|
||||
|
|
@ -8337,17 +8425,27 @@ async function renderSettings(container) {
|
|||
item.querySelector(".settings-item-desc").textContent = "Подключение...";
|
||||
try {
|
||||
await window.__TAURI__.core.invoke("ble_connect", { address: addr, deviceType: type });
|
||||
await DB.setProfileValue("ble_device_addr", addr);
|
||||
await DB.setProfileValue("ble_device_type", type);
|
||||
await DB.setProfileValue("ble_device_name", name);
|
||||
// Write file for Kotlin to read at GPS start
|
||||
try { await window.__TAURI__.core.invoke("ble_save_device", { address: addr, deviceType: type }); } catch {}
|
||||
state.profile.ble_device_addr = addr;
|
||||
state.profile.ble_device_type = type;
|
||||
state.profile.ble_device_name = name;
|
||||
container.querySelector("#s-ble-name").textContent = name;
|
||||
container.querySelector("#s-ble-status").textContent = addr + " (подключён)";
|
||||
bleResults.innerHTML = "";
|
||||
if (type === "baro") {
|
||||
// barometer -> its own slot, so it doesn't overwrite the heart-rate strap
|
||||
await DB.setProfileValue("baro_device_addr", addr);
|
||||
await DB.setProfileValue("baro_device_name", name);
|
||||
state.profile.baro_device_addr = addr;
|
||||
state.profile.baro_device_name = name;
|
||||
// persist for auto-connect at start (separate from the HR device file)
|
||||
try { await window.__TAURI__.core.invoke("ble_save_baro_device", { address: addr }); } catch {}
|
||||
try { if (window.BARO) { BARO.reset(); BARO.start(); } } catch {}
|
||||
} else {
|
||||
await DB.setProfileValue("ble_device_addr", addr);
|
||||
await DB.setProfileValue("ble_device_type", type);
|
||||
await DB.setProfileValue("ble_device_name", name);
|
||||
// Write file for Kotlin to read at GPS start (HR auto-connect)
|
||||
try { await window.__TAURI__.core.invoke("ble_save_device", { address: addr, deviceType: type }); } catch {}
|
||||
state.profile.ble_device_addr = addr;
|
||||
state.profile.ble_device_type = type;
|
||||
state.profile.ble_device_name = name;
|
||||
}
|
||||
// re-render settings so the Connect/Unbind buttons appear and the baro readout resumes
|
||||
await renderSettings(container);
|
||||
} catch (e) {
|
||||
item.querySelector(".settings-item-desc").textContent = "Ошибка: " + e;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue