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.
This commit is contained in:
4aiman 2026-10-30 00:38:12 +03:00
commit 82f3dc9d4a
9 changed files with 417 additions and 6 deletions

View file

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

View file

@ -29,6 +29,17 @@ pub struct HrReading {
pub battery: Option<u8>,
}
// 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<f64>,
#[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<Option<BaroReading>, String> {
if let Some(s) = read_file(app, "ble_baro_data.json") {
if let Ok(r) = serde_json::from_str::<BaroReading>(&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<Option<BaroReading>, 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

View file

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