feat(train): the Loop's live heart rate in a recording (ADR 0030, D41)

A recording with a Loop set up reads its heart rate through Polar's SDK
while no strap sends: the strap first, the Loop where there is none or
while it is lost. The SDK is one for the process, held by the open
app's bridge and by the recording service, so that the band stays
connected with the app gone; the app reopening finds it connected. The
recording file gains `loop` state lines; the rate is kept alone, as `h`
lines, and the Record sheet names the source.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-28 21:32:13 +01:00
commit fa7680821d
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
27 changed files with 577 additions and 72 deletions

View file

@ -6,7 +6,6 @@ import com.polar.androidcommunications.api.ble.model.gatt.client.ChargeState
import com.polar.androidcommunications.api.ble.model.gatt.client.PowerSourcesState
import com.polar.sdk.api.PolarBleApi
import com.polar.sdk.api.PolarBleApiCallbackProvider
import com.polar.sdk.api.PolarBleApiDefaultImpl
import com.polar.sdk.api.model.FirmwareUpdateStatus
import com.polar.sdk.api.model.PolarDeviceInfo
import com.polar.sdk.api.model.PolarDeviceUuid
@ -40,28 +39,11 @@ class LoopBridge(private val context: Context) : MethodChannel.MethodCallHandler
private val calls = Mutex()
private var events: EventChannel.EventSink? = null
// The SDK made on first use, so that an app without a Loop never starts it.
// Its telemetry stays off: FEATURE_TELEMETRY is never asked for.
// The SDK held on first use, so that an app without a Loop never starts
// it; shared with a recording (D41), which may hold it on.
private val made = lazy {
PolarBleApiDefaultImpl.defaultImplementation(
context.applicationContext,
setOf(
PolarBleApi.PolarBleSdkFeature.FEATURE_DEVICE_INFO,
PolarBleApi.PolarBleSdkFeature.FEATURE_BATTERY_INFO,
PolarBleApi.PolarBleSdkFeature.FEATURE_POLAR_DEVICE_TIME_SETUP,
PolarBleApi.PolarBleSdkFeature.FEATURE_POLAR_FILE_TRANSFER,
PolarBleApi.PolarBleSdkFeature.FEATURE_POLAR_DEVICE_CONTROL,
PolarBleApi.PolarBleSdkFeature.FEATURE_POLAR_ACTIVITY_DATA,
PolarBleApi.PolarBleSdkFeature.FEATURE_POLAR_SLEEP_DATA,
PolarBleApi.PolarBleSdkFeature.FEATURE_POLAR_TEMPERATURE_DATA,
PolarBleApi.PolarBleSdkFeature.FEATURE_POLAR_TRAINING_DATA,
PolarBleApi.PolarBleSdkFeature.FEATURE_POLAR_FIRMWARE_UPDATE,
),
).apply {
holding = true
setAutomaticReconnection(true)
setApiCallback(callback)
}
PolarSdk.listener = callback
PolarSdk.hold(context, this)
}
private val api: PolarBleApi by made
@ -119,8 +101,21 @@ class LoopBridge(private val context: Context) : MethodChannel.MethodCallHandler
// One call to the band at a time, as Polar's sync guideline asks.
calls.withLock {
when (call.method) {
"connect" -> result.success(api.connectToDevice(id).let { null })
"disconnect" -> result.success(api.disconnectFromDevice(id).let { null })
"connect" -> {
// Connected already where a recording held it on (D41).
val sdk = api
val name = PolarSdk.connectedName(id)
if (name == null) {
sdk.connectToDevice(id)
} else {
send(id, "connected", "name" to name)
if (PolarSdk.isReady(id)) send(id, "ready")
}
result.success(null)
}
// A band a recording reads stays connected for it.
"disconnect" ->
result.success(if (PolarSdk.isRead(id)) null else api.disconnectFromDevice(id).let { null })
"isSetUp" -> result.success(api.isFtuDone(id))
"setUp" -> {
val config = LoopSetup.configOf(call.argument<Map<String, Any?>>("figures")!!, ZonedDateTime.now())
@ -176,8 +171,8 @@ class LoopBridge(private val context: Context) : MethodChannel.MethodCallHandler
scope.cancel()
events = null
if (made.isInitialized()) {
api.shutDown()
holding = false
if (PolarSdk.listener === callback) PolarSdk.listener = null
PolarSdk.release(this)
}
}
@ -199,8 +194,7 @@ class LoopBridge(private val context: Context) : MethodChannel.MethodCallHandler
send(polarDeviceInfo.deviceId, "disconnected")
override fun bleSdkFeatureReady(identifier: String, feature: PolarBleApi.PolarBleSdkFeature) {
// The clock and the first-time use go through the band's files.
if (feature == PolarBleApi.PolarBleSdkFeature.FEATURE_POLAR_DEVICE_TIME_SETUP) send(identifier, "ready")
if (feature == PolarSdk.READY_WITH) send(identifier, "ready")
}
override fun bleSdkFeaturesReadiness(
@ -229,10 +223,6 @@ class LoopBridge(private val context: Context) : MethodChannel.MethodCallHandler
}
companion object {
/** Whether the open app holds the SDK, so that the background job leaves the band alone. */
@Volatile
var holding = false
/** Device Information's Firmware Revision String (0x2A26). */
private val FIRMWARE_REVISION: UUID = UUID.fromString("00002a26-0000-1000-8000-00805f9b34fb")
}

View file

@ -0,0 +1,50 @@
package app.halkyon.train
import com.polar.sdk.api.model.PolarHrData
import kotlin.coroutines.cancellation.CancellationException
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
/**
* The Loop's heart rate in a recording (ADR 0030, D41): streamed through
* Polar's SDK for as long as the recording runs, asked for again a few
* seconds after the stream stops or fails (the band not ready yet, out of
* reach), its state told as a strap's is (`connecting` at first, `connected`
* with its first rate, `lost` as it stops), each rate the band reads on the
* skin handed on.
*/
class LoopHeart(
private val stream: () -> Flow<PolarHrData>,
private val onState: (String) -> Unit,
private val onBpm: (Int) -> Unit,
private val againAfterMs: Long = 5_000,
) {
/** Runs until cancelled with the recording. */
suspend fun run() {
onState("connecting")
while (true) {
var connected = false
try {
stream().collect { data ->
if (!connected) {
connected = true
onState("connected")
}
data.samples.mapNotNull(::bpmOf).forEach(onBpm)
}
} catch (error: CancellationException) {
throw error
} catch (_: Exception) {
// Not ready, or gone: asked for again below.
}
if (connected) onState("lost")
delay(againAfterMs)
}
}
companion object {
/** A sample's rate; null where none was read, or the band isn't on the skin. */
fun bpmOf(sample: PolarHrData.PolarHrSample): Int? =
sample.hr.takeIf { it > 0 && (!sample.contactStatusSupported || sample.contactStatus) }
}
}

View file

@ -32,11 +32,11 @@ import org.json.JSONObject
* person's `loop_days.txt` (a line of JSON each, as [LoopDays] makes them),
* deletes on the band what the app said it kept, and lets go. The app seals
* the lines as it opens; this never seals, as only the app holds the key.
* It leaves the band alone while the open app holds it.
* It leaves the band alone while the open app or a recording holds it.
*/
class LoopWorker(context: Context, parameters: WorkerParameters) : CoroutineWorker(context, parameters) {
override suspend fun doWork(): Result {
if (LoopBridge.holding) return Result.success()
if (PolarSdk.held) return Result.success()
val config = configOf(applicationContext) ?: return Result.success()
val ready = CompletableDeferred<Unit>()
val api =

View file

@ -56,6 +56,7 @@ class MainActivity : FlutterActivity() {
notice = call.argument<String>("notice")!!,
strap = call.argument<String>("strap"),
rower = call.argument<String>("rower"),
loop = call.argument<String>("loop"),
)
result.success(null)
}

View file

@ -0,0 +1,159 @@
package app.halkyon.train
import android.content.Context
import com.polar.androidcommunications.api.ble.model.DisInfo
import com.polar.androidcommunications.api.ble.model.gatt.client.ChargeState
import com.polar.androidcommunications.api.ble.model.gatt.client.PowerSourcesState
import com.polar.sdk.api.PolarBleApi
import com.polar.sdk.api.PolarBleApiCallbackProvider
import com.polar.sdk.api.PolarBleApiDefaultImpl
import com.polar.sdk.api.model.PolarDeviceInfo
import com.polar.sdk.api.model.PolarHealthThermometerData
import com.polar.sdk.api.model.PolarHrData
import java.util.UUID
/**
* Polar's SDK, one for the process (ADR 0030, D41): held by the open app's
* [LoopBridge] and by a recording reading the Loop's heart rate, made on the
* first hold and shut down as the last lets go, so that the band stays
* connected while a recording runs with the app gone. What the band tells
* goes to [listener], the open app's bridge.
*/
object PolarSdk {
private var api: PolarBleApi? = null
private val holders = mutableSetOf<Any>()
// What the SDK told of each band, for a bridge holding it after a
// recording did: its name while connected, and whether it is ready.
private val connected = mutableMapOf<String, String>()
private val ready = mutableSetOf<String>()
private val reading = mutableSetOf<String>()
/** Hears what the band tells; null while no app listens. */
@Volatile var listener: PolarBleApiCallbackProvider? = null
/** Whether anyone holds the SDK, so that the background job leaves the band alone. */
val held: Boolean
@Synchronized get() = holders.isNotEmpty()
/** The SDK, held by [holder] until it lets go. */
@Synchronized
fun hold(context: Context, holder: Any): PolarBleApi {
holders += holder
return api ?: made(context).also { api = it }
}
/** The band's name while it is connected; null while it isn't. */
@Synchronized fun connectedName(id: String): String? = connected[id]
@Synchronized fun isReady(id: String): Boolean = id in ready
/** Whether a recording reads the band [id], which the app then leaves connected. */
@Synchronized fun isRead(id: String): Boolean = id in reading
@Synchronized
fun reads(id: String, now: Boolean) {
if (now) reading += id else reading -= id
}
/** [holder] lets go; the last one shuts the SDK down. */
@Synchronized
fun release(holder: Any) {
if (holders.remove(holder) && holders.isEmpty()) {
api?.shutDown()
api = null
connected.clear()
ready.clear()
}
}
/** The feature a band is ready with: the clock and the first-time use go through its files. */
val READY_WITH = PolarBleApi.PolarBleSdkFeature.FEATURE_POLAR_DEVICE_TIME_SETUP
// Its telemetry stays off: FEATURE_TELEMETRY is never asked for.
private fun made(context: Context): PolarBleApi =
PolarBleApiDefaultImpl.defaultImplementation(
context.applicationContext,
setOf(
PolarBleApi.PolarBleSdkFeature.FEATURE_HR,
PolarBleApi.PolarBleSdkFeature.FEATURE_DEVICE_INFO,
PolarBleApi.PolarBleSdkFeature.FEATURE_BATTERY_INFO,
PolarBleApi.PolarBleSdkFeature.FEATURE_POLAR_DEVICE_TIME_SETUP,
PolarBleApi.PolarBleSdkFeature.FEATURE_POLAR_FILE_TRANSFER,
PolarBleApi.PolarBleSdkFeature.FEATURE_POLAR_DEVICE_CONTROL,
PolarBleApi.PolarBleSdkFeature.FEATURE_POLAR_ACTIVITY_DATA,
PolarBleApi.PolarBleSdkFeature.FEATURE_POLAR_SLEEP_DATA,
PolarBleApi.PolarBleSdkFeature.FEATURE_POLAR_TEMPERATURE_DATA,
PolarBleApi.PolarBleSdkFeature.FEATURE_POLAR_TRAINING_DATA,
PolarBleApi.PolarBleSdkFeature.FEATURE_POLAR_FIRMWARE_UPDATE,
),
).apply {
setAutomaticReconnection(true)
setApiCallback(forwarded)
}
private val forwarded =
object : PolarBleApiCallbackProvider {
override fun blePowerStateChanged(powered: Boolean) {
listener?.blePowerStateChanged(powered)
}
override fun deviceConnected(polarDeviceInfo: PolarDeviceInfo) {
synchronized(this@PolarSdk) { connected[polarDeviceInfo.deviceId] = polarDeviceInfo.name }
listener?.deviceConnected(polarDeviceInfo)
}
override fun deviceConnecting(polarDeviceInfo: PolarDeviceInfo) {
listener?.deviceConnecting(polarDeviceInfo)
}
override fun deviceDisconnected(polarDeviceInfo: PolarDeviceInfo) {
synchronized(this@PolarSdk) {
connected -= polarDeviceInfo.deviceId
ready -= polarDeviceInfo.deviceId
}
listener?.deviceDisconnected(polarDeviceInfo)
}
override fun bleSdkFeatureReady(identifier: String, feature: PolarBleApi.PolarBleSdkFeature) {
if (feature == READY_WITH) synchronized(this@PolarSdk) { ready += identifier }
listener?.bleSdkFeatureReady(identifier, feature)
}
override fun bleSdkFeaturesReadiness(
identifier: String,
ready: List<PolarBleApi.PolarBleSdkFeature>,
unavailable: List<PolarBleApi.PolarBleSdkFeature>,
) {
listener?.bleSdkFeaturesReadiness(identifier, ready, unavailable)
}
override fun disInformationReceived(identifier: String, uuid: UUID, value: String) {
listener?.disInformationReceived(identifier, uuid, value)
}
override fun disInformationReceived(identifier: String, disInfo: DisInfo) {
listener?.disInformationReceived(identifier, disInfo)
}
override fun batteryLevelReceived(identifier: String, level: Int) {
listener?.batteryLevelReceived(identifier, level)
}
override fun batteryChargingStatusReceived(identifier: String, chargingStatus: ChargeState) {
listener?.batteryChargingStatusReceived(identifier, chargingStatus)
}
override fun powerSourcesStateReceived(identifier: String, powerSourcesState: PowerSourcesState) {
listener?.powerSourcesStateReceived(identifier, powerSourcesState)
}
override fun hrNotificationReceived(identifier: String, data: PolarHrData.PolarHrSample) {
listener?.hrNotificationReceived(identifier, data)
}
override fun htsNotificationReceived(identifier: String, data: PolarHealthThermometerData) {
listener?.htsNotificationReceived(identifier, data)
}
}
}

View file

@ -28,6 +28,10 @@ import androidx.core.app.NotificationManagerCompat
import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat
import java.io.File
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.launch
import java.io.Writer
import kotlin.math.roundToLong
import org.json.JSONObject
@ -36,7 +40,8 @@ import org.json.JSONObject
// foreground service, started from the open app, that takes GPS fixes from
// Android's LocationManager (no Play services) and steps from the step
// detector, reads the heart-rate strap and the rower where the person chose
// them (D26 to D28), pauses by itself, and appends each sample as a line to the
// them (D26 to D28), and the Loop's heart rate where no strap gives one (D41),
// 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
@ -54,6 +59,7 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
private const val EXTRA_NOTICE = "notice"
private const val EXTRA_STRAP = "strap"
private const val EXTRA_ROWER = "rower"
private const val EXTRA_LOOP = "loop"
private const val CHANNEL = "recording"
private const val NOTIFICATION_ID = 5301
private const val FLUSH_EVERY_MS = 5_000L
@ -69,8 +75,9 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
/**
* Starts recording [activity] into the file at [path], pausing by itself
* by [autoPause] (null: never), with the strap at [strap] and the rower
* at [rower] (null: none), its notification as [notice] says.
* by [autoPause] (null: never), with the strap at [strap], the rower
* at [rower] and the Loop [loop] (null: none), its notification as
* [notice] says.
*/
fun start(
context: Context,
@ -80,6 +87,7 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
notice: String,
strap: String?,
rower: String?,
loop: String?,
) {
ContextCompat.startForegroundService(
context,
@ -90,7 +98,8 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
.putExtra(EXTRA_AUTO_PAUSE, autoPause)
.putExtra(EXTRA_NOTICE, notice)
.putExtra(EXTRA_STRAP, strap)
.putExtra(EXTRA_ROWER, rower),
.putExtra(EXTRA_ROWER, rower)
.putExtra(EXTRA_LOOP, loop),
)
}
@ -123,6 +132,12 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
private var strap: NotifyLink? = null
private var rower: NotifyLink? = null
/** The Loop read for its heart rate (D41), and whether the strap sends meanwhile. */
private var loopId: String? = null
private var loopReading: Job? = null
private var strapSends = false
private val scope: CoroutineScope = MainScope()
/** The rower's strokes and metres, from its first frame on (D28). */
private val strokes = Counted()
private val metres = Counted()
@ -172,6 +187,7 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
Build.VERSION.SDK_INT < Build.VERSION_CODES.S || granted(Manifest.permission.BLUETOOTH_CONNECT)
val strapAt = intent.getStringExtra(EXTRA_STRAP)?.takeIf { connects }
val rowerAt = intent.getStringExtra(EXTRA_ROWER)?.takeIf { connects }
val loopAt = intent.getStringExtra(EXTRA_LOOP)?.takeIf { connects }
val autoPauseBy = intent.getStringExtra(EXTRA_AUTO_PAUSE)
val stepping = autoPauseBy == "steps" && counted
notice = intent.getStringExtra(EXTRA_NOTICE)?.let(::JSONObject)
@ -181,7 +197,7 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
this,
NOTIFICATION_ID,
notification(),
types(located, stepping, linked = strapAt != null || rowerAt != null),
types(located, stepping, linked = strapAt != null || rowerAt != null || loopAt != null),
)
running = this
val now = System.currentTimeMillis()
@ -221,7 +237,10 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
HeartRateMeasurement.SERVICE,
HeartRateMeasurement.CHARACTERISTIC,
handler,
onState = { state -> write("strap ${System.currentTimeMillis()} $state") },
onState = { state ->
strapSends = state == "connected"
write("strap ${System.currentTimeMillis()} $state")
},
onValue = { value ->
HeartRateMeasurement.bpmOf(value)?.let { bpm -> write("h ${System.currentTimeMillis()} $bpm") }
},
@ -239,10 +258,33 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
onValue = { value -> RowerData.of(value)?.let(::rowed) },
).also { it.open() }
}
loopAt?.let(::readLoop)
lastFlush = now
handler.postDelayed(ticker, 1_000)
}
/**
* Reads the Loop [id]'s heart rate through Polar's SDK, held for the
* recording so that the band stays connected with the app gone (D41); a
* rate is kept only while no strap sends.
*/
private fun readLoop(id: String) {
val api = PolarSdk.hold(this, this)
PolarSdk.reads(id, now = true)
loopId = id
loopReading =
scope.launch {
if (PolarSdk.connectedName(id) == null) {
runCatching { api.connectToDevice(id) }
}
LoopHeart(
stream = { api.startHrStreaming(id) },
onState = { state -> write("loop ${System.currentTimeMillis()} $state") },
onBpm = { bpm -> if (!strapSends) write("h ${System.currentTimeMillis()} $bpm") },
).run()
}
}
/**
* The foreground service's types: location with GPS, health with the step
* detector, connectedDevice with the strap or the rower (Android 14 on asks).
@ -276,7 +318,7 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
data.metres?.let { metres.add(it) }
fun field(value: Any?) = value?.toString() ?: "-"
write("r $at ${field(data.strokeRate)} ${field(strokes.total)} ${field(metres.total)} ${field(data.watts)}")
if (strap == null) data.bpm?.let { write("h $at $it") }
if (strap == null && loopId == null) data.bpm?.let { write("h $at $it") }
}
/** A console's running total, counted from the first reading. */
@ -358,6 +400,11 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
strap = null
rower?.close()
rower = null
loopReading?.cancel()
loopReading = null
loopId?.let { PolarSdk.reads(it, now = false) }
if (loopId != null) PolarSdk.release(this)
loopId = null
write("end ${System.currentTimeMillis()}")
writer?.close()
writer = null

