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) { `; @@ -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 = ` +
${session.activity_type === "vr" ? "Руки/VR" : "Зарядка"} · ${session.date} ${session.start_time ?? ""}
${GPS.formatDuration(durSec)}Время
@@ -4765,7 +4815,8 @@ function renderWorkoutDetail(session) {
Пульс
- `; + +
`; showModal(html, () => {}, (overlay) => { let ctx = overlay.querySelector("#wk-hr-chart"); if (ctx && samples.length) { @@ -4792,17 +4843,21 @@ function renderWorkoutDetail(session) { function renderWorkoutInline(container) { container.innerHTML = `
-
--
-
уд/мин
-
-
0:00Время
-
0ккал
-
--средний
-
--макс
+
+
--
+
уд/мин
+
Подключите пульсометр
+
+
+
+
0:00Время
+
0ккал
+
--средний
+
--макс
+
+ +
-
Подключите пульсометр
- -
`; let hrEl = container.querySelector("#wk-hr"), timeEl = container.querySelector("#wk-time"); @@ -4846,8 +4901,8 @@ function renderWorkoutInline(container) { let res = await GPS.stopRecording(); if (res && res.sessionId) { showToast(`Зарядка: ${res.kcal} ккал, ${GPS.formatDuration(res.durationSec)}, пульс ${res.avgHr ?? "--"}/${res.maxHr ?? "--"}`); - showSessionDetail(res.sessionId); - } else { renderScreen("gps"); } + } + renderScreen("gps"); // back to the tracks list after stop, like a GPS ride (not into details) }; } @@ -5445,9 +5500,10 @@ async function renderRecordingInline(container) { let median = sorted[Math.floor(sorted.length / 2)] || 0; if (spdEl) spdEl.textContent = median.toFixed(1); - // Elevation - let alt = p.altitude; - if (alt != null && elevEl) elevEl.textContent = Math.round(alt) + " м"; + // Elevation - prefer the live barometer (smoother, tracks real climbs) over noisy GPS altitude + let baroAlt = (window.BARO && BARO.isLive()) ? BARO.altitude() : null; + let alt = (baroAlt != null) ? baroAlt : p.altitude; + if (alt != null && elevEl) elevEl.textContent = Math.round(alt) + (baroAlt != null ? " м·" : " м"); // Pause indicator if (GPS.recording.pauseStart) { @@ -5481,6 +5537,14 @@ async function renderRecordingInline(container) { // Update clock let clockEl = document.querySelector("#rec-clock"); if (clockEl) clockEl.textContent = new Date().toTimeString().slice(0, 5); + // Barometer readout at 1 Hz, independent of GPS fixes (so altitude/temp move smoothly). + if (window.BARO && BARO.isLive()) { + let ba = BARO.altitude(); + if (ba != null && elevEl) elevEl.textContent = Math.round(ba) + " м·"; // trailing dot = from the barometer + let tc = BARO.state ? BARO.state.lastTempC : null; + let tEl = document.querySelector("#rec-temp"); + if (tc != null && tEl) tEl.textContent = tc.toFixed(1) + "°"; // one decimal: visibly live + has the dot + } }, 1000); // Fetch temperature — try multiple APIs (mobile internet blocks foreign services) @@ -7781,18 +7845,34 @@ async function renderSettings(container) {
-
Пульсометр (BLE)
+
Датчики (BLE)
-
${state.profile.ble_device_name || "Не привязан"}
-
${state.profile.ble_device_addr ? state.profile.ble_device_addr : "Нажмите для поиска"}
+
Пульсометр: ${state.profile.ble_device_name || "не привязан"}
+
${state.profile.ble_device_addr ? state.profile.ble_device_addr : "найдите в поиске ниже"}
${state.profile.ble_device_addr ? `` : ""} ${state.profile.ble_device_addr ? `` : ""} -
+
+
+
Барометр высоты: ${state.profile.baro_device_name || "не привязан"}
+
${state.profile.baro_device_addr ? state.profile.baro_device_addr : "найдите в поиске ниже"}
+
+
+ ${state.profile.baro_device_addr ? `` : ""} + ${state.profile.baro_device_addr ? `` : ""} +
+
+
+
+
Поиск устройств
+
Пульсометр и барометр привяжутся каждый в свою строку. Пока работает одно BLE-подключение за раз.
+
+ +