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:
4aiman 2026-10-30 22:46:26 +03:00
commit 9251c1dd90
12 changed files with 653 additions and 34 deletions

View file

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

View file

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

View file

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

View file

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