HR strap battery + low-charge voice/beep alerts at 10% and 5%

- BleHrService reads the standard Battery service (0x180F/0x2A19) 2.5s after
  the HR subscribe and every 60s; writes 'bat' into ble_hr_data.json.
- HrReading gains battery: Option<u8> (serde alias 'bat').
- gps.js: the HR poll captures reading.battery; _checkBatteryAlert speaks
  (speechSynthesis ru-RU) and beeps once at 10% and 5% (beep is the
  guaranteed cue where TTS is absent), resetting above 15%.
- Workout screen shows the strap charge in its status line.
This commit is contained in:
4aiman 2026-10-17 00:58:31 +03:00
commit 62434d6155
5 changed files with 95 additions and 4 deletions

View file

@ -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<u8>` (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 ок. На телефоне: выбрать "Зарядка", Старт (без браслета покажет "Подключите

View file

@ -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) {

View file

@ -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);

View file

@ -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) {

View file

@ -25,6 +25,8 @@ pub struct HrReading {
pub timestamp: u64,
#[serde(default)]
pub device: String,
#[serde(alias = "bat", default)]
pub battery: Option<u8>,
}
// Shared state for BLE operations