feat(train): the heart-rate strap, recorded by the service itself (ADR 0030, D25-D27)
The Record sheet gains a Heart rate row: a sheet looks for straps advertising the Heart Rate Service, the nearest first, once nearby devices are allowed, and the one chosen is remembered in the person's folder; it records with every workout unless switched off for one. The recorder service connects to it with Android's own Bluetooth API, no package (D26), parses Heart Rate Measurement in Kotlin (tested on the JVM), writes each rate and the strap's state to the recording file, and asks for a lost strap again for as long as it records. The recording screen shows the heart rate, and "Strap lost" while it is gone; a workout's page shows the heart's average and highest rate, each split's average and its chart. iOS's recording waits for a Mac (D25), after the rower. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
405577050d
commit
de5fd43e06
7 changed files with 1782 additions and 42 deletions
|
|
@ -1,4 +1,5 @@
|
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
<!-- The app talks to its instance: release builds need this too (Flutter's
|
||||
template grants it to debug and profile builds only). -->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
|
|
@ -14,8 +15,18 @@
|
|||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION"/>
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_HEALTH"/>
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK"/>
|
||||
<!-- The heart-rate strap (ADR 0030, D26, D27): nearby devices from Android
|
||||
12 on, never used to locate; before 12, Bluetooth's own permissions,
|
||||
and scanning needs location, which the app has for GPS anyway. -->
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
|
||||
android:usesPermissionFlags="neverForLocation"
|
||||
tools:targetApi="s"/>
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT"/>
|
||||
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30"/>
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30"/>
|
||||
<uses-feature android:name="android.hardware.location.gps" android:required="false"/>
|
||||
<uses-feature android:name="android.hardware.sensor.stepdetector" android:required="false"/>
|
||||
<uses-feature android:name="android.hardware.bluetooth_le" android:required="false"/>
|
||||
<application
|
||||
android:label="Halkyon Train"
|
||||
android:name="${applicationName}"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,39 @@
|
|||
package app.halkyon.train
|
||||
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* The Heart Rate Service's measurement (Bluetooth SIG's Heart Rate Service
|
||||
* 1.0, characteristic 0x2A37), as a strap notifies it (ADR 0030, D26).
|
||||
*/
|
||||
object HeartRateMeasurement {
|
||||
val SERVICE: UUID = UUID.fromString("0000180d-0000-1000-8000-00805f9b34fb")
|
||||
val CHARACTERISTIC: UUID = UUID.fromString("00002a37-0000-1000-8000-00805f9b34fb")
|
||||
|
||||
/** The Client Characteristic Configuration descriptor, written to have it notify. */
|
||||
val CONFIG: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
|
||||
|
||||
private const val UINT16 = 0x01
|
||||
private const val CONTACT_DETECTED = 0x02
|
||||
private const val CONTACT_SUPPORTED = 0x04
|
||||
|
||||
/**
|
||||
* The rate in beats a minute [value] holds; null where the strap says it
|
||||
* isn't on the skin, the rate is 0, or the frame is cut short. The energy
|
||||
* expended and the R-R intervals after it are left aside (D27).
|
||||
*/
|
||||
fun bpmOf(value: ByteArray): Int? {
|
||||
if (value.isEmpty()) return null
|
||||
val flags = value[0].toInt() and 0xFF
|
||||
if (flags and CONTACT_SUPPORTED != 0 && flags and CONTACT_DETECTED == 0) return null
|
||||
val bpm =
|
||||
if (flags and UINT16 != 0) {
|
||||
if (value.size < 3) return null
|
||||
(value[1].toInt() and 0xFF) or ((value[2].toInt() and 0xFF) shl 8)
|
||||
} else {
|
||||
if (value.size < 2) return null
|
||||
value[1].toInt() and 0xFF
|
||||
}
|
||||
return bpm.takeIf { it > 0 }
|
||||
}
|
||||
}
|
||||
|
|
@ -28,6 +28,7 @@ class MainActivity : FlutterActivity() {
|
|||
activity = call.argument<String>("activity")!!,
|
||||
autoPause = call.argument<String>("autoPause"),
|
||||
notice = call.argument<String>("notice")!!,
|
||||
strap = call.argument<String>("strap"),
|
||||
)
|
||||
result.success(null)
|
||||
}
|
||||
|
|
@ -39,6 +40,8 @@ class MainActivity : FlutterActivity() {
|
|||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
// Heart-rate straps nearby, while the strap sheet looks (D27).
|
||||
EventChannel(messenger, "app.halkyon.train/recorder/straps").setStreamHandler(StrapScanner(this))
|
||||
EventChannel(messenger, "app.halkyon.train/recorder/lines").setStreamHandler(
|
||||
object : EventChannel.StreamHandler {
|
||||
override fun onListen(arguments: Any?, events: EventChannel.EventSink) {
|
||||
|
|
@ -62,7 +65,7 @@ class MainActivity : FlutterActivity() {
|
|||
|
||||
/**
|
||||
* Asks for the permissions [wanted] names (location, notifications,
|
||||
* activity) that aren't granted yet (ADR 0030, D19), and answers which are
|
||||
* activity, bluetooth) that aren't granted yet (ADR 0030, D19), and answers which are
|
||||
* granted afterwards.
|
||||
*/
|
||||
private fun ask(wanted: List<String>, result: MethodChannel.Result) {
|
||||
|
|
@ -111,6 +114,15 @@ class MainActivity : FlutterActivity() {
|
|||
} else {
|
||||
emptyList()
|
||||
}
|
||||
// Finding and connecting to a strap (D27): nearby devices from Android
|
||||
// 12 on, declared as never for location; before, scanning needs
|
||||
// location.
|
||||
"bluetooth" ->
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
listOf(Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT)
|
||||
} else {
|
||||
listOf(Manifest.permission.ACCESS_FINE_LOCATION)
|
||||
}
|
||||
else -> emptyList()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -35,7 +35,8 @@ import org.json.JSONObject
|
|||
// The recording's side of the system (ADR 0030, D13, D16, D18, D19): a
|
||||
// foreground service, started from the open app, that takes GPS fixes from
|
||||
// Android's LocationManager (no Play services) and steps from the step
|
||||
// detector, pauses by itself, and appends each sample as a line to the
|
||||
// detector, reads the heart-rate strap where the person chose one (D26,
|
||||
// D27), pauses by itself, and appends each sample as a line to the
|
||||
// recording file, flushed every few seconds, so a recording survives the app
|
||||
// and is found cut off if the process doesn't. Each line goes to the app as
|
||||
// well while it listens; the app works out the totals and tells the service
|
||||
|
|
@ -51,6 +52,7 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
|
|||
private const val EXTRA_ACTIVITY = "activity"
|
||||
private const val EXTRA_AUTO_PAUSE = "autoPause"
|
||||
private const val EXTRA_NOTICE = "notice"
|
||||
private const val EXTRA_STRAP = "strap"
|
||||
private const val CHANNEL = "recording"
|
||||
private const val NOTIFICATION_ID = 5301
|
||||
private const val FLUSH_EVERY_MS = 5_000L
|
||||
|
|
@ -66,9 +68,10 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
|
|||
|
||||
/**
|
||||
* Starts recording [activity] into the file at [path], pausing by itself
|
||||
* by [autoPause] (null: never), its notification as [notice] says.
|
||||
* by [autoPause] (null: never), with the strap at [strap] (null: none),
|
||||
* its notification as [notice] says.
|
||||
*/
|
||||
fun start(context: Context, path: String, activity: String, autoPause: String?, notice: String) {
|
||||
fun start(context: Context, path: String, activity: String, autoPause: String?, notice: String, strap: String?) {
|
||||
ContextCompat.startForegroundService(
|
||||
context,
|
||||
Intent(context, RecorderService::class.java)
|
||||
|
|
@ -76,7 +79,8 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
|
|||
.putExtra(EXTRA_PATH, path)
|
||||
.putExtra(EXTRA_ACTIVITY, activity)
|
||||
.putExtra(EXTRA_AUTO_PAUSE, autoPause)
|
||||
.putExtra(EXTRA_NOTICE, notice),
|
||||
.putExtra(EXTRA_NOTICE, notice)
|
||||
.putExtra(EXTRA_STRAP, strap),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -106,6 +110,7 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
|
|||
private var pausedByHand = false
|
||||
private var notice: JSONObject? = null
|
||||
private var wakeLock: PowerManager.WakeLock? = null
|
||||
private var strap: StrapLink? = null
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
private var lastFlush = 0L
|
||||
|
||||
|
|
@ -178,6 +183,20 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
|
|||
getSystemService(PowerManager::class.java)
|
||||
.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "HalkyonTrain:recording")
|
||||
.apply { acquire(12 * 60 * 60 * 1_000L) }
|
||||
// The strap where one was chosen and connecting is allowed (Android 12
|
||||
// on asks; before, scanning's location stands for it).
|
||||
val connects =
|
||||
Build.VERSION.SDK_INT < Build.VERSION_CODES.S || granted(Manifest.permission.BLUETOOTH_CONNECT)
|
||||
intent.getStringExtra(EXTRA_STRAP)?.takeIf { connects }?.let { address ->
|
||||
strap =
|
||||
StrapLink(
|
||||
this,
|
||||
address,
|
||||
handler,
|
||||
onState = { state -> write("strap ${System.currentTimeMillis()} $state") },
|
||||
onBpm = { bpm -> write("h ${System.currentTimeMillis()} $bpm") },
|
||||
).also { it.open() }
|
||||
}
|
||||
lastFlush = now
|
||||
handler.postDelayed(ticker, 1_000)
|
||||
}
|
||||
|
|
@ -246,6 +265,8 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
|
|||
handler.removeCallbacks(ticker)
|
||||
getSystemService(LocationManager::class.java).removeUpdates(this)
|
||||
getSystemService(SensorManager::class.java).unregisterListener(this)
|
||||
strap?.close()
|
||||
strap = null
|
||||
write("end ${System.currentTimeMillis()}")
|
||||
writer?.close()
|
||||
writer = null
|
||||
|
|
|
|||
|
|
@ -0,0 +1,127 @@
|
|||
package app.halkyon.train
|
||||
|
||||
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.content.Context
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
|
||||
/**
|
||||
* The heart-rate strap during a recording (ADR 0030, D26, D27): connected
|
||||
* by its address without bonding, its measurements notified, and asked for
|
||||
* again by itself whenever it goes, until [close]. Each state and each rate
|
||||
* reaches [onState] and [onBpm] on [handler]'s thread, the one the recorder
|
||||
* writes its file on.
|
||||
*/
|
||||
@SuppressLint("MissingPermission") // The recorder opens it only where connecting is allowed.
|
||||
class StrapLink(
|
||||
private val context: Context,
|
||||
private val address: String,
|
||||
private val handler: Handler,
|
||||
private val onState: (String) -> Unit,
|
||||
private val onBpm: (Int) -> Unit,
|
||||
) : BluetoothGattCallback() {
|
||||
private var gatt: BluetoothGatt? = null
|
||||
private var closed = false
|
||||
private var connected = false
|
||||
|
||||
/** Asks for the strap: at once first, then in the background after each loss. */
|
||||
fun open() {
|
||||
val device = adapterDevice() ?: return onState("lost")
|
||||
onState("connecting")
|
||||
// A direct attempt finds a strap in reach within seconds; one that
|
||||
// fails (out of reach, or off) falls back on the background one.
|
||||
gatt = device.connectGatt(context, false, this, BluetoothDevice.TRANSPORT_LE)
|
||||
}
|
||||
|
||||
/** Lets the strap go. */
|
||||
fun close() {
|
||||
handler.post {
|
||||
closed = true
|
||||
gatt?.disconnect()
|
||||
gatt?.close()
|
||||
gatt = null
|
||||
}
|
||||
}
|
||||
|
||||
private fun adapterDevice(): BluetoothDevice? =
|
||||
try {
|
||||
context.getSystemService(BluetoothManager::class.java)?.adapter?.getRemoteDevice(address)
|
||||
} catch (_: IllegalArgumentException) {
|
||||
null
|
||||
}
|
||||
|
||||
override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
|
||||
handler.post {
|
||||
if (closed) return@post
|
||||
when (newState) {
|
||||
BluetoothProfile.STATE_CONNECTED -> gatt.discoverServices()
|
||||
BluetoothProfile.STATE_DISCONNECTED -> {
|
||||
if (connected) onState("lost")
|
||||
connected = false
|
||||
gatt.close()
|
||||
// In the background, for as long as the recording runs: Android
|
||||
// connects once the strap is in reach again.
|
||||
this.gatt = adapterDevice()?.connectGatt(context, true, this, BluetoothDevice.TRANSPORT_LE)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
|
||||
handler.post {
|
||||
if (closed) return@post
|
||||
val measurement =
|
||||
gatt.getService(HeartRateMeasurement.SERVICE)?.getCharacteristic(HeartRateMeasurement.CHARACTERISTIC)
|
||||
?: return@post gatt.disconnect()
|
||||
gatt.setCharacteristicNotification(measurement, true)
|
||||
val config = measurement.getDescriptor(HeartRateMeasurement.CONFIG) ?: return@post gatt.disconnect()
|
||||
val enable = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
gatt.writeDescriptor(config, enable)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
config.value = enable
|
||||
@Suppress("DEPRECATION")
|
||||
gatt.writeDescriptor(config)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDescriptorWrite(gatt: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int) {
|
||||
handler.post {
|
||||
if (closed || status != BluetoothGatt.GATT_SUCCESS) return@post
|
||||
connected = true
|
||||
onState("connected")
|
||||
}
|
||||
}
|
||||
|
||||
// Android 13 on.
|
||||
override fun onCharacteristicChanged(
|
||||
gatt: BluetoothGatt,
|
||||
characteristic: BluetoothGattCharacteristic,
|
||||
value: ByteArray,
|
||||
) {
|
||||
measured(characteristic, value)
|
||||
}
|
||||
|
||||
// Before Android 13.
|
||||
@Deprecated("Android 13 hands the value itself")
|
||||
override fun onCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) return
|
||||
@Suppress("DEPRECATION")
|
||||
measured(characteristic, characteristic.value ?: return)
|
||||
}
|
||||
|
||||
private fun measured(characteristic: BluetoothGattCharacteristic, value: ByteArray) {
|
||||
if (characteristic.uuid != HeartRateMeasurement.CHARACTERISTIC) return
|
||||
val bpm = HeartRateMeasurement.bpmOf(value.copyOf()) ?: return
|
||||
handler.post { if (!closed) onBpm(bpm) }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
package app.halkyon.train
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.bluetooth.BluetoothManager
|
||||
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.os.ParcelUuid
|
||||
import io.flutter.plugin.common.EventChannel
|
||||
|
||||
/**
|
||||
* Looks for heart-rate straps nearby while the app listens (ADR 0030, D27):
|
||||
* every device advertising the Heart Rate Service, as `{address, name,
|
||||
* rssi}`; the error `off` where Bluetooth is, `refused` where scanning isn't
|
||||
* allowed (the app asks first).
|
||||
*/
|
||||
class StrapScanner(private val context: Context) : EventChannel.StreamHandler {
|
||||
private var callback: ScanCallback? = null
|
||||
|
||||
@SuppressLint("MissingPermission") // Refused scans end in the SecurityException below.
|
||||
override fun onListen(arguments: Any?, events: EventChannel.EventSink) {
|
||||
val adapter = context.getSystemService(BluetoothManager::class.java)?.adapter
|
||||
val scanner = adapter?.takeIf { it.isEnabled }?.bluetoothLeScanner
|
||||
if (scanner == null) {
|
||||
events.error("off", "Bluetooth is off", null)
|
||||
return
|
||||
}
|
||||
val found =
|
||||
object : ScanCallback() {
|
||||
override fun onScanResult(callbackType: Int, result: ScanResult) {
|
||||
val name =
|
||||
result.scanRecord?.deviceName
|
||||
?: try {
|
||||
result.device.name
|
||||
} catch (_: SecurityException) {
|
||||
null
|
||||
}
|
||||
events.success(mapOf("address" to result.device.address, "name" to name, "rssi" to result.rssi))
|
||||
}
|
||||
|
||||
override fun onScanFailed(errorCode: Int) {
|
||||
events.error("failed", "Scan failed: $errorCode", null)
|
||||
}
|
||||
}
|
||||
try {
|
||||
scanner.startScan(
|
||||
listOf(ScanFilter.Builder().setServiceUuid(ParcelUuid(HeartRateMeasurement.SERVICE)).build()),
|
||||
ScanSettings.Builder().setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY).build(),
|
||||
found,
|
||||
)
|
||||
callback = found
|
||||
} catch (_: SecurityException) {
|
||||
events.error("refused", "Scanning not allowed", null)
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
override fun onCancel(arguments: Any?) {
|
||||
val found = callback ?: return
|
||||
callback = null
|
||||
try {
|
||||
context.getSystemService(BluetoothManager::class.java)?.adapter?.bluetoothLeScanner?.stopScan(found)
|
||||
} catch (_: SecurityException) {
|
||||
// Taken away while it looked: it stopped with it.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
package app.halkyon.train
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
// Heart Rate Measurement (0x2A37, Bluetooth SIG's Heart Rate Service 1.0), as
|
||||
// straps send it (ADR 0030, D26, D27): a flags byte, the rate in 8 or 16 bits,
|
||||
// then the energy expended and R-R intervals where the flags say.
|
||||
class HeartRateMeasurementTest {
|
||||
private fun frame(vararg bytes: Int) = ByteArray(bytes.size) { bytes[it].toByte() }
|
||||
|
||||
@Test
|
||||
fun `an 8-bit rate`() {
|
||||
assertEquals(72, HeartRateMeasurement.bpmOf(frame(0x00, 72)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an 8-bit rate above 127 reads unsigned`() {
|
||||
assertEquals(182, HeartRateMeasurement.bpmOf(frame(0x00, 182)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a 16-bit rate, little-endian`() {
|
||||
assertEquals(300, HeartRateMeasurement.bpmOf(frame(0x01, 0x2C, 0x01)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `R-R intervals and energy after the rate are left aside`() {
|
||||
// As a Polar H10 sends it: contact detected, R-R present, two intervals.
|
||||
assertEquals(64, HeartRateMeasurement.bpmOf(frame(0x16, 64, 0x9A, 0x03, 0x8F, 0x03)))
|
||||
// Energy expended present (bit 3).
|
||||
assertEquals(130, HeartRateMeasurement.bpmOf(frame(0x08, 130, 0x10, 0x00)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a strap that tells it isn't on the skin gives no rate`() {
|
||||
// Contact supported (bit 2), not detected (bit 1 clear).
|
||||
assertNull(HeartRateMeasurement.bpmOf(frame(0x04, 80)))
|
||||
// Detected.
|
||||
assertEquals(80, HeartRateMeasurement.bpmOf(frame(0x06, 80)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `nothing, zero or a frame cut short gives no rate`() {
|
||||
assertNull(HeartRateMeasurement.bpmOf(frame()))
|
||||
assertNull(HeartRateMeasurement.bpmOf(frame(0x00)))
|
||||
assertNull(HeartRateMeasurement.bpmOf(frame(0x00, 0)))
|
||||
assertNull(HeartRateMeasurement.bpmOf(frame(0x01, 0x2C)))
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue