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:
parent
388b3b1ae6
commit
fa7680821d
29 changed files with 577 additions and 72 deletions
|
|
@ -7,6 +7,6 @@ What holds for every app and package of this repository is in the [root AGENTS.m
|
||||||
- **The app** is halkyon_app's `InstanceGate` and `SessionGate` with Train's frame (`trainFrame`: "Train" under "Halkyon", its icon, `train.example.org`, its own sentence) and `ServerpodInstanceChecker(app: 'train')`; `TrainDependencies` (`AppDependencies<TrainPerson>`) on one instance, `TrainPerson` per person: their own sync database in their folder (`sync.sqlite`, halkyon_app's `PersonFolders`, one person's at a time), halkyon_app's `SyncService` behind `AppCompatibility` (the module's basics' `minimumAppBuild`, against the build `package_info_plus` reads), their key (`TrainKeys`, `lib/src/core/privacy/`: one key per person, IDs `train1.<ms>.<nonce>`, the oldest-made writing, kept in `content_key` and opened only in Train's form, ADR 0030, D15) and their workouts (`WorkoutRepository`, `lib/src/feature/workout/`: halkyon_app's `PayloadEnvelope` seals a workout for its row and a series chunk for its row and its workout; payloads are versioned JSON, a series' columns delta-coded, gzipped before sealing; a deletion blanks every payload first); the gateways' tests run on a sync database opened without a server, the repository's on memory gateways and real libsodium; a person's app whose home lists their workouts (`WorkoutsCubit`, with `TrainPerson.deletions`, halkyon_app's `DelayedDeletions`) and opens each one's page (`WorkoutCubit`: `analyse` in `workout_analysis.dart` reads its series into the track's pieces, splits, elevation and charts, drawn by `workout_chart.dart`; the map with flutter_map on the instance's source, `MapSourceRepository` asking `map.source`, its tiles from `TrainPerson.mapTiles`, vector_map_tiles in the app and none in widget tests), and the account sheet: halkyon_app's `AccountTiles` (whose account on which instance, Switch account, Sign out, Delete account, which asks first) in Train's words, and halkyon_app's About (`showAppAbout`: the source of what the instance runs, from `TrainDependencies.sourceCodeUrl`, and the licence list). Texts through gen-l10n in British English (the template), German (informal du), an empty `en_US` and `en_GB` (`test/l10n/arb_files_test.dart` keeps them in step). The look follows the system's theme mode and language until Train has settings. Countries in where an instance keeps its data are named by halkyon_app's CLDR names (`AssetCountryNames`), its language picked by halkyon_app's `resolveAppLocale`. Widget tests start Train's whole tree with halkyon_app's fakes (`test/app/train_app_test.dart`).
|
- **The app** is halkyon_app's `InstanceGate` and `SessionGate` with Train's frame (`trainFrame`: "Train" under "Halkyon", its icon, `train.example.org`, its own sentence) and `ServerpodInstanceChecker(app: 'train')`; `TrainDependencies` (`AppDependencies<TrainPerson>`) on one instance, `TrainPerson` per person: their own sync database in their folder (`sync.sqlite`, halkyon_app's `PersonFolders`, one person's at a time), halkyon_app's `SyncService` behind `AppCompatibility` (the module's basics' `minimumAppBuild`, against the build `package_info_plus` reads), their key (`TrainKeys`, `lib/src/core/privacy/`: one key per person, IDs `train1.<ms>.<nonce>`, the oldest-made writing, kept in `content_key` and opened only in Train's form, ADR 0030, D15) and their workouts (`WorkoutRepository`, `lib/src/feature/workout/`: halkyon_app's `PayloadEnvelope` seals a workout for its row and a series chunk for its row and its workout; payloads are versioned JSON, a series' columns delta-coded, gzipped before sealing; a deletion blanks every payload first); the gateways' tests run on a sync database opened without a server, the repository's on memory gateways and real libsodium; a person's app whose home lists their workouts (`WorkoutsCubit`, with `TrainPerson.deletions`, halkyon_app's `DelayedDeletions`) and opens each one's page (`WorkoutCubit`: `analyse` in `workout_analysis.dart` reads its series into the track's pieces, splits, elevation and charts, drawn by `workout_chart.dart`; the map with flutter_map on the instance's source, `MapSourceRepository` asking `map.source`, its tiles from `TrainPerson.mapTiles`, vector_map_tiles in the app and none in widget tests), and the account sheet: halkyon_app's `AccountTiles` (whose account on which instance, Switch account, Sign out, Delete account, which asks first) in Train's words, and halkyon_app's About (`showAppAbout`: the source of what the instance runs, from `TrainDependencies.sourceCodeUrl`, and the licence list). Texts through gen-l10n in British English (the template), German (informal du), an empty `en_US` and `en_GB` (`test/l10n/arb_files_test.dart` keeps them in step). The look follows the system's theme mode and language until Train has settings. Countries in where an instance keeps its data are named by halkyon_app's CLDR names (`AssetCountryNames`), its language picked by halkyon_app's `resolveAppLocale`. Widget tests start Train's whole tree with halkyon_app's fakes (`test/app/train_app_test.dart`).
|
||||||
- **Recording** (ADR 0030, D13, D16, D18 to D21, D26 to D28) is Android's alone for now (iOS waits for a Mac, D25) (`TrainPerson.recording` null elsewhere): `RecorderService` (Kotlin, `android/app/src/main/kotlin/app/halkyon/train/`), a foreground service of the types `location` and `health` started from the open app, takes fixes from LocationManager's GPS (no Play services), steps from the step detector the heart rate from the strap and a row from the rower the person chose (ADR 0030, D26 to D28: `NotifyLink.kt` on Android's own Bluetooth API, no package, `HeartRateMeasurement.kt` parsing 0x2A37 and `RowerData.kt` FTMS's Rower Data 0x2AD1; `DeviceScanner.kt` finds them for the sheets over `…/recorder/straps` and `…/recorder/rowers`; each remembered in the person's `strap.json` and `rower.json`, `DeviceStore`), decides auto-pause (`AutoPause.kt`) and appends each sample as a line to the person's `recording.txt` (format in `recording_file.dart`), flushed every 5 s; `MainActivity` asks the permissions (never background location) and bridges `app.halkyon.train/recorder` (methods) and `…/recorder/lines` (the file so far, then each line). The app (`lib/src/feature/recording/`) follows those lines with `RecordingBloc`, works out what the screen and the notification show (`liveOf`, the saved totals' functions), and saves a recording as a workout and its series (`recordedWorkout`) or throws it away; one cut off, or stopped and not saved, is found as the person's app opens. Tests: the Dart side with `test/support/fake_recorder_platform.dart`; the Kotlin auto-pause and the strap's and the rower's parsers on the JVM, `JAVA_HOME=<Android Studio's jbr> ./gradlew :app:testDebugUnitTest` in `android/` after a first `flutter build apk` (which writes `gradlew`), not part of `tool/check.dart`, as CI's runner has no Android SDK.
|
- **Recording** (ADR 0030, D13, D16, D18 to D21, D26 to D28) is Android's alone for now (iOS waits for a Mac, D25) (`TrainPerson.recording` null elsewhere): `RecorderService` (Kotlin, `android/app/src/main/kotlin/app/halkyon/train/`), a foreground service of the types `location` and `health` started from the open app, takes fixes from LocationManager's GPS (no Play services), steps from the step detector the heart rate from the strap and a row from the rower the person chose (ADR 0030, D26 to D28: `NotifyLink.kt` on Android's own Bluetooth API, no package, `HeartRateMeasurement.kt` parsing 0x2A37 and `RowerData.kt` FTMS's Rower Data 0x2AD1; `DeviceScanner.kt` finds them for the sheets over `…/recorder/straps` and `…/recorder/rowers`; each remembered in the person's `strap.json` and `rower.json`, `DeviceStore`), decides auto-pause (`AutoPause.kt`) and appends each sample as a line to the person's `recording.txt` (format in `recording_file.dart`), flushed every 5 s; `MainActivity` asks the permissions (never background location) and bridges `app.halkyon.train/recorder` (methods) and `…/recorder/lines` (the file so far, then each line). The app (`lib/src/feature/recording/`) follows those lines with `RecordingBloc`, works out what the screen and the notification show (`liveOf`, the saved totals' functions), and saves a recording as a workout and its series (`recordedWorkout`) or throws it away; one cut off, or stopped and not saved, is found as the person's app opens. Tests: the Dart side with `test/support/fake_recorder_platform.dart`; the Kotlin auto-pause and the strap's and the rower's parsers on the JVM, `JAVA_HOME=<Android Studio's jbr> ./gradlew :app:testDebugUnitTest` in `android/` after a first `flutter build apk` (which writes `gradlew`), not part of `tool/check.dart`, as CI's runner has no Android SDK.
|
||||||
- **Health Connect** (ADR 0030, D30, D31) is Android's (`TrainPerson.health` null elsewhere): `HealthConnectBridge.kt` on AndroidX's `connect-client` 1.1.0 over `app.halkyon.train/health` (its records' mapping in `HealthRecords.kt`, tested on the JVM; the rationale's links from Health Connect in the manifest, `…/health/rationale` to the app), `HealthPlatform` in Dart (`lib/src/feature/health/`), `HealthConnect` per person: with writing on, every workout written as `healthRecordsOf` makes it (client IDs from the workout's ID, a version counted up as it changes, a fingerprint deciding whether it did), written again or deleted as the workouts change and as the app opens, what was written kept in the person's `health_connect.json` (`HealthStore`, never synced); with reading on, the sessions of the apps the person turned on brought in as workouts through the changes token (`importedWorkout` in `health_import.dart`, the workout's `origin` in its payload; a double of one Train has brings at most its heart rate, `isDouble`; one deleted in Train is remembered so it stays out; a track only when asked for on its page, `bringInTrack`), as the app opens and comes to the front (`HealthConnectListener`, which opens the rationale too); the screen from the account sheet (`HealthScreen`, `HealthCubit`). Tests on `test/support/fake_health_platform.dart`. Train needs Android 8 (API 26) for it.
|
- **Health Connect** (ADR 0030, D30, D31) is Android's (`TrainPerson.health` null elsewhere): `HealthConnectBridge.kt` on AndroidX's `connect-client` 1.1.0 over `app.halkyon.train/health` (its records' mapping in `HealthRecords.kt`, tested on the JVM; the rationale's links from Health Connect in the manifest, `…/health/rationale` to the app), `HealthPlatform` in Dart (`lib/src/feature/health/`), `HealthConnect` per person: with writing on, every workout written as `healthRecordsOf` makes it (client IDs from the workout's ID, a version counted up as it changes, a fingerprint deciding whether it did), written again or deleted as the workouts change and as the app opens, what was written kept in the person's `health_connect.json` (`HealthStore`, never synced); with reading on, the sessions of the apps the person turned on brought in as workouts through the changes token (`importedWorkout` in `health_import.dart`, the workout's `origin` in its payload; a double of one Train has brings at most its heart rate, `isDouble`; one deleted in Train is remembered so it stays out; a track only when asked for on its page, `bringInTrack`), as the app opens and comes to the front (`HealthConnectListener`, which opens the rationale too); the screen from the account sheet (`HealthScreen`, `HealthCubit`). Tests on `test/support/fake_health_platform.dart`. Train needs Android 8 (API 26) for it.
|
||||||
- **The Polar Loop** (ADR 0030, D32 to D36) is Android's (`TrainPerson.loop` null elsewhere): `LoopBridge.kt` on Polar's BLE SDK 8.3.0 (from JitPack, a repository asked for Polar's group alone, its files pinned by SHA-256 in `android/gradle/verification-metadata.xml`; a new version needs its checksums there) over `app.halkyon.train/loop`, `…/loop/state` and `…/loop/search`, the first-time use's figures mapped and checked in `LoopSetup.kt` (tested on the JVM); `LoopPlatform` in Dart (`lib/src/feature/loop/`), `PolarLoop` per person: the band remembered in the person's `loop.json` (`LoopStore`, never synced) with what it was set up with and what it told, connected while the app is open, its clock set as each connection is ready, a changed profile offered on the band's screen and sent only when the person says (D35), *Let the band go* a factory reset after asking; its days and nights (D37, D38, `lib/src/feature/day/`) read as each connection is ready (every half hour at most, or when the person pulls *Days*) and by a WorkManager job every four hours while the app is closed (`LoopWorker.kt`, into the person's `loop_days.txt`, which the app takes in as it opens, `LoopLines`), `LoopDays.kt` turning the SDK's answers into lines of JSON (tested on the JVM), `DayRepository` sealing them as `day`, `night` and `day_series` rows (payloads in `day_payload.dart`), and what is sealed, up to yesterday, deleted on the band by the next read (`KnownLoop.keptUntil`), never today; its training sessions (D39) read with the days and kept as workouts of origin *Polar Loop* (`loop_workouts.dart`: `takeLoopSessions`, Polar's sport 1 and 2 as running and cycling, the rest as `Activity.other`, which the edit sheet changes; a double of a recorded workout gives it its heart rate), remembered in `loop.json` (`takenSessions`), written to Health Connect as Train's own; its firmware (D10, D40) asked for on the day's first connection and on the band's screen, from Polar directly (the SDK) or through the instance as the person chose (`KnownLoop.firmwareVia`, `FirmwareRelayGateway` on the server's `firmware` endpoint), updated only after asking, the band read and kept first and set up again after (`PolarLoop.updateFirmware`, `LoopFirmware.kt`); home's *Days* tab and a day's page (`DaysCubit`, `DayCubit`); `loopFiguresOf` fills Polar's defaults for the band alone (`loop_figures.dart`), the setup sheet asks for what the profile lacks with `ProfileForm(only: …)`. The person's body's figures are the synced, sealed `profile` (D33, *Your body*, `lib/src/feature/profile/`). Polar's SDK isn't free software (D36): the app's AGPL carries a section 7 permission for it (`halkyon_train_flutter/LICENSE-polar-exception.md`), its notices are in the licence list (`registerPolarLicense`, on Android), and an F-Droid listing of Train declares `NonFreeDep`. Never ask the SDK for `FEATURE_TELEMETRY`. Tests on `test/support/fake_loop_platform.dart`.
|
- **The Polar Loop** (ADR 0030, D32 to D36) is Android's (`TrainPerson.loop` null elsewhere): `LoopBridge.kt` on Polar's BLE SDK 8.3.0 (from JitPack, a repository asked for Polar's group alone, its files pinned by SHA-256 in `android/gradle/verification-metadata.xml`; a new version needs its checksums there) over `app.halkyon.train/loop`, `…/loop/state` and `…/loop/search`, the first-time use's figures mapped and checked in `LoopSetup.kt` (tested on the JVM); `LoopPlatform` in Dart (`lib/src/feature/loop/`), `PolarLoop` per person: the band remembered in the person's `loop.json` (`LoopStore`, never synced) with what it was set up with and what it told, connected while the app is open, its clock set as each connection is ready, a changed profile offered on the band's screen and sent only when the person says (D35), *Let the band go* a factory reset after asking; its days and nights (D37, D38, `lib/src/feature/day/`) read as each connection is ready (every half hour at most, or when the person pulls *Days*) and by a WorkManager job every four hours while the app is closed (`LoopWorker.kt`, into the person's `loop_days.txt`, which the app takes in as it opens, `LoopLines`), `LoopDays.kt` turning the SDK's answers into lines of JSON (tested on the JVM), `DayRepository` sealing them as `day`, `night` and `day_series` rows (payloads in `day_payload.dart`), and what is sealed, up to yesterday, deleted on the band by the next read (`KnownLoop.keptUntil`), never today; its training sessions (D39) read with the days and kept as workouts of origin *Polar Loop* (`loop_workouts.dart`: `takeLoopSessions`, Polar's sport 1 and 2 as running and cycling, the rest as `Activity.other`, which the edit sheet changes; a double of a recorded workout gives it its heart rate), remembered in `loop.json` (`takenSessions`), written to Health Connect as Train's own; its firmware (D10, D40) asked for on the day's first connection and on the band's screen, from Polar directly (the SDK) or through the instance as the person chose (`KnownLoop.firmwareVia`, `FirmwareRelayGateway` on the server's `firmware` endpoint), updated only after asking, the band read and kept first and set up again after (`PolarLoop.updateFirmware`, `LoopFirmware.kt`); its live heart rate (D41) read by the recording service through the same SDK (`PolarSdk.kt`, one for the process, held by the bridge and by a recording; `LoopHeart.kt`) while no strap sends, the file's `loop` lines its state; home's *Days* tab and a day's page (`DaysCubit`, `DayCubit`); `loopFiguresOf` fills Polar's defaults for the band alone (`loop_figures.dart`), the setup sheet asks for what the profile lacks with `ProfileForm(only: …)`. The person's body's figures are the synced, sealed `profile` (D33, *Your body*, `lib/src/feature/profile/`). Polar's SDK isn't free software (D36): the app's AGPL carries a section 7 permission for it (`halkyon_train_flutter/LICENSE-polar-exception.md`), its notices are in the licence list (`registerPolarLicense`, on Android), and an F-Droid listing of Train declares `NonFreeDep`. Never ask the SDK for `FEATURE_TELEMETRY`. Tests on `test/support/fake_loop_platform.dart`.
|
||||||
- **The icon** is Lucide's `bike` fading from cobalt through violet and strawberry into carrot (ADR 0031), drawn with the other apps' by the repository's `tool/app_icons.py` (`python3 -I tool/app_icons.py train` from the root): `assets/icon/halkyon_train_icon.png`, Lucide's ISC licence beside it, and every platform's launcher icon through flutter_launcher_icons. `registerLucideLicense` puts Lucide's notice in the licence list.
|
- **The icon** is Lucide's `bike` fading from cobalt through violet and strawberry into carrot (ADR 0031), drawn with the other apps' by the repository's `tool/app_icons.py` (`python3 -I tool/app_icons.py train` from the root): `assets/icon/halkyon_train_icon.png`, Lucide's ISC licence beside it, and every platform's launcher icon through flutter_launcher_icons. `registerLucideLicense` puts Lucide's notice in the licence list.
|
||||||
- **`serverpod start`** runs in `apps/train/halkyon_train_server` (it launches the app from `lib/driver.dart`); `.mcp.json`'s `--server-dir` names it when a session works on Train. Its tests need throwaway passwords in its git-ignored `config/passwords.yaml` (`database`, `serviceSecret`, `serverSideSessionKeyHashPepper`, `emailSignInPepper`) or from the environment, as CI gives them. Train's development server has ports of its own (API 8180, Insights 8181, web 8182, PostgreSQL 8190; the app's `assets/config.json`), so it runs beside Learn's, as a member of Learn's register; its tests use `config/test.yaml`'s ports, as Learn's and Todo's do, which Melos runs one package at a time.
|
- **`serverpod start`** runs in `apps/train/halkyon_train_server` (it launches the app from `lib/driver.dart`); `.mcp.json`'s `--server-dir` names it when a session works on Train. Its tests need throwaway passwords in its git-ignored `config/passwords.yaml` (`database`, `serviceSecret`, `serverSideSessionKeyHashPepper`, `emailSignInPepper`) or from the environment, as CI gives them. Train's development server has ports of its own (API 8180, Insights 8181, web 8182, PostgreSQL 8190; the app's `assets/config.json`), so it runs beside Learn's, as a member of Learn's register; its tests use `config/test.yaml`'s ports, as Learn's and Todo's do, which Melos runs one package at a time.
|
||||||
|
|
|
||||||
|
|
@ -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.androidcommunications.api.ble.model.gatt.client.PowerSourcesState
|
||||||
import com.polar.sdk.api.PolarBleApi
|
import com.polar.sdk.api.PolarBleApi
|
||||||
import com.polar.sdk.api.PolarBleApiCallbackProvider
|
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.FirmwareUpdateStatus
|
||||||
import com.polar.sdk.api.model.PolarDeviceInfo
|
import com.polar.sdk.api.model.PolarDeviceInfo
|
||||||
import com.polar.sdk.api.model.PolarDeviceUuid
|
import com.polar.sdk.api.model.PolarDeviceUuid
|
||||||
|
|
@ -40,28 +39,11 @@ class LoopBridge(private val context: Context) : MethodChannel.MethodCallHandler
|
||||||
private val calls = Mutex()
|
private val calls = Mutex()
|
||||||
private var events: EventChannel.EventSink? = null
|
private var events: EventChannel.EventSink? = null
|
||||||
|
|
||||||
// The SDK made on first use, so that an app without a Loop never starts it.
|
// The SDK held on first use, so that an app without a Loop never starts
|
||||||
// Its telemetry stays off: FEATURE_TELEMETRY is never asked for.
|
// it; shared with a recording (D41), which may hold it on.
|
||||||
private val made = lazy {
|
private val made = lazy {
|
||||||
PolarBleApiDefaultImpl.defaultImplementation(
|
PolarSdk.listener = callback
|
||||||
context.applicationContext,
|
PolarSdk.hold(context, this)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
private val api: PolarBleApi by made
|
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.
|
// One call to the band at a time, as Polar's sync guideline asks.
|
||||||
calls.withLock {
|
calls.withLock {
|
||||||
when (call.method) {
|
when (call.method) {
|
||||||
"connect" -> result.success(api.connectToDevice(id).let { null })
|
"connect" -> {
|
||||||
"disconnect" -> result.success(api.disconnectFromDevice(id).let { null })
|
// 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))
|
"isSetUp" -> result.success(api.isFtuDone(id))
|
||||||
"setUp" -> {
|
"setUp" -> {
|
||||||
val config = LoopSetup.configOf(call.argument<Map<String, Any?>>("figures")!!, ZonedDateTime.now())
|
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()
|
scope.cancel()
|
||||||
events = null
|
events = null
|
||||||
if (made.isInitialized()) {
|
if (made.isInitialized()) {
|
||||||
api.shutDown()
|
if (PolarSdk.listener === callback) PolarSdk.listener = null
|
||||||
holding = false
|
PolarSdk.release(this)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -199,8 +194,7 @@ class LoopBridge(private val context: Context) : MethodChannel.MethodCallHandler
|
||||||
send(polarDeviceInfo.deviceId, "disconnected")
|
send(polarDeviceInfo.deviceId, "disconnected")
|
||||||
|
|
||||||
override fun bleSdkFeatureReady(identifier: String, feature: PolarBleApi.PolarBleSdkFeature) {
|
override fun bleSdkFeatureReady(identifier: String, feature: PolarBleApi.PolarBleSdkFeature) {
|
||||||
// The clock and the first-time use go through the band's files.
|
if (feature == PolarSdk.READY_WITH) send(identifier, "ready")
|
||||||
if (feature == PolarBleApi.PolarBleSdkFeature.FEATURE_POLAR_DEVICE_TIME_SETUP) send(identifier, "ready")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun bleSdkFeaturesReadiness(
|
override fun bleSdkFeaturesReadiness(
|
||||||
|
|
@ -229,10 +223,6 @@ class LoopBridge(private val context: Context) : MethodChannel.MethodCallHandler
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
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). */
|
/** Device Information's Firmware Revision String (0x2A26). */
|
||||||
private val FIRMWARE_REVISION: UUID = UUID.fromString("00002a26-0000-1000-8000-00805f9b34fb")
|
private val FIRMWARE_REVISION: UUID = UUID.fromString("00002a26-0000-1000-8000-00805f9b34fb")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -32,11 +32,11 @@ import org.json.JSONObject
|
||||||
* person's `loop_days.txt` (a line of JSON each, as [LoopDays] makes them),
|
* 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
|
* 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.
|
* 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) {
|
class LoopWorker(context: Context, parameters: WorkerParameters) : CoroutineWorker(context, parameters) {
|
||||||
override suspend fun doWork(): Result {
|
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 config = configOf(applicationContext) ?: return Result.success()
|
||||||
val ready = CompletableDeferred<Unit>()
|
val ready = CompletableDeferred<Unit>()
|
||||||
val api =
|
val api =
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,7 @@ class MainActivity : FlutterActivity() {
|
||||||
notice = call.argument<String>("notice")!!,
|
notice = call.argument<String>("notice")!!,
|
||||||
strap = call.argument<String>("strap"),
|
strap = call.argument<String>("strap"),
|
||||||
rower = call.argument<String>("rower"),
|
rower = call.argument<String>("rower"),
|
||||||
|
loop = call.argument<String>("loop"),
|
||||||
)
|
)
|
||||||
result.success(null)
|
result.success(null)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -28,6 +28,10 @@ import androidx.core.app.NotificationManagerCompat
|
||||||
import androidx.core.app.ServiceCompat
|
import androidx.core.app.ServiceCompat
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import java.io.File
|
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 java.io.Writer
|
||||||
import kotlin.math.roundToLong
|
import kotlin.math.roundToLong
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
|
|
@ -36,7 +40,8 @@ import org.json.JSONObject
|
||||||
// foreground service, started from the open app, that takes GPS fixes from
|
// foreground service, started from the open app, that takes GPS fixes from
|
||||||
// Android's LocationManager (no Play services) and steps from the step
|
// Android's LocationManager (no Play services) and steps from the step
|
||||||
// detector, reads the heart-rate strap and the rower where the person chose
|
// 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
|
// 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
|
// 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
|
// 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_NOTICE = "notice"
|
||||||
private const val EXTRA_STRAP = "strap"
|
private const val EXTRA_STRAP = "strap"
|
||||||
private const val EXTRA_ROWER = "rower"
|
private const val EXTRA_ROWER = "rower"
|
||||||
|
private const val EXTRA_LOOP = "loop"
|
||||||
private const val CHANNEL = "recording"
|
private const val CHANNEL = "recording"
|
||||||
private const val NOTIFICATION_ID = 5301
|
private const val NOTIFICATION_ID = 5301
|
||||||
private const val FLUSH_EVERY_MS = 5_000L
|
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
|
* Starts recording [activity] into the file at [path], pausing by itself
|
||||||
* by [autoPause] (null: never), with the strap at [strap] and the rower
|
* by [autoPause] (null: never), with the strap at [strap], the rower
|
||||||
* at [rower] (null: none), its notification as [notice] says.
|
* at [rower] and the Loop [loop] (null: none), its notification as
|
||||||
|
* [notice] says.
|
||||||
*/
|
*/
|
||||||
fun start(
|
fun start(
|
||||||
context: Context,
|
context: Context,
|
||||||
|
|
@ -80,6 +87,7 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
|
||||||
notice: String,
|
notice: String,
|
||||||
strap: String?,
|
strap: String?,
|
||||||
rower: String?,
|
rower: String?,
|
||||||
|
loop: String?,
|
||||||
) {
|
) {
|
||||||
ContextCompat.startForegroundService(
|
ContextCompat.startForegroundService(
|
||||||
context,
|
context,
|
||||||
|
|
@ -90,7 +98,8 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
|
||||||
.putExtra(EXTRA_AUTO_PAUSE, autoPause)
|
.putExtra(EXTRA_AUTO_PAUSE, autoPause)
|
||||||
.putExtra(EXTRA_NOTICE, notice)
|
.putExtra(EXTRA_NOTICE, notice)
|
||||||
.putExtra(EXTRA_STRAP, strap)
|
.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 strap: NotifyLink? = null
|
||||||
private var rower: 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). */
|
/** The rower's strokes and metres, from its first frame on (D28). */
|
||||||
private val strokes = Counted()
|
private val strokes = Counted()
|
||||||
private val metres = 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)
|
Build.VERSION.SDK_INT < Build.VERSION_CODES.S || granted(Manifest.permission.BLUETOOTH_CONNECT)
|
||||||
val strapAt = intent.getStringExtra(EXTRA_STRAP)?.takeIf { connects }
|
val strapAt = intent.getStringExtra(EXTRA_STRAP)?.takeIf { connects }
|
||||||
val rowerAt = intent.getStringExtra(EXTRA_ROWER)?.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 autoPauseBy = intent.getStringExtra(EXTRA_AUTO_PAUSE)
|
||||||
val stepping = autoPauseBy == "steps" && counted
|
val stepping = autoPauseBy == "steps" && counted
|
||||||
notice = intent.getStringExtra(EXTRA_NOTICE)?.let(::JSONObject)
|
notice = intent.getStringExtra(EXTRA_NOTICE)?.let(::JSONObject)
|
||||||
|
|
@ -181,7 +197,7 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
|
||||||
this,
|
this,
|
||||||
NOTIFICATION_ID,
|
NOTIFICATION_ID,
|
||||||
notification(),
|
notification(),
|
||||||
types(located, stepping, linked = strapAt != null || rowerAt != null),
|
types(located, stepping, linked = strapAt != null || rowerAt != null || loopAt != null),
|
||||||
)
|
)
|
||||||
running = this
|
running = this
|
||||||
val now = System.currentTimeMillis()
|
val now = System.currentTimeMillis()
|
||||||
|
|
@ -221,7 +237,10 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
|
||||||
HeartRateMeasurement.SERVICE,
|
HeartRateMeasurement.SERVICE,
|
||||||
HeartRateMeasurement.CHARACTERISTIC,
|
HeartRateMeasurement.CHARACTERISTIC,
|
||||||
handler,
|
handler,
|
||||||
onState = { state -> write("strap ${System.currentTimeMillis()} $state") },
|
onState = { state ->
|
||||||
|
strapSends = state == "connected"
|
||||||
|
write("strap ${System.currentTimeMillis()} $state")
|
||||||
|
},
|
||||||
onValue = { value ->
|
onValue = { value ->
|
||||||
HeartRateMeasurement.bpmOf(value)?.let { bpm -> write("h ${System.currentTimeMillis()} $bpm") }
|
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) },
|
onValue = { value -> RowerData.of(value)?.let(::rowed) },
|
||||||
).also { it.open() }
|
).also { it.open() }
|
||||||
}
|
}
|
||||||
|
loopAt?.let(::readLoop)
|
||||||
lastFlush = now
|
lastFlush = now
|
||||||
handler.postDelayed(ticker, 1_000)
|
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
|
* The foreground service's types: location with GPS, health with the step
|
||||||
* detector, connectedDevice with the strap or the rower (Android 14 on asks).
|
* 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) }
|
data.metres?.let { metres.add(it) }
|
||||||
fun field(value: Any?) = value?.toString() ?: "-"
|
fun field(value: Any?) = value?.toString() ?: "-"
|
||||||
write("r $at ${field(data.strokeRate)} ${field(strokes.total)} ${field(metres.total)} ${field(data.watts)}")
|
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. */
|
/** A console's running total, counted from the first reading. */
|
||||||
|
|
@ -358,6 +400,11 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
|
||||||
strap = null
|
strap = null
|
||||||
rower?.close()
|
rower?.close()
|
||||||
rower = null
|
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()}")
|
write("end ${System.currentTimeMillis()}")
|
||||||
writer?.close()
|
writer?.close()
|
||||||
writer = null
|
writer = null
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -88,6 +88,8 @@
|
||||||
|
|
||||||
"recordHeartRate": "Herzfrequenz",
|
"recordHeartRate": "Herzfrequenz",
|
||||||
"recordNoStrap": "Kein Brustgurt",
|
"recordNoStrap": "Kein Brustgurt",
|
||||||
|
"recordHeartLoop": "Vom Polar Loop",
|
||||||
|
"recordHeartStrapThenLoop": "{strap}, der Loop, solange er nicht verbunden ist",
|
||||||
"strapSheetTitle": "Brustgurt",
|
"strapSheetTitle": "Brustgurt",
|
||||||
"strapSearching": "Suche Brustgurte in der Nähe …",
|
"strapSearching": "Suche Brustgurte in der Nähe …",
|
||||||
"strapSearchingHint": "Leg den Gurt an: Die meisten wachen erst auf, wenn sie die Haut berühren.",
|
"strapSearchingHint": "Leg den Gurt an: Die meisten wachen erst auf, wenn sie die Haut berühren.",
|
||||||
|
|
|
||||||
|
|
@ -110,6 +110,9 @@
|
||||||
|
|
||||||
"recordHeartRate": "Heart rate",
|
"recordHeartRate": "Heart rate",
|
||||||
"recordNoStrap": "No strap",
|
"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."},
|
"@recordNoStrap": {"description": "On the Record sheet, under Heart rate, where no strap is chosen."},
|
||||||
"strapSheetTitle": "Heart-rate strap",
|
"strapSheetTitle": "Heart-rate strap",
|
||||||
"strapSearching": "Looking for straps nearby…",
|
"strapSearching": "Looking for straps nearby…",
|
||||||
|
|
|
||||||
|
|
@ -580,6 +580,18 @@ abstract class AppLocalizations {
|
||||||
/// **'No strap'**
|
/// **'No strap'**
|
||||||
String get recordNoStrap;
|
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.
|
/// No description provided for @strapSheetTitle.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|
|
||||||
|
|
@ -311,6 +311,14 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get recordNoStrap => 'Kein Brustgurt';
|
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
|
@override
|
||||||
String get strapSheetTitle => 'Brustgurt';
|
String get strapSheetTitle => 'Brustgurt';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -308,6 +308,14 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get recordNoStrap => 'No strap';
|
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
|
@override
|
||||||
String get strapSheetTitle => 'Heart-rate strap';
|
String get strapSheetTitle => 'Heart-rate strap';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -124,6 +124,7 @@ class const _Recording({required final Widget child}) extends StatelessWidget {
|
||||||
Localizations.localeOf(context).toString(),
|
Localizations.localeOf(context).toString(),
|
||||||
live,
|
live,
|
||||||
),
|
),
|
||||||
|
loop: () => train.loop?.status.loop?.id,
|
||||||
)..add(const RecordingEventOpened()),
|
)..add(const RecordingEventOpened()),
|
||||||
child: child,
|
child: child,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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.
|
/// 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 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
|
/// 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 recorder decides the pauses (D16); this works out what the screen and
|
||||||
/// the notification show from the same lines the file holds.
|
/// 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.
|
/// A second passing, while it records; every second by default.
|
||||||
Stream<void>? ticks,
|
Stream<void>? ticks,
|
||||||
|
|
||||||
|
/// The person's Loop as a recording starts; null where they have none.
|
||||||
|
final String? Function()? loop,
|
||||||
}) extends Bloc<RecordingEvent, RecordingState> {
|
}) extends Bloc<RecordingEvent, RecordingState> {
|
||||||
this : super(const RecordingState()) {
|
this : super(const RecordingState()) {
|
||||||
on<RecordingEventOpened>(_onOpened);
|
on<RecordingEventOpened>(_onOpened);
|
||||||
|
|
@ -181,6 +185,7 @@ class RecordingBloc(
|
||||||
notice: notice,
|
notice: notice,
|
||||||
strap: event.useStrap ? state.strap?.address : null,
|
strap: event.useStrap ? state.strap?.address : null,
|
||||||
rower: rows ? rower?.address : null,
|
rower: rows ? rower?.address : null,
|
||||||
|
loop: loop?.call(),
|
||||||
);
|
);
|
||||||
_shown = _keyOf(notice);
|
_shown = _keyOf(notice);
|
||||||
_follow();
|
_follow();
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ class AndroidRecorderPlatform() implements RecorderPlatform {
|
||||||
required RecorderNotice notice,
|
required RecorderNotice notice,
|
||||||
String? strap,
|
String? strap,
|
||||||
String? rower,
|
String? rower,
|
||||||
|
String? loop,
|
||||||
}) => _channel.invokeMethod<void>('start', {
|
}) => _channel.invokeMethod<void>('start', {
|
||||||
'path': path,
|
'path': path,
|
||||||
'activity': activity.name,
|
'activity': activity.name,
|
||||||
|
|
@ -43,6 +44,7 @@ class AndroidRecorderPlatform() implements RecorderPlatform {
|
||||||
'notice': noticeJson(notice),
|
'notice': noticeJson(notice),
|
||||||
'strap': strap,
|
'strap': strap,
|
||||||
'rower': rower,
|
'rower': rower,
|
||||||
|
'loop': loop,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,7 @@ LiveRecording liveOf(Recording recording, DateTime now) {
|
||||||
};
|
};
|
||||||
final lastFix = recording.fixes.lastOrNull;
|
final lastFix = recording.fixes.lastOrNull;
|
||||||
final strap = recording.straps.lastOrNull?.state;
|
final strap = recording.straps.lastOrNull?.state;
|
||||||
|
final loop = recording.loops.lastOrNull?.state;
|
||||||
final rate = recording.heartRates.lastOrNull;
|
final rate = recording.heartRates.lastOrNull;
|
||||||
final rows = recording.rowing.isNotEmpty || recording.rowers.isNotEmpty;
|
final rows = recording.rowing.isNotEmpty || recording.rowers.isNotEmpty;
|
||||||
final lastRowing = recording.rowing.lastOrNull;
|
final lastRowing = recording.rowing.lastOrNull;
|
||||||
|
|
@ -69,8 +70,9 @@ LiveRecording liveOf(Recording recording, DateTime now) {
|
||||||
paused: paused,
|
paused: paused,
|
||||||
autoPaused: paused && last!.auto,
|
autoPaused: paused && last!.auto,
|
||||||
strap: strap,
|
strap: strap,
|
||||||
|
loop: loop,
|
||||||
bpm:
|
bpm:
|
||||||
strap == LinkState.connected &&
|
(strap == LinkState.connected || loop == LinkState.connected) &&
|
||||||
rate != null &&
|
rate != null &&
|
||||||
now.difference(rate.time) <= freshHeartRate
|
now.difference(rate.time) <= freshHeartRate
|
||||||
? rate.bpm
|
? rate.bpm
|
||||||
|
|
|
||||||
|
|
@ -99,6 +99,9 @@ abstract interface class RecorderPlatform() {
|
||||||
required RecorderNotice notice,
|
required RecorderNotice notice,
|
||||||
String? strap,
|
String? strap,
|
||||||
String? rower,
|
String? rower,
|
||||||
|
|
||||||
|
/// The Loop whose heart rate is kept while no strap sends (D41).
|
||||||
|
String? loop,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Pauses, or goes on, by hand.
|
/// Pauses, or goes on, by hand.
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ const recordingFileMagic = 'halkyon-train-recording';
|
||||||
/// strap <time> connecting|connected|lost
|
/// strap <time> connecting|connected|lost
|
||||||
/// r <time> <stroke rate or -> <strokes or -> <metres or -> <watts or ->
|
/// r <time> <stroke rate or -> <strokes or -> <metres or -> <watts or ->
|
||||||
/// rower <time> connecting|connected|lost
|
/// rower <time> connecting|connected|lost
|
||||||
|
/// loop <time> connecting|connected|lost
|
||||||
/// end <time>
|
/// end <time>
|
||||||
/// ```
|
/// ```
|
||||||
///
|
///
|
||||||
|
|
@ -54,6 +55,7 @@ Recording? readRecording(String text) {
|
||||||
final steps = <DateTime>[];
|
final steps = <DateTime>[];
|
||||||
final pauses = <PauseMark>[];
|
final pauses = <PauseMark>[];
|
||||||
final straps = <LinkMark>[];
|
final straps = <LinkMark>[];
|
||||||
|
final loops = <LinkMark>[];
|
||||||
final rowing = <RowingSample>[];
|
final rowing = <RowingSample>[];
|
||||||
final rowers = <LinkMark>[];
|
final rowers = <LinkMark>[];
|
||||||
DateTime? endedAt;
|
DateTime? endedAt;
|
||||||
|
|
@ -94,11 +96,13 @@ Recording? readRecording(String text) {
|
||||||
PauseMark(time: time, paused: mark == 'pause', auto: by == 'auto'),
|
PauseMark(time: time, paused: mark == 'pause', auto: by == 'auto'),
|
||||||
);
|
);
|
||||||
case [final link, _, final name]
|
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?) {
|
if (LinkState.values.asNameMap()[name] case final state?) {
|
||||||
(link == 'strap' ? straps : rowers).add(
|
(switch (link) {
|
||||||
LinkMark(time: time, state: state),
|
'strap' => straps,
|
||||||
);
|
'loop' => loops,
|
||||||
|
_ => rowers,
|
||||||
|
}).add(LinkMark(time: time, state: state));
|
||||||
}
|
}
|
||||||
case ['r', _, final rate, final strokes, final metres, final watts]:
|
case ['r', _, final rate, final strokes, final metres, final watts]:
|
||||||
rowing.add(
|
rowing.add(
|
||||||
|
|
@ -122,6 +126,7 @@ Recording? readRecording(String text) {
|
||||||
steps: steps,
|
steps: steps,
|
||||||
pauses: pauses,
|
pauses: pauses,
|
||||||
straps: straps,
|
straps: straps,
|
||||||
|
loops: loops,
|
||||||
rowing: rowing,
|
rowing: rowing,
|
||||||
rowers: rowers,
|
rowers: rowers,
|
||||||
endedAt: endedAt,
|
endedAt: endedAt,
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,11 @@ final class const LiveRecording({
|
||||||
/// The strap's state; null where it records without one (D27).
|
/// The strap's state; null where it records without one (D27).
|
||||||
final LinkState? strap,
|
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,
|
final int? bpm,
|
||||||
|
|
||||||
/// The rower's state; null where it records without one (D28).
|
/// The rower's state; null where it records without one (D28).
|
||||||
|
|
@ -56,6 +60,7 @@ final class const LiveRecording({
|
||||||
paused,
|
paused,
|
||||||
autoPaused,
|
autoPaused,
|
||||||
strap,
|
strap,
|
||||||
|
loop,
|
||||||
bpm,
|
bpm,
|
||||||
rower,
|
rower,
|
||||||
split,
|
split,
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue