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,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