diff --git a/docs/session-logs/2026-10-30_barometer-ble-baldav.md b/docs/session-logs/2026-10-30_barometer-ble-baldav.md
index ab14fd0..ce991ec 100644
--- a/docs/session-logs/2026-10-30_barometer-ble-baldav.md
+++ b/docs/session-logs/2026-10-30_barometer-ble-baldav.md
@@ -22,6 +22,27 @@
## Состояние
Проверено компиляцией (сборка 0.1.538, без ошибок). На устройстве НЕ проверено - нет железа.
+## Продолжение того же дня: обвязка, UI, проверка эмулятором
+
+Железа ещё нет, поэтому эмулировали датчик на ПК (`tools/baro-sensor/emulate_baro.py` -
+BlueZ D-Bus GATT-сервер, вещает ESS `0x181A` с давлением/температурой), телефон читал его
+реальным путём. Проверено на устройстве: `BleHR: HR:70` и `BleBaro: Baro:... Pa` одновременно.
+
+Сделано:
+- `BleBaroService` - отдельный сервис/соединение для барометра, работает параллельно с
+ пульсометром (один адаптер держит несколько LE-связей). Баро-подключения маршрутизируются
+ в него из `MainActivity`. Авто-подключение при старте: баро из `baro_device.json`, пульс из
+ `ble_device.json` (оба сами цепляются).
+- Rust: `ble_baro_disconnect`, `ble_save_baro_device`. JS сохраняет baro-устройство при привязке.
+- Настройки: секция "Датчики (BLE)" - пульсометр и барометр раздельно, свои слоты и кнопки,
+ живой показ давления/температуры/высоты.
+- Трекинг: `altitude_baro_m` в точках, предпочитается в статистике высот (null без датчика).
+- Экран записи: "Высота" от барометра (обновление 1 Гц, пометка точкой), температура с датчика
+ (белая, с десятой).
+- Починки по пути: directConnect при таймауте скана; Locale.US в JSON (была запятая от RU-локали);
+ маршрутизация парсинга по характеристике (баро не писал в пульс); навигация после Стоп в VR/ЧСС
+ (на список, а не в детали).
+
## Осталось (по приходу платы)
- Активировать модуль: `BARO.start()` при подключении baro-устройства, задать `BARO.getGpsAlt`.
- Привязать высоту барометра к треку поездки (в точки / поправка высот П.5).
diff --git a/frontend/css/styles.css b/frontend/css/styles.css
index 276915a..e573c76 100644
--- a/frontend/css/styles.css
+++ b/frontend/css/styles.css
@@ -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;
diff --git a/frontend/js/db.js b/frontend/js/db.js
index eda3781..2925738 100644
--- a/frontend/js/db.js
+++ b/frontend/js/db.js
@@ -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
diff --git a/frontend/js/gps.js b/frontend/js/gps.js
index 9d9a288..ba79d19 100644
--- a/frontend/js/gps.js
+++ b/frontend/js/gps.js
@@ -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;
diff --git a/frontend/js/main.js b/frontend/js/main.js
index f40942e..4cee2f4 100644
--- a/frontend/js/main.js
+++ b/frontend/js/main.js
@@ -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) {
-
Пульсометр (BLE)
+
Датчики (BLE)
-
${state.profile.ble_device_name || "Не привязан"}
-
${state.profile.ble_device_addr ? state.profile.ble_device_addr : "Нажмите для поиска"}
+
Пульсометр: ${state.profile.ble_device_name || "не привязан"}
+
${state.profile.ble_device_addr ? state.profile.ble_device_addr : "найдите в поиске ниже"}
${state.profile.ble_device_addr ? `Отвязать ` : ""}
${state.profile.ble_device_addr ? `Подключить ` : ""}
- Поиск
+
+
+
Барометр высоты: ${state.profile.baro_device_name || "не привязан"}
+
${state.profile.baro_device_addr ? state.profile.baro_device_addr : "найдите в поиске ниже"}
+
+
+ ${state.profile.baro_device_addr ? `Отвязать ` : ""}
+ ${state.profile.baro_device_addr ? `Подключить ` : ""}
+
+
+
+
+
Поиск устройств
+
Пульсометр и барометр привяжутся каждый в свою строку. Пока работает одно BLE-подключение за раз.
+
+
Поиск
+
Не спрашивать про Bluetooth
@@ -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;
}
diff --git a/src-tauri/gen/android/app/src/main/AndroidManifest.xml b/src-tauri/gen/android/app/src/main/AndroidManifest.xml
index a7feaf4..f17984f 100644
--- a/src-tauri/gen/android/app/src/main/AndroidManifest.xml
+++ b/src-tauri/gen/android/app/src/main/AndroidManifest.xml
@@ -48,6 +48,11 @@
android:foregroundServiceType="connectedDevice"
android:exported="false" />
+
+
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()
+ }
+ private fun onGattOperationComplete() {
+ gattBusy = false
+ handler.postDelayed({ processGattQueue() }, 100)
+ }
+
+ private fun setupBaro(g: BluetoothGatt) {
+ val svc = g.getService(ESS_SERVICE)
+ if (svc == null) { Log.e(TAG, "ESS service not found"); return }
+ val pc = svc.getCharacteristic(PRESSURE_CHAR)
+ if (pc == null) { Log.e(TAG, "pressure characteristic not found"); return }
+ val tc = svc.getCharacteristic(TEMP_CHAR)
+ enqueueGatt { subscribeNotify(g, pc) }
+ if (tc != null) enqueueGatt { subscribeNotify(g, tc) }
+ Log.d(TAG, "subscribed pressure${if (tc != null) " + temp" else ""}")
+ }
+
+ private fun subscribeNotify(g: BluetoothGatt, char: BluetoothGattCharacteristic) {
+ try {
+ g.setCharacteristicNotification(char, true)
+ val desc = char.getDescriptor(CCCD_UUID)
+ if (desc != null) {
+ desc.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
+ g.writeDescriptor(desc)
+ } else onGattOperationComplete()
+ } catch (e: Exception) { Log.e(TAG, "subscribeNotify: ${e.message}"); onGattOperationComplete() }
+ }
+
+ private fun parseBaro(char: BluetoothGattCharacteristic, value: ByteArray) {
+ when (char.uuid) {
+ TEMP_CHAR -> if (value.size >= 2) {
+ val u = ((value[1].toInt() and 0xFF) shl 8) or (value[0].toInt() and 0xFF)
+ lastTempC = u.toShort().toInt() / 100.0
+ }
+ PRESSURE_CHAR -> if (value.size >= 4) {
+ val raw = (value[0].toLong() and 0xFF) or ((value[1].toLong() and 0xFF) shl 8) or
+ ((value[2].toLong() and 0xFF) shl 16) or ((value[3].toLong() and 0xFF) shl 24)
+ val pa = raw / 10.0
+ val ts = System.currentTimeMillis() / 1000
+ val tj = if (!lastTempC.isNaN()) ",\"temp_c\":${String.format(Locale.US, "%.2f", lastTempC)}" else ""
+ val json = "{\"pressure_pa\":${String.format(Locale.US, "%.1f", pa)}$tj,\"ts\":$ts}"
+ try { dataFile?.writeText(json) } catch (e: Exception) { Log.e(TAG, "write: ${e.message}") }
+ try { logFile?.appendText(json + "\n") } catch (e: Exception) {}
+ Log.d(TAG, "Baro: $pa Pa T=$lastTempC")
+ }
+ }
+ }
+
+ private fun disconnect() {
+ isRunning = false
+ handler.removeCallbacksAndMessages(null)
+ try { gatt?.disconnect() } catch (e: Exception) {}
+ try { gatt?.close() } catch (e: Exception) {}
+ gatt = null
+ }
+
+ private fun createNotificationChannel() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ val channel = NotificationChannel(CHANNEL_ID, "BLE барометр", NotificationManager.IMPORTANCE_LOW)
+ 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 барометр")
+ .setContentText(text)
+ .setSmallIcon(android.R.drawable.ic_menu_compass)
+ .setContentIntent(pi)
+ .setOngoing(true)
+ .build()
+ }
+ private fun updateNotification(text: String) {
+ getSystemService(NotificationManager::class.java).notify(NOTIFICATION_ID, buildNotification(text))
+ }
+
+ override fun onDestroy() {
+ stopping = true
+ try { unregisterReceiver(btStateReceiver) } catch (e: Exception) {}
+ disconnect()
+ super.onDestroy()
+ }
+}
diff --git a/src-tauri/gen/android/app/src/main/java/dev/baldav/app/BleHrService.kt b/src-tauri/gen/android/app/src/main/java/dev/baldav/app/BleHrService.kt
index 26b540f..d2c2523 100644
--- a/src-tauri/gen/android/app/src/main/java/dev/baldav/app/BleHrService.kt
+++ b/src-tauri/gen/android/app/src/main/java/dev/baldav/app/BleHrService.kt
@@ -19,6 +19,7 @@ import android.os.Looper
import android.os.ParcelUuid
import android.util.Log
import java.io.File
+import java.util.Locale
import java.util.UUID
import org.json.JSONArray
import org.json.JSONObject
@@ -260,12 +261,14 @@ class BleHrService : Service() {
handler.postDelayed({
try { scanner.stopScan(scanCallback) } catch (e: Exception) { Log.e(TAG, "stopScan: ${e.message}") }
if (gatt == null) {
- // Not seen in the scan → the band isn't nearby/on. Do NOT hold a GATT connect to
- // an absent device (that keeps the radio busy and drains the battery). Give up;
- // the user re-taps "Подключить" once the band is on.
- Log.d(TAG, "Scan timeout, device $address not found - stopping")
- writeData("""{"status":"not_found","ts":${System.currentTimeMillis()/1000}}""")
- stopSelf()
+ // Not re-found in the pre-connect scan within 5 s. A present-but-not-rescanned device
+ // (a stationary sensor, or a BlueZ peripheral that the scanner just missed) still
+ // connects by address, so fall back to a direct connect instead of giving up. The
+ // address type is already cached from the earlier scan, so connectGatt uses it.
+ Log.d(TAG, "Scan timeout, device $address not re-found - direct connect fallback")
+ val a = adapter()
+ if (a != null && a.isEnabled) directConnect(a, address)
+ else { writeData("""{"status":"not_found","ts":${System.currentTimeMillis()/1000}}"""); stopSelf() }
}
}, 5000)
}
@@ -335,6 +338,13 @@ class BleHrService : Service() {
override fun onCharacteristicChanged(g: BluetoothGatt, char: BluetoothGattCharacteristic, value: ByteArray) {
Log.d(TAG, "Data received (${value.size} bytes): ${value.joinToString(" ") { "%02X".format(it) }}")
+ // Route by WHAT the characteristic is, not the current deviceType: if an HR auto-connect
+ // races in and flips deviceType while the baro link is still delivering, a pressure/temp
+ // packet must NOT be parsed as HR. Standard GATT UUIDs are unambiguous.
+ when (char.uuid) {
+ PRESSURE_CHAR, TEMP_CHAR -> { parseBaro(char, value); return }
+ HR_MEASUREMENT_UUID -> { parseStandardHr(value); return }
+ }
when (deviceType) {
"hr_standard" -> parseStandardHr(value)
"baro" -> parseBaro(char, value)
@@ -455,8 +465,10 @@ class BleHrService : Service() {
((value[2].toLong() and 0xFF) shl 16) or ((value[3].toLong() and 0xFF) shl 24)
val pa = raw / 10.0 // 0.1 Pa units -> Pa
val ts = System.currentTimeMillis() / 1000
- val tj = if (!lastTempC.isNaN()) ",\"temp_c\":${"%.2f".format(lastTempC)}" else ""
- val json = "{\"pressure_pa\":${"%.1f".format(pa)}$tj,\"ts\":$ts}"
+ // format with Locale.US - the phone's RU locale writes a decimal comma, which is
+ // invalid JSON and fails the Rust parse
+ val tj = if (!lastTempC.isNaN()) ",\"temp_c\":${String.format(Locale.US, "%.2f", lastTempC)}" else ""
+ val json = "{\"pressure_pa\":${String.format(Locale.US, "%.1f", pa)}$tj,\"ts\":$ts}"
try { baroDataFile?.writeText(json) } catch (e: Exception) { Log.e(TAG, "baro write: ${e.message}") }
try { baroLogFile?.appendText(json + "\n") } catch (e: Exception) {}
Log.d(TAG, "Baro: $pa Pa T=$lastTempC")
diff --git a/src-tauri/gen/android/app/src/main/java/dev/baldav/app/MainActivity.kt b/src-tauri/gen/android/app/src/main/java/dev/baldav/app/MainActivity.kt
index bce5a65..653916f 100644
--- a/src-tauri/gen/android/app/src/main/java/dev/baldav/app/MainActivity.kt
+++ b/src-tauri/gen/android/app/src/main/java/dev/baldav/app/MainActivity.kt
@@ -565,9 +565,14 @@ class MainActivity : TauriActivity() {
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)
+ // barometer has its own service so it and the HR strap connect at the same time
+ val intent = if (type == "baro") {
+ Intent(this, BleBaroService::class.java).apply { putExtra(BleBaroService.EXTRA_ADDRESS, address) }
+ } else {
+ 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)
}
@@ -578,6 +583,12 @@ class MainActivity : TauriActivity() {
intent.action = BleHrService.ACTION_STOP
startService(intent)
}
+ cmd == "baro_disconnect" -> {
+ Log.d("BalDav", "Baro disconnect")
+ val intent = Intent(this, BleBaroService::class.java)
+ intent.action = BleBaroService.ACTION_STOP
+ startService(intent)
+ }
cmd == "ble_request_enable" -> {
Log.d("BalDav", "Requesting Bluetooth enable")
if (ensureBlePermissions()) {
@@ -711,6 +722,18 @@ class MainActivity : TauriActivity() {
private fun autoStartBle(appDataDir: File) {
try {
+ // Barometer: auto-connect its own device via its own service, so it runs alongside the HR strap
+ val baroFile = File(appDataDir, "baro_device.json")
+ if (baroFile.exists()) {
+ val bjson = baroFile.readText().trim()
+ val baddr = Regex(""""addr"\s*:\s*"([^"]+)"""").find(bjson)?.groupValues?.get(1)
+ if (!baddr.isNullOrEmpty() && ensureBlePermissions()) {
+ Log.d("BalDav", "Auto-starting baro: $baddr")
+ val bi = Intent(this, BleBaroService::class.java).apply { putExtra(BleBaroService.EXTRA_ADDRESS, baddr) }
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) startForegroundService(bi) else startService(bi)
+ }
+ }
+
// 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.
diff --git a/src-tauri/src/ble.rs b/src-tauri/src/ble.rs
index b099334..88509ce 100644
--- a/src-tauri/src/ble.rs
+++ b/src-tauri/src/ble.rs
@@ -596,6 +596,29 @@ pub fn ble_save_device(app: tauri::AppHandle, address: String, device_type: Stri
Ok(())
}
+// Disconnect ONLY the barometer (its own service), leaving the HR strap connected.
+#[tauri::command]
+pub async fn ble_baro_disconnect(app: tauri::AppHandle) -> Result {
+ #[cfg(not(target_os = "android"))]
+ { let _ = &app; Ok("desktop: no-op".to_string()) }
+ #[cfg(target_os = "android")]
+ { android::send_command(&app, "baro_disconnect")?; Ok("Disconnecting baro".to_string()) }
+}
+
+// Persist the paired barometer so the Kotlin side auto-connects it at start (separate from the HR
+// device file). Empty address clears it.
+#[tauri::command]
+pub fn ble_save_baro_device(app: tauri::AppHandle, address: 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":"baro"}}"#, address);
+ std::fs::write(data_dir.join("baro_device.json"), &json).ok();
+ if let Some(root) = data_dir.parent().and_then(|p| p.parent()) {
+ std::fs::write(root.join("baro_device.json"), &json).ok();
+ }
+ Ok(())
+}
+
#[tauri::command]
pub fn ble_check_bluetooth(
app: tauri::AppHandle,
diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs
index fa6560d..9025eb6 100644
--- a/src-tauri/src/lib.rs
+++ b/src-tauri/src/lib.rs
@@ -1315,7 +1315,7 @@ pub fn run() {
.add_migrations("sqlite:baldav.db", migrations)
.build(),
)
- .invoke_handler(tauri::generate_handler![get_safe_insets, import_seed_products, import_tiles, download_file, get_points_filename, delete_points_file, gps_set_session_id, list_points_files, read_points_file, pick_file_open, get_picked_file, debug_native_paths, rebuild_roads, get_road_points, get_ride_graph, get_elev_correction, get_app_info, gps_service_command, set_hydration_config, set_live_config, send_sms, pick_contact, get_contact_result, gps_service_read_points, brouter_route, brouter_download_tile, brouter_download_status, brouter_list_tiles, save_file, export_image, read_file_base64, export_db, import_db, restart_app, get_update_info, http_request, chzn_http, ble::ble_scan, ble::ble_scan_results, ble::ble_connect, ble::ble_disconnect, ble::ble_get_hr, ble::ble_get_baro, ble::ble_is_connected, ble::ble_check_bluetooth, ble::ble_save_device, ble::ble_clear_hr, ble::ble_read_hr_log, ble::ble_read_strap_uptime, ble::ble_reset_strap_uptime, ble::ble_request_enable, read_state_file])
+ .invoke_handler(tauri::generate_handler![get_safe_insets, import_seed_products, import_tiles, download_file, get_points_filename, delete_points_file, gps_set_session_id, list_points_files, read_points_file, pick_file_open, get_picked_file, debug_native_paths, rebuild_roads, get_road_points, get_ride_graph, get_elev_correction, get_app_info, gps_service_command, set_hydration_config, set_live_config, send_sms, pick_contact, get_contact_result, gps_service_read_points, brouter_route, brouter_download_tile, brouter_download_status, brouter_list_tiles, save_file, export_image, read_file_base64, export_db, import_db, restart_app, get_update_info, http_request, chzn_http, ble::ble_scan, ble::ble_scan_results, ble::ble_connect, ble::ble_disconnect, ble::ble_get_hr, ble::ble_get_baro, ble::ble_baro_disconnect, ble::ble_save_baro_device, ble::ble_is_connected, ble::ble_check_bluetooth, ble::ble_save_device, ble::ble_clear_hr, ble::ble_read_hr_log, ble::ble_read_strap_uptime, ble::ble_reset_strap_uptime, ble::ble_request_enable, read_state_file])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
diff --git a/tools/baro-sensor/emulate_baro.py b/tools/baro-sensor/emulate_baro.py
new file mode 100644
index 0000000..e0a3ca6
--- /dev/null
+++ b/tools/baro-sensor/emulate_baro.py
@@ -0,0 +1,168 @@
+#!/usr/bin/env python3
+# Emulate the "BaroBalDav" sensor on this PC's Bluetooth so the phone's baldav can be tested
+# against the REAL Kotlin BLE read path before the ESP32 arrives. Advertises the standard
+# Environmental Sensing service (0x181A) with Pressure (0x2A6D, uint32 0.1 Pa) and Temperature
+# (0x2A6E, int16 0.01 C), and pushes a simulated pressure that rises and falls so altitude moves.
+#
+# Pure BlueZ D-Bus GATT server (python3-dbus + python3-gi) - no bluetoothctl, no pip installs.
+# Run: python3 tools/baro-sensor/emulate_baro.py (Ctrl+C to stop)
+import struct, math, time, sys
+import dbus, dbus.mainloop.glib, dbus.service
+from gi.repository import GLib
+
+BLUEZ = "org.bluez"
+ADAPTER_IFACE = "org.bluez.Adapter1"
+GATT_MANAGER = "org.bluez.GattManager1"
+LE_ADV_MANAGER = "org.bluez.LEAdvertisingManager1"
+DBUS_OM = "org.freedesktop.DBus.ObjectManager"
+DBUS_PROP = "org.freedesktop.DBus.Properties"
+
+ESS_UUID = "0000181a-0000-1000-8000-00805f9b34fb"
+PRESSURE_UUID = "00002a6d-0000-1000-8000-00805f9b34fb"
+TEMP_UUID = "00002a6e-0000-1000-8000-00805f9b34fb"
+
+def find_adapter(bus):
+ om = dbus.Interface(bus.get_object(BLUEZ, "/"), DBUS_OM)
+ for path, ifaces in om.GetManagedObjects().items():
+ if ADAPTER_IFACE in ifaces and LE_ADV_MANAGER in ifaces:
+ return path
+ return None
+
+# ---- GATT application / service / characteristic boilerplate (BlueZ example pattern) ----
+class Application(dbus.service.Object):
+ def __init__(self, bus):
+ self.path = "/dev/baldav/baro"
+ self.services = []
+ dbus.service.Object.__init__(self, bus, self.path)
+ def get_path(self): return dbus.ObjectPath(self.path)
+ def add_service(self, s): self.services.append(s)
+ @dbus.service.method(DBUS_OM, out_signature="a{oa{sa{sv}}}")
+ def GetManagedObjects(self):
+ out = {}
+ for s in self.services:
+ out[s.get_path()] = s.get_properties()
+ for c in s.chars:
+ out[c.get_path()] = c.get_properties()
+ return out
+
+class Service(dbus.service.Object):
+ def __init__(self, bus, index, uuid, primary=True):
+ self.path = "/dev/baldav/baro/service%d" % index
+ self.uuid = uuid; self.primary = primary; self.chars = []
+ dbus.service.Object.__init__(self, bus, self.path)
+ def get_path(self): return dbus.ObjectPath(self.path)
+ def add_char(self, c): self.chars.append(c)
+ def get_properties(self):
+ return {"org.bluez.GattService1": {
+ "UUID": self.uuid, "Primary": self.primary,
+ "Characteristics": dbus.Array([c.get_path() for c in self.chars], signature="o")}}
+
+class Characteristic(dbus.service.Object):
+ def __init__(self, bus, index, uuid, flags, service):
+ self.path = service.path + "/char%d" % index
+ self.uuid = uuid; self.flags = flags; self.service = service
+ self.notifying = False; self.value = []
+ dbus.service.Object.__init__(self, bus, self.path)
+ def get_path(self): return dbus.ObjectPath(self.path)
+ def get_properties(self):
+ return {"org.bluez.GattCharacteristic1": {
+ "Service": self.service.get_path(), "UUID": self.uuid, "Flags": self.flags}}
+ @dbus.service.method(DBUS_PROP, in_signature="s", out_signature="a{sv}")
+ def GetAll(self, iface):
+ if iface != "org.bluez.GattCharacteristic1":
+ raise dbus.exceptions.DBusException("org.bluez.Error.InvalidArguments")
+ return self.get_properties()["org.bluez.GattCharacteristic1"]
+ @dbus.service.method("org.bluez.GattCharacteristic1", in_signature="a{sv}", out_signature="ay")
+ def ReadValue(self, options): return dbus.Array(self.value, signature="y")
+ @dbus.service.method("org.bluez.GattCharacteristic1")
+ def StartNotify(self):
+ self.notifying = True
+ print(" [%s] StartNotify" % self.uuid[4:8])
+ @dbus.service.method("org.bluez.GattCharacteristic1")
+ def StopNotify(self):
+ self.notifying = False
+ print(" [%s] StopNotify" % self.uuid[4:8])
+ @dbus.service.signal(DBUS_PROP, signature="sa{sv}as")
+ def PropertiesChanged(self, iface, changed, invalidated): pass
+ def set_value(self, raw):
+ self.value = [dbus.Byte(b) for b in raw]
+ if self.notifying:
+ self.PropertiesChanged("org.bluez.GattCharacteristic1",
+ {"Value": dbus.Array(self.value, signature="y")}, [])
+
+# ---- LE advertisement ----
+class Advertisement(dbus.service.Object):
+ def __init__(self, bus, index):
+ self.path = "/dev/baldav/baro/adv%d" % index
+ dbus.service.Object.__init__(self, bus, self.path)
+ def get_path(self): return dbus.ObjectPath(self.path)
+ @dbus.service.method(DBUS_PROP, in_signature="s", out_signature="a{sv}")
+ def GetAll(self, iface):
+ if iface != "org.bluez.LEAdvertisement1":
+ raise dbus.exceptions.DBusException("org.bluez.Error.InvalidArguments")
+ return {
+ "Type": "peripheral",
+ "ServiceUUIDs": dbus.Array([ESS_UUID], signature="s"),
+ "LocalName": dbus.String("BaroBalDav"),
+ "Includes": dbus.Array(["tx-power"], signature="s"),
+ }
+ @dbus.service.method("org.bluez.LEAdvertisement1")
+ def Release(self): print("Advertisement released")
+
+# ---- pressure/temperature simulation ----
+def enc_pressure(pa): return struct.pack("