View file

@ -0,0 +1,52 @@
package app.halkyon.train
import com.polar.sdk.api.model.PolarHrData
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeoutOrNull
import org.junit.Assert.assertEquals
import org.junit.Test
/** The Loop's heart rate in a recording (ADR 0030, D41). */
class LoopHeartTest {
private fun sample(hr: Int, contact: Boolean = true, supported: Boolean = true) =
PolarHrData.PolarHrSample(hr, hr, 0, emptyList(), emptyList(), false, contact, supported)
@Test
fun `a rate read on the skin is kept, none or off the skin is not`() {
assertEquals(142, LoopHeart.bpmOf(sample(142)))
assertEquals(null, LoopHeart.bpmOf(sample(0)))
assertEquals(null, LoopHeart.bpmOf(sample(90, contact = false)))
assertEquals(90, LoopHeart.bpmOf(sample(90, contact = false, supported = false)))
}
@Test
fun `a stream failing, then sending, then stopping is told as a strap's, and asked for again`() {
val told = mutableListOf<String>()
var asked = 0
val streams: List<Flow<PolarHrData>> =
listOf(
flow { throw IllegalStateException("not ready") },
flow {
emit(PolarHrData(listOf(sample(120), sample(0))))
emit(PolarHrData(listOf(sample(121))))
},
flow { emit(PolarHrData(listOf(sample(125)))) },
)
val heart =
LoopHeart(
stream = { streams.getOrElse(asked++) { flow { throw IllegalStateException("gone") } } },
onState = { told += it },
onBpm = { told += "h $it" },
againAfterMs = 1,
)
runBlocking { withTimeoutOrNull(200) { heart.run() } }
assertEquals(
listOf("connecting", "connected", "h 120", "h 121", "lost", "connected", "h 125", "lost"),
told,
)
}
}

View file

@ -88,6 +88,8 @@
"recordHeartRate": "Herzfrequenz",
"recordNoStrap": "Kein Brustgurt",
"recordHeartLoop": "Vom Polar Loop",
"recordHeartStrapThenLoop": "{strap}, der Loop, solange er nicht verbunden ist",
"strapSheetTitle": "Brustgurt",
"strapSearching": "Suche Brustgurte in der Nähe …",
"strapSearchingHint": "Leg den Gurt an: Die meisten wachen erst auf, wenn sie die Haut berühren.",

View file

@ -110,6 +110,9 @@
"recordHeartRate": "Heart rate",
"recordNoStrap": "No strap",
"recordHeartLoop": "From the Polar Loop",
"recordHeartStrapThenLoop": "{strap}, the Loop while it is lost",
"@recordHeartStrapThenLoop": {"placeholders": {"strap": {"type": "String", "example": "Polar H10"}}},
"@recordNoStrap": {"description": "On the Record sheet, under Heart rate, where no strap is chosen."},
"strapSheetTitle": "Heart-rate strap",
"strapSearching": "Looking for straps nearby…",

View file

@ -580,6 +580,18 @@ abstract class AppLocalizations {
/// **'No strap'**
String get recordNoStrap;
/// No description provided for @recordHeartLoop.
///
/// In en, this message translates to:
/// **'From the Polar Loop'**
String get recordHeartLoop;
/// No description provided for @recordHeartStrapThenLoop.
///
/// In en, this message translates to:
/// **'{strap}, the Loop while it is lost'**
String recordHeartStrapThenLoop(String strap);
/// No description provided for @strapSheetTitle.
///
/// In en, this message translates to:

View file

@ -311,6 +311,14 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get recordNoStrap => 'Kein Brustgurt';
@override
String get recordHeartLoop => 'Vom Polar Loop';
@override
String recordHeartStrapThenLoop(String strap) {
return '$strap, der Loop, solange er nicht verbunden ist';
}
@override
String get strapSheetTitle => 'Brustgurt';

View file

@ -308,6 +308,14 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get recordNoStrap => 'No strap';
@override
String get recordHeartLoop => 'From the Polar Loop';
@override
String recordHeartStrapThenLoop(String strap) {
return '$strap, the Loop while it is lost';
}
@override
String get strapSheetTitle => 'Heart-rate strap';

View file

@ -124,6 +124,7 @@ class const _Recording({required final Widget child}) extends StatelessWidget {
Localizations.localeOf(context).toString(),
live,
),
loop: () => train.loop?.status.loop?.id,
)..add(const RecordingEventOpened()),
child: child,
);

View file

@ -22,7 +22,8 @@ import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
/// app was gone, the service not), one cut off, or one stopped and not saved.
/// It remembers the person's heart-rate strap, which a recording uses unless
/// it is switched off for it (D27), and their rower, which a row needs
/// (D28).
/// (D28); their Loop, where they have one, gives the heart rate while no
/// strap does (D41).
///
/// The recorder decides the pauses (D16); this works out what the screen and
/// the notification show from the same lines the file holds.
@ -42,6 +43,9 @@ class RecordingBloc(
/// A second passing, while it records; every second by default.
Stream<void>? ticks,
/// The person's Loop as a recording starts; null where they have none.
final String? Function()? loop,
}) extends Bloc<RecordingEvent, RecordingState> {
this : super(const RecordingState()) {
on<RecordingEventOpened>(_onOpened);
@ -181,6 +185,7 @@ class RecordingBloc(
notice: notice,
strap: event.useStrap ? state.strap?.address : null,
rower: rows ? rower?.address : null,
loop: loop?.call(),
);
_shown = _keyOf(notice);
_follow();

View file

@ -36,6 +36,7 @@ class AndroidRecorderPlatform() implements RecorderPlatform {
required RecorderNotice notice,
String? strap,
String? rower,
String? loop,
}) => _channel.invokeMethod<void>('start', {
'path': path,
'activity': activity.name,
@ -43,6 +44,7 @@ class AndroidRecorderPlatform() implements RecorderPlatform {
'notice': noticeJson(notice),
'strap': strap,
'rower': rower,
'loop': loop,
});
@override

View file

@ -49,6 +49,7 @@ LiveRecording liveOf(Recording recording, DateTime now) {
};
final lastFix = recording.fixes.lastOrNull;
final strap = recording.straps.lastOrNull?.state;
final loop = recording.loops.lastOrNull?.state;
final rate = recording.heartRates.lastOrNull;
final rows = recording.rowing.isNotEmpty || recording.rowers.isNotEmpty;
final lastRowing = recording.rowing.lastOrNull;
@ -69,8 +70,9 @@ LiveRecording liveOf(Recording recording, DateTime now) {
paused: paused,
autoPaused: paused && last!.auto,
strap: strap,
loop: loop,
bpm:
strap == LinkState.connected &&
(strap == LinkState.connected || loop == LinkState.connected) &&
rate != null &&
now.difference(rate.time) <= freshHeartRate
? rate.bpm

View file

@ -99,6 +99,9 @@ abstract interface class RecorderPlatform() {
required RecorderNotice notice,
String? strap,
String? rower,
/// The Loop whose heart rate is kept while no strap sends (D41).
String? loop,
});
/// Pauses, or goes on, by hand.

View file

@ -22,6 +22,7 @@ const recordingFileMagic = 'halkyon-train-recording';
/// strap <time> connecting|connected|lost
/// r <time> <stroke rate or -> <strokes or -> <metres or -> <watts or ->
/// rower <time> connecting|connected|lost
/// loop <time> connecting|connected|lost
/// end <time>
/// ```
///
@ -54,6 +55,7 @@ Recording? readRecording(String text) {
final steps = <DateTime>[];
final pauses = <PauseMark>[];
final straps = <LinkMark>[];
final loops = <LinkMark>[];
final rowing = <RowingSample>[];
final rowers = <LinkMark>[];
DateTime? endedAt;
@ -94,11 +96,13 @@ Recording? readRecording(String text) {
PauseMark(time: time, paused: mark == 'pause', auto: by == 'auto'),
);
case [final link, _, final name]
when link == 'strap' || link == 'rower':
when link == 'strap' || link == 'rower' || link == 'loop':
if (LinkState.values.asNameMap()[name] case final state?) {
(link == 'strap' ? straps : rowers).add(
LinkMark(time: time, state: state),
);
(switch (link) {
'strap' => straps,
'loop' => loops,
_ => rowers,
}).add(LinkMark(time: time, state: state));
}
case ['r', _, final rate, final strokes, final metres, final watts]:
rowing.add(
@ -122,6 +126,7 @@ Recording? readRecording(String text) {
steps: steps,
pauses: pauses,
straps: straps,
loops: loops,
rowing: rowing,
rowers: rowers,
endedAt: endedAt,

View file

@ -32,7 +32,11 @@ final class const LiveRecording({
/// The strap's state; null where it records without one (D27).
final LinkState? strap,
/// The heart's rate now, from a connected strap's last few seconds.
/// The Loop's state; null where it records without one (D41).
final LinkState? loop,
/// The heart's rate now, from a connected strap's or Loop's last few
/// seconds.
final int? bpm,
/// The rower's state; null where it records without one (D28).
@ -56,6 +60,7 @@ final class const LiveRecording({
paused,
autoPaused,
strap,
loop,
bpm,
rower,
split,

View file

@ -44,7 +44,8 @@ final class const RowingSample({
List<Object?> get props => [time, strokeRate, strokes, metres, watts];
}
/// Where the strap or the rower stands (D27, D28), as the recorder sees it.
/// Where the strap, the rower or the Loop stands (D27, D28, D41), as the
/// recorder sees it.
enum LinkState() {
/// Asked for, not answering yet: at the start, and out of reach.
connecting,
@ -96,6 +97,9 @@ final class const Recording({
/// The strap's states, where it was recorded with one (D27).
final List<LinkMark> straps = const [],
/// The Loop's states, where its heart rate was read (D41).
final List<LinkMark> loops = const [],
/// The rower's frames and states (D28).
final List<RowingSample> rowing = const [],
final List<LinkMark> rowers = const [],
@ -126,6 +130,7 @@ final class const Recording({
steps,
pauses,
straps,
loops,
rowing,
rowers,
endedAt,

Some files were not shown because too many files have changed in this diff Show more