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:
parent
f704498d11
commit
62434d6155
5 changed files with 95 additions and 4 deletions
|
|
@ -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 ок. На телефоне: выбрать "Зарядка", Старт (без браслета покажет "Подключите
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue