Compare commits
4 commits
1df97d3e73
...
eb430ffb78
| Author | SHA1 | Date | |
|---|---|---|---|
| eb430ffb78 | |||
| 9251c1dd90 | |||
| 82f3dc9d4a | |||
| 1101016ef4 |
18 changed files with 1215 additions and 90 deletions
51
docs/session-logs/2026-10-30_barometer-ble-baldav.md
Normal file
51
docs/session-logs/2026-10-30_barometer-ble-baldav.md
Normal file
|
|
@ -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-соединение, поэтому барометр и пульсометр одновременно
|
||||||
|
пока нельзя - отдельный следующий шаг (две связи / второй сервис).
|
||||||
|
|
@ -751,7 +751,12 @@ select {
|
||||||
.full-width { width: 100%; }
|
.full-width { width: 100%; }
|
||||||
|
|
||||||
/* HR-only workout (зарядка) recording screen: big heart rate, timer, kcal, no map. */
|
/* 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-big { font-size: 92px; font-weight: 700; line-height: 1; color: var(--primary); }
|
||||||
.wk-hr-lbl { color: var(--muted); margin-top: -4px; }
|
.wk-hr-lbl { color: var(--muted); margin-top: -4px; }
|
||||||
.wk-stats { display: flex; gap: 18px; margin: 14px 0; flex-wrap: wrap; justify-content: center; }
|
.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);
|
background: var(--surface);
|
||||||
}
|
}
|
||||||
.rec-topbar .rec-top { font-size: 27px; font-weight: 700; color: var(--on-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 {
|
.rec-lbl {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
})();
|
||||||
|
|
@ -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],
|
["accel_rms", sensorData?.accel_rms], ["accel_max", sensorData?.accel_max],
|
||||||
["gyro_pitch", sensorData?.gyro_pitch], ["gyro_yaw", sensorData?.gyro_yaw],
|
["gyro_pitch", sensorData?.gyro_pitch], ["gyro_yaw", sensorData?.gyro_yaw],
|
||||||
["bearing_deg", sensorData?.bearing], ["accuracy_m", sensorData?.accuracy],
|
["bearing_deg", sensorData?.bearing], ["accuracy_m", sensorData?.accuracy],
|
||||||
|
["altitude_baro_m", sensorData?.altitude_baro_m],
|
||||||
];
|
];
|
||||||
for (const [col, val] of extra) {
|
for (const [col, val] of extra) {
|
||||||
if (_gpsPointCols.has(col)) { cols.push(col); vals.push(val ?? null); }
|
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("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("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("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("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("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
|
if (!gpsCols.includes("estimated")) await execute("ALTER TABLE gps_points ADD COLUMN estimated INTEGER DEFAULT 0"); // gap-fill (BRouter) points, reversible
|
||||||
|
|
|
||||||
|
|
@ -311,7 +311,8 @@ GPS.computeStats = function(points, weightKg, opts) {
|
||||||
// e.g. [45, 45, 52, 45, 45] — the 52 is a GPS altitude glitch.
|
// 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
|
// 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.
|
// 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();
|
const filteredAlt = rawAlt.slice();
|
||||||
for (let i = 1; i < filteredAlt.length - 1; i++) {
|
for (let i = 1; i < filteredAlt.length - 1; i++) {
|
||||||
if (filteredAlt[i] == null) continue;
|
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.
|
// live marker — the same fix is never written twice with different timestamps.
|
||||||
GPS._processIncomingPoint = async function(p, fromNative) {
|
GPS._processIncomingPoint = async function(p, fromNative) {
|
||||||
if (!GPS.recording.active) return;
|
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
|
// DB writes come only from the source of truth: native points on Android, or
|
||||||
// watchPosition on desktop (no native service).
|
// watchPosition on desktop (no native service).
|
||||||
const writeDb = !!fromNative || !GPS.recording.nativeService;
|
const writeDb = !!fromNative || !GPS.recording.nativeService;
|
||||||
|
|
|
||||||
|
|
@ -1364,6 +1364,7 @@ function showFoodModal() {
|
||||||
amountG = Math.round(rawAmount * selectedUnit.grams_per_unit * 10) / 10;
|
amountG = Math.round(rawAmount * selectedUnit.grams_per_unit * 10) / 10;
|
||||||
}
|
}
|
||||||
await DB.addFoodEntry(state.date, time, selectedProduct.id, amountG, null, unitId, unitAmount);
|
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 {
|
} else {
|
||||||
let desc = overlay.querySelector("#m-desc").value.trim();
|
let desc = overlay.querySelector("#m-desc").value.trim();
|
||||||
if (!desc) return false;
|
if (!desc) return false;
|
||||||
|
|
@ -1850,6 +1851,7 @@ async function renderProducts(container) {
|
||||||
let note = overlay.querySelector("#m-note").value.trim() || null;
|
let note = overlay.querySelector("#m-note").value.trim() || null;
|
||||||
if (isNaN(amount) || amount <= 0) return false;
|
if (isNaN(amount) || amount <= 0) return false;
|
||||||
await DB.addFoodEntry(state.date, time, productId, amount, note);
|
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
|
// 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
|
// 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.
|
// 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
|
if (localStorage.getItem("baldav_hydr_on") === "0") return; // ON by default unless turned off
|
||||||
try {
|
try {
|
||||||
let target = parseInt(state.profile?.daily_water_ml) || 2000;
|
let startDeficit = await hydrBaseDeficit();
|
||||||
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 alertMl = parseInt(localStorage.getItem("baldav_hydr_alert")) || 600;
|
let alertMl = parseInt(localStorage.getItem("baldav_hydr_alert")) || 600;
|
||||||
await window.__TAURI__.core.invoke("set_hydration_config",
|
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); }
|
} 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) {
|
async function renderGpsScreen(container) {
|
||||||
// Check Bluetooth state if BLE device is paired
|
// Check Bluetooth state if BLE device is paired
|
||||||
let bleAddr = await DB.getProfileValue("ble_device_addr");
|
let bleAddr = await DB.getProfileValue("ble_device_addr");
|
||||||
|
|
@ -2909,6 +2934,7 @@ async function renderGpsScreen(container) {
|
||||||
<div id="gps-sel-bar" class="gps-sel-bar" hidden>
|
<div id="gps-sel-bar" class="gps-sel-bar" hidden>
|
||||||
<span id="gps-sel-count">Выбрано 0</span>
|
<span id="gps-sel-count">Выбрано 0</span>
|
||||||
<button id="gps-sel-merge" class="btn-primary" disabled>Объединить</button>
|
<button id="gps-sel-merge" class="btn-primary" disabled>Объединить</button>
|
||||||
|
<button id="gps-sel-delete" class="btn-secondary" style="color:#cf6679" disabled>Удалить</button>
|
||||||
<button id="gps-sel-cancel" class="btn-secondary">Отмена</button>
|
<button id="gps-sel-cancel" class="btn-secondary">Отмена</button>
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
@ -2955,7 +2981,7 @@ async function renderGpsScreen(container) {
|
||||||
heightCm: parseFloat(state.profile?.height_cm) || 178,
|
heightCm: parseFloat(state.profile?.height_cm) || 178,
|
||||||
} : null;
|
} : null;
|
||||||
await GPS.startRecording(type, wkg, hrProfile);
|
await GPS.startRecording(type, wkg, hrProfile);
|
||||||
seedHydrationDeficit();
|
seedHydrationDeficit(type);
|
||||||
renderScreen("recording");
|
renderScreen("recording");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -2966,11 +2992,13 @@ async function renderGpsScreen(container) {
|
||||||
let selBar = container.querySelector("#gps-sel-bar");
|
let selBar = container.querySelector("#gps-sel-bar");
|
||||||
let selCount = container.querySelector("#gps-sel-count");
|
let selCount = container.querySelector("#gps-sel-count");
|
||||||
let selMergeBtn = container.querySelector("#gps-sel-merge");
|
let selMergeBtn = container.querySelector("#gps-sel-merge");
|
||||||
|
let selDeleteBtn = container.querySelector("#gps-sel-delete");
|
||||||
let refreshSel = () => {
|
let refreshSel = () => {
|
||||||
container.querySelectorAll(".gps-session-item").forEach(it => it.classList.toggle("selected", selected.has(it.dataset.sid)));
|
container.querySelectorAll(".gps-session-item").forEach(it => it.classList.toggle("selected", selected.has(it.dataset.sid)));
|
||||||
if (selBar) selBar.hidden = !selMode;
|
if (selBar) selBar.hidden = !selMode;
|
||||||
if (selCount) selCount.textContent = "Выбрано " + selected.size;
|
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 exitSel = () => { selMode = false; selected.clear(); refreshSel(); };
|
||||||
let toggleSel = (sid) => {
|
let toggleSel = (sid) => {
|
||||||
|
|
@ -3035,6 +3063,27 @@ async function renderGpsScreen(container) {
|
||||||
selMergeBtn.disabled = false; selMergeBtn.textContent = "Объединить";
|
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.
|
// Pagination: "Показать ещё" loads the next page from the DB and appends it.
|
||||||
let sessOffset = sessions.length;
|
let sessOffset = sessions.length;
|
||||||
|
|
@ -4752,6 +4801,7 @@ function renderWorkoutDetail(session) {
|
||||||
let max = hrs.length ? Math.max(...hrs) : null;
|
let max = hrs.length ? Math.max(...hrs) : null;
|
||||||
let kcal = Math.round(session.total_kcal || 0);
|
let kcal = Math.round(session.total_kcal || 0);
|
||||||
let html = `
|
let html = `
|
||||||
|
<div class="wkd-wrap">
|
||||||
<div class="sd-section"><div class="sd-title">${session.activity_type === "vr" ? "Руки/VR" : "Зарядка"} · ${session.date} ${session.start_time ?? ""}</div>
|
<div class="sd-section"><div class="sd-title">${session.activity_type === "vr" ? "Руки/VR" : "Зарядка"} · ${session.date} ${session.start_time ?? ""}</div>
|
||||||
<div class="sd-row">
|
<div class="sd-row">
|
||||||
<div class="sd-stat"><span class="sd-val">${GPS.formatDuration(durSec)}</span><span class="sd-lbl">Время</span></div>
|
<div class="sd-stat"><span class="sd-val">${GPS.formatDuration(durSec)}</span><span class="sd-lbl">Время</span></div>
|
||||||
|
|
@ -4765,7 +4815,8 @@ function renderWorkoutDetail(session) {
|
||||||
<div class="sd-section"><div class="sd-title">Пульс</div>
|
<div class="sd-section"><div class="sd-title">Пульс</div>
|
||||||
<div class="chart-wrap"><canvas id="wk-hr-chart" height="150"></canvas></div>
|
<div class="chart-wrap"><canvas id="wk-hr-chart" height="150"></canvas></div>
|
||||||
</div>
|
</div>
|
||||||
<button id="wk-del" class="btn-secondary full-width" style="margin-top:8px;color:#cf6679">Удалить зарядку</button>`;
|
<button id="wk-del" class="btn-secondary full-width wkd-del" style="color:#cf6679">Удалить зарядку</button>
|
||||||
|
</div>`;
|
||||||
showModal(html, () => {}, (overlay) => {
|
showModal(html, () => {}, (overlay) => {
|
||||||
let ctx = overlay.querySelector("#wk-hr-chart");
|
let ctx = overlay.querySelector("#wk-hr-chart");
|
||||||
if (ctx && samples.length) {
|
if (ctx && samples.length) {
|
||||||
|
|
@ -4792,17 +4843,21 @@ function renderWorkoutDetail(session) {
|
||||||
function renderWorkoutInline(container) {
|
function renderWorkoutInline(container) {
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div class="workout-screen">
|
<div class="workout-screen">
|
||||||
<div class="wk-hr-big" id="wk-hr">--</div>
|
<div class="wk-main">
|
||||||
<div class="wk-hr-lbl">уд/мин</div>
|
<div class="wk-hr-big" id="wk-hr">--</div>
|
||||||
<div class="wk-stats">
|
<div class="wk-hr-lbl">уд/мин</div>
|
||||||
<div class="wk-stat"><span id="wk-time" class="wk-val">0:00</span><span class="wk-lbl">Время</span></div>
|
<div class="wk-status" id="wk-status">Подключите пульсометр</div>
|
||||||
<div class="wk-stat"><span id="wk-kcal" class="wk-val">0</span><span class="wk-lbl">ккал</span></div>
|
</div>
|
||||||
<div class="wk-stat"><span id="wk-avg" class="wk-val">--</span><span class="wk-lbl">средний</span></div>
|
<div class="wk-panel">
|
||||||
<div class="wk-stat"><span id="wk-max" class="wk-val">--</span><span class="wk-lbl">макс</span></div>
|
<div class="wk-stats">
|
||||||
|
<div class="wk-stat"><span id="wk-time" class="wk-val">0:00</span><span class="wk-lbl">Время</span></div>
|
||||||
|
<div class="wk-stat"><span id="wk-kcal" class="wk-val">0</span><span class="wk-lbl">ккал</span></div>
|
||||||
|
<div class="wk-stat"><span id="wk-avg" class="wk-val">--</span><span class="wk-lbl">средний</span></div>
|
||||||
|
<div class="wk-stat"><span id="wk-max" class="wk-val">--</span><span class="wk-lbl">макс</span></div>
|
||||||
|
</div>
|
||||||
|
<button id="wk-connect" class="btn-secondary full-width">Подключить пульсометр</button>
|
||||||
|
<button id="wk-stop" class="btn-primary gps-rec recording full-width">Стоп</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="wk-status" id="wk-status">Подключите пульсометр</div>
|
|
||||||
<button id="wk-connect" class="btn-secondary full-width">Подключить пульсометр</button>
|
|
||||||
<button id="wk-stop" class="btn-primary gps-rec recording full-width">Стоп</button>
|
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
let hrEl = container.querySelector("#wk-hr"), timeEl = container.querySelector("#wk-time");
|
let hrEl = container.querySelector("#wk-hr"), timeEl = container.querySelector("#wk-time");
|
||||||
|
|
@ -4846,8 +4901,8 @@ function renderWorkoutInline(container) {
|
||||||
let res = await GPS.stopRecording();
|
let res = await GPS.stopRecording();
|
||||||
if (res && res.sessionId) {
|
if (res && res.sessionId) {
|
||||||
showToast(`Зарядка: ${res.kcal} ккал, ${GPS.formatDuration(res.durationSec)}, пульс ${res.avgHr ?? "--"}/${res.maxHr ?? "--"}`);
|
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;
|
let median = sorted[Math.floor(sorted.length / 2)] || 0;
|
||||||
if (spdEl) spdEl.textContent = median.toFixed(1);
|
if (spdEl) spdEl.textContent = median.toFixed(1);
|
||||||
|
|
||||||
// Elevation
|
// Elevation - prefer the live barometer (smoother, tracks real climbs) over noisy GPS altitude
|
||||||
let alt = p.altitude;
|
let baroAlt = (window.BARO && BARO.isLive()) ? BARO.altitude() : null;
|
||||||
if (alt != null && elevEl) elevEl.textContent = Math.round(alt) + " м";
|
let alt = (baroAlt != null) ? baroAlt : p.altitude;
|
||||||
|
if (alt != null && elevEl) elevEl.textContent = Math.round(alt) + (baroAlt != null ? " м·" : " м");
|
||||||
|
|
||||||
// Pause indicator
|
// Pause indicator
|
||||||
if (GPS.recording.pauseStart) {
|
if (GPS.recording.pauseStart) {
|
||||||
|
|
@ -5481,6 +5537,14 @@ async function renderRecordingInline(container) {
|
||||||
// Update clock
|
// Update clock
|
||||||
let clockEl = document.querySelector("#rec-clock");
|
let clockEl = document.querySelector("#rec-clock");
|
||||||
if (clockEl) clockEl.textContent = new Date().toTimeString().slice(0, 5);
|
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);
|
}, 1000);
|
||||||
|
|
||||||
// Fetch temperature — try multiple APIs (mobile internet blocks foreign services)
|
// Fetch temperature — try multiple APIs (mobile internet blocks foreign services)
|
||||||
|
|
@ -7781,18 +7845,34 @@ async function renderSettings(container) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="settings-section">
|
<div class="settings-section">
|
||||||
<div class="settings-section-title">Пульсометр (BLE)</div>
|
<div class="settings-section-title">Датчики (BLE)</div>
|
||||||
<div class="settings-item" id="s-ble-device">
|
<div class="settings-item" id="s-ble-device">
|
||||||
<div class="settings-item-left">
|
<div class="settings-item-left">
|
||||||
<div class="settings-item-title" id="s-ble-name">${state.profile.ble_device_name || "Не привязан"}</div>
|
<div class="settings-item-title">Пульсометр: <span id="s-ble-name">${state.profile.ble_device_name || "не привязан"}</span></div>
|
||||||
<div class="settings-item-desc" id="s-ble-status">${state.profile.ble_device_addr ? state.profile.ble_device_addr : "Нажмите для поиска"}</div>
|
<div class="settings-item-desc" id="s-ble-status">${state.profile.ble_device_addr ? state.profile.ble_device_addr : "найдите в поиске ниже"}</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="display:flex;gap:6px">
|
<div style="display:flex;gap:6px">
|
||||||
${state.profile.ble_device_addr ? `<button id="s-ble-unbind" style="padding:6px 12px;font-size:13px;background:#cf6679;color:#fff;border:none;border-radius:4px">Отвязать</button>` : ""}
|
${state.profile.ble_device_addr ? `<button id="s-ble-unbind" style="padding:6px 12px;font-size:13px;background:#cf6679;color:#fff;border:none;border-radius:4px">Отвязать</button>` : ""}
|
||||||
${state.profile.ble_device_addr ? `<button class="btn-primary" id="s-ble-connect" style="padding:6px 12px;font-size:13px">Подключить</button>` : ""}
|
${state.profile.ble_device_addr ? `<button class="btn-primary" id="s-ble-connect" style="padding:6px 12px;font-size:13px">Подключить</button>` : ""}
|
||||||
<button class="btn-primary" id="s-ble-scan" style="padding:6px 12px;font-size:13px">Поиск</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="settings-item" id="s-baro-device">
|
||||||
|
<div class="settings-item-left">
|
||||||
|
<div class="settings-item-title">Барометр высоты: <span id="s-baro-name">${state.profile.baro_device_name || "не привязан"}</span></div>
|
||||||
|
<div class="settings-item-desc" id="s-baro-status">${state.profile.baro_device_addr ? state.profile.baro_device_addr : "найдите в поиске ниже"}</div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:6px">
|
||||||
|
${state.profile.baro_device_addr ? `<button id="s-baro-unbind" style="padding:6px 12px;font-size:13px;background:#cf6679;color:#fff;border:none;border-radius:4px">Отвязать</button>` : ""}
|
||||||
|
${state.profile.baro_device_addr ? `<button class="btn-primary" id="s-baro-connect" style="padding:6px 12px;font-size:13px">Подключить</button>` : ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="settings-item">
|
||||||
|
<div class="settings-item-left">
|
||||||
|
<div class="settings-item-title">Поиск устройств</div>
|
||||||
|
<div class="settings-item-desc">Пульсометр и барометр привяжутся каждый в свою строку. Пока работает одно BLE-подключение за раз.</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn-primary" id="s-ble-scan" style="padding:6px 12px;font-size:13px">Поиск</button>
|
||||||
|
</div>
|
||||||
<div id="s-ble-results" style="padding:0 14px"></div>
|
<div id="s-ble-results" style="padding:0 14px"></div>
|
||||||
<label class="settings-item" style="cursor:pointer">
|
<label class="settings-item" style="cursor:pointer">
|
||||||
<div class="settings-item-left"><div class="settings-item-title">Не спрашивать про Bluetooth</div>
|
<div class="settings-item-left"><div class="settings-item-title">Не спрашивать про Bluetooth</div>
|
||||||
|
|
@ -8247,6 +8327,69 @@ async function renderSettings(container) {
|
||||||
bleConnectBtn.disabled = false; bleConnectBtn.textContent = "Подключить";
|
bleConnectBtn.disabled = false; bleConnectBtn.textContent = "Подключить";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// --- Barometer (altitude sensor): its own bind/connect, separate from the heart-rate strap ---
|
||||||
|
let baroReadoutOn = false;
|
||||||
|
function startBaroReadout() {
|
||||||
|
if (baroReadoutOn) return;
|
||||||
|
baroReadoutOn = true;
|
||||||
|
let anchorPa = null;
|
||||||
|
let loop = () => {
|
||||||
|
let st = container.querySelector("#s-baro-status");
|
||||||
|
if (!st) { baroReadoutOn = false; return; } // left the settings screen
|
||||||
|
let s = window.BARO ? BARO.state : null;
|
||||||
|
let pa = s ? s.lastPa : null, tc = s ? s.lastTempC : null;
|
||||||
|
if (pa) {
|
||||||
|
if (anchorPa == null) anchorPa = pa;
|
||||||
|
let rel = BARO.deltaH(anchorPa, pa, tc);
|
||||||
|
st.textContent = `${(pa / 100).toFixed(1)} гПа${tc != null ? " · " + tc.toFixed(1) + " C" : ""} · высота ${rel >= 0 ? "+" : ""}${rel.toFixed(1)} м (отн. подключения)`;
|
||||||
|
} else {
|
||||||
|
st.textContent = "подключение, ждём данные...";
|
||||||
|
}
|
||||||
|
if (baroReadoutOn) setTimeout(loop, 2000);
|
||||||
|
};
|
||||||
|
loop();
|
||||||
|
}
|
||||||
|
|
||||||
|
let baroUnbindBtn = container.querySelector("#s-baro-unbind");
|
||||||
|
if (baroUnbindBtn) baroUnbindBtn.onclick = async () => {
|
||||||
|
await DB.setProfileValue("baro_device_addr", "");
|
||||||
|
await DB.setProfileValue("baro_device_name", "");
|
||||||
|
state.profile.baro_device_addr = "";
|
||||||
|
state.profile.baro_device_name = "";
|
||||||
|
try { if (window.BARO) { BARO.stop(); BARO.reset(); } } catch {}
|
||||||
|
baroReadoutOn = false;
|
||||||
|
try { await window.__TAURI__.core.invoke("ble_save_baro_device", { address: "" }); } catch {}
|
||||||
|
try { await window.__TAURI__.core.invoke("ble_baro_disconnect"); } catch {}
|
||||||
|
let nm = container.querySelector("#s-baro-name"); if (nm) nm.textContent = "не привязан";
|
||||||
|
let st = container.querySelector("#s-baro-status"); if (st) st.textContent = "найдите в поиске ниже";
|
||||||
|
baroUnbindBtn.remove();
|
||||||
|
};
|
||||||
|
|
||||||
|
let baroConnectBtn = container.querySelector("#s-baro-connect");
|
||||||
|
if (baroConnectBtn) baroConnectBtn.onclick = async () => {
|
||||||
|
let addr = state.profile.baro_device_addr;
|
||||||
|
if (!addr) return;
|
||||||
|
let bt = "on"; try { bt = await window.__TAURI__.core.invoke("ble_check_bluetooth"); } catch {}
|
||||||
|
if (bt === "off") { try { await window.__TAURI__.core.invoke("ble_request_enable"); } catch {} showToast("Включите Bluetooth, затем «Подключить»"); return; }
|
||||||
|
if (bt === "no_adapter") { showToast("Bluetooth недоступен"); return; }
|
||||||
|
baroConnectBtn.disabled = true; baroConnectBtn.textContent = "...";
|
||||||
|
let st = container.querySelector("#s-baro-status"); if (st) st.textContent = "Подключение...";
|
||||||
|
try {
|
||||||
|
await window.__TAURI__.core.invoke("ble_connect", { address: addr, deviceType: "baro" });
|
||||||
|
try { await window.__TAURI__.core.invoke("ble_save_baro_device", { address: addr }); } catch {}
|
||||||
|
try { if (window.BARO) { BARO.reset(); BARO.start(); } } catch {}
|
||||||
|
startBaroReadout();
|
||||||
|
showToast("Барометр: подключение...");
|
||||||
|
} catch (e) {
|
||||||
|
if (st) st.textContent = "ошибка подключения";
|
||||||
|
showToast("Ошибка: " + (e.message || e));
|
||||||
|
}
|
||||||
|
baroConnectBtn.disabled = false; baroConnectBtn.textContent = "Подключить";
|
||||||
|
};
|
||||||
|
|
||||||
|
// already-bound barometer that is still polling: resume its live readout on opening settings
|
||||||
|
if (state.profile.baro_device_addr && window.BARO && BARO.state && BARO.state.polling) startBaroReadout();
|
||||||
|
|
||||||
let dontAskBt = container.querySelector("#s-dontask-bt");
|
let dontAskBt = container.querySelector("#s-dontask-bt");
|
||||||
if (dontAskBt) dontAskBt.onchange = () => { try { localStorage.setItem("baldav_dontask_bt", dontAskBt.checked ? "1" : "0"); } catch {} };
|
if (dontAskBt) dontAskBt.onchange = () => { try { localStorage.setItem("baldav_dontask_bt", dontAskBt.checked ? "1" : "0"); } catch {} };
|
||||||
let dontAskGps = container.querySelector("#s-dontask-gps");
|
let dontAskGps = container.querySelector("#s-dontask-gps");
|
||||||
|
|
@ -8312,17 +8455,27 @@ async function renderSettings(container) {
|
||||||
item.querySelector(".settings-item-desc").textContent = "Подключение...";
|
item.querySelector(".settings-item-desc").textContent = "Подключение...";
|
||||||
try {
|
try {
|
||||||
await window.__TAURI__.core.invoke("ble_connect", { address: addr, deviceType: type });
|
await window.__TAURI__.core.invoke("ble_connect", { address: addr, deviceType: type });
|
||||||
await DB.setProfileValue("ble_device_addr", addr);
|
if (type === "baro") {
|
||||||
await DB.setProfileValue("ble_device_type", type);
|
// barometer -> its own slot, so it doesn't overwrite the heart-rate strap
|
||||||
await DB.setProfileValue("ble_device_name", name);
|
await DB.setProfileValue("baro_device_addr", addr);
|
||||||
// Write file for Kotlin to read at GPS start
|
await DB.setProfileValue("baro_device_name", name);
|
||||||
try { await window.__TAURI__.core.invoke("ble_save_device", { address: addr, deviceType: type }); } catch {}
|
state.profile.baro_device_addr = addr;
|
||||||
state.profile.ble_device_addr = addr;
|
state.profile.baro_device_name = name;
|
||||||
state.profile.ble_device_type = type;
|
// persist for auto-connect at start (separate from the HR device file)
|
||||||
state.profile.ble_device_name = name;
|
try { await window.__TAURI__.core.invoke("ble_save_baro_device", { address: addr }); } catch {}
|
||||||
container.querySelector("#s-ble-name").textContent = name;
|
try { if (window.BARO) { BARO.reset(); BARO.start(); } } catch {}
|
||||||
container.querySelector("#s-ble-status").textContent = addr + " (подключён)";
|
} else {
|
||||||
bleResults.innerHTML = "";
|
await DB.setProfileValue("ble_device_addr", addr);
|
||||||
|
await DB.setProfileValue("ble_device_type", type);
|
||||||
|
await DB.setProfileValue("ble_device_name", name);
|
||||||
|
// Write file for Kotlin to read at GPS start (HR auto-connect)
|
||||||
|
try { await window.__TAURI__.core.invoke("ble_save_device", { address: addr, deviceType: type }); } catch {}
|
||||||
|
state.profile.ble_device_addr = addr;
|
||||||
|
state.profile.ble_device_type = type;
|
||||||
|
state.profile.ble_device_name = name;
|
||||||
|
}
|
||||||
|
// re-render settings so the Connect/Unbind buttons appear and the baro readout resumes
|
||||||
|
await renderSettings(container);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
item.querySelector(".settings-item-desc").textContent = "Ошибка: " + e;
|
item.querySelector(".settings-item-desc").textContent = "Ошибка: " + e;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,11 @@
|
||||||
android:foregroundServiceType="connectedDevice"
|
android:foregroundServiceType="connectedDevice"
|
||||||
android:exported="false" />
|
android:exported="false" />
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name=".BleBaroService"
|
||||||
|
android:foregroundServiceType="connectedDevice"
|
||||||
|
android:exported="false" />
|
||||||
|
|
||||||
<provider
|
<provider
|
||||||
android:name="androidx.core.content.FileProvider"
|
android:name="androidx.core.content.FileProvider"
|
||||||
android:authorities="${applicationId}.fileprovider"
|
android:authorities="${applicationId}.fileprovider"
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,257 @@
|
||||||
|
package dev.baldav.app
|
||||||
|
|
||||||
|
import android.app.Notification
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.app.Service
|
||||||
|
import android.bluetooth.*
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.IntentFilter
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.IBinder
|
||||||
|
import android.os.Looper
|
||||||
|
import android.util.Log
|
||||||
|
import java.io.File
|
||||||
|
import java.util.Locale
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
// Dedicated BLE connection for the DIY barometer "BaroBalDav" (ESP32-C3 + BMP280). Kept separate
|
||||||
|
// from BleHrService so the heart-rate strap and the barometer each hold their OWN GATT link and work
|
||||||
|
// at the same time (one adapter handles several LE connections). Reads the standard Environmental
|
||||||
|
// Sensing service and writes the latest {pressure_pa, temp_c, ts} to ble_baro_data.json for Rust/JS.
|
||||||
|
class BleBaroService : Service() {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val TAG = "BleBaro"
|
||||||
|
const val CHANNEL_ID = "baldav_ble_baro"
|
||||||
|
const val NOTIFICATION_ID = 3
|
||||||
|
const val ACTION_STOP = "dev.baldav.app.STOP_BARO"
|
||||||
|
const val EXTRA_ADDRESS = "address"
|
||||||
|
|
||||||
|
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
|
||||||
|
val CCCD_UUID: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
|
||||||
|
|
||||||
|
var isRunning = false
|
||||||
|
private set
|
||||||
|
}
|
||||||
|
|
||||||
|
private var gatt: BluetoothGatt? = null
|
||||||
|
private var dataFile: File? = null
|
||||||
|
private var logFile: File? = null
|
||||||
|
private var lastAddress: String? = null
|
||||||
|
private var connectedOnce = false
|
||||||
|
private var stopping = false
|
||||||
|
private var reconnectTries = 0
|
||||||
|
private val maxReconnect = 3
|
||||||
|
private var lastTempC: Double = Double.NaN
|
||||||
|
private val handler = Handler(Looper.getMainLooper())
|
||||||
|
|
||||||
|
private val btStateReceiver = object : BroadcastReceiver() {
|
||||||
|
override fun onReceive(ctx: Context?, intent: Intent?) {
|
||||||
|
if (intent?.action != BluetoothAdapter.ACTION_STATE_CHANGED) return
|
||||||
|
val state = intent.getIntExtra(BluetoothAdapter.EXTRA_STATE, BluetoothAdapter.ERROR)
|
||||||
|
if (state == BluetoothAdapter.STATE_TURNING_OFF || state == BluetoothAdapter.STATE_OFF) {
|
||||||
|
stopping = true
|
||||||
|
handler.removeCallbacksAndMessages(null)
|
||||||
|
disconnect()
|
||||||
|
stopSelf()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun adapter(): BluetoothAdapter? =
|
||||||
|
(getSystemService(BLUETOOTH_SERVICE) as? android.bluetooth.BluetoothManager)?.adapter
|
||||||
|
|
||||||
|
override fun onBind(intent: Intent?): IBinder? = null
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
createNotificationChannel()
|
||||||
|
try { registerReceiver(btStateReceiver, IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED)) }
|
||||||
|
catch (e: Exception) { Log.e(TAG, "register BT receiver: ${e.message}") }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
|
if (intent?.action == ACTION_STOP) {
|
||||||
|
stopping = true
|
||||||
|
disconnect()
|
||||||
|
stopSelf()
|
||||||
|
return START_NOT_STICKY
|
||||||
|
}
|
||||||
|
val address = intent?.getStringExtra(EXTRA_ADDRESS)
|
||||||
|
if (address != null) {
|
||||||
|
startForeground(NOTIFICATION_ID, buildNotification("Барометр: подключение..."))
|
||||||
|
isRunning = true
|
||||||
|
val appDataDir = filesDir.parentFile ?: filesDir
|
||||||
|
dataFile = File(appDataDir, "ble_baro_data.json")
|
||||||
|
logFile = File(appDataDir, "ble_baro_log.jsonl")
|
||||||
|
lastAddress = address; stopping = false; connectedOnce = false; reconnectTries = 0
|
||||||
|
connectDevice(address)
|
||||||
|
}
|
||||||
|
return START_NOT_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quick scan to warm the BLE cache (avoids status=133), then connect. On scan timeout fall back
|
||||||
|
// to a direct connect by address - a stationary sensor is reachable even if a 5 s scan misses it.
|
||||||
|
private fun connectDevice(address: String) {
|
||||||
|
val adapter = adapter()
|
||||||
|
if (adapter == null || !adapter.isEnabled) { Log.e(TAG, "Bluetooth not available"); stopSelf(); return }
|
||||||
|
val scanner = adapter.bluetoothLeScanner
|
||||||
|
if (scanner == null) { directConnect(adapter, address); return }
|
||||||
|
val scanCallback = object : android.bluetooth.le.ScanCallback() {
|
||||||
|
override fun onScanResult(callbackType: Int, result: android.bluetooth.le.ScanResult) {
|
||||||
|
if (result.device.address == address) {
|
||||||
|
try { scanner.stopScan(this) } catch (e: Exception) {}
|
||||||
|
try { gatt = result.device.connectGatt(this@BleBaroService, false, gattCallback, BluetoothDevice.TRANSPORT_LE) }
|
||||||
|
catch (e: Exception) { Log.e(TAG, "connectGatt: ${e.message}") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
override fun onScanFailed(errorCode: Int) { directConnect(adapter, address) }
|
||||||
|
}
|
||||||
|
try { scanner.startScan(scanCallback) } catch (e: Exception) { directConnect(adapter, address); return }
|
||||||
|
handler.postDelayed({
|
||||||
|
try { scanner.stopScan(scanCallback) } catch (e: Exception) {}
|
||||||
|
if (gatt == null) { Log.d(TAG, "scan timeout - direct connect"); directConnect(adapter, address) }
|
||||||
|
}, 5000)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun directConnect(adapter: BluetoothAdapter, address: String) {
|
||||||
|
if (!adapter.isEnabled) { stopSelf(); return }
|
||||||
|
try {
|
||||||
|
gatt = adapter.getRemoteDevice(address).connectGatt(this, false, gattCallback, BluetoothDevice.TRANSPORT_LE)
|
||||||
|
} catch (e: Exception) { Log.e(TAG, "directConnect: ${e.message}"); stopSelf() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private val gattCallback = object : BluetoothGattCallback() {
|
||||||
|
override fun onConnectionStateChange(g: BluetoothGatt, status: Int, newState: Int) {
|
||||||
|
if (newState == BluetoothProfile.STATE_CONNECTED) {
|
||||||
|
updateNotification("Барометр: поиск сервисов...")
|
||||||
|
try { g.discoverServices() } catch (e: Exception) { Log.e(TAG, "discoverServices: ${e.message}") }
|
||||||
|
} else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
|
||||||
|
isRunning = false
|
||||||
|
try { g.close() } catch (e: Exception) {}
|
||||||
|
gatt = null
|
||||||
|
if (!stopping && connectedOnce && reconnectTries < maxReconnect && adapter()?.isEnabled == true && lastAddress != null) {
|
||||||
|
reconnectTries++
|
||||||
|
updateNotification("Барометр: переподключение...")
|
||||||
|
handler.postDelayed({ if (!stopping) connectDevice(lastAddress!!) }, 8000)
|
||||||
|
} else stopSelf()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onServicesDiscovered(g: BluetoothGatt, status: Int) {
|
||||||
|
if (status != BluetoothGatt.GATT_SUCCESS) { Log.e(TAG, "service discovery failed: $status"); return }
|
||||||
|
connectedOnce = true; reconnectTries = 0; isRunning = true
|
||||||
|
setupBaro(g)
|
||||||
|
updateNotification("Барометр подключён")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCharacteristicChanged(g: BluetoothGatt, char: BluetoothGattCharacteristic, value: ByteArray) {
|
||||||
|
parseBaro(char, value)
|
||||||
|
}
|
||||||
|
@Deprecated("Deprecated in API 33")
|
||||||
|
override fun onCharacteristicChanged(g: BluetoothGatt, char: BluetoothGattCharacteristic) {
|
||||||
|
parseBaro(char, char.value ?: return)
|
||||||
|
}
|
||||||
|
override fun onDescriptorWrite(g: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int) {
|
||||||
|
onGattOperationComplete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val gattQueue = mutableListOf<() -> Unit>()
|
||||||
|
private var gattBusy = false
|
||||||
|
private fun enqueueGatt(op: () -> Unit) { gattQueue.add(op); processGattQueue() }
|
||||||
|
private fun processGattQueue() {
|
||||||
|
if (gattBusy || gattQueue.isEmpty()) return
|
||||||
|
gattBusy = true
|
||||||
|
gattQueue.removeAt(0).invoke()
|
||||||
|
}
|
||||||
|
private fun onGattOperationComplete() {
|
||||||
|
gattBusy = false
|
||||||
|
handler.postDelayed({ processGattQueue() }, 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
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, "subscribed pressure${if (tc != null) " + temp" else ""}")
|
||||||
|
}
|
||||||
|
|
||||||
|
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() }
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
val ts = System.currentTimeMillis() / 1000
|
||||||
|
val tj = if (!lastTempC.isNaN()) ",\"temp_c\":${String.format(Locale.US, "%.2f", lastTempC)}" else ""
|
||||||
|
val json = "{\"pressure_pa\":${String.format(Locale.US, "%.1f", pa)}$tj,\"ts\":$ts}"
|
||||||
|
try { dataFile?.writeText(json) } catch (e: Exception) { Log.e(TAG, "write: ${e.message}") }
|
||||||
|
try { logFile?.appendText(json + "\n") } catch (e: Exception) {}
|
||||||
|
Log.d(TAG, "Baro: $pa Pa T=$lastTempC")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun disconnect() {
|
||||||
|
isRunning = false
|
||||||
|
handler.removeCallbacksAndMessages(null)
|
||||||
|
try { gatt?.disconnect() } catch (e: Exception) {}
|
||||||
|
try { gatt?.close() } catch (e: Exception) {}
|
||||||
|
gatt = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createNotificationChannel() {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
val channel = NotificationChannel(CHANNEL_ID, "BLE барометр", NotificationManager.IMPORTANCE_LOW)
|
||||||
|
getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private fun buildNotification(text: String): Notification {
|
||||||
|
val intent = packageManager.getLaunchIntentForPackage(packageName)
|
||||||
|
val pi = PendingIntent.getActivity(this, 0, intent, PendingIntent.FLAG_IMMUTABLE)
|
||||||
|
return Notification.Builder(this, CHANNEL_ID)
|
||||||
|
.setContentTitle("BalDav барометр")
|
||||||
|
.setContentText(text)
|
||||||
|
.setSmallIcon(android.R.drawable.ic_menu_compass)
|
||||||
|
.setContentIntent(pi)
|
||||||
|
.setOngoing(true)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
private fun updateNotification(text: String) {
|
||||||
|
getSystemService(NotificationManager::class.java).notify(NOTIFICATION_ID, buildNotification(text))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
stopping = true
|
||||||
|
try { unregisterReceiver(btStateReceiver) } catch (e: Exception) {}
|
||||||
|
disconnect()
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -19,6 +19,7 @@ import android.os.Looper
|
||||||
import android.os.ParcelUuid
|
import android.os.ParcelUuid
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
import java.util.Locale
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import org.json.JSONArray
|
import org.json.JSONArray
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
|
|
@ -42,6 +43,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 +84,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 +146,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 +192,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"
|
||||||
|
|
@ -243,12 +261,14 @@ class BleHrService : Service() {
|
||||||
handler.postDelayed({
|
handler.postDelayed({
|
||||||
try { scanner.stopScan(scanCallback) } catch (e: Exception) { Log.e(TAG, "stopScan: ${e.message}") }
|
try { scanner.stopScan(scanCallback) } catch (e: Exception) { Log.e(TAG, "stopScan: ${e.message}") }
|
||||||
if (gatt == null) {
|
if (gatt == null) {
|
||||||
// Not seen in the scan → the band isn't nearby/on. Do NOT hold a GATT connect to
|
// Not re-found in the pre-connect scan within 5 s. A present-but-not-rescanned device
|
||||||
// an absent device (that keeps the radio busy and drains the battery). Give up;
|
// (a stationary sensor, or a BlueZ peripheral that the scanner just missed) still
|
||||||
// the user re-taps "Подключить" once the band is on.
|
// connects by address, so fall back to a direct connect instead of giving up. The
|
||||||
Log.d(TAG, "Scan timeout, device $address not found - stopping")
|
// address type is already cached from the earlier scan, so connectGatt uses it.
|
||||||
writeData("""{"status":"not_found","ts":${System.currentTimeMillis()/1000}}""")
|
Log.d(TAG, "Scan timeout, device $address not re-found - direct connect fallback")
|
||||||
stopSelf()
|
val a = adapter()
|
||||||
|
if (a != null && a.isEnabled) directConnect(a, address)
|
||||||
|
else { writeData("""{"status":"not_found","ts":${System.currentTimeMillis()/1000}}"""); stopSelf() }
|
||||||
}
|
}
|
||||||
}, 5000)
|
}, 5000)
|
||||||
}
|
}
|
||||||
|
|
@ -297,6 +317,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 +329,25 @@ 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) }}")
|
||||||
|
// Route by WHAT the characteristic is, not the current deviceType: if an HR auto-connect
|
||||||
|
// races in and flips deviceType while the baro link is still delivering, a pressure/temp
|
||||||
|
// packet must NOT be parsed as HR. Standard GATT UUIDs are unambiguous.
|
||||||
|
when (char.uuid) {
|
||||||
|
PRESSURE_CHAR, TEMP_CHAR -> { parseBaro(char, value); return }
|
||||||
|
HR_MEASUREMENT_UUID -> { parseStandardHr(value); return }
|
||||||
|
}
|
||||||
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 +422,61 @@ 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
|
||||||
|
// format with Locale.US - the phone's RU locale writes a decimal comma, which is
|
||||||
|
// invalid JSON and fails the Rust parse
|
||||||
|
val tj = if (!lastTempC.isNaN()) ",\"temp_c\":${String.format(Locale.US, "%.2f", lastTempC)}" else ""
|
||||||
|
val json = "{\"pressure_pa\":${String.format(Locale.US, "%.1f", 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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,10 @@ import android.os.Build
|
||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import android.os.VibrationEffect
|
import android.os.VibrationEffect
|
||||||
import android.os.Vibrator
|
import android.os.Vibrator
|
||||||
|
import android.speech.tts.TextToSpeech
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
import java.util.Locale
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
|
|
||||||
// Drink reminder shared by GpsRecorderService (rides) and BleHrService (VR/workout). LEVEL-based,
|
// Drink reminder shared by GpsRecorderService (rides) and BleHrService (VR/workout). LEVEL-based,
|
||||||
|
|
@ -25,18 +27,23 @@ import org.json.JSONObject
|
||||||
object Hydration {
|
object Hydration {
|
||||||
private const val TAG = "Hydration"
|
private const val TAG = "Hydration"
|
||||||
private const val CH = "baldav_hydration"
|
private const val CH = "baldav_hydration"
|
||||||
private var accruedMl = 0.0 // running water deficit: start-of-session shortfall + sweat
|
private var baseDeficit = 0.0 // sedentary shortfall at (re)seed: need-so-far minus intake
|
||||||
private var startDeficitMl = 0.0 // how far behind you were at session start (from the food log)
|
private var sweatMl = 0.0 // activity sweat accrued this session (grows with the session)
|
||||||
private var pendingStart = false // apply startDeficitMl on the first tick after reset()
|
private var startDeficitMl = 0.0 // last seeded base from the food log; applied on (re)seed
|
||||||
|
private var seedSeq = 0L // JS bumps it on (re)seed (e.g. after you log a drink)
|
||||||
|
private var lastSeedSeq = -1L // a change re-applies startDeficitMl to baseDeficit mid-session
|
||||||
|
private var pendingStart = false // apply the seed on the first tick after reset()
|
||||||
private var lastTickMs = 0L
|
private var lastTickMs = 0L
|
||||||
private var lastBeepMs = 0L
|
private var lastBeepMs = 0L
|
||||||
private var lastCfgMs = 0L
|
private var lastCfgMs = 0L
|
||||||
private var enabled = true // ON by default — works out of the box, no toggle to find
|
private var enabled = true // ON by default — works out of the box, no toggle to find
|
||||||
private var sweatMlPerMin = 10.0 // need rate during an active session (600 ml/h default)
|
private var sweatMlPerMin = 10.0 // need rate during an active session (set per activity by JS)
|
||||||
private var alertMl = 600.0 // beep once the deficit reaches this
|
private var alertMl = 600.0 // beep once the deficit reaches this
|
||||||
private var reAlertMs = 15 * 60 * 1000L
|
private var reAlertMs = 15 * 60 * 1000L
|
||||||
private var lastLogMs = 0L
|
private var lastLogMs = 0L
|
||||||
private var savedAlarmVol = -1 // alarm volume to restore after an alert
|
private var savedAlarmVol = -1 // alarm volume to restore after an alert
|
||||||
|
private var tts: TextToSpeech? = null
|
||||||
|
private var ttsReady = false // the signal is a SPOKEN "hydrate now" — unmistakably BalDav
|
||||||
|
|
||||||
private fun readCfg(ctx: Context) {
|
private fun readCfg(ctx: Context) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -48,6 +55,11 @@ object Hydration {
|
||||||
alertMl = j.optInt("alertMl", 600).coerceIn(100, 3000).toDouble()
|
alertMl = j.optInt("alertMl", 600).coerceIn(100, 3000).toDouble()
|
||||||
reAlertMs = j.optInt("reAlertMin", 15).coerceIn(2, 120) * 60_000L
|
reAlertMs = j.optInt("reAlertMin", 15).coerceIn(2, 120) * 60_000L
|
||||||
startDeficitMl = j.optInt("startDeficitMl", 0).coerceIn(0, 5000).toDouble()
|
startDeficitMl = j.optInt("startDeficitMl", 0).coerceIn(0, 5000).toDouble()
|
||||||
|
seedSeq = j.optLong("seedSeq", 0L)
|
||||||
|
// a NON-ZERO new seedSeq means JS recomputed the base (session start / after a drink)
|
||||||
|
// -> re-apply it to baseDeficit mid-session; sweat keeps accruing. seedSeq=0 (profile
|
||||||
|
// save, init) must NOT zero the deficit mid-ride, so it's ignored here.
|
||||||
|
if (seedSeq != 0L && seedSeq != lastSeedSeq) { baseDeficit = startDeficitMl; lastSeedSeq = seedSeq }
|
||||||
} else enabled = true // no config yet -> ON by default
|
} else enabled = true // no config yet -> ON by default
|
||||||
} catch (e: Exception) { Log.e(TAG, "cfg: ${e.message}"); enabled = true }
|
} catch (e: Exception) { Log.e(TAG, "cfg: ${e.message}"); enabled = true }
|
||||||
}
|
}
|
||||||
|
|
@ -63,14 +75,15 @@ object Hydration {
|
||||||
val now = System.currentTimeMillis()
|
val now = System.currentTimeMillis()
|
||||||
if (now - lastCfgMs > 30_000L) { readCfg(ctx); lastCfgMs = now }
|
if (now - lastCfgMs > 30_000L) { readCfg(ctx); lastCfgMs = now }
|
||||||
if (!enabled) { if (now - lastLogMs > 120_000L) { logState(ctx, "disabled"); lastLogMs = now }; return }
|
if (!enabled) { if (now - lastLogMs > 120_000L) { logState(ctx, "disabled"); lastLogMs = now }; return }
|
||||||
if (pendingStart) { accruedMl = startDeficitMl; pendingStart = false }
|
if (pendingStart) { baseDeficit = startDeficitMl; sweatMl = 0.0; lastSeedSeq = seedSeq; pendingStart = false }
|
||||||
if (lastTickMs == 0L) { lastTickMs = now; logState(ctx, "start"); lastLogMs = now; return }
|
if (lastTickMs == 0L) { lastTickMs = now; ensureTts(ctx); logState(ctx, "start"); lastLogMs = now; return }
|
||||||
val dtMin = (now - lastTickMs) / 60_000.0
|
val dtMin = (now - lastTickMs) / 60_000.0
|
||||||
lastTickMs = now
|
lastTickMs = now
|
||||||
accruedMl += sweatMlPerMin * dtMin
|
sweatMl += sweatMlPerMin * dtMin
|
||||||
if (accruedMl >= alertMl && now - lastBeepMs >= reAlertMs) {
|
val deficit = baseDeficit + sweatMl
|
||||||
|
if (deficit >= alertMl && now - lastBeepMs >= reAlertMs) {
|
||||||
lastBeepMs = now
|
lastBeepMs = now
|
||||||
val ok = beep(ctx, handler); vibrate(ctx); notify(ctx, accruedMl.toInt())
|
val ok = beep(ctx, handler); vibrate(ctx); notify(ctx, deficit.toInt())
|
||||||
logState(ctx, if (ok) "beepOk" else "beepError"); lastLogMs = now
|
logState(ctx, if (ok) "beepOk" else "beepError"); lastLogMs = now
|
||||||
} else if (now - lastLogMs > 120_000L) { logState(ctx, "tick"); lastLogMs = now }
|
} else if (now - lastLogMs > 120_000L) { logState(ctx, "tick"); lastLogMs = now }
|
||||||
}
|
}
|
||||||
|
|
@ -80,8 +93,8 @@ object Hydration {
|
||||||
try {
|
try {
|
||||||
val f = File(ctx.filesDir.parentFile ?: ctx.filesDir, "hydration_log.jsonl")
|
val f = File(ctx.filesDir.parentFile ?: ctx.filesDir, "hydration_log.jsonl")
|
||||||
f.appendText("{\"ts\":${System.currentTimeMillis() / 1000},\"ev\":\"$event\"," +
|
f.appendText("{\"ts\":${System.currentTimeMillis() / 1000},\"ev\":\"$event\"," +
|
||||||
"\"enabled\":$enabled,\"accruedMl\":${accruedMl.toInt()},\"alertMl\":${alertMl.toInt()}," +
|
"\"enabled\":$enabled,\"accruedMl\":${(baseDeficit + sweatMl).toInt()},\"sweatMl\":${sweatMl.toInt()}," +
|
||||||
"\"startMl\":${startDeficitMl.toInt()}}\n")
|
"\"baseMl\":${baseDeficit.toInt()},\"alertMl\":${alertMl.toInt()},\"startMl\":${startDeficitMl.toInt()}}\n")
|
||||||
} catch (e: Exception) {}
|
} catch (e: Exception) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -93,24 +106,43 @@ object Hydration {
|
||||||
val am = ctx.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
val am = ctx.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||||
if (savedAlarmVol < 0) savedAlarmVol = am.getStreamVolume(AudioManager.STREAM_ALARM)
|
if (savedAlarmVol < 0) savedAlarmVol = am.getStreamVolume(AudioManager.STREAM_ALARM)
|
||||||
am.setStreamVolume(AudioManager.STREAM_ALARM, am.getStreamMaxVolume(AudioManager.STREAM_ALARM), 0)
|
am.setStreamVolume(AudioManager.STREAM_ALARM, am.getStreamMaxVolume(AudioManager.STREAM_ALARM), 0)
|
||||||
val uri = RingtoneManager.getActualDefaultRingtoneUri(ctx, RingtoneManager.TYPE_ALARM)
|
if (tts == null) {
|
||||||
?: RingtoneManager.getDefaultUri(RingtoneManager.TYPE_ALARM)
|
tts = TextToSpeech(ctx.applicationContext) { status ->
|
||||||
val mp = MediaPlayer()
|
ttsReady = status == TextToSpeech.SUCCESS
|
||||||
mp.setAudioAttributes(AudioAttributes.Builder()
|
if (ttsReady) { try { tts?.language = Locale.US } catch (e: Exception) {}; speakNow() }
|
||||||
.setUsage(AudioAttributes.USAGE_ALARM)
|
}
|
||||||
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION).build())
|
} else if (ttsReady) speakNow()
|
||||||
mp.setDataSource(ctx, uri)
|
// restore the alarm volume a few seconds after the phrase
|
||||||
mp.isLooping = true
|
|
||||||
mp.prepare()
|
|
||||||
mp.start()
|
|
||||||
handler.postDelayed({
|
handler.postDelayed({
|
||||||
try { mp.stop(); mp.release() } catch (e: Exception) {}
|
|
||||||
try { if (savedAlarmVol >= 0) { am.setStreamVolume(AudioManager.STREAM_ALARM, savedAlarmVol, 0); savedAlarmVol = -1 } } catch (e: Exception) {}
|
try { if (savedAlarmVol >= 0) { am.setStreamVolume(AudioManager.STREAM_ALARM, savedAlarmVol, 0); savedAlarmVol = -1 } } catch (e: Exception) {}
|
||||||
}, 4000)
|
}, 5000)
|
||||||
true
|
true
|
||||||
} catch (e: Exception) { Log.e(TAG, "beep: ${e.message}"); false }
|
} catch (e: Exception) { Log.e(TAG, "beep: ${e.message}"); false }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Warm the TTS engine up at session start so the first alert isn't silent (init is async).
|
||||||
|
private fun ensureTts(ctx: Context) {
|
||||||
|
if (tts != null) return
|
||||||
|
try {
|
||||||
|
tts = TextToSpeech(ctx.applicationContext) { status ->
|
||||||
|
ttsReady = status == TextToSpeech.SUCCESS
|
||||||
|
if (ttsReady) { try { tts?.language = Locale.US } catch (e: Exception) {} }
|
||||||
|
}
|
||||||
|
} catch (e: Exception) { Log.e(TAG, "tts init: ${e.message}") }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Speak the phrase over the ALARM stream so it's loud even with media muted.
|
||||||
|
private fun speakNow() {
|
||||||
|
try {
|
||||||
|
val t = tts ?: return
|
||||||
|
t.setAudioAttributes(AudioAttributes.Builder()
|
||||||
|
.setUsage(AudioAttributes.USAGE_ALARM)
|
||||||
|
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH).build())
|
||||||
|
t.speak("hydrate now", TextToSpeech.QUEUE_FLUSH, null, "hyd1")
|
||||||
|
t.speak("hydrate now", TextToSpeech.QUEUE_ADD, null, "hyd2")
|
||||||
|
} catch (e: Exception) { Log.e(TAG, "speak: ${e.message}") }
|
||||||
|
}
|
||||||
|
|
||||||
private fun vibrate(ctx: Context) {
|
private fun vibrate(ctx: Context) {
|
||||||
try {
|
try {
|
||||||
val v = ctx.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator ?: return
|
val v = ctx.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator ?: return
|
||||||
|
|
@ -127,11 +159,12 @@ object Hydration {
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && nm.getNotificationChannel(CH) == null) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && nm.getNotificationChannel(CH) == null) {
|
||||||
nm.createNotificationChannel(NotificationChannel(CH, "Напоминание пить", NotificationManager.IMPORTANCE_HIGH))
|
nm.createNotificationChannel(NotificationChannel(CH, "Напоминание пить", NotificationManager.IMPORTANCE_HIGH))
|
||||||
}
|
}
|
||||||
|
val time = java.text.SimpleDateFormat("HH:mm", Locale.getDefault()).format(java.util.Date())
|
||||||
val n = Notification.Builder(ctx, CH)
|
val n = Notification.Builder(ctx, CH)
|
||||||
.setContentTitle("Попей воды!")
|
.setContentTitle("БалДав: попей воды")
|
||||||
.setContentText("Отстаёшь примерно на $deficitMl мл — попей")
|
.setContentText("$time — отстаёшь примерно на $deficitMl мл")
|
||||||
.setSmallIcon(android.R.drawable.ic_menu_compass)
|
.setSmallIcon(android.R.drawable.ic_menu_compass)
|
||||||
.setAutoCancel(true)
|
.setAutoCancel(true) // stays in the shade as a record until you tap it
|
||||||
.build()
|
.build()
|
||||||
nm.notify(776601, n)
|
nm.notify(776601, n)
|
||||||
} catch (e: Exception) { Log.e(TAG, "notify: ${e.message}") }
|
} catch (e: Exception) { Log.e(TAG, "notify: ${e.message}") }
|
||||||
|
|
|
||||||
|
|
@ -565,9 +565,14 @@ class MainActivity : TauriActivity() {
|
||||||
val type = if (lastColon > 0) payload.substring(lastColon + 1) else "hr_standard"
|
val type = if (lastColon > 0) payload.substring(lastColon + 1) else "hr_standard"
|
||||||
Log.d("BalDav", "BLE connect: $address ($type)")
|
Log.d("BalDav", "BLE connect: $address ($type)")
|
||||||
if (ensureBlePermissions()) {
|
if (ensureBlePermissions()) {
|
||||||
val intent = Intent(this, BleHrService::class.java).apply {
|
// barometer has its own service so it and the HR strap connect at the same time
|
||||||
putExtra(BleHrService.EXTRA_ADDRESS, address)
|
val intent = if (type == "baro") {
|
||||||
putExtra(BleHrService.EXTRA_DEVICE_TYPE, type)
|
Intent(this, BleBaroService::class.java).apply { putExtra(BleBaroService.EXTRA_ADDRESS, address) }
|
||||||
|
} else {
|
||||||
|
Intent(this, BleHrService::class.java).apply {
|
||||||
|
putExtra(BleHrService.EXTRA_ADDRESS, address)
|
||||||
|
putExtra(BleHrService.EXTRA_DEVICE_TYPE, type)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) startForegroundService(intent) else startService(intent)
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) startForegroundService(intent) else startService(intent)
|
||||||
}
|
}
|
||||||
|
|
@ -578,6 +583,12 @@ class MainActivity : TauriActivity() {
|
||||||
intent.action = BleHrService.ACTION_STOP
|
intent.action = BleHrService.ACTION_STOP
|
||||||
startService(intent)
|
startService(intent)
|
||||||
}
|
}
|
||||||
|
cmd == "baro_disconnect" -> {
|
||||||
|
Log.d("BalDav", "Baro disconnect")
|
||||||
|
val intent = Intent(this, BleBaroService::class.java)
|
||||||
|
intent.action = BleBaroService.ACTION_STOP
|
||||||
|
startService(intent)
|
||||||
|
}
|
||||||
cmd == "ble_request_enable" -> {
|
cmd == "ble_request_enable" -> {
|
||||||
Log.d("BalDav", "Requesting Bluetooth enable")
|
Log.d("BalDav", "Requesting Bluetooth enable")
|
||||||
if (ensureBlePermissions()) {
|
if (ensureBlePermissions()) {
|
||||||
|
|
@ -711,6 +722,18 @@ class MainActivity : TauriActivity() {
|
||||||
|
|
||||||
private fun autoStartBle(appDataDir: File) {
|
private fun autoStartBle(appDataDir: File) {
|
||||||
try {
|
try {
|
||||||
|
// Barometer: auto-connect its own device via its own service, so it runs alongside the HR strap
|
||||||
|
val baroFile = File(appDataDir, "baro_device.json")
|
||||||
|
if (baroFile.exists()) {
|
||||||
|
val bjson = baroFile.readText().trim()
|
||||||
|
val baddr = Regex(""""addr"\s*:\s*"([^"]+)"""").find(bjson)?.groupValues?.get(1)
|
||||||
|
if (!baddr.isNullOrEmpty() && ensureBlePermissions()) {
|
||||||
|
Log.d("BalDav", "Auto-starting baro: $baddr")
|
||||||
|
val bi = Intent(this, BleBaroService::class.java).apply { putExtra(BleBaroService.EXTRA_ADDRESS, baddr) }
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) startForegroundService(bi) else startService(bi)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Read paired BLE device from profile in DB
|
// Read paired BLE device from profile in DB
|
||||||
val bleAddr = File(appDataDir, "ble_device.txt")
|
val bleAddr = File(appDataDir, "ble_device.txt")
|
||||||
// JS saves ble_device_addr in profile (SQLite), but we can't read SQLite here easily.
|
// JS saves ble_device_addr in profile (SQLite), but we can't read SQLite here easily.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -557,6 +596,29 @@ pub fn ble_save_device(app: tauri::AppHandle, address: String, device_type: Stri
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Disconnect ONLY the barometer (its own service), leaving the HR strap connected.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn ble_baro_disconnect(app: tauri::AppHandle) -> Result<String, String> {
|
||||||
|
#[cfg(not(target_os = "android"))]
|
||||||
|
{ let _ = &app; Ok("desktop: no-op".to_string()) }
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
{ android::send_command(&app, "baro_disconnect")?; Ok("Disconnecting baro".to_string()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist the paired barometer so the Kotlin side auto-connects it at start (separate from the HR
|
||||||
|
// device file). Empty address clears it.
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn ble_save_baro_device(app: tauri::AppHandle, address: String) -> Result<(), String> {
|
||||||
|
use tauri::Manager;
|
||||||
|
let data_dir = app.path().app_data_dir().map_err(|e: tauri::Error| e.to_string())?;
|
||||||
|
let json = format!(r#"{{"addr":"{}","type":"baro"}}"#, address);
|
||||||
|
std::fs::write(data_dir.join("baro_device.json"), &json).ok();
|
||||||
|
if let Some(root) = data_dir.parent().and_then(|p| p.parent()) {
|
||||||
|
std::fs::write(root.join("baro_device.json"), &json).ok();
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn ble_check_bluetooth(
|
pub fn ble_check_bluetooth(
|
||||||
app: tauri::AppHandle,
|
app: tauri::AppHandle,
|
||||||
|
|
|
||||||
|
|
@ -550,10 +550,12 @@ fn gps_service_command(app: tauri::AppHandle, cmd: String) -> Result<String, Str
|
||||||
// start_deficit_ml: how far behind on water you were at session start (from logged intake vs
|
// start_deficit_ml: how far behind on water you were at session start (from logged intake vs
|
||||||
// need-so-far), computed in JS and seeded into the native deficit. From the settings screen it is 0.
|
// need-so-far), computed in JS and seeded into the native deficit. From the settings screen it is 0.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn set_hydration_config(app: tauri::AppHandle, enabled: bool, alert_ml: u32, sweat_ml_per_hour: u32, re_alert_min: u32, start_deficit_ml: u32) -> Result<String, String> {
|
fn set_hydration_config(app: tauri::AppHandle, enabled: bool, alert_ml: u32, sweat_ml_per_hour: u32, re_alert_min: u32, start_deficit_ml: u32, seed_seq: Option<u64>) -> Result<String, String> {
|
||||||
|
// seed_seq: bump it (e.g. a timestamp) whenever the base deficit is recomputed mid-session
|
||||||
|
// (after logging a drink) so the native side re-applies start_deficit_ml to its baseDeficit.
|
||||||
let json = format!(
|
let json = format!(
|
||||||
"{{\"enabled\":{},\"alertMl\":{},\"sweatMlPerHour\":{},\"reAlertMin\":{},\"startDeficitMl\":{}}}",
|
"{{\"enabled\":{},\"alertMl\":{},\"sweatMlPerHour\":{},\"reAlertMin\":{},\"startDeficitMl\":{},\"seedSeq\":{}}}",
|
||||||
enabled, alert_ml.clamp(100, 3000), sweat_ml_per_hour.clamp(60, 2000), re_alert_min.clamp(2, 120), start_deficit_ml.clamp(0, 5000)
|
enabled, alert_ml.clamp(100, 3000), sweat_ml_per_hour.clamp(60, 2000), re_alert_min.clamp(2, 120), start_deficit_ml.clamp(0, 5000), seed_seq.unwrap_or(0)
|
||||||
);
|
);
|
||||||
write_app_root_file(&app, "hydration.json", &json);
|
write_app_root_file(&app, "hydration.json", &json);
|
||||||
Ok(json)
|
Ok(json)
|
||||||
|
|
@ -1313,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_baro_disconnect, ble::ble_save_baro_device, 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);
|
||||||
|
}
|
||||||
168
tools/baro-sensor/emulate_baro.py
Normal file
168
tools/baro-sensor/emulate_baro.py
Normal file
|
|
@ -0,0 +1,168 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
# Emulate the "BaroBalDav" sensor on this PC's Bluetooth so the phone's baldav can be tested
|
||||||
|
# against the REAL Kotlin BLE read path before the ESP32 arrives. Advertises the standard
|
||||||
|
# Environmental Sensing service (0x181A) with Pressure (0x2A6D, uint32 0.1 Pa) and Temperature
|
||||||
|
# (0x2A6E, int16 0.01 C), and pushes a simulated pressure that rises and falls so altitude moves.
|
||||||
|
#
|
||||||
|
# Pure BlueZ D-Bus GATT server (python3-dbus + python3-gi) - no bluetoothctl, no pip installs.
|
||||||
|
# Run: python3 tools/baro-sensor/emulate_baro.py (Ctrl+C to stop)
|
||||||
|
import struct, math, time, sys
|
||||||
|
import dbus, dbus.mainloop.glib, dbus.service
|
||||||
|
from gi.repository import GLib
|
||||||
|
|
||||||
|
BLUEZ = "org.bluez"
|
||||||
|
ADAPTER_IFACE = "org.bluez.Adapter1"
|
||||||
|
GATT_MANAGER = "org.bluez.GattManager1"
|
||||||
|
LE_ADV_MANAGER = "org.bluez.LEAdvertisingManager1"
|
||||||
|
DBUS_OM = "org.freedesktop.DBus.ObjectManager"
|
||||||
|
DBUS_PROP = "org.freedesktop.DBus.Properties"
|
||||||
|
|
||||||
|
ESS_UUID = "0000181a-0000-1000-8000-00805f9b34fb"
|
||||||
|
PRESSURE_UUID = "00002a6d-0000-1000-8000-00805f9b34fb"
|
||||||
|
TEMP_UUID = "00002a6e-0000-1000-8000-00805f9b34fb"
|
||||||
|
|
||||||
|
def find_adapter(bus):
|
||||||
|
om = dbus.Interface(bus.get_object(BLUEZ, "/"), DBUS_OM)
|
||||||
|
for path, ifaces in om.GetManagedObjects().items():
|
||||||
|
if ADAPTER_IFACE in ifaces and LE_ADV_MANAGER in ifaces:
|
||||||
|
return path
|
||||||
|
return None
|
||||||
|
|
||||||
|
# ---- GATT application / service / characteristic boilerplate (BlueZ example pattern) ----
|
||||||
|
class Application(dbus.service.Object):
|
||||||
|
def __init__(self, bus):
|
||||||
|
self.path = "/dev/baldav/baro"
|
||||||
|
self.services = []
|
||||||
|
dbus.service.Object.__init__(self, bus, self.path)
|
||||||
|
def get_path(self): return dbus.ObjectPath(self.path)
|
||||||
|
def add_service(self, s): self.services.append(s)
|
||||||
|
@dbus.service.method(DBUS_OM, out_signature="a{oa{sa{sv}}}")
|
||||||
|
def GetManagedObjects(self):
|
||||||
|
out = {}
|
||||||
|
for s in self.services:
|
||||||
|
out[s.get_path()] = s.get_properties()
|
||||||
|
for c in s.chars:
|
||||||
|
out[c.get_path()] = c.get_properties()
|
||||||
|
return out
|
||||||
|
|
||||||
|
class Service(dbus.service.Object):
|
||||||
|
def __init__(self, bus, index, uuid, primary=True):
|
||||||
|
self.path = "/dev/baldav/baro/service%d" % index
|
||||||
|
self.uuid = uuid; self.primary = primary; self.chars = []
|
||||||
|
dbus.service.Object.__init__(self, bus, self.path)
|
||||||
|
def get_path(self): return dbus.ObjectPath(self.path)
|
||||||
|
def add_char(self, c): self.chars.append(c)
|
||||||
|
def get_properties(self):
|
||||||
|
return {"org.bluez.GattService1": {
|
||||||
|
"UUID": self.uuid, "Primary": self.primary,
|
||||||
|
"Characteristics": dbus.Array([c.get_path() for c in self.chars], signature="o")}}
|
||||||
|
|
||||||
|
class Characteristic(dbus.service.Object):
|
||||||
|
def __init__(self, bus, index, uuid, flags, service):
|
||||||
|
self.path = service.path + "/char%d" % index
|
||||||
|
self.uuid = uuid; self.flags = flags; self.service = service
|
||||||
|
self.notifying = False; self.value = []
|
||||||
|
dbus.service.Object.__init__(self, bus, self.path)
|
||||||
|
def get_path(self): return dbus.ObjectPath(self.path)
|
||||||
|
def get_properties(self):
|
||||||
|
return {"org.bluez.GattCharacteristic1": {
|
||||||
|
"Service": self.service.get_path(), "UUID": self.uuid, "Flags": self.flags}}
|
||||||
|
@dbus.service.method(DBUS_PROP, in_signature="s", out_signature="a{sv}")
|
||||||
|
def GetAll(self, iface):
|
||||||
|
if iface != "org.bluez.GattCharacteristic1":
|
||||||
|
raise dbus.exceptions.DBusException("org.bluez.Error.InvalidArguments")
|
||||||
|
return self.get_properties()["org.bluez.GattCharacteristic1"]
|
||||||
|
@dbus.service.method("org.bluez.GattCharacteristic1", in_signature="a{sv}", out_signature="ay")
|
||||||
|
def ReadValue(self, options): return dbus.Array(self.value, signature="y")
|
||||||
|
@dbus.service.method("org.bluez.GattCharacteristic1")
|
||||||
|
def StartNotify(self):
|
||||||
|
self.notifying = True
|
||||||
|
print(" [%s] StartNotify" % self.uuid[4:8])
|
||||||
|
@dbus.service.method("org.bluez.GattCharacteristic1")
|
||||||
|
def StopNotify(self):
|
||||||
|
self.notifying = False
|
||||||
|
print(" [%s] StopNotify" % self.uuid[4:8])
|
||||||
|
@dbus.service.signal(DBUS_PROP, signature="sa{sv}as")
|
||||||
|
def PropertiesChanged(self, iface, changed, invalidated): pass
|
||||||
|
def set_value(self, raw):
|
||||||
|
self.value = [dbus.Byte(b) for b in raw]
|
||||||
|
if self.notifying:
|
||||||
|
self.PropertiesChanged("org.bluez.GattCharacteristic1",
|
||||||
|
{"Value": dbus.Array(self.value, signature="y")}, [])
|
||||||
|
|
||||||
|
# ---- LE advertisement ----
|
||||||
|
class Advertisement(dbus.service.Object):
|
||||||
|
def __init__(self, bus, index):
|
||||||
|
self.path = "/dev/baldav/baro/adv%d" % index
|
||||||
|
dbus.service.Object.__init__(self, bus, self.path)
|
||||||
|
def get_path(self): return dbus.ObjectPath(self.path)
|
||||||
|
@dbus.service.method(DBUS_PROP, in_signature="s", out_signature="a{sv}")
|
||||||
|
def GetAll(self, iface):
|
||||||
|
if iface != "org.bluez.LEAdvertisement1":
|
||||||
|
raise dbus.exceptions.DBusException("org.bluez.Error.InvalidArguments")
|
||||||
|
return {
|
||||||
|
"Type": "peripheral",
|
||||||
|
"ServiceUUIDs": dbus.Array([ESS_UUID], signature="s"),
|
||||||
|
"LocalName": dbus.String("BaroBalDav"),
|
||||||
|
"Includes": dbus.Array(["tx-power"], signature="s"),
|
||||||
|
}
|
||||||
|
@dbus.service.method("org.bluez.LEAdvertisement1")
|
||||||
|
def Release(self): print("Advertisement released")
|
||||||
|
|
||||||
|
# ---- pressure/temperature simulation ----
|
||||||
|
def enc_pressure(pa): return struct.pack("<I", round(pa * 10)) # uint32, 0.1 Pa
|
||||||
|
def enc_temp(tc): return struct.pack("<h", round(tc * 100)) # int16, 0.01 C
|
||||||
|
|
||||||
|
def main():
|
||||||
|
dbus.mainloop.glib.DBusGMainLoop(set_as_default=True)
|
||||||
|
bus = dbus.SystemBus()
|
||||||
|
adapter = find_adapter(bus)
|
||||||
|
if not adapter:
|
||||||
|
print("No BLE adapter with GATT+Advertising found"); sys.exit(1)
|
||||||
|
print("adapter:", adapter)
|
||||||
|
|
||||||
|
app = Application(bus)
|
||||||
|
svc = Service(bus, 0, ESS_UUID, True)
|
||||||
|
press = Characteristic(bus, 0, PRESSURE_UUID, ["read", "notify"], svc)
|
||||||
|
temp = Characteristic(bus, 1, TEMP_UUID, ["read", "notify"], svc)
|
||||||
|
svc.add_char(press); svc.add_char(temp); app.add_service(svc)
|
||||||
|
|
||||||
|
gatt = dbus.Interface(bus.get_object(BLUEZ, adapter), GATT_MANAGER)
|
||||||
|
adv_mgr = dbus.Interface(bus.get_object(BLUEZ, adapter), LE_ADV_MANAGER)
|
||||||
|
adv = Advertisement(bus, 0)
|
||||||
|
|
||||||
|
t0 = time.time()
|
||||||
|
def tick():
|
||||||
|
t = time.time() - t0
|
||||||
|
# sea-level-ish pressure that swings ~600 Pa (~ +-50 m) with a ~60 s period, so altitude moves
|
||||||
|
pa = 101325.0 - 600.0 * math.sin(t / 10.0)
|
||||||
|
tc = 24.0 + 0.5 * math.sin(t / 25.0)
|
||||||
|
press.set_value(enc_pressure(pa))
|
||||||
|
temp.set_value(enc_temp(tc))
|
||||||
|
if int(t) % 5 == 0:
|
||||||
|
print(" sim: P=%.1f Pa (%.2f hPa) T=%.2f C" % (pa, pa/100.0, tc))
|
||||||
|
return True
|
||||||
|
tick()
|
||||||
|
|
||||||
|
def reg_ok(): print("GATT application registered")
|
||||||
|
def reg_err(e): print("GATT register error:", e); mainloop.quit()
|
||||||
|
def adv_ok(): print("Advertising as 'BaroBalDav' (service 0x181A)")
|
||||||
|
def adv_err(e): print("Advertising error:", e); mainloop.quit()
|
||||||
|
|
||||||
|
gatt.RegisterApplication(app.get_path(), {}, reply_handler=reg_ok, error_handler=reg_err)
|
||||||
|
adv_mgr.RegisterAdvertisement(adv.get_path(), {}, reply_handler=adv_ok, error_handler=adv_err)
|
||||||
|
|
||||||
|
GLib.timeout_add(1000, tick)
|
||||||
|
mainloop = GLib.MainLoop()
|
||||||
|
print("BaroBalDav emulator running - connect from the phone. Ctrl+C to stop.")
|
||||||
|
try:
|
||||||
|
mainloop.run()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\nstopping")
|
||||||
|
try: adv_mgr.UnregisterAdvertisement(adv.get_path())
|
||||||
|
except Exception: pass
|
||||||
|
try: gatt.UnregisterApplication(app.get_path())
|
||||||
|
except Exception: pass
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
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