diff --git a/docs/session-logs/2026-10-30_barometer-ble-baldav.md b/docs/session-logs/2026-10-30_barometer-ble-baldav.md
new file mode 100644
index 0000000..ab14fd0
--- /dev/null
+++ b/docs/session-logs/2026-10-30_barometer-ble-baldav.md
@@ -0,0 +1,30 @@
+# 2026-10-30 - BLE-барометр: сторона baldav
+
+## Контекст
+Делаем самодельный датчик высоты: ESP32-C3 SuperMini + BMP280, вещает давление и
+температуру по BLE (стандартный Environmental Sensing). Железо заказано, ещё едет.
+Прошивка датчика - в `tools/baro-sensor/` (PlatformIO + Arduino-скетч), схема сборки -
+`tools/baro-sensor/baro-guide.html`. Эта задача - прочитать датчик в baldav и считать высоту.
+
+## Что сделано
+Читаем барометр тем же путём, что и пульсометр (Kotlin BLE-сервис -> файл -> Rust -> JS),
+новым `device_type = "baro"`. Диспетчер команд (`MainActivity`) не менялся - тип проходит насквозь.
+
+- `BleHrService.kt`: тип `baro` - подписка на ESS (`0x181A`): давление `0x2A6D` (uint32, 0.1 Па)
+ и температура `0x2A6E` (int16, 0.01 C). Пишет `ble_baro_data.json` + `ble_baro_log.jsonl`.
+ Все ветки барометра под `deviceType == "baro"`; HR/GPS-пути не тронуты.
+- `ble.rs`: структура `BaroReading`, определение ESS, команда `ble_get_baro`
+ (Android читает файл; десктоп возвращает None). Зарегистрирована в `lib.rs`.
+- `frontend/js/baro.js`: ПАССИВНЫЙ модуль `window.BARO`. Гипсометрический пересчёт давления
+ в высоту с реальной температурой датчика, якорь к высоте GPS на старте. Ничего не исполняется,
+ пока не вызван `BARO.start()` - без барометра приложение работает как раньше.
+
+## Состояние
+Проверено компиляцией (сборка 0.1.538, без ошибок). На устройстве НЕ проверено - нет железа.
+
+## Осталось (по приходу платы)
+- Активировать модуль: `BARO.start()` при подключении baro-устройства, задать `BARO.getGpsAlt`.
+- Привязать высоту барометра к треку поездки (в точки / поправка высот П.5).
+- UI: показать барометрическую высоту; выбор baro-устройства в scan-UI уже работает (тип проходит).
+- Ограничение: сервис держит одно BLE-соединение, поэтому барометр и пульсометр одновременно
+ пока нельзя - отдельный следующий шаг (две связи / второй сервис).
diff --git a/frontend/index.html b/frontend/index.html
index f4550de..b13b9e1 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -20,6 +20,7 @@
+
diff --git a/frontend/js/baro.js b/frontend/js/baro.js
new file mode 100644
index 0000000..e99ffb0
--- /dev/null
+++ b/frontend/js/baro.js
@@ -0,0 +1,116 @@
+// Barometric altitude from the DIY BLE sensor "BaroBalDav" (ESP32-C3 + BMP280).
+// PASSIVE module: it only defines window.BARO. Nothing runs until BARO.start() is called, so with
+// no barometer connected the app behaves exactly as before. Activation (start on a "baro" connect,
+// plus the getGpsAlt hook) is wired in main.js only when a baro device is in use.
+//
+// How it works: the Kotlin BLE service writes the latest {pressure_pa, temp_c, ts} to a file; the
+// Rust command `ble_get_baro` returns it. We turn pressure into altitude with the hypsometric
+// formula using the sensor's own temperature, and anchor the absolute value to the GPS altitude at
+// ride start. The sensor is excellent for relative change (~1 m); weather drift of the absolute
+// baseline is handled by the GPS anchor (optionally re-anchored slowly).
+let BARO = {};
+(function () {
+ let R = 287.05; // specific gas constant for dry air, J/(kg*K)
+ let G = 9.80665; // standard gravity, m/s^2
+
+ let state = {
+ anchorPa: null, // reference pressure at the anchor point
+ anchorAltM: null, // absolute altitude (m) at the anchor point, from GPS
+ lastPa: null, // most recent pressure, Pa
+ lastTempC: null, // most recent chip temperature, C (for the hypsometric term)
+ lastTs: 0, // unix seconds of the last reading
+ lastAltM: null, // computed absolute altitude at the last reading
+ polling: false,
+ timer: null,
+ };
+ BARO.state = state;
+
+ // Height of a level at pressure `pa` above a level at pressure `refPa`, at air temperature tC.
+ // Hypsometric formula: dh = (R*T/g) * ln(refPa/pa). Measured temperature is used instead of a
+ // 15 C constant, so a hot or cold day does not bias the climb. Returns metres (positive = higher).
+ function deltaH(refPa, pa, tC) {
+ let T = (tC == null ? 15 : tC) + 273.15;
+ return (R * T / G) * Math.log(refPa / pa);
+ }
+ BARO.deltaH = deltaH;
+
+ // Pin the barometric altitude to a known absolute altitude (from GPS) at a given pressure.
+ // Call once at ride start; may be re-called later to slowly correct weather drift.
+ BARO.anchor = function (gpsAltM, pa) {
+ if (pa == null) pa = state.lastPa;
+ if (gpsAltM == null || pa == null) return false;
+ state.anchorAltM = gpsAltM;
+ state.anchorPa = pa;
+ state.lastAltM = BARO.altitude();
+ return true;
+ };
+
+ // Absolute altitude now = anchor altitude + barometric rise since the anchor. null until anchored.
+ BARO.altitude = function () {
+ if (state.anchorPa == null || state.lastPa == null) return null;
+ return state.anchorAltM + deltaH(state.anchorPa, state.lastPa, state.lastTempC);
+ };
+
+ // Fresh reading within 15 s?
+ BARO.isLive = function () {
+ return state.lastTs > 0 && (Date.now() / 1000 - state.lastTs) < 15;
+ };
+
+ // Override this to provide the current GPS altitude (m) for auto-anchoring; return null if no fix.
+ // Default: the altitude of the last recorded GPS point, if a recording is active.
+ BARO.getGpsAlt = function () {
+ try {
+ let pts = window.GPS && GPS.recording && GPS.recording.points;
+ if (pts && pts.length) {
+ let p = pts[pts.length - 1];
+ let a = p.altitude_srtm_m != null ? p.altitude_srtm_m : p.altitude;
+ return a != null ? a : null;
+ }
+ } catch (e) {}
+ return null;
+ };
+
+ async function tick() {
+ let r = null;
+ try { r = await window.__TAURI__.core.invoke("ble_get_baro"); } catch (e) {}
+ if (r && r.pressure_pa > 0) {
+ state.lastPa = r.pressure_pa;
+ if (r.temp_c != null) state.lastTempC = r.temp_c;
+ state.lastTs = r.timestamp || Math.floor(Date.now() / 1000);
+ // auto-anchor on the first good reading, once GPS has an altitude to anchor to
+ if (state.anchorPa == null) {
+ let g = null;
+ try { g = BARO.getGpsAlt(); } catch (e) {}
+ if (g != null) BARO.anchor(g, state.lastPa);
+ }
+ state.lastAltM = BARO.altitude();
+ }
+ }
+ BARO.tick = tick;
+
+ // Begin polling the sensor (~1 Hz). No-op if already running. Call this only when a baro device
+ // is connected, so the app does nothing extra when no barometer is in use.
+ BARO.start = function () {
+ if (state.polling) return;
+ state.polling = true;
+ let loop = async function () {
+ if (!state.polling) return;
+ await tick();
+ if (state.polling) state.timer = setTimeout(loop, 1000); // 1 s poll interval (a real delay)
+ };
+ loop();
+ };
+
+ BARO.stop = function () {
+ state.polling = false;
+ if (state.timer) { clearTimeout(state.timer); state.timer = null; }
+ };
+
+ // Clear the anchor and readings (e.g. between rides) so the next ride re-anchors fresh.
+ BARO.reset = function () {
+ state.anchorPa = null; state.anchorAltM = null;
+ state.lastPa = null; state.lastTempC = null; state.lastTs = 0; state.lastAltM = null;
+ };
+
+ window.BARO = BARO;
+})();
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 01f54ad..26b540f 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
@@ -42,6 +42,11 @@ class BleHrService : Service() {
val BATTERY_SERVICE: UUID = UUID.fromString("0000180f-0000-1000-8000-00805f9b34fb")
val BATTERY_LEVEL: UUID = UUID.fromString("00002a19-0000-1000-8000-00805f9b34fb")
+ // Standard Environmental Sensing — our DIY barometer "BaroBalDav" (ESP32-C3 + BMP280)
+ val ESS_SERVICE: UUID = UUID.fromString("0000181a-0000-1000-8000-00805f9b34fb")
+ val PRESSURE_CHAR: UUID = UUID.fromString("00002a6d-0000-1000-8000-00805f9b34fb") // uint32, 0.1 Pa
+ val TEMP_CHAR: UUID = UUID.fromString("00002a6e-0000-1000-8000-00805f9b34fb") // int16, 0.01 C
+
// 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")
@@ -78,6 +83,11 @@ class BleHrService : Service() {
private var scanResultsFile: File? = null
private var fitproWriteChar: BluetoothGattCharacteristic? = null
private var fitproGatt: BluetoothGatt? = null
+ // DIY barometer (BaroBalDav): its own data/log files, and the last temperature so each pressure
+ // reading can carry the temperature for the altitude calc on the JS side.
+ private var baroDataFile: File? = null
+ private var baroLogFile: File? = null
+ private var lastTempC: Double = Double.NaN
// One handler for all postDelayed work, so teardown can cancel every pending callback at once
// (a stray callback touching a GATT/scanner after the adapter is off crashes the native BT stack).
@@ -135,9 +145,14 @@ class BleHrService : Service() {
startForeground(NOTIFICATION_ID, buildNotification("Подключение..."))
isRunning = true
val appDataDir = filesDir.parentFile ?: filesDir
- dataFile = File(appDataDir, "ble_hr_data.json")
- logFile = File(appDataDir, "ble_hr_log.jsonl")
- uptimeFile = File(appDataDir, "strap_uptime.json")
+ if (deviceType == "baro") {
+ baroDataFile = File(appDataDir, "ble_baro_data.json")
+ baroLogFile = File(appDataDir, "ble_baro_log.jsonl")
+ } else {
+ dataFile = File(appDataDir, "ble_hr_data.json")
+ logFile = File(appDataDir, "ble_hr_log.jsonl")
+ uptimeFile = File(appDataDir, "strap_uptime.json")
+ }
// fresh user-initiated connect: arm reconnect only after it actually connects
lastAddress = address; stopping = false; connectedOnce = false; reconnectTries = 0
connectDevice(address)
@@ -176,8 +191,10 @@ class BleHrService : Service() {
val services = result.scanRecord?.serviceUuids?.map { it.uuid } ?: emptyList()
val type = when {
+ services.contains(ESS_SERVICE) -> "baro"
services.contains(HR_SERVICE_UUID) -> "hr_standard"
services.contains(FITPRO_SERVICE) -> "fitpro"
+ name.lowercase().contains("baro") -> "baro"
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"
@@ -297,6 +314,7 @@ class BleHrService : Service() {
if (deviceType == "unknown") {
deviceType = when {
g.getService(HR_SERVICE_UUID) != null -> "hr_standard"
+ g.getService(ESS_SERVICE) != null -> "baro"
g.getService(FITPRO_SERVICE) != null -> "fitpro"
else -> "fitpro" // fallback: try FitPro (Nordic UART is common for cheap bracelets)
}
@@ -308,15 +326,18 @@ class BleHrService : Service() {
when (deviceType) {
"hr_standard" -> setupStandardHr(g)
"fitpro" -> setupFitPro(g)
+ "baro" -> setupBaro(g)
}
- updateNotification("Пульсометр подключён")
- startHydrationReminder() // workout is on (strap connected) → arm the drink reminder
+ updateNotification(if (deviceType == "baro") "Барометр подключён" else "Пульсометр подключён")
+ // drink reminder is a workout/HR feature; the barometer is just a passive altitude source
+ if (deviceType != "baro") startHydrationReminder()
}
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)
+ "baro" -> parseBaro(char, value)
"fitpro" -> {
// Send ACK for data packets (0xCD) — required by FitPro protocol
if (value.isNotEmpty() && value[0] == 0xCD.toByte() && value.size >= 4) {
@@ -391,6 +412,59 @@ class BleHrService : Service() {
} catch (e: Exception) { Log.e(TAG, "readBattery: ${e.message}") }
}
+ // DIY barometer (Environmental Sensing): subscribe to pressure (required) and temperature
+ // (optional) notifications. Two CCCD writes can't overlap, so queue them like the FitPro setup.
+ 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, "Baro setup: subscribed pressure${if (tc != null) " + temp" else ""}")
+ }
+
+ // Enable notifications on one characteristic and write its CCCD; onDescriptorWrite advances the
+ // GATT queue. If there's no CCCD, complete immediately so the queue doesn't stall.
+ 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() }
+ }
+
+ // Pressure char = uint32 little-endian in 0.1 Pa; Temperature char = int16 little-endian in 0.01 C
+ // (standard BLE Environmental Sensing units). Temperature arrives on its own notification, so keep
+ // the last value and attach it to each pressure reading. JS turns pressure into altitude.
+ 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 // 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}"
+ 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")
+ }
+ }
+ }
+ }
+
private val gattQueue = mutableListOf<() -> Unit>()
private var gattBusy = false
diff --git a/src-tauri/src/ble.rs b/src-tauri/src/ble.rs
index 1e33525..b099334 100644
--- a/src-tauri/src/ble.rs
+++ b/src-tauri/src/ble.rs
@@ -29,6 +29,17 @@ pub struct HrReading {
pub battery: Option,
}
+// DIY barometer reading (BaroBalDav): atmospheric pressure in Pa and optional chip temperature.
+// JS turns pressure into altitude (hypsometric formula, using temp), anchored to GPS at ride start.
+#[derive(Serialize, Deserialize, Clone, Debug, Default)]
+pub struct BaroReading {
+ pub pressure_pa: f64,
+ #[serde(default)]
+ pub temp_c: Option,
+ #[serde(alias = "ts", default)]
+ pub timestamp: u64,
+}
+
// Shared state for BLE operations
#[cfg(not(target_os = "android"))]
lazy_static::lazy_static! {
@@ -49,6 +60,9 @@ struct BleState {
const HR_SERVICE_UUID: &str = "0000180d-0000-1000-8000-00805f9b34fb";
const HR_MEASUREMENT_UUID: &str = "00002a37-0000-1000-8000-00805f9b34fb";
+// Standard Environmental Sensing — our DIY barometer "BaroBalDav"
+const ESS_SERVICE: &str = "0000181a-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";
@@ -81,6 +95,10 @@ mod desktop {
}
fn detect_device_type(name: &str, services: &[Uuid]) -> String {
+ // Our DIY barometer advertises standard Environmental Sensing
+ if services.contains(&uuid(ESS_SERVICE)) {
+ return "baro".to_string();
+ }
// Check for standard HR service
if services.contains(&uuid(HR_SERVICE_UUID)) {
return "hr_standard".to_string();
@@ -384,6 +402,15 @@ mod android {
}
Ok(None)
}
+
+ pub fn read_baro_data(app: &tauri::AppHandle) -> Result