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..ce991ec
--- /dev/null
+++ b/docs/session-logs/2026-10-30_barometer-ble-baldav.md
@@ -0,0 +1,51 @@
+# 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, без ошибок). На устройстве НЕ проверено - нет железа.
+
+## Продолжение того же дня: обвязка, UI, проверка эмулятором
+
+Железа ещё нет, поэтому эмулировали датчик на ПК (`tools/baro-sensor/emulate_baro.py` -
+BlueZ D-Bus GATT-сервер, вещает ESS `0x181A` с давлением/температурой), телефон читал его
+реальным путём. Проверено на устройстве: `BleHR: HR:70` и `BleBaro: Baro:... Pa` одновременно.
+
+Сделано:
+- `BleBaroService` - отдельный сервис/соединение для барометра, работает параллельно с
+ пульсометром (один адаптер держит несколько LE-связей). Баро-подключения маршрутизируются
+ в него из `MainActivity`. Авто-подключение при старте: баро из `baro_device.json`, пульс из
+ `ble_device.json` (оба сами цепляются).
+- Rust: `ble_baro_disconnect`, `ble_save_baro_device`. JS сохраняет baro-устройство при привязке.
+- Настройки: секция "Датчики (BLE)" - пульсометр и барометр раздельно, свои слоты и кнопки,
+ живой показ давления/температуры/высоты.
+- Трекинг: `altitude_baro_m` в точках, предпочитается в статистике высот (null без датчика).
+- Экран записи: "Высота" от барометра (обновление 1 Гц, пометка точкой), температура с датчика
+ (белая, с десятой).
+- Починки по пути: directConnect при таймауте скана; Locale.US в JSON (была запятая от RU-локали);
+ маршрутизация парсинга по характеристике (баро не писал в пульс); навигация после Стоп в VR/ЧСС
+ (на список, а не в детали).
+
+## Осталось (по приходу платы)
+- Активировать модуль: `BARO.start()` при подключении baro-устройства, задать `BARO.getGpsAlt`.
+- Привязать высоту барометра к треку поездки (в точки / поправка высот П.5).
+- UI: показать барометрическую высоту; выбор baro-устройства в scan-UI уже работает (тип проходит).
+- Ограничение: сервис держит одно BLE-соединение, поэтому барометр и пульсометр одновременно
+ пока нельзя - отдельный следующий шаг (две связи / второй сервис).
diff --git a/frontend/css/styles.css b/frontend/css/styles.css
index 276915a..a4e21d9 100644
--- a/frontend/css/styles.css
+++ b/frontend/css/styles.css
@@ -751,7 +751,12 @@ select {
.full-width { width: 100%; }
/* HR-only workout (зарядка) recording screen: big heart rate, timer, kcal, no map. */
-.workout-screen { padding: 24px 16px; display: flex; flex-direction: column; align-items: center; gap: 8px; }
+.workout-screen { display: flex; flex-direction: column; height: 100%; }
+.wk-main { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; padding: 16px; }
+.wk-panel { flex-shrink: 0; display: flex; flex-direction: column; padding: 10px 16px; padding-bottom: calc(10px + var(--safe-area-bottom, 0px)); }
+/* Workout detail (in a modal): fill the scrollable body so the delete button sits at the bottom. */
+.wkd-wrap { display: flex; flex-direction: column; min-height: 100%; }
+.wkd-del { margin-top: auto; flex: none; align-self: stretch; }
.wk-hr-big { font-size: 92px; font-weight: 700; line-height: 1; color: var(--primary); }
.wk-hr-lbl { color: var(--muted); margin-top: -4px; }
.wk-stats { display: flex; gap: 18px; margin: 14px 0; flex-wrap: wrap; justify-content: center; }
@@ -1460,7 +1465,7 @@ body.dark-map .poi-badge { background: #111; border-color: #eee; }
background: var(--surface);
}
.rec-topbar .rec-top { font-size: 27px; font-weight: 700; color: var(--on-surface); }
-.rec-topbar .rec-top-mid { color: var(--muted); }
+.rec-topbar .rec-top-mid { color: var(--on-surface); }
.rec-lbl {
font-size: 10px;
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/frontend/js/db.js b/frontend/js/db.js
index eda3781..2925738 100644
--- a/frontend/js/db.js
+++ b/frontend/js/db.js
@@ -745,6 +745,7 @@ async function addGpsPoint(sessionId, ts, lat, lon, altGps, speedMs, seq, bleDat
["accel_rms", sensorData?.accel_rms], ["accel_max", sensorData?.accel_max],
["gyro_pitch", sensorData?.gyro_pitch], ["gyro_yaw", sensorData?.gyro_yaw],
["bearing_deg", sensorData?.bearing], ["accuracy_m", sensorData?.accuracy],
+ ["altitude_baro_m", sensorData?.altitude_baro_m],
];
for (const [col, val] of extra) {
if (_gpsPointCols.has(col)) { cols.push(col); vals.push(val ?? null); }
@@ -1676,6 +1677,7 @@ async function runMigrations() {
if (!gpsCols.includes("gyro_yaw")) await execute("ALTER TABLE gps_points ADD COLUMN gyro_yaw REAL");
if (!gpsCols.includes("bearing_deg")) await execute("ALTER TABLE gps_points ADD COLUMN bearing_deg REAL");
if (!gpsCols.includes("accuracy_m")) await execute("ALTER TABLE gps_points ADD COLUMN accuracy_m REAL");
+ if (!gpsCols.includes("altitude_baro_m")) await execute("ALTER TABLE gps_points ADD COLUMN altitude_baro_m REAL"); // barometric altitude from the BLE sensor (null without it)
if (!gpsCols.includes("lat_smoothed")) await execute("ALTER TABLE gps_points ADD COLUMN lat_smoothed REAL");
if (!gpsCols.includes("lon_smoothed")) await execute("ALTER TABLE gps_points ADD COLUMN lon_smoothed REAL");
if (!gpsCols.includes("estimated")) await execute("ALTER TABLE gps_points ADD COLUMN estimated INTEGER DEFAULT 0"); // gap-fill (BRouter) points, reversible
diff --git a/frontend/js/gps.js b/frontend/js/gps.js
index 9d9a288..ba79d19 100644
--- a/frontend/js/gps.js
+++ b/frontend/js/gps.js
@@ -311,7 +311,8 @@ GPS.computeStats = function(points, weightKg, opts) {
// e.g. [45, 45, 52, 45, 45] — the 52 is a GPS altitude glitch.
// Prefer terrain (SRTM/open-elevation) altitude when available — GPS altitude is noisy
// and the stuck-filter destroys its real signal. Terrain is clean and accurate for slope.
- const rawAlt = sorted.map(p => p.altitude_srtm_m ?? p.altitude ?? p.altitude_gps_m ?? null);
+ // Prefer the barometer (best for real climbs/descents) when present, then terrain (SRTM), then GPS.
+ const rawAlt = sorted.map(p => p.altitude_baro_m ?? p.altitude_srtm_m ?? p.altitude ?? p.altitude_gps_m ?? null);
const filteredAlt = rawAlt.slice();
for (let i = 1; i < filteredAlt.length - 1; i++) {
if (filteredAlt[i] == null) continue;
@@ -1170,6 +1171,15 @@ GPS.recoverFromFiles = async function(onProgress) {
// live marker — the same fix is never written twice with different timestamps.
GPS._processIncomingPoint = async function(p, fromNative) {
if (!GPS.recording.active) return;
+
+ // Attach barometric altitude to this point when the BLE baro sensor is live and anchored.
+ // Null without a barometer, so elevation stats fall back to GPS/SRTM exactly as before.
+ try {
+ if (window.BARO && BARO.isLive()) {
+ let ba = BARO.altitude();
+ if (ba != null && isFinite(ba)) p.altitude_baro_m = ba;
+ }
+ } catch (e) {}
// DB writes come only from the source of truth: native points on Android, or
// watchPosition on desktop (no native service).
const writeDb = !!fromNative || !GPS.recording.nativeService;
diff --git a/frontend/js/main.js b/frontend/js/main.js
index 7e59ec0..f998d98 100644
--- a/frontend/js/main.js
+++ b/frontend/js/main.js
@@ -1364,6 +1364,7 @@ function showFoodModal() {
amountG = Math.round(rawAmount * selectedUnit.grams_per_unit * 10) / 10;
}
await DB.addFoodEntry(state.date, time, selectedProduct.id, amountG, null, unitId, unitAmount);
+ hydrationOnDrink(); // if it was liquid, quiet the reminder once you've drunk
} else {
let desc = overlay.querySelector("#m-desc").value.trim();
if (!desc) return false;
@@ -1850,6 +1851,7 @@ async function renderProducts(container) {
let note = overlay.querySelector("#m-note").value.trim() || null;
if (isNaN(amount) || amount <= 0) return false;
await DB.addFoodEntry(state.date, time, productId, amount, note);
+ hydrationOnDrink(); // quiet the drink reminder if this was liquid
});
}
@@ -2824,23 +2826,46 @@ let gpsMarker = null;
// At session start, seed the native hydration deficit from the FOOD LOG: how far behind on water
// you are for the time of day (need-so-far minus logged intake). During the session the native
// service adds sweat on top. Intake comes from what you log, not a button.
-async function seedHydrationDeficit() {
+// Sweat (extra water loss) per activity — cycling sweats far more than a walk, so a walk must
+// NOT accrue the ride rate (that made it falsely hit the threshold after ~1 h of walking).
+let HYDR_SWEAT = { bike: 600, walk: 200, run: 450, workout: 250, vr: 250 };
+function hydrSweatFor(type) { return HYDR_SWEAT[type] || 600; }
+
+// Compute the current sedentary shortfall (need-so-far for the time of day minus what you've
+// logged drinking today). Shared by the session seed and the on-drink re-seed.
+async function hydrBaseDeficit() {
+ let target = parseInt(state.profile?.daily_water_ml) || 2000;
+ let now = new Date();
+ let nowMin = now.getHours() * 60 + now.getMinutes();
+ let wakeStart = 7 * 60, wakeEnd = 23 * 60; // assume waking 07:00–23:00
+ let frac = Math.max(0, Math.min(1, (nowMin - wakeStart) / (wakeEnd - wakeStart)));
+ let intake = await DB.getDailyWaterMl(now.toISOString().slice(0, 10));
+ return Math.max(0, Math.min(5000, Math.round(target * frac - intake)));
+}
+
+async function seedHydrationDeficit(activityType) {
if (localStorage.getItem("baldav_hydr_on") === "0") return; // ON by default unless turned off
try {
- let target = parseInt(state.profile?.daily_water_ml) || 2000;
- let now = new Date();
- let nowMin = now.getHours() * 60 + now.getMinutes();
- let wakeStart = 7 * 60, wakeEnd = 23 * 60; // assume waking 07:00–23:00
- let frac = Math.max(0, Math.min(1, (nowMin - wakeStart) / (wakeEnd - wakeStart)));
- let needSoFar = target * frac;
- let intake = await DB.getDailyWaterMl(now.toISOString().slice(0, 10));
- let startDeficit = Math.max(0, Math.min(5000, Math.round(needSoFar - intake)));
+ let startDeficit = await hydrBaseDeficit();
let alertMl = parseInt(localStorage.getItem("baldav_hydr_alert")) || 600;
await window.__TAURI__.core.invoke("set_hydration_config",
- { enabled: true, alertMl, sweatMlPerHour: 600, reAlertMin: 15, startDeficitMl: startDeficit });
+ { enabled: true, alertMl, sweatMlPerHour: hydrSweatFor(activityType), reAlertMin: 15, startDeficitMl: startDeficit, seedSeq: Date.now() });
} catch (e) { console.error("seedHydration", e); }
}
+// After you log a drink DURING a session: recompute the base deficit (now lower) and push it with
+// a fresh seedSeq so the native side drops baseDeficit — the reminder goes quiet once you've drunk.
+async function hydrationOnDrink() {
+ try {
+ if (!(GPS.recording && GPS.recording.active)) return; // only matters during a session
+ if (localStorage.getItem("baldav_hydr_on") === "0") return;
+ let base = await hydrBaseDeficit();
+ let alertMl = parseInt(localStorage.getItem("baldav_hydr_alert")) || 600;
+ await window.__TAURI__.core.invoke("set_hydration_config",
+ { enabled: true, alertMl, sweatMlPerHour: hydrSweatFor(GPS.recording.activityType), reAlertMin: 15, startDeficitMl: base, seedSeq: Date.now() });
+ } catch (e) {}
+}
+
async function renderGpsScreen(container) {
// Check Bluetooth state if BLE device is paired
let bleAddr = await DB.getProfileValue("ble_device_addr");
@@ -2909,6 +2934,7 @@ async function renderGpsScreen(container) {
Выбрано 0
+
`;
@@ -2955,7 +2981,7 @@ async function renderGpsScreen(container) {
heightCm: parseFloat(state.profile?.height_cm) || 178,
} : null;
await GPS.startRecording(type, wkg, hrProfile);
- seedHydrationDeficit();
+ seedHydrationDeficit(type);
renderScreen("recording");
}
};
@@ -2966,11 +2992,13 @@ async function renderGpsScreen(container) {
let selBar = container.querySelector("#gps-sel-bar");
let selCount = container.querySelector("#gps-sel-count");
let selMergeBtn = container.querySelector("#gps-sel-merge");
+ let selDeleteBtn = container.querySelector("#gps-sel-delete");
let refreshSel = () => {
container.querySelectorAll(".gps-session-item").forEach(it => it.classList.toggle("selected", selected.has(it.dataset.sid)));
if (selBar) selBar.hidden = !selMode;
if (selCount) selCount.textContent = "Выбрано " + selected.size;
- if (selMergeBtn) selMergeBtn.disabled = selected.size < 2;
+ if (selMergeBtn) selMergeBtn.disabled = selected.size < 2; // merge needs 2+
+ if (selDeleteBtn) selDeleteBtn.disabled = selected.size < 1; // delete works from 1
};
let exitSel = () => { selMode = false; selected.clear(); refreshSel(); };
let toggleSel = (sid) => {
@@ -3035,6 +3063,27 @@ async function renderGpsScreen(container) {
selMergeBtn.disabled = false; selMergeBtn.textContent = "Объединить";
}
};
+ if (selDeleteBtn) selDeleteBtn.onclick = async () => {
+ if (selected.size < 1) return;
+ let ids = [...selected];
+ let n = ids.length;
+ let word = (n % 10 === 1 && n % 100 !== 11) ? "трек"
+ : (n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 10 || n % 100 >= 20)) ? "трека" : "треков";
+ if (!(await appConfirm(`Удалить ${n} ${word}? Это необратимо.`, "Удалить"))) return;
+ try {
+ selDeleteBtn.disabled = true; selDeleteBtn.textContent = "Удаляю...";
+ let done = 0;
+ for (let id of ids) {
+ try { await DB.deleteGpsSession(id); done++; } catch (e) { console.error("[bulk delete]", id, e); }
+ }
+ showToast(`Удалено: ${done}`);
+ exitSel();
+ renderScreen("gps");
+ } catch (e) {
+ showToast("Ошибка удаления: " + (e.message || e));
+ selDeleteBtn.disabled = false; selDeleteBtn.textContent = "Удалить";
+ }
+ };
// Pagination: "Показать ещё" loads the next page from the DB and appends it.
let sessOffset = sessions.length;
@@ -4752,6 +4801,7 @@ function renderWorkoutDetail(session) {
let max = hrs.length ? Math.max(...hrs) : null;
let kcal = Math.round(session.total_kcal || 0);
let html = `
+