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
30
docs/session-logs/2026-10-30_barometer-ble-baldav.md
Normal file
30
docs/session-logs/2026-10-30_barometer-ble-baldav.md
Normal 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-соединение, поэтому барометр и пульсометр одновременно
|
||||||
|
пока нельзя - отдельный следующий шаг (две связи / второй сервис).
|
||||||
|
|
@ -20,6 +20,7 @@
|
||||||
<script src="lib/jszip.min.js"></script>
|
<script src="lib/jszip.min.js"></script>
|
||||||
<script src="lib/zxing-wasm.js"></script>
|
<script src="lib/zxing-wasm.js"></script>
|
||||||
<script src="js/db.js"></script>
|
<script src="js/db.js"></script>
|
||||||
|
<script src="js/baro.js"></script>
|
||||||
<script src="js/bmr.js"></script>
|
<script src="js/bmr.js"></script>
|
||||||
<script src="js/tilecache.js"></script>
|
<script src="js/tilecache.js"></script>
|
||||||
<script src="js/gps.js"></script>
|
<script src="js/gps.js"></script>
|
||||||
|
|
|
||||||
116
frontend/js/baro.js
Normal file
116
frontend/js/baro.js
Normal 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;
|
||||||
|
})();
|
||||||
|
|
@ -42,6 +42,11 @@ class BleHrService : Service() {
|
||||||
val BATTERY_SERVICE: UUID = UUID.fromString("0000180f-0000-1000-8000-00805f9b34fb")
|
val BATTERY_SERVICE: UUID = UUID.fromString("0000180f-0000-1000-8000-00805f9b34fb")
|
||||||
val BATTERY_LEVEL: UUID = UUID.fromString("00002a19-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
|
// FitPro Nordic UART
|
||||||
val FITPRO_SERVICE: UUID = UUID.fromString("6e400001-b5a3-f393-e0a9-e50e24dcca9d")
|
val FITPRO_SERVICE: UUID = UUID.fromString("6e400001-b5a3-f393-e0a9-e50e24dcca9d")
|
||||||
val FITPRO_WRITE: UUID = UUID.fromString("6e400002-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 scanResultsFile: File? = null
|
||||||
private var fitproWriteChar: BluetoothGattCharacteristic? = null
|
private var fitproWriteChar: BluetoothGattCharacteristic? = null
|
||||||
private var fitproGatt: BluetoothGatt? = 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
|
// 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).
|
// (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("Подключение..."))
|
startForeground(NOTIFICATION_ID, buildNotification("Подключение..."))
|
||||||
isRunning = true
|
isRunning = true
|
||||||
val appDataDir = filesDir.parentFile ?: filesDir
|
val appDataDir = filesDir.parentFile ?: filesDir
|
||||||
dataFile = File(appDataDir, "ble_hr_data.json")
|
if (deviceType == "baro") {
|
||||||
logFile = File(appDataDir, "ble_hr_log.jsonl")
|
baroDataFile = File(appDataDir, "ble_baro_data.json")
|
||||||
uptimeFile = File(appDataDir, "strap_uptime.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
|
// fresh user-initiated connect: arm reconnect only after it actually connects
|
||||||
lastAddress = address; stopping = false; connectedOnce = false; reconnectTries = 0
|
lastAddress = address; stopping = false; connectedOnce = false; reconnectTries = 0
|
||||||
connectDevice(address)
|
connectDevice(address)
|
||||||
|
|
@ -176,8 +191,10 @@ class BleHrService : Service() {
|
||||||
|
|
||||||
val services = result.scanRecord?.serviceUuids?.map { it.uuid } ?: emptyList()
|
val services = result.scanRecord?.serviceUuids?.map { it.uuid } ?: emptyList()
|
||||||
val type = when {
|
val type = when {
|
||||||
|
services.contains(ESS_SERVICE) -> "baro"
|
||||||
services.contains(HR_SERVICE_UUID) -> "hr_standard"
|
services.contains(HR_SERVICE_UUID) -> "hr_standard"
|
||||||
services.contains(FITPRO_SERVICE) -> "fitpro"
|
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("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"
|
name.lowercase().let { it.contains("coospo") || it.contains("polar") || it.contains("garmin") || it.contains("hr") } -> "hr_standard"
|
||||||
else -> "unknown"
|
else -> "unknown"
|
||||||
|
|
@ -297,6 +314,7 @@ class BleHrService : Service() {
|
||||||
if (deviceType == "unknown") {
|
if (deviceType == "unknown") {
|
||||||
deviceType = when {
|
deviceType = when {
|
||||||
g.getService(HR_SERVICE_UUID) != null -> "hr_standard"
|
g.getService(HR_SERVICE_UUID) != null -> "hr_standard"
|
||||||
|
g.getService(ESS_SERVICE) != null -> "baro"
|
||||||
g.getService(FITPRO_SERVICE) != null -> "fitpro"
|
g.getService(FITPRO_SERVICE) != null -> "fitpro"
|
||||||
else -> "fitpro" // fallback: try FitPro (Nordic UART is common for cheap bracelets)
|
else -> "fitpro" // fallback: try FitPro (Nordic UART is common for cheap bracelets)
|
||||||
}
|
}
|
||||||
|
|
@ -308,15 +326,18 @@ class BleHrService : Service() {
|
||||||
when (deviceType) {
|
when (deviceType) {
|
||||||
"hr_standard" -> setupStandardHr(g)
|
"hr_standard" -> setupStandardHr(g)
|
||||||
"fitpro" -> setupFitPro(g)
|
"fitpro" -> setupFitPro(g)
|
||||||
|
"baro" -> setupBaro(g)
|
||||||
}
|
}
|
||||||
updateNotification("Пульсометр подключён")
|
updateNotification(if (deviceType == "baro") "Барометр подключён" else "Пульсометр подключён")
|
||||||
startHydrationReminder() // workout is on (strap connected) → arm the drink reminder
|
// 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) {
|
override fun onCharacteristicChanged(g: BluetoothGatt, char: BluetoothGattCharacteristic, value: ByteArray) {
|
||||||
Log.d(TAG, "Data received (${value.size} bytes): ${value.joinToString(" ") { "%02X".format(it) }}")
|
Log.d(TAG, "Data received (${value.size} bytes): ${value.joinToString(" ") { "%02X".format(it) }}")
|
||||||
when (deviceType) {
|
when (deviceType) {
|
||||||
"hr_standard" -> parseStandardHr(value)
|
"hr_standard" -> parseStandardHr(value)
|
||||||
|
"baro" -> parseBaro(char, value)
|
||||||
"fitpro" -> {
|
"fitpro" -> {
|
||||||
// Send ACK for data packets (0xCD) — required by FitPro protocol
|
// Send ACK for data packets (0xCD) — required by FitPro protocol
|
||||||
if (value.isNotEmpty() && value[0] == 0xCD.toByte() && value.size >= 4) {
|
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}") }
|
} 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 val gattQueue = mutableListOf<() -> Unit>()
|
||||||
private var gattBusy = false
|
private var gattBusy = false
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,17 @@ pub struct HrReading {
|
||||||
pub battery: Option<u8>,
|
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
|
// Shared state for BLE operations
|
||||||
#[cfg(not(target_os = "android"))]
|
#[cfg(not(target_os = "android"))]
|
||||||
lazy_static::lazy_static! {
|
lazy_static::lazy_static! {
|
||||||
|
|
@ -49,6 +60,9 @@ struct BleState {
|
||||||
const HR_SERVICE_UUID: &str = "0000180d-0000-1000-8000-00805f9b34fb";
|
const HR_SERVICE_UUID: &str = "0000180d-0000-1000-8000-00805f9b34fb";
|
||||||
const HR_MEASUREMENT_UUID: &str = "00002a37-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
|
// FitPro Nordic UART UUIDs
|
||||||
const FITPRO_SERVICE: &str = "6e400001-b5a3-f393-e0a9-e50e24dcca9d";
|
const FITPRO_SERVICE: &str = "6e400001-b5a3-f393-e0a9-e50e24dcca9d";
|
||||||
const FITPRO_WRITE: &str = "6e400002-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 {
|
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
|
// Check for standard HR service
|
||||||
if services.contains(&uuid(HR_SERVICE_UUID)) {
|
if services.contains(&uuid(HR_SERVICE_UUID)) {
|
||||||
return "hr_standard".to_string();
|
return "hr_standard".to_string();
|
||||||
|
|
@ -384,6 +402,15 @@ mod android {
|
||||||
}
|
}
|
||||||
Ok(None)
|
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 ---
|
// --- Tauri commands ---
|
||||||
|
|
@ -465,6 +492,18 @@ pub fn ble_get_hr(
|
||||||
{ android::read_hr_data(&app) }
|
{ 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
|
// 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
|
// 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
|
// 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)
|
.add_migrations("sqlite:baldav.db", migrations)
|
||||||
.build(),
|
.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!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
70
tools/baro-sensor/arduino/baro_sensor/baro_sensor.ino
Normal file
70
tools/baro-sensor/arduino/baro_sensor/baro_sensor.ino
Normal 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);
|
||||||
|
}
|
||||||
20
tools/baro-sensor/platformio.ini
Normal file
20
tools/baro-sensor/platformio.ini
Normal 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
|
||||||
61
tools/baro-sensor/src/main.cpp
Normal file
61
tools/baro-sensor/src/main.cpp
Normal 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);
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue