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

@ -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-соединение, поэтому барометр и пульсометр одновременно
пока нельзя - отдельный следующий шаг (две связи / второй сервис).

View file

@ -20,6 +20,7 @@
<script src="lib/jszip.min.js"></script>
<script src="lib/zxing-wasm.js"></script>
<script src="js/db.js"></script>
<script src="js/baro.js"></script>
<script src="js/bmr.js"></script>
<script src="js/tilecache.js"></script>
<script src="js/gps.js"></script>

116
frontend/js/baro.js Normal file
View file

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

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

View file

@ -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 <Wire.h>
#include <Adafruit_BMP280.h>
#include <NimBLEDevice.h>
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);
}

View file

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

View file

@ -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 <Wire.h>
#include <Adafruit_BMP280.h>
#include <NimBLEDevice.h>
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);
}