From 1101016ef4cd059a2459de6f825a3df9e417d6f2 Mon Sep 17 00:00:00 2001 From: 4aiman Date: Tue, 27 Oct 2026 21:47:10 +0300 Subject: [PATCH 1/4] hydration: spoken 'hydrate now' alert, per-activity sweat, drink reconciliation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - signal is now a loud TTS 'hydrate now' on the ALARM stream (TTS warmed at session start), so it is unmistakably BalDav and audible when the phone is not in view (walking/VR); the old ringtone was indistinguishable from a system alarm. - sweat need scales by activity instead of a flat 600 ml/h: bike 600, walk 200, run 450, workout/vr 250 ml/h. Walking no longer nags every 15 min. - deficit split into baseDeficit (seeded from the food log) + accrued sweat; logging a drink mid-session recomputes the base and re-seeds via seedSeq, so it silences the reminder. seedSeq=0 (profile save / init) never zeroes it mid-ride. - persistent 'БалДав: попей воды (HH:MM)' notification left as a record. --- frontend/js/main.js | 47 +++++++--- .../src/main/java/dev/baldav/app/Hydration.kt | 85 +++++++++++++------ src-tauri/src/lib.rs | 8 +- 3 files changed, 100 insertions(+), 40 deletions(-) diff --git a/frontend/js/main.js b/frontend/js/main.js index 7e59ec0..f40942e 100644 --- a/frontend/js/main.js +++ b/frontend/js/main.js @@ -1364,6 +1364,7 @@ function showFoodModal() { amountG = Math.round(rawAmount * selectedUnit.grams_per_unit * 10) / 10; } await DB.addFoodEntry(state.date, time, selectedProduct.id, amountG, null, unitId, unitAmount); + hydrationOnDrink(); // if it was liquid, quiet the reminder once you've drunk } else { let desc = overlay.querySelector("#m-desc").value.trim(); if (!desc) return false; @@ -1850,6 +1851,7 @@ async function renderProducts(container) { let note = overlay.querySelector("#m-note").value.trim() || null; if (isNaN(amount) || amount <= 0) return false; await DB.addFoodEntry(state.date, time, productId, amount, note); + hydrationOnDrink(); // quiet the drink reminder if this was liquid }); } @@ -2824,23 +2826,46 @@ let gpsMarker = null; // At session start, seed the native hydration deficit from the FOOD LOG: how far behind on water // you are for the time of day (need-so-far minus logged intake). During the session the native // service adds sweat on top. Intake comes from what you log, not a button. -async function seedHydrationDeficit() { +// Sweat (extra water loss) per activity — cycling sweats far more than a walk, so a walk must +// NOT accrue the ride rate (that made it falsely hit the threshold after ~1 h of walking). +let HYDR_SWEAT = { bike: 600, walk: 200, run: 450, workout: 250, vr: 250 }; +function hydrSweatFor(type) { return HYDR_SWEAT[type] || 600; } + +// Compute the current sedentary shortfall (need-so-far for the time of day minus what you've +// logged drinking today). Shared by the session seed and the on-drink re-seed. +async function hydrBaseDeficit() { + let target = parseInt(state.profile?.daily_water_ml) || 2000; + let now = new Date(); + let nowMin = now.getHours() * 60 + now.getMinutes(); + let wakeStart = 7 * 60, wakeEnd = 23 * 60; // assume waking 07:00–23:00 + let frac = Math.max(0, Math.min(1, (nowMin - wakeStart) / (wakeEnd - wakeStart))); + let intake = await DB.getDailyWaterMl(now.toISOString().slice(0, 10)); + return Math.max(0, Math.min(5000, Math.round(target * frac - intake))); +} + +async function seedHydrationDeficit(activityType) { if (localStorage.getItem("baldav_hydr_on") === "0") return; // ON by default unless turned off try { - let target = parseInt(state.profile?.daily_water_ml) || 2000; - let now = new Date(); - let nowMin = now.getHours() * 60 + now.getMinutes(); - let wakeStart = 7 * 60, wakeEnd = 23 * 60; // assume waking 07:00–23:00 - let frac = Math.max(0, Math.min(1, (nowMin - wakeStart) / (wakeEnd - wakeStart))); - let needSoFar = target * frac; - let intake = await DB.getDailyWaterMl(now.toISOString().slice(0, 10)); - let startDeficit = Math.max(0, Math.min(5000, Math.round(needSoFar - intake))); + let startDeficit = await hydrBaseDeficit(); let alertMl = parseInt(localStorage.getItem("baldav_hydr_alert")) || 600; await window.__TAURI__.core.invoke("set_hydration_config", - { enabled: true, alertMl, sweatMlPerHour: 600, reAlertMin: 15, startDeficitMl: startDeficit }); + { enabled: true, alertMl, sweatMlPerHour: hydrSweatFor(activityType), reAlertMin: 15, startDeficitMl: startDeficit, seedSeq: Date.now() }); } catch (e) { console.error("seedHydration", e); } } +// After you log a drink DURING a session: recompute the base deficit (now lower) and push it with +// a fresh seedSeq so the native side drops baseDeficit — the reminder goes quiet once you've drunk. +async function hydrationOnDrink() { + try { + if (!(GPS.recording && GPS.recording.active)) return; // only matters during a session + if (localStorage.getItem("baldav_hydr_on") === "0") return; + let base = await hydrBaseDeficit(); + let alertMl = parseInt(localStorage.getItem("baldav_hydr_alert")) || 600; + await window.__TAURI__.core.invoke("set_hydration_config", + { enabled: true, alertMl, sweatMlPerHour: hydrSweatFor(GPS.recording.activityType), reAlertMin: 15, startDeficitMl: base, seedSeq: Date.now() }); + } catch (e) {} +} + async function renderGpsScreen(container) { // Check Bluetooth state if BLE device is paired let bleAddr = await DB.getProfileValue("ble_device_addr"); @@ -2955,7 +2980,7 @@ async function renderGpsScreen(container) { heightCm: parseFloat(state.profile?.height_cm) || 178, } : null; await GPS.startRecording(type, wkg, hrProfile); - seedHydrationDeficit(); + seedHydrationDeficit(type); renderScreen("recording"); } }; diff --git a/src-tauri/gen/android/app/src/main/java/dev/baldav/app/Hydration.kt b/src-tauri/gen/android/app/src/main/java/dev/baldav/app/Hydration.kt index c645276..46a8ba2 100644 --- a/src-tauri/gen/android/app/src/main/java/dev/baldav/app/Hydration.kt +++ b/src-tauri/gen/android/app/src/main/java/dev/baldav/app/Hydration.kt @@ -12,8 +12,10 @@ import android.os.Build import android.os.Handler import android.os.VibrationEffect import android.os.Vibrator +import android.speech.tts.TextToSpeech import android.util.Log import java.io.File +import java.util.Locale import org.json.JSONObject // Drink reminder shared by GpsRecorderService (rides) and BleHrService (VR/workout). LEVEL-based, @@ -25,18 +27,23 @@ import org.json.JSONObject object Hydration { private const val TAG = "Hydration" private const val CH = "baldav_hydration" - private var accruedMl = 0.0 // running water deficit: start-of-session shortfall + sweat - private var startDeficitMl = 0.0 // how far behind you were at session start (from the food log) - private var pendingStart = false // apply startDeficitMl on the first tick after reset() + private var baseDeficit = 0.0 // sedentary shortfall at (re)seed: need-so-far minus intake + private var sweatMl = 0.0 // activity sweat accrued this session (grows with the session) + private var startDeficitMl = 0.0 // last seeded base from the food log; applied on (re)seed + private var seedSeq = 0L // JS bumps it on (re)seed (e.g. after you log a drink) + private var lastSeedSeq = -1L // a change re-applies startDeficitMl to baseDeficit mid-session + private var pendingStart = false // apply the seed on the first tick after reset() private var lastTickMs = 0L private var lastBeepMs = 0L private var lastCfgMs = 0L private var enabled = true // ON by default — works out of the box, no toggle to find - private var sweatMlPerMin = 10.0 // need rate during an active session (600 ml/h default) + private var sweatMlPerMin = 10.0 // need rate during an active session (set per activity by JS) private var alertMl = 600.0 // beep once the deficit reaches this private var reAlertMs = 15 * 60 * 1000L private var lastLogMs = 0L private var savedAlarmVol = -1 // alarm volume to restore after an alert + private var tts: TextToSpeech? = null + private var ttsReady = false // the signal is a SPOKEN "hydrate now" — unmistakably BalDav private fun readCfg(ctx: Context) { try { @@ -48,6 +55,11 @@ object Hydration { alertMl = j.optInt("alertMl", 600).coerceIn(100, 3000).toDouble() reAlertMs = j.optInt("reAlertMin", 15).coerceIn(2, 120) * 60_000L startDeficitMl = j.optInt("startDeficitMl", 0).coerceIn(0, 5000).toDouble() + seedSeq = j.optLong("seedSeq", 0L) + // a NON-ZERO new seedSeq means JS recomputed the base (session start / after a drink) + // -> re-apply it to baseDeficit mid-session; sweat keeps accruing. seedSeq=0 (profile + // save, init) must NOT zero the deficit mid-ride, so it's ignored here. + if (seedSeq != 0L && seedSeq != lastSeedSeq) { baseDeficit = startDeficitMl; lastSeedSeq = seedSeq } } else enabled = true // no config yet -> ON by default } catch (e: Exception) { Log.e(TAG, "cfg: ${e.message}"); enabled = true } } @@ -63,14 +75,15 @@ object Hydration { val now = System.currentTimeMillis() if (now - lastCfgMs > 30_000L) { readCfg(ctx); lastCfgMs = now } if (!enabled) { if (now - lastLogMs > 120_000L) { logState(ctx, "disabled"); lastLogMs = now }; return } - if (pendingStart) { accruedMl = startDeficitMl; pendingStart = false } - if (lastTickMs == 0L) { lastTickMs = now; logState(ctx, "start"); lastLogMs = now; return } + if (pendingStart) { baseDeficit = startDeficitMl; sweatMl = 0.0; lastSeedSeq = seedSeq; pendingStart = false } + if (lastTickMs == 0L) { lastTickMs = now; ensureTts(ctx); logState(ctx, "start"); lastLogMs = now; return } val dtMin = (now - lastTickMs) / 60_000.0 lastTickMs = now - accruedMl += sweatMlPerMin * dtMin - if (accruedMl >= alertMl && now - lastBeepMs >= reAlertMs) { + sweatMl += sweatMlPerMin * dtMin + val deficit = baseDeficit + sweatMl + if (deficit >= alertMl && now - lastBeepMs >= reAlertMs) { lastBeepMs = now - val ok = beep(ctx, handler); vibrate(ctx); notify(ctx, accruedMl.toInt()) + val ok = beep(ctx, handler); vibrate(ctx); notify(ctx, deficit.toInt()) logState(ctx, if (ok) "beepOk" else "beepError"); lastLogMs = now } else if (now - lastLogMs > 120_000L) { logState(ctx, "tick"); lastLogMs = now } } @@ -80,8 +93,8 @@ object Hydration { try { val f = File(ctx.filesDir.parentFile ?: ctx.filesDir, "hydration_log.jsonl") f.appendText("{\"ts\":${System.currentTimeMillis() / 1000},\"ev\":\"$event\"," + - "\"enabled\":$enabled,\"accruedMl\":${accruedMl.toInt()},\"alertMl\":${alertMl.toInt()}," + - "\"startMl\":${startDeficitMl.toInt()}}\n") + "\"enabled\":$enabled,\"accruedMl\":${(baseDeficit + sweatMl).toInt()},\"sweatMl\":${sweatMl.toInt()}," + + "\"baseMl\":${baseDeficit.toInt()},\"alertMl\":${alertMl.toInt()},\"startMl\":${startDeficitMl.toInt()}}\n") } catch (e: Exception) {} } @@ -93,24 +106,43 @@ object Hydration { val am = ctx.getSystemService(Context.AUDIO_SERVICE) as AudioManager if (savedAlarmVol < 0) savedAlarmVol = am.getStreamVolume(AudioManager.STREAM_ALARM) am.setStreamVolume(AudioManager.STREAM_ALARM, am.getStreamMaxVolume(AudioManager.STREAM_ALARM), 0) - val uri = RingtoneManager.getActualDefaultRingtoneUri(ctx, RingtoneManager.TYPE_ALARM) - ?: RingtoneManager.getDefaultUri(RingtoneManager.TYPE_ALARM) - val mp = MediaPlayer() - mp.setAudioAttributes(AudioAttributes.Builder() - .setUsage(AudioAttributes.USAGE_ALARM) - .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION).build()) - mp.setDataSource(ctx, uri) - mp.isLooping = true - mp.prepare() - mp.start() + if (tts == null) { + tts = TextToSpeech(ctx.applicationContext) { status -> + ttsReady = status == TextToSpeech.SUCCESS + if (ttsReady) { try { tts?.language = Locale.US } catch (e: Exception) {}; speakNow() } + } + } else if (ttsReady) speakNow() + // restore the alarm volume a few seconds after the phrase handler.postDelayed({ - try { mp.stop(); mp.release() } catch (e: Exception) {} try { if (savedAlarmVol >= 0) { am.setStreamVolume(AudioManager.STREAM_ALARM, savedAlarmVol, 0); savedAlarmVol = -1 } } catch (e: Exception) {} - }, 4000) + }, 5000) true } catch (e: Exception) { Log.e(TAG, "beep: ${e.message}"); false } } + // Warm the TTS engine up at session start so the first alert isn't silent (init is async). + private fun ensureTts(ctx: Context) { + if (tts != null) return + try { + tts = TextToSpeech(ctx.applicationContext) { status -> + ttsReady = status == TextToSpeech.SUCCESS + if (ttsReady) { try { tts?.language = Locale.US } catch (e: Exception) {} } + } + } catch (e: Exception) { Log.e(TAG, "tts init: ${e.message}") } + } + + // Speak the phrase over the ALARM stream so it's loud even with media muted. + private fun speakNow() { + try { + val t = tts ?: return + t.setAudioAttributes(AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_ALARM) + .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH).build()) + t.speak("hydrate now", TextToSpeech.QUEUE_FLUSH, null, "hyd1") + t.speak("hydrate now", TextToSpeech.QUEUE_ADD, null, "hyd2") + } catch (e: Exception) { Log.e(TAG, "speak: ${e.message}") } + } + private fun vibrate(ctx: Context) { try { val v = ctx.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator ?: return @@ -127,11 +159,12 @@ object Hydration { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && nm.getNotificationChannel(CH) == null) { nm.createNotificationChannel(NotificationChannel(CH, "Напоминание пить", NotificationManager.IMPORTANCE_HIGH)) } + val time = java.text.SimpleDateFormat("HH:mm", Locale.getDefault()).format(java.util.Date()) val n = Notification.Builder(ctx, CH) - .setContentTitle("Попей воды!") - .setContentText("Отстаёшь примерно на $deficitMl мл — попей") + .setContentTitle("БалДав: попей воды") + .setContentText("$time — отстаёшь примерно на $deficitMl мл") .setSmallIcon(android.R.drawable.ic_menu_compass) - .setAutoCancel(true) + .setAutoCancel(true) // stays in the shade as a record until you tap it .build() nm.notify(776601, n) } catch (e: Exception) { Log.e(TAG, "notify: ${e.message}") } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b7b4260..168b67e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -550,10 +550,12 @@ fn gps_service_command(app: tauri::AppHandle, cmd: String) -> Result Result { +fn set_hydration_config(app: tauri::AppHandle, enabled: bool, alert_ml: u32, sweat_ml_per_hour: u32, re_alert_min: u32, start_deficit_ml: u32, seed_seq: Option) -> Result { + // seed_seq: bump it (e.g. a timestamp) whenever the base deficit is recomputed mid-session + // (after logging a drink) so the native side re-applies start_deficit_ml to its baseDeficit. let json = format!( - "{{\"enabled\":{},\"alertMl\":{},\"sweatMlPerHour\":{},\"reAlertMin\":{},\"startDeficitMl\":{}}}", - enabled, alert_ml.clamp(100, 3000), sweat_ml_per_hour.clamp(60, 2000), re_alert_min.clamp(2, 120), start_deficit_ml.clamp(0, 5000) + "{{\"enabled\":{},\"alertMl\":{},\"sweatMlPerHour\":{},\"reAlertMin\":{},\"startDeficitMl\":{},\"seedSeq\":{}}}", + enabled, alert_ml.clamp(100, 3000), sweat_ml_per_hour.clamp(60, 2000), re_alert_min.clamp(2, 120), start_deficit_ml.clamp(0, 5000), seed_seq.unwrap_or(0) ); write_app_root_file(&app, "hydration.json", &json); Ok(json) From 82f3dc9d4a6a2280eda64f499302c6ac21d8e5f8 Mon Sep 17 00:00:00 2001 From: 4aiman Date: Fri, 30 Oct 2026 00:38:12 +0300 Subject: [PATCH 2/4] ble: read DIY barometer (BaroBalDav) over BLE + firmware Self-made altitude sensor: ESP32-C3 SuperMini + BMP280 broadcasting pressure and temperature over standard BLE Environmental Sensing. Read into baldav the same way as the heart-rate strap (Kotlin BLE service -> file -> Rust -> JS), new device_type 'baro'. - BleHrService: 'baro' type subscribes to ESS 0x181A (pressure 0x2A6D uint32 0.1 Pa, temperature 0x2A6E int16 0.01 C), writes ble_baro_data.json + ble_baro_log.jsonl. Every baro branch is gated by deviceType=='baro', so HR/GPS paths are untouched. - ble.rs: BaroReading, ESS detection, ble_get_baro command (Android reads the file, desktop returns None). Registered in lib.rs. - frontend/js/baro.js: passive window.BARO - hypsometric pressure->altitude using the sensor temperature, anchored to GPS altitude at ride start. Nothing runs until BARO.start(), so with no barometer the app is unchanged. - tools/baro-sensor: firmware (PlatformIO main.cpp + Arduino sketch) that reads BMP280 at max oversampling and advertises the ESS service. Prep only: no hardware yet, verified by compile (build 0.1.538). Live wiring and ride-track integration pending the board. --- .../2026-10-30_barometer-ble-baldav.md | 30 +++++ frontend/index.html | 1 + frontend/js/baro.js | 116 ++++++++++++++++++ .../main/java/dev/baldav/app/BleHrService.kt | 84 ++++++++++++- src-tauri/src/ble.rs | 39 ++++++ src-tauri/src/lib.rs | 2 +- .../arduino/baro_sensor/baro_sensor.ino | 70 +++++++++++ tools/baro-sensor/platformio.ini | 20 +++ tools/baro-sensor/src/main.cpp | 61 +++++++++ 9 files changed, 417 insertions(+), 6 deletions(-) create mode 100644 docs/session-logs/2026-10-30_barometer-ble-baldav.md create mode 100644 frontend/js/baro.js create mode 100644 tools/baro-sensor/arduino/baro_sensor/baro_sensor.ino create mode 100644 tools/baro-sensor/platformio.ini create mode 100644 tools/baro-sensor/src/main.cpp 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, String> { + if let Some(s) = read_file(app, "ble_baro_data.json") { + if let Ok(r) = serde_json::from_str::(&s) { + if r.pressure_pa > 0.0 { return Ok(Some(r)); } + } + } + Ok(None) + } } // --- Tauri commands --- @@ -465,6 +492,18 @@ pub fn ble_get_hr( { android::read_hr_data(&app) } } +// Latest barometer reading (BaroBalDav). Android: read from the file the Kotlin BLE service writes. +// Desktop: not wired (btleplug baro path not implemented) — returns None. +#[tauri::command] +pub fn ble_get_baro( + app: tauri::AppHandle, +) -> Result, String> { + #[cfg(not(target_os = "android"))] + { let _ = &app; Ok(None) } + #[cfg(target_os = "android")] + { android::read_baro_data(&app) } +} + // Full HR history for the current workout. The Kotlin foreground service appends every // reading to ble_hr_log.jsonl (one {"hr","ts"[,"bat"]} per line) — that keeps logging with // the screen off, unlike the JS poll which Android freezes in the background. The workout diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 168b67e..fa6560d 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_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_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/arduino/baro_sensor/baro_sensor.ino b/tools/baro-sensor/arduino/baro_sensor/baro_sensor.ino new file mode 100644 index 0000000..3b14838 --- /dev/null +++ b/tools/baro-sensor/arduino/baro_sensor/baro_sensor.ino @@ -0,0 +1,70 @@ +// BalDav BLE barometer - Arduino IDE version (ESP32-C3 SuperMini + BMP280). +// Reads BMP280 over I2C at max oversampling (+IIR) and broadcasts pressure and temperature over +// BLE as the standard Environmental Sensing service, ~1 Hz. baldav reads it via noble and turns +// pressure into altitude (using the temperature for the hypsometric correction). It advertises +// continuously so a USB power bank does not cut off on an idle load. +// +// One-time setup in Arduino IDE: +// 1. File > Preferences > Additional Boards Manager URLs: +// https://raw.githubusercontent.com/espressif/arduino-esp32/gh-pages/package_esp32_index.json +// 2. Boards Manager: install "esp32 by Espressif Systems". +// 3. Tools > Board > ESP32 Arduino > "ESP32C3 Dev Module". +// Tools > USB CDC On Boot > Enabled (so the native USB serial works). +// 4. Library Manager: install "Adafruit BMP280 Library" (pulls Adafruit Unified Sensor + +// Adafruit BusIO) and "NimBLE-Arduino". +// 5. Plug the board (USB-C), pick the port, press Upload. +// If upload can't connect: hold BOOT (GPIO9), tap RESET, release BOOT, upload again. + +#include +#include +#include + +static const int SDA_PIN = 3; // BMP280 SDA -> GPIO3 +static const int SCL_PIN = 4; // BMP280 SCL -> GPIO4 +static const uint8_t BMP_ADDR_A = 0x76; // SDO -> GND +static const uint8_t BMP_ADDR_B = 0x77; // SDO -> 3V3 (fallback) + +Adafruit_BMP280 bmp; +NimBLECharacteristic* chPress; // 0x2A6D Pressure, uint32, units 0.1 Pa +NimBLECharacteristic* chTemp; // 0x2A6E Temperature, int16, units 0.01 C + +void setup() { + Serial.begin(115200); + Wire.begin(SDA_PIN, SCL_PIN); + if (!bmp.begin(BMP_ADDR_A) && !bmp.begin(BMP_ADDR_B)) { + Serial.println("BMP280 not found - check VCC/GND/SDA/SCL, CSB->3V3, SDO->GND"); + } + bmp.setSampling(Adafruit_BMP280::MODE_NORMAL, + Adafruit_BMP280::SAMPLING_X2, // temperature oversampling + Adafruit_BMP280::SAMPLING_X16, // pressure oversampling (max) + Adafruit_BMP280::FILTER_X16, // IIR filter (max smoothing) + Adafruit_BMP280::STANDBY_MS_250); + + NimBLEDevice::init("BaroBalDav"); + NimBLEServer* srv = NimBLEDevice::createServer(); + NimBLEService* ess = srv->createService("181A"); // Environmental Sensing + chPress = ess->createCharacteristic("2A6D", + NIMBLE_PROPERTY::READ | NIMBLE_PROPERTY::NOTIFY); // Pressure + chTemp = ess->createCharacteristic("2A6E", + NIMBLE_PROPERTY::READ | NIMBLE_PROPERTY::NOTIFY); // Temperature + ess->start(); + + NimBLEAdvertising* adv = NimBLEDevice::getAdvertising(); + adv->addServiceUUID("181A"); + adv->setName("BaroBalDav"); + adv->start(); +} + +void loop() { + float pa = bmp.readPressure(); // Pa + float tc = bmp.readTemperature(); // deg C (chip temperature) + + uint32_t pv = (uint32_t)lroundf(pa * 10.0f); // 0.1 Pa units per BLE spec + int16_t tv = (int16_t)lroundf(tc * 100.0f); // 0.01 C units per BLE spec + + chPress->setValue((uint8_t*)&pv, sizeof(pv)); chPress->notify(); + chTemp->setValue((uint8_t*)&tv, sizeof(tv)); chTemp->notify(); + + Serial.printf("P=%.2f hPa T=%.2f C\n", pa / 100.0f, tc); + delay(1000); +} diff --git a/tools/baro-sensor/platformio.ini b/tools/baro-sensor/platformio.ini new file mode 100644 index 0000000..aa06880 --- /dev/null +++ b/tools/baro-sensor/platformio.ini @@ -0,0 +1,20 @@ +; BalDav BLE barometer - ESP32-C3 SuperMini + BMP280 +; Build & flash over USB, no Arduino IDE: +; pip install platformio (once) +; pio run -t upload (compile + flash via USB-C) +; pio device monitor (optional: see logs) +; ESP32-C3 flashes over its native USB. If upload can't find the port the first time: +; hold BOOT (GPIO9), tap RESET, release BOOT -> it enters download mode, then retry upload. + +[env:esp32-c3-supermini] +platform = espressif32 +board = esp32-c3-devkitm-1 +framework = arduino +monitor_speed = 115200 +; native USB-CDC so the board enumerates as a serial port over its own USB-C +build_flags = + -D ARDUINO_USB_MODE=1 + -D ARDUINO_USB_CDC_ON_BOOT=1 +lib_deps = + adafruit/Adafruit BMP280 Library @ ^2.6.8 + h2zero/NimBLE-Arduino @ ^1.4.1 diff --git a/tools/baro-sensor/src/main.cpp b/tools/baro-sensor/src/main.cpp new file mode 100644 index 0000000..94a341c --- /dev/null +++ b/tools/baro-sensor/src/main.cpp @@ -0,0 +1,61 @@ +// BalDav BLE barometer: ESP32-C3 SuperMini + BMP280. +// Reads the BMP280 over I2C at max oversampling (+IIR filter) and broadcasts pressure and +// temperature over BLE as the standard Environmental Sensing service, ~1 Hz. baldav reads it +// via noble like the HR strap and turns pressure into altitude (using the temperature for the +// hypsometric correction). It advertises continuously (no deep sleep) so a USB power bank does +// not cut off on an idle load; current is dominated by the radio, the barometer is negligible. +#include +#include +#include + +static const int SDA_PIN = 3; // BMP280 SDA -> GPIO3 +static const int SCL_PIN = 4; // BMP280 SCL -> GPIO4 +static const uint8_t BMP_ADDR_A = 0x76; // SDO->GND +static const uint8_t BMP_ADDR_B = 0x77; // SDO->3V3 (fallback) + +Adafruit_BMP280 bmp; +NimBLECharacteristic* chPress; // 0x2A6D Pressure, uint32, units 0.1 Pa +NimBLECharacteristic* chTemp; // 0x2A6E Temperature, int16, units 0.01 C + +void setup() { + Serial.begin(115200); + Wire.begin(SDA_PIN, SCL_PIN); + if (!bmp.begin(BMP_ADDR_A) && !bmp.begin(BMP_ADDR_B)) { + Serial.println("BMP280 not found - check wiring (VCC/GND/SDA/SCL, CSB->3V3, SDO->GND)"); + } + // Max pressure oversampling + max IIR filter = lowest noise. We sample ~1 Hz, so the extra + // conversion time costs microamps - nothing next to the BLE radio. + bmp.setSampling(Adafruit_BMP280::MODE_NORMAL, + Adafruit_BMP280::SAMPLING_X2, // temperature oversampling + Adafruit_BMP280::SAMPLING_X16, // pressure oversampling (max) + Adafruit_BMP280::FILTER_X16, // IIR filter (max smoothing) + Adafruit_BMP280::STANDBY_MS_250); + + NimBLEDevice::init("BaroBalDav"); + NimBLEServer* srv = NimBLEDevice::createServer(); + NimBLEService* ess = srv->createService("181A"); // Environmental Sensing + chPress = ess->createCharacteristic("2A6D", + NIMBLE_PROPERTY::READ | NIMBLE_PROPERTY::NOTIFY); // Pressure + chTemp = ess->createCharacteristic("2A6E", + NIMBLE_PROPERTY::READ | NIMBLE_PROPERTY::NOTIFY); // Temperature + ess->start(); + + NimBLEAdvertising* adv = NimBLEDevice::getAdvertising(); + adv->addServiceUUID("181A"); + adv->setName("BaroBalDav"); + adv->start(); +} + +void loop() { + float pa = bmp.readPressure(); // Pa + float tc = bmp.readTemperature(); // deg C (chip temperature) + + uint32_t pv = (uint32_t)lroundf(pa * 10.0f); // 0.1 Pa units per BLE spec + int16_t tv = (int16_t)lroundf(tc * 100.0f); // 0.01 C units per BLE spec + + chPress->setValue((uint8_t*)&pv, sizeof(pv)); chPress->notify(); + chTemp->setValue((uint8_t*)&tv, sizeof(tv)); chTemp->notify(); + + Serial.printf("P=%.2f hPa T=%.2f C\n", pa / 100.0f, tc); + delay(1000); +} From 9251c1dd90a0f59df9e18f1adfaaaff06c350c8f Mon Sep 17 00:00:00 2001 From: 4aiman Date: Fri, 30 Oct 2026 22:46:26 +0300 Subject: [PATCH 3/4] ble: barometer on its own connection - simultaneous HR+baro, auto-connect, UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../2026-10-30_barometer-ble-baldav.md | 21 ++ frontend/css/styles.css | 2 +- frontend/js/db.js | 2 + frontend/js/gps.js | 12 +- frontend/js/main.js | 138 ++++++++-- .../android/app/src/main/AndroidManifest.xml | 5 + .../java/dev/baldav/app/BleBaroService.kt | 257 ++++++++++++++++++ .../main/java/dev/baldav/app/BleHrService.kt | 28 +- .../main/java/dev/baldav/app/MainActivity.kt | 29 +- src-tauri/src/ble.rs | 23 ++ src-tauri/src/lib.rs | 2 +- tools/baro-sensor/emulate_baro.py | 168 ++++++++++++ 12 files changed, 653 insertions(+), 34 deletions(-) create mode 100644 src-tauri/gen/android/app/src/main/java/dev/baldav/app/BleBaroService.kt create mode 100644 tools/baro-sensor/emulate_baro.py 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-подключение за раз.
+
+ +