feat(android): add heart rate BLE diagnostics
This commit is contained in:
parent
14c3ac294e
commit
984b1e058e
15 changed files with 985 additions and 4 deletions
|
|
@ -9,6 +9,14 @@
|
||||||
consumed ACK, after which those stopped captures remain local history but are
|
consumed ACK, after which those stopped captures remain local history but are
|
||||||
excluded from ordinary re-publication. Desktop/Web remain sensor consumers
|
excluded from ordinary re-publication. Desktop/Web remain sensor consumers
|
||||||
only; Bluetooth Classic Trainlog synchronization is unchanged.
|
only; Bluetooth Classic Trainlog synchronization is unchanged.
|
||||||
|
- Added the Android Heart Rate Service diagnostic foundation. The independent
|
||||||
|
`0x2A37` parser covers 8/16-bit BPM, contact, energy and raw RR flags with
|
||||||
|
strict malformed-payload rejection. A settings diagnostic provides bounded
|
||||||
|
HRS-filtered and expanded BLE scans, GATT service discovery and notification
|
||||||
|
subscription without turning desktop/Web into sensor clients. Android 12+
|
||||||
|
scan/connect permissions and the Android 11-and-older scan-location boundary
|
||||||
|
are explicit. Real armband notification characterization remains a hardware
|
||||||
|
validation gate before persistent acquisition.
|
||||||
|
|
||||||
Development opened after stable v0.1.4 release.
|
Development opened after stable v0.1.4 release.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,14 @@
|
||||||
<uses-permission
|
<uses-permission
|
||||||
android:name="android.permission.BLUETOOTH_ADMIN"
|
android:name="android.permission.BLUETOOTH_ADMIN"
|
||||||
android:maxSdkVersion="30" />
|
android:maxSdkVersion="30" />
|
||||||
|
<!-- BLE cardio discovery does not derive physical location. Android 11 and
|
||||||
|
lower still require fine location for direct BLE scans. -->
|
||||||
|
<uses-permission
|
||||||
|
android:name="android.permission.ACCESS_FINE_LOCATION"
|
||||||
|
android:maxSdkVersion="30" />
|
||||||
|
<uses-permission
|
||||||
|
android:name="android.permission.BLUETOOTH_SCAN"
|
||||||
|
android:usesPermissionFlags="neverForLocation" />
|
||||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,482 @@
|
||||||
|
/*
|
||||||
|
* Android-only BLE Heart Rate diagnostics.
|
||||||
|
*
|
||||||
|
* This owner is intentionally bounded and screen-scoped. Long-running capture
|
||||||
|
* belongs to the later foreground acquisition service, not this diagnostic.
|
||||||
|
*/
|
||||||
|
package com.labfytools.trainlog.data
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.bluetooth.BluetoothDevice
|
||||||
|
import android.bluetooth.BluetoothGatt
|
||||||
|
import android.bluetooth.BluetoothGattCallback
|
||||||
|
import android.bluetooth.BluetoothGattCharacteristic
|
||||||
|
import android.bluetooth.BluetoothGattDescriptor
|
||||||
|
import android.bluetooth.BluetoothManager
|
||||||
|
import android.bluetooth.BluetoothProfile
|
||||||
|
import android.bluetooth.BluetoothStatusCodes
|
||||||
|
import android.bluetooth.le.ScanCallback
|
||||||
|
import android.bluetooth.le.ScanFilter
|
||||||
|
import android.bluetooth.le.ScanResult
|
||||||
|
import android.bluetooth.le.ScanSettings
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.os.ParcelUuid
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
internal val HEART_RATE_SERVICE_UUID: UUID =
|
||||||
|
UUID.fromString("0000180d-0000-1000-8000-00805f9b34fb")
|
||||||
|
internal val HEART_RATE_MEASUREMENT_UUID: UUID =
|
||||||
|
UUID.fromString("00002a37-0000-1000-8000-00805f9b34fb")
|
||||||
|
internal val BATTERY_SERVICE_UUID: UUID =
|
||||||
|
UUID.fromString("0000180f-0000-1000-8000-00805f9b34fb")
|
||||||
|
private val CLIENT_CHARACTERISTIC_CONFIG_UUID: UUID =
|
||||||
|
UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
|
||||||
|
|
||||||
|
internal enum class HeartRateDiagnosticPhase {
|
||||||
|
IDLE,
|
||||||
|
SCANNING,
|
||||||
|
CONNECTING,
|
||||||
|
DISCOVERING,
|
||||||
|
SUBSCRIBING,
|
||||||
|
RECEIVING,
|
||||||
|
DISCONNECTED,
|
||||||
|
ERROR,
|
||||||
|
}
|
||||||
|
|
||||||
|
internal data class HeartRateDiagnosticDevice(
|
||||||
|
val address: String,
|
||||||
|
val name: String?,
|
||||||
|
val rssi: Int,
|
||||||
|
val advertisesHeartRate: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
internal data class HeartRateDiagnosticSnapshot(
|
||||||
|
val phase: HeartRateDiagnosticPhase = HeartRateDiagnosticPhase.IDLE,
|
||||||
|
val devices: List<HeartRateDiagnosticDevice> = emptyList(),
|
||||||
|
val selectedAddress: String? = null,
|
||||||
|
val serviceFound: Boolean = false,
|
||||||
|
val measurementFound: Boolean = false,
|
||||||
|
val batteryServiceFound: Boolean = false,
|
||||||
|
val notificationCount: Int = 0,
|
||||||
|
val latestMeasurement: ParsedHeartRateMeasurement? = null,
|
||||||
|
val diagnostic: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
internal fun heartRateBleRuntimePermissions(): Array<String> =
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
arrayOf(Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT)
|
||||||
|
} else {
|
||||||
|
arrayOf(Manifest.permission.ACCESS_FINE_LOCATION)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun hasHeartRateBleRuntimePermissions(context: Context): Boolean =
|
||||||
|
heartRateBleRuntimePermissions().all {
|
||||||
|
ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class HeartRateBleDiagnostics(
|
||||||
|
context: Context,
|
||||||
|
private val onSnapshot: (HeartRateDiagnosticSnapshot) -> Unit,
|
||||||
|
) : AutoCloseable {
|
||||||
|
private val appContext = context.applicationContext
|
||||||
|
private val main = Handler(Looper.getMainLooper())
|
||||||
|
private val bluetoothManager =
|
||||||
|
appContext.getSystemService(BluetoothManager::class.java)
|
||||||
|
private val adapter get() = bluetoothManager?.adapter
|
||||||
|
private val devices = linkedMapOf<String, BluetoothDevice>()
|
||||||
|
private var snapshot = HeartRateDiagnosticSnapshot()
|
||||||
|
private var scannerCallback: ScanCallback? = null
|
||||||
|
private var scanStop: Runnable? = null
|
||||||
|
private var gatt: BluetoothGatt? = null
|
||||||
|
private var closed = false
|
||||||
|
|
||||||
|
private fun update(transform: (HeartRateDiagnosticSnapshot) -> HeartRateDiagnosticSnapshot) {
|
||||||
|
if (closed) return
|
||||||
|
if (Looper.myLooper() != Looper.getMainLooper()) {
|
||||||
|
main.post { update(transform) }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
snapshot = transform(snapshot)
|
||||||
|
onSnapshot(snapshot)
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("MissingPermission")
|
||||||
|
fun startScan(includeAllBle: Boolean = false) {
|
||||||
|
if (!hasHeartRateBleRuntimePermissions(appContext)) {
|
||||||
|
update { it.copy(phase = HeartRateDiagnosticPhase.ERROR, diagnostic = "permission") }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val currentAdapter = adapter
|
||||||
|
if (currentAdapter == null) {
|
||||||
|
update { it.copy(phase = HeartRateDiagnosticPhase.ERROR, diagnostic = "bluetooth_unavailable") }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!currentAdapter.isEnabled) {
|
||||||
|
update { it.copy(phase = HeartRateDiagnosticPhase.ERROR, diagnostic = "bluetooth_disabled") }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val scanner = currentAdapter.bluetoothLeScanner
|
||||||
|
if (scanner == null) {
|
||||||
|
update { it.copy(phase = HeartRateDiagnosticPhase.ERROR, diagnostic = "scanner_unavailable") }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
stopScan()
|
||||||
|
devices.clear()
|
||||||
|
update {
|
||||||
|
HeartRateDiagnosticSnapshot(
|
||||||
|
phase = HeartRateDiagnosticPhase.SCANNING,
|
||||||
|
diagnostic = if (includeAllBle) "scan_all" else "scan_heart_rate",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val callback =
|
||||||
|
object : ScanCallback() {
|
||||||
|
override fun onScanResult(callbackType: Int, result: ScanResult) {
|
||||||
|
handleScanResult(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBatchScanResults(results: MutableList<ScanResult>) {
|
||||||
|
results.forEach(::handleScanResult)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onScanFailed(errorCode: Int) {
|
||||||
|
update {
|
||||||
|
it.copy(
|
||||||
|
phase = HeartRateDiagnosticPhase.ERROR,
|
||||||
|
diagnostic = "scan_failed:" + errorCode,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
scannerCallback = callback
|
||||||
|
val filters =
|
||||||
|
if (includeAllBle) {
|
||||||
|
emptyList()
|
||||||
|
} else {
|
||||||
|
listOf(
|
||||||
|
ScanFilter.Builder()
|
||||||
|
.setServiceUuid(ParcelUuid(HEART_RATE_SERVICE_UUID))
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
scanner.startScan(
|
||||||
|
filters,
|
||||||
|
ScanSettings.Builder().setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY).build(),
|
||||||
|
callback,
|
||||||
|
)
|
||||||
|
val stopper =
|
||||||
|
Runnable {
|
||||||
|
if (snapshot.phase == HeartRateDiagnosticPhase.SCANNING) {
|
||||||
|
stopScan()
|
||||||
|
update { it.copy(phase = HeartRateDiagnosticPhase.IDLE, diagnostic = "scan_complete") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
scanStop = stopper
|
||||||
|
main.postDelayed(stopper, SCAN_DURATION_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("MissingPermission")
|
||||||
|
private fun handleScanResult(result: ScanResult) {
|
||||||
|
val record = result.scanRecord
|
||||||
|
val advertised =
|
||||||
|
record?.serviceUuids?.any { it.uuid == HEART_RATE_SERVICE_UUID } == true
|
||||||
|
val device = result.device
|
||||||
|
devices[device.address] = device
|
||||||
|
val row =
|
||||||
|
HeartRateDiagnosticDevice(
|
||||||
|
address = device.address,
|
||||||
|
name = record?.deviceName,
|
||||||
|
rssi = result.rssi,
|
||||||
|
advertisesHeartRate = advertised,
|
||||||
|
)
|
||||||
|
update { old ->
|
||||||
|
val rows =
|
||||||
|
(old.devices.filterNot { it.address == row.address } + row)
|
||||||
|
.sortedWith(
|
||||||
|
compareByDescending<HeartRateDiagnosticDevice> { it.advertisesHeartRate }
|
||||||
|
.thenByDescending { it.rssi },
|
||||||
|
)
|
||||||
|
old.copy(devices = rows)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("MissingPermission")
|
||||||
|
fun connect(address: String) {
|
||||||
|
if (!hasHeartRateBleRuntimePermissions(appContext)) {
|
||||||
|
update { it.copy(phase = HeartRateDiagnosticPhase.ERROR, diagnostic = "permission") }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val device =
|
||||||
|
devices[address]
|
||||||
|
?: runCatching { adapter?.getRemoteDevice(address) }.getOrNull()
|
||||||
|
?: run {
|
||||||
|
update { it.copy(phase = HeartRateDiagnosticPhase.ERROR, diagnostic = "device_missing") }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
stopScan()
|
||||||
|
closeGatt()
|
||||||
|
update {
|
||||||
|
it.copy(
|
||||||
|
phase = HeartRateDiagnosticPhase.CONNECTING,
|
||||||
|
selectedAddress = address,
|
||||||
|
serviceFound = false,
|
||||||
|
measurementFound = false,
|
||||||
|
batteryServiceFound = false,
|
||||||
|
notificationCount = 0,
|
||||||
|
latestMeasurement = null,
|
||||||
|
diagnostic = "connecting",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
runCatching {
|
||||||
|
device.connectGatt(
|
||||||
|
appContext,
|
||||||
|
false,
|
||||||
|
gattCallback,
|
||||||
|
BluetoothDevice.TRANSPORT_LE,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.onSuccess { gatt = it }
|
||||||
|
.onFailure { error ->
|
||||||
|
update {
|
||||||
|
it.copy(
|
||||||
|
phase = HeartRateDiagnosticPhase.ERROR,
|
||||||
|
diagnostic = "connect_failed:" + error.javaClass.simpleName,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val gattCallback =
|
||||||
|
object : BluetoothGattCallback() {
|
||||||
|
@SuppressLint("MissingPermission")
|
||||||
|
override fun onConnectionStateChange(
|
||||||
|
callbackGatt: BluetoothGatt,
|
||||||
|
status: Int,
|
||||||
|
newState: Int,
|
||||||
|
) {
|
||||||
|
main.post {
|
||||||
|
if (closed || callbackGatt !== gatt) return@post
|
||||||
|
if (status == BluetoothGatt.GATT_SUCCESS &&
|
||||||
|
newState == BluetoothProfile.STATE_CONNECTED
|
||||||
|
) {
|
||||||
|
update {
|
||||||
|
it.copy(
|
||||||
|
phase = HeartRateDiagnosticPhase.DISCOVERING,
|
||||||
|
diagnostic = "connected",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!callbackGatt.discoverServices()) {
|
||||||
|
update {
|
||||||
|
it.copy(
|
||||||
|
phase = HeartRateDiagnosticPhase.ERROR,
|
||||||
|
diagnostic = "discover_start_failed",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
|
||||||
|
update {
|
||||||
|
it.copy(
|
||||||
|
phase = HeartRateDiagnosticPhase.DISCONNECTED,
|
||||||
|
diagnostic = "disconnected:" + status,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
closeGatt()
|
||||||
|
} else if (status != BluetoothGatt.GATT_SUCCESS) {
|
||||||
|
update {
|
||||||
|
it.copy(
|
||||||
|
phase = HeartRateDiagnosticPhase.ERROR,
|
||||||
|
diagnostic = "gatt_status:" + status,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
closeGatt()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("MissingPermission")
|
||||||
|
override fun onServicesDiscovered(callbackGatt: BluetoothGatt, status: Int) {
|
||||||
|
main.post {
|
||||||
|
if (closed || callbackGatt !== gatt) return@post
|
||||||
|
if (status != BluetoothGatt.GATT_SUCCESS) {
|
||||||
|
update {
|
||||||
|
it.copy(
|
||||||
|
phase = HeartRateDiagnosticPhase.ERROR,
|
||||||
|
diagnostic = "service_discovery:" + status,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return@post
|
||||||
|
}
|
||||||
|
val service = callbackGatt.getService(HEART_RATE_SERVICE_UUID)
|
||||||
|
val measurement = service?.getCharacteristic(HEART_RATE_MEASUREMENT_UUID)
|
||||||
|
val battery = callbackGatt.getService(BATTERY_SERVICE_UUID) != null
|
||||||
|
update {
|
||||||
|
it.copy(
|
||||||
|
serviceFound = service != null,
|
||||||
|
measurementFound = measurement != null,
|
||||||
|
batteryServiceFound = battery,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (measurement == null) {
|
||||||
|
update {
|
||||||
|
it.copy(
|
||||||
|
phase = HeartRateDiagnosticPhase.ERROR,
|
||||||
|
diagnostic = "heart_rate_measurement_missing",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return@post
|
||||||
|
}
|
||||||
|
subscribe(callbackGatt, measurement)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDescriptorWrite(
|
||||||
|
callbackGatt: BluetoothGatt,
|
||||||
|
descriptor: BluetoothGattDescriptor,
|
||||||
|
status: Int,
|
||||||
|
) {
|
||||||
|
if (descriptor.uuid != CLIENT_CHARACTERISTIC_CONFIG_UUID) return
|
||||||
|
update {
|
||||||
|
if (status == BluetoothGatt.GATT_SUCCESS) {
|
||||||
|
it.copy(
|
||||||
|
phase = HeartRateDiagnosticPhase.RECEIVING,
|
||||||
|
diagnostic = "notifications_enabled",
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
it.copy(
|
||||||
|
phase = HeartRateDiagnosticPhase.ERROR,
|
||||||
|
diagnostic = "descriptor_write:" + status,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Deprecated("Deprecated by Android API 33 callback with explicit value")
|
||||||
|
override fun onCharacteristicChanged(
|
||||||
|
callbackGatt: BluetoothGatt,
|
||||||
|
characteristic: BluetoothGattCharacteristic,
|
||||||
|
) {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
val value = characteristic.value ?: return
|
||||||
|
handleMeasurement(characteristic.uuid, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCharacteristicChanged(
|
||||||
|
callbackGatt: BluetoothGatt,
|
||||||
|
characteristic: BluetoothGattCharacteristic,
|
||||||
|
value: ByteArray,
|
||||||
|
) {
|
||||||
|
handleMeasurement(characteristic.uuid, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("MissingPermission")
|
||||||
|
private fun subscribe(
|
||||||
|
callbackGatt: BluetoothGatt,
|
||||||
|
measurement: BluetoothGattCharacteristic,
|
||||||
|
) {
|
||||||
|
if (!callbackGatt.setCharacteristicNotification(measurement, true)) {
|
||||||
|
update {
|
||||||
|
it.copy(
|
||||||
|
phase = HeartRateDiagnosticPhase.ERROR,
|
||||||
|
diagnostic = "notification_enable_failed",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val descriptor = measurement.getDescriptor(CLIENT_CHARACTERISTIC_CONFIG_UUID)
|
||||||
|
if (descriptor == null) {
|
||||||
|
update {
|
||||||
|
it.copy(
|
||||||
|
phase = HeartRateDiagnosticPhase.ERROR,
|
||||||
|
diagnostic = "ccc_descriptor_missing",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
update {
|
||||||
|
it.copy(
|
||||||
|
phase = HeartRateDiagnosticPhase.SUBSCRIBING,
|
||||||
|
diagnostic = "enabling_notifications",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val started =
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
callbackGatt.writeDescriptor(
|
||||||
|
descriptor,
|
||||||
|
BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE,
|
||||||
|
) == BluetoothStatusCodes.SUCCESS
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
run {
|
||||||
|
descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
|
||||||
|
callbackGatt.writeDescriptor(descriptor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!started) {
|
||||||
|
update {
|
||||||
|
it.copy(
|
||||||
|
phase = HeartRateDiagnosticPhase.ERROR,
|
||||||
|
diagnostic = "descriptor_write_start_failed",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleMeasurement(uuid: UUID, value: ByteArray) {
|
||||||
|
if (uuid != HEART_RATE_MEASUREMENT_UUID) return
|
||||||
|
when (val parsed = HeartRateMeasurementParser.parse(value)) {
|
||||||
|
is HeartRateMeasurementParseResult.Invalid ->
|
||||||
|
update {
|
||||||
|
it.copy(
|
||||||
|
phase = HeartRateDiagnosticPhase.ERROR,
|
||||||
|
diagnostic = "measurement:" + parsed.reason,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
is HeartRateMeasurementParseResult.Parsed ->
|
||||||
|
update {
|
||||||
|
it.copy(
|
||||||
|
phase = HeartRateDiagnosticPhase.RECEIVING,
|
||||||
|
notificationCount = it.notificationCount + 1,
|
||||||
|
latestMeasurement = parsed.measurement,
|
||||||
|
diagnostic = "measurement",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("MissingPermission")
|
||||||
|
fun stopScan() {
|
||||||
|
val callback = scannerCallback ?: return
|
||||||
|
scanStop?.let(main::removeCallbacks)
|
||||||
|
scanStop = null
|
||||||
|
runCatching { adapter?.bluetoothLeScanner?.stopScan(callback) }
|
||||||
|
scannerCallback = null
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("MissingPermission")
|
||||||
|
private fun closeGatt() {
|
||||||
|
val current = gatt
|
||||||
|
gatt = null
|
||||||
|
runCatching { current?.disconnect() }
|
||||||
|
runCatching { current?.close() }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
if (closed) return
|
||||||
|
stopScan()
|
||||||
|
closeGatt()
|
||||||
|
closed = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val SCAN_DURATION_MS = 10_000L
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,103 @@
|
||||||
|
/*
|
||||||
|
* Bluetooth Heart Rate Measurement parser.
|
||||||
|
*
|
||||||
|
* This file deliberately has no Android Bluetooth dependency so protocol
|
||||||
|
* decoding can be exhaustively unit-tested independently of GATT transport.
|
||||||
|
*/
|
||||||
|
package com.labfytools.trainlog.data
|
||||||
|
|
||||||
|
internal data class ParsedHeartRateMeasurement(
|
||||||
|
val bpm: Int,
|
||||||
|
val sensorContactDetected: Boolean?,
|
||||||
|
val energyExpended: Int?,
|
||||||
|
/** Raw Bluetooth Heart Rate Service units: 1/1024 second. */
|
||||||
|
val rrIntervals1024: List<Int>,
|
||||||
|
)
|
||||||
|
|
||||||
|
internal sealed interface HeartRateMeasurementParseResult {
|
||||||
|
data class Parsed(val measurement: ParsedHeartRateMeasurement) :
|
||||||
|
HeartRateMeasurementParseResult
|
||||||
|
data class Invalid(val reason: String) : HeartRateMeasurementParseResult
|
||||||
|
}
|
||||||
|
|
||||||
|
internal object HeartRateMeasurementParser {
|
||||||
|
private const val FLAG_HEART_RATE_16_BIT = 1 shl 0
|
||||||
|
private const val FLAG_CONTACT_DETECTED = 1 shl 1
|
||||||
|
private const val FLAG_CONTACT_SUPPORTED = 1 shl 2
|
||||||
|
private const val FLAG_ENERGY_PRESENT = 1 shl 3
|
||||||
|
private const val FLAG_RR_PRESENT = 1 shl 4
|
||||||
|
private const val KNOWN_FLAGS =
|
||||||
|
FLAG_HEART_RATE_16_BIT or FLAG_CONTACT_DETECTED or FLAG_CONTACT_SUPPORTED or
|
||||||
|
FLAG_ENERGY_PRESENT or FLAG_RR_PRESENT
|
||||||
|
|
||||||
|
fun parse(payload: ByteArray): HeartRateMeasurementParseResult {
|
||||||
|
if (payload.isEmpty()) return HeartRateMeasurementParseResult.Invalid("missing flags")
|
||||||
|
val flags = payload[0].toInt() and 0xff
|
||||||
|
if ((flags and KNOWN_FLAGS.inv()) != 0) {
|
||||||
|
return HeartRateMeasurementParseResult.Invalid("reserved flags are set")
|
||||||
|
}
|
||||||
|
|
||||||
|
var offset = 1
|
||||||
|
fun uint8(): Int? =
|
||||||
|
payload.getOrNull(offset)?.let {
|
||||||
|
offset += 1
|
||||||
|
it.toInt() and 0xff
|
||||||
|
}
|
||||||
|
fun uint16(): Int? {
|
||||||
|
if (offset + 2 > payload.size) return null
|
||||||
|
val value =
|
||||||
|
(payload[offset].toInt() and 0xff) or
|
||||||
|
((payload[offset + 1].toInt() and 0xff) shl 8)
|
||||||
|
offset += 2
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
val bpm =
|
||||||
|
if ((flags and FLAG_HEART_RATE_16_BIT) != 0) {
|
||||||
|
uint16() ?: return HeartRateMeasurementParseResult.Invalid("truncated 16-bit BPM")
|
||||||
|
} else {
|
||||||
|
uint8() ?: return HeartRateMeasurementParseResult.Invalid("missing BPM")
|
||||||
|
}
|
||||||
|
|
||||||
|
val contact =
|
||||||
|
if ((flags and FLAG_CONTACT_SUPPORTED) == 0) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
(flags and FLAG_CONTACT_DETECTED) != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
val energy =
|
||||||
|
if ((flags and FLAG_ENERGY_PRESENT) != 0) {
|
||||||
|
uint16()
|
||||||
|
?: return HeartRateMeasurementParseResult.Invalid(
|
||||||
|
"truncated energy-expended value",
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
val rr = mutableListOf<Int>()
|
||||||
|
if ((flags and FLAG_RR_PRESENT) != 0) {
|
||||||
|
if (offset == payload.size) {
|
||||||
|
return HeartRateMeasurementParseResult.Invalid("RR flag has no interval")
|
||||||
|
}
|
||||||
|
if ((payload.size - offset) % 2 != 0) {
|
||||||
|
return HeartRateMeasurementParseResult.Invalid("truncated RR interval")
|
||||||
|
}
|
||||||
|
while (offset < payload.size) {
|
||||||
|
rr += checkNotNull(uint16())
|
||||||
|
}
|
||||||
|
} else if (offset != payload.size) {
|
||||||
|
return HeartRateMeasurementParseResult.Invalid("unexpected trailing bytes")
|
||||||
|
}
|
||||||
|
|
||||||
|
return HeartRateMeasurementParseResult.Parsed(
|
||||||
|
ParsedHeartRateMeasurement(
|
||||||
|
bpm = bpm,
|
||||||
|
sensorContactDetected = contact,
|
||||||
|
energyExpended = energy,
|
||||||
|
rrIntervals1024 = rr,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -77,6 +77,7 @@ fun AndroidAppShell(
|
||||||
AppRoute.BodyMeasurements -> R.string.route_body_measurements; AppRoute.LatestMaxima -> R.string.route_latest_maxima
|
AppRoute.BodyMeasurements -> R.string.route_body_measurements; AppRoute.LatestMaxima -> R.string.route_latest_maxima
|
||||||
AppRoute.SleepDiary -> R.string.nav_sleep
|
AppRoute.SleepDiary -> R.string.nav_sleep
|
||||||
AppRoute.Sync -> R.string.nav_sync; AppRoute.Settings -> R.string.nav_settings
|
AppRoute.Sync -> R.string.nav_sync; AppRoute.Settings -> R.string.nav_settings
|
||||||
|
AppRoute.HeartRateDiagnostics -> R.string.route_heart_rate_diagnostics
|
||||||
})
|
})
|
||||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ sealed interface AppRoute {
|
||||||
data object LatestMaxima : AppRoute { override val section = AppSection.EXERCISES }
|
data object LatestMaxima : AppRoute { override val section = AppSection.EXERCISES }
|
||||||
data object Sync : AppRoute { override val section = AppSection.SYNC }
|
data object Sync : AppRoute { override val section = AppSection.SYNC }
|
||||||
data object Settings : AppRoute { override val section = AppSection.SETTINGS }
|
data object Settings : AppRoute { override val section = AppSection.SETTINGS }
|
||||||
|
data object HeartRateDiagnostics : AppRoute { override val section = AppSection.SETTINGS }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun AppSection.rootRoute(): AppRoute = when (this) {
|
fun AppSection.rootRoute(): AppRoute = when (this) {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,180 @@
|
||||||
|
package com.labfytools.trainlog.ui
|
||||||
|
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.labfytools.trainlog.R
|
||||||
|
import com.labfytools.trainlog.data.HeartRateBleDiagnostics
|
||||||
|
import com.labfytools.trainlog.data.HeartRateDiagnosticPhase
|
||||||
|
import com.labfytools.trainlog.data.HeartRateDiagnosticSnapshot
|
||||||
|
import com.labfytools.trainlog.data.hasHeartRateBleRuntimePermissions
|
||||||
|
import com.labfytools.trainlog.data.heartRateBleRuntimePermissions
|
||||||
|
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun HeartRateDiagnosticsScreen() {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val strings = localizedContext()
|
||||||
|
val colors = LocalTrainlogColors.current
|
||||||
|
var snapshot by remember { mutableStateOf(HeartRateDiagnosticSnapshot()) }
|
||||||
|
var pendingExpandedScan by remember { mutableStateOf(false) }
|
||||||
|
var permissionDenied by remember { mutableStateOf(false) }
|
||||||
|
val diagnostics =
|
||||||
|
remember(context) {
|
||||||
|
HeartRateBleDiagnostics(context) { next -> snapshot = next }
|
||||||
|
}
|
||||||
|
|
||||||
|
DisposableEffect(diagnostics) {
|
||||||
|
onDispose { diagnostics.close() }
|
||||||
|
}
|
||||||
|
|
||||||
|
val permissionLauncher =
|
||||||
|
rememberLauncherForActivityResult(
|
||||||
|
ActivityResultContracts.RequestMultiplePermissions(),
|
||||||
|
) { results ->
|
||||||
|
if (heartRateBleRuntimePermissions().all { results[it] == true }) {
|
||||||
|
permissionDenied = false
|
||||||
|
diagnostics.startScan(pendingExpandedScan)
|
||||||
|
} else {
|
||||||
|
permissionDenied = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun scan(expanded: Boolean) {
|
||||||
|
pendingExpandedScan = expanded
|
||||||
|
if (hasHeartRateBleRuntimePermissions(context)) {
|
||||||
|
permissionDenied = false
|
||||||
|
diagnostics.startScan(expanded)
|
||||||
|
} else {
|
||||||
|
permissionLauncher.launch(heartRateBleRuntimePermissions())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun phaseLabel(): String =
|
||||||
|
strings.getString(
|
||||||
|
when (snapshot.phase) {
|
||||||
|
HeartRateDiagnosticPhase.IDLE -> R.string.hr_diag_idle
|
||||||
|
HeartRateDiagnosticPhase.SCANNING -> R.string.hr_diag_scanning
|
||||||
|
HeartRateDiagnosticPhase.CONNECTING -> R.string.hr_diag_connecting
|
||||||
|
HeartRateDiagnosticPhase.DISCOVERING -> R.string.hr_diag_discovering
|
||||||
|
HeartRateDiagnosticPhase.SUBSCRIBING -> R.string.hr_diag_subscribing
|
||||||
|
HeartRateDiagnosticPhase.RECEIVING -> R.string.hr_diag_receiving
|
||||||
|
HeartRateDiagnosticPhase.DISCONNECTED -> R.string.hr_diag_disconnected
|
||||||
|
HeartRateDiagnosticPhase.ERROR -> R.string.hr_diag_error
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
TrainlogScreen(strings.getString(R.string.route_heart_rate_diagnostics)) {
|
||||||
|
TrainlogFrame(strings.getString(R.string.hr_diag_status)) {
|
||||||
|
TrainlogInfo(
|
||||||
|
phaseLabel(),
|
||||||
|
when (snapshot.phase) {
|
||||||
|
HeartRateDiagnosticPhase.RECEIVING -> colors.success
|
||||||
|
HeartRateDiagnosticPhase.ERROR -> colors.error
|
||||||
|
HeartRateDiagnosticPhase.CONNECTING,
|
||||||
|
HeartRateDiagnosticPhase.DISCOVERING,
|
||||||
|
HeartRateDiagnosticPhase.SUBSCRIBING,
|
||||||
|
HeartRateDiagnosticPhase.SCANNING -> colors.warning
|
||||||
|
else -> colors.muted
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (permissionDenied) {
|
||||||
|
TrainlogInfo(strings.getString(R.string.hr_diag_permission_denied), colors.error)
|
||||||
|
}
|
||||||
|
snapshot.diagnostic?.let {
|
||||||
|
TrainlogInfo(strings.getString(R.string.hr_diag_technical, it), colors.muted)
|
||||||
|
}
|
||||||
|
TrainlogInfo(
|
||||||
|
strings.getString(
|
||||||
|
R.string.hr_diag_services,
|
||||||
|
if (snapshot.serviceFound) strings.getString(R.string.yes) else strings.getString(R.string.no),
|
||||||
|
if (snapshot.measurementFound) strings.getString(R.string.yes) else strings.getString(R.string.no),
|
||||||
|
if (snapshot.batteryServiceFound) strings.getString(R.string.yes) else strings.getString(R.string.no),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
snapshot.latestMeasurement?.let { measurement ->
|
||||||
|
TrainlogInfo(
|
||||||
|
strings.getString(R.string.hr_diag_bpm, measurement.bpm),
|
||||||
|
colors.success,
|
||||||
|
)
|
||||||
|
TrainlogInfo(
|
||||||
|
strings.getString(
|
||||||
|
R.string.hr_diag_measurement_meta,
|
||||||
|
measurement.rrIntervals1024.size,
|
||||||
|
measurement.sensorContactDetected?.let { if (it) strings.getString(R.string.yes) else strings.getString(R.string.no) } ?: "—",
|
||||||
|
measurement.energyExpended?.toString() ?: "—",
|
||||||
|
),
|
||||||
|
colors.muted,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
TrainlogInfo(
|
||||||
|
strings.getString(R.string.hr_diag_notifications, snapshot.notificationCount),
|
||||||
|
colors.muted,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
TrainlogFrame(strings.getString(R.string.hr_diag_scan_section)) {
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
TrainlogButton(
|
||||||
|
strings.getString(R.string.hr_diag_scan),
|
||||||
|
{ scan(false) },
|
||||||
|
Modifier.weight(1f),
|
||||||
|
enabled = snapshot.phase != HeartRateDiagnosticPhase.SCANNING,
|
||||||
|
)
|
||||||
|
TrainlogButton(
|
||||||
|
strings.getString(R.string.hr_diag_scan_all),
|
||||||
|
{ scan(true) },
|
||||||
|
Modifier.weight(1f),
|
||||||
|
enabled = snapshot.phase != HeartRateDiagnosticPhase.SCANNING,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
TrainlogInfo(strings.getString(R.string.hr_diag_scan_description), colors.muted)
|
||||||
|
}
|
||||||
|
|
||||||
|
TrainlogFrame(
|
||||||
|
strings.getString(R.string.hr_diag_devices),
|
||||||
|
active = snapshot.devices.isNotEmpty(),
|
||||||
|
) {
|
||||||
|
if (snapshot.devices.isEmpty()) {
|
||||||
|
TrainlogInfo(strings.getString(R.string.hr_diag_no_devices), colors.muted)
|
||||||
|
}
|
||||||
|
snapshot.devices.forEach { device ->
|
||||||
|
val title =
|
||||||
|
device.name?.takeIf { it.isNotBlank() }
|
||||||
|
?: strings.getString(R.string.hr_diag_unnamed_device)
|
||||||
|
val advertised =
|
||||||
|
strings.getString(
|
||||||
|
if (device.advertisesHeartRate) {
|
||||||
|
R.string.hr_diag_hrs_advertised
|
||||||
|
} else {
|
||||||
|
R.string.hr_diag_hrs_not_advertised
|
||||||
|
},
|
||||||
|
)
|
||||||
|
TrainlogAction(
|
||||||
|
title,
|
||||||
|
strings.getString(
|
||||||
|
R.string.hr_diag_device_description,
|
||||||
|
device.address,
|
||||||
|
device.rssi,
|
||||||
|
advertised,
|
||||||
|
),
|
||||||
|
{ diagnostics.connect(device.address) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -472,7 +472,12 @@ private fun catalogCountsText(context: android.content.Context, imported: Int, r
|
||||||
).joinToString(", ")
|
).joinToString(", ")
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SettingsScreen(repository: TrainlogRepository, inbox: SyncCatalogInbox, onCatalogChanged: () -> Unit) {
|
fun SettingsScreen(
|
||||||
|
repository: TrainlogRepository,
|
||||||
|
inbox: SyncCatalogInbox,
|
||||||
|
onHeartRateDiagnostics: () -> Unit,
|
||||||
|
onCatalogChanged: () -> Unit,
|
||||||
|
) {
|
||||||
val colors = LocalTrainlogColors.current
|
val colors = LocalTrainlogColors.current
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val language = LocalLanguagePresentation.current
|
val language = LocalLanguagePresentation.current
|
||||||
|
|
@ -533,6 +538,13 @@ fun SettingsScreen(repository: TrainlogRepository, inbox: SyncCatalogInbox, onCa
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
TrainlogFrame(strings.getString(R.string.settings_cardio_section)) {
|
||||||
|
TrainlogAction(
|
||||||
|
strings.getString(R.string.settings_cardio_diagnostics),
|
||||||
|
strings.getString(R.string.settings_cardio_diagnostics_description),
|
||||||
|
onHeartRateDiagnostics,
|
||||||
|
)
|
||||||
|
}
|
||||||
TrainlogFrame(strings.getString(R.string.settings_exchange_folder)) {
|
TrainlogFrame(strings.getString(R.string.settings_exchange_folder)) {
|
||||||
TrainlogInfo(strings.getString(if (authorized) R.string.settings_folder_granted else R.string.settings_folder_required), if (authorized) colors.success else colors.warning)
|
TrainlogInfo(strings.getString(if (authorized) R.string.settings_folder_granted else R.string.settings_folder_required), if (authorized) colors.success else colors.warning)
|
||||||
TrainlogAction(strings.getString(if (authorized) R.string.settings_open_access else R.string.settings_authorize), strings.getString(R.string.settings_authorize_description), { launcher.launch(directStoragePermissionIntent(context)) })
|
TrainlogAction(strings.getString(if (authorized) R.string.settings_open_access else R.string.settings_authorize), strings.getString(R.string.settings_authorize_description), { launcher.launch(directStoragePermissionIntent(context)) })
|
||||||
|
|
|
||||||
|
|
@ -195,7 +195,8 @@ fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: S
|
||||||
AppRoute.SleepDiary -> SleepDiaryScreen(repository)
|
AppRoute.SleepDiary -> SleepDiaryScreen(repository)
|
||||||
AppRoute.LatestMaxima -> LatestMaximaScreen(repository)
|
AppRoute.LatestMaxima -> LatestMaximaScreen(repository)
|
||||||
AppRoute.Sync -> SyncScreen(repository, inbox, exporter, requestOutbox, { exportSnapshot(); catalogRevision++ }, { back() })
|
AppRoute.Sync -> SyncScreen(repository, inbox, exporter, requestOutbox, { exportSnapshot(); catalogRevision++ }, { back() })
|
||||||
AppRoute.Settings -> SettingsScreen(repository, inbox) { exportSnapshot(); catalogRevision++ }
|
AppRoute.Settings -> SettingsScreen(repository, inbox, { open(AppRoute.HeartRateDiagnostics) }) { exportSnapshot(); catalogRevision++ }
|
||||||
|
AppRoute.HeartRateDiagnostics -> HeartRateDiagnosticsScreen()
|
||||||
}}
|
}}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -480,4 +480,35 @@
|
||||||
<string name="sleep_unit">Unit</string>
|
<string name="sleep_unit">Unit</string>
|
||||||
<string name="sleep_deactivate">Deactivate</string>
|
<string name="sleep_deactivate">Deactivate</string>
|
||||||
<string name="sleep_edit">Edit</string>
|
<string name="sleep_edit">Edit</string>
|
||||||
|
<string name="yes">Yes</string>
|
||||||
|
<string name="no">No</string>
|
||||||
|
<string name="route_heart_rate_diagnostics">BLE heart-rate diagnostics</string>
|
||||||
|
<string name="settings_cardio_section">Cardio</string>
|
||||||
|
<string name="settings_cardio_diagnostics">Heart-rate sensor diagnostics</string>
|
||||||
|
<string name="settings_cardio_diagnostics_description">Find and verify a Bluetooth Heart Rate Service sensor.</string>
|
||||||
|
<string name="hr_diag_status">Sensor status</string>
|
||||||
|
<string name="hr_diag_idle">Ready</string>
|
||||||
|
<string name="hr_diag_scanning">Scanning…</string>
|
||||||
|
<string name="hr_diag_connecting">Connecting…</string>
|
||||||
|
<string name="hr_diag_discovering">Discovering services…</string>
|
||||||
|
<string name="hr_diag_subscribing">Enabling measurements…</string>
|
||||||
|
<string name="hr_diag_receiving">Measurements received</string>
|
||||||
|
<string name="hr_diag_disconnected">Disconnected</string>
|
||||||
|
<string name="hr_diag_error">BLE error</string>
|
||||||
|
<string name="hr_diag_permission_denied">Bluetooth permission denied.</string>
|
||||||
|
<string name="hr_diag_technical">Diagnostic: %1$s</string>
|
||||||
|
<string name="hr_diag_services">Heart Rate Service: %1$s · measurement: %2$s · battery: %3$s</string>
|
||||||
|
<string name="hr_diag_bpm">%1$d BPM</string>
|
||||||
|
<string name="hr_diag_measurement_meta">%1$d RR · contact %2$s · energy %3$s</string>
|
||||||
|
<string name="hr_diag_notifications">Notifications received: %1$d</string>
|
||||||
|
<string name="hr_diag_scan_section">BLE scan</string>
|
||||||
|
<string name="hr_diag_scan">Find heart-rate</string>
|
||||||
|
<string name="hr_diag_scan_all">Expanded scan</string>
|
||||||
|
<string name="hr_diag_scan_description">Heart-rate scan lasts at most 10 seconds. Expanded scan is for sensors that do not advertise their service in BLE advertisements.</string>
|
||||||
|
<string name="hr_diag_devices">BLE devices</string>
|
||||||
|
<string name="hr_diag_no_devices">No device found yet.</string>
|
||||||
|
<string name="hr_diag_unnamed_device">Unnamed device</string>
|
||||||
|
<string name="hr_diag_hrs_advertised">Heart Rate advertised</string>
|
||||||
|
<string name="hr_diag_hrs_not_advertised">Heart Rate not advertised</string>
|
||||||
|
<string name="hr_diag_device_description">%1$s · %2$d dBm · %3$s</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
|
||||||
|
|
@ -490,4 +490,35 @@
|
||||||
<string name="sleep_unit">Unité</string>
|
<string name="sleep_unit">Unité</string>
|
||||||
<string name="sleep_deactivate">Désactiver</string>
|
<string name="sleep_deactivate">Désactiver</string>
|
||||||
<string name="sleep_edit">Modifier</string>
|
<string name="sleep_edit">Modifier</string>
|
||||||
|
<string name="yes">Oui</string>
|
||||||
|
<string name="no">Non</string>
|
||||||
|
<string name="route_heart_rate_diagnostics">Diagnostic cardio BLE</string>
|
||||||
|
<string name="settings_cardio_section">Cardio</string>
|
||||||
|
<string name="settings_cardio_diagnostics">Diagnostic du capteur cardio</string>
|
||||||
|
<string name="settings_cardio_diagnostics_description">Rechercher et vérifier un capteur Bluetooth Heart Rate Service.</string>
|
||||||
|
<string name="hr_diag_status">État du capteur</string>
|
||||||
|
<string name="hr_diag_idle">Prêt</string>
|
||||||
|
<string name="hr_diag_scanning">Recherche en cours…</string>
|
||||||
|
<string name="hr_diag_connecting">Connexion…</string>
|
||||||
|
<string name="hr_diag_discovering">Découverte des services…</string>
|
||||||
|
<string name="hr_diag_subscribing">Activation des mesures…</string>
|
||||||
|
<string name="hr_diag_receiving">Mesures reçues</string>
|
||||||
|
<string name="hr_diag_disconnected">Déconnecté</string>
|
||||||
|
<string name="hr_diag_error">Erreur BLE</string>
|
||||||
|
<string name="hr_diag_permission_denied">Autorisation Bluetooth refusée.</string>
|
||||||
|
<string name="hr_diag_technical">Diagnostic : %1$s</string>
|
||||||
|
<string name="hr_diag_services">Heart Rate Service : %1$s · mesure : %2$s · batterie : %3$s</string>
|
||||||
|
<string name="hr_diag_bpm">%1$d BPM</string>
|
||||||
|
<string name="hr_diag_measurement_meta">%1$d RR · contact %2$s · énergie %3$s</string>
|
||||||
|
<string name="hr_diag_notifications">Notifications reçues : %1$d</string>
|
||||||
|
<string name="hr_diag_scan_section">Recherche BLE</string>
|
||||||
|
<string name="hr_diag_scan">Rechercher cardio</string>
|
||||||
|
<string name="hr_diag_scan_all">Recherche élargie</string>
|
||||||
|
<string name="hr_diag_scan_description">La recherche cardio dure au maximum 10 secondes. La recherche élargie sert aux capteurs qui n’annoncent pas leur service dans la publicité BLE.</string>
|
||||||
|
<string name="hr_diag_devices">Périphériques BLE</string>
|
||||||
|
<string name="hr_diag_no_devices">Aucun périphérique trouvé pour l’instant.</string>
|
||||||
|
<string name="hr_diag_unnamed_device">Périphérique sans nom</string>
|
||||||
|
<string name="hr_diag_hrs_advertised">Heart Rate annoncé</string>
|
||||||
|
<string name="hr_diag_hrs_not_advertised">Heart Rate non annoncé</string>
|
||||||
|
<string name="hr_diag_device_description">%1$s · %2$d dBm · %3$s</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,95 @@
|
||||||
|
package com.labfytools.trainlog.data
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class HeartRateMeasurementParserTest {
|
||||||
|
private fun parsed(vararg bytes: Int): ParsedHeartRateMeasurement {
|
||||||
|
val result =
|
||||||
|
HeartRateMeasurementParser.parse(
|
||||||
|
ByteArray(bytes.size) { index -> bytes[index].toByte() },
|
||||||
|
)
|
||||||
|
return (result as HeartRateMeasurementParseResult.Parsed).measurement
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun invalid(vararg bytes: Int): HeartRateMeasurementParseResult.Invalid =
|
||||||
|
HeartRateMeasurementParser.parse(
|
||||||
|
ByteArray(bytes.size) { index -> bytes[index].toByte() },
|
||||||
|
) as HeartRateMeasurementParseResult.Invalid
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parsesEightBitBpm() {
|
||||||
|
val value = parsed(0x00, 72)
|
||||||
|
assertEquals(72, value.bpm)
|
||||||
|
assertNull(value.sensorContactDetected)
|
||||||
|
assertNull(value.energyExpended)
|
||||||
|
assertTrue(value.rrIntervals1024.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parsesSixteenBitBpmLittleEndian() {
|
||||||
|
assertEquals(300, parsed(0x01, 0x2c, 0x01).bpm)
|
||||||
|
assertEquals(65535, parsed(0x01, 0xff, 0xff).bpm)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun contactIsNullableAndOnlyMeaningfulWhenSupported() {
|
||||||
|
assertNull(parsed(0x02, 80).sensorContactDetected)
|
||||||
|
assertEquals(false, parsed(0x04, 80).sensorContactDetected)
|
||||||
|
assertEquals(true, parsed(0x06, 80).sensorContactDetected)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parsesEnergyExpended() {
|
||||||
|
val value = parsed(0x08, 90, 0x34, 0x12)
|
||||||
|
assertEquals(0x1234, value.energyExpended)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parsesOneAndMultipleRawRrIntervals() {
|
||||||
|
assertEquals(listOf(1024), parsed(0x10, 60, 0x00, 0x04).rrIntervals1024)
|
||||||
|
assertEquals(
|
||||||
|
listOf(1024, 1000, 65535),
|
||||||
|
parsed(0x10, 60, 0x00, 0x04, 0xe8, 0x03, 0xff, 0xff).rrIntervals1024,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parsesCombinedKnownFlagsInWireOrder() {
|
||||||
|
val value =
|
||||||
|
parsed(
|
||||||
|
0x1f,
|
||||||
|
0x96,
|
||||||
|
0x00,
|
||||||
|
0x07,
|
||||||
|
0x00,
|
||||||
|
0xf0,
|
||||||
|
0x03,
|
||||||
|
0x00,
|
||||||
|
0x04,
|
||||||
|
)
|
||||||
|
assertEquals(150, value.bpm)
|
||||||
|
assertEquals(true, value.sensorContactDetected)
|
||||||
|
assertEquals(7, value.energyExpended)
|
||||||
|
assertEquals(listOf(1008, 1024), value.rrIntervals1024)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun rejectsTruncatedAndIncoherentPayloads() {
|
||||||
|
assertEquals("missing flags", invalid().reason)
|
||||||
|
assertEquals("truncated 16-bit BPM", invalid(0x01, 0x2c).reason)
|
||||||
|
assertEquals("truncated energy-expended value", invalid(0x08, 70, 0x01).reason)
|
||||||
|
assertEquals("RR flag has no interval", invalid(0x10, 70).reason)
|
||||||
|
assertEquals("truncated RR interval", invalid(0x10, 70, 0x01).reason)
|
||||||
|
assertEquals("unexpected trailing bytes", invalid(0x00, 70, 0x01).reason)
|
||||||
|
assertEquals("reserved flags are set", invalid(0x20, 70).reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun preservesBoundaryBpmValues() {
|
||||||
|
assertEquals(0, parsed(0x00, 0).bpm)
|
||||||
|
assertEquals(255, parsed(0x00, 255).bpm)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -148,7 +148,8 @@ parallel implementation of its rules.
|
||||||
| Local Web | `TRAINLOG_WEB_V1=CONTRACT_FROZEN / IMPLEMENTATION_STARTED` |
|
| Local Web | `TRAINLOG_WEB_V1=CONTRACT_FROZEN / IMPLEMENTATION_STARTED` |
|
||||||
| Web Exercises V1 | `TRAINLOG_WEB_EXERCISES_V1=PASS` |
|
| Web Exercises V1 | `TRAINLOG_WEB_EXERCISES_V1=PASS` |
|
||||||
| Cardio persistence/sync | `TRAINLOG_CARDIO_PERSISTENCE_SYNC_V1=PASS`: Android v27 + desktop v30, strict optional `trainlog-heart-rate` V1 Android→desktop companion, generation relation and correlated ACK |
|
| Cardio persistence/sync | `TRAINLOG_CARDIO_PERSISTENCE_SYNC_V1=PASS`: Android v27 + desktop v30, strict optional `trainlog-heart-rate` V1 Android→desktop companion, generation relation and correlated ACK |
|
||||||
| Current operational cursor | `TRAINLOG_CARDIO_BLE_DIAGNOSTICS_V1`: standard Heart Rate Service parser, Android BLE discovery and real-sensor characterization |
|
| Cardio BLE diagnostics | `TRAINLOG_CARDIO_BLE_DIAGNOSTICS_V1=SOFTWARE_PASS / HARDWARE_VALIDATION_PENDING`: strict `0x2A37` parser, bounded HRS/expanded BLE scan, GATT service discovery and notification subscription are installed on Android 0.1.5 vc38; filtered real-device scan observed no advertising HRS device on 2026-09-23 |
|
||||||
|
| Current operational cursor | `TRAINLOG_CARDIO_BLE_HARDWARE_VALIDATION_V1`: identify the real armband, confirm `0x180D/0x2A37`, notification cadence and optional RR/contact/energy fields before persistent acquisition |
|
||||||
| Complete synchronization gap contract | Frozen dependency contract; operational USB/Drive slices required by v0.1.2 are delivered |
|
| Complete synchronization gap contract | Frozen dependency contract; operational USB/Drive slices required by v0.1.2 are delivered |
|
||||||
| Isolated synchronization test environment | `TRAINLOG_SYNC_TEST_ENV_V1=PASS/FROZEN` |
|
| Isolated synchronization test environment | `TRAINLOG_SYNC_TEST_ENV_V1=PASS/FROZEN` |
|
||||||
| Synchronization characterization | `TRAINLOG_SYNC_CHARACTERIZATION_V1=PASS/FROZEN` |
|
| Synchronization characterization | `TRAINLOG_SYNC_CHARACTERIZATION_V1=PASS/FROZEN` |
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,7 @@ sensor. Heart-rate ingestion must not reuse or overload the frozen Trainlog
|
||||||
Bluetooth synchronization transport contract.
|
Bluetooth synchronization transport contract.
|
||||||
|
|
||||||
```text
|
```text
|
||||||
CURRENT_OPERATIONAL_CURSOR=TRAINLOG_CARDIO_BLE_DIAGNOSTICS_V1
|
CURRENT_OPERATIONAL_CURSOR=TRAINLOG_CARDIO_BLE_HARDWARE_VALIDATION_V1
|
||||||
```
|
```
|
||||||
|
|
||||||
The focused `TRAINLOG_SYNC_CAUSAL_DELETE_V1_CLOSEOUT` is complete before this
|
The focused `TRAINLOG_SYNC_CAUSAL_DELETE_V1_CLOSEOUT` is complete before this
|
||||||
|
|
|
||||||
|
|
@ -1670,3 +1670,30 @@ The 2026-09-23 closeout passes the complete native Meson inventory:
|
||||||
**258 tests: 253 passed, five skipped, zero failures/errors**. The five skips
|
**258 tests: 253 passed, five skipped, zero failures/errors**. The five skips
|
||||||
remain optional historical/external fixture gates and are not Cardio V1
|
remain optional historical/external fixture gates and are not Cardio V1
|
||||||
failures.
|
failures.
|
||||||
|
|
||||||
|
## Cardio BLE diagnostics V1 validation
|
||||||
|
|
||||||
|
`TRAINLOG_CARDIO_BLE_DIAGNOSTICS_V1=SOFTWARE_PASS / HARDWARE_VALIDATION_PENDING`
|
||||||
|
keeps BLE protocol parsing separate from Android GATT transport. Unit coverage
|
||||||
|
exercises 8-bit and 16-bit BPM, nullable sensor-contact semantics, cumulative
|
||||||
|
energy, one/multiple raw RR intervals, combined flags, boundary BPM values,
|
||||||
|
reserved flags, missing fields, truncated RR/energy values and unexpected
|
||||||
|
trailing bytes.
|
||||||
|
|
||||||
|
The Android diagnostic uses a 10-second Heart Rate Service-filtered scan with
|
||||||
|
an explicit expanded BLE fallback, then discovers `0x180D`, locates `0x2A37`,
|
||||||
|
checks optional Battery Service and enables the CCC notification descriptor.
|
||||||
|
It is screen-scoped and does not implement the later persistent foreground
|
||||||
|
acquisition owner.
|
||||||
|
|
||||||
|
On 2026-09-23 the diagnostic APK was installed non-destructively on the
|
||||||
|
authorized SM_G990B as Trainlog 0.1.5 versionCode 38 using the same Android
|
||||||
|
debug signing certificate as the existing private installation. Application
|
||||||
|
startup completed without SQLite/crash errors and the diagnostic route opened.
|
||||||
|
The first real 10-second HRS-filtered scan completed cleanly but found no
|
||||||
|
device advertising Heart Rate Service. Real armband identification, GATT HRS
|
||||||
|
confirmation and notification-field characterization therefore remain
|
||||||
|
`HARDWARE_VALIDATION_PENDING` and are not represented as PASS.
|
||||||
|
|
||||||
|
The complete Android JVM inventory after this tranche reports **266 tests:
|
||||||
|
261 passed, five skipped, zero failures/errors**, and `assembleDebug` succeeds.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue