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:
parent
1101016ef4
commit
82f3dc9d4a
9 changed files with 417 additions and 6 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue