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); +}