baldav/frontend/js/baro.js
4aiman 82f3dc9d4a 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.
2026-10-30 00:38:12 +03:00

116 lines
4.8 KiB
JavaScript

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