diff --git a/docs/session-logs/2026-10-17_hr-workout-mode.md b/docs/session-logs/2026-10-17_hr-workout-mode.md index 130e3b1..324b4a9 100644 --- a/docs/session-logs/2026-10-17_hr-workout-mode.md +++ b/docs/session-logs/2026-10-17_hr-workout-mode.md @@ -31,8 +31,18 @@ project-hr-sensor. `gps_sessions.total_kcal`). `recalculateSession` для workout - no-op (точек нет, возвращает null), так что "Пересчитать всё" не затирает HR-ккал. -## Осталось (отдельным коммитом) -Заряд ремня ЧСС (0x180F/0x2A19) + голос/писк на 10% и 5%. +## Заряд ремня ЧСС + голос/писк на 10% и 5% (сделано) +- Kotlin `BleHrService`: читает 0x180F/0x2A19 (readBattery) через 2.5с после подписки и раз в + 60с; `lastBattery` пишется в `ble_hr_data.json` как `"bat":N`. onCharacteristicRead (две + перегрузки под API 33+/старый). +- Rust `HrReading`: поле `battery: Option` (alias "bat"). +- JS `gps.js`: поллинг ловит `reading.battery` -> `GPS.recording.currentBattery` + + `GPS._checkBatteryAlert`: на 10% и 5% ОДИН раз - голос (`speechSynthesis` ru-RU) + писк + (Web Audio, гарантированный сигнал, т.к. TTS на de-Googled ROM может не быть). Сброс флагов + при заряде > 15% (перезарядили/заменили). +- Экран зарядки показывает "заряд N%" в статусе. +- Заметка: аудио в WebView может требовать user-gesture; старт записи - это жест, должно + играть. Проверить на устройстве. ## Проверка Синтаксис JS ок. На телефоне: выбрать "Зарядка", Старт (без браслета покажет "Подключите diff --git a/frontend/js/gps.js b/frontend/js/gps.js index 50f2784..599270a 100644 --- a/frontend/js/gps.js +++ b/frontend/js/gps.js @@ -840,6 +840,7 @@ GPS._startHrMonitor = async function() { GPS.recording.hrPollInterval = setInterval(async () => { try { const reading = await window.__TAURI__.core.invoke("ble_get_hr"); + if (reading && reading.battery != null) { GPS.recording.currentBattery = reading.battery; GPS._checkBatteryAlert(reading.battery); } if (reading && reading.hr > 0) { GPS.recording.currentHr = reading.hr; GPS.recording.currentBleData = reading; @@ -867,6 +868,47 @@ GPS._stopHrMonitor = function() { try { window.__TAURI__.core.invoke("ble_clear_hr"); } catch {} }; +// Low-battery warning for the HR strap: announce (voice) and beep ONCE at 10% and 5%. +// Resets when the strap reads healthy again (recharged / battery replaced). +GPS._batAlerted = {}; +GPS._checkBatteryAlert = function(bat) { + if (bat == null || bat < 0 || bat > 100) return; + if (bat > 15) { GPS._batAlerted = {}; return; } + for (let thr of [10, 5]) { + if (bat <= thr && !GPS._batAlerted[thr]) { + GPS._batAlerted[thr] = true; + GPS._speakOrBeep(`Заряд пульсометра ${thr} процентов`); + } + } +}; +GPS._speakOrBeep = function(text) { + let spoke = false; + try { + if (window.speechSynthesis && typeof SpeechSynthesisUtterance !== "undefined") { + let u = new SpeechSynthesisUtterance(text); + u.lang = "ru-RU"; u.rate = 0.95; + window.speechSynthesis.speak(u); + spoke = true; + } + } catch {} + // Always beep too (TTS may be absent on a de-Googled ROM; the beep is the guaranteed cue). + try { GPS._beep(spoke ? 1 : 3); } catch {} +}; +GPS._beep = function(times) { + times = times || 1; + let ctx = new (window.AudioContext || window.webkitAudioContext)(); + let one = (i) => { + let o = ctx.createOscillator(), g = ctx.createGain(); + o.frequency.value = 880; o.type = "sine"; + o.connect(g); g.connect(ctx.destination); + g.gain.value = 0.25; + let t0 = ctx.currentTime + i * 0.35; + o.start(t0); o.stop(t0 + 0.2); + }; + for (let i = 0; i < times; i++) one(i); + setTimeout(() => { try { ctx.close(); } catch {} }, times * 350 + 400); +}; + // Read points from native service file // Parse raw JSONL (one native point per line) into sorted point objects. GPS._parseNativePoints = function(raw) { diff --git a/frontend/js/main.js b/frontend/js/main.js index 467a10f..3c8a407 100644 --- a/frontend/js/main.js +++ b/frontend/js/main.js @@ -4514,6 +4514,10 @@ function renderWorkoutInline(container) { if (avgEl) avgEl.textContent = hrs.length ? Math.round(hrs.reduce((a, b) => a + b, 0) / hrs.length) : "--"; if (maxEl) maxEl.textContent = hrs.length ? Math.max(...hrs) : "--"; if (GPS.recording.currentHr && hrEl) hrEl.textContent = GPS.recording.currentHr; + if (statusEl && GPS.recording.currentHr) { + let b = GPS.recording.currentBattery; + statusEl.textContent = "Пульсометр на связи" + (b != null ? ` · заряд ${b}%` : ""); + } }; clearInterval(window._wkTimer); window._wkTimer = setInterval(tick, 1000); diff --git a/src-tauri/gen/android/app/src/main/java/dev/baldav/app/BleHrService.kt b/src-tauri/gen/android/app/src/main/java/dev/baldav/app/BleHrService.kt index 70eb9f4..46b1506 100644 --- a/src-tauri/gen/android/app/src/main/java/dev/baldav/app/BleHrService.kt +++ b/src-tauri/gen/android/app/src/main/java/dev/baldav/app/BleHrService.kt @@ -31,6 +31,9 @@ class BleHrService : Service() { val HR_SERVICE_UUID: UUID = UUID.fromString("0000180d-0000-1000-8000-00805f9b34fb") val HR_MEASUREMENT_UUID: UUID = UUID.fromString("00002a37-0000-1000-8000-00805f9b34fb") val CCCD_UUID: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb") + // Standard Battery Service (strap charge) + val BATTERY_SERVICE: UUID = UUID.fromString("0000180f-0000-1000-8000-00805f9b34fb") + val BATTERY_LEVEL: UUID = UUID.fromString("00002a19-0000-1000-8000-00805f9b34fb") // FitPro Nordic UART val FITPRO_SERVICE: UUID = UUID.fromString("6e400001-b5a3-f393-e0a9-e50e24dcca9d") @@ -51,6 +54,7 @@ class BleHrService : Service() { private var gatt: BluetoothGatt? = null private var deviceType = "hr_standard" private var dataFile: File? = null + private var lastBattery: Int = -1 // strap charge %, -1 = unknown private var scanResultsFile: File? = null private var fitproWriteChar: BluetoothGattCharacteristic? = null private var fitproGatt: BluetoothGatt? = null @@ -260,6 +264,15 @@ class BleHrService : Service() { override fun onCharacteristicChanged(g: BluetoothGatt, char: BluetoothGattCharacteristic) { onCharacteristicChanged(g, char, char.value) } + // Battery level read (0x2A19). Two overloads: API 33+ passes the value, older reads char.value. + override fun onCharacteristicRead(g: BluetoothGatt, char: BluetoothGattCharacteristic, value: ByteArray, status: Int) { + if (char.uuid == BATTERY_LEVEL && value.isNotEmpty()) { lastBattery = value[0].toInt() and 0xFF; Log.d(TAG, "Battery: $lastBattery%") } + } + @Deprecated("Deprecated in API 33") + override fun onCharacteristicRead(g: BluetoothGatt, char: BluetoothGattCharacteristic, status: Int) { + val v = char.value + if (char.uuid == BATTERY_LEVEL && v != null && v.isNotEmpty()) { lastBattery = v[0].toInt() and 0xFF; Log.d(TAG, "Battery: $lastBattery%") } + } override fun onDescriptorWrite(g: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int) { Log.d(TAG, "onDescriptorWrite status=$status") @@ -284,6 +297,25 @@ class BleHrService : Service() { g.writeDescriptor(desc) } Log.d(TAG, "Standard HR setup complete") + // Strap battery (0x180F/0x2A19): read once after the HR subscribe settles, then every 60s. + android.os.Handler(mainLooper).also { h -> + val readBat = object : Runnable { + override fun run() { + if (!isRunning) return + readBattery(g) + h.postDelayed(this, 60000) + } + } + h.postDelayed(readBat, 2500) + } + } + + private fun readBattery(g: BluetoothGatt) { + try { + val bs = g.getService(BATTERY_SERVICE) ?: return + val bc = bs.getCharacteristic(BATTERY_LEVEL) ?: return + g.readCharacteristic(bc) + } catch (e: Exception) { Log.e(TAG, "readBattery: ${e.message}") } } private val gattQueue = mutableListOf<() -> Unit>() @@ -382,8 +414,9 @@ class BleHrService : Service() { } if (hr <= 0) return val ts = System.currentTimeMillis() / 1000 - writeData("""{"hr":$hr,"ts":$ts}""") - Log.d(TAG, "HR: $hr") + val batJson = if (lastBattery in 0..100) ""","bat":$lastBattery""" else "" + writeData("""{"hr":$hr,"ts":$ts$batJson}""") + Log.d(TAG, "HR: $hr bat: $lastBattery") } private fun parseFitProData(data: ByteArray) { diff --git a/src-tauri/src/ble.rs b/src-tauri/src/ble.rs index 89e33d2..d20d531 100644 --- a/src-tauri/src/ble.rs +++ b/src-tauri/src/ble.rs @@ -25,6 +25,8 @@ pub struct HrReading { pub timestamp: u64, #[serde(default)] pub device: String, + #[serde(alias = "bat", default)] + pub battery: Option, } // Shared state for BLE operations