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
|
||||
excluded from ordinary re-publication. Desktop/Web remain sensor consumers
|
||||
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.
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,14 @@
|
|||
<uses-permission
|
||||
android:name="android.permission.BLUETOOTH_ADMIN"
|
||||
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.FOREGROUND_SERVICE" />
|
||||
<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.SleepDiary -> R.string.nav_sleep
|
||||
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 scope = rememberCoroutineScope()
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ sealed interface AppRoute {
|
|||
data object LatestMaxima : AppRoute { override val section = AppSection.EXERCISES }
|
||||
data object Sync : AppRoute { override val section = AppSection.SYNC }
|
||||
data object Settings : AppRoute { override val section = AppSection.SETTINGS }
|
||||
data object HeartRateDiagnostics : AppRoute { override val section = AppSection.SETTINGS }
|
||||
}
|
||||
|
||||
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(", ")
|
||||
|
||||
@Composable
|
||||
fun SettingsScreen(repository: TrainlogRepository, inbox: SyncCatalogInbox, onCatalogChanged: () -> Unit) {
|
||||
fun SettingsScreen(
|
||||
repository: TrainlogRepository,
|
||||
inbox: SyncCatalogInbox,
|
||||
onHeartRateDiagnostics: () -> Unit,
|
||||
onCatalogChanged: () -> Unit,
|
||||
) {
|
||||
val colors = LocalTrainlogColors.current
|
||||
val context = LocalContext.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)) {
|
||||
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)) })
|
||||
|
|
|
|||
|
|
@ -195,7 +195,8 @@ fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: S
|
|||
AppRoute.SleepDiary -> SleepDiaryScreen(repository)
|
||||
AppRoute.LatestMaxima -> LatestMaximaScreen(repository)
|
||||
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_deactivate">Deactivate</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>
|
||||
|
|
|
|||
|
|
@ -490,4 +490,35 @@
|
|||
<string name="sleep_unit">Unité</string>
|
||||
<string name="sleep_deactivate">Désactiver</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>
|
||||
|
|
|
|||
|
|
@ -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` |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| Isolated synchronization test environment | `TRAINLOG_SYNC_TEST_ENV_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.
|
||||
|
||||
```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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
remain optional historical/external fixture gates and are not Cardio V1
|
||||
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