ble: barometer on its own connection - simultaneous HR+baro, auto-connect, UI
Separate BleBaroService so the HR strap and the barometer each hold their own GATT link and work at the same time (one adapter, several LE connections). Verified on device against a PC BlueZ emulator of the sensor: BleHR and BleBaro deliver in parallel, no cross-talk. - BleBaroService: dedicated baro connection (scan-before-connect + direct-connect fallback, ESS subscribe, writes ble_baro_data.json). MainActivity routes baro connects to it and auto-connects both HR (ble_device.json) and baro (baro_device.json) at start. - Rust: ble_baro_disconnect, ble_save_baro_device. - Settings: a 'Датчики (BLE)' section - heart-rate and barometer as separate rows with their own slots, bind/connect/unbind, and a live pressure/temperature/altitude readout. - Tracking: altitude_baro_m stored per point, preferred in elevation stats (null without baro). - Record screen: 'Высота' from the barometer (1 Hz, dot marker), temperature from the sensor. - Fixes: direct-connect on scan timeout; Locale.US JSON (RU decimal comma broke the parse); parsing routed by characteristic (baro no longer written into the pulse); VR/HR stop returns to the tracks list instead of into details. - tools/baro-sensor/emulate_baro.py: PC BlueZ peripheral that emulates the sensor for testing. Emulator-verified only; real board still pending.
This commit is contained in:
parent
82f3dc9d4a
commit
9251c1dd90
12 changed files with 653 additions and 34 deletions
|
|
@ -22,6 +22,27 @@
|
|||
## Состояние
|
||||
Проверено компиляцией (сборка 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).
|
||||
|
|
|
|||
|
|
@ -1460,7 +1460,7 @@ body.dark-map .poi-badge { background: #111; border-color: #eee; }
|
|||
background: var(--surface);
|
||||
}
|
||||
.rec-topbar .rec-top { font-size: 27px; font-weight: 700; color: var(--on-surface); }
|
||||
.rec-topbar .rec-top-mid { color: var(--muted); }
|
||||
.rec-topbar .rec-top-mid { color: var(--on-surface); }
|
||||
|
||||
.rec-lbl {
|
||||
font-size: 10px;
|
||||
|
|
|
|||
|
|
@ -745,6 +745,7 @@ async function addGpsPoint(sessionId, ts, lat, lon, altGps, speedMs, seq, bleDat
|
|||
["accel_rms", sensorData?.accel_rms], ["accel_max", sensorData?.accel_max],
|
||||
["gyro_pitch", sensorData?.gyro_pitch], ["gyro_yaw", sensorData?.gyro_yaw],
|
||||
["bearing_deg", sensorData?.bearing], ["accuracy_m", sensorData?.accuracy],
|
||||
["altitude_baro_m", sensorData?.altitude_baro_m],
|
||||
];
|
||||
for (const [col, val] of extra) {
|
||||
if (_gpsPointCols.has(col)) { cols.push(col); vals.push(val ?? null); }
|
||||
|
|
@ -1676,6 +1677,7 @@ async function runMigrations() {
|
|||
if (!gpsCols.includes("gyro_yaw")) await execute("ALTER TABLE gps_points ADD COLUMN gyro_yaw REAL");
|
||||
if (!gpsCols.includes("bearing_deg")) await execute("ALTER TABLE gps_points ADD COLUMN bearing_deg REAL");
|
||||
if (!gpsCols.includes("accuracy_m")) await execute("ALTER TABLE gps_points ADD COLUMN accuracy_m REAL");
|
||||
if (!gpsCols.includes("altitude_baro_m")) await execute("ALTER TABLE gps_points ADD COLUMN altitude_baro_m REAL"); // barometric altitude from the BLE sensor (null without it)
|
||||
if (!gpsCols.includes("lat_smoothed")) await execute("ALTER TABLE gps_points ADD COLUMN lat_smoothed REAL");
|
||||
if (!gpsCols.includes("lon_smoothed")) await execute("ALTER TABLE gps_points ADD COLUMN lon_smoothed REAL");
|
||||
if (!gpsCols.includes("estimated")) await execute("ALTER TABLE gps_points ADD COLUMN estimated INTEGER DEFAULT 0"); // gap-fill (BRouter) points, reversible
|
||||
|
|
|
|||
|
|
@ -311,7 +311,8 @@ GPS.computeStats = function(points, weightKg, opts) {
|
|||
// e.g. [45, 45, 52, 45, 45] — the 52 is a GPS altitude glitch.
|
||||
// Prefer terrain (SRTM/open-elevation) altitude when available — GPS altitude is noisy
|
||||
// and the stuck-filter destroys its real signal. Terrain is clean and accurate for slope.
|
||||
const rawAlt = sorted.map(p => p.altitude_srtm_m ?? p.altitude ?? p.altitude_gps_m ?? null);
|
||||
// Prefer the barometer (best for real climbs/descents) when present, then terrain (SRTM), then GPS.
|
||||
const rawAlt = sorted.map(p => p.altitude_baro_m ?? p.altitude_srtm_m ?? p.altitude ?? p.altitude_gps_m ?? null);
|
||||
const filteredAlt = rawAlt.slice();
|
||||
for (let i = 1; i < filteredAlt.length - 1; i++) {
|
||||
if (filteredAlt[i] == null) continue;
|
||||
|
|
@ -1170,6 +1171,15 @@ GPS.recoverFromFiles = async function(onProgress) {
|
|||
// live marker — the same fix is never written twice with different timestamps.
|
||||
GPS._processIncomingPoint = async function(p, fromNative) {
|
||||
if (!GPS.recording.active) return;
|
||||
|
||||
// Attach barometric altitude to this point when the BLE baro sensor is live and anchored.
|
||||
// Null without a barometer, so elevation stats fall back to GPS/SRTM exactly as before.
|
||||
try {
|
||||
if (window.BARO && BARO.isLive()) {
|
||||
let ba = BARO.altitude();
|
||||
if (ba != null && isFinite(ba)) p.altitude_baro_m = ba;
|
||||
}
|
||||
} catch (e) {}
|
||||
// DB writes come only from the source of truth: native points on Android, or
|
||||
// watchPosition on desktop (no native service).
|
||||
const writeDb = !!fromNative || !GPS.recording.nativeService;
|
||||
|
|
|
|||
|
|
@ -4871,8 +4871,8 @@ function renderWorkoutInline(container) {
|
|||
let res = await GPS.stopRecording();
|
||||
if (res && res.sessionId) {
|
||||
showToast(`Зарядка: ${res.kcal} ккал, ${GPS.formatDuration(res.durationSec)}, пульс ${res.avgHr ?? "--"}/${res.maxHr ?? "--"}`);
|
||||
showSessionDetail(res.sessionId);
|
||||
} else { renderScreen("gps"); }
|
||||
}
|
||||
renderScreen("gps"); // back to the tracks list after stop, like a GPS ride (not into details)
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -5470,9 +5470,10 @@ async function renderRecordingInline(container) {
|
|||
let median = sorted[Math.floor(sorted.length / 2)] || 0;
|
||||
if (spdEl) spdEl.textContent = median.toFixed(1);
|
||||
|
||||
// Elevation
|
||||
let alt = p.altitude;
|
||||
if (alt != null && elevEl) elevEl.textContent = Math.round(alt) + " м";
|
||||
// Elevation - prefer the live barometer (smoother, tracks real climbs) over noisy GPS altitude
|
||||
let baroAlt = (window.BARO && BARO.isLive()) ? BARO.altitude() : null;
|
||||
let alt = (baroAlt != null) ? baroAlt : p.altitude;
|
||||
if (alt != null && elevEl) elevEl.textContent = Math.round(alt) + (baroAlt != null ? " м·" : " м");
|
||||
|
||||
// Pause indicator
|
||||
if (GPS.recording.pauseStart) {
|
||||
|
|
@ -5506,6 +5507,14 @@ async function renderRecordingInline(container) {
|
|||
// Update clock
|
||||
let clockEl = document.querySelector("#rec-clock");
|
||||
if (clockEl) clockEl.textContent = new Date().toTimeString().slice(0, 5);
|
||||
// Barometer readout at 1 Hz, independent of GPS fixes (so altitude/temp move smoothly).
|
||||
if (window.BARO && BARO.isLive()) {
|
||||
let ba = BARO.altitude();
|
||||
if (ba != null && elevEl) elevEl.textContent = Math.round(ba) + " м·"; // trailing dot = from the barometer
|
||||
let tc = BARO.state ? BARO.state.lastTempC : null;
|
||||
let tEl = document.querySelector("#rec-temp");
|
||||
if (tc != null && tEl) tEl.textContent = tc.toFixed(1) + "°"; // one decimal: visibly live + has the dot
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
// Fetch temperature — try multiple APIs (mobile internet blocks foreign services)
|
||||
|
|
@ -7806,18 +7815,34 @@ async function renderSettings(container) {
|
|||
</div>
|
||||
|
||||
<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-left">
|
||||
<div class="settings-item-title" id="s-ble-name">${state.profile.ble_device_name || "Не привязан"}</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-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>
|
||||
<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 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 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>
|
||||
<label class="settings-item" style="cursor:pointer">
|
||||
<div class="settings-item-left"><div class="settings-item-title">Не спрашивать про Bluetooth</div>
|
||||
|
|
@ -8272,6 +8297,69 @@ async function renderSettings(container) {
|
|||
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");
|
||||
if (dontAskBt) dontAskBt.onchange = () => { try { localStorage.setItem("baldav_dontask_bt", dontAskBt.checked ? "1" : "0"); } catch {} };
|
||||
let dontAskGps = container.querySelector("#s-dontask-gps");
|
||||
|
|
@ -8337,17 +8425,27 @@ async function renderSettings(container) {
|
|||
item.querySelector(".settings-item-desc").textContent = "Подключение...";
|
||||
try {
|
||||
await window.__TAURI__.core.invoke("ble_connect", { address: addr, deviceType: type });
|
||||
if (type === "baro") {
|
||||
// barometer -> its own slot, so it doesn't overwrite the heart-rate strap
|
||||
await DB.setProfileValue("baro_device_addr", addr);
|
||||
await DB.setProfileValue("baro_device_name", name);
|
||||
state.profile.baro_device_addr = addr;
|
||||
state.profile.baro_device_name = name;
|
||||
// persist for auto-connect at start (separate from the HR device file)
|
||||
try { await window.__TAURI__.core.invoke("ble_save_baro_device", { address: addr }); } catch {}
|
||||
try { if (window.BARO) { BARO.reset(); BARO.start(); } } catch {}
|
||||
} else {
|
||||
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
|
||||
// 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;
|
||||
container.querySelector("#s-ble-name").textContent = name;
|
||||
container.querySelector("#s-ble-status").textContent = addr + " (подключён)";
|
||||
bleResults.innerHTML = "";
|
||||
}
|
||||
// re-render settings so the Connect/Unbind buttons appear and the baro readout resumes
|
||||
await renderSettings(container);
|
||||
} catch (e) {
|
||||
item.querySelector(".settings-item-desc").textContent = "Ошибка: " + e;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,6 +48,11 @@
|
|||
android:foregroundServiceType="connectedDevice"
|
||||
android:exported="false" />
|
||||
|
||||
<service
|
||||
android:name=".BleBaroService"
|
||||
android:foregroundServiceType="connectedDevice"
|
||||
android:exported="false" />
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.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.util.Log
|
||||
import java.io.File
|
||||
import java.util.Locale
|
||||
import java.util.UUID
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
|
@ -260,12 +261,14 @@ class BleHrService : Service() {
|
|||
handler.postDelayed({
|
||||
try { scanner.stopScan(scanCallback) } catch (e: Exception) { Log.e(TAG, "stopScan: ${e.message}") }
|
||||
if (gatt == null) {
|
||||
// Not seen in the scan → the band isn't nearby/on. Do NOT hold a GATT connect to
|
||||
// an absent device (that keeps the radio busy and drains the battery). Give up;
|
||||
// the user re-taps "Подключить" once the band is on.
|
||||
Log.d(TAG, "Scan timeout, device $address not found - stopping")
|
||||
writeData("""{"status":"not_found","ts":${System.currentTimeMillis()/1000}}""")
|
||||
stopSelf()
|
||||
// Not re-found in the pre-connect scan within 5 s. A present-but-not-rescanned device
|
||||
// (a stationary sensor, or a BlueZ peripheral that the scanner just missed) still
|
||||
// connects by address, so fall back to a direct connect instead of giving up. The
|
||||
// address type is already cached from the earlier scan, so connectGatt uses it.
|
||||
Log.d(TAG, "Scan timeout, device $address not re-found - direct connect fallback")
|
||||
val a = adapter()
|
||||
if (a != null && a.isEnabled) directConnect(a, address)
|
||||
else { writeData("""{"status":"not_found","ts":${System.currentTimeMillis()/1000}}"""); stopSelf() }
|
||||
}
|
||||
}, 5000)
|
||||
}
|
||||
|
|
@ -335,6 +338,13 @@ class BleHrService : Service() {
|
|||
|
||||
override fun onCharacteristicChanged(g: BluetoothGatt, char: BluetoothGattCharacteristic, value: ByteArray) {
|
||||
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) {
|
||||
"hr_standard" -> parseStandardHr(value)
|
||||
"baro" -> parseBaro(char, value)
|
||||
|
|
@ -455,8 +465,10 @@ class BleHrService : Service() {
|
|||
((value[2].toLong() and 0xFF) shl 16) or ((value[3].toLong() and 0xFF) shl 24)
|
||||
val pa = raw / 10.0 // 0.1 Pa units -> Pa
|
||||
val ts = System.currentTimeMillis() / 1000
|
||||
val tj = if (!lastTempC.isNaN()) ",\"temp_c\":${"%.2f".format(lastTempC)}" else ""
|
||||
val json = "{\"pressure_pa\":${"%.1f".format(pa)}$tj,\"ts\":$ts}"
|
||||
// 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")
|
||||
|
|
|
|||
|
|
@ -565,10 +565,15 @@ class MainActivity : TauriActivity() {
|
|||
val type = if (lastColon > 0) payload.substring(lastColon + 1) else "hr_standard"
|
||||
Log.d("BalDav", "BLE connect: $address ($type)")
|
||||
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
|
||||
val intent = if (type == "baro") {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -578,6 +583,12 @@ class MainActivity : TauriActivity() {
|
|||
intent.action = BleHrService.ACTION_STOP
|
||||
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" -> {
|
||||
Log.d("BalDav", "Requesting Bluetooth enable")
|
||||
if (ensureBlePermissions()) {
|
||||
|
|
@ -711,6 +722,18 @@ class MainActivity : TauriActivity() {
|
|||
|
||||
private fun autoStartBle(appDataDir: File) {
|
||||
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
|
||||
val bleAddr = File(appDataDir, "ble_device.txt")
|
||||
// JS saves ble_device_addr in profile (SQLite), but we can't read SQLite here easily.
|
||||
|
|
|
|||
|
|
@ -596,6 +596,29 @@ pub fn ble_save_device(app: tauri::AppHandle, address: String, device_type: Stri
|
|||
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]
|
||||
pub fn ble_check_bluetooth(
|
||||
app: tauri::AppHandle,
|
||||
|
|
|
|||
|
|
@ -1315,7 +1315,7 @@ pub fn run() {
|
|||
.add_migrations("sqlite:baldav.db", migrations)
|
||||
.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_get_baro, ble::ble_is_connected, ble::ble_check_bluetooth, ble::ble_save_device, ble::ble_clear_hr, ble::ble_read_hr_log, ble::ble_read_strap_uptime, ble::ble_reset_strap_uptime, ble::ble_request_enable, read_state_file])
|
||||
.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!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
|
|
|
|||
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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue