feat(train): rowing on the WP 5000 over FTMS (ADR 0030, D28)

Rowing indoors is offered again, with the rower chosen on the Record
sheet: a sheet looks for Fitness Machine Service rowers, and the one
chosen is remembered in the person's folder. The recorder service
connects to it as to the strap (one NotifyLink for both), parses
FTMS's Rower Data in Kotlin (tested on the JVM on frames written from
the specification and D8's), counts its totals from its first frame,
pauses 5 s after the last stroke, and gets the connectedDevice type. A
row's screen shows its metres, split per 500 m, stroke rate and watts;
its page its average split, stroke rate and power, the highest power,
its strokes, splits per 500 m and charts of the split, stroke rate and
power. Not yet tried on the WP 5000.

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 16:20:37 +01:00
commit f3d08e4e69
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
48 changed files with 2266 additions and 514 deletions

View file

@ -5,6 +5,6 @@ What holds for every app and package of this repository is in the [root AGENTS.m
- **A shell on the shared packages**, Todo's copied and renamed (decided 2026-10-28, ADR 0030, D12; what Train is to do: ADR 0030): signing in, the instances, the account and the look work, and since step 5a (28 Oct 2026) a person's workouts are kept and synced with `serverpod_offline_sync`, every row sealed (ADR 0030, D7, D14, D15), and on Android a workout is recorded (5b: ADR 0030, D13, D16, D18 to D21), and since 5c home lists them and each has its page with its map (D22 to D24). Packages: `halkyon_train_server`, `halkyon_train_client` (generated, never edited), `halkyon_train_flutter`; app ID `app.halkyon.train` on every platform; no web build. Licences as Learn's (server SSPL, client and app AGPL; `LICENSE.md`). - **A shell on the shared packages**, Todo's copied and renamed (decided 2026-10-28, ADR 0030, D12; what Train is to do: ADR 0030): signing in, the instances, the account and the look work, and since step 5a (28 Oct 2026) a person's workouts are kept and synced with `serverpod_offline_sync`, every row sealed (ADR 0030, D7, D14, D15), and on Android a workout is recorded (5b: ADR 0030, D13, D16, D18 to D21), and since 5c home lists them and each has its page with its map (D22 to D24). Packages: `halkyon_train_server`, `halkyon_train_client` (generated, never edited), `halkyon_train_flutter`; app ID `app.halkyon.train` on every platform; no web build. Licences as Learn's (server SSPL, client and app AGPL; `LICENSE.md`).
- **The server** includes `halkyon_base` (ADR 0014) and configures it in `server.dart` (`trainBase()`, `lib/src/train_base.dart`): its basics say it serves `train` (never change it once apps use it), sign-in mails name Halkyon Train, the source is this repository, `minimumAppBuild` 1, and `deleteTrainData` (`lib/src/train_data.dart`) as its `deletePersonData` hook, which deletes the person's sync spaces and so, by the sync engine's cascades, every row of theirs (`test/integration/workout/person_deletion_test.dart`). Authentication through the module's builders: a password, SimpleLogin on the register, email codes proving an address, server-side sessions; no anonymous accounts (ADR 0020, D25), nor GitHub (D36). **Accounts** (ADR 0016): Train holds its own register when it runs alone, and is a member of the instance's register (Learn's server on learn.halkyon.eu) where the operator sets `ACCOUNT_REGISTER_URL` (`trainBase(accounts: AccountsMode.fromEnvironment())`) with the instance's shared `INSTANCE_SECRET` (`instanceSecret`); then sign-in, handles and profiles are the register's, the email settings are the register's alone, and an identified person's deletion goes to the register first. `addInstanceRoutes` serves the calls between the services on its web server, and the server doesn't start without the secret where it needs it. `schedulePruning` (`lib/src/pruning.dart`) keeps the module's pruning scheduled, one entry each however often the server starts (and cancels the one under the pruning's name before ADR 0020, D25): the anonymous accounts left from before daily, sign-up attempts and codes hourly, as the register the deletions its members haven't confirmed hourly, as a member the people the register no longer knows daily (each a no-op in the other mode). Session logs are never persisted, in any run mode. Migrations (2026-10-28): an initial one with Serverpod's tables, its auth modules' and the module's as they stand (Todo's grew one by one with the module; Train starts where they ended), then `workouts`, purely additive: the sync engine's tables and Train's first three. **Synced models** (`database: sync`, the module `offline_sync`, `experimental_features: databaseSync` in `config/generator.yaml`): `content_key` (the person's key sealed to the account), `workout` and `workout_series` (a workout's series in chunks of at most ten minutes, deleted with it), each a sealed `payload` with its `keyId` and `nonce`, nothing else readable. Tests (`test/integration/train_server_test.dart`) call the module's endpoints through the server's own dispatch (`halkyon_base.<endpoint>`), since the generated test tools expose the project's endpoints alone. - **The server** includes `halkyon_base` (ADR 0014) and configures it in `server.dart` (`trainBase()`, `lib/src/train_base.dart`): its basics say it serves `train` (never change it once apps use it), sign-in mails name Halkyon Train, the source is this repository, `minimumAppBuild` 1, and `deleteTrainData` (`lib/src/train_data.dart`) as its `deletePersonData` hook, which deletes the person's sync spaces and so, by the sync engine's cascades, every row of theirs (`test/integration/workout/person_deletion_test.dart`). Authentication through the module's builders: a password, SimpleLogin on the register, email codes proving an address, server-side sessions; no anonymous accounts (ADR 0020, D25), nor GitHub (D36). **Accounts** (ADR 0016): Train holds its own register when it runs alone, and is a member of the instance's register (Learn's server on learn.halkyon.eu) where the operator sets `ACCOUNT_REGISTER_URL` (`trainBase(accounts: AccountsMode.fromEnvironment())`) with the instance's shared `INSTANCE_SECRET` (`instanceSecret`); then sign-in, handles and profiles are the register's, the email settings are the register's alone, and an identified person's deletion goes to the register first. `addInstanceRoutes` serves the calls between the services on its web server, and the server doesn't start without the secret where it needs it. `schedulePruning` (`lib/src/pruning.dart`) keeps the module's pruning scheduled, one entry each however often the server starts (and cancels the one under the pruning's name before ADR 0020, D25): the anonymous accounts left from before daily, sign-up attempts and codes hourly, as the register the deletions its members haven't confirmed hourly, as a member the people the register no longer knows daily (each a no-op in the other mode). Session logs are never persisted, in any run mode. Migrations (2026-10-28): an initial one with Serverpod's tables, its auth modules' and the module's as they stand (Todo's grew one by one with the module; Train starts where they ended), then `workouts`, purely additive: the sync engine's tables and Train's first three. **Synced models** (`database: sync`, the module `offline_sync`, `experimental_features: databaseSync` in `config/generator.yaml`): `content_key` (the person's key sealed to the account), `workout` and `workout_series` (a workout's series in chunks of at most ten minutes, deleted with it), each a sealed `payload` with its `keyId` and `nonce`, nothing else readable. Tests (`test/integration/train_server_test.dart`) call the module's endpoints through the server's own dispatch (`halkyon_base.<endpoint>`), since the generated test tools expose the project's endpoints alone.
- **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, D27) 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 and the heart rate from the strap the person chose (ADR 0030, D26, D27: `StrapLink.kt` on Android's own Bluetooth API, no package, `HeartRateMeasurement.kt` parsing 0x2A37; `StrapScanner.kt` finds straps for the sheet over `…/recorder/straps`; the strap remembered in the person's `strap.json`, `StrapStore`), 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 heart-rate parser 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.
- **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.

View file

@ -14,8 +14,9 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/> <uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION"/> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_HEALTH"/> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_HEALTH"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE"/>
<uses-permission android:name="android.permission.WAKE_LOCK"/> <uses-permission android:name="android.permission.WAKE_LOCK"/>
<!-- The heart-rate strap (ADR 0030, D26, D27): nearby devices from Android <!-- The heart-rate strap and the rower (ADR 0030, D26 to D28): nearby devices from Android
12 on, never used to locate; before 12, Bluetooth's own permissions, 12 on, never used to locate; before 12, Bluetooth's own permissions,
and scanning needs location, which the app has for GPS anyway. --> and scanning needs location, which the app has for GPS anyway. -->
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" <uses-permission android:name="android.permission.BLUETOOTH_SCAN"
@ -68,7 +69,7 @@
<service <service
android:name=".RecorderService" android:name=".RecorderService"
android:exported="false" android:exported="false"
android:foregroundServiceType="location|health" /> android:foregroundServiceType="location|health|connectedDevice" />
<!-- Don't delete the meta-data below. <!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java --> This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data <meta-data

View file

@ -2,7 +2,8 @@ package app.halkyon.train
// Auto-pause by each activity's best signal (ADR 0030, D16), decided in the // Auto-pause by each activity's best signal (ADR 0030, D16), decided in the
// service, which runs while the app may not: steps on foot, confirmed by GPS; // service, which runs while the app may not: steps on foot, confirmed by GPS;
// GPS speed on a bike; GPS speed on foot where steps aren't allowed (D19). // GPS speed on a bike; GPS speed on foot where steps aren't allowed (D19);
// the rower's strokes (D28).
// Times in milliseconds; each call returns true where it paused, false where // Times in milliseconds; each call returns true where it paused, false where
// it went on, null where nothing changed. // it went on, null where nothing changed.
@ -17,6 +18,9 @@ class AutoPause(private val signal: Signal) {
/** By bike, by GPS speed: paused below 3 km/h, going on above 5 km/h. */ /** By bike, by GPS speed: paused below 3 km/h, going on above 5 km/h. */
BIKE_SPEED, BIKE_SPEED,
/** On the rower, by its strokes: paused 5 s after the last, going on with the next. */
STROKES,
} }
/** Whether it paused by itself. */ /** Whether it paused by itself. */
@ -41,6 +45,17 @@ class AutoPause(private val signal: Signal) {
return null return null
} }
/** A new stroke at [at]. */
fun stroke(at: Long): Boolean? {
if (signal != Signal.STROKES || byHand) return null
lastStep = at
if (paused) {
paused = false
return false
}
return null
}
/** A fix at [at], moving at [metresPerSecond]. */ /** A fix at [at], moving at [metresPerSecond]. */
fun speed(at: Long, metresPerSecond: Double): Boolean? { fun speed(at: Long, metresPerSecond: Double): Boolean? {
val kmh = metresPerSecond * 3.6 val kmh = metresPerSecond * 3.6
@ -70,6 +85,11 @@ class AutoPause(private val signal: Signal) {
if (byHand || paused) return null if (byHand || paused) return null
val still = val still =
when (signal) { when (signal) {
Signal.STROKES -> {
// Starting counts as a stroke.
val last = lastStep ?: at.also { lastStep = it }
at - last >= STILL_MS
}
Signal.STEPS -> { Signal.STEPS -> {
// Starting counts as a step. // Starting counts as a step.
val last = lastStep ?: at.also { lastStep = it } val last = lastStep ?: at.also { lastStep = it }

View file

@ -0,0 +1,87 @@
package app.halkyon.train
import android.annotation.SuppressLint
import android.bluetooth.BluetoothManager
import android.bluetooth.le.ScanCallback
import android.bluetooth.le.ScanFilter
import android.bluetooth.le.ScanResult
import android.bluetooth.le.ScanSettings
import android.content.Context
import android.os.ParcelUuid
import io.flutter.plugin.common.EventChannel
import java.util.UUID
/**
* Looks for devices nearby while the app listens (ADR 0030, D27, D28): those
* advertising [service] that [accepts] lets through, as `{address, name,
* rssi}`; the error `off` where Bluetooth is, `refused` where scanning isn't
* allowed (the app asks first).
*/
class DeviceScanner(
private val context: Context,
private val service: UUID,
private val accepts: (ScanResult) -> Boolean = { true },
) : EventChannel.StreamHandler {
private var callback: ScanCallback? = null
@SuppressLint("MissingPermission") // Refused scans end in the SecurityException below.
override fun onListen(arguments: Any?, events: EventChannel.EventSink) {
val adapter = context.getSystemService(BluetoothManager::class.java)?.adapter
val scanner = adapter?.takeIf { it.isEnabled }?.bluetoothLeScanner
if (scanner == null) {
events.error("off", "Bluetooth is off", null)
return
}
val found =
object : ScanCallback() {
override fun onScanResult(callbackType: Int, result: ScanResult) {
if (!accepts(result)) return
val name =
result.scanRecord?.deviceName
?: try {
result.device.name
} catch (_: SecurityException) {
null
}
events.success(mapOf("address" to result.device.address, "name" to name, "rssi" to result.rssi))
}
override fun onScanFailed(errorCode: Int) {
events.error("failed", "Scan failed: $errorCode", null)
}
}
try {
scanner.startScan(
listOf(ScanFilter.Builder().setServiceUuid(ParcelUuid(service)).build()),
ScanSettings.Builder().setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY).build(),
found,
)
callback = found
} catch (_: SecurityException) {
events.error("refused", "Scanning not allowed", null)
}
}
@SuppressLint("MissingPermission")
override fun onCancel(arguments: Any?) {
val found = callback ?: return
callback = null
try {
context.getSystemService(BluetoothManager::class.java)?.adapter?.bluetoothLeScanner?.stopScan(found)
} catch (_: SecurityException) {
// Taken away while it looked: it stopped with it.
}
}
}
/**
* Whether [result] may be a rower (D28): its Fitness Machine service data
* says "rower" (FTMS 3.1.1: flags, then the type's 16 bits); one without the
* data can't tell, and is listed too.
*/
fun mayBeRower(result: ScanResult): Boolean {
val data = result.scanRecord?.getServiceData(ParcelUuid(RowerData.SERVICE)) ?: return true
if (data.size < 3) return true
val type = (data[1].toInt() and 0xFF) or ((data[2].toInt() and 0xFF) shl 8)
return type and RowerData.ROWER_TYPE != 0
}

View file

@ -29,6 +29,7 @@ class MainActivity : FlutterActivity() {
autoPause = call.argument<String>("autoPause"), autoPause = call.argument<String>("autoPause"),
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"),
) )
result.success(null) result.success(null)
} }
@ -40,8 +41,11 @@ class MainActivity : FlutterActivity() {
else -> result.notImplemented() else -> result.notImplemented()
} }
} }
// Heart-rate straps nearby, while the strap sheet looks (D27). // Heart-rate straps and rowers nearby, while their sheets look (D27, D28).
EventChannel(messenger, "app.halkyon.train/recorder/straps").setStreamHandler(StrapScanner(this)) EventChannel(messenger, "app.halkyon.train/recorder/straps")
.setStreamHandler(DeviceScanner(this, HeartRateMeasurement.SERVICE))
EventChannel(messenger, "app.halkyon.train/recorder/rowers")
.setStreamHandler(DeviceScanner(this, RowerData.SERVICE, ::mayBeRower))
EventChannel(messenger, "app.halkyon.train/recorder/lines").setStreamHandler( EventChannel(messenger, "app.halkyon.train/recorder/lines").setStreamHandler(
object : EventChannel.StreamHandler { object : EventChannel.StreamHandler {
override fun onListen(arguments: Any?, events: EventChannel.EventSink) { override fun onListen(arguments: Any?, events: EventChannel.EventSink) {

View file

@ -0,0 +1,130 @@
package app.halkyon.train
import android.annotation.SuppressLint
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothGatt
import android.bluetooth.BluetoothGattCallback
import android.bluetooth.BluetoothGattCharacteristic
import android.bluetooth.BluetoothGattDescriptor
import android.bluetooth.BluetoothManager
import android.bluetooth.BluetoothProfile
import android.content.Context
import android.os.Build
import android.os.Handler
import java.util.UUID
/**
* A device during a recording (ADR 0030, D26 to D28), the heart-rate strap
* or the rower: connected by its address without bonding, [characteristic]
* of [service] notified, and asked for again by itself whenever it goes,
* until [close]. Each state and each value reaches [onState] and [onValue]
* on [handler]'s thread, the one the recorder writes its file on.
*/
@SuppressLint("MissingPermission") // The recorder opens it only where connecting is allowed.
class NotifyLink(
private val context: Context,
private val address: String,
private val service: UUID,
private val characteristic: UUID,
private val handler: Handler,
private val onState: (String) -> Unit,
private val onValue: (ByteArray) -> Unit,
) : BluetoothGattCallback() {
private var gatt: BluetoothGatt? = null
private var closed = false
private var connected = false
/** Asks for the device: at once first, then in the background after each loss. */
fun open() {
val device = adapterDevice() ?: return onState("lost")
onState("connecting")
// A direct attempt finds a device in reach within seconds; one that
// fails (out of reach, off, or held by another app) falls back on the
// background one.
gatt = device.connectGatt(context, false, this, BluetoothDevice.TRANSPORT_LE)
}
/** Lets the device go. */
fun close() {
handler.post {
closed = true
gatt?.disconnect()
gatt?.close()
gatt = null
}
}
private fun adapterDevice(): BluetoothDevice? =
try {
context.getSystemService(BluetoothManager::class.java)?.adapter?.getRemoteDevice(address)
} catch (_: IllegalArgumentException) {
null
}
override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
handler.post {
if (closed) return@post
when (newState) {
BluetoothProfile.STATE_CONNECTED -> gatt.discoverServices()
BluetoothProfile.STATE_DISCONNECTED -> {
if (connected) onState("lost")
connected = false
gatt.close()
// In the background, for as long as the recording runs: Android
// connects once the device is in reach again.
this.gatt = adapterDevice()?.connectGatt(context, true, this, BluetoothDevice.TRANSPORT_LE)
}
}
}
}
override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
handler.post {
if (closed) return@post
val measurement =
gatt.getService(service)?.getCharacteristic(characteristic) ?: return@post gatt.disconnect()
gatt.setCharacteristicNotification(measurement, true)
val config = measurement.getDescriptor(HeartRateMeasurement.CONFIG) ?: return@post gatt.disconnect()
val enable = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
gatt.writeDescriptor(config, enable)
} else {
@Suppress("DEPRECATION")
config.value = enable
@Suppress("DEPRECATION")
gatt.writeDescriptor(config)
}
}
}
override fun onDescriptorWrite(gatt: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int) {
handler.post {
if (closed || status != BluetoothGatt.GATT_SUCCESS) return@post
connected = true
onState("connected")
}
}
// Android 13 on.
override fun onCharacteristicChanged(
gatt: BluetoothGatt,
characteristic: BluetoothGattCharacteristic,
value: ByteArray,
) {
measured(characteristic, value)
}
// Before Android 13.
@Deprecated("Android 13 hands the value itself")
override fun onCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) return
@Suppress("DEPRECATION")
measured(characteristic, characteristic.value ?: return)
}
private fun measured(characteristic: BluetoothGattCharacteristic, value: ByteArray) {
if (characteristic.uuid != this.characteristic) return
val copy = value.copyOf()
handler.post { if (!closed) onValue(copy) }
}
}

View file

@ -35,8 +35,8 @@ import org.json.JSONObject
// The recording's side of the system (ADR 0030, D13, D16, D18, D19): a // The recording's side of the system (ADR 0030, D13, D16, D18, D19): a
// foreground service, started from the open app, that takes GPS fixes from // 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 where the person chose one (D26, // detector, reads the heart-rate strap and the rower where the person chose
// D27), pauses by itself, and appends each sample as a line to the // them (D26 to D28), 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
@ -53,6 +53,7 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
private const val EXTRA_AUTO_PAUSE = "autoPause" private const val EXTRA_AUTO_PAUSE = "autoPause"
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 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
@ -68,10 +69,18 @@ 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] (null: none), * by [autoPause] (null: never), with the strap at [strap] and the rower
* its notification as [notice] says. * at [rower] (null: none), its notification as [notice] says.
*/ */
fun start(context: Context, path: String, activity: String, autoPause: String?, notice: String, strap: String?) { fun start(
context: Context,
path: String,
activity: String,
autoPause: String?,
notice: String,
strap: String?,
rower: String?,
) {
ContextCompat.startForegroundService( ContextCompat.startForegroundService(
context, context,
Intent(context, RecorderService::class.java) Intent(context, RecorderService::class.java)
@ -80,7 +89,8 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
.putExtra(EXTRA_ACTIVITY, activity) .putExtra(EXTRA_ACTIVITY, activity)
.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),
) )
} }
@ -110,7 +120,12 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
private var pausedByHand = false private var pausedByHand = false
private var notice: JSONObject? = null private var notice: JSONObject? = null
private var wakeLock: PowerManager.WakeLock? = null private var wakeLock: PowerManager.WakeLock? = null
private var strap: StrapLink? = null private var strap: NotifyLink? = null
private var rower: NotifyLink? = null
/** The rower's strokes and metres, from its first frame on (D28). */
private val strokes = Counted()
private val metres = Counted()
private val handler = Handler(Looper.getMainLooper()) private val handler = Handler(Looper.getMainLooper())
private var lastFlush = 0L private var lastFlush = 0L
@ -147,30 +162,45 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
private fun begin(intent: Intent) { private fun begin(intent: Intent) {
path = intent.getStringExtra(EXTRA_PATH) ?: return stopSelf() path = intent.getStringExtra(EXTRA_PATH) ?: return stopSelf()
val activity = intent.getStringExtra(EXTRA_ACTIVITY) ?: return stopSelf() val activity = intent.getStringExtra(EXTRA_ACTIVITY) ?: return stopSelf()
val located = granted(Manifest.permission.ACCESS_FINE_LOCATION) // No GPS on the rower, whatever location allows.
val located = activity != "indoorRowing" && granted(Manifest.permission.ACCESS_FINE_LOCATION)
val counted = val counted =
Build.VERSION.SDK_INT < Build.VERSION_CODES.Q || granted(Manifest.permission.ACTIVITY_RECOGNITION) Build.VERSION.SDK_INT < Build.VERSION_CODES.Q || granted(Manifest.permission.ACTIVITY_RECOGNITION)
// The strap and the rower where they were chosen and connecting is
// allowed (Android 12 on asks; before, scanning's location stands for it).
val connects =
Build.VERSION.SDK_INT < Build.VERSION_CODES.S || granted(Manifest.permission.BLUETOOTH_CONNECT)
val strapAt = intent.getStringExtra(EXTRA_STRAP)?.takeIf { connects }
val rowerAt = intent.getStringExtra(EXTRA_ROWER)?.takeIf { connects }
val autoPauseBy = intent.getStringExtra(EXTRA_AUTO_PAUSE)
val stepping = autoPauseBy == "steps" && counted
notice = intent.getStringExtra(EXTRA_NOTICE)?.let(::JSONObject) notice = intent.getStringExtra(EXTRA_NOTICE)?.let(::JSONObject)
channel(notice?.optString("channelName")) channel(notice?.optString("channelName"))
// In the foreground first: Android allows a few seconds for it. // In the foreground first: Android allows a few seconds for it.
ServiceCompat.startForeground(this, NOTIFICATION_ID, notification(), types(located, counted)) ServiceCompat.startForeground(
this,
NOTIFICATION_ID,
notification(),
types(located, stepping, linked = strapAt != null || rowerAt != null),
)
running = this running = this
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
writer = File(path).apply { parentFile?.mkdirs() }.bufferedWriter().also { writer = File(path).apply { parentFile?.mkdirs() }.bufferedWriter().also {
it.append("halkyon-train-recording 1 $activity $now\n") it.append("halkyon-train-recording 1 $activity $now\n")
} }
autoPause = autoPause =
when (intent.getStringExtra(EXTRA_AUTO_PAUSE)) { when (autoPauseBy) {
// Steps where they may be counted, GPS speed where not (D19). // Steps where they may be counted, GPS speed where not (D19).
"steps" -> AutoPause(if (counted) AutoPause.Signal.STEPS else AutoPause.Signal.FOOT_SPEED) "steps" -> AutoPause(if (counted) AutoPause.Signal.STEPS else AutoPause.Signal.FOOT_SPEED)
"bike" -> AutoPause(AutoPause.Signal.BIKE_SPEED) "bike" -> AutoPause(AutoPause.Signal.BIKE_SPEED)
"strokes" -> AutoPause(AutoPause.Signal.STROKES)
else -> null else -> null
} }
if (located) { if (located) {
getSystemService(LocationManager::class.java) getSystemService(LocationManager::class.java)
.requestLocationUpdates(LocationManager.GPS_PROVIDER, 1_000L, 0f, this, Looper.getMainLooper()) .requestLocationUpdates(LocationManager.GPS_PROVIDER, 1_000L, 0f, this, Looper.getMainLooper())
} }
if (counted && autoPause != null) { if (stepping) {
getSystemService(SensorManager::class.java).let { sensors -> getSystemService(SensorManager::class.java).let { sensors ->
sensors.getDefaultSensor(Sensor.TYPE_STEP_DETECTOR)?.let { sensors.getDefaultSensor(Sensor.TYPE_STEP_DETECTOR)?.let {
sensors.registerListener(this, it, SensorManager.SENSOR_DELAY_NORMAL) sensors.registerListener(this, it, SensorManager.SENSOR_DELAY_NORMAL)
@ -183,35 +213,94 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
getSystemService(PowerManager::class.java) getSystemService(PowerManager::class.java)
.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "HalkyonTrain:recording") .newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "HalkyonTrain:recording")
.apply { acquire(12 * 60 * 60 * 1_000L) } .apply { acquire(12 * 60 * 60 * 1_000L) }
// The strap where one was chosen and connecting is allowed (Android 12 strapAt?.let { address ->
// on asks; before, scanning's location stands for it).
val connects =
Build.VERSION.SDK_INT < Build.VERSION_CODES.S || granted(Manifest.permission.BLUETOOTH_CONNECT)
intent.getStringExtra(EXTRA_STRAP)?.takeIf { connects }?.let { address ->
strap = strap =
StrapLink( NotifyLink(
this, this,
address, address,
HeartRateMeasurement.SERVICE,
HeartRateMeasurement.CHARACTERISTIC,
handler, handler,
onState = { state -> write("strap ${System.currentTimeMillis()} $state") }, onState = { state -> write("strap ${System.currentTimeMillis()} $state") },
onBpm = { bpm -> write("h ${System.currentTimeMillis()} $bpm") }, onValue = { value ->
HeartRateMeasurement.bpmOf(value)?.let { bpm -> write("h ${System.currentTimeMillis()} $bpm") }
},
).also { it.open() }
}
rowerAt?.let { address ->
rower =
NotifyLink(
this,
address,
RowerData.SERVICE,
RowerData.CHARACTERISTIC,
handler,
onState = { state -> write("rower ${System.currentTimeMillis()} $state") },
onValue = { value -> RowerData.of(value)?.let(::rowed) },
).also { it.open() } ).also { it.open() }
} }
lastFlush = now lastFlush = now
handler.postDelayed(ticker, 1_000) handler.postDelayed(ticker, 1_000)
} }
/** The foreground service's types: location with GPS, health with the step detector (Android 14 on asks). */ /**
private fun types(located: Boolean, counted: Boolean): Int { * The foreground service's types: location with GPS, health with the step
* detector, connectedDevice with the strap or the rower (Android 14 on asks).
*/
private fun types(located: Boolean, stepping: Boolean, linked: Boolean): Int {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return 0 if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return 0
var types = 0 var types = 0
if (located) types = types or ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION if (located) types = types or ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION
if (counted && Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { if (linked) types = types or ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE
if (stepping && Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
types = types or ServiceInfo.FOREGROUND_SERVICE_TYPE_HEALTH types = types or ServiceInfo.FOREGROUND_SERVICE_TYPE_HEALTH
} }
return types return types
} }
/**
* A frame of the rower's (D28): its totals counted from its first frame
* (a console reset to zero mid-row counting on from there), a line of what
* it carries, a new stroke for auto-pause, and its heart rate where no
* strap gives one. A frame of a reading split in two keeps the totals the
* other half brought.
*/
private fun rowed(data: RowerData) {
val at = System.currentTimeMillis()
data.strokes?.let { raw ->
val stroked = strokes.add(raw)
if (stroked && !pausedByHand) {
autoPause?.stroke(at)?.let { paused -> mark(paused, auto = true, at = at) }
}
}
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") }
}
/** A console's running total, counted from the first reading. */
private class Counted {
private var last: Int? = null
/** Null before the first reading. */
var total: Int? = null
private set
/** Takes [raw] in; true where the total grew. */
fun add(raw: Int): Boolean {
val before = last
last = raw
if (before == null) {
total = 0
return false
}
val grown = if (raw < before) raw else raw - before
total = (total ?: 0) + grown
return grown > 0
}
}
override fun onLocationChanged(location: Location) { override fun onLocationChanged(location: Location) {
val at = wallTime(location.elapsedRealtimeNanos) val at = wallTime(location.elapsedRealtimeNanos)
val altitude = if (location.hasAltitude()) (location.altitude * 100).roundToLong().toString() else "-" val altitude = if (location.hasAltitude()) (location.altitude * 100).roundToLong().toString() else "-"
@ -267,6 +356,8 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
getSystemService(SensorManager::class.java).unregisterListener(this) getSystemService(SensorManager::class.java).unregisterListener(this)
strap?.close() strap?.close()
strap = null strap = null
rower?.close()
rower = null
write("end ${System.currentTimeMillis()}") write("end ${System.currentTimeMillis()}")
writer?.close() writer?.close()
writer = null writer = null

View file

@ -0,0 +1,104 @@
package app.halkyon.train
import java.util.UUID
/**
* A rower's Rower Data (Bluetooth SIG's Fitness Machine Service 1.0, 4.8,
* characteristic 0x2AD1), as it notifies it (ADR 0030, D8, D28): what Train
* keeps of a frame, each null where the frame doesn't carry it. A machine may
* split one reading over several frames (More Data), each with its own
* fields; the averages, the resistance, the metabolic equivalent and the
* remaining time are left aside.
*/
data class RowerData(
/** Strokes a minute, in halves. */
val strokeRate: Double?,
/** Strokes since the console started counting. */
val strokes: Int?,
/** Metres since the console started counting. */
val metres: Int?,
/** Seconds 500 m take at this moment. */
val paceSeconds: Int?,
val watts: Int?,
/** Kilocalories since the console started counting. */
val kcal: Int?,
/** The console's own heart rate; null for its 0, no receiver or no belt. */
val bpm: Int?,
val elapsedSeconds: Int?,
) {
companion object {
val SERVICE: UUID = UUID.fromString("00001826-0000-1000-8000-00805f9b34fb")
val CHARACTERISTIC: UUID = UUID.fromString("00002ad1-0000-1000-8000-00805f9b34fb")
/** The Fitness Machine Type's rower bit in the service's advertised data (FTMS 3.1.1). */
const val ROWER_TYPE = 0x10
private const val MORE_DATA = 1 shl 0
private const val AVERAGE_STROKE_RATE = 1 shl 1
private const val TOTAL_DISTANCE = 1 shl 2
private const val INSTANT_PACE = 1 shl 3
private const val AVERAGE_PACE = 1 shl 4
private const val INSTANT_POWER = 1 shl 5
private const val AVERAGE_POWER = 1 shl 6
private const val RESISTANCE = 1 shl 7
private const val ENERGY = 1 shl 8
private const val HEART_RATE = 1 shl 9
private const val METABOLIC = 1 shl 10
private const val ELAPSED = 1 shl 11
/** [value] read; null where it is cut short. */
fun of(value: ByteArray): RowerData? {
val reader = Reader(value)
val flags = reader.uint16() ?: return null
fun has(flag: Int) = flags and flag != 0
// Stroke rate and count are there where More Data is clear (4.8.1.2).
val strokeRate = if (!has(MORE_DATA)) (reader.uint8() ?: return null) / 2.0 else null
val strokes = if (!has(MORE_DATA)) reader.uint16() ?: return null else null
if (has(AVERAGE_STROKE_RATE)) reader.skip(1) ?: return null
val metres = if (has(TOTAL_DISTANCE)) reader.uint24() ?: return null else null
val pace = if (has(INSTANT_PACE)) reader.uint16() ?: return null else null
if (has(AVERAGE_PACE)) reader.skip(2) ?: return null
val watts = if (has(INSTANT_POWER)) reader.sint16() ?: return null else null
if (has(AVERAGE_POWER)) reader.skip(2) ?: return null
if (has(RESISTANCE)) reader.skip(2) ?: return null
// Total energy, then the energy per hour and per minute.
val kcal = if (has(ENERGY)) (reader.uint16() ?: return null).also { reader.skip(3) ?: return null } else null
val bpm = if (has(HEART_RATE)) reader.uint8() ?: return null else null
if (has(METABOLIC)) reader.skip(1) ?: return null
val elapsed = if (has(ELAPSED)) reader.uint16() ?: return null else null
return RowerData(
strokeRate = strokeRate,
strokes = strokes,
metres = metres,
paceSeconds = pace,
watts = watts,
// 0xFFFF: not available.
kcal = kcal?.takeIf { it != 0xFFFF },
bpm = bpm?.takeIf { it > 0 },
elapsedSeconds = elapsed,
)
}
}
/** Reads little-endian fields off [bytes], null once they run out. */
private class Reader(private val bytes: ByteArray) {
private var at = 0
private fun byte(i: Int) = bytes[at + i].toInt() and 0xFF
private inline fun <T> take(size: Int, read: () -> T): T? {
if (at + size > bytes.size) return null
return read().also { at += size }
}
fun skip(size: Int): Unit? = take(size) {}
fun uint8(): Int? = take(1) { byte(0) }
fun uint16(): Int? = take(2) { byte(0) or (byte(1) shl 8) }
fun sint16(): Int? = take(2) { (byte(0) or (byte(1) shl 8)).toShort().toInt() }
fun uint24(): Int? = take(3) { byte(0) or (byte(1) shl 8) or (byte(2) shl 16) }
}
}

View file

@ -84,4 +84,19 @@ class AutoPauseTest {
assertNull(pause.tick(16_000)) assertNull(pause.tick(16_000))
assertEquals(true, pause.tick(17_000)) assertEquals(true, pause.tick(17_000))
} }
@Test
fun `a row pauses 5 s after its last stroke, and goes on with the next`() {
val pause = AutoPause(AutoPause.Signal.STROKES)
val changes = mutableMapOf<Int, Boolean>()
for (second in 0..20) {
val at = second * 1000L
// Strokes every 2 s up to 6, then none until 15.
if (second <= 6 && second % 2 == 0 || second == 15) pause.stroke(at)?.let { changes[second] = it }
pause.tick(at)?.let { changes[second] = it }
}
// The one stroke at 15 is followed by none again: paused at 20.
assertEquals(mapOf(11 to true, 15 to false, 20 to true), changes)
}
} }

View file

@ -0,0 +1,83 @@
package app.halkyon.train
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
// Rower Data (0x2AD1, Bluetooth SIG's Fitness Machine Service 1.0, 4.8), as a
// rower notifies it (ADR 0030, D8, D28): 16 bits of flags, then each field the
// flags name, in the specification's order, little-endian.
class RowerDataTest {
private fun frame(vararg bytes: Int) = ByteArray(bytes.size) { bytes[it].toByte() }
@Test
fun `the WP 5000's frame as nRF Connect showed it (D8)`() {
// Stroke rate and count (More Data clear), total distance, pace, power,
// resistance, energy, heart rate, elapsed time.
val data =
RowerData.of(
frame(
0xAC, 0x0B,
50, 0x06, 0x00,
0x12, 0x00, 0x00,
0xB7, 0x01,
0x64, 0x00,
0x01, 0x00,
0x01, 0x00, 0xFF, 0xFF, 0xFF,
0x00,
0x11, 0x00,
),
)!!
assertEquals(25.0, data.strokeRate!!, 0.0)
assertEquals(6, data.strokes)
assertEquals(18, data.metres)
assertEquals(439, data.paceSeconds)
assertEquals(100, data.watts)
assertEquals(1, data.kcal)
// 0 is no heart rate: no strap on the console.
assertNull(data.bpm)
assertEquals(17, data.elapsedSeconds)
}
@Test
fun `More Data set leaves the stroke out, the rest read on`() {
// A frame split in two (4.8.1.2): the second carries no stroke, only the
// distance and the power here.
val data = RowerData.of(frame(0x25, 0x00, 0x2C, 0x01, 0x00, 0x96, 0x00))!!
assertNull(data.strokeRate)
assertNull(data.strokes)
assertEquals(300, data.metres)
assertEquals(150, data.watts)
}
@Test
fun `the averages are skipped over, and a stroke rate in halves`() {
// Stroke (51 = 25.5 a minute), average stroke rate, average pace,
// average power, heart rate.
val data =
RowerData.of(
frame(0x52, 0x02, 51, 0x10, 0x00, 48, 0x00, 0x02, 0x50, 0x00, 132),
)!!
assertEquals(25.5, data.strokeRate!!, 0.0)
assertEquals(16, data.strokes)
assertNull(data.watts)
assertEquals(132, data.bpm)
}
@Test
fun `energy not available reads as none`() {
val data = RowerData.of(frame(0x01, 0x01, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF))!!
assertNull(data.kcal)
}
@Test
fun `a frame cut short reads as nothing`() {
assertNull(RowerData.of(frame()))
assertNull(RowerData.of(frame(0x00, 0x00, 50)))
assertNull(RowerData.of(frame(0x05, 0x00, 0x12, 0x00)))
}
}

View file

@ -94,11 +94,40 @@
"strapUnnamed": "Brustgurt ohne Namen", "strapUnnamed": "Brustgurt ohne Namen",
"strapForget": "Brustgurt vergessen", "strapForget": "Brustgurt vergessen",
"strapRefused": "Train braucht Geräte in der Nähe, um deinen Brustgurt zu finden. Du kannst das in den Systemeinstellungen erlauben.", "strapRefused": "Train braucht Geräte in der Nähe, um deinen Brustgurt zu finden. Du kannst das in den Systemeinstellungen erlauben.",
"strapBluetoothOff": "Bluetooth ist aus. Schalte es ein, um deinen Brustgurt zu finden.", "strapBluetoothOff": "Bluetooth ist aus. Schalte es ein, um nach Geräten in der Nähe zu suchen.",
"recordingHeartRate": "Herzfrequenz", "recordingHeartRate": "Herzfrequenz",
"recordingStrapLost": "Brustgurt nicht verbunden", "recordingStrapLost": "Brustgurt nicht verbunden",
"workoutAverageHeartRate": "mittlere Herzfrequenz", "workoutAverageHeartRate": "mittlere Herzfrequenz",
"workoutMaxHeartRate": "höchste Herzfrequenz", "workoutMaxHeartRate": "höchste Herzfrequenz",
"workoutBpmValue": "{bpm} bpm", "workoutBpmValue": "{bpm} bpm",
"workoutHeartRateChart": "Herzfrequenz" "workoutHeartRateChart": "Herzfrequenz",
"recordRower": "Rudergerät",
"recordNoRower": "Wähle dein Rudergerät",
"recordRowerRefused": "Train braucht Geräte in der Nähe, um von deinem Rudergerät aufzuzeichnen. Du kannst das in den Systemeinstellungen erlauben.",
"rowerSheetTitle": "Rudergerät",
"rowerSearching": "Suche Rudergeräte in der Nähe …",
"rowerSearchingHint": "Weck die Konsole: Die meisten erscheinen, sobald du ziehst oder eine Taste drückst. Eines, das eine andere App hält (Christopeit Connect, Kinomap), ist nicht zu finden: Schließ diese App zuerst.",
"rowerUnnamed": "Rudergerät ohne Namen",
"rowerForget": "Rudergerät vergessen",
"rowerRefused": "Train braucht Geräte in der Nähe, um dein Rudergerät zu finden. Du kannst das in den Systemeinstellungen erlauben.",
"recordingRowerConnecting": "Suche das Rudergerät",
"recordingRowerLost": "Rudergerät nicht verbunden",
"recordingSplit": "Zeit pro 500 m",
"recordingStrokeRate": "Schläge pro Minute",
"recordingWatts": "Watt",
"workoutSplitValue": "{split} /500 m",
"workoutAverageSplit": "mittlere Zeit pro 500 m",
"workoutAverageStrokeRate": "mittlere Schlagzahl",
"workoutStrokeRateValue": "{rate} /min",
"workoutAveragePower": "mittlere Leistung",
"workoutMaxPower": "höchste Leistung",
"workoutWattsValue": "{watts} W",
"workoutStrokes": "Schläge",
"workoutSplitMetres": "{from}–{to} m",
"workoutSplitChart": "Zeit pro 500 m",
"workoutStrokeRateChart": "Schlagzahl",
"workoutPowerChart": "Leistung",
"recordAutoPauseRowingHint": "Wenn du aufhörst zu rudern, weiter mit dem nächsten Schlag",
"recordingMetres": "Meter"
} }

View file

@ -120,7 +120,7 @@
"@strapUnnamed": {"description": "A strap that sends no name."}, "@strapUnnamed": {"description": "A strap that sends no name."},
"strapForget": "Forget strap", "strapForget": "Forget strap",
"strapRefused": "Train needs nearby devices to find your strap. You can allow it in the system's settings.", "strapRefused": "Train needs nearby devices to find your strap. You can allow it in the system's settings.",
"strapBluetoothOff": "Bluetooth is off. Turn it on to find your strap.", "strapBluetoothOff": "Bluetooth is off. Turn it on to look for devices nearby.",
"recordingHeartRate": "heart rate", "recordingHeartRate": "heart rate",
"@recordingHeartRate": {"description": "Under the heart rate, lower case."}, "@recordingHeartRate": {"description": "Under the heart rate, lower case."},
"recordingStrapLost": "Strap lost", "recordingStrapLost": "Strap lost",
@ -131,5 +131,48 @@
"@workoutMaxHeartRate": {"description": "Lower case, under a figure."}, "@workoutMaxHeartRate": {"description": "Lower case, under a figure."},
"workoutBpmValue": "{bpm} bpm", "workoutBpmValue": "{bpm} bpm",
"@workoutBpmValue": {"placeholders": {"bpm": {"type": "int"}}}, "@workoutBpmValue": {"placeholders": {"bpm": {"type": "int"}}},
"workoutHeartRateChart": "Heart rate" "workoutHeartRateChart": "Heart rate",
"recordRower": "Rowing machine",
"recordNoRower": "Choose your rowing machine",
"recordRowerRefused": "Train needs nearby devices to record from your rowing machine. You can allow it in the system's settings.",
"rowerSheetTitle": "Rowing machine",
"rowerSearching": "Looking for rowing machines nearby…",
"rowerSearchingHint": "Wake the console: most show once you pull or press a button. One another app holds (Christopeit Connect, Kinomap) can't be found: close that app first.",
"rowerUnnamed": "Unnamed rowing machine",
"rowerForget": "Forget rowing machine",
"rowerRefused": "Train needs nearby devices to find your rowing machine. You can allow it in the system's settings.",
"recordingRowerConnecting": "Looking for the rowing machine",
"@recordingRowerConnecting": {"description": "While recording a row: the rower isn't connected yet. Another app may hold it."},
"recordingRowerLost": "Rowing machine lost",
"@recordingRowerLost": {"description": "While recording a row: the rower is out of reach or off; Train keeps asking for it."},
"recordingSplit": "split",
"@recordingSplit": {"description": "Lower case, under a figure: how long 500 m take now."},
"recordingStrokeRate": "strokes a minute",
"@recordingStrokeRate": {"description": "Lower case, under a figure."},
"recordingWatts": "watts",
"@recordingWatts": {"description": "Lower case, under a figure."},
"workoutSplitValue": "{split} /500 m",
"@workoutSplitValue": {"description": "Minutes and seconds 500 m take.", "placeholders": {"split": {"type": "String", "example": "2:04"}}},
"workoutAverageSplit": "average split",
"@workoutAverageSplit": {"description": "Lower case, under a figure."},
"workoutAverageStrokeRate": "average stroke rate",
"@workoutAverageStrokeRate": {"description": "Lower case, under a figure."},
"workoutStrokeRateValue": "{rate} /min",
"@workoutStrokeRateValue": {"description": "Strokes a minute.", "placeholders": {"rate": {"type": "String", "example": "24"}}},
"workoutAveragePower": "average power",
"@workoutAveragePower": {"description": "Lower case, under a figure."},
"workoutMaxPower": "highest power",
"@workoutMaxPower": {"description": "Lower case, under a figure."},
"workoutWattsValue": "{watts} W",
"@workoutWattsValue": {"placeholders": {"watts": {"type": "int"}}},
"workoutStrokes": "strokes",
"@workoutStrokes": {"description": "Lower case, under a figure: how many strokes."},
"workoutSplitMetres": "{from}–{to} m",
"@workoutSplitMetres": {"description": "A rowing split of 500 m: where it starts and ends.", "placeholders": {"from": {"type": "int"}, "to": {"type": "int"}}},
"workoutSplitChart": "Split",
"workoutStrokeRateChart": "Stroke rate",
"workoutPowerChart": "Power",
"recordAutoPauseRowingHint": "When you stop rowing, going on with the next stroke",
"recordingMetres": "metres"
} }

View file

@ -619,7 +619,7 @@ abstract class AppLocalizations {
/// No description provided for @strapBluetoothOff. /// No description provided for @strapBluetoothOff.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Bluetooth is off. Turn it on to find your strap.'** /// **'Bluetooth is off. Turn it on to look for devices nearby.'**
String get strapBluetoothOff; String get strapBluetoothOff;
/// Under the heart rate, lower case. /// Under the heart rate, lower case.
@ -657,6 +657,174 @@ abstract class AppLocalizations {
/// In en, this message translates to: /// In en, this message translates to:
/// **'Heart rate'** /// **'Heart rate'**
String get workoutHeartRateChart; String get workoutHeartRateChart;
/// No description provided for @recordRower.
///
/// In en, this message translates to:
/// **'Rowing machine'**
String get recordRower;
/// No description provided for @recordNoRower.
///
/// In en, this message translates to:
/// **'Choose your rowing machine'**
String get recordNoRower;
/// No description provided for @recordRowerRefused.
///
/// In en, this message translates to:
/// **'Train needs nearby devices to record from your rowing machine. You can allow it in the system\'s settings.'**
String get recordRowerRefused;
/// No description provided for @rowerSheetTitle.
///
/// In en, this message translates to:
/// **'Rowing machine'**
String get rowerSheetTitle;
/// No description provided for @rowerSearching.
///
/// In en, this message translates to:
/// **'Looking for rowing machines nearby…'**
String get rowerSearching;
/// No description provided for @rowerSearchingHint.
///
/// In en, this message translates to:
/// **'Wake the console: most show once you pull or press a button. One another app holds (Christopeit Connect, Kinomap) can\'t be found: close that app first.'**
String get rowerSearchingHint;
/// No description provided for @rowerUnnamed.
///
/// In en, this message translates to:
/// **'Unnamed rowing machine'**
String get rowerUnnamed;
/// No description provided for @rowerForget.
///
/// In en, this message translates to:
/// **'Forget rowing machine'**
String get rowerForget;
/// No description provided for @rowerRefused.
///
/// In en, this message translates to:
/// **'Train needs nearby devices to find your rowing machine. You can allow it in the system\'s settings.'**
String get rowerRefused;
/// While recording a row: the rower isn't connected yet. Another app may hold it.
///
/// In en, this message translates to:
/// **'Looking for the rowing machine'**
String get recordingRowerConnecting;
/// While recording a row: the rower is out of reach or off; Train keeps asking for it.
///
/// In en, this message translates to:
/// **'Rowing machine lost'**
String get recordingRowerLost;
/// Lower case, under a figure: how long 500 m take now.
///
/// In en, this message translates to:
/// **'split'**
String get recordingSplit;
/// Lower case, under a figure.
///
/// In en, this message translates to:
/// **'strokes a minute'**
String get recordingStrokeRate;
/// Lower case, under a figure.
///
/// In en, this message translates to:
/// **'watts'**
String get recordingWatts;
/// Minutes and seconds 500 m take.
///
/// In en, this message translates to:
/// **'{split} /500 m'**
String workoutSplitValue(String split);
/// Lower case, under a figure.
///
/// In en, this message translates to:
/// **'average split'**
String get workoutAverageSplit;
/// Lower case, under a figure.
///
/// In en, this message translates to:
/// **'average stroke rate'**
String get workoutAverageStrokeRate;
/// Strokes a minute.
///
/// In en, this message translates to:
/// **'{rate} /min'**
String workoutStrokeRateValue(String rate);
/// Lower case, under a figure.
///
/// In en, this message translates to:
/// **'average power'**
String get workoutAveragePower;
/// Lower case, under a figure.
///
/// In en, this message translates to:
/// **'highest power'**
String get workoutMaxPower;
/// No description provided for @workoutWattsValue.
///
/// In en, this message translates to:
/// **'{watts} W'**
String workoutWattsValue(int watts);
/// Lower case, under a figure: how many strokes.
///
/// In en, this message translates to:
/// **'strokes'**
String get workoutStrokes;
/// A rowing split of 500 m: where it starts and ends.
///
/// In en, this message translates to:
/// **'{from}–{to} m'**
String workoutSplitMetres(int from, int to);
/// No description provided for @workoutSplitChart.
///
/// In en, this message translates to:
/// **'Split'**
String get workoutSplitChart;
/// No description provided for @workoutStrokeRateChart.
///
/// In en, this message translates to:
/// **'Stroke rate'**
String get workoutStrokeRateChart;
/// No description provided for @workoutPowerChart.
///
/// In en, this message translates to:
/// **'Power'**
String get workoutPowerChart;
/// No description provided for @recordAutoPauseRowingHint.
///
/// In en, this message translates to:
/// **'When you stop rowing, going on with the next stroke'**
String get recordAutoPauseRowingHint;
/// No description provided for @recordingMetres.
///
/// In en, this message translates to:
/// **'metres'**
String get recordingMetres;
} }
class _AppLocalizationsDelegate class _AppLocalizationsDelegate

