ble: barometer on its own connection - simultaneous HR+baro, auto-connect, UI

Separate BleBaroService so the HR strap and the barometer each hold their own GATT link and
work at the same time (one adapter, several LE connections). Verified on device against a PC
BlueZ emulator of the sensor: BleHR and BleBaro deliver in parallel, no cross-talk.

- BleBaroService: dedicated baro connection (scan-before-connect + direct-connect fallback,
  ESS subscribe, writes ble_baro_data.json). MainActivity routes baro connects to it and
  auto-connects both HR (ble_device.json) and baro (baro_device.json) at start.
- Rust: ble_baro_disconnect, ble_save_baro_device.
- Settings: a 'Датчики (BLE)' section - heart-rate and barometer as separate rows with their
  own slots, bind/connect/unbind, and a live pressure/temperature/altitude readout.
- Tracking: altitude_baro_m stored per point, preferred in elevation stats (null without baro).
- Record screen: 'Высота' from the barometer (1 Hz, dot marker), temperature from the sensor.
- Fixes: direct-connect on scan timeout; Locale.US JSON (RU decimal comma broke the parse);
  parsing routed by characteristic (baro no longer written into the pulse); VR/HR stop returns
  to the tracks list instead of into details.
- tools/baro-sensor/emulate_baro.py: PC BlueZ peripheral that emulates the sensor for testing.

Emulator-verified only; real board still pending.
This commit is contained in:
4aiman 2026-10-30 22:46:26 +03:00
commit 9251c1dd90
12 changed files with 653 additions and 34 deletions

View file

@ -48,6 +48,11 @@
android:foregroundServiceType="connectedDevice"
android:exported="false" />
<service
android:name=".BleBaroService"
android:foregroundServiceType="connectedDevice"
android:exported="false" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"

View file

@ -0,0 +1,257 @@
package dev.baldav.app
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.bluetooth.*
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.Build
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import android.util.Log
import java.io.File
import java.util.Locale
import java.util.UUID
// Dedicated BLE connection for the DIY barometer "BaroBalDav" (ESP32-C3 + BMP280). Kept separate
// from BleHrService so the heart-rate strap and the barometer each hold their OWN GATT link and work
// at the same time (one adapter handles several LE connections). Reads the standard Environmental
// Sensing service and writes the latest {pressure_pa, temp_c, ts} to ble_baro_data.json for Rust/JS.
class BleBaroService : Service() {
companion object {
const val TAG = "BleBaro"
const val CHANNEL_ID = "baldav_ble_baro"
const val NOTIFICATION_ID = 3
const val ACTION_STOP = "dev.baldav.app.STOP_BARO"
const val EXTRA_ADDRESS = "address"
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
val CCCD_UUID: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
var isRunning = false
private set
}
private var gatt: BluetoothGatt? = null
private var dataFile: File? = null
private var logFile: File? = null
private var lastAddress: String? = null
private var connectedOnce = false
private var stopping = false
private var reconnectTries = 0
private val maxReconnect = 3
private var lastTempC: Double = Double.NaN
private val handler = Handler(Looper.getMainLooper())
private val btStateReceiver = object : BroadcastReceiver() {
override fun onReceive(ctx: Context?, intent: Intent?) {
if (intent?.action != BluetoothAdapter.ACTION_STATE_CHANGED) return
val state = intent.getIntExtra(BluetoothAdapter.EXTRA_STATE, BluetoothAdapter.ERROR)
if (state == BluetoothAdapter.STATE_TURNING_OFF || state == BluetoothAdapter.STATE_OFF) {
stopping = true
handler.removeCallbacksAndMessages(null)
disconnect()
stopSelf()
}
}
}
private fun adapter(): BluetoothAdapter? =
(getSystemService(BLUETOOTH_SERVICE) as? android.bluetooth.BluetoothManager)?.adapter
override fun onBind(intent: Intent?): IBinder? = null
override fun onCreate() {
super.onCreate()
createNotificationChannel()
try { registerReceiver(btStateReceiver, IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED)) }
catch (e: Exception) { Log.e(TAG, "register BT receiver: ${e.message}") }
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent?.action == ACTION_STOP) {
stopping = true
disconnect()
stopSelf()
return START_NOT_STICKY
}
val address = intent?.getStringExtra(EXTRA_ADDRESS)
if (address != null) {
startForeground(NOTIFICATION_ID, buildNotification("Барометр: подключение..."))
isRunning = true
val appDataDir = filesDir.parentFile ?: filesDir
dataFile = File(appDataDir, "ble_baro_data.json")
logFile = File(appDataDir, "ble_baro_log.jsonl")
lastAddress = address; stopping = false; connectedOnce = false; reconnectTries = 0
connectDevice(address)
}
return START_NOT_STICKY
}
// Quick scan to warm the BLE cache (avoids status=133), then connect. On scan timeout fall back
// to a direct connect by address - a stationary sensor is reachable even if a 5 s scan misses it.
private fun connectDevice(address: String) {
val adapter = adapter()
if (adapter == null || !adapter.isEnabled) { Log.e(TAG, "Bluetooth not available"); stopSelf(); return }
val scanner = adapter.bluetoothLeScanner
if (scanner == null) { directConnect(adapter, address); return }
val scanCallback = object : android.bluetooth.le.ScanCallback() {
override fun onScanResult(callbackType: Int, result: android.bluetooth.le.ScanResult) {
if (result.device.address == address) {
try { scanner.stopScan(this) } catch (e: Exception) {}
try { gatt = result.device.connectGatt(this@BleBaroService, false, gattCallback, BluetoothDevice.TRANSPORT_LE) }
catch (e: Exception) { Log.e(TAG, "connectGatt: ${e.message}") }
}
}
override fun onScanFailed(errorCode: Int) { directConnect(adapter, address) }
}
try { scanner.startScan(scanCallback) } catch (e: Exception) { directConnect(adapter, address); return }
handler.postDelayed({
try { scanner.stopScan(scanCallback) } catch (e: Exception) {}
if (gatt == null) { Log.d(TAG, "scan timeout - direct connect"); directConnect(adapter, address) }
}, 5000)
}
private fun directConnect(adapter: BluetoothAdapter, address: String) {
if (!adapter.isEnabled) { stopSelf(); return }
try {
gatt = adapter.getRemoteDevice(address).connectGatt(this, false, gattCallback, BluetoothDevice.TRANSPORT_LE)
} catch (e: Exception) { Log.e(TAG, "directConnect: ${e.message}"); stopSelf() }
}
private val gattCallback = object : BluetoothGattCallback() {
override fun onConnectionStateChange(g: BluetoothGatt, status: Int, newState: Int) {
if (newState == BluetoothProfile.STATE_CONNECTED) {
updateNotification("Барометр: поиск сервисов...")
try { g.discoverServices() } catch (e: Exception) { Log.e(TAG, "discoverServices: ${e.message}") }
} else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
isRunning = false
try { g.close() } catch (e: Exception) {}
gatt = null
if (!stopping && connectedOnce && reconnectTries < maxReconnect && adapter()?.isEnabled == true && lastAddress != null) {
reconnectTries++
updateNotification("Барометр: переподключение...")
handler.postDelayed({ if (!stopping) connectDevice(lastAddress!!) }, 8000)
} else stopSelf()
}
}
override fun onServicesDiscovered(g: BluetoothGatt, status: Int) {
if (status != BluetoothGatt.GATT_SUCCESS) { Log.e(TAG, "service discovery failed: $status"); return }
connectedOnce = true; reconnectTries = 0; isRunning = true
setupBaro(g)
updateNotification("Барометр подключён")
}
override fun onCharacteristicChanged(g: BluetoothGatt, char: BluetoothGattCharacteristic, value: ByteArray) {
parseBaro(char, value)
}
@Deprecated("Deprecated in API 33")
override fun onCharacteristicChanged(g: BluetoothGatt, char: BluetoothGattCharacteristic) {
parseBaro(char, char.value ?: return)
}
override fun onDescriptorWrite(g: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int) {
onGattOperationComplete()
}
}
private val gattQueue = mutableListOf<() -> 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()
}
}

View file

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

View file

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

View file

@ -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<String, String> {
#[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,

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