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

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