View file

@ -333,7 +333,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get strapBluetoothOff => String get strapBluetoothOff =>
'Bluetooth ist aus. Schalte es ein, um deinen Brustgurt zu finden.'; 'Bluetooth ist aus. Schalte es ein, um nach Geräten in der Nähe zu suchen.';
@override @override
String get recordingHeartRate => 'Herzfrequenz'; String get recordingHeartRate => 'Herzfrequenz';
@ -354,4 +354,100 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get workoutHeartRateChart => 'Herzfrequenz'; String get workoutHeartRateChart => 'Herzfrequenz';
@override
String get recordRower => 'Rudergerät';
@override
String get recordNoRower => 'Wähle dein Rudergerät';
@override
String get recordRowerRefused =>
'Train braucht Geräte in der Nähe, um von deinem Rudergerät aufzuzeichnen. Du kannst das in den Systemeinstellungen erlauben.';
@override
String get rowerSheetTitle => 'Rudergerät';
@override
String get rowerSearching => 'Suche Rudergeräte in der Nähe …';
@override
String get rowerSearchingHint =>
'Weck die Konsole: Die meisten erscheinen, sobald du ziehst oder eine Taste drückst. Eines, das eine andere App hält (Christopeit Connect, Kinomap), ist nicht zu finden: Schließ diese App zuerst.';
@override
String get rowerUnnamed => 'Rudergerät ohne Namen';
@override
String get rowerForget => 'Rudergerät vergessen';
@override
String get rowerRefused =>
'Train braucht Geräte in der Nähe, um dein Rudergerät zu finden. Du kannst das in den Systemeinstellungen erlauben.';
@override
String get recordingRowerConnecting => 'Suche das Rudergerät';
@override
String get recordingRowerLost => 'Rudergerät nicht verbunden';
@override
String get recordingSplit => 'Zeit pro 500 m';
@override
String get recordingStrokeRate => 'Schläge pro Minute';
@override
String get recordingWatts => 'Watt';
@override
String workoutSplitValue(String split) {
return '$split /500 m';
}
@override
String get workoutAverageSplit => 'mittlere Zeit pro 500 m';
@override
String get workoutAverageStrokeRate => 'mittlere Schlagzahl';
@override
String workoutStrokeRateValue(String rate) {
return '$rate /min';
}
@override
String get workoutAveragePower => 'mittlere Leistung';
@override
String get workoutMaxPower => 'höchste Leistung';
@override
String workoutWattsValue(int watts) {
return '$watts W';
}
@override
String get workoutStrokes => 'Schläge';
@override
String workoutSplitMetres(int from, int to) {
return '$from–$to m';
}
@override
String get workoutSplitChart => 'Zeit pro 500 m';
@override
String get workoutStrokeRateChart => 'Schlagzahl';
@override
String get workoutPowerChart => 'Leistung';
@override
String get recordAutoPauseRowingHint =>
'Wenn du aufhörst zu rudern, weiter mit dem nächsten Schlag';
@override
String get recordingMetres => 'Meter';
} }

View file

@ -330,7 +330,7 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get strapBluetoothOff => String get strapBluetoothOff =>
'Bluetooth is off. Turn it on to find your strap.'; 'Bluetooth is off. Turn it on to look for devices nearby.';
@override @override
String get recordingHeartRate => 'heart rate'; String get recordingHeartRate => 'heart rate';
@ -351,6 +351,102 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get workoutHeartRateChart => 'Heart rate'; String get workoutHeartRateChart => 'Heart rate';
@override
String get recordRower => 'Rowing machine';
@override
String get recordNoRower => 'Choose your rowing machine';
@override
String get recordRowerRefused =>
'Train needs nearby devices to record from your rowing machine. You can allow it in the system\'s settings.';
@override
String get rowerSheetTitle => 'Rowing machine';
@override
String get rowerSearching => 'Looking for rowing machines nearby…';
@override
String get rowerSearchingHint =>
'Wake the console: most show once you pull or press a button. One another app holds (Christopeit Connect, Kinomap) can\'t be found: close that app first.';
@override
String get rowerUnnamed => 'Unnamed rowing machine';
@override
String get rowerForget => 'Forget rowing machine';
@override
String get rowerRefused =>
'Train needs nearby devices to find your rowing machine. You can allow it in the system\'s settings.';
@override
String get recordingRowerConnecting => 'Looking for the rowing machine';
@override
String get recordingRowerLost => 'Rowing machine lost';
@override
String get recordingSplit => 'split';
@override
String get recordingStrokeRate => 'strokes a minute';
@override
String get recordingWatts => 'watts';
@override
String workoutSplitValue(String split) {
return '$split /500 m';
}
@override
String get workoutAverageSplit => 'average split';
@override
String get workoutAverageStrokeRate => 'average stroke rate';
@override
String workoutStrokeRateValue(String rate) {
return '$rate /min';
}
@override
String get workoutAveragePower => 'average power';
@override
String get workoutMaxPower => 'highest power';
@override
String workoutWattsValue(int watts) {
return '$watts W';
}
@override
String get workoutStrokes => 'strokes';
@override
String workoutSplitMetres(int from, int to) {
return '$from–$to m';
}
@override
String get workoutSplitChart => 'Split';
@override
String get workoutStrokeRateChart => 'Stroke rate';
@override
String get workoutPowerChart => 'Power';
@override
String get recordAutoPauseRowingHint =>
'When you stop rowing, going on with the next stroke';
@override
String get recordingMetres => 'metres';
} }
/// The translations for English, as used in the United Kingdom (`en_GB`). /// The translations for English, as used in the United Kingdom (`en_GB`).

View file

@ -108,6 +108,7 @@ class const _Recording({required final Widget child}) extends StatelessWidget {
train.workouts, train.workouts,
recording.file, recording.file,
recording.straps, recording.straps,
recording.rowers,
notice: (live) => noticeOf( notice: (live) => noticeOf(
AppLocalizations.of(context), AppLocalizations.of(context),
Localizations.localeOf(context).toString(), Localizations.localeOf(context).toString(),

View file

@ -11,8 +11,8 @@ import 'package:halkyon_train_flutter/src/core/privacy/train_keys.dart';
import 'package:halkyon_train_flutter/src/feature/map/data/map_source_repository.dart'; import 'package:halkyon_train_flutter/src/feature/map/data/map_source_repository.dart';
import 'package:halkyon_train_flutter/src/feature/map/widget/map_tiles.dart'; import 'package:halkyon_train_flutter/src/feature/map/widget/map_tiles.dart';
import 'package:halkyon_train_flutter/src/feature/recording/data/android_recorder_platform.dart'; import 'package:halkyon_train_flutter/src/feature/recording/data/android_recorder_platform.dart';
import 'package:halkyon_train_flutter/src/feature/recording/data/device_store.dart';
import 'package:halkyon_train_flutter/src/feature/recording/data/recorder_platform.dart'; import 'package:halkyon_train_flutter/src/feature/recording/data/recorder_platform.dart';
import 'package:halkyon_train_flutter/src/feature/recording/data/strap_store.dart';
import 'package:halkyon_train_flutter/src/feature/workout/data/sync_workout_gateway.dart'; import 'package:halkyon_train_flutter/src/feature/workout/data/sync_workout_gateway.dart';
import 'package:halkyon_train_flutter/src/feature/workout/data/workout_repository.dart'; import 'package:halkyon_train_flutter/src/feature/workout/data/workout_repository.dart';
import 'package:package_info_plus/package_info_plus.dart'; import 'package:package_info_plus/package_info_plus.dart';
@ -22,12 +22,14 @@ import 'package:serverpod_auth_idp_flutter/serverpod_auth_idp_flutter.dart';
import 'package:serverpod_flutter/serverpod_flutter.dart'; import 'package:serverpod_flutter/serverpod_flutter.dart';
import 'package:serverpod_offline_sync_client/offline_sync.dart'; import 'package:serverpod_offline_sync_client/offline_sync.dart';
/// Where a device records workouts (ADR 0030, D13, D18, D27): its recorder, /// Where a device records workouts (ADR 0030, D13, D18, D27, D28): its
/// and the person's recording file and strap in their folder. /// recorder, and the person's recording file, strap and rower in their
/// folder.
typedef RecordingDevice = ({ typedef RecordingDevice = ({
RecorderPlatform platform, RecorderPlatform platform,
File file, File file,
StrapStore straps, DeviceStore straps,
DeviceStore rowers,
}); });
/// A signed-in person's dependencies: who they are and their workouts, on /// A signed-in person's dependencies: who they are and their workouts, on
@ -298,7 +300,8 @@ Future<TrainPerson> _openPersonData(
? ( ? (
platform: AndroidRecorderPlatform(), platform: AndroidRecorderPlatform(),
file: File(p.join(folder.path, 'recording.txt')), file: File(p.join(folder.path, 'recording.txt')),
straps: StrapStore(File(p.join(folder.path, 'strap.json'))), straps: DeviceStore(File(p.join(folder.path, 'strap.json'))),
rowers: DeviceStore(File(p.join(folder.path, 'rower.json'))),
) )
: null, : null,
onDispose: () async { onDispose: () async {

View file

@ -0,0 +1,97 @@
import 'dart:async';
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_train_flutter/src/feature/recording/data/recorder_platform.dart';
/// Looks for straps or rowers nearby (ADR 0030, D27, D28), once nearby
/// devices are allowed (asked first, as D19 asks when a thing is first
/// needed), until it is closed.
class DeviceSearchCubit(
final RecorderPlatform _platform,
/// What it looks for.
final DeviceKind kind,
) extends Cubit<DeviceSearchState> {
this : super(const DeviceSearchState());
StreamSubscription<FoundDevice>? _scan;
Future<void> open() async {
final granted = await _platform.ask({RecorderPermission.bluetooth});
if (isClosed) {
return;
}
if (!granted.contains(RecorderPermission.bluetooth)) {
emit(const DeviceSearchState(phase: DeviceSearchPhase.refused));
return;
}
emit(const DeviceSearchState(phase: DeviceSearchPhase.searching));
_scan = _platform
.scan(kind)
.listen(
_seen,
onError: (Object error) {
if (error is BluetoothOff && !isClosed) {
emit(
const DeviceSearchState(phase: DeviceSearchPhase.bluetoothOff),
);
}
},
);
}
void _seen(FoundDevice device) {
final before = [
for (final found in state.found)
if (found.address == device.address) found,
].firstOrNull;
final seen = FoundDevice(
address: device.address,
// Some send their name in one advertisement and not the next.
name: device.name ?? before?.name,
rssi: device.rssi,
);
emit(
DeviceSearchState(
phase: state.phase,
found: [
for (final found in state.found)
if (found.address != device.address) found,
seen,
]..sort((a, b) => b.rssi.compareTo(a.rssi)),
),
);
}
@override
Future<void> close() async {
await _scan?.cancel();
await super.close();
}
}
/// Where the search stands.
enum DeviceSearchPhase() {
/// Asking for nearby devices.
asking,
/// Looking: what it found so far.
searching,
/// Nearby devices refused.
refused,
/// Bluetooth off.
bluetoothOff,
}
final class const DeviceSearchState({
final DeviceSearchPhase phase = DeviceSearchPhase.asking,
/// The devices seen, the nearest first.
final List<FoundDevice> found = const [],
}) with Equatable {
@override
List<Object?> get props => [phase, found];
}

View file

@ -5,11 +5,11 @@ import 'dart:io';
import 'package:clock/clock.dart'; import 'package:clock/clock.dart';
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_train_flutter/src/feature/recording/data/device_store.dart';
import 'package:halkyon_train_flutter/src/feature/recording/data/live_recording.dart'; import 'package:halkyon_train_flutter/src/feature/recording/data/live_recording.dart';
import 'package:halkyon_train_flutter/src/feature/recording/data/recorded_workout.dart'; import 'package:halkyon_train_flutter/src/feature/recording/data/recorded_workout.dart';
import 'package:halkyon_train_flutter/src/feature/recording/data/recorder_platform.dart'; import 'package:halkyon_train_flutter/src/feature/recording/data/recorder_platform.dart';
import 'package:halkyon_train_flutter/src/feature/recording/data/recording_file.dart'; import 'package:halkyon_train_flutter/src/feature/recording/data/recording_file.dart';
import 'package:halkyon_train_flutter/src/feature/recording/data/strap_store.dart';
import 'package:halkyon_train_flutter/src/feature/recording/model/live_recording.dart'; import 'package:halkyon_train_flutter/src/feature/recording/model/live_recording.dart';
import 'package:halkyon_train_flutter/src/feature/recording/model/recording.dart'; import 'package:halkyon_train_flutter/src/feature/recording/model/recording.dart';
import 'package:halkyon_train_flutter/src/feature/workout/data/workout_repository.dart'; import 'package:halkyon_train_flutter/src/feature/workout/data/workout_repository.dart';
@ -21,7 +21,8 @@ import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
/// it away once stopped. As it opens, it finds a recording still running (the /// it away once stopped. As it opens, it finds a recording still running (the
/// 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). /// it is switched off for it (D27), and their rower, which a row needs
/// (D28).
/// ///
/// 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.
@ -32,8 +33,9 @@ class RecordingBloc(
/// The person's recording file, in their folder. /// The person's recording file, in their folder.
final File _file, final File _file,
/// The person's strap on this device, in their folder. /// The person's strap and rower on this device, in their folder.
final StrapStore _straps, { final DeviceStore _straps,
final DeviceStore _rowers, {
/// What the notification shows of [LiveRecording], in the app's language. /// What the notification shows of [LiveRecording], in the app's language.
required final RecorderNotice Function(LiveRecording live) notice, required final RecorderNotice Function(LiveRecording live) notice,
@ -49,13 +51,13 @@ class RecordingBloc(
on<RecordingEventStopRequested>(_onStop); on<RecordingEventStopRequested>(_onStop);
on<RecordingEventSaveRequested>(_onSave); on<RecordingEventSaveRequested>(_onSave);
on<RecordingEventDiscardRequested>(_onDiscard); on<RecordingEventDiscardRequested>(_onDiscard);
on<RecordingEventStrapChosen>((event, emit) { on<RecordingEventDeviceChosen>((event, emit) {
_straps.save(event.strap); _storeOf(event.kind).save(event.device);
emit(state._withStrap(event.strap)); emit(state._withDevice(event.kind, event.device));
}); });
on<RecordingEventStrapForgotten>((event, emit) { on<RecordingEventDeviceForgotten>((event, emit) {
_straps.forget(); _storeOf(event.kind).forget();
emit(state._withStrap(null)); emit(state._withDevice(event.kind, null));
}); });
on<RecordingEventLineWritten>((event, emit) { on<RecordingEventLineWritten>((event, emit) {
_lines.writeln(event.line); _lines.writeln(event.line);
@ -82,12 +84,18 @@ class RecordingBloc(
/// What the notification shows now, so it is told only of changes. /// What the notification shows now, so it is told only of changes.
(String, String, bool)? _shown; (String, String, bool)? _shown;
DeviceStore _storeOf(DeviceKind kind) => switch (kind) {
DeviceKind.strap => _straps,
DeviceKind.rower => _rowers,
};
Future<void> _onOpened( Future<void> _onOpened(
RecordingEventOpened event, RecordingEventOpened event,
Emitter<RecordingState> emit, Emitter<RecordingState> emit,
) async { ) async {
final lastActivity = await _lastActivity(); final lastActivity = await _lastActivity();
final strap = _straps.read(); final strap = _straps.read();
final rower = _rowers.read();
if (await _platform.isRecording()) { if (await _platform.isRecording()) {
_follow(); _follow();
emit( emit(
@ -95,6 +103,7 @@ class RecordingBloc(
phase: RecordingPhase.recording, phase: RecordingPhase.recording,
lastActivity: lastActivity, lastActivity: lastActivity,
strap: strap, strap: strap,
rower: rower,
), ),
); );
return; return;
@ -110,6 +119,7 @@ class RecordingBloc(
lastActivity: lastActivity, lastActivity: lastActivity,
recording: kept, recording: kept,
strap: strap, strap: strap,
rower: rower,
), ),
); );
} }
@ -119,9 +129,21 @@ class RecordingBloc(
Emitter<RecordingState> emit, Emitter<RecordingState> emit,
) async { ) async {
// Asked anew: a refusal before says nothing of this one. // Asked anew: a refusal before says nothing of this one.
emit(state.copyWith(locationRefused: false)); emit(
state.copyWith(
locationRefused: false,
bluetoothRefused: false,
rowerMissing: false,
),
);
final activity = event.activity; final activity = event.activity;
final gps = usesGps(activity); final gps = usesGps(activity);
final rows = activity == Activity.indoorRowing;
final rower = state.rower;
if (rows && rower == null) {
emit(state.copyWith(rowerMissing: true));
return;
}
final autoPause = !event.autoPause final autoPause = !event.autoPause
? null ? null
: switch (activity) { : switch (activity) {
@ -129,9 +151,11 @@ class RecordingBloc(
Activity.running || Activity.running ||
Activity.walking || Activity.walking ||
Activity.hiking => AutoPauseBy.steps, Activity.hiking => AutoPauseBy.steps,
Activity.indoorRowing => null, Activity.indoorRowing => AutoPauseBy.strokes,
}; };
final granted = await _platform.ask({ final granted = await _platform.ask({
// The rower's connection, which also gives Android its service's type.
if (rows) RecorderPermission.bluetooth,
if (gps) RecorderPermission.location, if (gps) RecorderPermission.location,
RecorderPermission.notifications, RecorderPermission.notifications,
if (autoPause == AutoPauseBy.steps) RecorderPermission.activity, if (autoPause == AutoPauseBy.steps) RecorderPermission.activity,
@ -140,6 +164,10 @@ class RecordingBloc(
emit(state.copyWith(locationRefused: true)); emit(state.copyWith(locationRefused: true));
return; return;
} }
if (rows && !granted.contains(RecorderPermission.bluetooth)) {
emit(state.copyWith(bluetoothRefused: true));
return;
}
final now = clock.now().toUtc(); final now = clock.now().toUtc();
final notice = _notice( final notice = _notice(
liveOf(Recording(activity: activity, startedAt: now), now), liveOf(Recording(activity: activity, startedAt: now), now),
@ -150,6 +178,7 @@ class RecordingBloc(
autoPause: autoPause, autoPause: autoPause,
notice: notice, notice: notice,
strap: event.useStrap ? state.strap?.address : null, strap: event.useStrap ? state.strap?.address : null,
rower: rows ? rower?.address : null,
); );
_shown = _keyOf(notice); _shown = _keyOf(notice);
_follow(); _follow();
@ -159,6 +188,7 @@ class RecordingBloc(
lastActivity: activity, lastActivity: activity,
live: liveOf(Recording(activity: activity, startedAt: now), now), live: liveOf(Recording(activity: activity, startedAt: now), now),
strap: state.strap, strap: state.strap,
rower: state.rower,
), ),
); );
} }
@ -176,6 +206,7 @@ class RecordingBloc(
lastActivity: state.lastActivity, lastActivity: state.lastActivity,
recording: recording, recording: recording,
strap: state.strap, strap: state.strap,
rower: state.rower,
), ),
); );
} }
@ -203,6 +234,7 @@ class RecordingBloc(
phase: RecordingPhase.idle, phase: RecordingPhase.idle,
lastActivity: recording.activity, lastActivity: recording.activity,
strap: state.strap, strap: state.strap,
rower: state.rower,
), ),
); );
} }
@ -217,6 +249,7 @@ class RecordingBloc(
phase: RecordingPhase.idle, phase: RecordingPhase.idle,
lastActivity: state.lastActivity, lastActivity: state.lastActivity,
strap: state.strap, strap: state.strap,
rower: state.rower,
), ),
); );
} }
@ -337,13 +370,23 @@ final class const RecordingState({
/// Whether saving failed (as on a device without the account's keys). /// Whether saving failed (as on a device without the account's keys).
final bool saveFailed = false, final bool saveFailed = false,
/// The person's strap on this device, if they chose one (D27). /// Whether nearby devices were refused as a row was to start (D28).
final Strap? strap, final bool bluetoothRefused = false,
/// Whether a row was to start without a rower chosen.
final bool rowerMissing = false,
/// The person's strap and rower on this device, if they chose them (D27,
/// D28).
final KnownDevice? strap,
final KnownDevice? rower,
}) with Equatable { }) with Equatable {
RecordingState copyWith({ RecordingState copyWith({
LiveRecording? live, LiveRecording? live,
bool? locationRefused, bool? locationRefused,
bool? saveFailed, bool? saveFailed,
bool? bluetoothRefused,
bool? rowerMissing,
}) => RecordingState( }) => RecordingState(
phase: phase, phase: phase,
lastActivity: lastActivity, lastActivity: lastActivity,
@ -351,18 +394,25 @@ final class const RecordingState({
recording: recording, recording: recording,
locationRefused: locationRefused ?? this.locationRefused, locationRefused: locationRefused ?? this.locationRefused,
saveFailed: saveFailed ?? this.saveFailed, saveFailed: saveFailed ?? this.saveFailed,
bluetoothRefused: bluetoothRefused ?? this.bluetoothRefused,
rowerMissing: rowerMissing ?? this.rowerMissing,
strap: strap, strap: strap,
rower: rower,
); );
RecordingState _withStrap(Strap? strap) => RecordingState( RecordingState _withDevice(DeviceKind kind, KnownDevice? device) =>
phase: phase, RecordingState(
lastActivity: lastActivity, phase: phase,
live: live, lastActivity: lastActivity,
recording: recording, live: live,
locationRefused: locationRefused, recording: recording,
saveFailed: saveFailed, locationRefused: locationRefused,
strap: strap, saveFailed: saveFailed,
); bluetoothRefused: bluetoothRefused,
rowerMissing: rowerMissing,
strap: kind == DeviceKind.strap ? device : strap,
rower: kind == DeviceKind.rower ? device : rower,
);
@override @override
List<Object?> get props => [ List<Object?> get props => [
@ -372,7 +422,10 @@ final class const RecordingState({
recording, recording,
locationRefused, locationRefused,
saveFailed, saveFailed,
bluetoothRefused,
rowerMissing,
strap, strap,
rower,
]; ];
} }
@ -390,12 +443,15 @@ final class const RecordingEventStartRequested(
final bool useStrap = true, final bool useStrap = true,
}) extends RecordingEvent; }) extends RecordingEvent;
/// The person chose [strap] to record with on this device. /// The person chose [device] as their [kind] on this device.
final class const RecordingEventStrapChosen(final Strap strap) final class const RecordingEventDeviceChosen(
extends RecordingEvent; final DeviceKind kind,
final KnownDevice device,
) extends RecordingEvent;
/// The person forgot their strap on this device. /// The person forgot their [kind] on this device.
final class const RecordingEventStrapForgotten() extends RecordingEvent; final class const RecordingEventDeviceForgotten(final DeviceKind kind)
extends RecordingEvent;
final class const RecordingEventPauseRequested() extends RecordingEvent; final class const RecordingEventPauseRequested() extends RecordingEvent;

View file

@ -1,89 +0,0 @@
import 'dart:async';
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_train_flutter/src/feature/recording/data/recorder_platform.dart';
/// Looks for heart-rate straps nearby (ADR 0030, D27), once nearby devices
/// are allowed (asked first, as D19 asks when a thing is first needed), until
/// it is closed.
class StrapSearchCubit(final RecorderPlatform _platform)
extends Cubit<StrapSearchState> {
this : super(const StrapSearchState());
StreamSubscription<FoundStrap>? _scan;
Future<void> open() async {
final granted = await _platform.ask({RecorderPermission.bluetooth});
if (isClosed) {
return;
}
if (!granted.contains(RecorderPermission.bluetooth)) {
emit(const StrapSearchState(phase: StrapSearchPhase.refused));
return;
}
emit(const StrapSearchState(phase: StrapSearchPhase.searching));
_scan = _platform.scanStraps().listen(
_seen,
onError: (Object error) {
if (error is BluetoothOff && !isClosed) {
emit(const StrapSearchState(phase: StrapSearchPhase.bluetoothOff));
}
},
);
}
void _seen(FoundStrap strap) {
final before = [
for (final found in state.found)
if (found.address == strap.address) found,
].firstOrNull;
final seen = FoundStrap(
address: strap.address,
// Some straps send their name in one advertisement and not the next.
name: strap.name ?? before?.name,
rssi: strap.rssi,
);
emit(
StrapSearchState(
phase: state.phase,
found: [
for (final found in state.found)
if (found.address != strap.address) found,
seen,
]..sort((a, b) => b.rssi.compareTo(a.rssi)),
),
);
}
@override
Future<void> close() async {
await _scan?.cancel();
await super.close();
}
}
/// Where the search stands.
enum StrapSearchPhase() {
/// Asking for nearby devices.
asking,
/// Looking: what it found so far.
searching,
/// Nearby devices refused.
refused,
/// Bluetooth off.
bluetoothOff,
}
final class const StrapSearchState({
final StrapSearchPhase phase = StrapSearchPhase.asking,
/// The straps seen, the nearest first.
final List<FoundStrap> found = const [],
}) with Equatable {
@override
List<Object?> get props => [phase, found];
}

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