From f33e99ebb06ed4ced748b1c07410684d631e8787 Mon Sep 17 00:00:00 2001 From: Bernd Helm Date: Mon, 24 Aug 2026 20:13:35 +0200 Subject: [PATCH 01/45] feat(android): add hardware ground truth collector --- .../noop/testcentre/GroundTruthCollector.kt | 280 ++++++++++++++++++ .../app/src/main/java/com/noop/ui/AppRoot.kt | 8 +- .../com/noop/ui/GroundTruthCollectorScreen.kt | 200 +++++++++++++ .../main/java/com/noop/ui/TestCentreScreen.kt | 16 +- android/app/src/main/res/values/strings.xml | 20 ++ 5 files changed, 522 insertions(+), 2 deletions(-) create mode 100644 android/app/src/main/java/com/noop/testcentre/GroundTruthCollector.kt create mode 100644 android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt diff --git a/android/app/src/main/java/com/noop/testcentre/GroundTruthCollector.kt b/android/app/src/main/java/com/noop/testcentre/GroundTruthCollector.kt new file mode 100644 index 0000000000..57a8c4cbf5 --- /dev/null +++ b/android/app/src/main/java/com/noop/testcentre/GroundTruthCollector.kt @@ -0,0 +1,280 @@ +package com.noop.testcentre + +import android.content.Context +import android.content.Intent +import android.os.Build +import androidx.core.content.FileProvider +import com.noop.BuildConfig +import com.noop.data.WhoopRepository +import com.noop.ingest.RawSensorExport +import java.io.File +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.json.JSONObject + +/** App-private, explicitly user-started manual ground-truth capture. No production metric reads this. */ +class GroundTruthCollector private constructor(private val context: Context) { + data class KeyInfo( + val keyCode: Int, + val keyName: String, + val scanCode: Int, + val deviceId: Int, + val deviceName: String, + val source: Int, + ) + + data class Snapshot( + val active: Boolean, + val sessionId: String?, + val deviceId: String?, + val startedAtMs: Long, + val endedAtMs: Long, + val steps: Int, + val stairs: Int, + val lastKind: String?, + val lastKey: KeyInfo?, + val noopStepsAtStart: Int?, + val lastNoopSteps: Int?, + val exported: Boolean, + ) + + private val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + private val directory = File(context.filesDir, "ground-truth").apply { mkdirs() } + + @Synchronized + fun snapshot(): Snapshot = Snapshot( + active = prefs.getBoolean("active", false), + sessionId = prefs.getString("sessionId", null), + deviceId = prefs.getString("deviceId", null), + startedAtMs = prefs.getLong("startedAtMs", 0L), + endedAtMs = prefs.getLong("endedAtMs", 0L), + steps = prefs.getInt("steps", 0), + stairs = prefs.getInt("stairs", 0), + lastKind = prefs.getString("lastKind", null), + lastKey = prefs.getString("lastKey", null)?.let(::decodeKey), + noopStepsAtStart = prefs.getInt("noopStepsAtStart", Int.MIN_VALUE).takeUnless { it == Int.MIN_VALUE }, + lastNoopSteps = prefs.getInt("lastNoopSteps", Int.MIN_VALUE).takeUnless { it == Int.MIN_VALUE }, + exported = prefs.getBoolean("exported", false), + ) + + @Synchronized + fun start(noopSteps: Int, deviceId: String, nowMs: Long = System.currentTimeMillis()): Snapshot { + check(snapshot().sessionId == null || snapshot().exported) { "Export the previous session before starting another" } + val id = nowMs.toString() + prefs.edit().clear() + .putBoolean("active", true) + .putBoolean("exported", false) + .putString("sessionId", id) + .putString("deviceId", deviceId) + .putLong("startedAtMs", nowMs) + .putInt("steps", 0) + .putInt("stairs", 0) + .applyNoopStart(noopSteps) + .applyNoop(noopSteps) + .apply() + append(id, event(nowMs, "start", 0, 0, noopSteps, null)) + return snapshot() + } + + @Synchronized + fun record(kind: String, key: KeyInfo, noopSteps: Int?, nowMs: Long = System.currentTimeMillis()): Snapshot { + val before = snapshot() + if (!before.active || before.sessionId == null) return before + val steps = before.steps + if (kind == KIND_STEP || kind == KIND_STAIR) 1 else 0 + val stairs = before.stairs + if (kind == KIND_STAIR) 1 else 0 + append(before.sessionId, event(nowMs, kind, steps, stairs, noopSteps, key)) + prefs.edit() + .putInt("steps", steps) + .putInt("stairs", stairs) + .putString("lastKind", kind) + .putString("lastKey", encodeKey(key)) + .applyNoop(noopSteps) + .apply() + return snapshot() + } + + /** Record an unmapped key for diagnosis without changing either ground-truth counter. */ + @Synchronized + fun observeKey(key: KeyInfo, noopSteps: Int?, nowMs: Long = System.currentTimeMillis()): Snapshot { + val before = snapshot() + prefs.edit().putString("lastKey", encodeKey(key)).applyNoop(noopSteps).apply() + if (before.active && before.sessionId != null) { + append(before.sessionId, event(nowMs, KIND_KEY, before.steps, before.stairs, noopSteps, key)) + } + return snapshot() + } + + @Synchronized + fun undo(noopSteps: Int?, nowMs: Long = System.currentTimeMillis()): Snapshot { + val before = snapshot() + if (!before.active || before.sessionId == null) return before + val undoKind = when (before.lastKind) { + KIND_STEP -> KIND_UNDO_STEP + KIND_STAIR -> KIND_UNDO_STAIR + else -> return before + } + val steps = (before.steps - 1).coerceAtLeast(0) + val stairs = (before.stairs - if (undoKind == KIND_UNDO_STAIR) 1 else 0).coerceAtLeast(0) + append(before.sessionId, event(nowMs, undoKind, steps, stairs, noopSteps, null)) + prefs.edit().putInt("steps", steps).putInt("stairs", stairs).remove("lastKind") + .applyNoop(noopSteps).apply() + return snapshot() + } + + @Synchronized + fun stop(noopSteps: Int?, nowMs: Long = System.currentTimeMillis()): Snapshot { + val before = snapshot() + if (!before.active || before.sessionId == null) return before + append(before.sessionId, event(nowMs, "stop", before.steps, before.stairs, noopSteps, null)) + prefs.edit().putBoolean("active", false).putLong("endedAtMs", nowMs) + .applyNoop(noopSteps).apply() + return snapshot() + } + + suspend fun export(repo: WhoopRepository): File = withContext(Dispatchers.IO) { + val snap = snapshot() + require(!snap.active) { "Stop the session before exporting" } + val id = requireNotNull(snap.sessionId) { "No ground-truth session has been recorded" } + val deviceId = requireNotNull(snap.deviceId) { "The session has no recorded device" } + val source = eventFile(id) + require(source.isFile) { "Ground-truth event file is missing" } + val events = source.useLines { lines -> lines.filter { it.isNotBlank() }.map(::JSONObject).toList() } + val endMs = snap.endedAtMs.takeIf { it > 0 } ?: System.currentTimeMillis() + val outDir = File(context.cacheDir, "logs").apply { mkdirs() } + val zip = File(outDir, "noop-ground-truth-$id.zip") + ZipOutputStream(zip.outputStream().buffered()).use { out -> + out.putNextEntry(ZipEntry("meta.json")) + out.writerEntry(JSONObject().apply { + put("schema_version", 1) + put("session_id", id) + put("device_id", deviceId) + put("started_at_ms", snap.startedAtMs) + put("ended_at_ms", endMs) + put("manual_steps", snap.steps) + put("manual_stairs", snap.stairs) + if (snap.noopStepsAtStart != null) put("noop_steps_at_start", snap.noopStepsAtStart) + if (snap.lastNoopSteps != null) put("noop_steps_at_end", snap.lastNoopSteps) + put("device_family", "Android") + put("app_version", BuildConfig.VERSION_NAME) + put("ground_truth", "JX-05S hardware clicker; ZOOM_OUT=step, ZOOM_IN=stair") + }.toString(2)) + + out.putNextEntry(ZipEntry("events.jsonl")) + source.inputStream().use { it.copyTo(out) } + out.closeEntry() + + out.putNextEntry(ZipEntry("events.csv")) + out.writerEntry(eventsCsv(events)) + + out.putNextEntry(ZipEntry("seconds.csv")) + out.writerEntry(secondsCsv(events, snap.startedAtMs, endMs)) + + out.putNextEntry(ZipEntry("raw-sensors.csv")) + out.bufferedWriterNoClose().use { writer -> + RawSensorExport.writeCsv( + writer, repo, deviceId, + snap.startedAtMs / 1_000L - 5L, + endMs / 1_000L + 5L, + ) + writer.flush() + } + out.closeEntry() + } + prefs.edit().putBoolean("exported", true).apply() + zip + } + + fun share(file: File) { + val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file) + context.startActivity(Intent.createChooser(Intent(Intent.ACTION_SEND).apply { + type = "application/zip" + putExtra(Intent.EXTRA_STREAM, uri) + putExtra(Intent.EXTRA_SUBJECT, "NOOP ground-truth session") + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + }, "Export ground-truth session")) + } + + private fun eventFile(id: String) = File(directory, "session-$id.jsonl") + private fun append(id: String, value: JSONObject) = eventFile(id).appendText(value.toString() + "\n") + + private fun event( + atMs: Long, kind: String, steps: Int, stairs: Int, noopSteps: Int?, key: KeyInfo?, + ) = JSONObject().apply { + put("at_ms", atMs); put("kind", kind); put("steps_total", steps); put("stairs_total", stairs) + if (noopSteps != null) put("noop_steps", noopSteps) + if (key != null) { + put("key_code", key.keyCode); put("key_name", key.keyName); put("scan_code", key.scanCode) + put("device_id", key.deviceId); put("device_name", key.deviceName); put("source", key.source) + } + } + + private fun eventsCsv(events: List): String = buildString { + append("at_ms,unix_s,kind,steps_total,stairs_total,noop_steps,key_code,key_name,scan_code,device_id,device_name,source\n") + for (e in events) append(listOf( + e.optLong("at_ms"), e.optLong("at_ms") / 1_000L, e.optString("kind"), + e.optInt("steps_total"), e.optInt("stairs_total"), e.opt("noop_steps") ?: "", + e.opt("key_code") ?: "", e.optString("key_name"), e.opt("scan_code") ?: "", + e.opt("device_id") ?: "", e.optString("device_name"), e.opt("source") ?: "", + ).joinToString(",") { csv(it.toString()) }).append('\n') + } + + private fun secondsCsv(events: List, startMs: Long, endMs: Long): String = buildString { + append("unix_s,manual_steps_delta,manual_stairs_delta,manual_steps_total,manual_stairs_total,noop_steps\n") + val bySecond = events.groupBy { it.optLong("at_ms") / 1_000L } + var steps = 0; var stairs = 0; var noop: Int? = null + for (second in startMs / 1_000L..endMs / 1_000L) { + val rows = bySecond[second].orEmpty() + val stepDelta = rows.count { it.optString("kind") == KIND_STEP || it.optString("kind") == KIND_STAIR } - + rows.count { it.optString("kind") == KIND_UNDO_STEP || it.optString("kind") == KIND_UNDO_STAIR } + val stairDelta = rows.count { it.optString("kind") == KIND_STAIR } - rows.count { it.optString("kind") == KIND_UNDO_STAIR } + rows.lastOrNull()?.let { last -> + steps = last.optInt("steps_total", steps); stairs = last.optInt("stairs_total", stairs) + if (last.has("noop_steps")) noop = last.optInt("noop_steps") + } + append("$second,$stepDelta,$stairDelta,$steps,$stairs,${noop ?: ""}\n") + } + } + + private fun csv(value: String): String = if (value.any { it == ',' || it == '"' || it == '\n' }) + "\"${value.replace("\"", "\"\"")}\"" else value + + private fun encodeKey(key: KeyInfo) = JSONObject().apply { + put("keyCode", key.keyCode); put("keyName", key.keyName); put("scanCode", key.scanCode) + put("deviceId", key.deviceId); put("deviceName", key.deviceName); put("source", key.source) + }.toString() + + private fun decodeKey(raw: String): KeyInfo = JSONObject(raw).let { + KeyInfo(it.getInt("keyCode"), it.getString("keyName"), it.getInt("scanCode"), + it.getInt("deviceId"), it.getString("deviceName"), it.getInt("source")) + } + + private fun android.content.SharedPreferences.Editor.applyNoop(value: Int?) = apply { + if (value == null) remove("lastNoopSteps") else putInt("lastNoopSteps", value) + } + + private fun android.content.SharedPreferences.Editor.applyNoopStart(value: Int?) = apply { + if (value == null) remove("noopStepsAtStart") else putInt("noopStepsAtStart", value) + } + + private fun ZipOutputStream.writerEntry(text: String) { + write(text.toByteArray(Charsets.UTF_8)); closeEntry() + } + + /** A Writer whose close only flushes, because closing it must not close the surrounding ZIP. */ + private fun ZipOutputStream.bufferedWriterNoClose() = object : java.io.OutputStreamWriter(this, Charsets.UTF_8) { + override fun close() = flush() + }.buffered() + + companion object { + const val KIND_STEP = "step" + const val KIND_STAIR = "stair" + private const val KIND_KEY = "key" + private const val KIND_UNDO_STEP = "undo_step" + private const val KIND_UNDO_STAIR = "undo_stair" + private const val PREFS = "noop_ground_truth_collector" + + fun from(context: Context) = GroundTruthCollector(context.applicationContext) + } +} diff --git a/android/app/src/main/java/com/noop/ui/AppRoot.kt b/android/app/src/main/java/com/noop/ui/AppRoot.kt index fc533ab50b..4b193ed77b 100644 --- a/android/app/src/main/java/com/noop/ui/AppRoot.kt +++ b/android/app/src/main/java/com/noop/ui/AppRoot.kt @@ -179,6 +179,7 @@ private enum class Destination( Icons.Filled.Tune, ), TestCentre("test_centre", R.string.nav_test_centre, Icons.Filled.BugReport), + GroundTruthCollector("ground_truth_collector", R.string.ground_truth_title, Icons.Filled.Sensors), // The "More" tab: its own navigated page (mirroring the iOS More tab) that hosts the full // grouped destination list. It is NOT itself in any [DrawerGroup] — it's the door to them. @@ -468,7 +469,12 @@ fun AppRoot(viewModel: AppViewModel = viewModel()) { onClose = { nav.popBackStack() }, ) } - composable(Destination.TestCentre.route) { TestCentreScreen(viewModel) } + composable(Destination.TestCentre.route) { + TestCentreScreen(viewModel, onOpenGroundTruthCollector = { + nav.navigate(Destination.GroundTruthCollector.route) + }) + } + composable(Destination.GroundTruthCollector.route) { GroundTruthCollectorScreen(viewModel) } // The "More" page — the iOS More tab's twin: a navigated ScreenScaffold page hosting the // full grouped destination list (was a pull-up sheet). A row pushes its destination so // Android Back returns to More instead of skipping straight to Today. diff --git a/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt b/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt new file mode 100644 index 0000000000..443d7ee1b5 --- /dev/null +++ b/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt @@ -0,0 +1,200 @@ +package com.noop.ui + +import android.app.Activity +import android.content.Context +import android.content.ContextWrapper +import android.os.Build +import android.view.HapticFeedbackConstants +import android.view.InputDevice +import android.view.KeyEvent +import android.view.WindowManager +import android.widget.Toast +import androidx.compose.foundation.focusable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.noop.R +import com.noop.testcentre.GroundTruthCollector +import kotlinx.coroutines.launch + +/** Foreground-only hardware-clicker capture. Production steps never depend on this research surface. */ +@Composable +fun GroundTruthCollectorScreen(vm: AppViewModel) { + val context = LocalContext.current + val activity = remember(context) { context.findActivity() } + val collector = remember { GroundTruthCollector.from(context) } + val focusRequester = remember { FocusRequester() } + val view = LocalView.current + val scope = rememberCoroutineScope() + val cycle by vm.activeDayCycle.collectAsStateWithLifecycle() + val days by vm.recentDays.collectAsStateWithLifecycle() + val noopSteps = cycle?.steps ?: days.lastOrNull()?.steps + var state by remember { mutableStateOf(collector.snapshot()) } + var haptics by remember { mutableStateOf(false) } + var exporting by remember { mutableStateOf(false) } + + DisposableEffect(activity) { + val window = activity?.window + val oldBrightness = window?.attributes?.screenBrightness + window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + window?.attributes = window?.attributes?.apply { screenBrightness = 0.01f } + onDispose { + window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + if (oldBrightness != null) window.attributes = window.attributes.apply { screenBrightness = oldBrightness } + } + } + LaunchedEffect(Unit) { focusRequester.requestFocus() } + + ScreenScaffold( + title = stringResource(R.string.ground_truth_title), + subtitle = stringResource(R.string.ground_truth_subtitle), + modifier = Modifier + .focusRequester(focusRequester) + .focusable() + .onPreviewKeyEvent { composeEvent -> + val event = composeEvent.nativeKeyEvent + if (event.action != KeyEvent.ACTION_DOWN || event.repeatCount != 0) return@onPreviewKeyEvent false + val key = event.toCollectorKey() + state = when (event.keyCode) { + KeyEvent.KEYCODE_ZOOM_OUT -> collector.record(GroundTruthCollector.KIND_STEP, key, noopSteps) + KeyEvent.KEYCODE_ZOOM_IN -> collector.record(GroundTruthCollector.KIND_STAIR, key, noopSteps) + else -> collector.observeKey(key, noopSteps) + } + val mapped = event.keyCode == KeyEvent.KEYCODE_ZOOM_OUT || event.keyCode == KeyEvent.KEYCODE_ZOOM_IN + if (mapped && haptics) { + view.performHapticFeedback(if (Build.VERSION.SDK_INT >= 30) HapticFeedbackConstants.CONFIRM else HapticFeedbackConstants.KEYBOARD_TAP) + } + mapped + }, + ) { + NoopCard(tint = Palette.accent) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + CounterColumn(stringResource(R.string.ground_truth_noop_steps), noopSteps?.toString() ?: "-") + CounterColumn(stringResource(R.string.ground_truth_manual_steps), state.steps.toString()) + CounterColumn(stringResource(R.string.ground_truth_stairs), state.stairs.toString()) + } + val noopSessionSteps = state.noopStepsAtStart?.let { start -> noopSteps?.minus(start) } + val delta = noopSessionSteps?.minus(state.steps) + Text( + text = stringResource(R.string.ground_truth_delta, delta?.toString() ?: "-"), + style = NoopType.subhead, + color = Palette.textSecondary, + modifier = Modifier.padding(top = 12.dp), + ) + } + + NoopCard { + Text(stringResource(R.string.ground_truth_mapping_title), style = NoopType.headline, color = Palette.textPrimary) + Text(stringResource(R.string.ground_truth_mapping_body), style = NoopType.body, color = Palette.textSecondary) + val key = state.lastKey + Text( + text = if (key == null) stringResource(R.string.ground_truth_no_key) + else stringResource( + R.string.ground_truth_key_detail, + key.keyName, key.keyCode, key.scanCode, key.deviceName, key.deviceId, key.source, + ), + style = NoopType.caption, + color = Palette.textSecondary, + modifier = Modifier.padding(top = 12.dp), + ) + } + + NoopCard { + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text(stringResource(R.string.ground_truth_haptics), style = NoopType.body, color = Palette.textPrimary) + Text(stringResource(R.string.ground_truth_haptics_desc), style = NoopType.caption, color = Palette.textSecondary) + } + Switch(checked = haptics, onCheckedChange = { haptics = it }) + } + } + + if (!state.active && (state.sessionId == null || state.exported)) { + NoopButton( + text = stringResource(R.string.ground_truth_start), + fullWidth = true, + enabled = noopSteps != null && vm.activeStrapId.isNotBlank(), + onClick = { state = collector.start(requireNotNull(noopSteps), vm.activeStrapId); focusRequester.requestFocus() }, + ) + } else { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { + NoopButton( + text = stringResource(R.string.ground_truth_undo), + kind = NoopButtonKind.Secondary, + modifier = Modifier.weight(1f), + onClick = { state = collector.undo(noopSteps); focusRequester.requestFocus() }, + ) + NoopButton( + text = stringResource(R.string.ground_truth_stop), + kind = NoopButtonKind.Destructive, + modifier = Modifier.weight(1f), + onClick = { state = collector.stop(noopSteps); focusRequester.requestFocus() }, + ) + } + } + NoopButton( + text = if (exporting) stringResource(R.string.ground_truth_exporting) else stringResource(R.string.ground_truth_export), + kind = NoopButtonKind.Secondary, + fullWidth = true, + enabled = state.sessionId != null && !state.active && !exporting, + onClick = { + exporting = true + scope.launch { + runCatching { collector.export(vm.repo) } + .onSuccess { file -> state = collector.snapshot(); collector.share(file) } + .onFailure { Toast.makeText(context, context.getString(R.string.ground_truth_export_failed, it.message ?: "?"), Toast.LENGTH_LONG).show() } + exporting = false + focusRequester.requestFocus() + } + }, + ) + } +} + +@Composable +private fun CounterColumn(label: String, value: String) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text(value, style = NoopType.number(32f), color = Palette.textPrimary) + Text(label, style = NoopType.caption, color = Palette.textSecondary) + } +} + +private fun KeyEvent.toCollectorKey(): GroundTruthCollector.KeyInfo { + val input = InputDevice.getDevice(deviceId) + return GroundTruthCollector.KeyInfo( + keyCode = keyCode, + keyName = KeyEvent.keyCodeToString(keyCode), + scanCode = scanCode, + deviceId = deviceId, + deviceName = input?.name ?: "unknown", + source = source, + ) +} + +private tailrec fun Context.findActivity(): Activity? = when (this) { + is Activity -> this + is ContextWrapper -> baseContext.findActivity() + else -> null +} diff --git a/android/app/src/main/java/com/noop/ui/TestCentreScreen.kt b/android/app/src/main/java/com/noop/ui/TestCentreScreen.kt index 77e9480a5a..3290a5fe96 100644 --- a/android/app/src/main/java/com/noop/ui/TestCentreScreen.kt +++ b/android/app/src/main/java/com/noop/ui/TestCentreScreen.kt @@ -18,6 +18,7 @@ import androidx.compose.material.icons.filled.Autorenew import androidx.compose.material.icons.filled.BugReport import androidx.compose.material.icons.filled.DeleteOutline import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.automirrored.filled.DirectionsWalk import androidx.compose.material.icons.filled.Science import androidx.compose.material.icons.filled.Upload import androidx.compose.material3.AlertDialog @@ -80,7 +81,7 @@ import kotlin.math.roundToInt * scheduled-export / experimental controls on the same bindings the Settings cards use. No em-dash. */ @Composable -fun TestCentreScreen(vm: AppViewModel) { +fun TestCentreScreen(vm: AppViewModel, onOpenGroundTruthCollector: () -> Unit = {}) { val context = LocalContext.current val testCentre = remember { TestCentre.from(context) } // CAPTURE-D: a UI scope to emit the data-volume line off the toggle-on path (a store read, so it can't @@ -182,6 +183,19 @@ fun TestCentreScreen(vm: AppViewModel) { // --- Section 2: Diagnostic tools --- DiagnosticToolsCard(vm) + SettingsSectionTC( + icon = Icons.AutoMirrored.Filled.DirectionsWalk, + title = stringResource(R.string.ground_truth_title), + blurb = stringResource(R.string.ground_truth_test_centre_desc), + ) { + NoopButton( + text = stringResource(R.string.ground_truth_open), + kind = NoopButtonKind.Secondary, + fullWidth = true, + onClick = onOpenGroundTruthCollector, + ) + } + // --- Section 3: Export and auto-export --- ExportCard( vm = vm, diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 6b31c3ce4e..9e5a3c56c3 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -2478,4 +2478,24 @@ Your strap reports a blood oxygen estimate, but it is unverified and off by default. Turn on the strap estimate in Settings to see it, or import a WHOOP export in Data Sources for the calibrated value. No blood oxygen percentage from a WHOOP 4.0 Your strap banks the raw optical channels, but NOOP does not turn them into a blood oxygen percentage, so this stays empty however long you wear it. Import a WHOOP export in Data Sources to fill it from your account history. + Ground-truth collector + Count steps and stairs with a hardware button while NOOP records the matching sensor timeline. + A foreground hardware-clicker session for step-algorithm research. It never changes your health data. + Open collector + NOOP steps + Manual steps + Stairs + NOOP − manual: %1$s + Hardware mapping + Zoom Out adds one step. Zoom In adds one stair and one step. Every other key is shown and logged without changing a count. + Press a hardware button to inspect its key code. + %1$s · key %2$d · scan %3$d\n%4$s · device %5$d · source 0x%6$x + Vibrate on a counted press + Optional confirmation; disabled by default to save battery during long sessions. + Start new session + Undo last count + Stop session + Export session + Building export… + Couldn’t export session: %1$s From ccc8c709f096a215f284c47046f66e24661ab228 Mon Sep 17 00:00:00 2001 From: Bernd Helm Date: Mon, 24 Aug 2026 21:04:01 +0200 Subject: [PATCH 02/45] fix(android): stabilize ground truth capture --- .../noop/testcentre/GroundTruthCollector.kt | 32 +++++++++++++ .../com/noop/ui/GroundTruthCollectorScreen.kt | 45 +++++++------------ 2 files changed, 47 insertions(+), 30 deletions(-) diff --git a/android/app/src/main/java/com/noop/testcentre/GroundTruthCollector.kt b/android/app/src/main/java/com/noop/testcentre/GroundTruthCollector.kt index 57a8c4cbf5..996d1dc454 100644 --- a/android/app/src/main/java/com/noop/testcentre/GroundTruthCollector.kt +++ b/android/app/src/main/java/com/noop/testcentre/GroundTruthCollector.kt @@ -7,6 +7,7 @@ import androidx.core.content.FileProvider import com.noop.BuildConfig import com.noop.data.WhoopRepository import com.noop.ingest.RawSensorExport +import com.noop.protocol.Whoop5RawImu import java.io.File import java.util.zip.ZipEntry import java.util.zip.ZipOutputStream @@ -181,6 +182,13 @@ class GroundTruthCollector private constructor(private val context: Context) { writer.flush() } out.closeEntry() + + out.putNextEntry(ZipEntry("raw-imu.csv")) + out.bufferedWriterNoClose().use { writer -> + writeRawImuCsv(writer, repo, deviceId, snap.startedAtMs / 1_000L - 5L, endMs / 1_000L + 5L) + writer.flush() + } + out.closeEntry() } prefs.edit().putBoolean("exported", true).apply() zip @@ -240,6 +248,30 @@ class GroundTruthCollector private constructor(private val context: Context) { private fun csv(value: String): String = if (value.any { it == ',' || it == '"' || it == '\n' }) "\"${value.replace("\"", "\"\"")}\"" else value + private suspend fun writeRawImuCsv( + out: java.io.Writer, + repo: WhoopRepository, + deviceId: String, + from: Long, + to: Long, + ) { + out.write("unix_s,sample_index,ax_g,ay_g,az_g,gx_dps,gy_dps,gz_dps\n") + for ((ts, columns) in repo.rawImuSamples(deviceId, from, to, limit = 200_000)) { + if (columns.size < Whoop5RawImu.sampleCount * 6) continue + for (i in 0 until Whoop5RawImu.sampleCount) { + val values = listOf( + columns[i] * Whoop5RawImu.accelScale, + columns[Whoop5RawImu.sampleCount + i] * Whoop5RawImu.accelScale, + columns[Whoop5RawImu.sampleCount * 2 + i] * Whoop5RawImu.accelScale, + columns[Whoop5RawImu.sampleCount * 3 + i] * Whoop5RawImu.gyroScale, + columns[Whoop5RawImu.sampleCount * 4 + i] * Whoop5RawImu.gyroScale, + columns[Whoop5RawImu.sampleCount * 5 + i] * Whoop5RawImu.gyroScale, + ).joinToString(",") { String.format(java.util.Locale.US, "%.8f", it) } + out.write("$ts,$i,$values\n") + } + } + } + private fun encodeKey(key: KeyInfo) = JSONObject().apply { put("keyCode", key.keyCode); put("keyName", key.keyName); put("scanCode", key.scanCode) put("deviceId", key.deviceId); put("deviceName", key.deviceName); put("source", key.source) diff --git a/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt b/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt index 443d7ee1b5..2e1dd250c3 100644 --- a/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt +++ b/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt @@ -1,13 +1,9 @@ package com.noop.ui -import android.app.Activity -import android.content.Context -import android.content.ContextWrapper import android.os.Build import android.view.HapticFeedbackConstants import android.view.InputDevice import android.view.KeyEvent -import android.view.WindowManager import android.widget.Toast import androidx.compose.foundation.focusable import androidx.compose.foundation.layout.Arrangement @@ -43,7 +39,6 @@ import kotlinx.coroutines.launch @Composable fun GroundTruthCollectorScreen(vm: AppViewModel) { val context = LocalContext.current - val activity = remember(context) { context.findActivity() } val collector = remember { GroundTruthCollector.from(context) } val focusRequester = remember { FocusRequester() } val view = LocalView.current @@ -55,17 +50,14 @@ fun GroundTruthCollectorScreen(vm: AppViewModel) { var haptics by remember { mutableStateOf(false) } var exporting by remember { mutableStateOf(false) } - DisposableEffect(activity) { - val window = activity?.window - val oldBrightness = window?.attributes?.screenBrightness - window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) - window?.attributes = window?.attributes?.apply { screenBrightness = 0.01f } + DisposableEffect(view) { + val oldKeepScreenOn = view.keepScreenOn + view.keepScreenOn = true onDispose { - window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) - if (oldBrightness != null) window.attributes = window.attributes.apply { screenBrightness = oldBrightness } + view.keepScreenOn = oldKeepScreenOn } } - LaunchedEffect(Unit) { focusRequester.requestFocus() } + LaunchedEffect(state.active, state.steps, state.stairs, exporting) { focusRequester.requestFocus() } ScreenScaffold( title = stringResource(R.string.ground_truth_title), @@ -131,28 +123,28 @@ fun GroundTruthCollectorScreen(vm: AppViewModel) { } } - if (!state.active && (state.sessionId == null || state.exported)) { - NoopButton( - text = stringResource(R.string.ground_truth_start), - fullWidth = true, - enabled = noopSteps != null && vm.activeStrapId.isNotBlank(), - onClick = { state = collector.start(requireNotNull(noopSteps), vm.activeStrapId); focusRequester.requestFocus() }, - ) - } else { + if (state.active) { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { NoopButton( text = stringResource(R.string.ground_truth_undo), kind = NoopButtonKind.Secondary, modifier = Modifier.weight(1f), - onClick = { state = collector.undo(noopSteps); focusRequester.requestFocus() }, + onClick = { state = collector.undo(noopSteps) }, ) NoopButton( text = stringResource(R.string.ground_truth_stop), kind = NoopButtonKind.Destructive, modifier = Modifier.weight(1f), - onClick = { state = collector.stop(noopSteps); focusRequester.requestFocus() }, + onClick = { state = collector.stop(noopSteps) }, ) } + } else if (state.sessionId == null || state.exported) { + NoopButton( + text = stringResource(R.string.ground_truth_start), + fullWidth = true, + enabled = noopSteps != null && vm.activeStrapId.isNotBlank(), + onClick = { state = collector.start(requireNotNull(noopSteps), vm.activeStrapId) }, + ) } NoopButton( text = if (exporting) stringResource(R.string.ground_truth_exporting) else stringResource(R.string.ground_truth_export), @@ -166,7 +158,6 @@ fun GroundTruthCollectorScreen(vm: AppViewModel) { .onSuccess { file -> state = collector.snapshot(); collector.share(file) } .onFailure { Toast.makeText(context, context.getString(R.string.ground_truth_export_failed, it.message ?: "?"), Toast.LENGTH_LONG).show() } exporting = false - focusRequester.requestFocus() } }, ) @@ -192,9 +183,3 @@ private fun KeyEvent.toCollectorKey(): GroundTruthCollector.KeyInfo { source = source, ) } - -private tailrec fun Context.findActivity(): Activity? = when (this) { - is Activity -> this - is ContextWrapper -> baseContext.findActivity() - else -> null -} From ec59ed89778b23f640f465c4e039343bc00c7692 Mon Sep 17 00:00:00 2001 From: Bernd Helm Date: Mon, 24 Aug 2026 21:18:50 +0200 Subject: [PATCH 03/45] fix(android): clarify ground truth session layout --- .../com/noop/ui/GroundTruthCollectorScreen.kt | 65 +++++++++++-------- android/app/src/main/res/values/strings.xml | 1 + 2 files changed, 38 insertions(+), 28 deletions(-) diff --git a/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt b/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt index 2e1dd250c3..cd91def2b5 100644 --- a/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt +++ b/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt @@ -29,6 +29,7 @@ import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.noop.R @@ -82,35 +83,37 @@ fun GroundTruthCollectorScreen(vm: AppViewModel) { }, ) { NoopCard(tint = Palette.accent) { - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { - CounterColumn(stringResource(R.string.ground_truth_noop_steps), noopSteps?.toString() ?: "-") - CounterColumn(stringResource(R.string.ground_truth_manual_steps), state.steps.toString()) - CounterColumn(stringResource(R.string.ground_truth_stairs), state.stairs.toString()) + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Row(Modifier.fillMaxWidth()) { + CounterColumn(stringResource(R.string.ground_truth_noop_steps), noopSteps?.toString() ?: "-", Modifier.weight(1f)) + CounterColumn(stringResource(R.string.ground_truth_manual_steps), state.steps.toString(), Modifier.weight(1f)) + CounterColumn(stringResource(R.string.ground_truth_stairs), state.stairs.toString(), Modifier.weight(1f)) + } + val noopSessionSteps = state.noopStepsAtStart?.let { start -> noopSteps?.minus(start) } + val delta = noopSessionSteps?.minus(state.steps) + Text( + text = stringResource(R.string.ground_truth_delta, delta?.toString() ?: "-"), + style = NoopType.subhead, + color = Palette.textSecondary, + ) } - val noopSessionSteps = state.noopStepsAtStart?.let { start -> noopSteps?.minus(start) } - val delta = noopSessionSteps?.minus(state.steps) - Text( - text = stringResource(R.string.ground_truth_delta, delta?.toString() ?: "-"), - style = NoopType.subhead, - color = Palette.textSecondary, - modifier = Modifier.padding(top = 12.dp), - ) } NoopCard { - Text(stringResource(R.string.ground_truth_mapping_title), style = NoopType.headline, color = Palette.textPrimary) - Text(stringResource(R.string.ground_truth_mapping_body), style = NoopType.body, color = Palette.textSecondary) - val key = state.lastKey - Text( - text = if (key == null) stringResource(R.string.ground_truth_no_key) - else stringResource( - R.string.ground_truth_key_detail, - key.keyName, key.keyCode, key.scanCode, key.deviceName, key.deviceId, key.source, - ), - style = NoopType.caption, - color = Palette.textSecondary, - modifier = Modifier.padding(top = 12.dp), - ) + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(stringResource(R.string.ground_truth_mapping_title), style = NoopType.headline, color = Palette.textPrimary) + Text(stringResource(R.string.ground_truth_mapping_body), style = NoopType.body, color = Palette.textSecondary) + val key = state.lastKey + Text( + text = if (key == null) stringResource(R.string.ground_truth_no_key) + else stringResource( + R.string.ground_truth_key_detail, + key.keyName, key.keyCode, key.scanCode, key.deviceName, key.deviceId, key.source, + ), + style = NoopType.caption, + color = Palette.textSecondary, + ) + } } NoopCard { @@ -145,6 +148,12 @@ fun GroundTruthCollectorScreen(vm: AppViewModel) { enabled = noopSteps != null && vm.activeStrapId.isNotBlank(), onClick = { state = collector.start(requireNotNull(noopSteps), vm.activeStrapId) }, ) + } else { + Text( + text = stringResource(R.string.ground_truth_stopped_export_hint), + style = NoopType.subhead, + color = Palette.textSecondary, + ) } NoopButton( text = if (exporting) stringResource(R.string.ground_truth_exporting) else stringResource(R.string.ground_truth_export), @@ -165,10 +174,10 @@ fun GroundTruthCollectorScreen(vm: AppViewModel) { } @Composable -private fun CounterColumn(label: String, value: String) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { +private fun CounterColumn(label: String, value: String, modifier: Modifier = Modifier) { + Column(modifier = modifier.padding(horizontal = 4.dp), horizontalAlignment = Alignment.CenterHorizontally) { Text(value, style = NoopType.number(32f), color = Palette.textPrimary) - Text(label, style = NoopType.caption, color = Palette.textSecondary) + Text(label, style = NoopType.footnote, color = Palette.textSecondary, textAlign = TextAlign.Center, maxLines = 2) } } diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 9e5a3c56c3..1f003e41c4 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -2498,4 +2498,5 @@ Export session Building export… Couldn’t export session: %1$s + Session stopped. Export it before starting the next session. From 7b9a3c4954b461008e6b950758aba133b7aeacaf Mon Sep 17 00:00:00 2001 From: Bernd Helm Date: Mon, 24 Aug 2026 21:31:19 +0200 Subject: [PATCH 04/45] fix(android): make collector sessions restartable --- .../main/java/com/noop/ingest/RawSensorExport.kt | 3 ++- .../com/noop/testcentre/GroundTruthCollector.kt | 16 ++++++++++++---- .../com/noop/ui/GroundTruthCollectorScreen.kt | 8 +------- android/app/src/main/res/values/strings.xml | 1 - 4 files changed, 15 insertions(+), 13 deletions(-) diff --git a/android/app/src/main/java/com/noop/ingest/RawSensorExport.kt b/android/app/src/main/java/com/noop/ingest/RawSensorExport.kt index 3e9028bc89..d1efed710c 100644 --- a/android/app/src/main/java/com/noop/ingest/RawSensorExport.kt +++ b/android/app/src/main/java/com/noop/ingest/RawSensorExport.kt @@ -66,6 +66,8 @@ object RawSensorExport { to: Long, limit: Int = 200_000, ): Map { + out.write(HEADER); out.write("\n") + if (from > to) return emptyMap() // index: 0 hr_bpm,1 rr_ms,2 grav_x,3 grav_y,4 grav_z,5 step_counter,6 ppg_bpm,7 ppg_conf, // 8 spo2_red,9 spo2_ir,10 skintemp_raw,11 resp_raw,12 band_sleep_state,13 event_kind,14 event_payload val rows = ArrayList() @@ -118,7 +120,6 @@ object RawSensorExport { // Stable sort by ts asc (a stream's intra-ts order is its query's secondary key). rows.sortBy { it.ts } - out.write(HEADER); out.write("\n") for (r in rows) { out.write(r.line); out.write("\n") } return counts } diff --git a/android/app/src/main/java/com/noop/testcentre/GroundTruthCollector.kt b/android/app/src/main/java/com/noop/testcentre/GroundTruthCollector.kt index 996d1dc454..80636e7f97 100644 --- a/android/app/src/main/java/com/noop/testcentre/GroundTruthCollector.kt +++ b/android/app/src/main/java/com/noop/testcentre/GroundTruthCollector.kt @@ -62,7 +62,6 @@ class GroundTruthCollector private constructor(private val context: Context) { @Synchronized fun start(noopSteps: Int, deviceId: String, nowMs: Long = System.currentTimeMillis()): Snapshot { - check(snapshot().sessionId == null || snapshot().exported) { "Export the previous session before starting another" } val id = nowMs.toString() prefs.edit().clear() .putBoolean("active", true) @@ -143,6 +142,14 @@ class GroundTruthCollector private constructor(private val context: Context) { require(source.isFile) { "Ground-truth event file is missing" } val events = source.useLines { lines -> lines.filter { it.isNotBlank() }.map(::JSONObject).toList() } val endMs = snap.endedAtMs.takeIf { it > 0 } ?: System.currentTimeMillis() + // Sensor data is useful only around manually marked ground truth. An abandoned collector left + // open overnight must not turn one tap on Export into a multi-gigabyte query/allocation. + val markedEvents = events.filter { + it.optString("kind") == KIND_STEP || it.optString("kind") == KIND_STAIR || + it.optString("kind") == KIND_UNDO_STEP || it.optString("kind") == KIND_UNDO_STAIR + } + val sensorFrom = markedEvents.firstOrNull()?.optLong("at_ms")?.div(1_000L)?.minus(5L) ?: 1L + val sensorTo = markedEvents.lastOrNull()?.optLong("at_ms")?.div(1_000L)?.plus(5L) ?: 0L val outDir = File(context.cacheDir, "logs").apply { mkdirs() } val zip = File(outDir, "noop-ground-truth-$id.zip") ZipOutputStream(zip.outputStream().buffered()).use { out -> @@ -176,8 +183,8 @@ class GroundTruthCollector private constructor(private val context: Context) { out.bufferedWriterNoClose().use { writer -> RawSensorExport.writeCsv( writer, repo, deviceId, - snap.startedAtMs / 1_000L - 5L, - endMs / 1_000L + 5L, + sensorFrom, + sensorTo, ) writer.flush() } @@ -185,7 +192,7 @@ class GroundTruthCollector private constructor(private val context: Context) { out.putNextEntry(ZipEntry("raw-imu.csv")) out.bufferedWriterNoClose().use { writer -> - writeRawImuCsv(writer, repo, deviceId, snap.startedAtMs / 1_000L - 5L, endMs / 1_000L + 5L) + writeRawImuCsv(writer, repo, deviceId, sensorFrom, sensorTo) writer.flush() } out.closeEntry() @@ -256,6 +263,7 @@ class GroundTruthCollector private constructor(private val context: Context) { to: Long, ) { out.write("unix_s,sample_index,ax_g,ay_g,az_g,gx_dps,gy_dps,gz_dps\n") + if (from > to) return for ((ts, columns) in repo.rawImuSamples(deviceId, from, to, limit = 200_000)) { if (columns.size < Whoop5RawImu.sampleCount * 6) continue for (i in 0 until Whoop5RawImu.sampleCount) { diff --git a/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt b/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt index cd91def2b5..972103fbab 100644 --- a/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt +++ b/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt @@ -141,19 +141,13 @@ fun GroundTruthCollectorScreen(vm: AppViewModel) { onClick = { state = collector.stop(noopSteps) }, ) } - } else if (state.sessionId == null || state.exported) { + } else { NoopButton( text = stringResource(R.string.ground_truth_start), fullWidth = true, enabled = noopSteps != null && vm.activeStrapId.isNotBlank(), onClick = { state = collector.start(requireNotNull(noopSteps), vm.activeStrapId) }, ) - } else { - Text( - text = stringResource(R.string.ground_truth_stopped_export_hint), - style = NoopType.subhead, - color = Palette.textSecondary, - ) } NoopButton( text = if (exporting) stringResource(R.string.ground_truth_exporting) else stringResource(R.string.ground_truth_export), diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 1f003e41c4..9e5a3c56c3 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -2498,5 +2498,4 @@ Export session Building export… Couldn’t export session: %1$s - Session stopped. Export it before starting the next session. From 35af0527d57fa5f7a30dc50c5c21271e2b0fca13 Mon Sep 17 00:00:00 2001 From: Bernd Helm Date: Mon, 24 Aug 2026 22:09:57 +0200 Subject: [PATCH 05/45] fix(android): launch collector export safely --- .../noop/testcentre/GroundTruthCollector.kt | 5 +++-- .../com/noop/ui/GroundTruthCollectorScreen.kt | 21 +++++++++++++++---- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/android/app/src/main/java/com/noop/testcentre/GroundTruthCollector.kt b/android/app/src/main/java/com/noop/testcentre/GroundTruthCollector.kt index 80636e7f97..cee01e34df 100644 --- a/android/app/src/main/java/com/noop/testcentre/GroundTruthCollector.kt +++ b/android/app/src/main/java/com/noop/testcentre/GroundTruthCollector.kt @@ -208,7 +208,7 @@ class GroundTruthCollector private constructor(private val context: Context) { putExtra(Intent.EXTRA_STREAM, uri) putExtra(Intent.EXTRA_SUBJECT, "NOOP ground-truth session") addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - }, "Export ground-truth session")) + }, "Export ground-truth session").addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) } private fun eventFile(id: String) = File(directory, "session-$id.jsonl") @@ -264,7 +264,8 @@ class GroundTruthCollector private constructor(private val context: Context) { ) { out.write("unix_s,sample_index,ax_g,ay_g,az_g,gx_dps,gy_dps,gz_dps\n") if (from > to) return - for ((ts, columns) in repo.rawImuSamples(deviceId, from, to, limit = 200_000)) { + val secondsInWindow = (to - from + 1L).coerceIn(1L, 86_400L).toInt() + for ((ts, columns) in repo.rawImuSamples(deviceId, from, to, limit = secondsInWindow)) { if (columns.size < Whoop5RawImu.sampleCount * 6) continue for (i in 0 until Whoop5RawImu.sampleCount) { val values = listOf( diff --git a/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt b/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt index 972103fbab..064d183197 100644 --- a/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt +++ b/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt @@ -157,10 +157,23 @@ fun GroundTruthCollectorScreen(vm: AppViewModel) { onClick = { exporting = true scope.launch { - runCatching { collector.export(vm.repo) } - .onSuccess { file -> state = collector.snapshot(); collector.share(file) } - .onFailure { Toast.makeText(context, context.getString(R.string.ground_truth_export_failed, it.message ?: "?"), Toast.LENGTH_LONG).show() } - exporting = false + try { + val file = collector.export(vm.repo) + state = collector.snapshot() + collector.share(file) + } catch (failure: Throwable) { + if (failure is kotlinx.coroutines.CancellationException) throw failure + Toast.makeText( + context, + context.getString( + R.string.ground_truth_export_failed, + "${failure.javaClass.simpleName}: ${failure.message ?: "unknown error"}", + ), + Toast.LENGTH_LONG, + ).show() + } finally { + exporting = false + } } }, ) From 9625c385a31c0bd29a3b12b0a46594951eae07f1 Mon Sep 17 00:00:00 2001 From: Bernd Helm Date: Mon, 24 Aug 2026 22:12:00 +0200 Subject: [PATCH 06/45] fix(android): clarify collector undo action --- android/app/src/main/res/values/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 9e5a3c56c3..7f764f1681 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -2493,7 +2493,7 @@ Vibrate on a counted press Optional confirmation; disabled by default to save battery during long sessions. Start new session - Undo last count + Undo last click Stop session Export session Building export… From babc56cea1b49dd4f4666f9517cb3798ccd6000f Mon Sep 17 00:00:00 2001 From: Bernd Helm Date: Mon, 24 Aug 2026 22:47:18 +0200 Subject: [PATCH 07/45] feat(android): manage ground truth sessions --- .../noop/testcentre/GroundTruthCollector.kt | 152 +++++++++++++++--- .../com/noop/ui/GroundTruthCollectorScreen.kt | 140 ++++++++++++---- android/app/src/main/res/values/strings.xml | 7 + 3 files changed, 244 insertions(+), 55 deletions(-) diff --git a/android/app/src/main/java/com/noop/testcentre/GroundTruthCollector.kt b/android/app/src/main/java/com/noop/testcentre/GroundTruthCollector.kt index cee01e34df..3d9fb64ea8 100644 --- a/android/app/src/main/java/com/noop/testcentre/GroundTruthCollector.kt +++ b/android/app/src/main/java/com/noop/testcentre/GroundTruthCollector.kt @@ -41,6 +41,19 @@ class GroundTruthCollector private constructor(private val context: Context) { val exported: Boolean, ) + data class SessionSummary( + val id: String, + val deviceId: String?, + val startedAtMs: Long, + val endedAtMs: Long?, + val steps: Int, + val stairs: Int, + val excludedWindows: Int, + val comment: String, + val active: Boolean, + val exported: Boolean, + ) + private val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) private val directory = File(context.filesDir, "ground-truth").apply { mkdirs() } @@ -63,7 +76,7 @@ class GroundTruthCollector private constructor(private val context: Context) { @Synchronized fun start(noopSteps: Int, deviceId: String, nowMs: Long = System.currentTimeMillis()): Snapshot { val id = nowMs.toString() - prefs.edit().clear() + prefs.edit() .putBoolean("active", true) .putBoolean("exported", false) .putString("sessionId", id) @@ -73,11 +86,67 @@ class GroundTruthCollector private constructor(private val context: Context) { .putInt("stairs", 0) .applyNoopStart(noopSteps) .applyNoop(noopSteps) + .putString(sessionDeviceKey(id), deviceId) + .putLong(sessionStartKey(id), nowMs) + .putBoolean(sessionExportedKey(id), false) .apply() - append(id, event(nowMs, "start", 0, 0, noopSteps, null)) + append(id, event(nowMs, "start", 0, 0, noopSteps, null).put("strap_device_id", deviceId)) + return snapshot() + } + + @Synchronized + fun excludeLastMinutes(minutes: Int, nowMs: Long = System.currentTimeMillis()): Snapshot { + require(minutes in 1..240) { "Minutes must be between 1 and 240" } + val before = snapshot() + if (!before.active || before.sessionId == null) return before + val fromMs = maxOf(before.startedAtMs, nowMs - minutes * 60_000L) + append(before.sessionId, event(nowMs, KIND_EXCLUDE, before.steps, before.stairs, before.lastNoopSteps, null).apply { + put("from_ms", fromMs) + put("to_ms", nowMs) + }) return snapshot() } + @Synchronized + fun setComment(sessionId: String, comment: String) { + prefs.edit().putString(sessionCommentKey(sessionId), comment.take(4_000)).apply() + } + + @Synchronized + fun sessions(): List { + val current = snapshot() + return directory.listFiles { file -> file.name.startsWith("session-") && file.name.endsWith(".jsonl") } + .orEmpty() + .mapNotNull { file -> + val id = file.name.removePrefix("session-").removeSuffix(".jsonl") + val rows = runCatching { + file.useLines { lines -> + lines.filter { it.isNotBlank() } + .mapNotNull { line -> runCatching { JSONObject(line) }.getOrNull() } + .toList() + } + }.getOrNull() ?: return@mapNotNull null + val first = rows.firstOrNull() ?: return@mapNotNull null + val last = rows.last() + val isCurrent = current.sessionId == id + SessionSummary( + id = id, + deviceId = prefs.getString(sessionDeviceKey(id), null) + ?: first.optString("strap_device_id").takeIf(String::isNotBlank) + ?: current.deviceId.takeIf { isCurrent }, + startedAtMs = prefs.getLong(sessionStartKey(id), first.optLong("at_ms")), + endedAtMs = rows.lastOrNull { it.optString("kind") == "stop" }?.optLong("at_ms"), + steps = last.optInt("steps_total"), + stairs = last.optInt("stairs_total"), + excludedWindows = rows.count { it.optString("kind") == KIND_EXCLUDE }, + comment = prefs.getString(sessionCommentKey(id), "").orEmpty(), + active = isCurrent && current.active, + exported = prefs.getBoolean(sessionExportedKey(id), isCurrent && current.exported), + ) + } + .sortedByDescending { it.startedAtMs } + } + @Synchronized fun record(kind: String, key: KeyInfo, noopSteps: Int?, nowMs: Long = System.currentTimeMillis()): Snapshot { val before = snapshot() @@ -129,41 +198,52 @@ class GroundTruthCollector private constructor(private val context: Context) { if (!before.active || before.sessionId == null) return before append(before.sessionId, event(nowMs, "stop", before.steps, before.stairs, noopSteps, null)) prefs.edit().putBoolean("active", false).putLong("endedAtMs", nowMs) + .putLong(sessionEndKey(before.sessionId), nowMs) .applyNoop(noopSteps).apply() return snapshot() } - suspend fun export(repo: WhoopRepository): File = withContext(Dispatchers.IO) { + suspend fun export(repo: WhoopRepository, sessionId: String? = null): File = withContext(Dispatchers.IO) { val snap = snapshot() - require(!snap.active) { "Stop the session before exporting" } - val id = requireNotNull(snap.sessionId) { "No ground-truth session has been recorded" } - val deviceId = requireNotNull(snap.deviceId) { "The session has no recorded device" } + val id = sessionId ?: requireNotNull(snap.sessionId) { "No ground-truth session has been recorded" } + val summary = sessions().firstOrNull { it.id == id } ?: error("Ground-truth session is missing") + require(!summary.active) { "Stop the session before exporting" } + val deviceId = summary.deviceId val source = eventFile(id) require(source.isFile) { "Ground-truth event file is missing" } - val events = source.useLines { lines -> lines.filter { it.isNotBlank() }.map(::JSONObject).toList() } - val endMs = snap.endedAtMs.takeIf { it > 0 } ?: System.currentTimeMillis() + val events = source.useLines { lines -> + lines.filter { it.isNotBlank() } + .mapNotNull { line -> runCatching { JSONObject(line) }.getOrNull() } + .toList() + } + val endMs = summary.endedAtMs ?: events.lastOrNull()?.optLong("at_ms") ?: summary.startedAtMs // Sensor data is useful only around manually marked ground truth. An abandoned collector left // open overnight must not turn one tap on Export into a multi-gigabyte query/allocation. val markedEvents = events.filter { it.optString("kind") == KIND_STEP || it.optString("kind") == KIND_STAIR || it.optString("kind") == KIND_UNDO_STEP || it.optString("kind") == KIND_UNDO_STAIR } - val sensorFrom = markedEvents.firstOrNull()?.optLong("at_ms")?.div(1_000L)?.minus(5L) ?: 1L - val sensorTo = markedEvents.lastOrNull()?.optLong("at_ms")?.div(1_000L)?.plus(5L) ?: 0L + val exclusions = exclusionWindows(events) + val sensorBoundsMs = markedEvents.map { it.optLong("at_ms") } + exclusions.flatMap { listOf(it.first, it.second) } + val sensorFrom = if (deviceId == null) 1L else sensorBoundsMs.minOrNull()?.div(1_000L)?.minus(5L) ?: 1L + val sensorTo = if (deviceId == null) 0L else sensorBoundsMs.maxOrNull()?.div(1_000L)?.plus(5L) ?: 0L val outDir = File(context.cacheDir, "logs").apply { mkdirs() } val zip = File(outDir, "noop-ground-truth-$id.zip") ZipOutputStream(zip.outputStream().buffered()).use { out -> out.putNextEntry(ZipEntry("meta.json")) out.writerEntry(JSONObject().apply { - put("schema_version", 1) + put("schema_version", 2) put("session_id", id) - put("device_id", deviceId) - put("started_at_ms", snap.startedAtMs) + if (deviceId != null) put("device_id", deviceId) + put("sensor_export_available", deviceId != null) + put("started_at_ms", summary.startedAtMs) put("ended_at_ms", endMs) - put("manual_steps", snap.steps) - put("manual_stairs", snap.stairs) - if (snap.noopStepsAtStart != null) put("noop_steps_at_start", snap.noopStepsAtStart) - if (snap.lastNoopSteps != null) put("noop_steps_at_end", snap.lastNoopSteps) + put("manual_steps", summary.steps) + put("manual_stairs", summary.stairs) + put("comment", summary.comment) + put("excluded_windows", summary.excludedWindows) + if (id == snap.sessionId && snap.noopStepsAtStart != null) put("noop_steps_at_start", snap.noopStepsAtStart) + if (id == snap.sessionId && snap.lastNoopSteps != null) put("noop_steps_at_end", snap.lastNoopSteps) put("device_family", "Android") put("app_version", BuildConfig.VERSION_NAME) put("ground_truth", "JX-05S hardware clicker; ZOOM_OUT=step, ZOOM_IN=stair") @@ -177,12 +257,15 @@ class GroundTruthCollector private constructor(private val context: Context) { out.writerEntry(eventsCsv(events)) out.putNextEntry(ZipEntry("seconds.csv")) - out.writerEntry(secondsCsv(events, snap.startedAtMs, endMs)) + out.writerEntry(secondsCsv(events, sensorFrom * 1_000L, sensorTo * 1_000L)) + + out.putNextEntry(ZipEntry("exclusions.csv")) + out.writerEntry(exclusionsCsv(events)) out.putNextEntry(ZipEntry("raw-sensors.csv")) out.bufferedWriterNoClose().use { writer -> RawSensorExport.writeCsv( - writer, repo, deviceId, + writer, repo, deviceId.orEmpty(), sensorFrom, sensorTo, ) @@ -192,12 +275,13 @@ class GroundTruthCollector private constructor(private val context: Context) { out.putNextEntry(ZipEntry("raw-imu.csv")) out.bufferedWriterNoClose().use { writer -> - writeRawImuCsv(writer, repo, deviceId, sensorFrom, sensorTo) + writeRawImuCsv(writer, repo, deviceId.orEmpty(), sensorFrom, sensorTo) writer.flush() } out.closeEntry() } - prefs.edit().putBoolean("exported", true).apply() + prefs.edit().putBoolean(sessionExportedKey(id), true).apply() + if (id == snap.sessionId) prefs.edit().putBoolean("exported", true).apply() zip } @@ -236,8 +320,9 @@ class GroundTruthCollector private constructor(private val context: Context) { } private fun secondsCsv(events: List, startMs: Long, endMs: Long): String = buildString { - append("unix_s,manual_steps_delta,manual_stairs_delta,manual_steps_total,manual_stairs_total,noop_steps\n") + append("unix_s,manual_steps_delta,manual_stairs_delta,manual_steps_total,manual_stairs_total,noop_steps,excluded\n") val bySecond = events.groupBy { it.optLong("at_ms") / 1_000L } + val exclusions = exclusionWindows(events) var steps = 0; var stairs = 0; var noop: Int? = null for (second in startMs / 1_000L..endMs / 1_000L) { val rows = bySecond[second].orEmpty() @@ -248,10 +333,24 @@ class GroundTruthCollector private constructor(private val context: Context) { steps = last.optInt("steps_total", steps); stairs = last.optInt("stairs_total", stairs) if (last.has("noop_steps")) noop = last.optInt("noop_steps") } - append("$second,$stepDelta,$stairDelta,$steps,$stairs,${noop ?: ""}\n") + val excluded = exclusions.any { second * 1_000L <= it.second && second * 1_000L + 999L >= it.first } + append("$second,$stepDelta,$stairDelta,$steps,$stairs,${noop ?: ""},$excluded\n") } } + private fun exclusionsCsv(events: List): String = buildString { + append("from_ms,to_ms,from_unix_s,to_unix_s\n") + for ((from, to) in exclusionWindows(events)) append("$from,$to,${from / 1_000L},${to / 1_000L}\n") + } + + private fun exclusionWindows(events: List): List> = events + .filter { it.optString("kind") == KIND_EXCLUDE } + .mapNotNull { row -> + val from = row.optLong("from_ms", -1L) + val to = row.optLong("to_ms", -1L) + if (from >= 0L && to >= from) from to to else null + } + private fun csv(value: String): String = if (value.any { it == ',' || it == '"' || it == '\n' }) "\"${value.replace("\"", "\"\"")}\"" else value @@ -299,6 +398,12 @@ class GroundTruthCollector private constructor(private val context: Context) { if (value == null) remove("noopStepsAtStart") else putInt("noopStepsAtStart", value) } + private fun sessionDeviceKey(id: String) = "session.$id.deviceId" + private fun sessionStartKey(id: String) = "session.$id.startedAtMs" + private fun sessionEndKey(id: String) = "session.$id.endedAtMs" + private fun sessionCommentKey(id: String) = "session.$id.comment" + private fun sessionExportedKey(id: String) = "session.$id.exported" + private fun ZipOutputStream.writerEntry(text: String) { write(text.toByteArray(Charsets.UTF_8)); closeEntry() } @@ -314,6 +419,7 @@ class GroundTruthCollector private constructor(private val context: Context) { private const val KIND_KEY = "key" private const val KIND_UNDO_STEP = "undo_step" private const val KIND_UNDO_STAIR = "undo_stair" + private const val KIND_EXCLUDE = "exclude_window" private const val PREFS = "noop_ground_truth_collector" fun from(context: Context) = GroundTruthCollector(context.applicationContext) diff --git a/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt b/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt index 064d183197..1ccc3e6cd9 100644 --- a/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt +++ b/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt @@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material3.Switch import androidx.compose.material3.Text +import androidx.compose.material3.OutlinedTextField import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -30,6 +31,8 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.noop.R @@ -49,7 +52,9 @@ fun GroundTruthCollectorScreen(vm: AppViewModel) { val noopSteps = cycle?.steps ?: days.lastOrNull()?.steps var state by remember { mutableStateOf(collector.snapshot()) } var haptics by remember { mutableStateOf(false) } - var exporting by remember { mutableStateOf(false) } + var exportingSessionId by remember { mutableStateOf(null) } + var excludeMinutes by remember { mutableStateOf("5") } + var sessions by remember { mutableStateOf(collector.sessions()) } DisposableEffect(view) { val oldKeepScreenOn = view.keepScreenOn @@ -58,7 +63,7 @@ fun GroundTruthCollectorScreen(vm: AppViewModel) { view.keepScreenOn = oldKeepScreenOn } } - LaunchedEffect(state.active, state.steps, state.stairs, exporting) { focusRequester.requestFocus() } + LaunchedEffect(state.active, state.steps, state.stairs) { focusRequester.requestFocus() } ScreenScaffold( title = stringResource(R.string.ground_truth_title), @@ -138,7 +143,27 @@ fun GroundTruthCollectorScreen(vm: AppViewModel) { text = stringResource(R.string.ground_truth_stop), kind = NoopButtonKind.Destructive, modifier = Modifier.weight(1f), - onClick = { state = collector.stop(noopSteps) }, + onClick = { state = collector.stop(noopSteps); sessions = collector.sessions() }, + ) + } + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) { + OutlinedTextField( + value = excludeMinutes, + onValueChange = { value -> excludeMinutes = value.filter(Char::isDigit).take(3) }, + label = { Text(stringResource(R.string.ground_truth_minutes)) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + singleLine = true, + modifier = Modifier.weight(1f), + ) + NoopButton( + text = stringResource(R.string.ground_truth_exclude_recent), + kind = NoopButtonKind.Secondary, + modifier = Modifier.weight(2f), + enabled = excludeMinutes.toIntOrNull() in 1..240, + onClick = { + excludeMinutes.toIntOrNull()?.let { collector.excludeLastMinutes(it) } + sessions = collector.sessions() + }, ) } } else { @@ -146,37 +171,88 @@ fun GroundTruthCollectorScreen(vm: AppViewModel) { text = stringResource(R.string.ground_truth_start), fullWidth = true, enabled = noopSteps != null && vm.activeStrapId.isNotBlank(), - onClick = { state = collector.start(requireNotNull(noopSteps), vm.activeStrapId) }, + onClick = { + state = collector.start(requireNotNull(noopSteps), vm.activeStrapId) + sessions = collector.sessions() + }, + ) + } + Text(stringResource(R.string.ground_truth_sessions), style = NoopType.title2, color = Palette.textPrimary) + if (sessions.isEmpty()) { + Text(stringResource(R.string.ground_truth_no_sessions), style = NoopType.body, color = Palette.textSecondary) + } else { + sessions.forEach { session -> + SessionCard( + session = session, + exporting = exportingSessionId == session.id, + onComment = { comment -> + collector.setComment(session.id, comment) + sessions = sessions.map { if (it.id == session.id) it.copy(comment = comment) else it } + }, + onExport = { + exportingSessionId = session.id + scope.launch { + try { + collector.share(collector.export(vm.repo, session.id)) + sessions = collector.sessions() + } catch (failure: Throwable) { + if (failure is kotlinx.coroutines.CancellationException) throw failure + Toast.makeText(context, context.getString(R.string.ground_truth_export_failed, + "${failure.javaClass.simpleName}: ${failure.message ?: "unknown error"}"), Toast.LENGTH_LONG).show() + } finally { + exportingSessionId = null + } + } + }, + ) + } + } + } +} + +@Composable +private fun SessionCard( + session: GroundTruthCollector.SessionSummary, + exporting: Boolean, + onComment: (String) -> Unit, + onExport: () -> Unit, +) { + val time = remember(session.startedAtMs) { + java.text.DateFormat.getDateTimeInstance(java.text.DateFormat.SHORT, java.text.DateFormat.SHORT) + .format(java.util.Date(session.startedAtMs)) + } + NoopCard { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text(time, style = NoopType.headline, color = Palette.textPrimary) + Text( + stringResource( + R.string.ground_truth_session_summary, + session.steps, + session.stairs, + session.excludedWindows, + ), + style = NoopType.caption, + color = Palette.textSecondary, + ) + OutlinedTextField( + value = session.comment, + onValueChange = onComment, + label = { Text(stringResource(R.string.ground_truth_comment)) }, + modifier = Modifier.fillMaxWidth(), + minLines = 2, + maxLines = 4, + ) + if (session.deviceId == null) { + Text(stringResource(R.string.ground_truth_legacy_no_sensors), style = NoopType.caption, color = Palette.textSecondary) + } + NoopButton( + text = if (exporting) stringResource(R.string.ground_truth_exporting) else stringResource(R.string.ground_truth_export), + kind = NoopButtonKind.Secondary, + fullWidth = true, + enabled = !session.active && !exporting, + onClick = onExport, ) } - NoopButton( - text = if (exporting) stringResource(R.string.ground_truth_exporting) else stringResource(R.string.ground_truth_export), - kind = NoopButtonKind.Secondary, - fullWidth = true, - enabled = state.sessionId != null && !state.active && !exporting, - onClick = { - exporting = true - scope.launch { - try { - val file = collector.export(vm.repo) - state = collector.snapshot() - collector.share(file) - } catch (failure: Throwable) { - if (failure is kotlinx.coroutines.CancellationException) throw failure - Toast.makeText( - context, - context.getString( - R.string.ground_truth_export_failed, - "${failure.javaClass.simpleName}: ${failure.message ?: "unknown error"}", - ), - Toast.LENGTH_LONG, - ).show() - } finally { - exporting = false - } - } - }, - ) } } diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 7f764f1681..883a7369c4 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -2498,4 +2498,11 @@ Export session Building export… Couldn’t export session: %1$s + Minutes + Ignore recent period + Recorded sessions + No sessions recorded yet. + %1$d steps · %2$d stairs · %3$d excluded periods + Session comment + Legacy session: event export is available, but its strap cannot be identified for sensor export. From f8e979bb4014628c7b105271e98b2db936d6b6e4 Mon Sep 17 00:00:00 2001 From: Bernd Helm Date: Mon, 24 Aug 2026 23:42:54 +0200 Subject: [PATCH 08/45] feat(android): export step algorithm signals --- .../noop/testcentre/GroundTruthCollector.kt | 81 +++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/android/app/src/main/java/com/noop/testcentre/GroundTruthCollector.kt b/android/app/src/main/java/com/noop/testcentre/GroundTruthCollector.kt index 3d9fb64ea8..5e8d2d453f 100644 --- a/android/app/src/main/java/com/noop/testcentre/GroundTruthCollector.kt +++ b/android/app/src/main/java/com/noop/testcentre/GroundTruthCollector.kt @@ -262,6 +262,27 @@ class GroundTruthCollector private constructor(private val context: Context) { out.putNextEntry(ZipEntry("exclusions.csv")) out.writerEntry(exclusionsCsv(events)) + out.putNextEntry(ZipEntry("algorithm-signals.csv")) + out.bufferedWriterNoClose().use { writer -> + writeAlgorithmSignalsCsv( + writer, + repo, + deviceId.orEmpty(), + sensorFrom, + sensorTo, + exclusions, + ) + writer.flush() + } + out.closeEntry() + + out.putNextEntry(ZipEntry("v18-aux.csv")) + out.bufferedWriterNoClose().use { writer -> + writeV18AuxCsv(writer, repo, deviceId.orEmpty(), sensorFrom, sensorTo) + writer.flush() + } + out.closeEntry() + out.putNextEntry(ZipEntry("raw-sensors.csv")) out.bufferedWriterNoClose().use { writer -> RawSensorExport.writeCsv( @@ -354,6 +375,66 @@ class GroundTruthCollector private constructor(private val context: Context) { private fun csv(value: String): String = if (value.any { it == ',' || it == '"' || it == '\n' }) "\"${value.replace("\"", "\"\"")}\"" else value + private suspend fun writeAlgorithmSignalsCsv( + out: java.io.Writer, + repo: WhoopRepository, + deviceId: String, + from: Long, + to: Long, + exclusions: List>, + ) { + out.write("unix_s,counter,activity_class,gravity_x,gravity_y,gravity_z,dynamic_accel,cadence,heart_rate,band_sleep_state,excluded\n") + if (from > to || deviceId.isBlank()) return + val seconds = (to - from + 1L).coerceIn(1L, 86_400L).toInt() + val steps = repo.stepSamples(deviceId, from, to, seconds).associateBy { it.ts } + val gravity = repo.gravitySamples(deviceId, from, to, seconds).associateBy { it.ts } + val cadence = repo.v18AuxSamples(deviceId, from, to, seconds).associate { it.ts to it.stepCadence } + val heartRate = repo.rawHrSamples(deviceId, from, to, seconds).associate { it.ts to it.bpm } + val sleepState = repo.sleepStateSamples(deviceId, from, to, seconds).associate { it.ts to it.state } + val timestamps = (steps.keys + gravity.keys + cadence.keys + heartRate.keys + sleepState.keys).sorted() + for (ts in timestamps) { + val step = steps[ts] + val grav = gravity[ts] + val excluded = exclusions.any { ts * 1_000L <= it.second && ts * 1_000L + 999L >= it.first } + out.write(listOf( + ts, + step?.counter ?: "", + step?.activityClass ?: "", + grav?.x?.let(::decimal) ?: "", + grav?.y?.let(::decimal) ?: "", + grav?.z?.let(::decimal) ?: "", + grav?.dynAccel?.let(::decimal) ?: "", + cadence[ts] ?: "", + heartRate[ts] ?: "", + sleepState[ts] ?: "", + excluded, + ).joinToString(",")) + out.write("\n") + } + } + + private suspend fun writeV18AuxCsv( + out: java.io.Writer, + repo: WhoopRepository, + deviceId: String, + from: Long, + to: Long, + ) { + out.write( + "unix_s,record_index,rr_count,cardiac_flags,hr_quality_flags,heart_rate_alt,rr_packed," + + "cardiac_status,step_cadence,status_word,status_word_1,status_word_2,aux_byte_82," + + "optical_baseline_a,optical_baseline_b,optical_amp_a,optical_amp_b,unknown_f32_bits\n", + ) + if (from > to || deviceId.isBlank()) return + val seconds = (to - from + 1L).coerceIn(1L, 86_400L).toInt() + for (row in repo.v18AuxSamples(deviceId, from, to, seconds)) { + out.write((listOf(row.ts) + row.slotValues.map { it ?: "" }).joinToString(",")) + out.write("\n") + } + } + + private fun decimal(value: Double): String = String.format(java.util.Locale.US, "%.9f", value) + private suspend fun writeRawImuCsv( out: java.io.Writer, repo: WhoopRepository, From fd4eff3db719eb574ce48337de8f45c09b64ac72 Mon Sep 17 00:00:00 2001 From: Bernd Helm Date: Wed, 26 Aug 2026 17:15:44 +0200 Subject: [PATCH 09/45] Add ground-truth realtime IMU diagnostics --- .../main/java/com/noop/ble/WhoopBleClient.kt | 112 +++++++++++++++++- .../src/main/java/com/noop/protocol/Enums.kt | 4 + .../noop/testcentre/GroundTruthCollector.kt | 10 ++ .../com/noop/ui/GroundTruthCollectorScreen.kt | 83 ++++++++++++- 4 files changed, 207 insertions(+), 2 deletions(-) diff --git a/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt b/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt index 94fe81b2ad..33bb1a3977 100644 --- a/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt +++ b/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt @@ -90,6 +90,10 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import java.util.UUID +import java.io.BufferedOutputStream +import java.io.DataOutputStream +import java.io.File +import java.io.FileOutputStream import java.util.concurrent.ConcurrentLinkedQueue import java.util.concurrent.atomic.AtomicInteger @@ -392,6 +396,16 @@ class RealGattOps(private val gatt: BluetoothGatt) : GattOps { gatt.setPreferredPhy(txPhy, rxPhy, phyOptions) } +/** Foreground ground-truth diagnostics. Kept separate from [LiveState] so production UI/state stays clean. */ +data class GroundTruthImuStatus( + val sessionId: String? = null, + val requested: Boolean = false, + val packets: Long = 0, + val bytes: Long = 0, + val lastPacketAtMs: Long? = null, + val note: String = "Not started", +) + class WhoopBleClient( private val context: Context, /** @@ -1763,6 +1777,9 @@ class WhoopBleClient( ) val state: StateFlow = _state.asStateFlow() + private val _groundTruthImuStatus = MutableStateFlow(GroundTruthImuStatus()) + val groundTruthImuStatus: StateFlow = _groundTruthImuStatus.asStateFlow() + /** Monotonic event for the in-memory/exportable strap log. Test Centre panels collect it only while * active and coalesce bursts, so a new tagged line refreshes its readout without mutating LiveState. */ private val _logRevision = MutableStateFlow(0L) @@ -2165,6 +2182,96 @@ class WhoopBleClient( * thread (user actions) - the barrier makes a main-thread send see the current characteristic. */ @Volatile private var cmdCharacteristic: BluetoothGattCharacteristic? = null + @Volatile private var groundTruthImuCommandAllowed = false + private var groundTruthImuSessionId: String? = null + private var groundTruthImuOut: DataOutputStream? = null + + /** Start the hardware-confirmed WHOOP 5 realtime IMU mode for an explicit ground-truth session. */ + @Synchronized + fun startGroundTruthImuCapture(sessionId: String): Boolean { + if (gatt == null || cmdCharacteristic == null) { + _groundTruthImuStatus.value = GroundTruthImuStatus( + sessionId = sessionId, + note = "Band not connected / command channel not ready", + ) + return false + } + if (connectedFamily != DeviceFamily.WHOOP5) { + _groundTruthImuStatus.value = GroundTruthImuStatus( + sessionId = sessionId, + note = "Realtime IMU unsupported for connected family: $connectedFamily", + ) + return false + } + if (groundTruthImuSessionId != sessionId || groundTruthImuOut == null) { + closeGroundTruthImuFile() + val dir = File(context.filesDir, "ground-truth").apply { mkdirs() } + val file = File(dir, "realtime-imu-$sessionId.bin") + // Append lets a foreground collector resume the same session after an Android process restart. + groundTruthImuOut = DataOutputStream(BufferedOutputStream(FileOutputStream(file, true), 64 * 1024)) + groundTruthImuSessionId = sessionId + _groundTruthImuStatus.value = GroundTruthImuStatus(sessionId = sessionId, note = "Capture file open") + } + groundTruthImuCommandAllowed = true + try { + send(CommandNumber.TOGGLE_IMU_MODE, byteArrayOf(1, 1), withResponse = true) + } finally { + groundTruthImuCommandAllowed = false + } + log("Ground-truth realtime IMU requested ON (session=$sessionId)") + _groundTruthImuStatus.update { it.copy(requested = true, note = "IMU requested; waiting for packets") } + return true + } + + /** Stop the realtime IMU before closing its lossless length-prefixed frame file. */ + @Synchronized + fun stopGroundTruthImuCapture() { + if (connectedFamily == DeviceFamily.WHOOP5 && gatt != null && cmdCharacteristic != null) { + groundTruthImuCommandAllowed = true + try { + send(CommandNumber.TOGGLE_IMU_MODE, byteArrayOf(1, 0), withResponse = true) + } finally { + groundTruthImuCommandAllowed = false + } + } + log("Ground-truth realtime IMU requested OFF (session=${groundTruthImuSessionId ?: "none"})") + _groundTruthImuStatus.update { it.copy(requested = false, note = "Stopped") } + closeGroundTruthImuFile() + } + + @Synchronized + private fun recordGroundTruthImuFrame(frame: ByteArray) { + if (frame.size <= 8 || (frame[8].toInt() and 0xFF) != 51) return + groundTruthImuOut?.let { out -> + runCatching { + out.writeLong(System.currentTimeMillis()) + out.writeInt(frame.size) + out.write(frame) + val now = System.currentTimeMillis() + _groundTruthImuStatus.update { + it.copy( + packets = it.packets + 1, + bytes = it.bytes + frame.size, + lastPacketAtMs = now, + note = "Receiving realtime IMU", + ) + } + }.onFailure { + log("Ground-truth realtime IMU write failed (${it.javaClass.simpleName}); capture stopped") + _groundTruthImuStatus.update { status -> status.copy(requested = false, note = "Write failed: ${it.javaClass.simpleName}") } + closeGroundTruthImuFile() + } + } + } + + @Synchronized + private fun closeGroundTruthImuFile() { + runCatching { groundTruthImuOut?.flush() } + runCatching { groundTruthImuOut?.close() } + groundTruthImuOut = null + groundTruthImuSessionId = null + } + /** Frame reassembler for the fragmented custom notify chars (port of Reassembler). Reassigned per * connection with the detected family — WHOOP5/MG frames use a different length encoding. */ private var reassembler = Reassembler() @@ -3448,7 +3555,9 @@ class WhoopBleClient( // 5/MG allow-list: live HR, buzz, and the historical-offload pair (trigger + ack). The // offload commands ride the SAME proven puffin COMMAND frame as the Swift path // (whoop5HistoricalAckFrame = puffinCommandFrame(23, [0x01]+endData)). (#78) - if (cmd != CommandNumber.TOGGLE_REALTIME_HR && cmd != CommandNumber.RUN_HAPTICS_PATTERN && + if (cmd != CommandNumber.TOGGLE_REALTIME_HR && + !(cmd == CommandNumber.TOGGLE_IMU_MODE && groundTruthImuCommandAllowed) && + cmd != CommandNumber.RUN_HAPTICS_PATTERN && cmd != CommandNumber.SEND_HISTORICAL_DATA && cmd != CommandNumber.HISTORICAL_DATA_RESULT && // ABORT_HISTORICAL_TRANSMITS (20) over puffin: stop an offload already in flight. Allowed // ONLY while one actually is, so a default install can never form these bytes on a 5/MG — @@ -5948,6 +6057,7 @@ class WhoopBleClient( // A frame replayed as part of the historical offload (type 47/48/… during a backfill) // must not drive LIVE-only state (the charging pill). Mirrors iOS, where the offload // path skips the live router entirely. (PR #568 reimpl) + recordGroundTruthImuFrame(frame) handleFrame(frame, parsed, replayedOffload = offloadFrame) // Capture the strap's newest stored record from a GET_DATA_RANGE reply, feeding diff --git a/android/app/src/main/java/com/noop/protocol/Enums.kt b/android/app/src/main/java/com/noop/protocol/Enums.kt index 0e9ef0117c..0339d3b122 100644 --- a/android/app/src/main/java/com/noop/protocol/Enums.kt +++ b/android/app/src/main/java/com/noop/protocol/Enums.kt @@ -215,6 +215,10 @@ enum class CommandNumber(val rawValue: Int) { SET_DEVICE_CONFIG(119), START_RAW_DATA(81), STOP_RAW_DATA(82), + // WHOOP 5/MG realtime 6-axis IMU. The production WHOOP app sends revision 1 plus an enabled + // byte while a Strength Builder workout is active (hardware-captured on a normal WHOOP 5). + // NOOP exposes this only through the explicitly user-started Ground Truth collector. + TOGGLE_IMU_MODE(106), // GET_EXTENDED_BATTERY_INFO (98) — read-only extended battery read (mV etc.). The NUMBER is disputed // (#592): an independent APK decompile reads this family 11 lower (87), while whoomp's table says 98 — // partially supported by a real WHOOP 5 (fw 50.38.1.0) ANSWERING 98, though with a short stub that diff --git a/android/app/src/main/java/com/noop/testcentre/GroundTruthCollector.kt b/android/app/src/main/java/com/noop/testcentre/GroundTruthCollector.kt index 5e8d2d453f..df3e05dfdf 100644 --- a/android/app/src/main/java/com/noop/testcentre/GroundTruthCollector.kt +++ b/android/app/src/main/java/com/noop/testcentre/GroundTruthCollector.kt @@ -57,6 +57,9 @@ class GroundTruthCollector private constructor(private val context: Context) { private val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) private val directory = File(context.filesDir, "ground-truth").apply { mkdirs() } + fun realtimeImuBytes(sessionId: String): Long = + File(directory, "realtime-imu-$sessionId.bin").takeIf(File::isFile)?.length() ?: 0L + @Synchronized fun snapshot(): Snapshot = Snapshot( active = prefs.getBoolean("active", false), @@ -300,6 +303,13 @@ class GroundTruthCollector private constructor(private val context: Context) { writer.flush() } out.closeEntry() + + val realtimeImu = File(directory, "realtime-imu-$id.bin") + if (realtimeImu.isFile) { + out.putNextEntry(ZipEntry("realtime-imu.bin")) + realtimeImu.inputStream().use { it.copyTo(out) } + out.closeEntry() + } } prefs.edit().putBoolean(sessionExportedKey(id), true).apply() if (id == snap.sessionId) prefs.edit().putBoolean("exported", true).apply() diff --git a/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt b/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt index 1ccc3e6cd9..05f19daf9a 100644 --- a/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt +++ b/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt @@ -37,6 +37,7 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.noop.R import com.noop.testcentre.GroundTruthCollector +import kotlinx.coroutines.delay import kotlinx.coroutines.launch /** Foreground-only hardware-clicker capture. Production steps never depend on this research surface. */ @@ -49,12 +50,22 @@ fun GroundTruthCollectorScreen(vm: AppViewModel) { val scope = rememberCoroutineScope() val cycle by vm.activeDayCycle.collectAsStateWithLifecycle() val days by vm.recentDays.collectAsStateWithLifecycle() + val live by vm.live.collectAsStateWithLifecycle() + val imuStatus by vm.ble.groundTruthImuStatus.collectAsStateWithLifecycle() val noopSteps = cycle?.steps ?: days.lastOrNull()?.steps var state by remember { mutableStateOf(collector.snapshot()) } var haptics by remember { mutableStateOf(false) } var exportingSessionId by remember { mutableStateOf(null) } var excludeMinutes by remember { mutableStateOf("5") } var sessions by remember { mutableStateOf(collector.sessions()) } + var latestSensorTs by remember { mutableStateOf(null) } + + LaunchedEffect(vm.activeStrapId) { + while (true) { + latestSensorTs = vm.activeStrapId.takeIf(String::isNotBlank)?.let { vm.repo.latestHrSampleTs(it) } + delay(1_000) + } + } DisposableEffect(view) { val oldKeepScreenOn = view.keepScreenOn @@ -64,6 +75,10 @@ fun GroundTruthCollectorScreen(vm: AppViewModel) { } } LaunchedEffect(state.active, state.steps, state.stairs) { focusRequester.requestFocus() } + // Re-arm after a BLE reconnect or Android process restart while the manual session is still active. + LaunchedEffect(state.active, state.sessionId, live.connected) { + if (state.active && live.connected) state.sessionId?.let(vm.ble::startGroundTruthImuCapture) + } ScreenScaffold( title = stringResource(R.string.ground_truth_title), @@ -104,6 +119,39 @@ fun GroundTruthCollectorScreen(vm: AppViewModel) { } } + NoopCard { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text("Capture diagnostics", style = NoopType.headline, color = Palette.textPrimary) + Text( + if (live.connected) "Band: connected${if (live.bonded) " + paired" else "; pairing"}" + else "Band: disconnected${if (live.scanning) "; searching" else ""}", + style = NoopType.body, + color = if (live.connected) Palette.statusPositive else Palette.statusCritical, + ) + Text( + when { + live.backfilling -> "History sync: running (${live.syncChunksThisSession} chunks)" + live.lastSyncAt != null -> "History sync completed: ${diagnosticTime(live.lastSyncAt!! * 1000)}" + else -> "History sync: no completed sync recorded" + }, + style = NoopType.body, + color = Palette.textSecondary, + ) + Text( + latestSensorTs?.let { "Sensor data through: ${diagnosticTime(it * 1000)} (${diagnosticAge(it * 1000)} behind)" } + ?: "Sensor data through: none", + style = NoopType.body, + color = Palette.textSecondary, + ) + Text( + "Realtime IMU: ${imuStatus.note}; ${imuStatus.packets} packets / ${imuStatus.bytes} bytes" + + (imuStatus.lastPacketAtMs?.let { "; last ${diagnosticAge(it)} ago" } ?: ""), + style = NoopType.body, + color = if (imuStatus.packets > 0) Palette.statusPositive else Palette.textSecondary, + ) + } + } + NoopCard { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Text(stringResource(R.string.ground_truth_mapping_title), style = NoopType.headline, color = Palette.textPrimary) @@ -143,7 +191,11 @@ fun GroundTruthCollectorScreen(vm: AppViewModel) { text = stringResource(R.string.ground_truth_stop), kind = NoopButtonKind.Destructive, modifier = Modifier.weight(1f), - onClick = { state = collector.stop(noopSteps); sessions = collector.sessions() }, + onClick = { + vm.ble.stopGroundTruthImuCapture() + state = collector.stop(noopSteps) + sessions = collector.sessions() + }, ) } Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) { @@ -173,6 +225,7 @@ fun GroundTruthCollectorScreen(vm: AppViewModel) { enabled = noopSteps != null && vm.activeStrapId.isNotBlank(), onClick = { state = collector.start(requireNotNull(noopSteps), vm.activeStrapId) + state.sessionId?.let(vm.ble::startGroundTruthImuCapture) sessions = collector.sessions() }, ) @@ -184,6 +237,8 @@ fun GroundTruthCollectorScreen(vm: AppViewModel) { sessions.forEach { session -> SessionCard( session = session, + latestSensorTs = latestSensorTs, + realtimeImuBytes = collector.realtimeImuBytes(session.id), exporting = exportingSessionId == session.id, onComment = { comment -> collector.setComment(session.id, comment) @@ -213,6 +268,8 @@ fun GroundTruthCollectorScreen(vm: AppViewModel) { @Composable private fun SessionCard( session: GroundTruthCollector.SessionSummary, + latestSensorTs: Long?, + realtimeImuBytes: Long, exporting: Boolean, onComment: (String) -> Unit, onExport: () -> Unit, @@ -223,6 +280,18 @@ private fun SessionCard( } NoopCard { Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + val sensorCovered = session.endedAtMs?.let { end -> latestSensorTs?.times(1000)?.let { it >= end } } == true + val captureReady = sensorCovered && realtimeImuBytes > 0 + Text( + when { + session.endedAtMs == null -> "Export status: recording" + captureReady -> "Export status: ready (history covered, IMU ${realtimeImuBytes} bytes)" + !sensorCovered -> "Export status: wait for history sync through ${diagnosticTime(requireNotNull(session.endedAtMs))}" + else -> "Export status: no realtime IMU packets captured" + }, + style = NoopType.caption, + color = if (captureReady) Palette.statusPositive else Palette.statusCritical, + ) Text(time, style = NoopType.headline, color = Palette.textPrimary) Text( stringResource( @@ -275,3 +344,15 @@ private fun KeyEvent.toCollectorKey(): GroundTruthCollector.KeyInfo { source = source, ) } + +private fun diagnosticTime(epochMs: Long): String = + java.text.SimpleDateFormat("HH:mm:ss", java.util.Locale.getDefault()).format(java.util.Date(epochMs)) + +private fun diagnosticAge(epochMs: Long): String { + val seconds = ((System.currentTimeMillis() - epochMs).coerceAtLeast(0L) / 1_000L) + return when { + seconds < 60 -> "${seconds}s" + seconds < 3_600 -> "${seconds / 60}m ${seconds % 60}s" + else -> "${seconds / 3_600}h ${(seconds % 3_600) / 60}m" + } +} From 01f2fcac1c3357704b8872134dfa10d265df0893 Mon Sep 17 00:00:00 2001 From: Bernd Helm Date: Wed, 26 Aug 2026 17:42:13 +0200 Subject: [PATCH 10/45] Start the raw producer for ground-truth IMU capture --- .../main/java/com/noop/ble/WhoopBleClient.kt | 19 ++++++++++++++++--- .../com/noop/ui/GroundTruthCollectorScreen.kt | 1 - 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt b/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt index 33bb1a3977..ec517a6192 100644 --- a/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt +++ b/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt @@ -2214,6 +2214,9 @@ class WhoopBleClient( } groundTruthImuCommandAllowed = true try { + // The verified bounded raw-capture sequence is START_RAW_DATA followed by the + // realtime IMU selector. Opcode 106 alone ACKs but does not start the producer. + send(CommandNumber.START_RAW_DATA, byteArrayOf(1), withResponse = true) send(CommandNumber.TOGGLE_IMU_MODE, byteArrayOf(1, 1), withResponse = true) } finally { groundTruthImuCommandAllowed = false @@ -2229,6 +2232,7 @@ class WhoopBleClient( if (connectedFamily == DeviceFamily.WHOOP5 && gatt != null && cmdCharacteristic != null) { groundTruthImuCommandAllowed = true try { + send(CommandNumber.STOP_RAW_DATA, byteArrayOf(1), withResponse = true) send(CommandNumber.TOGGLE_IMU_MODE, byteArrayOf(1, 0), withResponse = true) } finally { groundTruthImuCommandAllowed = false @@ -2241,7 +2245,12 @@ class WhoopBleClient( @Synchronized private fun recordGroundTruthImuFrame(frame: ByteArray) { - if (frame.size <= 8 || (frame[8].toInt() and 0xFF) != 51) return + if (frame.size <= 8) return + val packetType = frame[8].toInt() and 0xFF + // 51 is the named realtime IMU stream. Firmware/protocol revisions also use the older raw + // stream (43) and historical IMU stream (52); retain all three losslessly during an explicit + // ground-truth session instead of silently discarding a valid 100 Hz producer. + if (packetType !in setOf(43, 51, 52)) return groundTruthImuOut?.let { out -> runCatching { out.writeLong(System.currentTimeMillis()) @@ -2253,7 +2262,7 @@ class WhoopBleClient( packets = it.packets + 1, bytes = it.bytes + frame.size, lastPacketAtMs = now, - note = "Receiving realtime IMU", + note = "Receiving IMU packet type $packetType", ) } }.onFailure { @@ -3556,7 +3565,11 @@ class WhoopBleClient( // offload commands ride the SAME proven puffin COMMAND frame as the Swift path // (whoop5HistoricalAckFrame = puffinCommandFrame(23, [0x01]+endData)). (#78) if (cmd != CommandNumber.TOGGLE_REALTIME_HR && - !(cmd == CommandNumber.TOGGLE_IMU_MODE && groundTruthImuCommandAllowed) && + !(cmd in setOf( + CommandNumber.START_RAW_DATA, + CommandNumber.STOP_RAW_DATA, + CommandNumber.TOGGLE_IMU_MODE, + ) && groundTruthImuCommandAllowed) && cmd != CommandNumber.RUN_HAPTICS_PATTERN && cmd != CommandNumber.SEND_HISTORICAL_DATA && cmd != CommandNumber.HISTORICAL_DATA_RESULT && // ABORT_HISTORICAL_TRANSMITS (20) over puffin: stop an offload already in flight. Allowed diff --git a/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt b/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt index 05f19daf9a..0d0eb8f6bd 100644 --- a/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt +++ b/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt @@ -225,7 +225,6 @@ fun GroundTruthCollectorScreen(vm: AppViewModel) { enabled = noopSteps != null && vm.activeStrapId.isNotBlank(), onClick = { state = collector.start(requireNotNull(noopSteps), vm.activeStrapId) - state.sessionId?.let(vm.ble::startGroundTruthImuCapture) sessions = collector.sessions() }, ) From 73939af8bf5889fb830633b8f8470b38058af3b4 Mon Sep 17 00:00:00 2001 From: Bernd Helm Date: Wed, 26 Aug 2026 18:49:57 +0200 Subject: [PATCH 11/45] Build a cross-platform 5/MG raw data collector --- .../Sources/WhoopProtocol/Whoop5RawImu.swift | 6 +- Strand/BLE/BLEManager.swift | 19 +++- Strand/Screens/TestCentreView.swift | 34 +++++++ .../main/java/com/noop/ble/WhoopBleClient.kt | 5 +- .../noop/testcentre/GroundTruthCollector.kt | 26 +++-- .../main/java/com/noop/ui/AddDeviceWizard.kt | 2 +- .../com/noop/ui/GroundTruthCollectorScreen.kt | 13 +-- .../main/java/com/noop/ui/SettingsScreen.kt | 82 +--------------- .../main/java/com/noop/ui/TestCentreScreen.kt | 90 +++++++++++++++++ .../app/src/main/res/values-de/strings.xml | 98 +++++++++++++++++++ android/app/src/main/res/values/strings.xml | 16 +-- 11 files changed, 274 insertions(+), 117 deletions(-) diff --git a/Packages/WhoopProtocol/Sources/WhoopProtocol/Whoop5RawImu.swift b/Packages/WhoopProtocol/Sources/WhoopProtocol/Whoop5RawImu.swift index bd2230957e..b419231782 100644 --- a/Packages/WhoopProtocol/Sources/WhoopProtocol/Whoop5RawImu.swift +++ b/Packages/WhoopProtocol/Sources/WhoopProtocol/Whoop5RawImu.swift @@ -8,9 +8,9 @@ import Foundation // app captures (documented in Asherlc/dofek `docs/whoop-ble-protocol.md` as the type-0x2B raw packet) // and the same columnar convention as the 4.0's 1917 REALTIME_RAW_DATA variant. // -// IMPORTANT: the 5/MG's *live* raw-IMU stream is firmware-refused (TOGGLE_IMU_MODE / cmd 106 acks but -// never streams), but the OFFLOAD buffer carries full accel AND gyro — so 100 Hz 6-axis IMU IS -// obtainable on the 5.0 via the historical path. +// Hardware validation on a WHOOP 5/MG established that live raw IMU requires START_RAW_DATA (81) +// followed by TOGGLE_IMU_MODE (106) with payload [1,1]. Opcode 106 on its own acknowledges without +// starting the producer. The same 1244-byte shape is also banked and later returned by history sync. // // Frame layout (the reassembled BLE frame = 8-byte puffin envelope + payload; offsets are FRAME-absolute): // @15 u32 LE strap unix seconds for this 1-second frame diff --git a/Strand/BLE/BLEManager.swift b/Strand/BLE/BLEManager.swift index a9db87dbc5..fb841ddb7c 100644 --- a/Strand/BLE/BLEManager.swift +++ b/Strand/BLE/BLEManager.swift @@ -1756,8 +1756,14 @@ public final class BLEManager: NSObject, ObservableObject { rawCaptureInFlight = true let secs = RawCaptureWindow.clamp(seconds) collector?.beginRawCapture(seconds: secs) - send(.startRawData, payload: [0x01]) - send(.toggleIMUMode, payload: [0x01]) + // Hardware-verified on WHOOP 5/MG: opcode 106 alone acknowledges but does not start the + // producer. START_RAW_DATA must precede the two-byte realtime IMU selector. + send(.startRawData, payload: [0x01], writeType: .withResponse) + if selectedModel.deviceFamily == .whoop5 { + send(.toggleIMUMode, payload: [0x01, 0x01], writeType: .withResponse) + } else { + send(.toggleIMUMode, payload: [0x01], writeType: .withResponse) + } log("Raw-accel capture: started for \(secs)s") DispatchQueue.main.asyncAfter(deadline: .now() + secs) { [weak self] in guard let self else { return } @@ -1765,7 +1771,10 @@ public final class BLEManager: NSObject, ObservableObject { // continuous stream must keep running — we just flush/upload the bounded window we // captured without halting the wider session. if !UserDefaults.standard.bool(forKey: "enableRawCapture") { - self.send(.stopRawData, payload: [0x01]) + self.send(.stopRawData, payload: [0x01], writeType: .withResponse) + if self.selectedModel.deviceFamily == .whoop5 { + self.send(.toggleIMUMode, payload: [0x01, 0x00], writeType: .withResponse) + } } self.rawCaptureInFlight = false Task { @MainActor in @@ -1857,6 +1866,10 @@ public final class BLEManager: NSObject, ObservableObject { || (DeviceConfigReadProbe.isReadOnlyOpcode(command.rawValue) && deviceConfigReport != nil) || command == .sendHistoricalData || command == .historicalDataResult || command == .setClock || command == .getClock + // Bounded Raw Data Collector only. These writes remain impossible unless the explicit + // user-started capture window is in flight; normal sync never enables this gate. + || (rawCaptureInFlight && (command == .startRawData + || command == .stopRawData || command == .toggleIMUMode)) // SET_CONFIG / SET_FF_VALUE (120), ENABLE direction — the R22 deep-stream unlock. Allowed // only while the deep-data experiment is opted in, and only for a KEY and a VALUE the gate // recognises: one of the sixteen R22 flags carrying that key's own enable value. The clause diff --git a/Strand/Screens/TestCentreView.swift b/Strand/Screens/TestCentreView.swift index ec5a195605..4b65a0668c 100644 --- a/Strand/Screens/TestCentreView.swift +++ b/Strand/Screens/TestCentreView.swift @@ -35,6 +35,7 @@ struct TestCentreView: View { // Retention (#650): how many scheduled-export generations to keep, and the manual clear confirm. @State private var debugExportKeep = ScheduledDebugExport.keepCount @State private var showClearExportsConfirm = false + @State private var rawMotionCaptureActive = false /// The strap model the user last picked, the same key SettingsView's showFiveMGControls gate reads. @AppStorage("selectedWhoopModel") private var selectedWhoopModelRaw = WhoopModel.whoop4.rawValue @@ -77,6 +78,7 @@ struct TestCentreView: View { VStack(alignment: .leading, spacing: NoopMetrics.sectionSpacing) { domainModesCard.staggeredAppear(index: 0) diagnosticToolsCard.staggeredAppear(index: 1) + if is5MG { rawDataCollectorCard.staggeredAppear(index: 2) } exportCard.staggeredAppear(index: 2) experimentalAlgorithmsCard.staggeredAppear(index: 3) } @@ -132,6 +134,38 @@ struct TestCentreView: View { // MARK: - Section 2: Diagnostic tools (strap log + recalibrate + env dump) + @ViewBuilder private var rawDataCollectorCard: some View { + NoopCard { + VStack(alignment: .leading, spacing: NoopMetrics.space3) { + Text("5/MG RAW DATA COLLECTOR") + .font(StrandFont.overline).tracking(StrandFont.overlineTracking) + .foregroundStyle(StrandPalette.textSecondary) + Text("Records a bounded 60-second, 100 Hz motion window. The verified start/stop sequence is used only while this user-started session is active; normal sync is unchanged.") + .font(StrandFont.caption).foregroundStyle(StrandPalette.textTertiary) + .fixedSize(horizontal: false, vertical: true) + NoopButton( + rawMotionCaptureActive ? "Capturing 100 Hz motion…" : "Capture 60 seconds", + systemImage: rawMotionCaptureActive ? "waveform.path.ecg" : "record.circle", + kind: .primary + ) { + guard !rawMotionCaptureActive else { return } + rawMotionCaptureActive = true + model.ble.captureRawAccel(seconds: 60) + Task { @MainActor in + try? await Task.sleep(nanoseconds: 60_000_000_000) + rawMotionCaptureActive = false + } + } + .disabled(rawMotionCaptureActive || !live.connected) + Text(live.connected + ? "The bounded raw window is persisted locally and can be included in Test Centre diagnostics." + : "Connect your WHOOP 5/MG to start a capture.") + .font(StrandFont.caption) + .foregroundStyle(live.connected ? StrandPalette.textSecondary : StrandPalette.statusWarning) + } + } + } + @ViewBuilder private var diagnosticToolsCard: some View { NoopCard { VStack(alignment: .leading, spacing: NoopMetrics.space3) { diff --git a/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt b/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt index ec517a6192..c0f0477a52 100644 --- a/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt +++ b/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt @@ -9257,7 +9257,10 @@ class WhoopBleClient( * 1244-B 6-axis buffer decodes (rawColumns null otherwise). IO-dispatched so it never blocks the GATT * thread; bounded by a rolling retention prune. Raw i16, no downstream consumer yet (instrument-first). */ private fun storeWhoop5RawImuIfBuffer(frame: ByteArray) { - if (!PuffinExperiment.from(context).isCaptureEnabled) return + // The bounded collector owns its own capture gate. It must not require the unrelated passive + // protocol-trace preference: live and history copies then converge on the same keyed raw-IMU + // table, whose primary key deduplicates a Bluetooth-gap repair automatically. + if (!PuffinExperiment.from(context).isCaptureEnabled && groundTruthImuSessionId == null) return val cols = Whoop5RawImu.rawColumns(frame) ?: return val baseTs = PuffinDeepBufferLog.strapTs(frame)?.toLong() ?: return val dev = deviceId diff --git a/android/app/src/main/java/com/noop/testcentre/GroundTruthCollector.kt b/android/app/src/main/java/com/noop/testcentre/GroundTruthCollector.kt index df3e05dfdf..388f97da04 100644 --- a/android/app/src/main/java/com/noop/testcentre/GroundTruthCollector.kt +++ b/android/app/src/main/java/com/noop/testcentre/GroundTruthCollector.kt @@ -15,7 +15,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.json.JSONObject -/** App-private, explicitly user-started manual ground-truth capture. No production metric reads this. */ +/** App-private, explicitly user-started bounded 5/MG raw-data capture with optional manual labels. */ class GroundTruthCollector private constructor(private val context: Context) { data class KeyInfo( val keyCode: Int, @@ -77,7 +77,7 @@ class GroundTruthCollector private constructor(private val context: Context) { ) @Synchronized - fun start(noopSteps: Int, deviceId: String, nowMs: Long = System.currentTimeMillis()): Snapshot { + fun start(noopSteps: Int?, deviceId: String, nowMs: Long = System.currentTimeMillis()): Snapshot { val id = nowMs.toString() prefs.edit() .putBoolean("active", true) @@ -220,22 +220,18 @@ class GroundTruthCollector private constructor(private val context: Context) { .toList() } val endMs = summary.endedAtMs ?: events.lastOrNull()?.optLong("at_ms") ?: summary.startedAtMs - // Sensor data is useful only around manually marked ground truth. An abandoned collector left - // open overnight must not turn one tap on Export into a multi-gigabyte query/allocation. - val markedEvents = events.filter { - it.optString("kind") == KIND_STEP || it.optString("kind") == KIND_STAIR || - it.optString("kind") == KIND_UNDO_STEP || it.optString("kind") == KIND_UNDO_STAIR - } val exclusions = exclusionWindows(events) - val sensorBoundsMs = markedEvents.map { it.optLong("at_ms") } + exclusions.flatMap { listOf(it.first, it.second) } - val sensorFrom = if (deviceId == null) 1L else sensorBoundsMs.minOrNull()?.div(1_000L)?.minus(5L) ?: 1L - val sensorTo = if (deviceId == null) 0L else sensorBoundsMs.maxOrNull()?.div(1_000L)?.plus(5L) ?: 0L + // A raw-data session is useful even without manual step labels. Export its complete bounded + // interval; labels and exclusion windows are optional annotations, not a prerequisite for data. + val sensorFrom = if (deviceId == null) 1L else summary.startedAtMs / 1_000L + val sensorTo = if (deviceId == null) 0L else endMs / 1_000L val outDir = File(context.cacheDir, "logs").apply { mkdirs() } val zip = File(outDir, "noop-ground-truth-$id.zip") ZipOutputStream(zip.outputStream().buffered()).use { out -> out.putNextEntry(ZipEntry("meta.json")) out.writerEntry(JSONObject().apply { - put("schema_version", 2) + put("schema_version", 3) + put("capture_kind", "whoop_5mg_raw_data") put("session_id", id) if (deviceId != null) put("device_id", deviceId) put("sensor_export_available", deviceId != null) @@ -249,7 +245,7 @@ class GroundTruthCollector private constructor(private val context: Context) { if (id == snap.sessionId && snap.lastNoopSteps != null) put("noop_steps_at_end", snap.lastNoopSteps) put("device_family", "Android") put("app_version", BuildConfig.VERSION_NAME) - put("ground_truth", "JX-05S hardware clicker; ZOOM_OUT=step, ZOOM_IN=stair") + put("manual_labels", "optional; JX-05S ZOOM_OUT=step, ZOOM_IN=stair") }.toString(2)) out.putNextEntry(ZipEntry("events.jsonl")) @@ -321,9 +317,9 @@ class GroundTruthCollector private constructor(private val context: Context) { context.startActivity(Intent.createChooser(Intent(Intent.ACTION_SEND).apply { type = "application/zip" putExtra(Intent.EXTRA_STREAM, uri) - putExtra(Intent.EXTRA_SUBJECT, "NOOP ground-truth session") + putExtra(Intent.EXTRA_SUBJECT, "NOOP 5/MG raw-data session") addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - }, "Export ground-truth session").addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) + }, "Export raw-data session").addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) } private fun eventFile(id: String) = File(directory, "session-$id.jsonl") diff --git a/android/app/src/main/java/com/noop/ui/AddDeviceWizard.kt b/android/app/src/main/java/com/noop/ui/AddDeviceWizard.kt index cd97888b81..8253fa1998 100644 --- a/android/app/src/main/java/com/noop/ui/AddDeviceWizard.kt +++ b/android/app/src/main/java/com/noop/ui/AddDeviceWizard.kt @@ -700,7 +700,7 @@ private fun ouraHeaderSubtitle(step: OuraStep, advanced: Boolean): String? = whe @Composable private fun TypeStep(onPick: (DeviceType) -> Unit) { Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { - TypeRow(Icons.Filled.Watch, DeviceType.Whoop5MG.title, "Newer WHOOP band. Experimental in NOOP") { + TypeRow(Icons.Filled.Watch, DeviceType.Whoop5MG.title, "Newer WHOOP band with live data and history sync") { onPick(DeviceType.Whoop5MG) } TypeRow(Icons.Filled.Watch, DeviceType.Whoop4.title, "NOOP's primary, fully-supported band") { diff --git a/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt b/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt index 0d0eb8f6bd..eec206d5d7 100644 --- a/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt +++ b/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt @@ -40,7 +40,7 @@ import com.noop.testcentre.GroundTruthCollector import kotlinx.coroutines.delay import kotlinx.coroutines.launch -/** Foreground-only hardware-clicker capture. Production steps never depend on this research surface. */ +/** Bounded 5/MG raw-data capture. Manual clicker labels are an optional fork extension. */ @Composable fun GroundTruthCollectorScreen(vm: AppViewModel) { val context = LocalContext.current @@ -48,11 +48,12 @@ fun GroundTruthCollectorScreen(vm: AppViewModel) { val focusRequester = remember { FocusRequester() } val view = LocalView.current val scope = rememberCoroutineScope() - val cycle by vm.activeDayCycle.collectAsStateWithLifecycle() val days by vm.recentDays.collectAsStateWithLifecycle() val live by vm.live.collectAsStateWithLifecycle() val imuStatus by vm.ble.groundTruthImuStatus.collectAsStateWithLifecycle() - val noopSteps = cycle?.steps ?: days.lastOrNull()?.steps + // Optional fork overlay only. The raw collector itself must not depend on the still-separate + // physiological day-cycle work; use the latest published total when the step counter is present. + val noopSteps = days.lastOrNull()?.steps var state by remember { mutableStateOf(collector.snapshot()) } var haptics by remember { mutableStateOf(false) } var exportingSessionId by remember { mutableStateOf(null) } @@ -121,7 +122,7 @@ fun GroundTruthCollectorScreen(vm: AppViewModel) { NoopCard { Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { - Text("Capture diagnostics", style = NoopType.headline, color = Palette.textPrimary) + Text("Capture coverage", style = NoopType.headline, color = Palette.textPrimary) Text( if (live.connected) "Band: connected${if (live.bonded) " + paired" else "; pairing"}" else "Band: disconnected${if (live.scanning) "; searching" else ""}", @@ -222,9 +223,9 @@ fun GroundTruthCollectorScreen(vm: AppViewModel) { NoopButton( text = stringResource(R.string.ground_truth_start), fullWidth = true, - enabled = noopSteps != null && vm.activeStrapId.isNotBlank(), + enabled = vm.activeStrapId.isNotBlank(), onClick = { - state = collector.start(requireNotNull(noopSteps), vm.activeStrapId) + state = collector.start(noopSteps, vm.activeStrapId) sessions = collector.sessions() }, ) diff --git a/android/app/src/main/java/com/noop/ui/SettingsScreen.kt b/android/app/src/main/java/com/noop/ui/SettingsScreen.kt index 9c26d63161..afefbdd7fa 100644 --- a/android/app/src/main/java/com/noop/ui/SettingsScreen.kt +++ b/android/app/src/main/java/com/noop/ui/SettingsScreen.kt @@ -528,8 +528,6 @@ fun SettingsScreen( // callee's error handling - a throw would strand the button disabled behind a spinner that never // stops, with no way back short of leaving the screen. var strapLogBusy by remember { mutableStateOf(false) } - var whoop5CaptureBusy by remember { mutableStateOf(false) } - var rawAndLogBusy by remember { mutableStateOf(false) } // Re-scan must request the runtime Bluetooth permission before scanning — without this the // button calls connect() directly and silently no-ops on Android 12+ when the permission was @@ -572,7 +570,6 @@ fun SettingsScreen( // SharedPreferences isn't reactive, so the Switch drives a local mutableState that the store reads. val puffinExperiment = remember { PuffinExperiment.from(context) } var puffinExperiments by remember(rev) { mutableStateOf(puffinExperiment.isEnabled) } - var puffinCapture by remember(rev) { mutableStateOf(puffinExperiment.isCaptureEnabled) } var deepData by remember(rev) { mutableStateOf(puffinExperiment.isDeepDataEnabled) } // #174: set when the deep-data switch is turned OFF, so the app can OFFER to clear the flags on the @@ -2234,7 +2231,7 @@ fun SettingsScreen( SettingsCard( icon = Icons.Filled.Science, title = uiString(R.string.l10n_settings_screen_experimental_whoop_5_mg_41ef7041), - blurb = "Live heart rate already works on a WHOOP 5/MG strap. These probes go further and try to coax more out of it. They are guesses, off by default, and only ever touch a 5/MG strap. WHOOP 4.0 is never affected.", + blurb = "Normal WHOOP 5/MG recording and history sync are supported. These remaining controls are developer experiments for unmapped protocol features; they are not required for everyday use.", ) { Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { Row( @@ -2547,86 +2544,11 @@ fun SettingsScreen( } } - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(16.dp), - ) { - Text( - uiString(R.string.l10n_settings_screen_record_5_mg_raw_capture_research_1d966bbf), - style = NoopType.subhead, - color = Palette.textPrimary, - modifier = Modifier.weight(1f), - ) - Switch( - checked = puffinCapture, - onCheckedChange = { - puffinCapture = it - puffinExperiment.isCaptureEnabled = it - }, - colors = SwitchDefaults.colors( - checkedThumbColor = Palette.surfaceBase, - checkedTrackColor = Palette.accent, - uncheckedThumbColor = Palette.textSecondary, - uncheckedTrackColor = Palette.surfaceInset, - uncheckedBorderColor = Palette.hairline, - ), - modifier = Modifier.semantics { - contentDescription = uiString(R.string.l10n_settings_screen_record_5_mg_raw_capture_9354fe89) - }, - ) - } Text( - uiString(R.string.l10n_settings_screen_records_the_raw_frames_of_each_98a284df), + "Raw capture and its protocol-trace exports now live in Test Centre → 5/MG protocol diagnostics.", style = NoopType.caption, color = Palette.textTertiary, ) - NoopButton( - text = uiString(R.string.l10n_settings_screen_share_5_mg_capture_for_the_e41ac6bd), - leadingIcon = Icons.Filled.Upload, - kind = NoopButtonKind.Secondary, - fullWidth = true, - enabled = !whoop5CaptureBusy, - onClick = { - whoop5CaptureBusy = true - scope.launch { - // try/finally: the flag must clear on any exit, not just the happy path (#961 follow-up). - try { - LogExport.shareWhoop5Capture(context, live.whoop5Detected, live.encryptedBond) - } finally { - whoop5CaptureBusy = false - } - } - }, - ) - if (whoop5CaptureBusy) { - NoopBusyRow() - } - - // One-tap "matched pair" export (#510): hands a reporter BOTH the raw capture file and - // the strap log together (timestamped, same minute) so a protocol-mapping issue arrives - // with the frames AND the context that produced them. - NoopButton( - text = uiString(R.string.l10n_settings_screen_export_raw_log_matched_pair_d65390bf), - leadingIcon = Icons.Filled.IosShare, - kind = NoopButtonKind.Secondary, - fullWidth = true, - enabled = !rawAndLogBusy, - onClick = { - rawAndLogBusy = true - scope.launch { - // try/finally: the flag must clear on any exit, not just the happy path (#961 follow-up). - try { - LogExport.shareRawAndLog(context, vm.ble.exportLogText(), live.whoop5Detected, live.encryptedBond) - } finally { - rawAndLogBusy = false - } - } - }, - ) - if (rawAndLogBusy) { - NoopBusyRow() - } } } } // end if (showFiveMGControls) diff --git a/android/app/src/main/java/com/noop/ui/TestCentreScreen.kt b/android/app/src/main/java/com/noop/ui/TestCentreScreen.kt index 3290a5fe96..eb20620294 100644 --- a/android/app/src/main/java/com/noop/ui/TestCentreScreen.kt +++ b/android/app/src/main/java/com/noop/ui/TestCentreScreen.kt @@ -98,6 +98,11 @@ fun TestCentreScreen(vm: AppViewModel, onOpenGroundTruthCollector: () -> Unit = // Match the Settings `showFiveMGControls` gate exactly: pref OR a live-detected 5/MG this session, so a // 5/MG connected before its pref is written still sees the experimental block. (SettingsScreen.kt:346.) val is5MG = selectedModelName == WhoopModel.WHOOP5_MG.name || live.whoop5Detected + val puffinExperiment = remember { PuffinExperiment.from(context) } + var protocolProbes by remember { mutableStateOf(puffinExperiment.isEnabled) } + var passiveRawCapture by remember { mutableStateOf(puffinExperiment.isCaptureEnabled) } + var rawCaptureBusy by remember { mutableStateOf(false) } + var rawAndLogBusy by remember { mutableStateOf(false) } // A report awaiting the mandatory review-before-share gate (spec section 12). Non-null shows the // review dialog; confirming runs TestReportFlow.run. @@ -196,6 +201,71 @@ fun TestCentreScreen(vm: AppViewModel, onOpenGroundTruthCollector: () -> Unit = ) } + if (is5MG) { + SettingsSectionTC( + icon = Icons.Filled.Science, + title = "5/MG protocol diagnostics", + blurb = "Developer tools for protocol research. These are separate from the bounded Raw Data Collector above.", + ) { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + DeveloperToggleRow( + title = "Protocol probes", + detail = "Sends experimental protocol queries. It is not needed for normal WHOOP 5/MG sync, sleep, recovery, or steps.", + checked = protocolProbes, + onCheckedChange = { + protocolProbes = it + puffinExperiment.isEnabled = it + }, + ) + DeveloperToggleRow( + title = "Passive history/protocol trace", + detail = "Records frames that already arrive during history sync. It does not start IMU or any other sensor and may create large files.", + checked = passiveRawCapture, + onCheckedChange = { + passiveRawCapture = it + puffinExperiment.isCaptureEnabled = it + }, + ) + NoopButton( + text = "Share passive raw trace", + leadingIcon = Icons.Filled.Upload, + kind = NoopButtonKind.Secondary, + fullWidth = true, + enabled = !rawCaptureBusy, + onClick = { + rawCaptureBusy = true + scope.launch { + try { + LogExport.shareWhoop5Capture(context, live.whoop5Detected, live.encryptedBond) + } finally { + rawCaptureBusy = false + } + } + }, + ) + NoopButton( + text = "Export passive trace + strap log", + leadingIcon = Icons.Filled.Upload, + kind = NoopButtonKind.Secondary, + fullWidth = true, + enabled = !rawAndLogBusy, + onClick = { + rawAndLogBusy = true + scope.launch { + try { + LogExport.shareRawAndLog( + context, vm.ble.exportLogText(), live.whoop5Detected, live.encryptedBond, + ) + } finally { + rawAndLogBusy = false + } + } + }, + ) + } + } + } + // --- Section 3: Export and auto-export --- ExportCard( vm = vm, @@ -244,6 +314,26 @@ fun TestCentreScreen(vm: AppViewModel, onOpenGroundTruthCollector: () -> Unit = } } +@Composable +private fun DeveloperToggleRow( + title: String, + detail: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text(title, style = NoopType.subhead, color = Palette.textPrimary) + Text(detail, style = NoopType.caption, color = Palette.textSecondary) + } + Switch(checked = checked, onCheckedChange = onCheckedChange) + } +} + /** A report staged for the mandatory review gate: the profile, its title, the already-redacted entries * and the gate built over them. The Kotlin gate keeps its entries private, so we hold them here too to * hand TestReportFlow.run the same list it reviews. [modeInactive] (#1002): the selected profile's test diff --git a/android/app/src/main/res/values-de/strings.xml b/android/app/src/main/res/values-de/strings.xml index bab45f8753..5a39dd4b46 100644 --- a/android/app/src/main/res/values-de/strings.xml +++ b/android/app/src/main/res/values-de/strings.xml @@ -2466,4 +2466,102 @@ Android um Kopplung bitten (experimentell) NOOP hat bisher darauf gesetzt, dass ein Schreibvorgang an dein 5.0/MG Android zur Kopplung veranlasst. Das passiert nie. Diese Option fragt Android direkt und kann einen System-Kopplungsdialog anzeigen. Die Live-Herzfrequenz funktioniert in beiden Fällen weiter; schalte sie aus, wenn der Dialog stört. Android um Kopplung bitten + 5/MG-Rohdaten-Collector + Zeichnet eine begrenzte Bewegungssession mit 100 Hz auf, ergänzt Bluetooth-Lücken aus dem Strap-Verlauf und exportiert die vollständige Zeitachse. + Starte und exportiere eine begrenzte 5/MG-Bewegungsaufnahme. Manuelle Schritt- und Stufenmarkierungen sind optional und ändern niemals deine Gesundheitsdaten. + Rohdaten-Collector öffnen + NOOP-Schritte + Manuelle Schritte + Stufen + NOOP − manuell: %1$s + Optionale manuelle Markierungen + Für die Schrittalgorithmus-Forschung markiert Verkleinern einen Schritt und Vergrößern eine Stufe plus einen Schritt. Die Rohdatenaufnahme funktioniert ohne Klicker. + Drücke eine Hardwaretaste, um ihren Tastencode zu sehen. + %1$s · Taste %2$d · Scan %3$d\n%4$s · Gerät %5$d · Quelle 0x%6$x + Bei markiertem Klick vibrieren + Optionale Bestätigung; standardmäßig aus, um bei langen Sessions Akku zu sparen. + Rohdaten-Session starten + Letzten Klick rückgängig + Session beenden + Session exportieren + Export wird erstellt… + Session konnte nicht exportiert werden: %1$s + Minuten + Letzten Zeitraum ignorieren + Aufgezeichnete Sessions + Noch keine Sessions aufgezeichnet. + %1$d Schritte · %2$d Stufen · %3$d ausgeschlossene Zeiträume + Kommentar zur Session + Ältere Session: Ereignisse können exportiert werden, der zugehörige Strap für Sensordaten ist aber nicht mehr eindeutig. + Self-hosted Push + Self-hosted Push + Experimentell · Android · nur WLAN + Dein Endpunkt + Wenn aktiviert, sendet NOOP rohe Gesundheitsdaten von diesem Telefon an den von dir kontrollierten Endpunkt. Dabei verlassen Daten ausdrücklich das Gerät. + Nur Einweg-Export. NOOP kann Daten von diesem Endpunkt nicht wiederherstellen und stellt keinen Empfänger bereit. + Endpunkt-URL + Bearer-Token (ausgeblendet) + Bearer-Token (gespeichert; zur Ersetzung eingeben) + Gespeichertes Token löschen + Gespeichertes Token gelöscht. Push wurde deaktiviert. + Ziel speichern + Ziel gespeichert. + Verbindung testen + Verbindung wird getestet… + Prüft Anmeldung und unterstützte Datenarten. Es werden keine Gesundheitsdaten gesendet. + Verbinde dich mit einem WLAN, bevor du den Server testest. + Der Server ist derzeit nicht erreichbar. Teste die Verbindung später erneut. + Der Server hat ungültige oder nicht unterstützte Capabilities geliefert. + DNS-Auflösung fehlgeschlagen. Prüfe Hostnamen und DNS-Konfiguration des Endpoints. + TLS-Zertifikatsprüfung fehlgeschlagen. Prüfe Hostname, Zertifikatskette und Ablaufdatum. + TLS-Handshake fehlgeschlagen. Prüfe Reverse Proxy, TLS-Versionen und Cipher-Konfiguration. + Netzwerk-Timeout vor Abschluss der Anfrage. Prüfe Erreichbarkeit und Proxy-Timeouts. + Verbindung abgelehnt. Prüfe, ob Receiver und Proxy beziehungsweise Backend-Port laufen und lauschen. + Keine Netzwerkroute verfügbar. Prüfe WLAN, Routing und Firewall-Regeln. + Verbindung wurde zurückgesetzt. Prüfe Neustarts und Verbindungs-Timeouts von Receiver oder Proxy. + Netzwerk-E/A fehlgeschlagen, bevor eine dauerhafte Bestätigung empfangen wurde. + HTTP %1$d: Anmeldung abgelehnt. Prüfe Bearer-Token und Weiterleitung des Authorization-Headers im Proxy. + HTTP %1$d: Endpoint-Pfad nicht gefunden. Prüfe vollständige URL und Reverse-Proxy-Route. + HTTP %1$d: Receiver oder Proxy hat bei der Verarbeitung das Zeitlimit überschritten. + HTTP %1$d: Anfrage zu groß. Gleiche Body-Limits von Receiver und Reverse Proxy mit Protokoll 1.0 ab. + HTTP %1$d: Der Receiver hat sowohl gzip- als auch unkomprimierte NDJSON-Uploads abgelehnt. + HTTP %1$d: Der Receiver hat den Protokoll-Batch abgelehnt. Prüfe v1-Schema, Stream-Allowlist und Serverlogs. + HTTP %1$d: Rate-Limit des Receivers erreicht. Automatische Wiederholung nutzt Backoff. + HTTP %1$d: Fehler im Receiver oder Reverse Proxy. Prüfe Serverlogs und Health-Status. + HTTP %1$d: Unerwarteter Clientfehler. Prüfe Endpoint- und Reverse-Proxy-Konfiguration. + Die Capability-Antwort ist fehlerhaft oder entspricht nicht Protokoll 1.0. + Die Bestätigung des Receivers ist fehlerhaft oder passt nicht zum gesendeten Batch. Der Cursor wurde nicht vorgerückt. + Ein lokaler Datensatz überschreitet Protokollgrenzen oder lässt sich nicht exakt kodieren. Kein Cursor wurde vorgerückt. + Lokale Datenbank oder Push-Fortschritt konnten nicht gelesen oder gespeichert werden. + Warte auf WLAN. + %1$s Eine automatische Wiederholung ist eingeplant. + %1$s Automatische Wiederholungen sind pausiert. Behebe die Ursache und nutze dann „Jetzt exportieren“ oder synchronisiere den Strap erneut. + Der gespeicherte Bearer-Token ist nicht verfügbar. Speichere ihn erneut. + Der Export-Worker konnte nicht sicher gestartet werden. + Der nächste begrenzte Exportschritt konnte nicht eingereiht werden. + Server unterstützt %1$d von %2$d Datenarten + Zuletzt geprüft: %1$s + Dieser Server akzeptiert derzeit keine Gesundheitsdaten aus Protokoll 1.0. + Akzeptierte Daten: %1$s + Automatischen Export aktivieren + Exportiert neue und geänderte Daten nach vollständiger Strap-Synchronisierung und holt Rückstände beim App-Start nach. Nur über WLAN. + Jetzt exportieren + Startet jetzt einen Abgleich. Der automatische Export muss aktiviert sein. + Speichere vor dem Aktivieren einen gültigen Endpunkt und ein Token. + Status + Zugangsdaten erscheinen nie im Status oder in Worker-Metadaten. + Nie + Letzter vollständiger Abgleich: %1$s + Letzter Fehler: %1$s + Aktueller Status: %1$s + Aktueller Export: %1$d Batches · %2$d Datensätze akzeptiert + Aktuelle Datenart: %1$s + Inaktiv + Eingeplant + Wird gesendet + Weitere lokale Daten gefunden; wird fortgesetzt + Wiederholung nach einem Fehler + Aktuell + Nach einem Fehler pausiert + Experimenteller Einweg-Export an einen von dir kontrollierten Endpunkt. Standardmäßig aus. diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 883a7369c4..6635a8bbfc 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -1600,7 +1600,7 @@ Whole night is NOOP\'s default measure; Deep sleep pools HRV over slow-wave sleep only, reading lower and matching WHOOP. Switching re-scores your recent nights over the new window and takes effect right away once you have a few nights of data. WHOOP 4.0 versus 5.0: what each can read and why WHOOP 4.0 vs 5.0/MG - WHOOP 5/MG straps hand a fresh app only live heart rate. The official app switches on the deeper streams (high-rate HR + motion + history) by writing a set of feature flags, a sequence two independent projects have documented. With this on, the button below sends that exact sequence to your strap. Unlike everything else here it does write to the strap, but it\'s reversible (it only changes which data the strap emits) and is the same thing the official app does. Experimental: it may do nothing on your firmware. + Legacy R22 feature-flag experiment. The strap accepts these writes, but NOOP has not observed them enabling a separate live stream. This is not required for normal WHOOP 5/MG support or for the Raw Data Collector. It writes persistent strap settings and may do nothing on your firmware. WHOOP Working… You\'re on the latest (%1$s). @@ -2478,21 +2478,21 @@ Your strap reports a blood oxygen estimate, but it is unverified and off by default. Turn on the strap estimate in Settings to see it, or import a WHOOP export in Data Sources for the calibrated value. No blood oxygen percentage from a WHOOP 4.0 Your strap banks the raw optical channels, but NOOP does not turn them into a blood oxygen percentage, so this stays empty however long you wear it. Import a WHOOP export in Data Sources to fill it from your account history. - Ground-truth collector - Count steps and stairs with a hardware button while NOOP records the matching sensor timeline. - A foreground hardware-clicker session for step-algorithm research. It never changes your health data. - Open collector + 5/MG Raw Data Collector + Record a bounded 100 Hz motion session, recover Bluetooth gaps from strap history, and export the complete timeline. + Start and export a bounded 5/MG motion capture. Manual step and stair labels are optional and never change your health data. + Open raw-data collector NOOP steps Manual steps Stairs NOOP − manual: %1$s - Hardware mapping - Zoom Out adds one step. Zoom In adds one stair and one step. Every other key is shown and logged without changing a count. + Optional manual labels + For step-algorithm research, Zoom Out labels one step and Zoom In labels one stair plus one step. Raw capture works without a clicker. Press a hardware button to inspect its key code. %1$s · key %2$d · scan %3$d\n%4$s · device %5$d · source 0x%6$x Vibrate on a counted press Optional confirmation; disabled by default to save battery during long sessions. - Start new session + Start raw-data session Undo last click Stop session Export session From 40f7f6387cd3bb037d25abcd24ec09809e0121aa Mon Sep 17 00:00:00 2001 From: Bernd Helm Date: Wed, 26 Aug 2026 18:58:11 +0200 Subject: [PATCH 12/45] Move 5/MG developer controls into Test Centre --- Strand/Screens/AddDeviceWizard.swift | 4 +- Strand/Screens/SettingsView.swift | 13 +-- Strand/Screens/TestCentreView.swift | 82 +++++++++++++++++++ .../main/java/com/noop/ui/SettingsScreen.kt | 4 +- .../main/java/com/noop/ui/TestCentreScreen.kt | 82 +++++++++++++++++++ 5 files changed, 176 insertions(+), 9 deletions(-) diff --git a/Strand/Screens/AddDeviceWizard.swift b/Strand/Screens/AddDeviceWizard.swift index d885560651..6f70fc91dc 100644 --- a/Strand/Screens/AddDeviceWizard.swift +++ b/Strand/Screens/AddDeviceWizard.swift @@ -327,7 +327,7 @@ struct AddDeviceWizard: View { VStack(alignment: .leading, spacing: 10) { typeRow(.whoop5mg, icon: "applewatch.side.right", title: "WHOOP 5.0 / MG", - subtitle: String(localized: "Newer WHOOP band, experimental in NOOP")) + subtitle: String(localized: "Newer WHOOP band with live data and history sync")) typeRow(.whoop4, icon: "applewatch.side.right", title: "WHOOP 4.0", subtitle: String(localized: "NOOP's primary, fully-supported band")) @@ -1430,7 +1430,7 @@ struct AddDeviceWizard: View { Image(systemName: "flask") .foregroundStyle(StrandPalette.statusWarning) .accessibilityHidden(true) - Text("WHOOP 5.0 / MG support is newer and still experimental in NOOP.") + Text("WHOOP 5.0 / MG supports live data and strap history. Protocol-research tools are available separately in Test Centre.") .font(StrandFont.footnote) .foregroundStyle(StrandPalette.statusWarning) .fixedSize(horizontal: false, vertical: true) diff --git a/Strand/Screens/SettingsView.swift b/Strand/Screens/SettingsView.swift index 7c76d02b5a..14026173ba 100644 --- a/Strand/Screens/SettingsView.swift +++ b/Strand/Screens/SettingsView.swift @@ -1724,7 +1724,8 @@ struct SettingsView: View { @ViewBuilder private var experimentalCard: some View { liquidTodayCard liveSessionsCard - if showFiveMGControls { fiveMGCard } + // WHOOP 5/MG protocol research now lives in Test Centre. Everyday Settings no longer carries + // a second copy; the persisted keys and reversible disable actions remain unchanged there. if showFiveMGControls || model.repo.activeDeviceIsOura { spo2CandidateCard } sleepStagingCard rawSensorDiagnosticsCard @@ -1881,7 +1882,7 @@ struct SettingsView: View { SettingsSection( icon: "flask.fill", title: "Experimental · WHOOP 5 / MG", - blurb: "Live heart rate already works on a WHOOP 5/MG strap. These probes go further and try to coax more out of it. They are guesses, off by default, and only ever touch a 5/MG strap. WHOOP 4.0 is never affected." + blurb: "Normal WHOOP 5/MG recording and history sync are supported. These remaining controls are developer experiments for unmapped protocol features and now live in Test Centre." ) { VStack(alignment: .leading, spacing: NoopMetrics.rowSpacing) { Toggle(isOn: $puffinExperiments) { @@ -1910,7 +1911,7 @@ struct SettingsView: View { // strap kept every flag the enable sequence set while the UI implied it had been undone. // Now it offers the real undo. Turning it ON still writes nothing until the button is tapped. .onChangeCompat(of: deepDataEnabled) { on in if !on { confirmingDeepDataDisable = true } } - Text("WHOOP 5/MG straps hand a fresh app only live heart rate. The official app switches on the deeper streams (high-rate HR + motion + history) by writing a set of feature flags, a sequence two independent projects have documented. With this on, the button below sends that exact sequence to your strap. Unlike everything else here it does write to the strap — and it is reversible: \u{201C}Turn deep data back off\u{201D} writes the off value to the same flags and then reads every one of them back, so you see what the strap actually stores rather than just that it acked. Experimental: it may do nothing on your firmware. iPhone/Android only. A Mac can't write to a 5/MG.") + Text("Legacy R22 feature-flag experiment. The strap accepts these writes, but NOOP has not observed them enabling a separate live stream. This is not required for normal WHOOP 5/MG support or for the Raw Data Collector. It writes persistent strap settings and may do nothing on your firmware.") .font(StrandFont.caption) .foregroundStyle(StrandPalette.textTertiary) .fixedSize(horizontal: false, vertical: true) @@ -3358,7 +3359,7 @@ struct StepsCalibrationSheet: View { .font(StrandFont.headline) .foregroundStyle(StrandPalette.textPrimary) Text(is5MG - ? String(localized: "NOOP estimates your steps from your WHOOP's motion, calibrated to your phone's step count. It's an estimate, not a step counter — a WHOOP 5.0 / MG streams motion (not a step count) only with deep data on.") + ? String(localized: "NOOP estimates your steps from your WHOOP's stored motion, calibrated to your phone's step count. It's an estimate, not a hardware step counter; normal WHOOP 5/MG history sync supplies the motion data.") : String(localized: "NOOP estimates your steps from your WHOOP's motion, calibrated to your phone's step count. It's an estimate, not a step counter. A WHOOP 4.0 doesn't transmit steps.")) .font(StrandFont.subhead) .foregroundStyle(StrandPalette.textSecondary) @@ -3399,7 +3400,7 @@ struct StepsCalibrationSheet: View { /// The "why it's empty" line — a 5/MG needs the deep-data unlock before it streams motion at all. private var noMotionLead: String { if is5MG { - return String(localized: "We're not seeing any motion from your WHOOP 5.0 / MG yet. Unlike a 4.0, a 5/MG only streams motion (and history) once the experimental deep-data unlock is on — so until then there's nothing to estimate steps from. Importing history from WHOOP or Apple Health doesn't provide the strap motion this needs.") + return String(localized: "We're not seeing motion from your WHOOP 5.0 / MG yet. Keep NOOP connected and let strap history finish syncing; the experimental R22 flags are not required. Account or Apple Health imports do not contain the raw strap motion this estimate needs.") } return String(localized: "We're not seeing any motion from your strap yet. Steps are estimated from your WHOOP's banked motion history, so your strap needs to sync that history before NOOP has anything to count.") } @@ -3407,7 +3408,7 @@ struct StepsCalibrationSheet: View { /// The "what to do" line — 5/MG points at the deep-data toggle (unless it's already on, then just sync). private var noMotionAction: String { if is5MG && !deepDataEnabled { - return String(localized: "Turn on Settings → \u{201C}Unlock WHOOP 5/MG deep data (R22)\u{201D}, reconnect your strap, then open NOOP near it and let a day or two of motion sync. Your step estimate and the calibration below fill in once motion lands.") + return String(localized: "Open NOOP near the strap and let WHOOP 5/MG history finish syncing. The step estimate and calibration fill in once enough stored motion has arrived; the legacy R22 experiment is not required.") } if is5MG { return String(localized: "Deep data is on — open NOOP near your strap and let it sync its motion history (a full first-run sync can take a while). Once a day or two of motion lands, your step estimate and the calibration below fill in.") diff --git a/Strand/Screens/TestCentreView.swift b/Strand/Screens/TestCentreView.swift index 4b65a0668c..8a7ead9892 100644 --- a/Strand/Screens/TestCentreView.swift +++ b/Strand/Screens/TestCentreView.swift @@ -4,6 +4,7 @@ import StrandAnalytics import StrandImport import PolarProtocol import WhoopStore +import WhoopProtocol /// Settings -> Test Centre. The single home for every diagnostic, log and test control (spec section 7). /// @@ -37,6 +38,14 @@ struct TestCentreView: View { @State private var showClearExportsConfirm = false @State private var rawMotionCaptureActive = false + // WHOOP 5/MG developer controls. These use the same persisted keys as the former Settings card; + // moving the UI does not reset an opt-in or lose the ability to undo a persistent strap write. + @AppStorage(PuffinExperiment.defaultsKey) private var puffinExperiments = false + @AppStorage(PuffinFrameRecorder.enabledKey) private var puffinCapture = false + @AppStorage(PuffinExperiment.deepDataKey) private var deepDataEnabled = false + @AppStorage(PuffinExperiment.broadcastHrKey) private var broadcastHrEnabled = false + @AppStorage(PuffinExperiment.ecgRawDataKey) private var ecgRawDataEnabled = false + /// The strap model the user last picked, the same key SettingsView's showFiveMGControls gate reads. @AppStorage("selectedWhoopModel") private var selectedWhoopModelRaw = WhoopModel.whoop4.rawValue @@ -79,6 +88,7 @@ struct TestCentreView: View { domainModesCard.staggeredAppear(index: 0) diagnosticToolsCard.staggeredAppear(index: 1) if is5MG { rawDataCollectorCard.staggeredAppear(index: 2) } + if is5MG { fiveMGProtocolDiagnosticsCard.staggeredAppear(index: 3) } exportCard.staggeredAppear(index: 2) experimentalAlgorithmsCard.staggeredAppear(index: 3) } @@ -166,6 +176,78 @@ struct TestCentreView: View { } } + @ViewBuilder private var fiveMGProtocolDiagnosticsCard: some View { + NoopCard { + VStack(alignment: .leading, spacing: NoopMetrics.space3) { + Text("5/MG PROTOCOL DIAGNOSTICS") + .font(StrandFont.overline).tracking(StrandFont.overlineTracking) + .foregroundStyle(StrandPalette.textSecondary) + Text("Developer tools for unmapped protocol features. None of these are required for normal WHOOP 5/MG recording, history sync, or the bounded Raw Data Collector.") + .font(StrandFont.caption).foregroundStyle(StrandPalette.textTertiary) + .fixedSize(horizontal: false, vertical: true) + + Toggle("Protocol probes", isOn: $puffinExperiments) + .toggleStyle(.switch).tint(StrandPalette.accent) + Text("Sends experimental protocol queries and records the replies in the strap log.") + .font(StrandFont.caption).foregroundStyle(StrandPalette.textTertiary) + + Divider().overlay(StrandPalette.hairline) + Toggle("Broadcast heart rate from the strap", isOn: $broadcastHrEnabled) + .toggleStyle(.switch).tint(StrandPalette.accent) + .onChangeCompat(of: broadcastHrEnabled) { model.ble.setBroadcastHr($0) } + Text("Writes the reversible 5/MG advertising flag for Garmin, Zwift, and compatible gym equipment.") + .font(StrandFont.caption).foregroundStyle(StrandPalette.textTertiary) + + Divider().overlay(StrandPalette.hairline) + Toggle("Legacy R22 feature-flag experiment", isOn: $deepDataEnabled) + .toggleStyle(.switch).tint(StrandPalette.accent) + Text("The strap accepts these writes, but NOOP has not observed them enabling a separate live stream. This is not the Raw Data Collector.") + .font(StrandFont.caption).foregroundStyle(StrandPalette.textTertiary) + if deepDataEnabled { + NoopButton("Send legacy R22 enable sequence", systemImage: "bolt.badge.automatic", kind: .secondary) { + model.ble.enableWhoop5DeepData() + } + .disabled(!live.encryptedBond || !live.worn) + } + NoopButton("Clear legacy R22 flags on strap", systemImage: "bolt.slash", kind: .secondary) { + model.ble.disableWhoop5DeepData() + } + .disabled(!live.encryptedBond || live.r22DisableReport == BLEManager.deviceConfigProbeWaiting) + if let result = live.r22DisableReport { + Text(result).font(StrandFont.caption).foregroundStyle(StrandPalette.textSecondary) + .fixedSize(horizontal: false, vertical: true) + } + + Divider().overlay(StrandPalette.hairline) + Toggle("WHOOP MG ECG raw-data gate", isOn: $ecgRawDataEnabled) + .toggleStyle(.switch).tint(StrandPalette.accent) + Text("MG-only protocol instrumentation, not a medical ECG feature.") + .font(StrandFont.caption).foregroundStyle(StrandPalette.textTertiary) + if ecgRawDataEnabled { + HStack(spacing: NoopMetrics.space3) { + NoopButton("Gate on", systemImage: "waveform.path.ecg", kind: .secondary) { + model.ble.setEcgRawDataGate(true) + } + NoopButton("Gate off", systemImage: "arrow.uturn.backward", kind: .secondary) { + model.ble.setEcgRawDataGate(false) + } + } + .disabled(!live.encryptedBond || live.whoop5Variant != Whoop5Variant.mg.label) + if let result = live.ecgRawDataGate { + Text(result.summary).font(StrandFont.caption).foregroundStyle(StrandPalette.textSecondary) + } + } + + Divider().overlay(StrandPalette.hairline) + Toggle("Passive history/protocol trace", isOn: $puffinCapture) + .toggleStyle(.switch).tint(StrandPalette.accent) + Text("Records frames that already arrive. It does not start sensors and can create large files. Use the export section below to save the trace with its strap log.") + .font(StrandFont.caption).foregroundStyle(StrandPalette.textTertiary) + .fixedSize(horizontal: false, vertical: true) + } + } + } + @ViewBuilder private var diagnosticToolsCard: some View { NoopCard { VStack(alignment: .leading, spacing: NoopMetrics.space3) { diff --git a/android/app/src/main/java/com/noop/ui/SettingsScreen.kt b/android/app/src/main/java/com/noop/ui/SettingsScreen.kt index afefbdd7fa..8c7057c4c7 100644 --- a/android/app/src/main/java/com/noop/ui/SettingsScreen.kt +++ b/android/app/src/main/java/com/noop/ui/SettingsScreen.kt @@ -2227,7 +2227,9 @@ fun SettingsScreen( ) { Column(verticalArrangement = Arrangement.spacedBy(Metrics.screenRowSpacing)) { // --- Experimental · WHOOP 5 / MG --- (hidden when the user is confidently on a 4.0, #22) - if (showFiveMGControls) { + // Developer-only 5/MG controls now live in Test Centre. Keep the implementation below during + // the compatibility transition, but never render a second copy in everyday Settings. + if (false && showFiveMGControls) { SettingsCard( icon = Icons.Filled.Science, title = uiString(R.string.l10n_settings_screen_experimental_whoop_5_mg_41ef7041), diff --git a/android/app/src/main/java/com/noop/ui/TestCentreScreen.kt b/android/app/src/main/java/com/noop/ui/TestCentreScreen.kt index eb20620294..fefd6aa646 100644 --- a/android/app/src/main/java/com/noop/ui/TestCentreScreen.kt +++ b/android/app/src/main/java/com/noop/ui/TestCentreScreen.kt @@ -101,6 +101,13 @@ fun TestCentreScreen(vm: AppViewModel, onOpenGroundTruthCollector: () -> Unit = val puffinExperiment = remember { PuffinExperiment.from(context) } var protocolProbes by remember { mutableStateOf(puffinExperiment.isEnabled) } var passiveRawCapture by remember { mutableStateOf(puffinExperiment.isCaptureEnabled) } + var deepData by remember { mutableStateOf(puffinExperiment.isDeepDataEnabled) } + var broadcastHr by remember { mutableStateOf(puffinExperiment.broadcastHr) } + var explicitBond by remember { mutableStateOf(puffinExperiment.explicitBond) } + var ecgRawData by remember { mutableStateOf(puffinExperiment.ecgRawData) } + val r22DisableReport by vm.ble.r22DisableReport.collectAsStateWithLifecycle() + val ecgGateReport by vm.ble.ecgRawDataGate.collectAsStateWithLifecycle() + val ecgVariant by vm.ble.whoop5VariantFlow.collectAsStateWithLifecycle() var rawCaptureBusy by remember { mutableStateOf(false) } var rawAndLogBusy by remember { mutableStateOf(false) } @@ -217,6 +224,81 @@ fun TestCentreScreen(vm: AppViewModel, onOpenGroundTruthCollector: () -> Unit = puffinExperiment.isEnabled = it }, ) + DeveloperToggleRow( + title = "Broadcast heart rate from the strap", + detail = "Writes the reversible WHOOP 5/MG advertising flag for Garmin, Zwift, and gym equipment.", + checked = broadcastHr, + onCheckedChange = { + broadcastHr = it + puffinExperiment.broadcastHr = it + vm.ble.setBroadcastHr(it) + }, + ) + DeveloperToggleRow( + title = "Ask Android to pair", + detail = "Experimental explicit Android bonding. Normal 5/MG support does not require this switch.", + checked = explicitBond, + onCheckedChange = { + explicitBond = it + puffinExperiment.explicitBond = it + }, + ) + DeveloperToggleRow( + title = "Legacy R22 feature-flag experiment", + detail = "Accepted writes have not been shown to enable a separate live stream. Not required for normal sync or raw capture.", + checked = deepData, + onCheckedChange = { + deepData = it + puffinExperiment.isDeepDataEnabled = it + }, + ) + if (deepData) { + NoopButton( + text = "Send legacy R22 enable sequence", + kind = NoopButtonKind.Secondary, + fullWidth = true, + enabled = live.encryptedBond && live.worn, + onClick = { vm.ble.enableWhoop5DeepData() }, + ) + } + NoopButton( + text = "Clear legacy R22 flags on strap", + kind = NoopButtonKind.Secondary, + fullWidth = true, + enabled = live.encryptedBond && r22DisableReport != WhoopBleClient.WAITING_DEVICE_CONFIG_PROBE, + onClick = { vm.ble.disableWhoop5DeepData() }, + ) + r22DisableReport?.let { + Text(it, style = NoopType.caption, color = Palette.textSecondary) + } + DeveloperToggleRow( + title = "WHOOP MG ECG raw-data gate", + detail = "MG-only protocol research. This is instrumentation, not a medical ECG feature.", + checked = ecgRawData, + onCheckedChange = { + ecgRawData = it + puffinExperiment.ecgRawData = it + }, + ) + if (ecgRawData) { + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + NoopButton( + text = "Turn ECG gate on", + kind = NoopButtonKind.Secondary, + enabled = live.bonded && ecgVariant.isMG, + onClick = { vm.ble.setEcgRawDataGate(true) }, + ) + NoopButton( + text = "Turn ECG gate off", + kind = NoopButtonKind.Secondary, + enabled = live.bonded && ecgVariant.isMG, + onClick = { vm.ble.setEcgRawDataGate(false) }, + ) + } + ecgGateReport?.let { + Text(it.summary, style = NoopType.caption, color = Palette.textSecondary) + } + } DeveloperToggleRow( title = "Passive history/protocol trace", detail = "Records frames that already arrive during history sync. It does not start IMU or any other sensor and may create large files.", From 2504ad3a9e45c9b61f3631f3a08e8db81911fa0e Mon Sep 17 00:00:00 2001 From: Bernd Helm Date: Wed, 26 Aug 2026 19:13:33 +0200 Subject: [PATCH 13/45] Remove unrelated sync translations from collector branch --- .../app/src/main/res/values-de/strings.xml | 71 ------------------- 1 file changed, 71 deletions(-) diff --git a/android/app/src/main/res/values-de/strings.xml b/android/app/src/main/res/values-de/strings.xml index 5a39dd4b46..d15916427a 100644 --- a/android/app/src/main/res/values-de/strings.xml +++ b/android/app/src/main/res/values-de/strings.xml @@ -2493,75 +2493,4 @@ %1$d Schritte · %2$d Stufen · %3$d ausgeschlossene Zeiträume Kommentar zur Session Ältere Session: Ereignisse können exportiert werden, der zugehörige Strap für Sensordaten ist aber nicht mehr eindeutig. - Self-hosted Push - Self-hosted Push - Experimentell · Android · nur WLAN - Dein Endpunkt - Wenn aktiviert, sendet NOOP rohe Gesundheitsdaten von diesem Telefon an den von dir kontrollierten Endpunkt. Dabei verlassen Daten ausdrücklich das Gerät. - Nur Einweg-Export. NOOP kann Daten von diesem Endpunkt nicht wiederherstellen und stellt keinen Empfänger bereit. - Endpunkt-URL - Bearer-Token (ausgeblendet) - Bearer-Token (gespeichert; zur Ersetzung eingeben) - Gespeichertes Token löschen - Gespeichertes Token gelöscht. Push wurde deaktiviert. - Ziel speichern - Ziel gespeichert. - Verbindung testen - Verbindung wird getestet… - Prüft Anmeldung und unterstützte Datenarten. Es werden keine Gesundheitsdaten gesendet. - Verbinde dich mit einem WLAN, bevor du den Server testest. - Der Server ist derzeit nicht erreichbar. Teste die Verbindung später erneut. - Der Server hat ungültige oder nicht unterstützte Capabilities geliefert. - DNS-Auflösung fehlgeschlagen. Prüfe Hostnamen und DNS-Konfiguration des Endpoints. - TLS-Zertifikatsprüfung fehlgeschlagen. Prüfe Hostname, Zertifikatskette und Ablaufdatum. - TLS-Handshake fehlgeschlagen. Prüfe Reverse Proxy, TLS-Versionen und Cipher-Konfiguration. - Netzwerk-Timeout vor Abschluss der Anfrage. Prüfe Erreichbarkeit und Proxy-Timeouts. - Verbindung abgelehnt. Prüfe, ob Receiver und Proxy beziehungsweise Backend-Port laufen und lauschen. - Keine Netzwerkroute verfügbar. Prüfe WLAN, Routing und Firewall-Regeln. - Verbindung wurde zurückgesetzt. Prüfe Neustarts und Verbindungs-Timeouts von Receiver oder Proxy. - Netzwerk-E/A fehlgeschlagen, bevor eine dauerhafte Bestätigung empfangen wurde. - HTTP %1$d: Anmeldung abgelehnt. Prüfe Bearer-Token und Weiterleitung des Authorization-Headers im Proxy. - HTTP %1$d: Endpoint-Pfad nicht gefunden. Prüfe vollständige URL und Reverse-Proxy-Route. - HTTP %1$d: Receiver oder Proxy hat bei der Verarbeitung das Zeitlimit überschritten. - HTTP %1$d: Anfrage zu groß. Gleiche Body-Limits von Receiver und Reverse Proxy mit Protokoll 1.0 ab. - HTTP %1$d: Der Receiver hat sowohl gzip- als auch unkomprimierte NDJSON-Uploads abgelehnt. - HTTP %1$d: Der Receiver hat den Protokoll-Batch abgelehnt. Prüfe v1-Schema, Stream-Allowlist und Serverlogs. - HTTP %1$d: Rate-Limit des Receivers erreicht. Automatische Wiederholung nutzt Backoff. - HTTP %1$d: Fehler im Receiver oder Reverse Proxy. Prüfe Serverlogs und Health-Status. - HTTP %1$d: Unerwarteter Clientfehler. Prüfe Endpoint- und Reverse-Proxy-Konfiguration. - Die Capability-Antwort ist fehlerhaft oder entspricht nicht Protokoll 1.0. - Die Bestätigung des Receivers ist fehlerhaft oder passt nicht zum gesendeten Batch. Der Cursor wurde nicht vorgerückt. - Ein lokaler Datensatz überschreitet Protokollgrenzen oder lässt sich nicht exakt kodieren. Kein Cursor wurde vorgerückt. - Lokale Datenbank oder Push-Fortschritt konnten nicht gelesen oder gespeichert werden. - Warte auf WLAN. - %1$s Eine automatische Wiederholung ist eingeplant. - %1$s Automatische Wiederholungen sind pausiert. Behebe die Ursache und nutze dann „Jetzt exportieren“ oder synchronisiere den Strap erneut. - Der gespeicherte Bearer-Token ist nicht verfügbar. Speichere ihn erneut. - Der Export-Worker konnte nicht sicher gestartet werden. - Der nächste begrenzte Exportschritt konnte nicht eingereiht werden. - Server unterstützt %1$d von %2$d Datenarten - Zuletzt geprüft: %1$s - Dieser Server akzeptiert derzeit keine Gesundheitsdaten aus Protokoll 1.0. - Akzeptierte Daten: %1$s - Automatischen Export aktivieren - Exportiert neue und geänderte Daten nach vollständiger Strap-Synchronisierung und holt Rückstände beim App-Start nach. Nur über WLAN. - Jetzt exportieren - Startet jetzt einen Abgleich. Der automatische Export muss aktiviert sein. - Speichere vor dem Aktivieren einen gültigen Endpunkt und ein Token. - Status - Zugangsdaten erscheinen nie im Status oder in Worker-Metadaten. - Nie - Letzter vollständiger Abgleich: %1$s - Letzter Fehler: %1$s - Aktueller Status: %1$s - Aktueller Export: %1$d Batches · %2$d Datensätze akzeptiert - Aktuelle Datenart: %1$s - Inaktiv - Eingeplant - Wird gesendet - Weitere lokale Daten gefunden; wird fortgesetzt - Wiederholung nach einem Fehler - Aktuell - Nach einem Fehler pausiert - Experimenteller Einweg-Export an einen von dir kontrollierten Endpunkt. Standardmäßig aus. From 62de1ef15e9c0bf2ad2bc9b85641606007778cf0 Mon Sep 17 00:00:00 2001 From: Bernd Helm Date: Wed, 26 Aug 2026 20:10:50 +0200 Subject: [PATCH 14/45] Add raw capture session deletion controls --- .../noop/testcentre/GroundTruthCollector.kt | 51 ++++++++++++ .../com/noop/ui/GroundTruthCollectorScreen.kt | 83 ++++++++++++++++++- .../app/src/main/res/values-de/strings.xml | 8 ++ android/app/src/main/res/values/strings.xml | 8 ++ 4 files changed, 146 insertions(+), 4 deletions(-) diff --git a/android/app/src/main/java/com/noop/testcentre/GroundTruthCollector.kt b/android/app/src/main/java/com/noop/testcentre/GroundTruthCollector.kt index 388f97da04..884b30d8d6 100644 --- a/android/app/src/main/java/com/noop/testcentre/GroundTruthCollector.kt +++ b/android/app/src/main/java/com/noop/testcentre/GroundTruthCollector.kt @@ -150,6 +150,42 @@ class GroundTruthCollector private constructor(private val context: Context) { .sortedByDescending { it.startedAtMs } } + /** Delete one completed capture and all app-private files and metadata belonging to it. */ + @Synchronized + fun deleteSession(sessionId: String): Boolean { + val current = snapshot() + if (current.active && current.sessionId == sessionId) return false + + val files = listOf( + eventFile(sessionId), + File(directory, "realtime-imu-$sessionId.bin"), + File(context.cacheDir, "logs/noop-ground-truth-$sessionId.zip"), + ) + // Evaluate every deletion even if one fails, so a stale export ZIP never survives merely + // because an earlier file could not be removed. + val filesDeleted = files.map { file -> !file.exists() || file.delete() }.all { it } + if (eventFile(sessionId).exists()) return false + + prefs.edit() + .remove(sessionDeviceKey(sessionId)) + .remove(sessionStartKey(sessionId)) + .remove(sessionEndKey(sessionId)) + .remove(sessionCommentKey(sessionId)) + .remove(sessionExportedKey(sessionId)) + .apply { + if (current.sessionId == sessionId) clearCurrentSession() + } + .apply() + return filesDeleted + } + + /** Delete every completed capture. Active captures are deliberately retained. */ + @Synchronized + fun deleteAllSessions(): Int { + val completedIds = sessions().filterNot(SessionSummary::active).map(SessionSummary::id) + return completedIds.count(::deleteSession) + } + @Synchronized fun record(kind: String, key: KeyInfo, noopSteps: Int?, nowMs: Long = System.currentTimeMillis()): Snapshot { val before = snapshot() @@ -485,6 +521,21 @@ class GroundTruthCollector private constructor(private val context: Context) { if (value == null) remove("noopStepsAtStart") else putInt("noopStepsAtStart", value) } + private fun android.content.SharedPreferences.Editor.clearCurrentSession() = apply { + remove("active") + remove("exported") + remove("sessionId") + remove("deviceId") + remove("startedAtMs") + remove("endedAtMs") + remove("steps") + remove("stairs") + remove("lastKind") + remove("lastKey") + remove("noopStepsAtStart") + remove("lastNoopSteps") + } + private fun sessionDeviceKey(id: String) = "session.$id.deviceId" private fun sessionStartKey(id: String) = "session.$id.startedAtMs" private fun sessionEndKey(id: String) = "session.$id.endedAtMs" diff --git a/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt b/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt index eec206d5d7..fa113c7a10 100644 --- a/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt +++ b/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt @@ -14,6 +14,8 @@ import androidx.compose.foundation.layout.padding import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -60,6 +62,8 @@ fun GroundTruthCollectorScreen(vm: AppViewModel) { var excludeMinutes by remember { mutableStateOf("5") } var sessions by remember { mutableStateOf(collector.sessions()) } var latestSensorTs by remember { mutableStateOf(null) } + var sessionPendingDelete by remember { mutableStateOf(null) } + var confirmDeleteAll by remember { mutableStateOf(false) } LaunchedEffect(vm.activeStrapId) { while (true) { @@ -234,6 +238,13 @@ fun GroundTruthCollectorScreen(vm: AppViewModel) { if (sessions.isEmpty()) { Text(stringResource(R.string.ground_truth_no_sessions), style = NoopType.body, color = Palette.textSecondary) } else { + NoopButton( + text = stringResource(R.string.ground_truth_delete_all), + kind = NoopButtonKind.Destructive, + fullWidth = true, + enabled = sessions.none { it.active }, + onClick = { confirmDeleteAll = true }, + ) sessions.forEach { session -> SessionCard( session = session, @@ -259,10 +270,57 @@ fun GroundTruthCollectorScreen(vm: AppViewModel) { } } }, + onDelete = { sessionPendingDelete = session }, ) } } } + + sessionPendingDelete?.let { session -> + AlertDialog( + onDismissRequest = { sessionPendingDelete = null }, + title = { Text(stringResource(R.string.ground_truth_delete_session_title)) }, + text = { Text(stringResource(R.string.ground_truth_delete_session_message, sessionTimeRange(session))) }, + dismissButton = { + TextButton(onClick = { sessionPendingDelete = null }) { + Text(stringResource(R.string.ground_truth_cancel)) + } + }, + confirmButton = { + TextButton(onClick = { + collector.deleteSession(session.id) + sessions = collector.sessions() + state = collector.snapshot() + sessionPendingDelete = null + }) { + Text(stringResource(R.string.ground_truth_delete_confirm), color = Palette.statusCritical) + } + }, + ) + } + + if (confirmDeleteAll) { + AlertDialog( + onDismissRequest = { confirmDeleteAll = false }, + title = { Text(stringResource(R.string.ground_truth_delete_all_title)) }, + text = { Text(stringResource(R.string.ground_truth_delete_all_message, sessions.size)) }, + dismissButton = { + TextButton(onClick = { confirmDeleteAll = false }) { + Text(stringResource(R.string.ground_truth_cancel)) + } + }, + confirmButton = { + TextButton(onClick = { + collector.deleteAllSessions() + sessions = collector.sessions() + state = collector.snapshot() + confirmDeleteAll = false + }) { + Text(stringResource(R.string.ground_truth_delete_confirm), color = Palette.statusCritical) + } + }, + ) + } } @Composable @@ -273,11 +331,9 @@ private fun SessionCard( exporting: Boolean, onComment: (String) -> Unit, onExport: () -> Unit, + onDelete: () -> Unit, ) { - val time = remember(session.startedAtMs) { - java.text.DateFormat.getDateTimeInstance(java.text.DateFormat.SHORT, java.text.DateFormat.SHORT) - .format(java.util.Date(session.startedAtMs)) - } + val time = remember(session.startedAtMs, session.endedAtMs) { sessionTimeRange(session) } NoopCard { Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { val sensorCovered = session.endedAtMs?.let { end -> latestSensorTs?.times(1000)?.let { it >= end } } == true @@ -321,10 +377,29 @@ private fun SessionCard( enabled = !session.active && !exporting, onClick = onExport, ) + NoopButton( + text = stringResource(R.string.ground_truth_delete_session), + kind = NoopButtonKind.Destructive, + fullWidth = true, + enabled = !session.active && !exporting, + onClick = onDelete, + ) } } } +private fun sessionTimeRange(session: GroundTruthCollector.SessionSummary): String { + val date = java.text.DateFormat.getDateInstance(java.text.DateFormat.SHORT) + val time = java.text.DateFormat.getTimeInstance(java.text.DateFormat.SHORT) + val from = java.util.Date(session.startedAtMs) + val to = session.endedAtMs?.let { java.util.Date(it) } + return if (to == null) { + "${date.format(from)} · ${time.format(from)}–…" + } else { + "${date.format(from)} · ${time.format(from)}–${time.format(to)}" + } +} + @Composable private fun CounterColumn(label: String, value: String, modifier: Modifier = Modifier) { Column(modifier = modifier.padding(horizontal = 4.dp), horizontalAlignment = Alignment.CenterHorizontally) { diff --git a/android/app/src/main/res/values-de/strings.xml b/android/app/src/main/res/values-de/strings.xml index d15916427a..ac271cb7e5 100644 --- a/android/app/src/main/res/values-de/strings.xml +++ b/android/app/src/main/res/values-de/strings.xml @@ -2492,5 +2492,13 @@ Noch keine Sessions aufgezeichnet. %1$d Schritte · %2$d Stufen · %3$d ausgeschlossene Zeiträume Kommentar zur Session + Session löschen + Diese Session löschen? + Die Session %1$s und ihre aufgezeichneten Rohdaten werden dauerhaft gelöscht. + Alle Sessions löschen + Alle Sessions löschen? + Alle %1$d aufgezeichneten Sessions und ihre Rohdaten werden dauerhaft gelöscht. + Löschen + Abbrechen Ältere Session: Ereignisse können exportiert werden, der zugehörige Strap für Sensordaten ist aber nicht mehr eindeutig. diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 6635a8bbfc..6e78c0e0b1 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -2504,5 +2504,13 @@ No sessions recorded yet. %1$d steps · %2$d stairs · %3$d excluded periods Session comment + Delete session + Delete this session? + The session %1$s and its captured raw data will be deleted permanently. + Delete all sessions + Delete all sessions? + All %1$d recorded sessions and their captured raw data will be deleted permanently. + Delete + Cancel Legacy session: event export is available, but its strap cannot be identified for sensor export. From 14d5ca8c451f3f334b949925cb7748934b32cab4 Mon Sep 17 00:00:00 2001 From: Bernd Helm Date: Wed, 26 Aug 2026 20:30:18 +0200 Subject: [PATCH 15/45] Bring iOS raw capture sessions to Android parity --- .../Sources/WhoopStore/RawOutbox.swift | 27 ++ .../Tests/WhoopStoreTests/PruneTests.swift | 27 ++ Strand/BLE/BLEManager.swift | 39 +++ Strand/Collect/Collector.swift | 18 ++ Strand/Collect/RawCaptureWindow.swift | 4 +- Strand/Collect/RawDataSessionStore.swift | 198 ++++++++++++++ Strand/Screens/RawDataCollectorView.swift | 245 ++++++++++++++++++ Strand/Screens/TestCentreView.swift | 25 +- StrandTests/RawDataSessionStoreTests.swift | 50 ++++ 9 files changed, 614 insertions(+), 19 deletions(-) create mode 100644 Strand/Collect/RawDataSessionStore.swift create mode 100644 Strand/Screens/RawDataCollectorView.swift create mode 100644 StrandTests/RawDataSessionStoreTests.swift diff --git a/Packages/WhoopStore/Sources/WhoopStore/RawOutbox.swift b/Packages/WhoopStore/Sources/WhoopStore/RawOutbox.swift index a08a53a9aa..4834619926 100644 --- a/Packages/WhoopStore/Sources/WhoopStore/RawOutbox.swift +++ b/Packages/WhoopStore/Sources/WhoopStore/RawOutbox.swift @@ -164,6 +164,33 @@ extension WhoopStore { } } + /// Raw batches overlapping a user-selected capture interval, oldest first. + public func rawBatches(deviceId: String, from: Int, to: Int) async throws -> [RawBatchMeta] { + guard from <= to else { return [] } + return try syncRead { db in + try Row.fetchAll(db, sql: """ + SELECT batchId, deviceId, capturedAt, deviceClockRef, wallClockRef, + startTs, endTs, frameCount, byteSize + FROM rawBatch + WHERE deviceId = ? AND capturedAt >= ? AND capturedAt <= ? + ORDER BY capturedAt ASC, rowid ASC + """, arguments: [deviceId, from, to]).map(WhoopStore.metaFromRow) + } + } + + /// Remove raw batches belonging to a deleted user capture interval. + @discardableResult + public func deleteRawBatches(deviceId: String, from: Int, to: Int) async throws -> Int { + guard from <= to else { return 0 } + return try syncWrite { db in + try db.execute(sql: """ + DELETE FROM rawBatch + WHERE deviceId = ? AND capturedAt >= ? AND capturedAt <= ? + """, arguments: [deviceId, from, to]) + return db.changesCount + } + } + /// Mark a batch synced (timestamp in unix seconds). /// /// #981 — NO production caller, and by design: see the dormancy note on `pruneRaw`'s Policy 1. Kept diff --git a/Packages/WhoopStore/Tests/WhoopStoreTests/PruneTests.swift b/Packages/WhoopStore/Tests/WhoopStoreTests/PruneTests.swift index 1ba9dcf511..d054375f67 100644 --- a/Packages/WhoopStore/Tests/WhoopStoreTests/PruneTests.swift +++ b/Packages/WhoopStore/Tests/WhoopStoreTests/PruneTests.swift @@ -77,4 +77,31 @@ final class PruneTests: XCTestCase { maxUnsyncedBytes: 1_000_000) XCTAssertEqual(pruned, 0) } + + func testCaptureIntervalListsOnlyMatchingDeviceAndWallTime() async throws { + let store = try await WhoopStore.inMemory() + try await store.upsertDevice(id: "dev1", mac: nil, name: nil) + try await store.upsertDevice(id: "dev2", mac: nil, name: nil) + try await store.enqueueRawBatch(meta("before", capturedAt: 99, bytes: 10), frames: frames) + try await store.enqueueRawBatch(meta("inside", capturedAt: 100, bytes: 10), frames: frames) + let other = RawBatchMeta(batchId: "other", deviceId: "dev2", clockRef: .init(device: 0, wall: 0), + capturedAt: 100, startTs: 0, endTs: 0, + frameCount: frames.count, byteSize: 10) + try await store.enqueueRawBatch(other, frames: frames) + + let rows = try await store.rawBatches(deviceId: "dev1", from: 100, to: 200) + XCTAssertEqual(rows.map(\.batchId), ["inside"]) + } + + func testDeletingCaptureIntervalDoesNotTouchOtherRaw() async throws { + let store = try await WhoopStore.inMemory() + try await store.upsertDevice(id: "dev1", mac: nil, name: nil) + try await store.enqueueRawBatch(meta("before", capturedAt: 99, bytes: 10), frames: frames) + try await store.enqueueRawBatch(meta("first", capturedAt: 100, bytes: 10), frames: frames) + try await store.enqueueRawBatch(meta("last", capturedAt: 200, bytes: 10), frames: frames) + try await store.enqueueRawBatch(meta("after", capturedAt: 201, bytes: 10), frames: frames) + + XCTAssertEqual(try await store.deleteRawBatches(deviceId: "dev1", from: 100, to: 200), 2) + XCTAssertEqual(try await store.allBatchIdsForTest(), ["before", "after"]) + } } diff --git a/Strand/BLE/BLEManager.swift b/Strand/BLE/BLEManager.swift index fb841ddb7c..f180775095 100644 --- a/Strand/BLE/BLEManager.swift +++ b/Strand/BLE/BLEManager.swift @@ -1784,6 +1784,42 @@ public final class BLEManager: NSObject, ObservableObject { } } + /// Start a manually stopped 5/MG raw-data session. Returns false when another capture is active. + @discardableResult + public func startGroundTruthRawCapture() -> Bool { + guard !rawCaptureInFlight else { return false } + rawCaptureInFlight = true + collector?.beginRawCapture(seconds: RawCaptureWindow.maxSeconds) + send(.startRawData, payload: [0x01], writeType: .withResponse) + send(.toggleIMUMode, + payload: selectedModel.deviceFamily == .whoop5 ? [0x01, 0x01] : [0x01], + writeType: .withResponse) + log("Raw-data session: started") + return true + } + + /// Stop and flush the current manually controlled raw-data session. + public func stopGroundTruthRawCapture() async { + if rawCaptureInFlight && !UserDefaults.standard.bool(forKey: "enableRawCapture") { + send(.stopRawData, payload: [0x01], writeType: .withResponse) + if selectedModel.deviceFamily == .whoop5 { + send(.toggleIMUMode, payload: [0x01, 0x00], writeType: .withResponse) + } + } + await collector?.endRawCapture() + rawCaptureInFlight = false + log("Raw-data session: stopped + flushed") + } + + public func groundTruthRawBatches(from: Int, to: Int) async -> [(RawBatchMeta, [[UInt8]])] { + await collector?.rawBatches(from: from, to: to) ?? [] + } + + @discardableResult + public func deleteGroundTruthRawBatches(from: Int, to: Int) async -> Int { + await collector?.deleteRawBatches(from: from, to: to) ?? 0 + } + /// Send a command to the WHOOP strap. /// - Parameters: /// - command: The command to send. @@ -5023,6 +5059,9 @@ extension BLEManager: @preconcurrency CBCentralManagerDelegate { didBond = false clientHelloWriteAt = nil // #1635: no hello survives the link that carried it whoop5RealtimeArmed = false + // A user capture remains represented by RawDataSessionStore, but its transport must be re-armed + // on the next connection. Keeping this true would make that reconnect attempt a silent no-op. + rawCaptureInFlight = false // The strap forgets the realtime-HR toggle across a disconnect; the post-bond branch re-arms it // from `wantsRealtime`. Clear only the "what we last sent" flag — `screenWantsRealtime` / // `keepRealtimeForData` (and thus `wantsRealtime`) are intent and must survive a reconnect so the diff --git a/Strand/Collect/Collector.swift b/Strand/Collect/Collector.swift index 87279bfa0a..32c9eef273 100644 --- a/Strand/Collect/Collector.swift +++ b/Strand/Collect/Collector.swift @@ -97,6 +97,24 @@ final class Collector { return try? await s.storageStats() } + func rawBatches(from: Int, to: Int) async -> [(RawBatchMeta, [[UInt8]])] { + guard let store = concreteStore, + let metas = try? await store.rawBatches(deviceId: deviceId, from: from, to: to) else { return [] } + var result: [(RawBatchMeta, [[UInt8]])] = [] + for meta in metas { + if let frames = try? await store.rawFrames(batchId: meta.batchId) { + result.append((meta, frames)) + } + } + return result + } + + @discardableResult + func deleteRawBatches(from: Int, to: Int) async -> Int { + guard let store = concreteStore else { return 0 } + return (try? await store.deleteRawBatches(deviceId: deviceId, from: from, to: to)) ?? 0 + } + /// Max persisted HR sample ts (the biometric "data frontier" for the stuck-strap watchdog). /// nil if there's no concrete store or nothing persisted yet. Mirrors storageStats(). func latestHRSampleTs() async -> Int? { diff --git a/Strand/Collect/RawCaptureWindow.swift b/Strand/Collect/RawCaptureWindow.swift index 336273ff4a..833868f325 100644 --- a/Strand/Collect/RawCaptureWindow.swift +++ b/Strand/Collect/RawCaptureWindow.swift @@ -5,7 +5,9 @@ import Foundation /// auto-expires the window so a missed stop callback can't leak raw forever. struct RawCaptureWindow { static let minSeconds: TimeInterval = 1 - static let maxSeconds: TimeInterval = 300 + // A manually stopped research session may legitimately last for hours. It is still bounded so an + // interrupted UI/process cannot accidentally turn the opt-in capture into permanent 24/7 logging. + static let maxSeconds: TimeInterval = 24 * 60 * 60 static func clamp(_ s: TimeInterval) -> TimeInterval { min(max(s, minSeconds), maxSeconds) } private var deadline: TimeInterval? // monotonic deadline; nil = inactive diff --git a/Strand/Collect/RawDataSessionStore.swift b/Strand/Collect/RawDataSessionStore.swift new file mode 100644 index 0000000000..77189fd46a --- /dev/null +++ b/Strand/Collect/RawDataSessionStore.swift @@ -0,0 +1,198 @@ +import Combine +import Foundation +import WhoopStore + +/// Persistent, user-controlled 5/MG capture sessions. This is the iOS/macOS twin of Android's +/// GroundTruthCollector: raw capture is useful on its own, while step/stair labels are optional. +@MainActor +final class RawDataSessionStore: ObservableObject { + enum LabelKind: String, Codable { + case start, stop, step, stair + case undoStep = "undo_step" + case undoStair = "undo_stair" + case excludeWindow = "exclude_window" + } + + struct Event: Codable, Identifiable, Equatable { + var id = UUID() + let atMs: Int64 + let kind: LabelKind + let stepsTotal: Int + let stairsTotal: Int + var fromMs: Int64? + var toMs: Int64? + } + + struct Session: Codable, Identifiable, Equatable { + let id: String + let deviceId: String + let startedAtMs: Int64 + var endedAtMs: Int64? + var steps: Int + var stairs: Int + var excludedWindows: Int + var comment: String + var exported: Bool + var events: [Event] + + var active: Bool { endedAtMs == nil } + + static func == (lhs: Session, rhs: Session) -> Bool { + lhs.id == rhs.id && lhs.endedAtMs == rhs.endedAtMs && lhs.steps == rhs.steps + && lhs.stairs == rhs.stairs && lhs.excludedWindows == rhs.excludedWindows + && lhs.comment == rhs.comment && lhs.exported == rhs.exported + } + } + + @Published private(set) var sessions: [Session] = [] + var active: Session? { sessions.first(where: \.active) } + + private let directory: URL + private let encoder: JSONEncoder + private let decoder = JSONDecoder() + + init(directory override: URL? = nil, fileManager: FileManager = .default) { + let base = (try? fileManager.url(for: .applicationSupportDirectory, in: .userDomainMask, + appropriateFor: nil, create: true)) ?? fileManager.temporaryDirectory + directory = override ?? base.appendingPathComponent("OpenWhoop/RawDataSessions", isDirectory: true) + try? fileManager.createDirectory(at: directory, withIntermediateDirectories: true) + encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + reload() + } + + func reload() { + let urls = (try? FileManager.default.contentsOfDirectory(at: directory, + includingPropertiesForKeys: nil)) ?? [] + sessions = urls.filter { $0.pathExtension == "json" } + .compactMap { try? Data(contentsOf: $0) } + .compactMap { try? decoder.decode(Session.self, from: $0) } + .sorted { $0.startedAtMs > $1.startedAtMs } + } + + @discardableResult + func start(deviceId: String, now: Date = Date()) -> Session? { + guard active == nil else { return nil } + let millis = Int64(now.timeIntervalSince1970 * 1_000) + let started = Event(atMs: millis, kind: .start, stepsTotal: 0, stairsTotal: 0) + let session = Session(id: String(millis), deviceId: deviceId, startedAtMs: millis, + endedAtMs: nil, steps: 0, stairs: 0, excludedWindows: 0, + comment: "", exported: false, events: [started]) + sessions.insert(session, at: 0) + persist(session) + return session + } + + func stop(now: Date = Date()) { + mutateActive { session in + let millis = Int64(now.timeIntervalSince1970 * 1_000) + session.endedAtMs = millis + session.events.append(Event(atMs: millis, kind: .stop, + stepsTotal: session.steps, stairsTotal: session.stairs)) + } + } + + func record(_ kind: LabelKind, now: Date = Date()) { + guard kind == .step || kind == .stair else { return } + mutateActive { session in + session.steps += 1 + if kind == .stair { session.stairs += 1 } + session.events.append(Event(atMs: Int64(now.timeIntervalSince1970 * 1_000), kind: kind, + stepsTotal: session.steps, stairsTotal: session.stairs)) + } + } + + func undo(now: Date = Date()) { + mutateActive { session in + guard let prior = session.events.last, + prior.kind == .step || prior.kind == .stair else { return } + let undo: LabelKind = prior.kind == .stair ? .undoStair : .undoStep + session.steps = max(0, session.steps - 1) + if prior.kind == .stair { session.stairs = max(0, session.stairs - 1) } + session.events.append(Event(atMs: Int64(now.timeIntervalSince1970 * 1_000), kind: undo, + stepsTotal: session.steps, stairsTotal: session.stairs)) + } + } + + func excludeLast(minutes: Int, now: Date = Date()) { + guard (1...240).contains(minutes) else { return } + mutateActive { session in + let to = Int64(now.timeIntervalSince1970 * 1_000) + let from = max(session.startedAtMs, to - Int64(minutes) * 60_000) + session.excludedWindows += 1 + session.events.append(Event(atMs: to, kind: .excludeWindow, stepsTotal: session.steps, + stairsTotal: session.stairs, fromMs: from, toMs: to)) + } + } + + func setComment(_ comment: String, sessionId: String) { + mutate(sessionId) { $0.comment = String(comment.prefix(4_000)) } + } + + func markExported(_ sessionId: String) { mutate(sessionId) { $0.exported = true } } + + func removeMetadata(_ sessionId: String) { + guard sessions.first(where: { $0.id == sessionId })?.active != true else { return } + try? FileManager.default.removeItem(at: file(sessionId)) + sessions.removeAll { $0.id == sessionId } + } + + private func mutateActive(_ body: (inout Session) -> Void) { + guard let index = sessions.firstIndex(where: \.active) else { return } + body(&sessions[index]) + persist(sessions[index]) + } + + private func mutate(_ id: String, _ body: (inout Session) -> Void) { + guard let index = sessions.firstIndex(where: { $0.id == id }) else { return } + body(&sessions[index]) + persist(sessions[index]) + } + + private func persist(_ session: Session) { + guard let data = try? encoder.encode(session) else { return } + try? data.write(to: file(session.id), options: .atomic) + } + + private func file(_ id: String) -> URL { directory.appendingPathComponent("session-\(id).json") } + + func exportEntries(for session: Session, + raw: [(RawBatchMeta, [[UInt8]])]) -> [FileExport.BundleEntry] { + let meta: [String: Any] = [ + "schema_version": 3, "capture_kind": "whoop_5mg_raw_data", + "session_id": session.id, "device_id": session.deviceId, + "started_at_ms": session.startedAtMs, "ended_at_ms": session.endedAtMs ?? session.startedAtMs, + "manual_steps": session.steps, "manual_stairs": session.stairs, + "comment": session.comment, "excluded_windows": session.excludedWindows, + "sensor_export_available": !raw.isEmpty, + "device_family": "iOS", + "app_version": Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "?", + "manual_labels": "optional; on-screen step and stair controls", + ] + let metaData = (try? JSONSerialization.data(withJSONObject: meta, options: [.prettyPrinted, .sortedKeys])) ?? Data() + let eventData = session.events.compactMap { event -> Data? in + var row: [String: Any] = ["at_ms": event.atMs, "kind": event.kind.rawValue, + "steps_total": event.stepsTotal, "stairs_total": event.stairsTotal] + if let from = event.fromMs { row["from_ms"] = from } + if let to = event.toMs { row["to_ms"] = to } + return try? JSONSerialization.data(withJSONObject: row, options: [.sortedKeys]) + }.reduce(into: Data()) { $0.append($1); $0.append(0x0A) } + var rawData = Data() + for (meta, frames) in raw { + for (index, frame) in frames.enumerated() { + let row: [String: Any] = ["batch_id": meta.batchId, "captured_at": meta.capturedAt, + "start_ts": meta.startTs, "end_ts": meta.endTs, + "frame_index": index, + "hex": frame.map { String(format: "%02x", $0) }.joined()] + if let data = try? JSONSerialization.data(withJSONObject: row, options: [.sortedKeys]) { + rawData.append(data); rawData.append(0x0A) + } + } + } + return [ + .init(name: "meta.json", data: metaData), + .init(name: "events.jsonl", data: eventData), + .init(name: "raw-frames.jsonl", data: rawData), + ] + } +} diff --git a/Strand/Screens/RawDataCollectorView.swift b/Strand/Screens/RawDataCollectorView.swift new file mode 100644 index 0000000000..7613b590aa --- /dev/null +++ b/Strand/Screens/RawDataCollectorView.swift @@ -0,0 +1,245 @@ +import SwiftUI +import StrandDesign + +/// iOS/macOS parity twin of Android's 5/MG Raw Data Collector screen. +struct RawDataCollectorView: View { + @EnvironmentObject private var model: AppModel + @EnvironmentObject private var live: LiveState + @StateObject private var store = RawDataSessionStore() + + @State private var excludeMinutes = "5" + @State private var exportingId: String? + @State private var deleteCandidate: RawDataSessionStore.Session? + @State private var confirmDeleteAll = false + @State private var exportError: String? + @State private var rawBatchCounts: [String: Int] = [:] + + var body: some View { + ScreenScaffold( + title: "5/MG Raw Data Collector", + subtitle: "Record a bounded 100 Hz motion session and export its complete timeline." + ) { + VStack(alignment: .leading, spacing: NoopMetrics.sectionSpacing) { + countersCard + coverageCard + labelsCard + controls + sessionsSection + } + } + .task { + // Restore a session after navigation/process lifecycle changes. The BLE layer rejects a + // duplicate arm, so this is safe when capture never stopped. + if store.active != nil, live.bonded { model.ble.startGroundTruthRawCapture() } + await refreshRawCounts() + } + .onChangeCompat(of: live.bonded) { bonded in + if bonded, store.active != nil { model.ble.startGroundTruthRawCapture() } + } + .confirmationDialog("Delete this session?", isPresented: Binding( + get: { deleteCandidate != nil }, set: { if !$0 { deleteCandidate = nil } } + ), titleVisibility: .visible) { + Button("Delete", role: .destructive) { + guard let session = deleteCandidate else { return } + deleteCandidate = nil + Task { await delete(session) } + } + Button("Cancel", role: .cancel) { deleteCandidate = nil } + } message: { + if let session = deleteCandidate { + Text("The session \(Self.range(session)) and its captured raw data will be deleted permanently.") + } + } + .confirmationDialog("Delete all sessions?", isPresented: $confirmDeleteAll, + titleVisibility: .visible) { + Button("Delete all", role: .destructive) { Task { await deleteAll() } } + Button("Cancel", role: .cancel) { } + } message: { + Text("All \(store.sessions.count) recorded sessions and their captured raw data will be deleted permanently.") + } + .alert("Couldn't export session", isPresented: Binding( + get: { exportError != nil }, set: { if !$0 { exportError = nil } } + )) { + Button("OK", role: .cancel) { exportError = nil } + } message: { Text(exportError ?? "Unknown error") } + } + + private var countersCard: some View { + StrandCard { + HStack { + counter("NOOP steps", model.repo.days.last?.steps.map(String.init) ?? "–") + counter("Manual steps", String(store.active?.steps ?? 0)) + counter("Stairs", String(store.active?.stairs ?? 0)) + } + } + } + + private var coverageCard: some View { + StrandCard { + VStack(alignment: .leading, spacing: NoopMetrics.space2) { + Text("Capture coverage").font(StrandFont.headline).foregroundStyle(StrandPalette.textPrimary) + Text(live.connected ? "Band: connected\(live.bonded ? " + paired" : "; pairing")" + : "Band: disconnected") + .foregroundStyle(live.connected ? StrandPalette.statusPositive : StrandPalette.statusCritical) + Text(live.backfilling + ? "History sync: running (\(live.syncChunksThisSession) chunks)" + : "History sync: idle") + .foregroundStyle(StrandPalette.textSecondary) + if let active = store.active { + Text("Realtime IMU: session active since \(Self.time(active.startedAtMs))") + .foregroundStyle(StrandPalette.accent) + } + } + .font(StrandFont.subhead) + } + } + + private var labelsCard: some View { + StrandCard { + VStack(alignment: .leading, spacing: NoopMetrics.space3) { + Text("Optional manual labels").font(StrandFont.headline) + .foregroundStyle(StrandPalette.textPrimary) + Text("For step-algorithm research, label one step or one stair plus one step. Raw capture works without labels.") + .font(StrandFont.caption).foregroundStyle(StrandPalette.textTertiary) + HStack { + NoopButton("Step", systemImage: "figure.walk", kind: .secondary, fullWidth: true) { + store.record(.step) + }.disabled(store.active == nil) + NoopButton("Stair", systemImage: "stairs", kind: .secondary, fullWidth: true) { + store.record(.stair) + }.disabled(store.active == nil) + } + } + } + } + + @ViewBuilder private var controls: some View { + if store.active != nil { + HStack { + NoopButton("Undo last click", systemImage: "arrow.uturn.backward", kind: .secondary, + fullWidth: true) { store.undo() } + NoopButton("Stop session", systemImage: "stop.fill", kind: .destructive, + fullWidth: true) { Task { await stop() } } + } + HStack { + TextField("Minutes", text: $excludeMinutes) + .textFieldStyle(.roundedBorder) + NoopButton("Ignore recent period", kind: .secondary, fullWidth: true) { + if let minutes = Int(excludeMinutes) { store.excludeLast(minutes: minutes) } + } + .disabled(!(1...240).contains(Int(excludeMinutes) ?? 0)) + } + } else { + NoopButton("Start raw-data session", systemImage: "record.circle", kind: .primary, + fullWidth: true) { start() } + .disabled(!live.bonded) + } + } + + private var sessionsSection: some View { + VStack(alignment: .leading, spacing: NoopMetrics.space3) { + Text("Recorded sessions").font(StrandFont.title2).foregroundStyle(StrandPalette.textPrimary) + if store.sessions.isEmpty { + Text("No sessions recorded yet.").font(StrandFont.subhead) + .foregroundStyle(StrandPalette.textSecondary) + } else { + NoopButton("Delete all sessions", systemImage: "trash", kind: .destructive, + fullWidth: true) { confirmDeleteAll = true } + .disabled(store.active != nil) + ForEach(store.sessions) { session in sessionCard(session) } + } + } + } + + private func sessionCard(_ session: RawDataSessionStore.Session) -> some View { + StrandCard { + VStack(alignment: .leading, spacing: NoopMetrics.space3) { + Text(Self.range(session)).font(StrandFont.headline).foregroundStyle(StrandPalette.textPrimary) + Text("\(session.steps) steps · \(session.stairs) stairs · \(session.excludedWindows) excluded periods") + .font(StrandFont.caption).foregroundStyle(StrandPalette.textSecondary) + Text(session.active ? "Export status: recording" + : "Export status: \(rawBatchCounts[session.id, default: 0]) raw batches") + .font(StrandFont.caption) + .foregroundStyle(session.active ? StrandPalette.statusWarning : StrandPalette.statusPositive) + TextField("Session comment", text: Binding( + get: { store.sessions.first(where: { $0.id == session.id })?.comment ?? session.comment }, + set: { store.setComment($0, sessionId: session.id) } + ), axis: .vertical) + .textFieldStyle(.roundedBorder).lineLimit(2...4) + NoopButton(exportingId == session.id ? "Building export…" : "Export session", + systemImage: "square.and.arrow.up", kind: .secondary, fullWidth: true) { + Task { await export(session) } + } + .disabled(session.active || exportingId != nil) + NoopButton("Delete session", systemImage: "trash", kind: .destructive, + fullWidth: true) { deleteCandidate = session } + .disabled(session.active || exportingId != nil) + } + } + } + + private func counter(_ label: LocalizedStringKey, _ value: String) -> some View { + VStack(spacing: NoopMetrics.space1) { + Text(value).font(StrandFont.number(28)).foregroundStyle(StrandPalette.textPrimary) + Text(label).font(StrandFont.caption).foregroundStyle(StrandPalette.textSecondary) + .multilineTextAlignment(.center) + }.frame(maxWidth: .infinity) + } + + private func start() { + guard model.ble.startGroundTruthRawCapture() else { return } + if store.start(deviceId: model.ble.deviceId) == nil { + Task { await model.ble.stopGroundTruthRawCapture() } + } + } + + private func stop() async { + await model.ble.stopGroundTruthRawCapture() + store.stop() + await refreshRawCounts() + } + + private func export(_ session: RawDataSessionStore.Session) async { + guard let end = session.endedAtMs else { return } + exportingId = session.id + let raw = await model.ble.groundTruthRawBatches(from: Int(session.startedAtMs / 1_000), + to: Int(end / 1_000)) + let entries = store.exportEntries(for: session, raw: raw) + let result = await FileExport.exportBundle(entries: entries, + suggestedName: "noop-ground-truth-\(session.id).zip") + if result == nil { exportError = "The export file could not be created or shared." } + else { store.markExported(session.id) } + exportingId = nil + } + + private func delete(_ session: RawDataSessionStore.Session) async { + guard let end = session.endedAtMs else { return } + _ = await model.ble.deleteGroundTruthRawBatches(from: Int(session.startedAtMs / 1_000), + to: Int(end / 1_000)) + store.removeMetadata(session.id) + rawBatchCounts[session.id] = nil + } + + private func deleteAll() async { + for session in store.sessions where !session.active { await delete(session) } + } + + private func refreshRawCounts() async { + for session in store.sessions { + guard let end = session.endedAtMs else { continue } + rawBatchCounts[session.id] = await model.ble.groundTruthRawBatches( + from: Int(session.startedAtMs / 1_000), to: Int(end / 1_000)).count + } + } + + private static func time(_ ms: Int64) -> String { + Date(timeIntervalSince1970: Double(ms) / 1_000).formatted(date: .omitted, time: .shortened) + } + + private static func range(_ session: RawDataSessionStore.Session) -> String { + let start = Date(timeIntervalSince1970: Double(session.startedAtMs) / 1_000) + let date = start.formatted(date: .numeric, time: .omitted) + let end = session.endedAtMs.map(time) ?? "…" + return "\(date) · \(time(session.startedAtMs))–\(end)" + } +} diff --git a/Strand/Screens/TestCentreView.swift b/Strand/Screens/TestCentreView.swift index 8a7ead9892..66b6e925cc 100644 --- a/Strand/Screens/TestCentreView.swift +++ b/Strand/Screens/TestCentreView.swift @@ -36,7 +36,6 @@ struct TestCentreView: View { // Retention (#650): how many scheduled-export generations to keep, and the manual clear confirm. @State private var debugExportKeep = ScheduledDebugExport.keepCount @State private var showClearExportsConfirm = false - @State private var rawMotionCaptureActive = false // WHOOP 5/MG developer controls. These use the same persisted keys as the former Settings card; // moving the UI does not reset an opt-in or lose the ability to undo a persistent strap write. @@ -150,26 +149,16 @@ struct TestCentreView: View { Text("5/MG RAW DATA COLLECTOR") .font(StrandFont.overline).tracking(StrandFont.overlineTracking) .foregroundStyle(StrandPalette.textSecondary) - Text("Records a bounded 60-second, 100 Hz motion window. The verified start/stop sequence is used only while this user-started session is active; normal sync is unchanged.") + Text("Start, label, review, export, and delete bounded 100 Hz motion sessions. Normal sync is unchanged.") .font(StrandFont.caption).foregroundStyle(StrandPalette.textTertiary) .fixedSize(horizontal: false, vertical: true) - NoopButton( - rawMotionCaptureActive ? "Capturing 100 Hz motion…" : "Capture 60 seconds", - systemImage: rawMotionCaptureActive ? "waveform.path.ecg" : "record.circle", - kind: .primary - ) { - guard !rawMotionCaptureActive else { return } - rawMotionCaptureActive = true - model.ble.captureRawAccel(seconds: 60) - Task { @MainActor in - try? await Task.sleep(nanoseconds: 60_000_000_000) - rawMotionCaptureActive = false - } + NavigationLink { + RawDataCollectorView() + } label: { + Label("Open raw-data collector", systemImage: "waveform.path.ecg") } - .disabled(rawMotionCaptureActive || !live.connected) - Text(live.connected - ? "The bounded raw window is persisted locally and can be included in Test Centre diagnostics." - : "Connect your WHOOP 5/MG to start a capture.") + .buttonStyle(NoopButtonStyle(.primary, fullWidth: true)) + Text(live.connected ? "WHOOP 5/MG connected." : "Connect your WHOOP 5/MG to start a capture.") .font(StrandFont.caption) .foregroundStyle(live.connected ? StrandPalette.textSecondary : StrandPalette.statusWarning) } diff --git a/StrandTests/RawDataSessionStoreTests.swift b/StrandTests/RawDataSessionStoreTests.swift new file mode 100644 index 0000000000..6bb96718cc --- /dev/null +++ b/StrandTests/RawDataSessionStoreTests.swift @@ -0,0 +1,50 @@ +import XCTest +@testable import Strand + +@MainActor +final class RawDataSessionStoreTests: XCTestCase { + private func temporaryDirectory() throws -> URL { + let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + addTeardownBlock { try? FileManager.default.removeItem(at: url) } + return url + } + + func testLifecycleLabelsUndoExclusionAndReload() throws { + let directory = try temporaryDirectory() + let store = RawDataSessionStore(directory: directory) + XCTAssertNotNil(store.start(deviceId: "strap", now: Date(timeIntervalSince1970: 100))) + store.record(.step, now: Date(timeIntervalSince1970: 101)) + store.record(.stair, now: Date(timeIntervalSince1970: 102)) + store.undo(now: Date(timeIntervalSince1970: 103)) + store.undo(now: Date(timeIntervalSince1970: 104)) // the same click cannot be undone twice + store.excludeLast(minutes: 5, now: Date(timeIntervalSince1970: 105)) + store.stop(now: Date(timeIntervalSince1970: 106)) + + let session = try XCTUnwrap(store.sessions.first) + XCTAssertEqual(session.steps, 1) + XCTAssertEqual(session.stairs, 0) + XCTAssertEqual(session.excludedWindows, 1) + XCTAssertEqual(session.endedAtMs, 106_000) + + let reloaded = RawDataSessionStore(directory: directory) + XCTAssertEqual(reloaded.sessions.first, session) + } + + func testActiveSessionCannotBeDeleted() throws { + let store = RawDataSessionStore(directory: try temporaryDirectory()) + let active = try XCTUnwrap(store.start(deviceId: "strap")) + store.removeMetadata(active.id) + XCTAssertEqual(store.sessions.map(\.id), [active.id]) + } + + func testCompletedSessionCanBeDeleted() throws { + let directory = try temporaryDirectory() + let store = RawDataSessionStore(directory: directory) + let session = try XCTUnwrap(store.start(deviceId: "strap")) + store.stop() + store.removeMetadata(session.id) + XCTAssertTrue(store.sessions.isEmpty) + XCTAssertTrue(RawDataSessionStore(directory: directory).sessions.isEmpty) + } +} From 79542543326eb965e183f4a09ed5821df85ece2a Mon Sep 17 00:00:00 2001 From: Bernd Helm Date: Wed, 26 Aug 2026 21:17:07 +0200 Subject: [PATCH 16/45] Add hybrid rolling and durable IMU storage --- .../Sources/WhoopStore/Database.swift | 20 ++++ .../Sources/WhoopStore/ImuChunkCatalog.swift | 62 +++++++++++++ .../Sources/WhoopStore/StreamStore.swift | 18 +++- .../Resources/schema_oracle.json | 28 +++++- Strand/BLE/BLEManager.swift | 17 ++++ Strand/Collect/Collector.swift | 37 ++++++++ Strand/Collect/ImuChunkArchiveStore.swift | 75 +++++++++++++++ Strand/Collect/RawDataSessionStore.swift | 48 +++++++++- Strand/Screens/RawDataCollectorView.swift | 71 ++++++++++++-- StrandTests/RawDataCollectorParityTests.swift | 49 ++++++++++ .../Resources/raw_data_collector_parity.json | 73 +++++++++++++++ .../src/main/java/com/noop/data/Entities.kt | 22 +++++ .../src/main/java/com/noop/data/WhoopDao.kt | 12 +++ .../main/java/com/noop/data/WhoopDatabase.kt | 22 ++++- .../java/com/noop/data/WhoopRepository.kt | 9 +- .../noop/testcentre/GroundTruthCollector.kt | 69 +++++++++++++- .../java/com/noop/testcentre/ImuChunkStore.kt | 93 +++++++++++++++++++ .../com/noop/ui/GroundTruthCollectorScreen.kt | 72 ++++++++++++++ .../app/src/main/res/values-de/strings.xml | 3 + android/app/src/main/res/values/strings.xml | 3 + .../testcentre/RawDataCollectorParityTest.kt | 49 ++++++++++ .../resources/raw_data_collector_parity.json | 73 +++++++++++++++ .../app/src/test/resources/schema_oracle.json | 28 +++++- 23 files changed, 933 insertions(+), 20 deletions(-) create mode 100644 Packages/WhoopStore/Sources/WhoopStore/ImuChunkCatalog.swift create mode 100644 Strand/Collect/ImuChunkArchiveStore.swift create mode 100644 StrandTests/RawDataCollectorParityTests.swift create mode 100644 StrandTests/Resources/raw_data_collector_parity.json create mode 100644 android/app/src/main/java/com/noop/testcentre/ImuChunkStore.kt create mode 100644 android/app/src/test/java/com/noop/testcentre/RawDataCollectorParityTest.kt create mode 100644 android/app/src/test/resources/raw_data_collector_parity.json diff --git a/Packages/WhoopStore/Sources/WhoopStore/Database.swift b/Packages/WhoopStore/Sources/WhoopStore/Database.swift index 1ac4d04088..f2cf80f986 100644 --- a/Packages/WhoopStore/Sources/WhoopStore/Database.swift +++ b/Packages/WhoopStore/Sources/WhoopStore/Database.swift @@ -875,6 +875,26 @@ extension WhoopStore { t.add(column: "burstIndex", .integer) } } + // v40: index immutable 100 Hz archive chunks whose payload lives outside the operational DB. + migrator.registerMigration("v40-imu-chunk-catalog") { db in + try db.create(table: "imuChunk") { t in + t.column("id", .text).primaryKey() + t.column("deviceId", .text).notNull() + t.column("startTs", .integer).notNull() + t.column("endTs", .integer).notNull() + t.column("sampleCount", .integer).notNull() + t.column("sampleRate", .integer).notNull() + t.column("formatVersion", .integer).notNull() + t.column("codec", .text).notNull() + t.column("relativePath", .text).notNull() + t.column("byteSize", .integer).notNull() + t.column("sha256", .text).notNull() + t.column("createdAt", .integer).notNull() + t.column("pinnedUntil", .integer) + } + try db.create(index: "index_imuChunk_deviceId_startTs_endTs", on: "imuChunk", + columns: ["deviceId", "startTs", "endTs"]) + } return migrator } } diff --git a/Packages/WhoopStore/Sources/WhoopStore/ImuChunkCatalog.swift b/Packages/WhoopStore/Sources/WhoopStore/ImuChunkCatalog.swift new file mode 100644 index 0000000000..c2bf14ea77 --- /dev/null +++ b/Packages/WhoopStore/Sources/WhoopStore/ImuChunkCatalog.swift @@ -0,0 +1,62 @@ +import Foundation +import GRDB + +public struct ImuChunkMeta: Codable, Equatable, Sendable { + public let id: String + public let deviceId: String + public let startTs: Int + public let endTs: Int + public let sampleCount: Int + public let sampleRate: Int + public let formatVersion: Int + public let codec: String + public let relativePath: String + public let byteSize: Int + public let sha256: String + public let createdAt: Int + public let pinnedUntil: Int? + + public init(id: String, deviceId: String, startTs: Int, endTs: Int, sampleCount: Int, + sampleRate: Int, formatVersion: Int, codec: String, relativePath: String, + byteSize: Int, sha256: String, createdAt: Int, pinnedUntil: Int?) { + self.id = id; self.deviceId = deviceId; self.startTs = startTs; self.endTs = endTs + self.sampleCount = sampleCount; self.sampleRate = sampleRate; self.formatVersion = formatVersion + self.codec = codec; self.relativePath = relativePath; self.byteSize = byteSize + self.sha256 = sha256; self.createdAt = createdAt; self.pinnedUntil = pinnedUntil + } +} + +extension WhoopStore { + public func upsertImuChunk(_ chunk: ImuChunkMeta) async throws { + try syncWrite { db in + try db.execute(sql: """ + INSERT INTO imuChunk + (id, deviceId, startTs, endTs, sampleCount, sampleRate, formatVersion, codec, + relativePath, byteSize, sha256, createdAt, pinnedUntil) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + relativePath=excluded.relativePath, byteSize=excluded.byteSize, + sha256=excluded.sha256, pinnedUntil=excluded.pinnedUntil + """, arguments: [chunk.id, chunk.deviceId, chunk.startTs, chunk.endTs, + chunk.sampleCount, chunk.sampleRate, chunk.formatVersion, chunk.codec, + chunk.relativePath, chunk.byteSize, chunk.sha256, chunk.createdAt, + chunk.pinnedUntil]) + } + } + + public func imuChunks(deviceId: String, from: Int, to: Int) async throws -> [ImuChunkMeta] { + try syncRead { db in + try Row.fetchAll(db, sql: """ + SELECT * FROM imuChunk + WHERE deviceId = ? AND endTs >= ? AND startTs <= ? ORDER BY startTs + """, arguments: [deviceId, from, to]).map { row in + ImuChunkMeta(id: row["id"], deviceId: row["deviceId"], startTs: row["startTs"], + endTs: row["endTs"], sampleCount: row["sampleCount"], + sampleRate: row["sampleRate"], formatVersion: row["formatVersion"], + codec: row["codec"], relativePath: row["relativePath"], + byteSize: row["byteSize"], sha256: row["sha256"], + createdAt: row["createdAt"], pinnedUntil: row["pinnedUntil"]) + } + } + } +} diff --git a/Packages/WhoopStore/Sources/WhoopStore/StreamStore.swift b/Packages/WhoopStore/Sources/WhoopStore/StreamStore.swift index 43bf0b7fe4..9c6e2ae4ab 100644 --- a/Packages/WhoopStore/Sources/WhoopStore/StreamStore.swift +++ b/Packages/WhoopStore/Sources/WhoopStore/StreamStore.swift @@ -59,8 +59,8 @@ extension WhoopStore { } /// #423 rolling retention for the raw-IMU capture table (twin of Kotlin `RAW_IMU_RETENTION_ROWS`): - /// ~1 h at 1 row/strap-second (~4 MB) hard-caps the table during a multi-day offload replay. - public static let rawImuRetentionRows = 3600 + /// 36 h at 1 row/strap-second (~155 MB uncompressed): retrospective activity/workout window. + public static let rawImuRetentionRows = 36 * 60 * 60 /// v31 rolling retention for the v18 aux-slot table (twin of Kotlin `V18_AUX_RETENTION_ROWS`). /// @@ -123,6 +123,20 @@ extension WhoopStore { } } + public func rawImuSamples(deviceId: String, from: Int, to: Int, limit: Int) async throws + -> [(ts: Int, cols: [Int16])] { + try syncRead { db in + try Row.fetchAll(db, sql: """ + SELECT ts, samples FROM rawImuSample + WHERE deviceId = ? AND ts >= ? AND ts <= ? ORDER BY ts LIMIT ? + """, arguments: [deviceId, from, to, limit]).map { row in + let ts: Int = row["ts"] + let data: Data = row["samples"] + return (ts, WhoopStore.unpackImuColumns(data)) + } + } + } + /// Idempotent upsert of decoded streams by natural key. Returns the number of rows /// ACTUALLY inserted per stream (0 for rows that already existed). /// diff --git a/Packages/WhoopStore/Tests/WhoopStoreTests/Resources/schema_oracle.json b/Packages/WhoopStore/Tests/WhoopStoreTests/Resources/schema_oracle.json index 4a19cda30f..bef71e5b5b 100644 --- a/Packages/WhoopStore/Tests/WhoopStoreTests/Resources/schema_oracle.json +++ b/Packages/WhoopStore/Tests/WhoopStoreTests/Resources/schema_oracle.json @@ -1,6 +1,6 @@ { "_readme": "SHARED Room<->GRDB SCHEMA ORACLE (#775). Two byte-identical copies: Packages/WhoopStore/Tests/WhoopStoreTests/Resources/schema_oracle.json and android/app/src/test/resources/schema_oracle.json. SchemaOracleTests.swift compares GRDB's PRAGMA table_info/index_list against it; SchemaOracleTest.kt compares Room's exported schema JSON against it. `columns` is the iOS/GRDB shape in GRDB column order (macOS is the reference implementation); every way Android differs is spelled out in an `android` / `iosAbsent` / `androidColumnOrder` override naming a key in `divergenceReasons`. Adding a column, reordering one, or changing a type/nullability on one platform only fails both suites until it is either fixed or written down here.", - "roomVersion": 33, + "roomVersion": 34, "grdbMigrations": [ "v1", "v2", @@ -40,7 +40,8 @@ "v36-whoop-caps-no-spo2", "v37-daily-avg-sdnn", "v38-apple-step-hour", - "v39-ppg-burst-index" + "v39-ppg-burst-index", + "v40-imu-chunk-catalog" ], "divergenceReasons": { "android-orphan-synced-column": "REAL COLUMN DRIFT: `synced` exists on Android only. GRDB's v10 note is explicit that stepSample gets no `synced` column ('unused; see StreamStore'), and v12's ppgHrSample never had one either; the Room entities copied the flag from the older per-second entities. Nothing reads it on Android \u2014 the per-row upload flag belongs to an upload path that does not exist there \u2014 so it is dead width, not divergent data. Removing it needs a Room table rebuild.", @@ -1252,6 +1253,29 @@ ], "indices": [] }, + "imuChunk": { + "platform": "both", + "columns": [ + { "name": "id", "affinity": "TEXT", "notNull": false, "default": null, + "android": { "notNull": true, "divergence": "sqlite-text-pk-nullable" } }, + { "name": "deviceId", "affinity": "TEXT", "notNull": true, "default": null }, + { "name": "startTs", "affinity": "INTEGER", "notNull": true, "default": null }, + { "name": "endTs", "affinity": "INTEGER", "notNull": true, "default": null }, + { "name": "sampleCount", "affinity": "INTEGER", "notNull": true, "default": null }, + { "name": "sampleRate", "affinity": "INTEGER", "notNull": true, "default": null }, + { "name": "formatVersion", "affinity": "INTEGER", "notNull": true, "default": null }, + { "name": "codec", "affinity": "TEXT", "notNull": true, "default": null }, + { "name": "relativePath", "affinity": "TEXT", "notNull": true, "default": null }, + { "name": "byteSize", "affinity": "INTEGER", "notNull": true, "default": null }, + { "name": "sha256", "affinity": "TEXT", "notNull": true, "default": null }, + { "name": "createdAt", "affinity": "INTEGER", "notNull": true, "default": null }, + { "name": "pinnedUntil", "affinity": "INTEGER", "notNull": false, "default": null } + ], + "primaryKey": ["id"], + "indices": [ + { "name": "index_imuChunk_deviceId_startTs_endTs", "columns": ["deviceId", "startTs", "endTs"], "unique": false } + ] + }, "rawImuSample": { "platform": "both", "columns": [ diff --git a/Strand/BLE/BLEManager.swift b/Strand/BLE/BLEManager.swift index f180775095..232a334b09 100644 --- a/Strand/BLE/BLEManager.swift +++ b/Strand/BLE/BLEManager.swift @@ -1815,11 +1815,28 @@ public final class BLEManager: NSObject, ObservableObject { await collector?.rawBatches(from: from, to: to) ?? [] } + public func groundTruthHistoryCSV(from: Int, to: Int) async -> Data { + await collector?.historySensorsCSV(from: from, to: to) + ?? Data("stream,unix_s,v1,v2,v3,v4\n".utf8) + } + @discardableResult public func deleteGroundTruthRawBatches(from: Int, to: Int) async -> Int { await collector?.deleteRawBatches(from: from, to: to) ?? 0 } + public func groundTruthRawImuSamples(from: Int, to: Int) async -> [(ts: Int, cols: [Int16])] { + await collector?.rawImuSamples(from: from, to: to) ?? [] + } + + public func groundTruthImuChunks(from: Int, to: Int) async -> [ImuChunkMeta] { + await collector?.imuChunks(from: from, to: to) ?? [] + } + + public func registerGroundTruthImuChunk(_ chunk: ImuChunkMeta) async { + await collector?.upsertImuChunk(chunk) + } + /// Send a command to the WHOOP strap. /// - Parameters: /// - command: The command to send. diff --git a/Strand/Collect/Collector.swift b/Strand/Collect/Collector.swift index 32c9eef273..0fee2e2128 100644 --- a/Strand/Collect/Collector.swift +++ b/Strand/Collect/Collector.swift @@ -109,12 +109,49 @@ final class Collector { return result } + /// Decoded history rows in a stable long-form CSV for arbitrary user-selected export windows. + func historySensorsCSV(from: Int, to: Int) async -> Data { + guard let store = concreteStore, from <= to else { return Data("stream,unix_s,v1,v2,v3,v4\n".utf8) } + let limit = min(max(to - from + 1, 1) * 4, 1_000_000) + async let hr = try? store.hrSamples(deviceId: deviceId, from: from, to: to, limit: limit) + async let battery = try? store.batterySamples(deviceId: deviceId, from: from, to: to, limit: limit) + async let spo2 = try? store.spo2Samples(deviceId: deviceId, from: from, to: to, limit: limit) + async let temp = try? store.skinTempSamples(deviceId: deviceId, from: from, to: to, limit: limit) + async let steps = try? store.stepSamples(deviceId: deviceId, from: from, to: to, limit: limit) + async let resp = try? store.respSamples(deviceId: deviceId, from: from, to: to, limit: limit) + async let gravity = try? store.gravitySamples(deviceId: deviceId, from: from, to: to, limit: limit) + var lines = ["stream,unix_s,v1,v2,v3,v4"] + lines += await (hr ?? []).map { "heart_rate,\($0.ts),\($0.bpm),,," } + lines += await (battery ?? []).map { "battery,\($0.ts),\($0.soc.map(String.init) ?? ""),\($0.mv.map(String.init) ?? ""),," } + lines += await (spo2 ?? []).map { "spo2_raw,\($0.ts),\($0.red),\($0.ir),," } + lines += await (temp ?? []).map { "skin_temp_raw,\($0.ts),\($0.raw),\($0.aux1Raw.map(String.init) ?? ""),\($0.aux2Raw.map(String.init) ?? "")," } + lines += await (steps ?? []).map { "steps,\($0.ts),\($0.counter),\($0.activityClass.map(String.init) ?? ""),," } + lines += await (resp ?? []).map { "resp_raw,\($0.ts),\($0.raw),,," } + lines += await (gravity ?? []).map { "gravity,\($0.ts),\($0.x),\($0.y),\($0.z),\($0.dynAccel.map(String.init) ?? "")" } + return Data((lines.joined(separator: "\n") + "\n").utf8) + } + @discardableResult func deleteRawBatches(from: Int, to: Int) async -> Int { guard let store = concreteStore else { return 0 } return (try? await store.deleteRawBatches(deviceId: deviceId, from: from, to: to)) ?? 0 } + func rawImuSamples(from: Int, to: Int) async -> [(ts: Int, cols: [Int16])] { + guard let store = concreteStore else { return [] } + let limit = min(max(to - from + 1, 1), 7 * 24 * 60 * 60) + return (try? await store.rawImuSamples(deviceId: deviceId, from: from, to: to, limit: limit)) ?? [] + } + + func imuChunks(from: Int, to: Int) async -> [ImuChunkMeta] { + guard let store = concreteStore else { return [] } + return (try? await store.imuChunks(deviceId: deviceId, from: from, to: to)) ?? [] + } + + func upsertImuChunk(_ chunk: ImuChunkMeta) async { + try? await concreteStore?.upsertImuChunk(chunk) + } + /// Max persisted HR sample ts (the biometric "data frontier" for the stuck-strap watchdog). /// nil if there's no concrete store or nothing persisted yet. Mirrors storageStats(). func latestHRSampleTs() async -> Int? { diff --git a/Strand/Collect/ImuChunkArchiveStore.swift b/Strand/Collect/ImuChunkArchiveStore.swift new file mode 100644 index 0000000000..78e08f15e9 --- /dev/null +++ b/Strand/Collect/ImuChunkArchiveStore.swift @@ -0,0 +1,75 @@ +import CryptoKit +import Foundation +import WhoopStore + +/// Apple twin of Android ImuChunkStore. ZIP entries and samples.bin layout are platform-identical. +@MainActor +final class ImuChunkArchiveStore { + static let formatVersion = 1 + static let sampleRate = 100 + private let directory: URL + + init() { + let base = (try? FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, + appropriateFor: nil, create: true)) + ?? FileManager.default.temporaryDirectory + directory = base.appendingPathComponent("OpenWhoop/imu-chunks", isDirectory: true) + try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + } + + func pin(deviceId: String, from: Int, to: Int, ble: BLEManager) async -> [ImuChunkMeta] { + let existing = await ble.groundTruthImuChunks(from: from, to: to) + .filter { $0.pinnedUntil == Int.max && file($0).isFileURL && FileManager.default.fileExists(atPath: file($0).path) } + if existing.contains(where: { $0.startTs <= from && $0.endTs >= to }) { return existing } + let rows = await ble.groundTruthRawImuSamples(from: from, to: to) + guard !rows.isEmpty else { return existing } + + var samples = Data() + for row in rows { + samples.appendBigEndian(Int64(row.ts)) + samples.appendBigEndian(Int32(row.cols.count * 2)) + for value in row.cols { + samples.append(UInt8(truncatingIfNeeded: value)) + samples.append(UInt8(truncatingIfNeeded: value >> 8)) + } + } + let manifest: [String: Any] = [ + "format": "NOOPIMU", "version": Self.formatVersion, + "sample_rate_hz": Self.sampleRate, + "axes": ["ax", "ay", "az", "gx", "gy", "gz"], + "layout": "column-major-int16-le", "row_count": rows.count, + "start_ts": rows.first!.ts, "end_ts": rows.last!.ts, + ] + guard let manifestData = try? JSONSerialization.data(withJSONObject: manifest, options: [.prettyPrinted, .sortedKeys]), + let staged = FileExport.zipData(entries: [ + .init(name: "manifest.json", data: manifestData), .init(name: "samples.bin", data: samples), + ], baseName: UUID().uuidString) else { return [] } + let id = UUID().uuidString + let destination = directory.appendingPathComponent("\(id).imuc") + do { try FileManager.default.moveItem(at: staged, to: destination) } catch { return [] } + let bytes = (try? Data(contentsOf: destination)) ?? Data() + let base = directory.deletingLastPathComponent().deletingLastPathComponent() + let relative = destination.path.replacingOccurrences(of: base.path + "/", with: "") + let chunk = ImuChunkMeta( + id: id, deviceId: deviceId, startTs: rows.first!.ts, endTs: rows.last!.ts, + sampleCount: rows.count * Self.sampleRate, sampleRate: Self.sampleRate, + formatVersion: Self.formatVersion, codec: "zip-deflate", relativePath: relative, + byteSize: bytes.count, sha256: SHA256.hash(data: bytes).map { String(format: "%02x", $0) }.joined(), + createdAt: Int(Date().timeIntervalSince1970), pinnedUntil: Int.max) + await ble.registerGroundTruthImuChunk(chunk) + return await ble.groundTruthImuChunks(from: from, to: to) + .filter { FileManager.default.fileExists(atPath: file($0).path) } + } + + func file(_ chunk: ImuChunkMeta) -> URL { + let base = directory.deletingLastPathComponent().deletingLastPathComponent() + return base.appendingPathComponent(chunk.relativePath) + } +} + +private extension Data { + mutating func appendBigEndian(_ value: T) { + var v = value.bigEndian + Swift.withUnsafeBytes(of: &v) { append(contentsOf: $0) } + } +} diff --git a/Strand/Collect/RawDataSessionStore.swift b/Strand/Collect/RawDataSessionStore.swift index 77189fd46a..d38ffd81b4 100644 --- a/Strand/Collect/RawDataSessionStore.swift +++ b/Strand/Collect/RawDataSessionStore.swift @@ -28,6 +28,9 @@ final class RawDataSessionStore: ObservableObject { let deviceId: String let startedAtMs: Int64 var endedAtMs: Int64? + var capturedStartedAtMs: Int64? + var capturedEndedAtMs: Int64? + var keepRaw: Bool? var steps: Int var stairs: Int var excludedWindows: Int @@ -41,6 +44,8 @@ final class RawDataSessionStore: ObservableObject { lhs.id == rhs.id && lhs.endedAtMs == rhs.endedAtMs && lhs.steps == rhs.steps && lhs.stairs == rhs.stairs && lhs.excludedWindows == rhs.excludedWindows && lhs.comment == rhs.comment && lhs.exported == rhs.exported + && lhs.capturedStartedAtMs == rhs.capturedStartedAtMs + && lhs.capturedEndedAtMs == rhs.capturedEndedAtMs } } @@ -76,7 +81,8 @@ final class RawDataSessionStore: ObservableObject { let millis = Int64(now.timeIntervalSince1970 * 1_000) let started = Event(atMs: millis, kind: .start, stepsTotal: 0, stairsTotal: 0) let session = Session(id: String(millis), deviceId: deviceId, startedAtMs: millis, - endedAtMs: nil, steps: 0, stairs: 0, excludedWindows: 0, + endedAtMs: nil, capturedStartedAtMs: millis, capturedEndedAtMs: nil, + keepRaw: true, steps: 0, stairs: 0, excludedWindows: 0, comment: "", exported: false, events: [started]) sessions.insert(session, at: 0) persist(session) @@ -87,11 +93,43 @@ final class RawDataSessionStore: ObservableObject { mutateActive { session in let millis = Int64(now.timeIntervalSince1970 * 1_000) session.endedAtMs = millis + session.capturedEndedAtMs = millis session.events.append(Event(atMs: millis, kind: .stop, stepsTotal: session.steps, stairsTotal: session.stairs)) } } + @discardableResult + func createHistorical(deviceId: String, from: Date, to: Date) -> Session? { + let fromMs = Int64(from.timeIntervalSince1970 * 1_000) + let toMs = Int64(to.timeIntervalSince1970 * 1_000) + guard validRange(fromMs, toMs) else { return nil } + let id = String(Int64(Date().timeIntervalSince1970 * 1_000)) + let events = [Event(atMs: fromMs, kind: .start, stepsTotal: 0, stairsTotal: 0), + Event(atMs: toMs, kind: .stop, stepsTotal: 0, stairsTotal: 0)] + let session = Session(id: id, deviceId: deviceId, startedAtMs: fromMs, endedAtMs: toMs, + capturedStartedAtMs: nil, capturedEndedAtMs: nil, keepRaw: true, + steps: 0, stairs: 0, excludedWindows: 0, comment: "", + exported: false, events: events) + sessions.insert(session, at: 0); persist(session) + return session + } + + func setRange(sessionId: String, from: Date, to: Date) { + let fromMs = Int64(from.timeIntervalSince1970 * 1_000) + let toMs = Int64(to.timeIntervalSince1970 * 1_000) + guard validRange(fromMs, toMs) else { return } + mutate(sessionId) { session in + guard !session.active else { return } + session = Session(id: session.id, deviceId: session.deviceId, startedAtMs: fromMs, + endedAtMs: toMs, capturedStartedAtMs: session.capturedStartedAtMs, + capturedEndedAtMs: session.capturedEndedAtMs, keepRaw: session.keepRaw, + steps: session.steps, stairs: session.stairs, + excludedWindows: session.excludedWindows, comment: session.comment, + exported: false, events: session.events) + } + } + func record(_ kind: LabelKind, now: Date = Date()) { guard kind == .step || kind == .stair else { return } mutateActive { session in @@ -156,9 +194,13 @@ final class RawDataSessionStore: ObservableObject { private func file(_ id: String) -> URL { directory.appendingPathComponent("session-\(id).json") } + private func validRange(_ from: Int64, _ to: Int64) -> Bool { + from > 0 && from <= to && to - from <= 7 * 24 * 60 * 60 * 1_000 + } + func exportEntries(for session: Session, raw: [(RawBatchMeta, [[UInt8]])]) -> [FileExport.BundleEntry] { - let meta: [String: Any] = [ + var meta: [String: Any] = [ "schema_version": 3, "capture_kind": "whoop_5mg_raw_data", "session_id": session.id, "device_id": session.deviceId, "started_at_ms": session.startedAtMs, "ended_at_ms": session.endedAtMs ?? session.startedAtMs, @@ -169,6 +211,8 @@ final class RawDataSessionStore: ObservableObject { "app_version": Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "?", "manual_labels": "optional; on-screen step and stair controls", ] + if let value = session.capturedStartedAtMs { meta["captured_started_at_ms"] = value } + if let value = session.capturedEndedAtMs { meta["captured_ended_at_ms"] = value } let metaData = (try? JSONSerialization.data(withJSONObject: meta, options: [.prettyPrinted, .sortedKeys])) ?? Data() let eventData = session.events.compactMap { event -> Data? in var row: [String: Any] = ["at_ms": event.atMs, "kind": event.kind.rawValue, diff --git a/Strand/Screens/RawDataCollectorView.swift b/Strand/Screens/RawDataCollectorView.swift index 7613b590aa..86e36b5c79 100644 --- a/Strand/Screens/RawDataCollectorView.swift +++ b/Strand/Screens/RawDataCollectorView.swift @@ -6,6 +6,7 @@ struct RawDataCollectorView: View { @EnvironmentObject private var model: AppModel @EnvironmentObject private var live: LiveState @StateObject private var store = RawDataSessionStore() + private let archive = ImuChunkArchiveStore() @State private var excludeMinutes = "5" @State private var exportingId: String? @@ -13,6 +14,8 @@ struct RawDataCollectorView: View { @State private var confirmDeleteAll = false @State private var exportError: String? @State private var rawBatchCounts: [String: Int] = [:] + @State private var historicalFrom = Date().addingTimeInterval(-3_600) + @State private var historicalTo = Date() var body: some View { ScreenScaffold( @@ -24,6 +27,7 @@ struct RawDataCollectorView: View { coverageCard labelsCard controls + historicalRangeCard sessionsSection } } @@ -136,6 +140,24 @@ struct RawDataCollectorView: View { } } + private var historicalRangeCard: some View { + StrandCard { + VStack(alignment: .leading, spacing: NoopMetrics.space3) { + Text("Historical export window").font(StrandFont.headline) + Text("Create a session from synchronized history without starting a live capture. 100 Hz coverage is included wherever it still exists in the rolling buffer.") + .font(StrandFont.caption).foregroundStyle(StrandPalette.textTertiary) + DatePicker("From", selection: $historicalFrom) + DatePicker("To", selection: $historicalTo, in: historicalFrom...) + NoopButton("Add historical session", systemImage: "clock.arrow.circlepath", + kind: .secondary, fullWidth: true) { + _ = store.createHistorical(deviceId: model.ble.deviceId, + from: historicalFrom, to: historicalTo) + } + .disabled(historicalTo <= historicalFrom || historicalTo.timeIntervalSince(historicalFrom) > 7 * 86_400) + } + } + } + private var sessionsSection: some View { VStack(alignment: .leading, spacing: NoopMetrics.space3) { Text("Recorded sessions").font(StrandFont.title2).foregroundStyle(StrandPalette.textPrimary) @@ -155,6 +177,18 @@ struct RawDataCollectorView: View { StrandCard { VStack(alignment: .leading, spacing: NoopMetrics.space3) { Text(Self.range(session)).font(StrandFont.headline).foregroundStyle(StrandPalette.textPrimary) + if !session.active, let endMs = session.endedAtMs { + DatePicker("From", selection: Binding( + get: { Date(timeIntervalSince1970: Double(session.startedAtMs) / 1_000) }, + set: { store.setRange(sessionId: session.id, from: $0, + to: Date(timeIntervalSince1970: Double(endMs) / 1_000)) } + )) + DatePicker("To", selection: Binding( + get: { Date(timeIntervalSince1970: Double(endMs) / 1_000) }, + set: { store.setRange(sessionId: session.id, + from: Date(timeIntervalSince1970: Double(session.startedAtMs) / 1_000), to: $0) } + )) + } Text("\(session.steps) steps · \(session.stairs) stairs · \(session.excludedWindows) excluded periods") .font(StrandFont.caption).foregroundStyle(StrandPalette.textSecondary) Text(session.active ? "Export status: recording" @@ -202,9 +236,25 @@ struct RawDataCollectorView: View { private func export(_ session: RawDataSessionStore.Session) async { guard let end = session.endedAtMs else { return } exportingId = session.id - let raw = await model.ble.groundTruthRawBatches(from: Int(session.startedAtMs / 1_000), - to: Int(end / 1_000)) - let entries = store.exportEntries(for: session, raw: raw) + let from = Int(session.startedAtMs / 1_000), to = Int(end / 1_000) + let chunks = await archive.pin(deviceId: session.deviceId, from: from, to: to, ble: model.ble) + let history = await model.ble.groundTruthHistoryCSV(from: from, to: to) + var entries = store.exportEntries(for: session, raw: []) + entries.append(.init(name: "history-sensors.csv", data: history)) + let coverage: [String: Any] = [ + "requested_start_ts": from, "requested_end_ts": to, + "complete": Self.covers(chunks.map { ($0.startTs, $0.endTs) }, from: from, to: to), + "chunks": chunks.map { ["start_ts": $0.startTs, "end_ts": $0.endTs, + "sample_count": $0.sampleCount, "sha256": $0.sha256] } + ] + if let data = try? JSONSerialization.data(withJSONObject: coverage, options: [.prettyPrinted, .sortedKeys]) { + entries.append(.init(name: "imu-coverage.json", data: data)) + } + for chunk in chunks { + if let data = try? Data(contentsOf: archive.file(chunk)) { + entries.append(.init(name: "imu/\(chunk.id).imuc", data: data)) + } + } let result = await FileExport.exportBundle(entries: entries, suggestedName: "noop-ground-truth-\(session.id).zip") if result == nil { exportError = "The export file could not be created or shared." } @@ -213,9 +263,8 @@ struct RawDataCollectorView: View { } private func delete(_ session: RawDataSessionStore.Session) async { - guard let end = session.endedAtMs else { return } - _ = await model.ble.deleteGroundTruthRawBatches(from: Int(session.startedAtMs / 1_000), - to: Int(end / 1_000)) + guard session.endedAtMs != nil else { return } + // Raw chunks may overlap another session; retention cleanup owns physical deletion. store.removeMetadata(session.id) rawBatchCounts[session.id] = nil } @@ -242,4 +291,14 @@ struct RawDataCollectorView: View { let end = session.endedAtMs.map(time) ?? "…" return "\(date) · \(time(session.startedAtMs))–\(end)" } + + private static func covers(_ intervals: [(Int, Int)], from: Int, to: Int) -> Bool { + var cursor = from + for (start, end) in intervals.sorted(by: { $0.0 < $1.0 }) { + if start > cursor { return false } + if end >= cursor { cursor = end + 1 } + if cursor > to { return true } + } + return cursor > to + } } diff --git a/StrandTests/RawDataCollectorParityTests.swift b/StrandTests/RawDataCollectorParityTests.swift new file mode 100644 index 0000000000..848251d072 --- /dev/null +++ b/StrandTests/RawDataCollectorParityTests.swift @@ -0,0 +1,49 @@ +import XCTest +@testable import Strand + +/// Feature-level parity guard for the Android and Apple raw-data collectors. The shared oracle lists +/// every user-visible/session-lifecycle capability and source markers on both implementations. +final class RawDataCollectorParityTests: XCTestCase { + private struct Oracle: Decodable { + let schemaVersion: Int + let capabilities: [String: Capability] + enum CodingKeys: String, CodingKey { case schemaVersion = "schema_version", capabilities } + } + private struct Capability: Decodable { let swift: [String]; let kotlin: [String] } + + private var repoRoot: URL { + URL(fileURLWithPath: #filePath).deletingLastPathComponent().deletingLastPathComponent() + } + + private func oracleData() throws -> Data { + let url = try XCTUnwrap(Bundle(for: Self.self).url(forResource: "raw_data_collector_parity", + withExtension: "json")) + return try Data(contentsOf: url) + } + + func testAppleSurfaceStillImplementsEveryDeclaredCapability() throws { + let oracle = try JSONDecoder().decode(Oracle.self, from: oracleData()) + XCTAssertEqual(oracle.schemaVersion, 1) + let paths = [ + "Strand/Collect/RawDataSessionStore.swift", "Strand/Collect/Collector.swift", + "Strand/BLE/BLEManager.swift", "Strand/Screens/RawDataCollectorView.swift", + "Strand/Collect/ImuChunkArchiveStore.swift", + "Packages/WhoopStore/Sources/WhoopStore/ImuChunkCatalog.swift", + "Packages/WhoopStore/Sources/WhoopStore/StreamStore.swift", + "Packages/WhoopStore/Sources/WhoopStore/RawOutbox.swift", + ] + let source = try paths.map { try String(contentsOf: repoRoot.appendingPathComponent($0)) } + .joined(separator: "\n") + for (name, capability) in oracle.capabilities { + for marker in capability.swift { + XCTAssertTrue(source.contains(marker), "Apple collector lost \(name) marker: \(marker)") + } + } + } + + func testAndroidAndAppleOracleCopiesAreByteIdentical() throws { + let android = repoRoot.appendingPathComponent("android/app/src/test/resources/raw_data_collector_parity.json") + XCTAssertEqual(oracleData(), try Data(contentsOf: android), + "Raw-data collector parity oracle copies must change together") + } +} diff --git a/StrandTests/Resources/raw_data_collector_parity.json b/StrandTests/Resources/raw_data_collector_parity.json new file mode 100644 index 0000000000..3129d3d7e9 --- /dev/null +++ b/StrandTests/Resources/raw_data_collector_parity.json @@ -0,0 +1,73 @@ +{ + "schema_version": 1, + "capabilities": { + "start_stop": { + "swift": ["startGroundTruthRawCapture", "stopGroundTruthRawCapture"], + "kotlin": ["startGroundTruthImuCapture", "stopGroundTruthImuCapture"] + }, + "persisted_sessions": { + "swift": ["RawDataSessionStore", "persist(session)"], + "kotlin": ["GroundTruthCollector", "sessionStartKey"] + }, + "manual_step_and_stair_labels": { + "swift": ["store.record(.step)", "store.record(.stair)"], + "kotlin": ["KIND_STEP", "KIND_STAIR"] + }, + "undo_last_label": { + "swift": ["func undo(", "Undo last click"], + "kotlin": ["fun undo(", "ground_truth_undo"] + }, + "exclude_recent_period": { + "swift": ["excludeLast(minutes:", "Ignore recent period"], + "kotlin": ["excludeLastMinutes(", "ground_truth_exclude_recent"] + }, + "session_comments": { + "swift": ["setComment(", "Session comment"], + "kotlin": ["setComment(", "ground_truth_comment"] + }, + "from_to_time": { + "swift": ["func range(", "endedAtMs"], + "kotlin": ["sessionTimeRange(", "endedAtMs"] + }, + "session_export": { + "swift": ["exportEntries(for:", "Export session"], + "kotlin": ["suspend fun export(", "ground_truth_export"] + }, + "historical_time_windows": { + "swift": ["createHistorical(deviceId:", "Historical export window"], + "kotlin": ["createHistoricalSession(", "ground_truth_add_historical"] + }, + "editable_analysis_range": { + "swift": ["func setRange(sessionId:", "DatePicker(\"From\""], + "kotlin": ["fun setSessionRange(", "ground_truth_edit_start"] + }, + "bounded_36h_imu_cache": { + "swift": ["rawImuRetentionRows = 36 * 60 * 60"], + "kotlin": ["RAW_IMU_RETENTION_ROWS = 36 * 60 * 60"] + }, + "durable_imu_chunk_catalog": { + "swift": ["ImuChunkArchiveStore", "ImuChunkMeta"], + "kotlin": ["ImuChunkStore", "ImuChunkEntity"] + }, + "explicit_imu_coverage": { + "swift": ["imu-coverage.json", "private static func covers"], + "kotlin": ["imu_100hz_complete", "private fun covers"] + }, + "delete_one_and_all_with_confirmation": { + "swift": ["Delete this session?", "Delete all sessions?", "deleteGroundTruthRawBatches"], + "kotlin": ["ground_truth_delete_session_title", "ground_truth_delete_all_title", "deleteAllSessions"] + }, + "active_session_delete_guard": { + "swift": ["active != true", ".disabled(store.active != nil)"], + "kotlin": ["current.active && current.sessionId == sessionId", "sessions.none { it.active }"] + }, + "bluetooth_reconnect_rearm": { + "swift": ["onChangeCompat(of: live.bonded)", "rawCaptureInFlight = false"], + "kotlin": ["LaunchedEffect(state.active, state.sessionId, live.connected)", "startGroundTruthImuCapture"] + }, + "raw_data_removed_with_session": { + "swift": ["deleteRawBatches(deviceId:", "capturedAt >= ? AND capturedAt <= ?"], + "kotlin": ["realtime-imu-$sessionId.bin", "noop-ground-truth-$sessionId.zip"] + } + } +} diff --git a/android/app/src/main/java/com/noop/data/Entities.kt b/android/app/src/main/java/com/noop/data/Entities.kt index 082a796fb3..9dd6d58017 100644 --- a/android/app/src/main/java/com/noop/data/Entities.kt +++ b/android/app/src/main/java/com/noop/data/Entities.kt @@ -3,6 +3,7 @@ package com.noop.data import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.Index +import androidx.room.PrimaryKey /* * Room entities mirroring the verified GRDB schema in @@ -691,6 +692,27 @@ data class RawImuSampleEntity( } } +/** Catalog row for one immutable, app-private IMU archive chunk. Payload bytes live outside SQLite. */ +@Entity( + tableName = "imuChunk", + indices = [Index(value = ["deviceId", "startTs", "endTs"])], +) +data class ImuChunkEntity( + @PrimaryKey val id: String, + val deviceId: String, + val startTs: Long, + val endTs: Long, + val sampleCount: Int, + val sampleRate: Int, + val formatVersion: Int, + val codec: String, + val relativePath: String, + val byteSize: Long, + val sha256: String, + val createdAt: Long, + val pinnedUntil: Long?, +) + /** * One Live Session (silent guardian) record (v22 / MIGRATION_15_16). Natural key (deviceId, startTs). * `endTs` is null while the session is still in progress. Fields are declared in the SAME order as the diff --git a/android/app/src/main/java/com/noop/data/WhoopDao.kt b/android/app/src/main/java/com/noop/data/WhoopDao.kt index 818d9f7cfd..f470ac2a50 100644 --- a/android/app/src/main/java/com/noop/data/WhoopDao.kt +++ b/android/app/src/main/java/com/noop/data/WhoopDao.kt @@ -163,6 +163,18 @@ interface WhoopDao : DeviceRegistryDao { ) suspend fun rawImuSamples(deviceId: String, from: Long, to: Long, limit: Int): List + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsertImuChunk(row: ImuChunkEntity) + + @Query("SELECT * FROM imuChunk WHERE deviceId = :deviceId AND endTs >= :from AND startTs <= :to ORDER BY startTs") + suspend fun imuChunks(deviceId: String, from: Long, to: Long): List + + @Query("DELETE FROM imuChunk WHERE id = :id") + suspend fun deleteImuChunk(id: String) + + @Query("SELECT * FROM imuChunk WHERE pinnedUntil IS NULL AND endTs < :cutoff ORDER BY endTs") + suspend fun expiredImuChunks(cutoff: Long): List + // MARK: - Server-derived caches (latest value wins) @Upsert diff --git a/android/app/src/main/java/com/noop/data/WhoopDatabase.kt b/android/app/src/main/java/com/noop/data/WhoopDatabase.kt index 09558f0158..6dd2b055fb 100644 --- a/android/app/src/main/java/com/noop/data/WhoopDatabase.kt +++ b/android/app/src/main/java/com/noop/data/WhoopDatabase.kt @@ -50,10 +50,11 @@ import androidx.sqlite.db.SupportSQLiteDatabase LiveSessionRow::class, PpgWaveformSampleEntity::class, RawImuSampleEntity::class, + ImuChunkEntity::class, V18AuxSampleEntity::class, AppleStepHour::class, ], - version = 33, + version = 34, // #775: ON so Room's KSP processor writes the generated schema (every table's exact `CREATE TABLE`, // columns in declaration order with affinity/NOT NULL/default, PK and indices) as JSON. That export // is what lets a plain JVM test — no device, no Robolectric — read Android's REAL schema and compare @@ -70,7 +71,7 @@ abstract class WhoopDatabase : RoomDatabase() { const val DB_NAME = "noop_whoop.db" /** Room schema version — MUST equal the `@Database(version = …)` above. Surfaced in the backup * manifest (#1410) so an export states its schema. Bump both together on a migration. */ - const val SCHEMA_VERSION = 33 + const val SCHEMA_VERSION = 34 @Volatile private var instance: WhoopDatabase? = null @@ -899,6 +900,21 @@ abstract class WhoopDatabase : RoomDatabase() { } } + internal val IMU_CHUNK_MIGRATION_SQL: List = listOf( + "CREATE TABLE IF NOT EXISTS `imuChunk` (`id` TEXT NOT NULL, `deviceId` TEXT NOT NULL, " + + "`startTs` INTEGER NOT NULL, `endTs` INTEGER NOT NULL, `sampleCount` INTEGER NOT NULL, " + + "`sampleRate` INTEGER NOT NULL, `formatVersion` INTEGER NOT NULL, `codec` TEXT NOT NULL, " + + "`relativePath` TEXT NOT NULL, `byteSize` INTEGER NOT NULL, `sha256` TEXT NOT NULL, " + + "`createdAt` INTEGER NOT NULL, `pinnedUntil` INTEGER, PRIMARY KEY(`id`))", + "CREATE INDEX IF NOT EXISTS `index_imuChunk_deviceId_startTs_endTs` ON `imuChunk` (`deviceId`, `startTs`, `endTs`)", + ) + + internal val MIGRATION_33_34 = object : Migration(33, 34) { + override fun migrate(db: SupportSQLiteDatabase) { + for (stmt in IMU_CHUNK_MIGRATION_SQL) db.execSQL(stmt) + } + } + /** * Every migration the builder registers, as a VALUE rather than an argument list. * @@ -924,7 +940,7 @@ abstract class WhoopDatabase : RoomDatabase() { MIGRATION_18_19, MIGRATION_19_20, MIGRATION_20_21, MIGRATION_21_22, MIGRATION_22_23, MIGRATION_23_24, MIGRATION_24_25, MIGRATION_25_26, MIGRATION_26_27, MIGRATION_27_28, MIGRATION_28_29, MIGRATION_29_30, - MIGRATION_30_31, MIGRATION_31_32, MIGRATION_32_33, + MIGRATION_30_31, MIGRATION_31_32, MIGRATION_32_33, MIGRATION_33_34, ) diff --git a/android/app/src/main/java/com/noop/data/WhoopRepository.kt b/android/app/src/main/java/com/noop/data/WhoopRepository.kt index b80e3b4c16..ba989deb21 100644 --- a/android/app/src/main/java/com/noop/data/WhoopRepository.kt +++ b/android/app/src/main/java/com/noop/data/WhoopRepository.kt @@ -1021,6 +1021,12 @@ class WhoopRepository( dao.rawImuSamples(deviceId, from, to, limit) .map { it.ts to StreamPersistence.unpackImuColumns(it.samples) } + suspend fun upsertImuChunk(row: ImuChunkEntity) = dao.upsertImuChunk(row) + suspend fun imuChunks(deviceId: String, from: Long, to: Long): List = + dao.imuChunks(deviceId, from, to) + suspend fun expiredImuChunks(cutoff: Long): List = dao.expiredImuChunks(cutoff) + suspend fun deleteImuChunk(id: String) = dao.deleteImuChunk(id) + /** Downsampled HR (mean bpm per [bucketSeconds]) for the strap, for the Today 24h trend chart. */ suspend fun hrBuckets(deviceId: String, from: Long, to: Long, bucketSeconds: Long = 300L) = dao.hrBuckets(deviceId, from, to, bucketSeconds) @@ -1964,7 +1970,8 @@ class WhoopRepository( /** #423: rolling retention for the raw-IMU capture table (1 row/strap-second, ~1.2 KB each). One * hour ≈ 3600 rows ≈ 4 MB caps the table hard, so an enabled capture can never balloon the DB * during a multi-day offload replay. Instrument-first bounded window; nothing consumes it yet. */ - const val RAW_IMU_RETENTION_ROWS = 3600 + /** 36 h rolling cache: enough for retrospective activity/workout windows without permanent growth. */ + const val RAW_IMU_RETENTION_ROWS = 36 * 60 * 60 /** * v31: rolling retention for the v18 aux-slot table (Swift twin `WhoopStore.v18AuxRetentionRows`). diff --git a/android/app/src/main/java/com/noop/testcentre/GroundTruthCollector.kt b/android/app/src/main/java/com/noop/testcentre/GroundTruthCollector.kt index 884b30d8d6..f5824b8168 100644 --- a/android/app/src/main/java/com/noop/testcentre/GroundTruthCollector.kt +++ b/android/app/src/main/java/com/noop/testcentre/GroundTruthCollector.kt @@ -46,6 +46,8 @@ class GroundTruthCollector private constructor(private val context: Context) { val deviceId: String?, val startedAtMs: Long, val endedAtMs: Long?, + val capturedStartedAtMs: Long?, + val capturedEndedAtMs: Long?, val steps: Int, val stairs: Int, val excludedWindows: Int, @@ -92,6 +94,7 @@ class GroundTruthCollector private constructor(private val context: Context) { .putString(sessionDeviceKey(id), deviceId) .putLong(sessionStartKey(id), nowMs) .putBoolean(sessionExportedKey(id), false) + .putBoolean(sessionCaptureKey(id), true) .apply() append(id, event(nowMs, "start", 0, 0, noopSteps, null).put("strap_device_id", deviceId)) return snapshot() @@ -132,13 +135,19 @@ class GroundTruthCollector private constructor(private val context: Context) { val first = rows.firstOrNull() ?: return@mapNotNull null val last = rows.last() val isCurrent = current.sessionId == id + val physicalStart = prefs.getLong(sessionStartKey(id), first.optLong("at_ms")) + val capturedEnd = rows.lastOrNull { it.optString("kind") == "stop" }?.optLong("at_ms") + val hasCapture = prefs.getBoolean(sessionCaptureKey(id), + File(directory, "realtime-imu-$id.bin").isFile) SessionSummary( id = id, deviceId = prefs.getString(sessionDeviceKey(id), null) ?: first.optString("strap_device_id").takeIf(String::isNotBlank) ?: current.deviceId.takeIf { isCurrent }, - startedAtMs = prefs.getLong(sessionStartKey(id), first.optLong("at_ms")), - endedAtMs = rows.lastOrNull { it.optString("kind") == "stop" }?.optLong("at_ms"), + startedAtMs = prefs.getLong(sessionUsedStartKey(id), physicalStart), + endedAtMs = capturedEnd?.let { prefs.getLong(sessionUsedEndKey(id), it) }, + capturedStartedAtMs = physicalStart.takeIf { hasCapture }, + capturedEndedAtMs = capturedEnd.takeIf { hasCapture }, steps = last.optInt("steps_total"), stairs = last.optInt("stairs_total"), excludedWindows = rows.count { it.optString("kind") == KIND_EXCLUDE }, @@ -172,6 +181,9 @@ class GroundTruthCollector private constructor(private val context: Context) { .remove(sessionEndKey(sessionId)) .remove(sessionCommentKey(sessionId)) .remove(sessionExportedKey(sessionId)) + .remove(sessionUsedStartKey(sessionId)) + .remove(sessionUsedEndKey(sessionId)) + .remove(sessionCaptureKey(sessionId)) .apply { if (current.sessionId == sessionId) clearCurrentSession() } @@ -179,6 +191,28 @@ class GroundTruthCollector private constructor(private val context: Context) { return filesDeleted } + /** Trim the analysis/export interval without rewriting the physical capture provenance. */ + @Synchronized + fun setSessionRange(sessionId: String, fromMs: Long, toMs: Long): Boolean { + val session = sessions().firstOrNull { it.id == sessionId } ?: return false + if (session.active || fromMs <= 0 || fromMs > toMs || toMs - fromMs > MAX_RANGE_MS) return false + prefs.edit().putLong(sessionUsedStartKey(sessionId), fromMs) + .putLong(sessionUsedEndKey(sessionId), toMs).apply() + return true + } + + @Synchronized + fun createHistoricalSession(deviceId: String, fromMs: Long, toMs: Long): SessionSummary? { + if (fromMs <= 0 || fromMs > toMs || toMs - fromMs > MAX_RANGE_MS) return null + val id = System.currentTimeMillis().toString() + prefs.edit().putString(sessionDeviceKey(id), deviceId).putLong(sessionStartKey(id), fromMs) + .putLong(sessionEndKey(id), toMs).putLong(sessionUsedStartKey(id), fromMs) + .putLong(sessionUsedEndKey(id), toMs).putBoolean(sessionCaptureKey(id), false).apply() + append(id, event(fromMs, "start", 0, 0, null, null).put("strap_device_id", deviceId)) + append(id, event(toMs, "stop", 0, 0, null, null)) + return sessions().firstOrNull { it.id == id } + } + /** Delete every completed capture. Active captures are deliberately retained. */ @Synchronized fun deleteAllSessions(): Int { @@ -261,6 +295,8 @@ class GroundTruthCollector private constructor(private val context: Context) { // interval; labels and exclusion windows are optional annotations, not a prerequisite for data. val sensorFrom = if (deviceId == null) 1L else summary.startedAtMs / 1_000L val sensorTo = if (deviceId == null) 0L else endMs / 1_000L + val pinnedChunks = if (deviceId == null) emptyList() else + ImuChunkStore(context, repo).pin(deviceId, sensorFrom, sensorTo) val outDir = File(context.cacheDir, "logs").apply { mkdirs() } val zip = File(outDir, "noop-ground-truth-$id.zip") ZipOutputStream(zip.outputStream().buffered()).use { out -> @@ -271,6 +307,14 @@ class GroundTruthCollector private constructor(private val context: Context) { put("session_id", id) if (deviceId != null) put("device_id", deviceId) put("sensor_export_available", deviceId != null) + put("imu_100hz_chunk_count", pinnedChunks.size) + put("imu_100hz_complete", covers(pinnedChunks.map { it.startTs to it.endTs }, sensorFrom, sensorTo)) + put("imu_100hz_coverage", org.json.JSONArray().apply { + pinnedChunks.forEach { chunk -> put(JSONObject().apply { + put("start_ts", chunk.startTs); put("end_ts", chunk.endTs) + put("sample_count", chunk.sampleCount); put("sha256", chunk.sha256) + }) } + }) put("started_at_ms", summary.startedAtMs) put("ended_at_ms", endMs) put("manual_steps", summary.steps) @@ -342,6 +386,13 @@ class GroundTruthCollector private constructor(private val context: Context) { realtimeImu.inputStream().use { it.copyTo(out) } out.closeEntry() } + for (chunk in pinnedChunks) { + val file = ImuChunkStore(context, repo).file(chunk) + if (!file.isFile) continue + out.putNextEntry(ZipEntry("imu/${chunk.id}.imuc")) + file.inputStream().use { it.copyTo(out) } + out.closeEntry() + } } prefs.edit().putBoolean(sessionExportedKey(id), true).apply() if (id == snap.sessionId) prefs.edit().putBoolean("exported", true).apply() @@ -358,6 +409,16 @@ class GroundTruthCollector private constructor(private val context: Context) { }, "Export raw-data session").addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) } + private fun covers(intervals: List>, from: Long, to: Long): Boolean { + var cursor = from + for ((start, end) in intervals.sortedBy { it.first }) { + if (start > cursor) return false + if (end >= cursor) cursor = end + 1 + if (cursor > to) return true + } + return cursor > to + } + private fun eventFile(id: String) = File(directory, "session-$id.jsonl") private fun append(id: String, value: JSONObject) = eventFile(id).appendText(value.toString() + "\n") @@ -541,6 +602,9 @@ class GroundTruthCollector private constructor(private val context: Context) { private fun sessionEndKey(id: String) = "session.$id.endedAtMs" private fun sessionCommentKey(id: String) = "session.$id.comment" private fun sessionExportedKey(id: String) = "session.$id.exported" + private fun sessionUsedStartKey(id: String) = "session.$id.usedStartedAtMs" + private fun sessionUsedEndKey(id: String) = "session.$id.usedEndedAtMs" + private fun sessionCaptureKey(id: String) = "session.$id.hasRealtimeCapture" private fun ZipOutputStream.writerEntry(text: String) { write(text.toByteArray(Charsets.UTF_8)); closeEntry() @@ -559,6 +623,7 @@ class GroundTruthCollector private constructor(private val context: Context) { private const val KIND_UNDO_STAIR = "undo_stair" private const val KIND_EXCLUDE = "exclude_window" private const val PREFS = "noop_ground_truth_collector" + private const val MAX_RANGE_MS = 7L * 24 * 60 * 60 * 1_000 fun from(context: Context) = GroundTruthCollector(context.applicationContext) } diff --git a/android/app/src/main/java/com/noop/testcentre/ImuChunkStore.kt b/android/app/src/main/java/com/noop/testcentre/ImuChunkStore.kt new file mode 100644 index 0000000000..bd12e8482a --- /dev/null +++ b/android/app/src/main/java/com/noop/testcentre/ImuChunkStore.kt @@ -0,0 +1,93 @@ +package com.noop.testcentre + +import android.content.Context +import com.noop.data.ImuChunkEntity +import com.noop.data.StreamPersistence +import com.noop.data.WhoopRepository +import java.io.BufferedOutputStream +import java.io.DataOutputStream +import java.io.File +import java.io.FileOutputStream +import java.security.MessageDigest +import java.util.UUID +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream +import org.json.JSONObject + +/** Materializes immutable, portable IMU archives from the bounded SQLite rolling cache. */ +class ImuChunkStore(private val context: Context, private val repository: WhoopRepository) { + private val directory = File(context.filesDir, "imu-chunks").apply { mkdirs() } + + suspend fun pin(deviceId: String, from: Long, to: Long): List { + require(from <= to) + val existing = repository.imuChunks(deviceId, from, to) + .filter { it.pinnedUntil == Long.MAX_VALUE && File(context.filesDir, it.relativePath).isFile } + if (existing.any { it.startTs <= from && it.endTs >= to }) return existing + val limit = (to - from + 1).coerceIn(1, MAX_EXPORT_SECONDS).toInt() + val rows = repository.rawImuSamples(deviceId, from, to, limit) + if (rows.isEmpty()) return existing + + val id = UUID.randomUUID().toString() + val final = File(directory, "$id.imuc") + val temp = File(directory, "$id.tmp") + ZipOutputStream(BufferedOutputStream(FileOutputStream(temp))).use { zip -> + zip.putNextEntry(ZipEntry("manifest.json")) + zip.write(JSONObject().apply { + put("format", "NOOPIMU") + put("version", FORMAT_VERSION) + put("sample_rate_hz", SAMPLE_RATE) + put("axes", listOf("ax", "ay", "az", "gx", "gy", "gz")) + put("layout", "column-major-int16-le") + put("row_count", rows.size) + put("start_ts", rows.first().first) + put("end_ts", rows.last().first) + }.toString(2).toByteArray()) + zip.closeEntry() + zip.putNextEntry(ZipEntry("samples.bin")) + DataOutputStream(zip).let { payload -> + for ((ts, columns) in rows) { + val packed = StreamPersistence.packImuColumns(columns) + payload.writeLong(ts) + payload.writeInt(packed.size) + payload.write(packed) + } + } + zip.closeEntry() + } + check(temp.renameTo(final)) { "Could not commit IMU chunk" } + val entity = ImuChunkEntity( + id = id, deviceId = deviceId, startTs = rows.first().first, endTs = rows.last().first, + sampleCount = rows.size * SAMPLE_RATE, sampleRate = SAMPLE_RATE, + formatVersion = FORMAT_VERSION, codec = "zip-deflate", + relativePath = final.relativeTo(context.filesDir).path, byteSize = final.length(), + sha256 = sha256(final), createdAt = System.currentTimeMillis() / 1_000L, + pinnedUntil = Long.MAX_VALUE, + ) + repository.upsertImuChunk(entity) + return coverage(deviceId, from, to) + } + + suspend fun coverage(deviceId: String, from: Long, to: Long): List = + repository.imuChunks(deviceId, from, to).filter { File(context.filesDir, it.relativePath).isFile } + + fun file(chunk: ImuChunkEntity) = File(context.filesDir, chunk.relativePath) + + private fun sha256(file: File): String { + val digest = MessageDigest.getInstance("SHA-256") + file.inputStream().buffered().use { input -> + val buffer = ByteArray(64 * 1024) + while (true) { + val count = input.read(buffer) + if (count < 0) break + digest.update(buffer, 0, count) + } + } + return digest.digest().joinToString("") { "%02x".format(it) } + } + + companion object { + const val FORMAT_VERSION = 1 + const val SAMPLE_RATE = 100 + const val MAX_EXPORT_SECONDS = 7 * 24 * 60 * 60L + } +} diff --git a/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt b/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt index fa113c7a10..616e4cdd36 100644 --- a/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt +++ b/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt @@ -1,6 +1,9 @@ package com.noop.ui import android.os.Build +import android.app.DatePickerDialog +import android.app.TimePickerDialog +import android.content.Context import android.view.HapticFeedbackConstants import android.view.InputDevice import android.view.KeyEvent @@ -234,6 +237,17 @@ fun GroundTruthCollectorScreen(vm: AppViewModel) { }, ) } + NoopButton( + text = stringResource(R.string.ground_truth_add_historical), + kind = NoopButtonKind.Secondary, + fullWidth = true, + enabled = !state.active && vm.activeStrapId.isNotBlank(), + onClick = { + val end = System.currentTimeMillis() + collector.createHistoricalSession(vm.activeStrapId, end - 60 * 60 * 1_000L, end) + sessions = collector.sessions() + }, + ) Text(stringResource(R.string.ground_truth_sessions), style = NoopType.title2, color = Palette.textPrimary) if (sessions.isEmpty()) { Text(stringResource(R.string.ground_truth_no_sessions), style = NoopType.body, color = Palette.textSecondary) @@ -271,6 +285,20 @@ fun GroundTruthCollectorScreen(vm: AppViewModel) { } }, onDelete = { sessionPendingDelete = session }, + onEditStart = { + pickDateTime(context, session.startedAtMs, EARLIEST_EXPORT_MS, + session.endedAtMs ?: session.capturedEndedAtMs ?: session.startedAtMs) { value -> + collector.setSessionRange(session.id, value, requireNotNull(session.endedAtMs)) + sessions = collector.sessions() + } + }, + onEditEnd = { + pickDateTime(context, requireNotNull(session.endedAtMs), session.startedAtMs, + System.currentTimeMillis()) { value -> + collector.setSessionRange(session.id, session.startedAtMs, value) + sessions = collector.sessions() + } + }, ) } } @@ -332,6 +360,8 @@ private fun SessionCard( onComment: (String) -> Unit, onExport: () -> Unit, onDelete: () -> Unit, + onEditStart: () -> Unit, + onEditEnd: () -> Unit, ) { val time = remember(session.startedAtMs, session.endedAtMs) { sessionTimeRange(session) } NoopCard { @@ -349,6 +379,22 @@ private fun SessionCard( color = if (captureReady) Palette.statusPositive else Palette.statusCritical, ) Text(time, style = NoopType.headline, color = Palette.textPrimary) + if (!session.active) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { + NoopButton( + text = stringResource(R.string.ground_truth_edit_start), + kind = NoopButtonKind.Secondary, + modifier = Modifier.weight(1f), + onClick = onEditStart, + ) + NoopButton( + text = stringResource(R.string.ground_truth_edit_end), + kind = NoopButtonKind.Secondary, + modifier = Modifier.weight(1f), + onClick = onEditEnd, + ) + } + } Text( stringResource( R.string.ground_truth_session_summary, @@ -400,6 +446,30 @@ private fun sessionTimeRange(session: GroundTruthCollector.SessionSummary): Stri } } +private fun pickDateTime( + context: Context, + initialMs: Long, + minimumMs: Long, + maximumMs: Long, + onPicked: (Long) -> Unit, +) { + val initial = java.util.Calendar.getInstance().apply { timeInMillis = initialMs } + val dateDialog = DatePickerDialog(context, { _, year, month, day -> + TimePickerDialog(context, { _, hour, minute -> + val picked = java.util.Calendar.getInstance().apply { + set(year, month, day, hour, minute, 0) + set(java.util.Calendar.MILLISECOND, 0) + }.timeInMillis.coerceIn(minimumMs, maximumMs) + onPicked(picked) + }, initial.get(java.util.Calendar.HOUR_OF_DAY), initial.get(java.util.Calendar.MINUTE), + android.text.format.DateFormat.is24HourFormat(context)).show() + }, initial.get(java.util.Calendar.YEAR), initial.get(java.util.Calendar.MONTH), + initial.get(java.util.Calendar.DAY_OF_MONTH)) + dateDialog.datePicker.minDate = minimumMs + dateDialog.datePicker.maxDate = maximumMs + dateDialog.show() +} + @Composable private fun CounterColumn(label: String, value: String, modifier: Modifier = Modifier) { Column(modifier = modifier.padding(horizontal = 4.dp), horizontalAlignment = Alignment.CenterHorizontally) { @@ -431,3 +501,5 @@ private fun diagnosticAge(epochMs: Long): String { else -> "${seconds / 3_600}h ${(seconds % 3_600) / 60}m" } } + +private const val EARLIEST_EXPORT_MS = 946_684_800_000L diff --git a/android/app/src/main/res/values-de/strings.xml b/android/app/src/main/res/values-de/strings.xml index ac271cb7e5..486dca0ae3 100644 --- a/android/app/src/main/res/values-de/strings.xml +++ b/android/app/src/main/res/values-de/strings.xml @@ -2481,6 +2481,7 @@ Bei markiertem Klick vibrieren Optionale Bestätigung; standardmäßig aus, um bei langen Sessions Akku zu sparen. Rohdaten-Session starten + Vergangenes Zeitfenster hinzufügen Letzten Klick rückgängig Session beenden Session exportieren @@ -2500,5 +2501,7 @@ Alle %1$d aufgezeichneten Sessions und ihre Rohdaten werden dauerhaft gelöscht. Löschen Abbrechen + Start ändern + Ende ändern Ältere Session: Ereignisse können exportiert werden, der zugehörige Strap für Sensordaten ist aber nicht mehr eindeutig. diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 6e78c0e0b1..356287064c 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -2493,6 +2493,7 @@ Vibrate on a counted press Optional confirmation; disabled by default to save battery during long sessions. Start raw-data session + Add past time window Undo last click Stop session Export session @@ -2512,5 +2513,7 @@ All %1$d recorded sessions and their captured raw data will be deleted permanently. Delete Cancel + Edit start + Edit end Legacy session: event export is available, but its strap cannot be identified for sensor export. diff --git a/android/app/src/test/java/com/noop/testcentre/RawDataCollectorParityTest.kt b/android/app/src/test/java/com/noop/testcentre/RawDataCollectorParityTest.kt new file mode 100644 index 0000000000..b5449ef323 --- /dev/null +++ b/android/app/src/test/java/com/noop/testcentre/RawDataCollectorParityTest.kt @@ -0,0 +1,49 @@ +package com.noop.testcentre + +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File + +/** Android half of the shared feature-level raw-data collector parity guard. */ +class RawDataCollectorParityTest { + private val oracleBytes: ByteArray + get() = requireNotNull(javaClass.classLoader!!.getResourceAsStream("raw_data_collector_parity.json")) + .use { it.readBytes() } + + private fun repoRoot(): File { + var cursor = File(System.getProperty("user.dir") ?: ".").canonicalFile + repeat(4) { + if (File(cursor, "android/app/src/main").isDirectory) return cursor + cursor = cursor.parentFile ?: cursor + } + error("repository root not found from ${System.getProperty("user.dir")}") + } + + @Test fun androidSurfaceStillImplementsEveryDeclaredCapability() { + val root = repoRoot() + val source = listOf( + "android/app/src/main/java/com/noop/testcentre/GroundTruthCollector.kt", + "android/app/src/main/java/com/noop/testcentre/ImuChunkStore.kt", + "android/app/src/main/java/com/noop/data/Entities.kt", + "android/app/src/main/java/com/noop/data/WhoopRepository.kt", + "android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt", + "android/app/src/main/java/com/noop/ble/WhoopBleClient.kt", + "android/app/src/main/res/values/strings.xml", + ).joinToString("\n") { File(root, it).readText() } + val capabilities = JSONObject(oracleBytes.toString(Charsets.UTF_8)).getJSONObject("capabilities") + for (name in capabilities.keys()) { + val markers = capabilities.getJSONObject(name).getJSONArray("kotlin") + for (index in 0 until markers.length()) { + val marker = markers.getString(index) + assertTrue("Android collector lost $name marker: $marker", source.contains(marker)) + } + } + } + + @Test fun androidAndAppleOracleCopiesAreByteIdentical() { + val apple = File(repoRoot(), "StrandTests/Resources/raw_data_collector_parity.json") + assertEquals(oracleBytes.toList(), apple.readBytes().toList()) + } +} diff --git a/android/app/src/test/resources/raw_data_collector_parity.json b/android/app/src/test/resources/raw_data_collector_parity.json new file mode 100644 index 0000000000..3129d3d7e9 --- /dev/null +++ b/android/app/src/test/resources/raw_data_collector_parity.json @@ -0,0 +1,73 @@ +{ + "schema_version": 1, + "capabilities": { + "start_stop": { + "swift": ["startGroundTruthRawCapture", "stopGroundTruthRawCapture"], + "kotlin": ["startGroundTruthImuCapture", "stopGroundTruthImuCapture"] + }, + "persisted_sessions": { + "swift": ["RawDataSessionStore", "persist(session)"], + "kotlin": ["GroundTruthCollector", "sessionStartKey"] + }, + "manual_step_and_stair_labels": { + "swift": ["store.record(.step)", "store.record(.stair)"], + "kotlin": ["KIND_STEP", "KIND_STAIR"] + }, + "undo_last_label": { + "swift": ["func undo(", "Undo last click"], + "kotlin": ["fun undo(", "ground_truth_undo"] + }, + "exclude_recent_period": { + "swift": ["excludeLast(minutes:", "Ignore recent period"], + "kotlin": ["excludeLastMinutes(", "ground_truth_exclude_recent"] + }, + "session_comments": { + "swift": ["setComment(", "Session comment"], + "kotlin": ["setComment(", "ground_truth_comment"] + }, + "from_to_time": { + "swift": ["func range(", "endedAtMs"], + "kotlin": ["sessionTimeRange(", "endedAtMs"] + }, + "session_export": { + "swift": ["exportEntries(for:", "Export session"], + "kotlin": ["suspend fun export(", "ground_truth_export"] + }, + "historical_time_windows": { + "swift": ["createHistorical(deviceId:", "Historical export window"], + "kotlin": ["createHistoricalSession(", "ground_truth_add_historical"] + }, + "editable_analysis_range": { + "swift": ["func setRange(sessionId:", "DatePicker(\"From\""], + "kotlin": ["fun setSessionRange(", "ground_truth_edit_start"] + }, + "bounded_36h_imu_cache": { + "swift": ["rawImuRetentionRows = 36 * 60 * 60"], + "kotlin": ["RAW_IMU_RETENTION_ROWS = 36 * 60 * 60"] + }, + "durable_imu_chunk_catalog": { + "swift": ["ImuChunkArchiveStore", "ImuChunkMeta"], + "kotlin": ["ImuChunkStore", "ImuChunkEntity"] + }, + "explicit_imu_coverage": { + "swift": ["imu-coverage.json", "private static func covers"], + "kotlin": ["imu_100hz_complete", "private fun covers"] + }, + "delete_one_and_all_with_confirmation": { + "swift": ["Delete this session?", "Delete all sessions?", "deleteGroundTruthRawBatches"], + "kotlin": ["ground_truth_delete_session_title", "ground_truth_delete_all_title", "deleteAllSessions"] + }, + "active_session_delete_guard": { + "swift": ["active != true", ".disabled(store.active != nil)"], + "kotlin": ["current.active && current.sessionId == sessionId", "sessions.none { it.active }"] + }, + "bluetooth_reconnect_rearm": { + "swift": ["onChangeCompat(of: live.bonded)", "rawCaptureInFlight = false"], + "kotlin": ["LaunchedEffect(state.active, state.sessionId, live.connected)", "startGroundTruthImuCapture"] + }, + "raw_data_removed_with_session": { + "swift": ["deleteRawBatches(deviceId:", "capturedAt >= ? AND capturedAt <= ?"], + "kotlin": ["realtime-imu-$sessionId.bin", "noop-ground-truth-$sessionId.zip"] + } + } +} diff --git a/android/app/src/test/resources/schema_oracle.json b/android/app/src/test/resources/schema_oracle.json index 4a19cda30f..bef71e5b5b 100644 --- a/android/app/src/test/resources/schema_oracle.json +++ b/android/app/src/test/resources/schema_oracle.json @@ -1,6 +1,6 @@ { "_readme": "SHARED Room<->GRDB SCHEMA ORACLE (#775). Two byte-identical copies: Packages/WhoopStore/Tests/WhoopStoreTests/Resources/schema_oracle.json and android/app/src/test/resources/schema_oracle.json. SchemaOracleTests.swift compares GRDB's PRAGMA table_info/index_list against it; SchemaOracleTest.kt compares Room's exported schema JSON against it. `columns` is the iOS/GRDB shape in GRDB column order (macOS is the reference implementation); every way Android differs is spelled out in an `android` / `iosAbsent` / `androidColumnOrder` override naming a key in `divergenceReasons`. Adding a column, reordering one, or changing a type/nullability on one platform only fails both suites until it is either fixed or written down here.", - "roomVersion": 33, + "roomVersion": 34, "grdbMigrations": [ "v1", "v2", @@ -40,7 +40,8 @@ "v36-whoop-caps-no-spo2", "v37-daily-avg-sdnn", "v38-apple-step-hour", - "v39-ppg-burst-index" + "v39-ppg-burst-index", + "v40-imu-chunk-catalog" ], "divergenceReasons": { "android-orphan-synced-column": "REAL COLUMN DRIFT: `synced` exists on Android only. GRDB's v10 note is explicit that stepSample gets no `synced` column ('unused; see StreamStore'), and v12's ppgHrSample never had one either; the Room entities copied the flag from the older per-second entities. Nothing reads it on Android \u2014 the per-row upload flag belongs to an upload path that does not exist there \u2014 so it is dead width, not divergent data. Removing it needs a Room table rebuild.", @@ -1252,6 +1253,29 @@ ], "indices": [] }, + "imuChunk": { + "platform": "both", + "columns": [ + { "name": "id", "affinity": "TEXT", "notNull": false, "default": null, + "android": { "notNull": true, "divergence": "sqlite-text-pk-nullable" } }, + { "name": "deviceId", "affinity": "TEXT", "notNull": true, "default": null }, + { "name": "startTs", "affinity": "INTEGER", "notNull": true, "default": null }, + { "name": "endTs", "affinity": "INTEGER", "notNull": true, "default": null }, + { "name": "sampleCount", "affinity": "INTEGER", "notNull": true, "default": null }, + { "name": "sampleRate", "affinity": "INTEGER", "notNull": true, "default": null }, + { "name": "formatVersion", "affinity": "INTEGER", "notNull": true, "default": null }, + { "name": "codec", "affinity": "TEXT", "notNull": true, "default": null }, + { "name": "relativePath", "affinity": "TEXT", "notNull": true, "default": null }, + { "name": "byteSize", "affinity": "INTEGER", "notNull": true, "default": null }, + { "name": "sha256", "affinity": "TEXT", "notNull": true, "default": null }, + { "name": "createdAt", "affinity": "INTEGER", "notNull": true, "default": null }, + { "name": "pinnedUntil", "affinity": "INTEGER", "notNull": false, "default": null } + ], + "primaryKey": ["id"], + "indices": [ + { "name": "index_imuChunk_deviceId_startTs_endTs", "columns": ["deviceId", "startTs", "endTs"], "unique": false } + ] + }, "rawImuSample": { "platform": "both", "columns": [ From 3ecaeecd9d915ae4feceefd5e1874f4cf42af9aa Mon Sep 17 00:00:00 2001 From: Bernd Helm Date: Wed, 26 Aug 2026 21:24:35 +0200 Subject: [PATCH 17/45] Disambiguate Apple history export formatting --- Strand/Collect/Collector.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Strand/Collect/Collector.swift b/Strand/Collect/Collector.swift index 0fee2e2128..49f101c4e1 100644 --- a/Strand/Collect/Collector.swift +++ b/Strand/Collect/Collector.swift @@ -122,12 +122,12 @@ final class Collector { async let gravity = try? store.gravitySamples(deviceId: deviceId, from: from, to: to, limit: limit) var lines = ["stream,unix_s,v1,v2,v3,v4"] lines += await (hr ?? []).map { "heart_rate,\($0.ts),\($0.bpm),,," } - lines += await (battery ?? []).map { "battery,\($0.ts),\($0.soc.map(String.init) ?? ""),\($0.mv.map(String.init) ?? ""),," } + lines += await (battery ?? []).map { "battery,\($0.ts),\($0.soc.map { String($0) } ?? ""),\($0.mv.map { String($0) } ?? ""),," } lines += await (spo2 ?? []).map { "spo2_raw,\($0.ts),\($0.red),\($0.ir),," } - lines += await (temp ?? []).map { "skin_temp_raw,\($0.ts),\($0.raw),\($0.aux1Raw.map(String.init) ?? ""),\($0.aux2Raw.map(String.init) ?? "")," } - lines += await (steps ?? []).map { "steps,\($0.ts),\($0.counter),\($0.activityClass.map(String.init) ?? ""),," } + lines += await (temp ?? []).map { "skin_temp_raw,\($0.ts),\($0.raw),\($0.aux1Raw.map { String($0) } ?? ""),\($0.aux2Raw.map { String($0) } ?? "")," } + lines += await (steps ?? []).map { "steps,\($0.ts),\($0.counter),\($0.activityClass.map { String($0) } ?? ""),," } lines += await (resp ?? []).map { "resp_raw,\($0.ts),\($0.raw),,," } - lines += await (gravity ?? []).map { "gravity,\($0.ts),\($0.x),\($0.y),\($0.z),\($0.dynAccel.map(String.init) ?? "")" } + lines += await (gravity ?? []).map { "gravity,\($0.ts),\($0.x),\($0.y),\($0.z),\($0.dynAccel.map { String($0) } ?? "")" } return Data((lines.joined(separator: "\n") + "\n").utf8) } From c8e6c8de5ff326ead02e23d190b3fa2490b37389 Mon Sep 17 00:00:00 2001 From: Bernd Helm Date: Wed, 26 Aug 2026 21:35:36 +0200 Subject: [PATCH 18/45] Fix Apple parity test error propagation --- StrandTests/RawDataCollectorParityTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/StrandTests/RawDataCollectorParityTests.swift b/StrandTests/RawDataCollectorParityTests.swift index 848251d072..23fcd7785f 100644 --- a/StrandTests/RawDataCollectorParityTests.swift +++ b/StrandTests/RawDataCollectorParityTests.swift @@ -43,7 +43,7 @@ final class RawDataCollectorParityTests: XCTestCase { func testAndroidAndAppleOracleCopiesAreByteIdentical() throws { let android = repoRoot.appendingPathComponent("android/app/src/test/resources/raw_data_collector_parity.json") - XCTAssertEqual(oracleData(), try Data(contentsOf: android), + XCTAssertEqual(try oracleData(), try Data(contentsOf: android), "Raw-data collector parity oracle copies must change together") } } From 8126ccafd7ce3d10ccc71817b1ef87c20d4d9f4b Mon Sep 17 00:00:00 2001 From: Bernd Helm Date: Wed, 26 Aug 2026 22:43:13 +0200 Subject: [PATCH 19/45] Store session IMU in compressed append-only files --- .../NoopLocalAccessCore/LocalAccessCore.swift | 2 +- .../Sources/WhoopStore/Database.swift | 4 + .../WhoopStore/DeviceRegistryStore.swift | 1 - .../WhoopStore/Sources/WhoopStore/Reads.swift | 2 +- .../Sources/WhoopStore/StreamStore.swift | 39 ------- .../Sources/WhoopStore/TimestampHeal.swift | 2 +- .../DeepCaptureChannelsTests.swift | 1 - .../Tests/WhoopStoreTests/ReadTests.swift | 9 +- .../Resources/schema_oracle.json | 28 ----- Strand/BLE/BLEManager.swift | 14 +-- Strand/Collect/Collector.swift | 39 +++---- Strand/Collect/ImuChunkArchiveStore.swift | 50 ++++++++- Strand/Collect/ImuSessionFileStore.swift | 106 ++++++++++++++++++ Strand/Collect/RawDataSessionStore.swift | 14 +++ Strand/Screens/RawDataCollectorView.swift | 29 +++-- StrandTests/RawDataCollectorParityTests.swift | 2 + .../Resources/raw_data_collector_parity.json | 12 +- .../main/java/com/noop/ble/WhoopBleClient.kt | 65 ++++------- .../main/java/com/noop/data/DeviceRegistry.kt | 2 - .../java/com/noop/data/DeviceRegistryDao.kt | 2 - .../src/main/java/com/noop/data/Entities.kt | 36 ------ .../src/main/java/com/noop/data/WhoopDao.kt | 23 ---- .../main/java/com/noop/data/WhoopDatabase.kt | 11 +- .../java/com/noop/data/WhoopRepository.kt | 28 +---- .../noop/testcentre/GroundTruthCollector.kt | 66 ++++------- .../java/com/noop/testcentre/ImuChunkStore.kt | 49 ++++++-- .../noop/testcentre/ImuSessionFileStore.kt | 83 ++++++++++++++ .../com/noop/ui/GroundTruthCollectorScreen.kt | 3 + .../analytics/RegistryDayOwnerSourceTest.kt | 2 - .../noop/ble/SourceCoordinatorAdoptionTest.kt | 2 - .../com/noop/data/DeepCaptureMigrationTest.kt | 1 - .../java/com/noop/data/DeviceRegistryTest.kt | 4 +- .../java/com/noop/data/RawImuMigrationTest.kt | 46 +------- .../testcentre/RawDataCollectorParityTest.kt | 2 + .../resources/raw_data_collector_parity.json | 12 +- .../app/src/test/resources/schema_oracle.json | 28 ----- 36 files changed, 418 insertions(+), 401 deletions(-) create mode 100644 Strand/Collect/ImuSessionFileStore.swift create mode 100644 android/app/src/main/java/com/noop/testcentre/ImuSessionFileStore.kt diff --git a/Packages/NoopLocalAccess/Sources/NoopLocalAccessCore/LocalAccessCore.swift b/Packages/NoopLocalAccess/Sources/NoopLocalAccessCore/LocalAccessCore.swift index 632e181a28..2e08b9543f 100644 --- a/Packages/NoopLocalAccess/Sources/NoopLocalAccessCore/LocalAccessCore.swift +++ b/Packages/NoopLocalAccess/Sources/NoopLocalAccessCore/LocalAccessCore.swift @@ -332,7 +332,7 @@ public final class ReadonlyNoopStore { let decodedTables = [ "hrSample", "rrInterval", "event", "battery", "spo2Sample", "skinTempSample", "respSample", "gravitySample", "ppgHrSample", "stepSample", - "sleepStateSample", "ppgWaveformSample", "rawImuSample", "v18AuxSample", + "sleepStateSample", "ppgWaveformSample", "v18AuxSample", ] var decodedRows = 0 for table in decodedTables where tableNames.contains(table) { diff --git a/Packages/WhoopStore/Sources/WhoopStore/Database.swift b/Packages/WhoopStore/Sources/WhoopStore/Database.swift index f2cf80f986..502a445e82 100644 --- a/Packages/WhoopStore/Sources/WhoopStore/Database.swift +++ b/Packages/WhoopStore/Sources/WhoopStore/Database.swift @@ -895,6 +895,10 @@ extension WhoopStore { try db.create(index: "index_imuChunk_deviceId_startTs_endTs", on: "imuChunk", columns: ["deviceId", "startTs", "endTs"]) } + // IMU session payloads are append-only compressed files; SQLite stores only their catalog. + migrator.registerMigration("v41-drop-raw-imu-sample") { db in + try db.drop(table: "rawImuSample") + } return migrator } } diff --git a/Packages/WhoopStore/Sources/WhoopStore/DeviceRegistryStore.swift b/Packages/WhoopStore/Sources/WhoopStore/DeviceRegistryStore.swift index 7788dc3642..0793863242 100644 --- a/Packages/WhoopStore/Sources/WhoopStore/DeviceRegistryStore.swift +++ b/Packages/WhoopStore/Sources/WhoopStore/DeviceRegistryStore.swift @@ -134,7 +134,6 @@ public struct DeviceRegistryStore: Sendable { "ppgWaveformSample", // v28-raw-imu (#423): the opt-in 5/MG raw-IMU offload capture is deviceId-keyed too — "delete all // of this device's data" must clear it, or the raw inertial samples survive deletion (same defect). - "rawImuSample", // v31-deep-capture-channels: the banked 5/MG v18 auxiliary fields are deviceId-keyed per-second // rows like every stream above, so a "delete all of this device's data" must clear them too. "v18AuxSample", diff --git a/Packages/WhoopStore/Sources/WhoopStore/Reads.swift b/Packages/WhoopStore/Sources/WhoopStore/Reads.swift index 73a3971387..99917aea30 100644 --- a/Packages/WhoopStore/Sources/WhoopStore/Reads.swift +++ b/Packages/WhoopStore/Sources/WhoopStore/Reads.swift @@ -397,7 +397,7 @@ extension WhoopStore { let rawTables = ["hrSample", "rrInterval", "event", "battery", "spo2Sample", "skinTempSample", "respSample", "gravitySample", "stepSample", "ppgHrSample", "sleepStateSample", "ppgWaveformSample", - "rawImuSample", "v18AuxSample"] + "v18AuxSample"] var decoded = 0 for t in rawTables { decoded += try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM \(t)") ?? 0 diff --git a/Packages/WhoopStore/Sources/WhoopStore/StreamStore.swift b/Packages/WhoopStore/Sources/WhoopStore/StreamStore.swift index 9c6e2ae4ab..f83a1d50e3 100644 --- a/Packages/WhoopStore/Sources/WhoopStore/StreamStore.swift +++ b/Packages/WhoopStore/Sources/WhoopStore/StreamStore.swift @@ -58,10 +58,6 @@ extension WhoopStore { return out } - /// #423 rolling retention for the raw-IMU capture table (twin of Kotlin `RAW_IMU_RETENTION_ROWS`): - /// 36 h at 1 row/strap-second (~155 MB uncompressed): retrospective activity/workout window. - public static let rawImuRetentionRows = 36 * 60 * 60 - /// v31 rolling retention for the v18 aux-slot table (twin of Kotlin `V18_AUX_RETENTION_ROWS`). /// /// `rawImuSample` is the closest precedent — raw instrumentation banked as a blob, capped rather than @@ -102,41 +98,6 @@ extension WhoopStore { } } - /// #423: persist decoded 5/MG raw-IMU offload buffers (one row per strap-second, packed i16 BLOB), - /// then bound the table to the newest `retentionRows` for the device (rolling retention). Written from - /// the deep-buffer capture seam, not the normal stream path, so it inserts directly (idempotent by ts). - /// Twin of Kotlin `WhoopRepository.insertRawImu`. - public func insertRawImu(deviceId: String, rows: [(ts: Int, cols: [Int16])], retentionRows: Int) async throws { - guard !rows.isEmpty else { return } - try syncWrite { db in - let ins = try db.cachedStatement(sql: """ - INSERT INTO rawImuSample (deviceId, ts, samples) VALUES (?, ?, ?) - ON CONFLICT(deviceId, ts) DO NOTHING - """) - // Pack the raw i16 columns to the LE BLOB HERE (packImuColumns is module-internal), so the - // caller passes plain [Int16] and never needs the packer. Mirrors how `insert` packs ppgWaveform. - for r in rows { try ins.execute(arguments: [deviceId, r.ts, WhoopStore.packImuColumns(r.cols)]) } - try db.execute(sql: """ - DELETE FROM rawImuSample WHERE deviceId = ? AND ts < ( - SELECT MIN(ts) FROM (SELECT ts FROM rawImuSample WHERE deviceId = ? ORDER BY ts DESC LIMIT ?)) - """, arguments: [deviceId, deviceId, retentionRows]) - } - } - - public func rawImuSamples(deviceId: String, from: Int, to: Int, limit: Int) async throws - -> [(ts: Int, cols: [Int16])] { - try syncRead { db in - try Row.fetchAll(db, sql: """ - SELECT ts, samples FROM rawImuSample - WHERE deviceId = ? AND ts >= ? AND ts <= ? ORDER BY ts LIMIT ? - """, arguments: [deviceId, from, to, limit]).map { row in - let ts: Int = row["ts"] - let data: Data = row["samples"] - return (ts, WhoopStore.unpackImuColumns(data)) - } - } - } - /// Idempotent upsert of decoded streams by natural key. Returns the number of rows /// ACTUALLY inserted per stream (0 for rows that already existed). /// diff --git a/Packages/WhoopStore/Sources/WhoopStore/TimestampHeal.swift b/Packages/WhoopStore/Sources/WhoopStore/TimestampHeal.swift index 8093834237..128e9c5401 100644 --- a/Packages/WhoopStore/Sources/WhoopStore/TimestampHeal.swift +++ b/Packages/WhoopStore/Sources/WhoopStore/TimestampHeal.swift @@ -55,7 +55,7 @@ extension WhoopStore { let rawTables = ["hrSample", "rrInterval", "event", "battery", "spo2Sample", "skinTempSample", "respSample", "gravitySample", "stepSample", "ppgHrSample", - "sleepStateSample", "ppgWaveformSample", "rawImuSample", "v18AuxSample"] + "sleepStateSample", "ppgWaveformSample", "v18AuxSample"] var rawDeleted = 0 for table in rawTables { try db.execute(sql: "DELETE FROM \(table) WHERE ts < ? OR ts > ?", diff --git a/Packages/WhoopStore/Tests/WhoopStoreTests/DeepCaptureChannelsTests.swift b/Packages/WhoopStore/Tests/WhoopStoreTests/DeepCaptureChannelsTests.swift index 2e5d4a9c6b..4d7f0794b6 100644 --- a/Packages/WhoopStore/Tests/WhoopStoreTests/DeepCaptureChannelsTests.swift +++ b/Packages/WhoopStore/Tests/WhoopStoreTests/DeepCaptureChannelsTests.swift @@ -347,7 +347,6 @@ final class DeepCaptureChannelsTests: XCTestCase { /// The shipped cap is a real bound, and documented in row terms. func testShippedRetentionConstant() { XCTAssertEqual(WhoopStore.v18AuxRetentionRows, 604_800) // 7 x 86_400 strap-seconds - XCTAssertGreaterThan(WhoopStore.v18AuxRetentionRows, WhoopStore.rawImuRetentionRows) } /// Deleting a device's data must clear the aux rows too — the same privacy rule every other diff --git a/Packages/WhoopStore/Tests/WhoopStoreTests/ReadTests.swift b/Packages/WhoopStore/Tests/WhoopStoreTests/ReadTests.swift index 5737a694cd..2096439301 100644 --- a/Packages/WhoopStore/Tests/WhoopStoreTests/ReadTests.swift +++ b/Packages/WhoopStore/Tests/WhoopStoreTests/ReadTests.swift @@ -275,9 +275,6 @@ final class ReadTests: XCTestCase { ppgWaveform: [PpgWaveformSample(ts: 400, samples: [1, 2, 3])], v18Aux: [V18AuxSample(ts: 400, slotValues: [1, 2])]), deviceId: "dev1") - _ = try await store.insertRawImu(deviceId: "dev1", - rows: [(ts: 400, cols: [Int16(1), Int16(2)])], - retentionRows: 1000) try await store.enqueueRawBatch( RawBatchMeta(batchId: "b1", deviceId: "dev1", clockRef: ClockRef(device: 0, wall: 0), capturedAt: 1, @@ -285,9 +282,9 @@ final class ReadTests: XCTestCase { frames: [[0xAA, 0x00, 0x01, 0x02]]) let stats = try await store.storageStats() // dev1: 3 hr + 2 rr + 1 event + 1 battery + 1 spo2 + 1 skinTemp + 1 resp + 1 gravity - // + 1 step + 1 sleepState + 1 ppgHr + 1 ppgWaveform + 1 v18Aux + 1 rawImu = 17 - // other: 1 hr = 1 → 18 decoded rows across all 14 raw tables. - XCTAssertEqual(stats.decodedRows, 18) + // + 1 step + 1 sleepState + 1 ppgHr + 1 ppgWaveform + 1 v18Aux = 16 + // other: 1 hr = 1 → 17 decoded rows. + XCTAssertEqual(stats.decodedRows, 17) XCTAssertEqual(stats.rawBatches, 1) XCTAssertEqual(stats.rawBytes, 4) } diff --git a/Packages/WhoopStore/Tests/WhoopStoreTests/Resources/schema_oracle.json b/Packages/WhoopStore/Tests/WhoopStoreTests/Resources/schema_oracle.json index bef71e5b5b..02b401e3e4 100644 --- a/Packages/WhoopStore/Tests/WhoopStoreTests/Resources/schema_oracle.json +++ b/Packages/WhoopStore/Tests/WhoopStoreTests/Resources/schema_oracle.json @@ -1276,34 +1276,6 @@ { "name": "index_imuChunk_deviceId_startTs_endTs", "columns": ["deviceId", "startTs", "endTs"], "unique": false } ] }, - "rawImuSample": { - "platform": "both", - "columns": [ - { - "name": "deviceId", - "affinity": "TEXT", - "notNull": true, - "default": null - }, - { - "name": "ts", - "affinity": "INTEGER", - "notNull": true, - "default": null - }, - { - "name": "samples", - "affinity": "BLOB", - "notNull": true, - "default": null - } - ], - "primaryKey": [ - "deviceId", - "ts" - ], - "indices": [] - }, "respSample": { "platform": "both", "columns": [ diff --git a/Strand/BLE/BLEManager.swift b/Strand/BLE/BLEManager.swift index 232a334b09..988317a57a 100644 --- a/Strand/BLE/BLEManager.swift +++ b/Strand/BLE/BLEManager.swift @@ -1786,10 +1786,10 @@ public final class BLEManager: NSObject, ObservableObject { /// Start a manually stopped 5/MG raw-data session. Returns false when another capture is active. @discardableResult - public func startGroundTruthRawCapture() -> Bool { + public func startGroundTruthRawCapture(sessionId: String) -> Bool { guard !rawCaptureInFlight else { return false } rawCaptureInFlight = true - collector?.beginRawCapture(seconds: RawCaptureWindow.maxSeconds) + collector?.beginGroundTruthRawCapture(sessionId: sessionId) send(.startRawData, payload: [0x01], writeType: .withResponse) send(.toggleIMUMode, payload: selectedModel.deviceFamily == .whoop5 ? [0x01, 0x01] : [0x01], @@ -1811,6 +1811,10 @@ public final class BLEManager: NSObject, ObservableObject { log("Raw-data session: stopped + flushed") } + public func finishGroundTruthRawCapture(sessionId: String) { + collector?.finishGroundTruthRawCapture(sessionId: sessionId) + } + public func groundTruthRawBatches(from: Int, to: Int) async -> [(RawBatchMeta, [[UInt8]])] { await collector?.rawBatches(from: from, to: to) ?? [] } @@ -1825,10 +1829,6 @@ public final class BLEManager: NSObject, ObservableObject { await collector?.deleteRawBatches(from: from, to: to) ?? 0 } - public func groundTruthRawImuSamples(from: Int, to: Int) async -> [(ts: Int, cols: [Int16])] { - await collector?.rawImuSamples(from: from, to: to) ?? [] - } - public func groundTruthImuChunks(from: Int, to: Int) async -> [ImuChunkMeta] { await collector?.imuChunks(from: from, to: to) ?? [] } @@ -6016,8 +6016,6 @@ extension BLEManager: @preconcurrency CBPeripheralDelegate { // BEFORE the offload branch so it catches the burst; no-op unless capture is on. puffinDeepBufferLog.appendIfDeepBuffer(frame: frame, char: characteristic.uuid, isOffload: isOffload) // #423: the queryable twin of that diagnostics line — persist the decoded 100 Hz 6-axis - // IMU samples into the rawImuSample table when raw capture is on (same gate, gated inside). - collector?.storeRawImu(frame: frame) if isOffload { // Same policy as WHOOP4: historical offload frames are bulk sync traffic. // Keep them out of the live UI parser during backfill and let Backfiller diff --git a/Strand/Collect/Collector.swift b/Strand/Collect/Collector.swift index 49f101c4e1..cdce01318d 100644 --- a/Strand/Collect/Collector.swift +++ b/Strand/Collect/Collector.swift @@ -12,12 +12,6 @@ protocol StoreWriting: AnyObject { -> (hr: Int, rr: Int, events: Int, battery: Int, spo2: Int, skinTemp: Int, resp: Int, gravity: Int) func enqueueRawBatch(_ meta: RawBatchMeta, frames: [[UInt8]]) async throws - func insertRawImu(deviceId: String, rows: [(ts: Int, cols: [Int16])], retentionRows: Int) async throws -} -extension StoreWriting { - /// #423: default no-op so a test SpyStore needn't implement the raw-IMU capture path. WhoopStore's - /// real impl (StreamStore.swift) satisfies the requirement and is used in production. - func insertRawImu(deviceId: String, rows: [(ts: Int, cols: [Int16])], retentionRows: Int) async throws {} } extension WhoopStore: StoreWriting {} @@ -69,6 +63,7 @@ final class Collector { /// activity sample" action can persist raw even when `enableRawCapture` is off. The window's /// monotonic deadline auto-expires so a missed stop callback can't leak raw forever. private var rawCapture = RawCaptureWindow() + private var groundTruthSessionId: String? /// #47: buffer the (raw frame, pre-parsed) pair. The raw bytes are still needed for the raw-capture /// outbox; the parse is the one the BLE seam already did, so `flush` doesn't re-decode the batch. private var buffer: [(frame: [UInt8], parsed: ParsedFrame)] = [] @@ -137,12 +132,6 @@ final class Collector { return (try? await store.deleteRawBatches(deviceId: deviceId, from: from, to: to)) ?? 0 } - func rawImuSamples(from: Int, to: Int) async -> [(ts: Int, cols: [Int16])] { - guard let store = concreteStore else { return [] } - let limit = min(max(to - from + 1, 1), 7 * 24 * 60 * 60) - return (try? await store.rawImuSamples(deviceId: deviceId, from: from, to: to, limit: limit)) ?? [] - } - func imuChunks(from: Int, to: Int) async -> [ImuChunkMeta] { guard let store = concreteStore else { return [] } return (try? await store.imuChunks(deviceId: deviceId, from: from, to: to)) ?? [] @@ -188,16 +177,6 @@ final class Collector { /// 1244-B 6-axis buffer decodes (rawColumns nil otherwise). Fire-and-forget into the store, bounded by /// a rolling retention prune. Raw i16, no downstream consumer yet. Twin of Android /// `WhoopBleClient.storeWhoop5RawImuIfBuffer`. - func storeRawImu(frame: [UInt8]) { - guard UserDefaults.standard.bool(forKey: PuffinFrameRecorder.enabledKey) else { return } - guard let cols = Whoop5RawImu.rawColumns(frame), let baseTs = Whoop5RawImu.baseTs(frame) else { return } - let dev = deviceId - Task { [store] in - try? await store.insertRawImu( - deviceId: dev, rows: [(ts: baseTs, cols: cols)], retentionRows: WhoopStore.rawImuRetentionRows) - } - } - /// Buffer one complete frame + its pre-parsed decode (synchronous: preserves delegate arrival order). /// Auto-flushes via a detached Task when the cadence threshold is hit (flush is async). (#47) func ingest(frame: [UInt8], parsed: ParsedFrame) { @@ -205,6 +184,7 @@ final class Collector { assert(parsed == parseFrame(frame, family: family), "Collector.ingest: threaded ParsedFrame != fresh parse (#47 parse-once invariant)") #endif + recordGroundTruthImu(frame) buffer.append((frame, parsed)) // Pre-clock only: bound memory if GET_CLOCK never lands while data keeps flowing. // Drop OLDEST beyond the cap (keep most recent). Post-clock this branch is skipped — @@ -288,6 +268,21 @@ final class Collector { rawCapture.open(at: monotonic(), duration: seconds) } + func beginGroundTruthRawCapture(sessionId: String) { + rawCapture.open(at: monotonic(), duration: RawCaptureWindow.maxSeconds) + groundTruthSessionId = sessionId + } + + func finishGroundTruthRawCapture(sessionId: String) { + guard groundTruthSessionId == sessionId else { return } + groundTruthSessionId = nil + } + + private func recordGroundTruthImu(_ frame: [UInt8]) { + _ = ImuSessionFileStore.shared.append(deviceId: deviceId, frame: frame, + receivedAtMs: Int64(Date().timeIntervalSince1970 * 1_000)) + } + /// Flush WHILE the window is still active so the just-captured frames get persisted as raw, /// THEN close the window. func endRawCapture() async { diff --git a/Strand/Collect/ImuChunkArchiveStore.swift b/Strand/Collect/ImuChunkArchiveStore.swift index 78e08f15e9..c009cac97d 100644 --- a/Strand/Collect/ImuChunkArchiveStore.swift +++ b/Strand/Collect/ImuChunkArchiveStore.swift @@ -1,5 +1,7 @@ import CryptoKit +import Compression import Foundation +import WhoopProtocol import WhoopStore /// Apple twin of Android ImuChunkStore. ZIP entries and samples.bin layout are platform-identical. @@ -17,12 +19,14 @@ final class ImuChunkArchiveStore { try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) } - func pin(deviceId: String, from: Int, to: Int, ble: BLEManager) async -> [ImuChunkMeta] { + func pin(sessionId: String, deviceId: String, from: Int, to: Int, ble: BLEManager) async -> [ImuChunkMeta] { let existing = await ble.groundTruthImuChunks(from: from, to: to) .filter { $0.pinnedUntil == Int.max && file($0).isFileURL && FileManager.default.fileExists(atPath: file($0).path) } - if existing.contains(where: { $0.startTs <= from && $0.endTs >= to }) { return existing } - let rows = await ble.groundTruthRawImuSamples(from: from, to: to) + if existing.contains(where: { $0.startTs <= from && $0.endTs >= to + && $0.sampleCount >= ($0.endTs - $0.startTs + 1) * $0.sampleRate }) { return existing } + let rows = readSessionRows(sessionId: sessionId, from: from, to: to) guard !rows.isEmpty else { return existing } + let startTs = rows.map(\.ts).min()!, endTs = rows.map(\.ts).max()! var samples = Data() for row in rows { @@ -38,7 +42,8 @@ final class ImuChunkArchiveStore { "sample_rate_hz": Self.sampleRate, "axes": ["ax", "ay", "az", "gx", "gy", "gz"], "layout": "column-major-int16-le", "row_count": rows.count, - "start_ts": rows.first!.ts, "end_ts": rows.last!.ts, + "ordering": "unspecified", "timestamp_source": "strap", + "start_ts": startTs, "end_ts": endTs, ] guard let manifestData = try? JSONSerialization.data(withJSONObject: manifest, options: [.prettyPrinted, .sortedKeys]), let staged = FileExport.zipData(entries: [ @@ -51,7 +56,7 @@ final class ImuChunkArchiveStore { let base = directory.deletingLastPathComponent().deletingLastPathComponent() let relative = destination.path.replacingOccurrences(of: base.path + "/", with: "") let chunk = ImuChunkMeta( - id: id, deviceId: deviceId, startTs: rows.first!.ts, endTs: rows.last!.ts, + id: id, deviceId: deviceId, startTs: startTs, endTs: endTs, sampleCount: rows.count * Self.sampleRate, sampleRate: Self.sampleRate, formatVersion: Self.formatVersion, codec: "zip-deflate", relativePath: relative, byteSize: bytes.count, sha256: SHA256.hash(data: bytes).map { String(format: "%02x", $0) }.joined(), @@ -65,6 +70,41 @@ final class ImuChunkArchiveStore { let base = directory.deletingLastPathComponent().deletingLastPathComponent() return base.appendingPathComponent(chunk.relativePath) } + + private func readSessionRows(sessionId: String, from: Int, to: Int) -> [(ts: Int, cols: [Int16])] { + let url = ImuSessionFileStore.shared.file(sessionId) + guard let data = try? Data(contentsOf: url) else { return [] } + let bytes = [UInt8](data); var offset = 0 + var rows: [(Int, [Int16])] = [] + while offset + 24 <= bytes.count { + let headerTs = Int64(bigEndianBytes: bytes, at: offset); offset += 16 // strap + receipt timestamps + let length = Int(bytes[offset]) << 24 | Int(bytes[offset + 1]) << 16 + | Int(bytes[offset + 2]) << 8 | Int(bytes[offset + 3]) + offset += 4 + let compressedLength = Int(bytes[offset]) << 24 | Int(bytes[offset + 1]) << 16 + | Int(bytes[offset + 2]) << 8 | Int(bytes[offset + 3]); offset += 4 + guard length > 0, length <= 1_048_576, compressedLength > 0, + compressedLength <= 1_048_576, offset + compressedLength <= bytes.count else { break } + let compressed = Data(bytes[offset..= from, ts <= to, + let cols = Whoop5RawImu.rawColumns(frame) else { continue } + rows.append((ts, cols)) + } + return rows + } +} + +private extension Int64 { + init(bigEndianBytes bytes: [UInt8], at offset: Int) { + self = bytes[offset..<(offset + 8)].reduce(0) { ($0 << 8) | Int64($1) } + } } private extension Data { diff --git a/Strand/Collect/ImuSessionFileStore.swift b/Strand/Collect/ImuSessionFileStore.swift new file mode 100644 index 0000000000..387dad5385 --- /dev/null +++ b/Strand/Collect/ImuSessionFileStore.swift @@ -0,0 +1,106 @@ +import Compression +import Foundation +import WhoopProtocol + +/// Persisted routing table plus append-only, independently compressed IMU session streams. +@MainActor +final class ImuSessionFileStore { + static let shared = ImuSessionFileStore() + + private struct Window: Codable { let id, deviceId: String; var from, to: Int64? } + private let defaults = UserDefaults.standard + private let key = "imu-session-windows-v1" + private let directory: URL + private var seen: [String: Set] = [:] + + private init() { + let fm = FileManager.default + let base = (try? fm.url(for: .applicationSupportDirectory, in: .userDomainMask, + appropriateFor: nil, create: true)) ?? fm.temporaryDirectory + directory = base.appendingPathComponent("OpenWhoop/RawDataSessions", isDirectory: true) + try? fm.createDirectory(at: directory, withIntermediateDirectories: true) + } + + func start(id: String, deviceId: String, fromMs: Int64) { + var value = windows().filter { $0.id != id } + value.append(Window(id: id, deviceId: deviceId, from: fromMs / 1_000, to: nil)); save(value) + } + + func complete(id: String, toMs: Int64) { + var value = windows() + if let index = value.firstIndex(where: { $0.id == id }) { value[index].to = toMs / 1_000; save(value) } + } + + func register(id: String, deviceId: String, fromMs: Int64, toMs: Int64) { + var value = windows().filter { $0.id != id } + value.append(Window(id: id, deviceId: deviceId, from: fromMs / 1_000, to: toMs / 1_000)); save(value) + } + + func remove(id: String) { save(windows().filter { $0.id != id }); seen[id] = nil } + + @discardableResult + func append(deviceId: String, frame: [UInt8], receivedAtMs: Int64) -> Int { + guard let ts = Whoop5RawImu.baseTs(frame), Whoop5RawImu.rawColumns(frame) != nil else { return 0 } + var count = 0 + for window in windows() where window.deviceId == deviceId && Int64(ts) >= window.from + && (window.to == nil || Int64(ts) <= window.to!) { + var timestamps = seen[window.id] ?? scan(window.id) + guard timestamps.insert(Int64(ts)).inserted, + let compressed = Self.deflate(Data(frame)) else { continue } + var record = Data() + record.appendBigEndian(Int64(ts)); record.appendBigEndian(receivedAtMs) + record.appendBigEndian(Int32(frame.count)); record.appendBigEndian(Int32(compressed.count)) + record.append(compressed) + let url = file(window.id) + if !FileManager.default.fileExists(atPath: url.path) { FileManager.default.createFile(atPath: url.path, contents: nil) } + guard let handle = try? FileHandle(forWritingTo: url) else { continue } + do { + try handle.seekToEnd() + try handle.write(contentsOf: record) + try handle.close() + timestamps.insert(Int64(ts)) + seen[window.id] = timestamps + count += 1 + } catch { try? handle.close() } + } + return count + } + + func file(_ id: String) -> URL { directory.appendingPathComponent("realtime-imu-\(id).imus") } + + private func windows() -> [Window] { + guard let data = defaults.data(forKey: key) else { return [] } + return (try? JSONDecoder().decode([Window].self, from: data)) ?? [] + } + private func save(_ value: [Window]) { defaults.set(try? JSONEncoder().encode(value), forKey: key) } + + private func scan(_ id: String) -> Set { + guard let data = try? Data(contentsOf: file(id)) else { return [] } + var result = Set(), offset = 0 + while offset + 24 <= data.count { + let ts: Int64 = data.bigEndian(at: offset); let compressed: Int32 = data.bigEndian(at: offset + 20) + guard compressed >= 0, offset + 24 + Int(compressed) <= data.count else { break } + result.insert(ts); offset += 24 + Int(compressed) + } + return result + } + + private static func deflate(_ input: Data) -> Data? { + let capacity = max(input.count + 128, 256); var output = Data(count: capacity) + let written = output.withUnsafeMutableBytes { dst in input.withUnsafeBytes { src in + compression_encode_buffer(dst.bindMemory(to: UInt8.self).baseAddress!, capacity, + src.bindMemory(to: UInt8.self).baseAddress!, input.count, + nil, COMPRESSION_ZLIB) + }} + guard written > 0 else { return nil }; output.count = written; return output + } +} + +private extension Data { + mutating func appendBigEndian(_ value: T) { + var value = value.bigEndian; Swift.withUnsafeBytes(of: &value) { append(contentsOf: $0) } + } + func bigEndian(at offset: Int) -> T { + self[offset..<(offset + MemoryLayout.size)].reduce(T.zero) { ($0 << 8) | T($1) } + } +} diff --git a/Strand/Collect/RawDataSessionStore.swift b/Strand/Collect/RawDataSessionStore.swift index d38ffd81b4..c567b54f38 100644 --- a/Strand/Collect/RawDataSessionStore.swift +++ b/Strand/Collect/RawDataSessionStore.swift @@ -86,10 +86,12 @@ final class RawDataSessionStore: ObservableObject { comment: "", exported: false, events: [started]) sessions.insert(session, at: 0) persist(session) + ImuSessionFileStore.shared.start(id: session.id, deviceId: deviceId, fromMs: millis) return session } func stop(now: Date = Date()) { + let activeId = active?.id mutateActive { session in let millis = Int64(now.timeIntervalSince1970 * 1_000) session.endedAtMs = millis @@ -97,6 +99,7 @@ final class RawDataSessionStore: ObservableObject { session.events.append(Event(atMs: millis, kind: .stop, stepsTotal: session.steps, stairsTotal: session.stairs)) } + if let activeId { ImuSessionFileStore.shared.complete(id: activeId, toMs: Int64(now.timeIntervalSince1970 * 1_000)) } } @discardableResult @@ -112,6 +115,7 @@ final class RawDataSessionStore: ObservableObject { steps: 0, stairs: 0, excludedWindows: 0, comment: "", exported: false, events: events) sessions.insert(session, at: 0); persist(session) + ImuSessionFileStore.shared.register(id: id, deviceId: deviceId, fromMs: fromMs, toMs: toMs) return session } @@ -128,6 +132,9 @@ final class RawDataSessionStore: ObservableObject { excludedWindows: session.excludedWindows, comment: session.comment, exported: false, events: session.events) } + if let session = sessions.first(where: { $0.id == sessionId }) { + ImuSessionFileStore.shared.register(id: sessionId, deviceId: session.deviceId, fromMs: fromMs, toMs: toMs) + } } func record(_ kind: LabelKind, now: Date = Date()) { @@ -169,9 +176,16 @@ final class RawDataSessionStore: ObservableObject { func markExported(_ sessionId: String) { mutate(sessionId) { $0.exported = true } } + func finishImuRecovery(_ sessionId: String) { + ImuSessionFileStore.shared.remove(id: sessionId) + try? FileManager.default.removeItem(at: ImuSessionFileStore.shared.file(sessionId)) + } + func removeMetadata(_ sessionId: String) { guard sessions.first(where: { $0.id == sessionId })?.active != true else { return } try? FileManager.default.removeItem(at: file(sessionId)) + try? FileManager.default.removeItem(at: ImuSessionFileStore.shared.file(sessionId)) + ImuSessionFileStore.shared.remove(id: sessionId) sessions.removeAll { $0.id == sessionId } } diff --git a/Strand/Screens/RawDataCollectorView.swift b/Strand/Screens/RawDataCollectorView.swift index 86e36b5c79..f30a10f9ad 100644 --- a/Strand/Screens/RawDataCollectorView.swift +++ b/Strand/Screens/RawDataCollectorView.swift @@ -34,11 +34,11 @@ struct RawDataCollectorView: View { .task { // Restore a session after navigation/process lifecycle changes. The BLE layer rejects a // duplicate arm, so this is safe when capture never stopped. - if store.active != nil, live.bonded { model.ble.startGroundTruthRawCapture() } + if let active = store.active, live.bonded { _ = model.ble.startGroundTruthRawCapture(sessionId: active.id) } await refreshRawCounts() } .onChangeCompat(of: live.bonded) { bonded in - if bonded, store.active != nil { model.ble.startGroundTruthRawCapture() } + if bonded, let active = store.active { _ = model.ble.startGroundTruthRawCapture(sessionId: active.id) } } .confirmationDialog("Delete this session?", isPresented: Binding( get: { deleteCandidate != nil }, set: { if !$0 { deleteCandidate = nil } } @@ -221,9 +221,9 @@ struct RawDataCollectorView: View { } private func start() { - guard model.ble.startGroundTruthRawCapture() else { return } - if store.start(deviceId: model.ble.deviceId) == nil { - Task { await model.ble.stopGroundTruthRawCapture() } + guard let session = store.start(deviceId: model.ble.deviceId) else { return } + guard model.ble.startGroundTruthRawCapture(sessionId: session.id) else { + store.stop(); store.removeMetadata(session.id); return } } @@ -237,13 +237,15 @@ struct RawDataCollectorView: View { guard let end = session.endedAtMs else { return } exportingId = session.id let from = Int(session.startedAtMs / 1_000), to = Int(end / 1_000) - let chunks = await archive.pin(deviceId: session.deviceId, from: from, to: to, ble: model.ble) + let chunks = await archive.pin(sessionId: session.id, deviceId: session.deviceId, + from: from, to: to, ble: model.ble) let history = await model.ble.groundTruthHistoryCSV(from: from, to: to) var entries = store.exportEntries(for: session, raw: []) entries.append(.init(name: "history-sensors.csv", data: history)) + let imuComplete = Self.covers(chunks, from: from, to: to) let coverage: [String: Any] = [ "requested_start_ts": from, "requested_end_ts": to, - "complete": Self.covers(chunks.map { ($0.startTs, $0.endTs) }, from: from, to: to), + "complete": imuComplete, "chunks": chunks.map { ["start_ts": $0.startTs, "end_ts": $0.endTs, "sample_count": $0.sampleCount, "sha256": $0.sha256] } ] @@ -258,12 +260,17 @@ struct RawDataCollectorView: View { let result = await FileExport.exportBundle(entries: entries, suggestedName: "noop-ground-truth-\(session.id).zip") if result == nil { exportError = "The export file could not be created or shared." } - else { store.markExported(session.id) } + else { + store.markExported(session.id) + if imuComplete { store.finishImuRecovery(session.id) } + model.ble.finishGroundTruthRawCapture(sessionId: session.id) + } exportingId = nil } private func delete(_ session: RawDataSessionStore.Session) async { guard session.endedAtMs != nil else { return } + model.ble.finishGroundTruthRawCapture(sessionId: session.id) // Raw chunks may overlap another session; retention cleanup owns physical deletion. store.removeMetadata(session.id) rawBatchCounts[session.id] = nil @@ -292,9 +299,11 @@ struct RawDataCollectorView: View { return "\(date) · \(time(session.startedAtMs))–\(end)" } - private static func covers(_ intervals: [(Int, Int)], from: Int, to: Int) -> Bool { + private static func covers(_ chunks: [ImuChunkMeta], from: Int, to: Int) -> Bool { var cursor = from - for (start, end) in intervals.sorted(by: { $0.0 < $1.0 }) { + for chunk in chunks.sorted(by: { $0.startTs < $1.startTs }) { + let start = chunk.startTs, end = chunk.endTs + if chunk.sampleCount < (end - start + 1) * chunk.sampleRate { continue } if start > cursor { return false } if end >= cursor { cursor = end + 1 } if cursor > to { return true } diff --git a/StrandTests/RawDataCollectorParityTests.swift b/StrandTests/RawDataCollectorParityTests.swift index 23fcd7785f..f8a3a1c42e 100644 --- a/StrandTests/RawDataCollectorParityTests.swift +++ b/StrandTests/RawDataCollectorParityTests.swift @@ -28,6 +28,8 @@ final class RawDataCollectorParityTests: XCTestCase { "Strand/Collect/RawDataSessionStore.swift", "Strand/Collect/Collector.swift", "Strand/BLE/BLEManager.swift", "Strand/Screens/RawDataCollectorView.swift", "Strand/Collect/ImuChunkArchiveStore.swift", + "Strand/Collect/ImuSessionFileStore.swift", + "Packages/WhoopStore/Sources/WhoopStore/Database.swift", "Packages/WhoopStore/Sources/WhoopStore/ImuChunkCatalog.swift", "Packages/WhoopStore/Sources/WhoopStore/StreamStore.swift", "Packages/WhoopStore/Sources/WhoopStore/RawOutbox.swift", diff --git a/StrandTests/Resources/raw_data_collector_parity.json b/StrandTests/Resources/raw_data_collector_parity.json index 3129d3d7e9..e3c13ecb28 100644 --- a/StrandTests/Resources/raw_data_collector_parity.json +++ b/StrandTests/Resources/raw_data_collector_parity.json @@ -41,9 +41,13 @@ "swift": ["func setRange(sessionId:", "DatePicker(\"From\""], "kotlin": ["fun setSessionRange(", "ground_truth_edit_start"] }, - "bounded_36h_imu_cache": { - "swift": ["rawImuRetentionRows = 36 * 60 * 60"], - "kotlin": ["RAW_IMU_RETENTION_ROWS = 36 * 60 * 60"] + "file_only_imu_storage": { + "swift": ["v41-drop-raw-imu-sample"], + "kotlin": ["MIGRATION_34_35", "DROP TABLE IF EXISTS `rawImuSample`"] + }, + "session_bound_imu_file": { + "swift": ["ImuSessionFileStore", "realtime-imu-\\(id).imus"], + "kotlin": ["ImuSessionFileStore", "realtime-imu-$id.imus"] }, "durable_imu_chunk_catalog": { "swift": ["ImuChunkArchiveStore", "ImuChunkMeta"], @@ -67,7 +71,7 @@ }, "raw_data_removed_with_session": { "swift": ["deleteRawBatches(deviceId:", "capturedAt >= ? AND capturedAt <= ?"], - "kotlin": ["realtime-imu-$sessionId.bin", "noop-ground-truth-$sessionId.zip"] + "kotlin": ["realtime-imu-$sessionId.imus", "noop-ground-truth-$sessionId.zip"] } } } diff --git a/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt b/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt index c0f0477a52..0d58f8393c 100644 --- a/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt +++ b/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt @@ -28,9 +28,9 @@ import com.noop.NoopApplication import com.noop.data.HrRow import com.noop.data.RrRow import com.noop.data.StreamBatch -import com.noop.data.RawImuSampleEntity import com.noop.data.StreamPersistence import com.noop.protocol.Whoop5RawImu +import com.noop.testcentre.ImuSessionFileStore import com.noop.data.WhoopRepository import com.noop.protocol.AlarmPayload import com.noop.protocol.DYN_ACCEL_STILL_THRESHOLD_G @@ -2184,7 +2184,6 @@ class WhoopBleClient( @Volatile private var groundTruthImuCommandAllowed = false private var groundTruthImuSessionId: String? = null - private var groundTruthImuOut: DataOutputStream? = null /** Start the hardware-confirmed WHOOP 5 realtime IMU mode for an explicit ground-truth session. */ @Synchronized @@ -2203,15 +2202,7 @@ class WhoopBleClient( ) return false } - if (groundTruthImuSessionId != sessionId || groundTruthImuOut == null) { - closeGroundTruthImuFile() - val dir = File(context.filesDir, "ground-truth").apply { mkdirs() } - val file = File(dir, "realtime-imu-$sessionId.bin") - // Append lets a foreground collector resume the same session after an Android process restart. - groundTruthImuOut = DataOutputStream(BufferedOutputStream(FileOutputStream(file, true), 64 * 1024)) - groundTruthImuSessionId = sessionId - _groundTruthImuStatus.value = GroundTruthImuStatus(sessionId = sessionId, note = "Capture file open") - } + groundTruthImuSessionId = sessionId groundTruthImuCommandAllowed = true try { // The verified bounded raw-capture sequence is START_RAW_DATA followed by the @@ -2239,23 +2230,24 @@ class WhoopBleClient( } } log("Ground-truth realtime IMU requested OFF (session=${groundTruthImuSessionId ?: "none"})") - _groundTruthImuStatus.update { it.copy(requested = false, note = "Stopped") } - closeGroundTruthImuFile() + groundTruthImuSessionId = null + _groundTruthImuStatus.update { it.copy(requested = false, note = "Stopped; accepting history repair") } + } + + @Synchronized + fun finishGroundTruthImuCapture(sessionId: String) { + if (groundTruthImuSessionId == sessionId) groundTruthImuSessionId = null } @Synchronized private fun recordGroundTruthImuFrame(frame: ByteArray) { if (frame.size <= 8) return val packetType = frame[8].toInt() and 0xFF - // 51 is the named realtime IMU stream. Firmware/protocol revisions also use the older raw - // stream (43) and historical IMU stream (52); retain all three losslessly during an explicit - // ground-truth session instead of silently discarding a valid 100 Hz producer. - if (packetType !in setOf(43, 51, 52)) return - groundTruthImuOut?.let { out -> - runCatching { - out.writeLong(System.currentTimeMillis()) - out.writeInt(frame.size) - out.write(frame) + // Decode, not the transport packet type, is authoritative: history replay can wrap the same + // 100 Hz payload differently from realtime delivery. + runCatching { + val routed = ImuSessionFileStore(context).append(deviceId, frame) + if (routed > 0) { val now = System.currentTimeMillis() _groundTruthImuStatus.update { it.copy( @@ -2265,22 +2257,13 @@ class WhoopBleClient( note = "Receiving IMU packet type $packetType", ) } - }.onFailure { - log("Ground-truth realtime IMU write failed (${it.javaClass.simpleName}); capture stopped") - _groundTruthImuStatus.update { status -> status.copy(requested = false, note = "Write failed: ${it.javaClass.simpleName}") } - closeGroundTruthImuFile() } + }.onFailure { + log("Ground-truth IMU append failed (${it.javaClass.simpleName})") + _groundTruthImuStatus.update { status -> status.copy(note = "Write failed: ${it.javaClass.simpleName}") } } } - @Synchronized - private fun closeGroundTruthImuFile() { - runCatching { groundTruthImuOut?.flush() } - runCatching { groundTruthImuOut?.close() } - groundTruthImuOut = null - groundTruthImuSessionId = null - } - /** Frame reassembler for the fragmented custom notify chars (port of Reassembler). Reassigned per * connection with the detected family — WHOOP5/MG frames use a different length encoding. */ private var reassembler = Reassembler() @@ -6245,7 +6228,6 @@ class WhoopBleClient( // BEFORE the offload branch so it catches the burst; no-op unless capture is on. writeWhoop5DeepBufferIfBig(uuid.toString(), frame, isOffloadFrame(frame, connectedFamily)) // #423: the queryable twin of that diagnostics line — persist the decoded IMU - // samples (100 Hz 6-axis) into the rawImuSample table when raw capture is on. storeWhoop5RawImuIfBuffer(frame) } // Opt-in raw capture: record EVERY frame of the session (offload AND live flood — @@ -9252,10 +9234,7 @@ class WhoopBleClient( * one previous generation. Cheap for every other frame: a length + single-byte compare BEFORE the * pref read; no-op unless the capture toggle is on. */ - /** #423: persist the WHOOP 5/MG raw-IMU offload buffer NOOP already decodes for the deep-buffer log — - * the queryable twin of that (table-less) diagnostics line. Same `isCaptureEnabled` gate; only the - * 1244-B 6-axis buffer decodes (rawColumns null otherwise). IO-dispatched so it never blocks the GATT - * thread; bounded by a rolling retention prune. Raw i16, no downstream consumer yet (instrument-first). */ + /** Debug heartbeat for valid WHOOP 5/MG raw-IMU buffers. Session persistence is file-only. */ private fun storeWhoop5RawImuIfBuffer(frame: ByteArray) { // The bounded collector owns its own capture gate. It must not require the unrelated passive // protocol-trace preference: live and history copies then converge on the same keyed raw-IMU @@ -9263,19 +9242,13 @@ class WhoopBleClient( if (!PuffinExperiment.from(context).isCaptureEnabled && groundTruthImuSessionId == null) return val cols = Whoop5RawImu.rawColumns(frame) ?: return val baseTs = PuffinDeepBufferLog.strapTs(frame)?.toLong() ?: return - val dev = deviceId // #423 debug heartbeat: confirm the offload IMU is arriving + decoding on-device without pulling the // JSONL. Throttled (first buffer, then every 500) so a large offload can't flood the strap log; the // count is a per-connection running total. Off unless raw capture is enabled (gated above). rawImuDecodedCount++ if (rawImuDecodedCount == 1 || rawImuDecodedCount % 500 == 0) { log("RAW IMU capture: $rawImuDecodedCount buffer(s) decoded, latest ts=$baseTs " + - "(${cols.size / 6} samples/axis) — storing (retain ${WhoopRepository.RAW_IMU_RETENTION_ROWS})") - } - val row = RawImuSampleEntity(dev, baseTs, StreamPersistence.packImuColumns(cols)) - ioScope.launch { - runCatching { repository.insertRawImu(dev, listOf(row)) } - .onFailure { log("RAW IMU capture: store failed (${it.message})") } + "(${cols.size / 6} samples/axis) — routed to matching file sessions") } } diff --git a/android/app/src/main/java/com/noop/data/DeviceRegistry.kt b/android/app/src/main/java/com/noop/data/DeviceRegistry.kt index 22ec1f9cf2..4343190d1e 100644 --- a/android/app/src/main/java/com/noop/data/DeviceRegistry.kt +++ b/android/app/src/main/java/com/noop/data/DeviceRegistry.kt @@ -98,7 +98,6 @@ class DeviceRegistry( dao.reKeySteps(activeId, serialId); dao.deleteStepsFor(activeId) dao.reKeyPpgHr(activeId, serialId); dao.deletePpgHrFor(activeId) dao.reKeyPpgWaveform(activeId, serialId); dao.deletePpgWaveformFor(activeId) - dao.reKeyRawImu(activeId, serialId); dao.deleteRawImuFor(activeId) dao.reKeyV18Aux(activeId, serialId); dao.deleteV18AuxFor(activeId) dao.reKeyEvents(activeId, serialId); dao.deleteEventsFor(activeId) dao.reKeyBattery(activeId, serialId); dao.deleteBatteryFor(activeId) @@ -188,7 +187,6 @@ class DeviceRegistry( dao.deleteStepsFor(id) dao.deletePpgHrFor(id) dao.deletePpgWaveformFor(id) - dao.deleteRawImuFor(id) // #423 dao.deleteV18AuxFor(id) dao.deleteEventsFor(id) dao.deleteBatteryFor(id) diff --git a/android/app/src/main/java/com/noop/data/DeviceRegistryDao.kt b/android/app/src/main/java/com/noop/data/DeviceRegistryDao.kt index 7275f2839d..099edec1b6 100644 --- a/android/app/src/main/java/com/noop/data/DeviceRegistryDao.kt +++ b/android/app/src/main/java/com/noop/data/DeviceRegistryDao.kt @@ -85,7 +85,6 @@ interface DeviceRegistryDao { @Query("DELETE FROM stepSample WHERE deviceId = :deviceId") suspend fun deleteStepsFor(deviceId: String) @Query("DELETE FROM ppgHrSample WHERE deviceId = :deviceId") suspend fun deletePpgHrFor(deviceId: String) @Query("DELETE FROM ppgWaveformSample WHERE deviceId = :deviceId") suspend fun deletePpgWaveformFor(deviceId: String) - @Query("DELETE FROM rawImuSample WHERE deviceId = :deviceId") suspend fun deleteRawImuFor(deviceId: String) // #423 @Query("DELETE FROM v18AuxSample WHERE deviceId = :deviceId") suspend fun deleteV18AuxFor(deviceId: String) @Query("DELETE FROM event WHERE deviceId = :deviceId") suspend fun deleteEventsFor(deviceId: String) @Query("DELETE FROM battery WHERE deviceId = :deviceId") suspend fun deleteBatteryFor(deviceId: String) @@ -126,7 +125,6 @@ interface DeviceRegistryDao { @Query("UPDATE OR IGNORE stepSample SET deviceId = :to WHERE deviceId = :from") suspend fun reKeySteps(from: String, to: String) @Query("UPDATE OR IGNORE ppgHrSample SET deviceId = :to WHERE deviceId = :from") suspend fun reKeyPpgHr(from: String, to: String) @Query("UPDATE OR IGNORE ppgWaveformSample SET deviceId = :to WHERE deviceId = :from") suspend fun reKeyPpgWaveform(from: String, to: String) - @Query("UPDATE OR IGNORE rawImuSample SET deviceId = :to WHERE deviceId = :from") suspend fun reKeyRawImu(from: String, to: String) @Query("UPDATE OR IGNORE v18AuxSample SET deviceId = :to WHERE deviceId = :from") suspend fun reKeyV18Aux(from: String, to: String) @Query("UPDATE OR IGNORE event SET deviceId = :to WHERE deviceId = :from") suspend fun reKeyEvents(from: String, to: String) @Query("UPDATE OR IGNORE battery SET deviceId = :to WHERE deviceId = :from") suspend fun reKeyBattery(from: String, to: String) diff --git a/android/app/src/main/java/com/noop/data/Entities.kt b/android/app/src/main/java/com/noop/data/Entities.kt index 9dd6d58017..5d1ac18c74 100644 --- a/android/app/src/main/java/com/noop/data/Entities.kt +++ b/android/app/src/main/java/com/noop/data/Entities.kt @@ -656,42 +656,6 @@ data class PpgWaveformSampleEntity( } } -/** - * One 1-second WHOOP 5/MG raw-IMU offload buffer (#423): 100 Hz 6-axis inertial data. [samples] is a - * packed little-endian i16 BLOB of the six columns in wire order — ax×100, ay×100, az×100, gx×100, gy×100, - * gz×100 (1200 bytes) — decoded by [com.noop.protocol.Whoop5RawImu] (scales 1/4096 g/LSB, 2000/32768 dps/ - * LSB). The strap already delivers this in the connect-time offload burst; capturing it needs NO arming. - * Instrument-first + bounded: written only when raw capture is enabled, and pruned to a rolling recent - * window ([WhoopRepository.RAW_IMU_RETENTION_ROWS]). Twin of the GRDB `rawImuSample` table. Natural key - * (deviceId, ts) = one row per strap-second. - * - * CONSUMER STATUS (#978): deliberately none yet — instrument-first, the same stance as `v18AuxSample`. The - * writer runs only with raw capture enabled + a 5/MG deep-data unlock; nothing scores, gates or shows a row. - * The [WhoopRepository.rawImuSamples] / [WhoopDao.rawImuSamples] reader is intentionally dormant (zero - * callers) — the eventual cross-check seam, NOT dead code, so do not delete it. The GRDB twin has no reader - * yet by the same rule: one lands WITH a validated consumer, not before. - */ -@Entity(tableName = "rawImuSample", primaryKeys = ["deviceId", "ts"]) -data class RawImuSampleEntity( - val deviceId: String, - val ts: Long, - val samples: ByteArray, -) { - // ByteArray needs structural equals/hashCode (the generated identity ones break round-trip asserts). - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other !is RawImuSampleEntity) return false - return deviceId == other.deviceId && ts == other.ts && samples.contentEquals(other.samples) - } - - override fun hashCode(): Int { - var result = deviceId.hashCode() - result = 31 * result + ts.hashCode() - result = 31 * result + samples.contentHashCode() - return result - } -} - /** Catalog row for one immutable, app-private IMU archive chunk. Payload bytes live outside SQLite. */ @Entity( tableName = "imuChunk", diff --git a/android/app/src/main/java/com/noop/data/WhoopDao.kt b/android/app/src/main/java/com/noop/data/WhoopDao.kt index f470ac2a50..2dbd0c3265 100644 --- a/android/app/src/main/java/com/noop/data/WhoopDao.kt +++ b/android/app/src/main/java/com/noop/data/WhoopDao.kt @@ -124,10 +124,6 @@ interface WhoopDao : DeviceRegistryDao { @Insert(onConflict = OnConflictStrategy.IGNORE) suspend fun insertPpgWaveform(rows: List): List - /** RAW 5/MG IMU offload buffers (packed i16 BLOB). Idempotent by (deviceId, ts). (#423) */ - @Insert(onConflict = OnConflictStrategy.IGNORE) - suspend fun insertRawImu(rows: List): List - /** * The remaining 5/MG v18 per-second fields, one compact blob per strap-second. Idempotent by * (deviceId, ts) — IGNORE keeps the FIRST-seen row, matching every other per-second stream, so a @@ -147,22 +143,6 @@ interface WhoopDao : DeviceRegistryDao { ) suspend fun pruneV18Aux(deviceId: String, keep: Int) - /** Bound the raw-IMU table to the newest [keep] rows for [deviceId] (rolling retention, #423). */ - @Query( - "DELETE FROM rawImuSample WHERE deviceId = :deviceId AND ts < " + - "(SELECT MIN(ts) FROM (SELECT ts FROM rawImuSample WHERE deviceId = :deviceId ORDER BY ts DESC LIMIT :keep))" - ) - suspend fun pruneRawImu(deviceId: String, keep: Int) - - /** RAW 5/MG IMU buffers in [from, to] (ascending), packed i16 BLOB. (#423) - * Intentionally dormant — zero callers, retained for the eventual cross-check (see [RawImuSampleEntity] - * CONSUMER STATUS, #978). Not dead code; do not delete. */ - @Query( - "SELECT * FROM rawImuSample WHERE deviceId = :deviceId AND ts >= :from AND ts <= :to " + - "ORDER BY ts ASC LIMIT :limit" - ) - suspend fun rawImuSamples(deviceId: String, from: Long, to: Long, limit: Int): List - @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsertImuChunk(row: ImuChunkEntity) @@ -972,7 +952,6 @@ interface WhoopDao : DeviceRegistryDao { @Query("SELECT COUNT(*) FROM ppgHrSample") suspend fun countPpgHr(): Int @Query("SELECT COUNT(*) FROM sleepStateSample") suspend fun countSleepState(): Int @Query("SELECT COUNT(*) FROM ppgWaveformSample") suspend fun countPpgWaveform(): Int - @Query("SELECT COUNT(*) FROM rawImuSample") suspend fun countRawImu(): Int @Query("SELECT COUNT(*) FROM v18AuxSample") suspend fun countV18Aux(): Int @Query("SELECT COUNT(*) FROM respSample") suspend fun countResp(): Int @Query("SELECT COUNT(*) FROM gravitySample") suspend fun countGravity(): Int @@ -1035,8 +1014,6 @@ interface WhoopDao : DeviceRegistryDao { @Query("DELETE FROM ppgWaveformSample WHERE ts < :minTs OR ts > :maxTs") suspend fun prunePpgWaveformByTs(minTs: Long, maxTs: Long): Int - @Query("DELETE FROM rawImuSample WHERE ts < :minTs OR ts > :maxTs") - suspend fun pruneRawImuByTs(minTs: Long, maxTs: Long): Int @Query("DELETE FROM v18AuxSample WHERE ts < :minTs OR ts > :maxTs") suspend fun pruneV18AuxByTs(minTs: Long, maxTs: Long): Int diff --git a/android/app/src/main/java/com/noop/data/WhoopDatabase.kt b/android/app/src/main/java/com/noop/data/WhoopDatabase.kt index 6dd2b055fb..d1337d1167 100644 --- a/android/app/src/main/java/com/noop/data/WhoopDatabase.kt +++ b/android/app/src/main/java/com/noop/data/WhoopDatabase.kt @@ -49,12 +49,11 @@ import androidx.sqlite.db.SupportSQLiteDatabase LabMarkerRow::class, LiveSessionRow::class, PpgWaveformSampleEntity::class, - RawImuSampleEntity::class, ImuChunkEntity::class, V18AuxSampleEntity::class, AppleStepHour::class, ], - version = 34, + version = 35, // #775: ON so Room's KSP processor writes the generated schema (every table's exact `CREATE TABLE`, // columns in declaration order with affinity/NOT NULL/default, PK and indices) as JSON. That export // is what lets a plain JVM test — no device, no Robolectric — read Android's REAL schema and compare @@ -71,7 +70,7 @@ abstract class WhoopDatabase : RoomDatabase() { const val DB_NAME = "noop_whoop.db" /** Room schema version — MUST equal the `@Database(version = …)` above. Surfaced in the backup * manifest (#1410) so an export states its schema. Bump both together on a migration. */ - const val SCHEMA_VERSION = 34 + const val SCHEMA_VERSION = 35 @Volatile private var instance: WhoopDatabase? = null @@ -915,6 +914,10 @@ abstract class WhoopDatabase : RoomDatabase() { } } + internal val MIGRATION_34_35 = object : Migration(34, 35) { + override fun migrate(db: SupportSQLiteDatabase) { db.execSQL("DROP TABLE IF EXISTS `rawImuSample`") } + } + /** * Every migration the builder registers, as a VALUE rather than an argument list. * @@ -940,7 +943,7 @@ abstract class WhoopDatabase : RoomDatabase() { MIGRATION_18_19, MIGRATION_19_20, MIGRATION_20_21, MIGRATION_21_22, MIGRATION_22_23, MIGRATION_23_24, MIGRATION_24_25, MIGRATION_25_26, MIGRATION_26_27, MIGRATION_27_28, MIGRATION_28_29, MIGRATION_29_30, - MIGRATION_30_31, MIGRATION_31_32, MIGRATION_32_33, MIGRATION_33_34, + MIGRATION_30_31, MIGRATION_31_32, MIGRATION_32_33, MIGRATION_33_34, MIGRATION_34_35, ) diff --git a/android/app/src/main/java/com/noop/data/WhoopRepository.kt b/android/app/src/main/java/com/noop/data/WhoopRepository.kt index ba989deb21..6f63e45dcd 100644 --- a/android/app/src/main/java/com/noop/data/WhoopRepository.kt +++ b/android/app/src/main/java/com/noop/data/WhoopRepository.kt @@ -795,7 +795,6 @@ class WhoopRepository( // The instrumentation streams, missing from this list since they landed (see the DAO note). deleted += dao.pruneSleepStateByTs(minTs, maxTs) deleted += dao.prunePpgWaveformByTs(minTs, maxTs) - deleted += dao.pruneRawImuByTs(minTs, maxTs) deleted += dao.pruneV18AuxByTs(minTs, maxTs) deleted += dao.pruneEventByTs(minTs, maxTs) deleted += dao.pruneBatteryByTs(minTs, maxTs) @@ -1004,23 +1003,6 @@ class WhoopRepository( dao.v18AuxSamples(deviceId, from, to, limit) .map { V18AuxCodec.unpack(it.fields, it.ts) } - /** #423: persist a batch of decoded 5/MG raw-IMU offload buffers (one row per strap-second, packed i16 - * BLOB), then bound the table to the newest [RAW_IMU_RETENTION_ROWS] for the device. Comes from the - * deep-buffer capture seam, not the normal stream path, so it inserts directly (idempotent by ts). */ - suspend fun insertRawImu(deviceId: String, rows: List) { - if (rows.isEmpty()) return - dao.insertRawImu(rows) - dao.pruneRawImu(deviceId, RAW_IMU_RETENTION_ROWS) - } - - /** #423: raw 5/MG IMU buffers in [from, to] as the decoded i16 columns [ax…az,gx…gz] (100/axis). - * Intentionally dormant — zero callers, retained for the eventual cross-check (see [RawImuSampleEntity] - * CONSUMER STATUS, #978). Not dead code; do not delete. */ - suspend fun rawImuSamples(deviceId: String, from: Long, to: Long, limit: Int = DEFAULT_LIMIT): - List> = - dao.rawImuSamples(deviceId, from, to, limit) - .map { it.ts to StreamPersistence.unpackImuColumns(it.samples) } - suspend fun upsertImuChunk(row: ImuChunkEntity) = dao.upsertImuChunk(row) suspend fun imuChunks(deviceId: String, from: Long, to: Long): List = dao.imuChunks(deviceId, from, to) @@ -1576,9 +1558,9 @@ class WhoopRepository( "resp" to dao.countResp(), "gravity" to dao.countGravity(), // The rest of the accumulating decoded raw streams, so the Test-Centre footprint counts ALL of // them (keep in sync with Swift storageStats / TimestampHeal's raw-table list). ppgHr/ppgWaveform - // /rawImu can each be large. + // /v18Aux can each be large. "ppgHr" to dao.countPpgHr(), "sleepState" to dao.countSleepState(), - "ppgWaveform" to dao.countPpgWaveform(), "rawImu" to dao.countRawImu(), + "ppgWaveform" to dao.countPpgWaveform(), "v18Aux" to dao.countV18Aux(), ) }.getOrDefault(emptyMap()) @@ -1967,12 +1949,6 @@ class WhoopRepository( /** Default row cap on range reads. Matches the Swift call sites' bounded scans. */ const val DEFAULT_LIMIT = 100_000 - /** #423: rolling retention for the raw-IMU capture table (1 row/strap-second, ~1.2 KB each). One - * hour ≈ 3600 rows ≈ 4 MB caps the table hard, so an enabled capture can never balloon the DB - * during a multi-day offload replay. Instrument-first bounded window; nothing consumes it yet. */ - /** 36 h rolling cache: enough for retrospective activity/workout windows without permanent growth. */ - const val RAW_IMU_RETENTION_ROWS = 36 * 60 * 60 - /** * v31: rolling retention for the v18 aux-slot table (Swift twin `WhoopStore.v18AuxRetentionRows`). * diff --git a/android/app/src/main/java/com/noop/testcentre/GroundTruthCollector.kt b/android/app/src/main/java/com/noop/testcentre/GroundTruthCollector.kt index f5824b8168..05d4132e91 100644 --- a/android/app/src/main/java/com/noop/testcentre/GroundTruthCollector.kt +++ b/android/app/src/main/java/com/noop/testcentre/GroundTruthCollector.kt @@ -60,7 +60,7 @@ class GroundTruthCollector private constructor(private val context: Context) { private val directory = File(context.filesDir, "ground-truth").apply { mkdirs() } fun realtimeImuBytes(sessionId: String): Long = - File(directory, "realtime-imu-$sessionId.bin").takeIf(File::isFile)?.length() ?: 0L + File(directory, "realtime-imu-$sessionId.imus").takeIf(File::isFile)?.length() ?: 0L @Synchronized fun snapshot(): Snapshot = Snapshot( @@ -97,6 +97,7 @@ class GroundTruthCollector private constructor(private val context: Context) { .putBoolean(sessionCaptureKey(id), true) .apply() append(id, event(nowMs, "start", 0, 0, noopSteps, null).put("strap_device_id", deviceId)) + ImuSessionFileStore(context).start(id, deviceId, nowMs) return snapshot() } @@ -138,7 +139,7 @@ class GroundTruthCollector private constructor(private val context: Context) { val physicalStart = prefs.getLong(sessionStartKey(id), first.optLong("at_ms")) val capturedEnd = rows.lastOrNull { it.optString("kind") == "stop" }?.optLong("at_ms") val hasCapture = prefs.getBoolean(sessionCaptureKey(id), - File(directory, "realtime-imu-$id.bin").isFile) + File(directory, "realtime-imu-$id.imus").isFile) SessionSummary( id = id, deviceId = prefs.getString(sessionDeviceKey(id), null) @@ -167,12 +168,13 @@ class GroundTruthCollector private constructor(private val context: Context) { val files = listOf( eventFile(sessionId), - File(directory, "realtime-imu-$sessionId.bin"), + File(directory, "realtime-imu-$sessionId.imus"), File(context.cacheDir, "logs/noop-ground-truth-$sessionId.zip"), ) // Evaluate every deletion even if one fails, so a stale export ZIP never survives merely // because an earlier file could not be removed. val filesDeleted = files.map { file -> !file.exists() || file.delete() }.all { it } + ImuSessionFileStore(context).remove(sessionId) if (eventFile(sessionId).exists()) return false prefs.edit() @@ -198,6 +200,8 @@ class GroundTruthCollector private constructor(private val context: Context) { if (session.active || fromMs <= 0 || fromMs > toMs || toMs - fromMs > MAX_RANGE_MS) return false prefs.edit().putLong(sessionUsedStartKey(sessionId), fromMs) .putLong(sessionUsedEndKey(sessionId), toMs).apply() + val deviceId = session.deviceId ?: return false + ImuSessionFileStore(context).register(sessionId, deviceId, fromMs, toMs) return true } @@ -210,6 +214,7 @@ class GroundTruthCollector private constructor(private val context: Context) { .putLong(sessionUsedEndKey(id), toMs).putBoolean(sessionCaptureKey(id), false).apply() append(id, event(fromMs, "start", 0, 0, null, null).put("strap_device_id", deviceId)) append(id, event(toMs, "stop", 0, 0, null, null)) + ImuSessionFileStore(context).register(id, deviceId, fromMs, toMs) return sessions().firstOrNull { it.id == id } } @@ -270,6 +275,7 @@ class GroundTruthCollector private constructor(private val context: Context) { val before = snapshot() if (!before.active || before.sessionId == null) return before append(before.sessionId, event(nowMs, "stop", before.steps, before.stairs, noopSteps, null)) + ImuSessionFileStore(context).complete(before.sessionId, nowMs) prefs.edit().putBoolean("active", false).putLong("endedAtMs", nowMs) .putLong(sessionEndKey(before.sessionId), nowMs) .applyNoop(noopSteps).apply() @@ -296,7 +302,8 @@ class GroundTruthCollector private constructor(private val context: Context) { val sensorFrom = if (deviceId == null) 1L else summary.startedAtMs / 1_000L val sensorTo = if (deviceId == null) 0L else endMs / 1_000L val pinnedChunks = if (deviceId == null) emptyList() else - ImuChunkStore(context, repo).pin(deviceId, sensorFrom, sensorTo) + ImuChunkStore(context, repo).pin(id, deviceId, sensorFrom, sensorTo) + val imuComplete = covers(pinnedChunks, sensorFrom, sensorTo) val outDir = File(context.cacheDir, "logs").apply { mkdirs() } val zip = File(outDir, "noop-ground-truth-$id.zip") ZipOutputStream(zip.outputStream().buffered()).use { out -> @@ -308,7 +315,7 @@ class GroundTruthCollector private constructor(private val context: Context) { if (deviceId != null) put("device_id", deviceId) put("sensor_export_available", deviceId != null) put("imu_100hz_chunk_count", pinnedChunks.size) - put("imu_100hz_complete", covers(pinnedChunks.map { it.startTs to it.endTs }, sensorFrom, sensorTo)) + put("imu_100hz_complete", imuComplete) put("imu_100hz_coverage", org.json.JSONArray().apply { pinnedChunks.forEach { chunk -> put(JSONObject().apply { put("start_ts", chunk.startTs); put("end_ts", chunk.endTs) @@ -373,19 +380,6 @@ class GroundTruthCollector private constructor(private val context: Context) { } out.closeEntry() - out.putNextEntry(ZipEntry("raw-imu.csv")) - out.bufferedWriterNoClose().use { writer -> - writeRawImuCsv(writer, repo, deviceId.orEmpty(), sensorFrom, sensorTo) - writer.flush() - } - out.closeEntry() - - val realtimeImu = File(directory, "realtime-imu-$id.bin") - if (realtimeImu.isFile) { - out.putNextEntry(ZipEntry("realtime-imu.bin")) - realtimeImu.inputStream().use { it.copyTo(out) } - out.closeEntry() - } for (chunk in pinnedChunks) { val file = ImuChunkStore(context, repo).file(chunk) if (!file.isFile) continue @@ -395,6 +389,10 @@ class GroundTruthCollector private constructor(private val context: Context) { } } prefs.edit().putBoolean(sessionExportedKey(id), true).apply() + if (imuComplete) { + ImuSessionFileStore(context).remove(id) + File(directory, "realtime-imu-$id.imus").delete() + } if (id == snap.sessionId) prefs.edit().putBoolean("exported", true).apply() zip } @@ -409,9 +407,11 @@ class GroundTruthCollector private constructor(private val context: Context) { }, "Export raw-data session").addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) } - private fun covers(intervals: List>, from: Long, to: Long): Boolean { + private fun covers(chunks: List, from: Long, to: Long): Boolean { var cursor = from - for ((start, end) in intervals.sortedBy { it.first }) { + for (chunk in chunks.sortedBy { it.startTs }) { + val start = chunk.startTs; val end = chunk.endTs + if (chunk.sampleCount < (end - start + 1) * chunk.sampleRate) continue if (start > cursor) return false if (end >= cursor) cursor = end + 1 if (cursor > to) return true @@ -538,32 +538,6 @@ class GroundTruthCollector private constructor(private val context: Context) { private fun decimal(value: Double): String = String.format(java.util.Locale.US, "%.9f", value) - private suspend fun writeRawImuCsv( - out: java.io.Writer, - repo: WhoopRepository, - deviceId: String, - from: Long, - to: Long, - ) { - out.write("unix_s,sample_index,ax_g,ay_g,az_g,gx_dps,gy_dps,gz_dps\n") - if (from > to) return - val secondsInWindow = (to - from + 1L).coerceIn(1L, 86_400L).toInt() - for ((ts, columns) in repo.rawImuSamples(deviceId, from, to, limit = secondsInWindow)) { - if (columns.size < Whoop5RawImu.sampleCount * 6) continue - for (i in 0 until Whoop5RawImu.sampleCount) { - val values = listOf( - columns[i] * Whoop5RawImu.accelScale, - columns[Whoop5RawImu.sampleCount + i] * Whoop5RawImu.accelScale, - columns[Whoop5RawImu.sampleCount * 2 + i] * Whoop5RawImu.accelScale, - columns[Whoop5RawImu.sampleCount * 3 + i] * Whoop5RawImu.gyroScale, - columns[Whoop5RawImu.sampleCount * 4 + i] * Whoop5RawImu.gyroScale, - columns[Whoop5RawImu.sampleCount * 5 + i] * Whoop5RawImu.gyroScale, - ).joinToString(",") { String.format(java.util.Locale.US, "%.8f", it) } - out.write("$ts,$i,$values\n") - } - } - } - private fun encodeKey(key: KeyInfo) = JSONObject().apply { put("keyCode", key.keyCode); put("keyName", key.keyName); put("scanCode", key.scanCode) put("deviceId", key.deviceId); put("deviceName", key.deviceName); put("source", key.source) diff --git a/android/app/src/main/java/com/noop/testcentre/ImuChunkStore.kt b/android/app/src/main/java/com/noop/testcentre/ImuChunkStore.kt index bd12e8482a..7ab3583bb3 100644 --- a/android/app/src/main/java/com/noop/testcentre/ImuChunkStore.kt +++ b/android/app/src/main/java/com/noop/testcentre/ImuChunkStore.kt @@ -4,28 +4,34 @@ import android.content.Context import com.noop.data.ImuChunkEntity import com.noop.data.StreamPersistence import com.noop.data.WhoopRepository +import com.noop.protocol.Whoop5RawImu import java.io.BufferedOutputStream +import java.io.DataInputStream import java.io.DataOutputStream +import java.io.EOFException import java.io.File import java.io.FileOutputStream import java.security.MessageDigest import java.util.UUID +import java.util.zip.Inflater import java.util.zip.ZipEntry import java.util.zip.ZipOutputStream import org.json.JSONObject -/** Materializes immutable, portable IMU archives from the bounded SQLite rolling cache. */ +/** Materializes immutable archives from a session file; SQLite is only a short history staging cache. */ class ImuChunkStore(private val context: Context, private val repository: WhoopRepository) { private val directory = File(context.filesDir, "imu-chunks").apply { mkdirs() } - suspend fun pin(deviceId: String, from: Long, to: Long): List { + suspend fun pin(sessionId: String, deviceId: String, from: Long, to: Long): List { require(from <= to) val existing = repository.imuChunks(deviceId, from, to) .filter { it.pinnedUntil == Long.MAX_VALUE && File(context.filesDir, it.relativePath).isFile } - if (existing.any { it.startTs <= from && it.endTs >= to }) return existing - val limit = (to - from + 1).coerceIn(1, MAX_EXPORT_SECONDS).toInt() - val rows = repository.rawImuSamples(deviceId, from, to, limit) + if (existing.any { it.startTs <= from && it.endTs >= to && + it.sampleCount >= (it.endTs - it.startTs + 1) * it.sampleRate }) return existing + val rows = readSessionRows(sessionId, from, to) if (rows.isEmpty()) return existing + val startTs = rows.minOf { it.first } + val endTs = rows.maxOf { it.first } val id = UUID.randomUUID().toString() val final = File(directory, "$id.imuc") @@ -38,9 +44,11 @@ class ImuChunkStore(private val context: Context, private val repository: WhoopR put("sample_rate_hz", SAMPLE_RATE) put("axes", listOf("ax", "ay", "az", "gx", "gy", "gz")) put("layout", "column-major-int16-le") + put("ordering", "unspecified") + put("timestamp_source", "strap") put("row_count", rows.size) - put("start_ts", rows.first().first) - put("end_ts", rows.last().first) + put("start_ts", startTs) + put("end_ts", endTs) }.toString(2).toByteArray()) zip.closeEntry() zip.putNextEntry(ZipEntry("samples.bin")) @@ -56,7 +64,7 @@ class ImuChunkStore(private val context: Context, private val repository: WhoopR } check(temp.renameTo(final)) { "Could not commit IMU chunk" } val entity = ImuChunkEntity( - id = id, deviceId = deviceId, startTs = rows.first().first, endTs = rows.last().first, + id = id, deviceId = deviceId, startTs = startTs, endTs = endTs, sampleCount = rows.size * SAMPLE_RATE, sampleRate = SAMPLE_RATE, formatVersion = FORMAT_VERSION, codec = "zip-deflate", relativePath = final.relativeTo(context.filesDir).path, byteSize = final.length(), @@ -72,6 +80,30 @@ class ImuChunkStore(private val context: Context, private val repository: WhoopR fun file(chunk: ImuChunkEntity) = File(context.filesDir, chunk.relativePath) + private fun readSessionRows(sessionId: String, from: Long, to: Long): List> { + val source = File(context.filesDir, "ground-truth/realtime-imu-$sessionId.imus") + if (!source.isFile) return emptyList() + val rows = ArrayList>() + DataInputStream(source.inputStream().buffered()).use { input -> + while (true) { + try { + val ts = input.readLong(); input.readLong() + val length = input.readInt(); val compressedLength = input.readInt() + if (length <= 0 || length > MAX_FRAME_BYTES || compressedLength <= 0 || compressedLength > MAX_FRAME_BYTES) break + val compressed = ByteArray(compressedLength); input.readFully(compressed) + val inflater = Inflater(); inflater.setInput(compressed) + val frame = ByteArray(length); val inflated = inflater.inflate(frame); inflater.end() + if (inflated != length) continue + val decoded = Whoop5RawImu.decode(frame) ?: continue + if (ts == decoded.baseTs && ts in from..to) { + Whoop5RawImu.rawColumns(frame)?.let { rows += ts to it } + } + } catch (_: EOFException) { break } + } + } + return rows + } + private fun sha256(file: File): String { val digest = MessageDigest.getInstance("SHA-256") file.inputStream().buffered().use { input -> @@ -89,5 +121,6 @@ class ImuChunkStore(private val context: Context, private val repository: WhoopR const val FORMAT_VERSION = 1 const val SAMPLE_RATE = 100 const val MAX_EXPORT_SECONDS = 7 * 24 * 60 * 60L + const val MAX_FRAME_BYTES = 1024 * 1024 } } diff --git a/android/app/src/main/java/com/noop/testcentre/ImuSessionFileStore.kt b/android/app/src/main/java/com/noop/testcentre/ImuSessionFileStore.kt new file mode 100644 index 0000000000..da094b1d5b --- /dev/null +++ b/android/app/src/main/java/com/noop/testcentre/ImuSessionFileStore.kt @@ -0,0 +1,83 @@ +package com.noop.testcentre + +import android.content.Context +import com.noop.protocol.Whoop5RawImu +import java.io.BufferedOutputStream +import java.io.DataOutputStream +import java.io.File +import java.io.FileOutputStream +import java.util.zip.Deflater + +/** Routes realtime and delayed history IMU frames into persisted session time windows. */ +class ImuSessionFileStore(private val context: Context) { + private val prefs = context.getSharedPreferences("imu-session-windows", Context.MODE_PRIVATE) + private val directory = File(context.filesDir, "ground-truth").apply { mkdirs() } + + fun start(id: String, deviceId: String, fromMs: Long) = synchronized(lock) { + prefs.edit().putStringSet("ids", prefs.getStringSet("ids", emptySet()).orEmpty() + id) + .putString("$id.device", deviceId).putLong("$id.from", fromMs / 1_000L).remove("$id.to").apply() + } + + fun complete(id: String, toMs: Long) = synchronized(lock) { + if (id in prefs.getStringSet("ids", emptySet()).orEmpty()) + prefs.edit().putLong("$id.to", toMs / 1_000L).apply() + } + + fun register(id: String, deviceId: String, fromMs: Long, toMs: Long) = synchronized(lock) { + start(id, deviceId, fromMs); complete(id, toMs) + } + + fun remove(id: String) = synchronized(lock) { + prefs.edit().putStringSet("ids", prefs.getStringSet("ids", emptySet()).orEmpty() - id) + .remove("$id.device").remove("$id.from").remove("$id.to").apply() + } + + /** + * Append one independently-deflated wire frame. The timestamp header makes the stream scanable and + * idempotent without inflating older records; delayed history may therefore fill gaps in any order. + */ + fun append(deviceId: String, frame: ByteArray, receivedAtMs: Long = System.currentTimeMillis()): Int = synchronized(lock) { + val ts = Whoop5RawImu.decode(frame)?.baseTs ?: return 0 + var writes = 0 + for (id in prefs.getStringSet("ids", emptySet()).orEmpty()) { + if (prefs.getString("$id.device", null) != deviceId) continue + val from = prefs.getLong("$id.from", Long.MAX_VALUE) + val to = if (prefs.contains("$id.to")) prefs.getLong("$id.to", Long.MIN_VALUE) else Long.MAX_VALUE + if (ts !in from..to) continue + val file = File(directory, "realtime-imu-$id.imus") + val timestamps = timestamps(file) + if (ts in timestamps) continue + val deflater = Deflater() + val compressed = ByteArray(frame.size + 64) + deflater.setInput(frame); deflater.finish() + val compressedSize = deflater.deflate(compressed); deflater.end() + DataOutputStream(BufferedOutputStream(FileOutputStream(file, true))).use { + it.writeLong(ts); it.writeLong(receivedAtMs); it.writeInt(frame.size); it.writeInt(compressedSize) + it.write(compressed, 0, compressedSize) + } + timestamps += ts + writes++ + } + return writes + } + + private fun timestamps(file: File): MutableSet = seen.getOrPut(file.absolutePath) { + val result = mutableSetOf() + if (!file.exists()) return@getOrPut result + java.io.DataInputStream(file.inputStream().buffered()).use { input -> + while (input.available() >= 24) { + val ts = input.readLong(); input.readLong(); input.readInt() + val compressed = input.readInt() + if (compressed < 0 || compressed > 1_048_576 || input.available() < compressed) break + result += ts + input.skipBytes(compressed) + } + } + result + } + + companion object { + private val lock = Any() + private val seen = mutableMapOf>() + } +} diff --git a/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt b/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt index 616e4cdd36..091f78db19 100644 --- a/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt +++ b/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt @@ -274,6 +274,7 @@ fun GroundTruthCollectorScreen(vm: AppViewModel) { scope.launch { try { collector.share(collector.export(vm.repo, session.id)) + vm.ble.finishGroundTruthImuCapture(session.id) sessions = collector.sessions() } catch (failure: Throwable) { if (failure is kotlinx.coroutines.CancellationException) throw failure @@ -316,6 +317,7 @@ fun GroundTruthCollectorScreen(vm: AppViewModel) { }, confirmButton = { TextButton(onClick = { + vm.ble.finishGroundTruthImuCapture(session.id) collector.deleteSession(session.id) sessions = collector.sessions() state = collector.snapshot() @@ -339,6 +341,7 @@ fun GroundTruthCollectorScreen(vm: AppViewModel) { }, confirmButton = { TextButton(onClick = { + sessions.forEach { vm.ble.finishGroundTruthImuCapture(it.id) } collector.deleteAllSessions() sessions = collector.sessions() state = collector.snapshot() diff --git a/android/app/src/test/java/com/noop/analytics/RegistryDayOwnerSourceTest.kt b/android/app/src/test/java/com/noop/analytics/RegistryDayOwnerSourceTest.kt index dc0008b194..78adb90f68 100644 --- a/android/app/src/test/java/com/noop/analytics/RegistryDayOwnerSourceTest.kt +++ b/android/app/src/test/java/com/noop/analytics/RegistryDayOwnerSourceTest.kt @@ -66,7 +66,6 @@ class RegistryDayOwnerSourceTest { override suspend fun deleteStepsFor(deviceId: String) {} override suspend fun deletePpgHrFor(deviceId: String) {} override suspend fun deletePpgWaveformFor(deviceId: String) {} - override suspend fun deleteRawImuFor(deviceId: String) {} override suspend fun deleteV18AuxFor(deviceId: String) {} override suspend fun deleteEventsFor(deviceId: String) {} override suspend fun deleteBatteryFor(deviceId: String) {} @@ -97,7 +96,6 @@ class RegistryDayOwnerSourceTest { override suspend fun reKeySteps(from: String, to: String) {} override suspend fun reKeyPpgHr(from: String, to: String) {} override suspend fun reKeyPpgWaveform(from: String, to: String) {} - override suspend fun reKeyRawImu(from: String, to: String) {} override suspend fun reKeyV18Aux(from: String, to: String) {} override suspend fun reKeyEvents(from: String, to: String) {} override suspend fun reKeyBattery(from: String, to: String) {} diff --git a/android/app/src/test/java/com/noop/ble/SourceCoordinatorAdoptionTest.kt b/android/app/src/test/java/com/noop/ble/SourceCoordinatorAdoptionTest.kt index d86a7dba31..573cd4006b 100644 --- a/android/app/src/test/java/com/noop/ble/SourceCoordinatorAdoptionTest.kt +++ b/android/app/src/test/java/com/noop/ble/SourceCoordinatorAdoptionTest.kt @@ -81,7 +81,6 @@ class SourceCoordinatorAdoptionTest { override suspend fun deleteStepsFor(deviceId: String) {} override suspend fun deletePpgHrFor(deviceId: String) {} override suspend fun deletePpgWaveformFor(deviceId: String) {} - override suspend fun deleteRawImuFor(deviceId: String) {} override suspend fun deleteV18AuxFor(deviceId: String) {} override suspend fun deleteEventsFor(deviceId: String) {} override suspend fun deleteBatteryFor(deviceId: String) {} @@ -114,7 +113,6 @@ class SourceCoordinatorAdoptionTest { override suspend fun reKeySteps(from: String, to: String) {} override suspend fun reKeyPpgHr(from: String, to: String) {} override suspend fun reKeyPpgWaveform(from: String, to: String) {} - override suspend fun reKeyRawImu(from: String, to: String) {} override suspend fun reKeyV18Aux(from: String, to: String) {} override suspend fun reKeyEvents(from: String, to: String) {} override suspend fun reKeyBattery(from: String, to: String) {} diff --git a/android/app/src/test/java/com/noop/data/DeepCaptureMigrationTest.kt b/android/app/src/test/java/com/noop/data/DeepCaptureMigrationTest.kt index 845917fe5e..592dca23f0 100644 --- a/android/app/src/test/java/com/noop/data/DeepCaptureMigrationTest.kt +++ b/android/app/src/test/java/com/noop/data/DeepCaptureMigrationTest.kt @@ -220,6 +220,5 @@ class DeepCaptureMigrationTest { @Test fun shippedRetentionConstant() { assertEquals(604_800, WhoopRepository.V18_AUX_RETENTION_ROWS) // 7 x 86_400 strap-seconds - assertTrue(WhoopRepository.V18_AUX_RETENTION_ROWS > WhoopRepository.RAW_IMU_RETENTION_ROWS) } } diff --git a/android/app/src/test/java/com/noop/data/DeviceRegistryTest.kt b/android/app/src/test/java/com/noop/data/DeviceRegistryTest.kt index fa9b475580..6c747b8520 100644 --- a/android/app/src/test/java/com/noop/data/DeviceRegistryTest.kt +++ b/android/app/src/test/java/com/noop/data/DeviceRegistryTest.kt @@ -97,7 +97,6 @@ class DeviceRegistryTest { override suspend fun deleteStepsFor(deviceId: String) { deletedTables += "stepSample" to deviceId } override suspend fun deletePpgHrFor(deviceId: String) { deletedTables += "ppgHrSample" to deviceId } override suspend fun deletePpgWaveformFor(deviceId: String) { deletedTables += "ppgWaveformSample" to deviceId } - override suspend fun deleteRawImuFor(deviceId: String) { deletedTables += "rawImuSample" to deviceId } override suspend fun deleteV18AuxFor(deviceId: String) { deletedTables += "v18AuxSample" to deviceId } override suspend fun deleteEventsFor(deviceId: String) { deletedTables += "event" to deviceId } override suspend fun deleteBatteryFor(deviceId: String) { deletedTables += "battery" to deviceId } @@ -134,7 +133,6 @@ class DeviceRegistryTest { override suspend fun reKeySteps(from: String, to: String) {} override suspend fun reKeyPpgHr(from: String, to: String) {} override suspend fun reKeyPpgWaveform(from: String, to: String) {} - override suspend fun reKeyRawImu(from: String, to: String) {} override suspend fun reKeyV18Aux(from: String, to: String) {} override suspend fun reKeyEvents(from: String, to: String) {} override suspend fun reKeyBattery(from: String, to: String) {} @@ -312,7 +310,7 @@ class DeviceRegistryTest { // were missing, leaving raw sleep-state, lab markers, live sessions and dismissed markers behind. val expectedTables = setOf( "hrSample", "rrInterval", "spo2Sample", "skinTempSample", "respSample", "gravitySample", - "stepSample", "ppgHrSample", "ppgWaveformSample", "rawImuSample", "v18AuxSample", + "stepSample", "ppgHrSample", "ppgWaveformSample", "v18AuxSample", "event", "battery", "dailyMetric", "sleepSession", "journal", "workout", "appleDaily", "metricSeries", "dayOwnership", "scoreInputProvenance", diff --git a/android/app/src/test/java/com/noop/data/RawImuMigrationTest.kt b/android/app/src/test/java/com/noop/data/RawImuMigrationTest.kt index 57b5075853..960bba5ae0 100644 --- a/android/app/src/test/java/com/noop/data/RawImuMigrationTest.kt +++ b/android/app/src/test/java/com/noop/data/RawImuMigrationTest.kt @@ -1,13 +1,11 @@ package com.noop.data import com.noop.protocol.Whoop5RawImu -import kotlinx.coroutines.runBlocking import org.junit.Assert.assertArrayEquals import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test -import java.lang.reflect.Proxy /** * Guards the additive v20 -> v21 Room migration (the `rawImuSample` table, #423), the Android twin of the @@ -54,6 +52,11 @@ class RawImuMigrationTest { assertEquals(21, WhoopDatabase.MIGRATION_20_21.endVersion) } + @Test fun obsoleteTable_isDroppedAt35() { + assertEquals(34, WhoopDatabase.MIGRATION_34_35.startVersion) + assertEquals(35, WhoopDatabase.MIGRATION_34_35.endVersion) + } + // MARK: - Packed-BLOB encoding (byte-identical i16 LE) @Test @@ -100,45 +103,6 @@ class RawImuMigrationTest { assertNull("wrong sample count", Whoop5RawImu.rawColumns(f)) } - // MARK: - Store-write plumbing (repository inserts + rolling prune through the DAO) - - @Test - fun repositoryInsertRawImu_insertsThenPrunes() = runBlocking { - var inserted: List? = null - var prunedDevice: String? = null - var prunedKeep = -1 - val dao = Proxy.newProxyInstance( - WhoopDao::class.java.classLoader, - arrayOf(WhoopDao::class.java), - ) { _, method, args -> - when (method.name) { - "insertRawImu" -> { - @Suppress("UNCHECKED_CAST") - inserted = args[0] as List - listOf(1L) - } - "pruneRawImu" -> { prunedDevice = args[0] as String; prunedKeep = args[1] as Int; Unit } - else -> throw UnsupportedOperationException("raw-imu insert must not call ${method.name}") - } - } as WhoopDao - - val row = RawImuSampleEntity("my-whoop", 1_780_917_232L, StreamPersistence.packImuColumns(shortArrayOf(1, -1, 100))) - WhoopRepository(dao).insertRawImu("my-whoop", listOf(row)) - - assertEquals(listOf(row), inserted) - assertEquals("my-whoop", prunedDevice) - assertEquals(WhoopRepository.RAW_IMU_RETENTION_ROWS, prunedKeep) - } - - @Test - fun repositoryInsertRawImu_emptyIsNoOp() = runBlocking { - val dao = Proxy.newProxyInstance( - WhoopDao::class.java.classLoader, - arrayOf(WhoopDao::class.java), - ) { _, method, _ -> throw AssertionError("empty insert must not touch the DAO (${method.name})") } as WhoopDao - WhoopRepository(dao).insertRawImu("my-whoop", emptyList()) - } - private fun validImuBuffer(): ByteArray { val f = ByteArray(Whoop5RawImu.bufferLength) putI16(f, 24, 100) // countA diff --git a/android/app/src/test/java/com/noop/testcentre/RawDataCollectorParityTest.kt b/android/app/src/test/java/com/noop/testcentre/RawDataCollectorParityTest.kt index b5449ef323..5c222e0ed2 100644 --- a/android/app/src/test/java/com/noop/testcentre/RawDataCollectorParityTest.kt +++ b/android/app/src/test/java/com/noop/testcentre/RawDataCollectorParityTest.kt @@ -26,6 +26,8 @@ class RawDataCollectorParityTest { val source = listOf( "android/app/src/main/java/com/noop/testcentre/GroundTruthCollector.kt", "android/app/src/main/java/com/noop/testcentre/ImuChunkStore.kt", + "android/app/src/main/java/com/noop/testcentre/ImuSessionFileStore.kt", + "android/app/src/main/java/com/noop/data/WhoopDatabase.kt", "android/app/src/main/java/com/noop/data/Entities.kt", "android/app/src/main/java/com/noop/data/WhoopRepository.kt", "android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt", diff --git a/android/app/src/test/resources/raw_data_collector_parity.json b/android/app/src/test/resources/raw_data_collector_parity.json index 3129d3d7e9..e3c13ecb28 100644 --- a/android/app/src/test/resources/raw_data_collector_parity.json +++ b/android/app/src/test/resources/raw_data_collector_parity.json @@ -41,9 +41,13 @@ "swift": ["func setRange(sessionId:", "DatePicker(\"From\""], "kotlin": ["fun setSessionRange(", "ground_truth_edit_start"] }, - "bounded_36h_imu_cache": { - "swift": ["rawImuRetentionRows = 36 * 60 * 60"], - "kotlin": ["RAW_IMU_RETENTION_ROWS = 36 * 60 * 60"] + "file_only_imu_storage": { + "swift": ["v41-drop-raw-imu-sample"], + "kotlin": ["MIGRATION_34_35", "DROP TABLE IF EXISTS `rawImuSample`"] + }, + "session_bound_imu_file": { + "swift": ["ImuSessionFileStore", "realtime-imu-\\(id).imus"], + "kotlin": ["ImuSessionFileStore", "realtime-imu-$id.imus"] }, "durable_imu_chunk_catalog": { "swift": ["ImuChunkArchiveStore", "ImuChunkMeta"], @@ -67,7 +71,7 @@ }, "raw_data_removed_with_session": { "swift": ["deleteRawBatches(deviceId:", "capturedAt >= ? AND capturedAt <= ?"], - "kotlin": ["realtime-imu-$sessionId.bin", "noop-ground-truth-$sessionId.zip"] + "kotlin": ["realtime-imu-$sessionId.imus", "noop-ground-truth-$sessionId.zip"] } } } diff --git a/android/app/src/test/resources/schema_oracle.json b/android/app/src/test/resources/schema_oracle.json index bef71e5b5b..02b401e3e4 100644 --- a/android/app/src/test/resources/schema_oracle.json +++ b/android/app/src/test/resources/schema_oracle.json @@ -1276,34 +1276,6 @@ { "name": "index_imuChunk_deviceId_startTs_endTs", "columns": ["deviceId", "startTs", "endTs"], "unique": false } ] }, - "rawImuSample": { - "platform": "both", - "columns": [ - { - "name": "deviceId", - "affinity": "TEXT", - "notNull": true, - "default": null - }, - { - "name": "ts", - "affinity": "INTEGER", - "notNull": true, - "default": null - }, - { - "name": "samples", - "affinity": "BLOB", - "notNull": true, - "default": null - } - ], - "primaryKey": [ - "deviceId", - "ts" - ], - "indices": [] - }, "respSample": { "platform": "both", "columns": [ From 5495f7b48298fd7936b4da5bfa30b76d201d7913 Mon Sep 17 00:00:00 2001 From: Bernd Helm Date: Wed, 26 Aug 2026 22:50:39 +0200 Subject: [PATCH 20/45] Import IMU chunk metadata in collector view --- Strand/Screens/RawDataCollectorView.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Strand/Screens/RawDataCollectorView.swift b/Strand/Screens/RawDataCollectorView.swift index f30a10f9ad..17dfad56f1 100644 --- a/Strand/Screens/RawDataCollectorView.swift +++ b/Strand/Screens/RawDataCollectorView.swift @@ -1,5 +1,6 @@ import SwiftUI import StrandDesign +import WhoopStore /// iOS/macOS parity twin of Android's 5/MG Raw Data Collector screen. struct RawDataCollectorView: View { From 53443a263f74b7d8a8dee0e72b2d6d7aa48478c3 Mon Sep 17 00:00:00 2001 From: Bernd Helm Date: Wed, 26 Aug 2026 22:56:24 +0200 Subject: [PATCH 21/45] Make IMU session start timestamp nonoptional --- Strand/Collect/ImuSessionFileStore.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Strand/Collect/ImuSessionFileStore.swift b/Strand/Collect/ImuSessionFileStore.swift index 387dad5385..f2ade04d33 100644 --- a/Strand/Collect/ImuSessionFileStore.swift +++ b/Strand/Collect/ImuSessionFileStore.swift @@ -7,7 +7,11 @@ import WhoopProtocol final class ImuSessionFileStore { static let shared = ImuSessionFileStore() - private struct Window: Codable { let id, deviceId: String; var from, to: Int64? } + private struct Window: Codable { + let id, deviceId: String + let from: Int64 + var to: Int64? + } private let defaults = UserDefaults.standard private let key = "imu-session-windows-v1" private let directory: URL From 011c691b3bba58ef4c730927d193fe182b75293e Mon Sep 17 00:00:00 2001 From: Bernd Helm Date: Wed, 26 Aug 2026 23:37:19 +0200 Subject: [PATCH 22/45] Batch IMU compression into 30-second blocks --- Strand/Collect/ImuChunkArchiveStore.swift | 41 +++++---- Strand/Collect/ImuSessionFileStore.swift | 86 ++++++++++++++----- .../java/com/noop/testcentre/ImuChunkStore.kt | 25 ++++-- .../noop/testcentre/ImuSessionFileStore.kt | 66 ++++++++++---- 4 files changed, 154 insertions(+), 64 deletions(-) diff --git a/Strand/Collect/ImuChunkArchiveStore.swift b/Strand/Collect/ImuChunkArchiveStore.swift index c009cac97d..66beef77d8 100644 --- a/Strand/Collect/ImuChunkArchiveStore.swift +++ b/Strand/Collect/ImuChunkArchiveStore.swift @@ -20,6 +20,7 @@ final class ImuChunkArchiveStore { } func pin(sessionId: String, deviceId: String, from: Int, to: Int, ble: BLEManager) async -> [ImuChunkMeta] { + ImuSessionFileStore.shared.prepareForRead(sessionId) let existing = await ble.groundTruthImuChunks(from: from, to: to) .filter { $0.pinnedUntil == Int.max && file($0).isFileURL && FileManager.default.fileExists(atPath: file($0).path) } if existing.contains(where: { $0.startTs <= from && $0.endTs >= to @@ -76,29 +77,37 @@ final class ImuChunkArchiveStore { guard let data = try? Data(contentsOf: url) else { return [] } let bytes = [UInt8](data); var offset = 0 var rows: [(Int, [Int16])] = [] - while offset + 24 <= bytes.count { - let headerTs = Int64(bigEndianBytes: bytes, at: offset); offset += 16 // strap + receipt timestamps - let length = Int(bytes[offset]) << 24 | Int(bytes[offset + 1]) << 16 - | Int(bytes[offset + 2]) << 8 | Int(bytes[offset + 3]) - offset += 4 - let compressedLength = Int(bytes[offset]) << 24 | Int(bytes[offset + 1]) << 16 - | Int(bytes[offset + 2]) << 8 | Int(bytes[offset + 3]); offset += 4 - guard length > 0, length <= 1_048_576, compressedLength > 0, - compressedLength <= 1_048_576, offset + compressedLength <= bytes.count else { break } + while offset + 12 <= bytes.count { + let count = int32(bytes, offset); let rawLength = int32(bytes, offset + 4) + let compressedLength = int32(bytes, offset + 8); offset += 12 + guard count > 0, count <= 30, rawLength > 0, compressedLength > 0, + offset + compressedLength <= bytes.count else { break } let compressed = Data(bytes[offset..= from, ts <= to, - let cols = Whoop5RawImu.rawColumns(frame) else { continue } - rows.append((ts, cols)) + guard written == rawLength else { continue } + let raw = [UInt8](decoded); var rawOffset = 0 + for _ in 0.. 0, rawOffset + length <= raw.count else { break } + let frame = Array(raw[rawOffset..= from, ts <= to, + let cols = Whoop5RawImu.rawColumns(frame) else { continue } + rows.append((ts, cols)) + } } return rows } + + private func int32(_ bytes: [UInt8], _ offset: Int) -> Int { + Int(bytes[offset]) << 24 | Int(bytes[offset + 1]) << 16 + | Int(bytes[offset + 2]) << 8 | Int(bytes[offset + 3]) + } } private extension Int64 { diff --git a/Strand/Collect/ImuSessionFileStore.swift b/Strand/Collect/ImuSessionFileStore.swift index f2ade04d33..20b870fc55 100644 --- a/Strand/Collect/ImuSessionFileStore.swift +++ b/Strand/Collect/ImuSessionFileStore.swift @@ -2,7 +2,7 @@ import Compression import Foundation import WhoopProtocol -/// Persisted routing table plus append-only, independently compressed IMU session streams. +/// Persisted routing table plus append-only, 30-frame compressed IMU session blocks. @MainActor final class ImuSessionFileStore { static let shared = ImuSessionFileStore() @@ -12,10 +12,13 @@ final class ImuSessionFileStore { let from: Int64 var to: Int64? } + private struct Record { let ts, receivedAtMs: Int64; let frame: [UInt8] } + private static let blockFrames = 30 private let defaults = UserDefaults.standard private let key = "imu-session-windows-v1" private let directory: URL private var seen: [String: Set] = [:] + private var pending: [String: [Record]] = [:] private init() { let fm = FileManager.default @@ -31,6 +34,7 @@ final class ImuSessionFileStore { } func complete(id: String, toMs: Int64) { + flush(id) var value = windows() if let index = value.firstIndex(where: { $0.id == id }) { value[index].to = toMs / 1_000; save(value) } } @@ -40,7 +44,13 @@ final class ImuSessionFileStore { value.append(Window(id: id, deviceId: deviceId, from: fromMs / 1_000, to: toMs / 1_000)); save(value) } - func remove(id: String) { save(windows().filter { $0.id != id }); seen[id] = nil } + func remove(id: String) { + pending[id] = nil + save(windows().filter { $0.id != id }) + seen[id] = nil + } + + func prepareForRead(_ id: String) { flush(id) } @discardableResult func append(deviceId: String, frame: [UInt8], receivedAtMs: Int64) -> Int { @@ -49,23 +59,12 @@ final class ImuSessionFileStore { for window in windows() where window.deviceId == deviceId && Int64(ts) >= window.from && (window.to == nil || Int64(ts) <= window.to!) { var timestamps = seen[window.id] ?? scan(window.id) - guard timestamps.insert(Int64(ts)).inserted, - let compressed = Self.deflate(Data(frame)) else { continue } - var record = Data() - record.appendBigEndian(Int64(ts)); record.appendBigEndian(receivedAtMs) - record.appendBigEndian(Int32(frame.count)); record.appendBigEndian(Int32(compressed.count)) - record.append(compressed) - let url = file(window.id) - if !FileManager.default.fileExists(atPath: url.path) { FileManager.default.createFile(atPath: url.path, contents: nil) } - guard let handle = try? FileHandle(forWritingTo: url) else { continue } - do { - try handle.seekToEnd() - try handle.write(contentsOf: record) - try handle.close() - timestamps.insert(Int64(ts)) - seen[window.id] = timestamps - count += 1 - } catch { try? handle.close() } + guard timestamps.insert(Int64(ts)).inserted else { continue } + seen[window.id] = timestamps + pending[window.id, default: []].append(Record(ts: Int64(ts), receivedAtMs: receivedAtMs, + frame: frame)) + if pending[window.id]!.count >= Self.blockFrames { flush(window.id) } + count += 1 } return count } @@ -81,14 +80,45 @@ final class ImuSessionFileStore { private func scan(_ id: String) -> Set { guard let data = try? Data(contentsOf: file(id)) else { return [] } var result = Set(), offset = 0 - while offset + 24 <= data.count { - let ts: Int64 = data.bigEndian(at: offset); let compressed: Int32 = data.bigEndian(at: offset + 20) - guard compressed >= 0, offset + 24 + Int(compressed) <= data.count else { break } - result.insert(ts); offset += 24 + Int(compressed) + while offset + 12 <= data.count { + let count: Int32 = data.bigEndian(at: offset) + let rawSize: Int32 = data.bigEndian(at: offset + 4) + let compressedSize: Int32 = data.bigEndian(at: offset + 8) + offset += 12 + guard count > 0, count <= Self.blockFrames, rawSize > 0, compressedSize > 0, + offset + Int(compressedSize) <= data.count, + let raw = Self.inflate(data[offset..<(offset + Int(compressedSize))], size: Int(rawSize)) else { break } + offset += Int(compressedSize) + var rawOffset = 0 + for _ in 0.. 0, rawOffset + 20 + Int(length) <= raw.count else { break } + result.insert(ts); rawOffset += 20 + Int(length) + } } return result } + private func flush(_ id: String) { + guard let records = pending.removeValue(forKey: id), !records.isEmpty else { return } + var raw = Data() + for record in records { + raw.appendBigEndian(record.ts); raw.appendBigEndian(record.receivedAtMs) + raw.appendBigEndian(Int32(record.frame.count)); raw.append(contentsOf: record.frame) + } + guard let compressed = Self.deflate(raw) else { pending[id] = records; return } + var block = Data() + block.appendBigEndian(Int32(records.count)); block.appendBigEndian(Int32(raw.count)) + block.appendBigEndian(Int32(compressed.count)); block.append(compressed) + let url = file(id) + if !FileManager.default.fileExists(atPath: url.path) { FileManager.default.createFile(atPath: url.path, contents: nil) } + guard let handle = try? FileHandle(forWritingTo: url) else { pending[id] = records; return } + do { + try handle.seekToEnd(); try handle.write(contentsOf: block); try handle.close() + } catch { try? handle.close(); pending[id] = records } + } + private static func deflate(_ input: Data) -> Data? { let capacity = max(input.count + 128, 256); var output = Data(count: capacity) let written = output.withUnsafeMutableBytes { dst in input.withUnsafeBytes { src in @@ -98,6 +128,16 @@ final class ImuSessionFileStore { }} guard written > 0 else { return nil }; output.count = written; return output } + + private static func inflate(_ input: Data.SubSequence, size: Int) -> Data? { + var output = Data(count: size) + let written = output.withUnsafeMutableBytes { dst in input.withUnsafeBytes { src in + compression_decode_buffer(dst.bindMemory(to: UInt8.self).baseAddress!, size, + src.bindMemory(to: UInt8.self).baseAddress!, input.count, + nil, COMPRESSION_ZLIB) + }} + return written == size ? output : nil + } } private extension Data { diff --git a/android/app/src/main/java/com/noop/testcentre/ImuChunkStore.kt b/android/app/src/main/java/com/noop/testcentre/ImuChunkStore.kt index 7ab3583bb3..2e96488a35 100644 --- a/android/app/src/main/java/com/noop/testcentre/ImuChunkStore.kt +++ b/android/app/src/main/java/com/noop/testcentre/ImuChunkStore.kt @@ -24,6 +24,7 @@ class ImuChunkStore(private val context: Context, private val repository: WhoopR suspend fun pin(sessionId: String, deviceId: String, from: Long, to: Long): List { require(from <= to) + ImuSessionFileStore(context).prepareForRead(sessionId) val existing = repository.imuChunks(deviceId, from, to) .filter { it.pinnedUntil == Long.MAX_VALUE && File(context.filesDir, it.relativePath).isFile } if (existing.any { it.startTs <= from && it.endTs >= to && @@ -87,17 +88,21 @@ class ImuChunkStore(private val context: Context, private val repository: WhoopR DataInputStream(source.inputStream().buffered()).use { input -> while (true) { try { - val ts = input.readLong(); input.readLong() - val length = input.readInt(); val compressedLength = input.readInt() - if (length <= 0 || length > MAX_FRAME_BYTES || compressedLength <= 0 || compressedLength > MAX_FRAME_BYTES) break + val count = input.readInt(); val rawSize = input.readInt(); val compressedLength = input.readInt() + if (count !in 1..BLOCK_FRAMES || rawSize !in 1..MAX_BLOCK_BYTES || + compressedLength !in 1..MAX_BLOCK_BYTES) break val compressed = ByteArray(compressedLength); input.readFully(compressed) val inflater = Inflater(); inflater.setInput(compressed) - val frame = ByteArray(length); val inflated = inflater.inflate(frame); inflater.end() - if (inflated != length) continue - val decoded = Whoop5RawImu.decode(frame) ?: continue - if (ts == decoded.baseTs && ts in from..to) { - Whoop5RawImu.rawColumns(frame)?.let { rows += ts to it } - } + val raw = ByteArray(rawSize); val inflated = inflater.inflate(raw); inflater.end() + if (inflated != rawSize) continue + DataInputStream(raw.inputStream()).use { block -> for (index in 0 until count) { + val ts = block.readLong(); block.readLong(); val length = block.readInt() + if (length !in 1..MAX_FRAME_BYTES) break + val frame = ByteArray(length); block.readFully(frame) + val decoded = Whoop5RawImu.decode(frame) ?: continue + if (ts == decoded.baseTs && ts in from..to) + Whoop5RawImu.rawColumns(frame)?.let { rows += ts to it } + }} } catch (_: EOFException) { break } } } @@ -122,5 +127,7 @@ class ImuChunkStore(private val context: Context, private val repository: WhoopR const val SAMPLE_RATE = 100 const val MAX_EXPORT_SECONDS = 7 * 24 * 60 * 60L const val MAX_FRAME_BYTES = 1024 * 1024 + const val BLOCK_FRAMES = 30 + const val MAX_BLOCK_BYTES = BLOCK_FRAMES * (MAX_FRAME_BYTES + 20) } } diff --git a/android/app/src/main/java/com/noop/testcentre/ImuSessionFileStore.kt b/android/app/src/main/java/com/noop/testcentre/ImuSessionFileStore.kt index da094b1d5b..f8312c0c97 100644 --- a/android/app/src/main/java/com/noop/testcentre/ImuSessionFileStore.kt +++ b/android/app/src/main/java/com/noop/testcentre/ImuSessionFileStore.kt @@ -7,6 +7,7 @@ import java.io.DataOutputStream import java.io.File import java.io.FileOutputStream import java.util.zip.Deflater +import java.util.zip.Inflater /** Routes realtime and delayed history IMU frames into persisted session time windows. */ class ImuSessionFileStore(private val context: Context) { @@ -19,6 +20,7 @@ class ImuSessionFileStore(private val context: Context) { } fun complete(id: String, toMs: Long) = synchronized(lock) { + flush(id) if (id in prefs.getStringSet("ids", emptySet()).orEmpty()) prefs.edit().putLong("$id.to", toMs / 1_000L).apply() } @@ -28,13 +30,16 @@ class ImuSessionFileStore(private val context: Context) { } fun remove(id: String) = synchronized(lock) { + pending.remove(id) prefs.edit().putStringSet("ids", prefs.getStringSet("ids", emptySet()).orEmpty() - id) .remove("$id.device").remove("$id.from").remove("$id.to").apply() } + fun prepareForRead(id: String) = synchronized(lock) { flush(id) } + /** - * Append one independently-deflated wire frame. The timestamp header makes the stream scanable and - * idempotent without inflating older records; delayed history may therefore fill gaps in any order. + * Queue a wire frame for a 30-second compressed block. Delayed history may append older blocks later; + * strap timestamps, not file order, are authoritative. */ fun append(deviceId: String, frame: ByteArray, receivedAtMs: Long = System.currentTimeMillis()): Int = synchronized(lock) { val ts = Whoop5RawImu.decode(frame)?.baseTs ?: return 0 @@ -47,15 +52,9 @@ class ImuSessionFileStore(private val context: Context) { val file = File(directory, "realtime-imu-$id.imus") val timestamps = timestamps(file) if (ts in timestamps) continue - val deflater = Deflater() - val compressed = ByteArray(frame.size + 64) - deflater.setInput(frame); deflater.finish() - val compressedSize = deflater.deflate(compressed); deflater.end() - DataOutputStream(BufferedOutputStream(FileOutputStream(file, true))).use { - it.writeLong(ts); it.writeLong(receivedAtMs); it.writeInt(frame.size); it.writeInt(compressedSize) - it.write(compressed, 0, compressedSize) - } timestamps += ts + pending.getOrPut(id) { mutableListOf() } += Record(ts, receivedAtMs, frame.copyOf()) + if (pending[id]!!.size >= BLOCK_FRAMES) flush(id) writes++ } return writes @@ -65,19 +64,54 @@ class ImuSessionFileStore(private val context: Context) { val result = mutableSetOf() if (!file.exists()) return@getOrPut result java.io.DataInputStream(file.inputStream().buffered()).use { input -> - while (input.available() >= 24) { - val ts = input.readLong(); input.readLong(); input.readInt() - val compressed = input.readInt() - if (compressed < 0 || compressed > 1_048_576 || input.available() < compressed) break - result += ts - input.skipBytes(compressed) + while (input.available() >= 12) { + val count = input.readInt(); val rawSize = input.readInt(); val compressedSize = input.readInt() + if (count !in 1..BLOCK_FRAMES || rawSize !in 1..MAX_BLOCK_BYTES || + compressedSize !in 1..MAX_BLOCK_BYTES || input.available() < compressedSize) break + val compressed = ByteArray(compressedSize); input.readFully(compressed) + val raw = inflate(compressed, rawSize) ?: break + java.io.DataInputStream(raw.inputStream()).use { block -> + for (index in 0 until count) { + result += block.readLong(); block.readLong() + val length = block.readInt(); if (length !in 1..MAX_FRAME_BYTES) break + block.skipBytes(length) + } + } } } result } + private fun flush(id: String) { + val records = pending.remove(id).orEmpty() + if (records.isEmpty()) return + val rawBytes = java.io.ByteArrayOutputStream() + DataOutputStream(rawBytes).use { out -> records.forEach { record -> + out.writeLong(record.ts); out.writeLong(record.receivedAtMs) + out.writeInt(record.frame.size); out.write(record.frame) + }} + val raw = rawBytes.toByteArray() + val deflater = Deflater(); deflater.setInput(raw); deflater.finish() + val compressed = ByteArray(raw.size + 128); val size = deflater.deflate(compressed); deflater.end() + DataOutputStream(BufferedOutputStream(FileOutputStream(File(directory, "realtime-imu-$id.imus"), true))).use { + it.writeInt(records.size); it.writeInt(raw.size); it.writeInt(size); it.write(compressed, 0, size) + } + } + + private fun inflate(compressed: ByteArray, size: Int): ByteArray? { + val inflater = Inflater(); inflater.setInput(compressed) + val raw = ByteArray(size); val written = runCatching { inflater.inflate(raw) }.getOrDefault(0); inflater.end() + return raw.takeIf { written == size } + } + + private data class Record(val ts: Long, val receivedAtMs: Long, val frame: ByteArray) + companion object { private val lock = Any() private val seen = mutableMapOf>() + private val pending = mutableMapOf>() + private const val BLOCK_FRAMES = 30 + private const val MAX_FRAME_BYTES = 1_048_576 + private const val MAX_BLOCK_BYTES = BLOCK_FRAMES * (MAX_FRAME_BYTES + 20) } } From 0b7b3d64ed5007da8e5e9e6dc584197a5e6aa012 Mon Sep 17 00:00:00 2001 From: Bernd Helm Date: Thu, 27 Aug 2026 00:23:14 +0200 Subject: [PATCH 23/45] Keep recent-period exclusion in step capture fork --- Strand/Screens/RawDataCollectorView.swift | 11 +-------- .../com/noop/ui/GroundTruthCollectorScreen.kt | 24 ------------------- .../app/src/main/res/values-de/strings.xml | 4 +--- android/app/src/main/res/values/strings.xml | 4 +--- 4 files changed, 3 insertions(+), 40 deletions(-) diff --git a/Strand/Screens/RawDataCollectorView.swift b/Strand/Screens/RawDataCollectorView.swift index 17dfad56f1..fd037e2d37 100644 --- a/Strand/Screens/RawDataCollectorView.swift +++ b/Strand/Screens/RawDataCollectorView.swift @@ -9,7 +9,6 @@ struct RawDataCollectorView: View { @StateObject private var store = RawDataSessionStore() private let archive = ImuChunkArchiveStore() - @State private var excludeMinutes = "5" @State private var exportingId: String? @State private var deleteCandidate: RawDataSessionStore.Session? @State private var confirmDeleteAll = false @@ -126,14 +125,6 @@ struct RawDataCollectorView: View { NoopButton("Stop session", systemImage: "stop.fill", kind: .destructive, fullWidth: true) { Task { await stop() } } } - HStack { - TextField("Minutes", text: $excludeMinutes) - .textFieldStyle(.roundedBorder) - NoopButton("Ignore recent period", kind: .secondary, fullWidth: true) { - if let minutes = Int(excludeMinutes) { store.excludeLast(minutes: minutes) } - } - .disabled(!(1...240).contains(Int(excludeMinutes) ?? 0)) - } } else { NoopButton("Start raw-data session", systemImage: "record.circle", kind: .primary, fullWidth: true) { start() } @@ -190,7 +181,7 @@ struct RawDataCollectorView: View { from: Date(timeIntervalSince1970: Double(session.startedAtMs) / 1_000), to: $0) } )) } - Text("\(session.steps) steps · \(session.stairs) stairs · \(session.excludedWindows) excluded periods") + Text("\(session.steps) steps · \(session.stairs) stairs") .font(StrandFont.caption).foregroundStyle(StrandPalette.textSecondary) Text(session.active ? "Export status: recording" : "Export status: \(rawBatchCounts[session.id, default: 0]) raw batches") diff --git a/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt b/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt index 091f78db19..9df4998215 100644 --- a/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt +++ b/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt @@ -36,8 +36,6 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.noop.R @@ -62,7 +60,6 @@ fun GroundTruthCollectorScreen(vm: AppViewModel) { var state by remember { mutableStateOf(collector.snapshot()) } var haptics by remember { mutableStateOf(false) } var exportingSessionId by remember { mutableStateOf(null) } - var excludeMinutes by remember { mutableStateOf("5") } var sessions by remember { mutableStateOf(collector.sessions()) } var latestSensorTs by remember { mutableStateOf(null) } var sessionPendingDelete by remember { mutableStateOf(null) } @@ -206,26 +203,6 @@ fun GroundTruthCollectorScreen(vm: AppViewModel) { }, ) } - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) { - OutlinedTextField( - value = excludeMinutes, - onValueChange = { value -> excludeMinutes = value.filter(Char::isDigit).take(3) }, - label = { Text(stringResource(R.string.ground_truth_minutes)) }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - singleLine = true, - modifier = Modifier.weight(1f), - ) - NoopButton( - text = stringResource(R.string.ground_truth_exclude_recent), - kind = NoopButtonKind.Secondary, - modifier = Modifier.weight(2f), - enabled = excludeMinutes.toIntOrNull() in 1..240, - onClick = { - excludeMinutes.toIntOrNull()?.let { collector.excludeLastMinutes(it) } - sessions = collector.sessions() - }, - ) - } } else { NoopButton( text = stringResource(R.string.ground_truth_start), @@ -403,7 +380,6 @@ private fun SessionCard( R.string.ground_truth_session_summary, session.steps, session.stairs, - session.excludedWindows, ), style = NoopType.caption, color = Palette.textSecondary, diff --git a/android/app/src/main/res/values-de/strings.xml b/android/app/src/main/res/values-de/strings.xml index 486dca0ae3..2cd61e4fcc 100644 --- a/android/app/src/main/res/values-de/strings.xml +++ b/android/app/src/main/res/values-de/strings.xml @@ -2487,11 +2487,9 @@ Session exportieren Export wird erstellt… Session konnte nicht exportiert werden: %1$s - Minuten - Letzten Zeitraum ignorieren Aufgezeichnete Sessions Noch keine Sessions aufgezeichnet. - %1$d Schritte · %2$d Stufen · %3$d ausgeschlossene Zeiträume + %1$d Schritte · %2$d Stufen Kommentar zur Session Session löschen Diese Session löschen? diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 356287064c..400108be05 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -2499,11 +2499,9 @@ Export session Building export… Couldn’t export session: %1$s - Minutes - Ignore recent period Recorded sessions No sessions recorded yet. - %1$d steps · %2$d stairs · %3$d excluded periods + %1$d steps · %2$d stairs Session comment Delete session Delete this session? From e396de8c8e92e65d466730b6ab7920d7d6da9b48 Mon Sep 17 00:00:00 2001 From: Bernd Helm Date: Thu, 27 Aug 2026 00:30:39 +0200 Subject: [PATCH 24/45] Document verified 5MG raw IMU capture --- README.md | 1 + docs/BLE_REVERSE_ENGINEERING.md | 22 +++-- docs/DATA_MODEL.md | 25 ++++++ docs/PROTOCOL.md | 6 ++ docs/RAW_DATA_CAPTURE.md | 137 ++++++++++++++++++++++++++++++++ docs/WHOOP5_DEEP_DATA.md | 20 ++++- 6 files changed, 202 insertions(+), 9 deletions(-) create mode 100644 docs/RAW_DATA_CAPTURE.md diff --git a/README.md b/README.md index 4226c6019a..59d1ea2701 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ 👽 Reddit · Features · Protocol · + Raw data capture ·

diff --git a/docs/BLE_REVERSE_ENGINEERING.md b/docs/BLE_REVERSE_ENGINEERING.md index bf7cb94ac6..289e5b71a4 100644 --- a/docs/BLE_REVERSE_ENGINEERING.md +++ b/docs/BLE_REVERSE_ENGINEERING.md @@ -312,9 +312,17 @@ stream off, NOOP's primary metric source becomes the **historical offload** (nex ### On-demand raw capture For research, raw IMU can be captured for a bounded window with `captureRawAccel(seconds:)`, which -sends `START_RAW_DATA` (81) + `TOGGLE_IMU_MODE` (106), records for the window, then re-issues -`STOP_RAW_DATA` and disables the stream again. This is opt-in only; the global research toggle -(`enableRawCapture`) defaults **off** and the app is decoded-only otherwise. +sends `START_RAW_DATA` (81) followed by `TOGGLE_IMU_MODE` (106), records for the window, then +re-issues `STOP_RAW_DATA` and disables the stream again. This ordering is hardware-verified on a +WHOOP 5/MG: opcode 106 alone returns an acknowledgement but does **not** start the producer. The 5/MG +selector is two bytes (`[0x01, 0x01]` on, `[0x01, 0x00]` off); retaining the older one-byte form here +was why an apparently successful capture contained no realtime IMU packets. + +The manually stopped Raw Data Collector uses the same sequence and accepts delayed historical IMU +buffers into the session by strap timestamp, so a later offload can repair a Bluetooth gap. This is +opt-in and bounded; the global research toggle (`enableRawCapture`) defaults **off** and the app is +decoded-only otherwise. Storage/export details and consumer ordering rules are in +[5/MG raw data capture](RAW_DATA_CAPTURE.md). --- @@ -930,9 +938,11 @@ anything, it plausibly gates *those streams* rather than the offload. Our exposure to that is limited but not zero, and worth stating precisely. NOOP takes live HR from the standard `0x2A37` profile and disables the R10/R11 flood on connect (§4), and it never sends `TOGGLE_OPTICAL_MODE` (108) at all. But `captureRawAccel` **does** send `START_RAW_DATA` (81) + -`TOGGLE_IMU_MODE` (106) — on demand, for a bounded window, never continuously. So the one place this -could bite is an on-demand raw-accel capture on a 5/MG. Whether that path yields IMU frames today is -the observation that would settle the narrow claim, and it is not recorded anywhere here. +`TOGGLE_IMU_MODE` (106) — on demand, for a bounded window, never continuously. That path is now +hardware-verified to yield decoded 100 Hz six-axis IMU after the two-byte 5/MG selector is used. +It works without implementing a `LINK_VALID` response, so the proposed handshake is not required for +this raw-IMU producer on the tested strap/firmware. This does not prove that every firmware treats +`LINK_VALID` identically or that ignoring it can never affect link stability. **Two questions are genuinely open**, and a capture answers both without implementing anything: diff --git a/docs/DATA_MODEL.md b/docs/DATA_MODEL.md index 0306fffdab..18a08e9fbf 100644 --- a/docs/DATA_MODEL.md +++ b/docs/DATA_MODEL.md @@ -297,6 +297,31 @@ inserts, identical range-read shape). **Primary key:** `(deviceId, ts)`. +This is the low-rate decoded vector used by normal analytics, not the 100 Hz six-axis capture. High-rate +IMU payloads deliberately remain outside the per-sample SQLite schema: SQLite stores only searchable +`imuChunk` metadata, while compressed `.imus`/`.imuc` files hold the original frames and materialised +sample columns. See [5/MG raw data capture](RAW_DATA_CAPTURE.md#storage-design). + +#### `imuChunk` — file-backed high-rate IMU catalog + +| Column | Type | Notes | +| --- | --- | --- | +| `id` | TEXT NOT NULL | Primary key and `.imuc` filename stem. | +| `deviceId` | TEXT NOT NULL | Owning strap. | +| `startTs` / `endTs` | INTEGER NOT NULL | Inclusive strap Unix-second coverage. | +| `sampleCount` | INTEGER NOT NULL | Decoded axis-sample count represented by the chunk. | +| `sampleRate` | INTEGER NOT NULL | Currently 100 Hz. | +| `formatVersion` | INTEGER NOT NULL | `NOOPIMU` container version. | +| `codec` | TEXT NOT NULL | Currently `zip-deflate`. | +| `relativePath` | TEXT NOT NULL | App-private path to the payload outside SQLite. | +| `byteSize` | INTEGER NOT NULL | Stored file length. | +| `sha256` | TEXT NOT NULL | Payload integrity/provenance hash. | +| `createdAt` | INTEGER NOT NULL | Unix seconds. | +| `pinnedUntil` | INTEGER | Retention marker; permanent session chunks use the platform maximum. | + +The index is `(deviceId, startTs, endTs)`. A catalog row without its file is not coverage; readers +always verify that `relativePath` exists. + --- ## Raw outbox (transient, prunable) diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index 2927329ba2..c06cc4f5ef 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -424,6 +424,12 @@ public func frame(seq: UInt8, payload: [UInt8] = [0x00]) -> [UInt8] { | 122 | `STOP_HAPTICS` | `[0x00]` | stop an in-progress haptic | | 123 | `SELECT_WRIST` | — | set strap wrist | +**5/MG raw-IMU sequence (hardware-verified):** command 106 accepting a write does not mean that the +producer started. A bounded capture first sends `START_RAW_DATA` (81) `[0x01]`, then command 106 with +the two-byte selector `[0x01, 0x01]`. Stop uses `STOP_RAW_DATA` (82) `[0x01]`, then command 106 +`[0x01, 0x00]`. The one-byte payload in the table remains the WHOOP 4 form. See +[5/MG raw data capture](RAW_DATA_CAPTURE.md) for storage, history repair, and export semantics. + **Payload builders** in `WhoopCommand`: - `setAlarmPayload(epochSec:)` → `[0x01] + epoch u32 LE + [0x00, 0x00]` (7 bytes). diff --git a/docs/RAW_DATA_CAPTURE.md b/docs/RAW_DATA_CAPTURE.md new file mode 100644 index 0000000000..de9d2314d8 --- /dev/null +++ b/docs/RAW_DATA_CAPTURE.md @@ -0,0 +1,137 @@ +# 5/MG raw data capture + +**Status:** experimental, user-initiated, hardware-verified on WHOOP 5/MG. The capture path is a +research/export facility; it does not feed recovery, sleep, steps, or another production score. + +## What it is for + +The Raw Data Collector records a bounded interval of the strap's high-rate six-axis motion data and +exports it with enough provenance to analyse later. Typical uses are protocol validation, labelled +activity datasets, offline algorithm development, and checking whether a Bluetooth interruption was +repaired by a later history sync. + +It lives in **Test Centre → 5/MG Raw Data Collector** on Android and Apple platforms. A session may be +started and stopped live, or created afterwards for an existing time range. Start/end times of a +completed session can be edited, individual sessions can be deleted, and all completed sessions can +be deleted after confirmation. The generic collector does not assume a particular labelling task; +manual step/stair controls are optional fork extensions rather than a prerequisite for capture. + +## What was verified on hardware + +On WHOOP 5/MG, command 106 by itself can return `SUCCESS` without starting any IMU producer. The +working bounded sequence is: + +1. `START_RAW_DATA` (81), payload `[0x01]`; +2. `TOGGLE_IMU_MODE` (106), payload `[0x01, 0x01]`; +3. receive and decode the 100 Hz six-axis buffers; +4. on stop, send `STOP_RAW_DATA` (82), payload `[0x01]`, then `TOGGLE_IMU_MODE` with + `[0x01, 0x00]`. + +The writes use the authenticated WHOOP command characteristic and require a connected/bonded strap. +An accepted command is not evidence that samples arrived, so the collector reports connection state, +request state, packet/byte counts, the last packet time, and history-sync progress separately. + +The decoded IMU buffer contains 100 signed 16-bit samples for each of `ax, ay, az, gx, gy, gz`, keyed +by a strap Unix timestamp. Accelerometer scale is `1/4096 g/LSB`; gyroscope scale is +`0.06104 deg/s/LSB`. See [BLE reverse engineering](BLE_REVERSE_ENGINEERING.md#4-the-realtime-r10r11-raw-stream-type-43) +for the byte layout and validation evidence. + +An Android Bluetooth HCI capture of the official WHOOP app around a Strength Trainer session was the +useful behavioural reference: high-rate collection was session-oriented rather than a permanently +enabled background stream. It helped identify what to test, but NOOP's implementation and validation +remain clean-room and use only independently implemented protocol commands/decoders. The observation +also does not establish the battery cost of keeping the mode enabled beyond a workout-sized window. + +## Live capture and history repair + +A live BLE connection is not assumed to be lossless. Each session is a time window tied to one strap, +and incoming IMU buffers are routed by **strap timestamp**, not by arrival time. If Bluetooth is off or +the phone is disconnected during part of a recording, matching delayed buffers from a later historical +offload can still be appended to the session. Duplicate timestamps are discarded. + +Consequences for consumers: + +- file order is not chronological: repaired older history may be appended after newer live data; +- strap timestamp is authoritative and readers must sort by it; +- export metadata reports actual chunk coverage and `imu_100hz_complete`; it must not infer complete + capture merely because the user started and stopped a session; +- history can repair only data the strap actually retained. The design does not promise that every + firmware retains every high-rate buffer for later offload. + +The historical-range action is therefore useful even when the collector was not running at the time: +it creates a session window over raw IMU buffers already available locally or delivered by the next +history sync. A range is currently bounded to seven days to keep an accidental export finite. + +## Storage design + +High-rate samples do **not** live as one SQLite row per sample. That would add avoidable write +amplification, database growth, migration burden, and backup cost to data whose main consumer is a +sequential signal-analysis job. + +Instead, storage is split by responsibility: + +| Data | Storage | Lifetime | +|---|---|---| +| Session window, comments, optional labels | Small app-private JSON/JSONL metadata | Until the user deletes the session | +| Incoming raw IMU frames | Append-only `.imus` session file | Until materialised/exported or the session is deleted | +| Searchable chunk metadata | SQLite (`imuChunk`) | Index/provenance only; no individual samples | +| Immutable analysis/export payload | Compressed `.imuc` file | Pinned to the retained session/export | + +The `.imus` writer batches up to 30 one-second frames into an independently zlib-compressed block. +Blocks are appended; a crash can at worst leave a truncated final block, while earlier blocks remain +readable. Pending blocks are flushed when a session stops or before it is read. Each record keeps the +strap timestamp, phone receive time, and original frame bytes. This preserves evidence for a future +decoder instead of committing early to today's interpretation. + +An `.imuc` file is a ZIP/deflate container with: + +- `manifest.json`: `NOOPIMU`, format version, 100 Hz sample rate, axes, timestamp source, coverage, + ordering contract, and row count; +- `samples.bin`: repeated big-endian timestamp and byte-length headers followed by column-major, + little-endian signed 16-bit axis samples. + +The format and archive semantics are mirrored on Android and Apple. SHA-256, byte size, codec, +coverage, and sample count are stored in `imuChunk`; the payload itself remains outside SQLite. + +## Session export + +The shareable ZIP contains session provenance and all available sensor material for its selected +interval. Android currently includes `meta.json`, label/event JSONL and CSV files, decoded one-second +signals, raw sensor CSV, and `imu/*.imuc`. Apple exports equivalent session metadata/events, +`history-sensors.csv`, `imu-coverage.json`, and IMU chunks through its platform export path. Inspect +`meta.json` plus the platform's IMU coverage object first: + +- `started_at_ms` / `ended_at_ms` are the selected analysis interval; +- `captured_started_at_ms` / `captured_ended_at_ms`, when present, preserve the physical recording + interval even after the selected interval is edited; +- Android's `imu_100hz_coverage` identifies the chunks actually present and `imu_100hz_complete` is + the conservative coverage result; Apple carries the same facts in `imu-coverage.json`; +- manual labels, when present, are annotations and never proof of sensor completeness. + +Exports stay local until the user invokes the operating system's share sheet. Raw captures are not +part of routine cloud sync or telemetry, consistent with NOOP's offline-first privacy model. + +## Scope and operational limits + +- Capture is deliberately bounded and explicit. The current evidence does **not** establish battery, + flash-retention, thermal, or BLE-airtime costs for continuous 24/7 100 Hz operation. +- A one-hour workout/research capture succeeding does not establish that a 36-hour rolling recorder is + safe. Any future rolling buffer needs hardware measurements and an explicit retention policy. +- The older passive “Record 5/MG raw capture” protocol trace and the session collector have different + jobs. The former preserves broad offload/debug frames; the latter owns a time-bounded, exportable IMU + dataset. They may observe the same wire frame, but the session store deduplicates it by strap time. +- The former `rawImuSample` per-sample SQLite design was intentionally removed: no production path had + written it, and high-rate payloads now use file-backed chunks. +- Do not use arrival order as time, do not fill gaps silently, and do not claim 100 Hz coverage from + packet count alone. + +## Implementation map + +| Concern | Apple | Android | +|---|---|---| +| Collector UI | `Strand/Screens/RawDataCollectorView.swift` | `com.noop.ui.GroundTruthCollectorScreen` | +| Session metadata | `RawDataSessionStore` | `GroundTruthCollector` | +| Live command path | `BLEManager.startGroundTruthRawCapture` | `WhoopBleClient.startGroundTruthImuCapture` | +| Append/recovery window | `ImuSessionFileStore` | `ImuSessionFileStore` | +| Immutable chunk export | `ImuChunkArchiveStore` | `ImuChunkStore` | +| Raw decoder | `Whoop5RawImu` in `WhoopProtocol` | `Whoop5RawImu` in `com.noop.protocol` | diff --git a/docs/WHOOP5_DEEP_DATA.md b/docs/WHOOP5_DEEP_DATA.md index ade528ec0e..e2f9734d09 100644 --- a/docs/WHOOP5_DEEP_DATA.md +++ b/docs/WHOOP5_DEEP_DATA.md @@ -79,6 +79,19 @@ that otherwise reproduced flags 1–15 byte-for-byte in this order. - **iOS / Android only on real hardware:** macOS CoreBluetooth can't complete the authenticated SMP bond the command characteristic requires, so the write path is unavailable on Mac. +## High-rate IMU capture is a separate switch + +The R22 feature flags above govern deep-history products; they are not the missing step for an explicit +100 Hz motion session. Hardware testing found a separate bounded raw-data sequence: `START_RAW_DATA` +(81) must precede the two-byte 5/MG `TOGGLE_IMU_MODE` (106) selector. Sending 106 alone can return +`SUCCESS` while producing no packets. With `[0x01, 0x01]` after command 81, NOOP receives and decodes +the six-axis 100 Hz buffers; stop uses command 82 followed by `[0x01, 0x00]`. + +This establishes an on-demand research/workout capture path, not a safe continuous mode. Battery, +retention, and BLE-airtime cost over day-scale recording remain unmeasured. The session collector, +file-backed storage, Bluetooth-gap repair, and export contract are documented in +[5/MG raw data capture](RAW_DATA_CAPTURE.md). + ## Honest limits - **No cloud scores.** Recovery/strain/sleep *scores* are computed in WHOOP's cloud and no public @@ -90,9 +103,10 @@ that otherwise reproduced flags 1–15 byte-for-byte in this order. `get_data_range`/`send_historical_data` loop NOOP already runs. If it does, the write path is belt-and- suspenders. - **The large records are no longer an undifferentiated type-`0x2F` blob.** Layout v21 (1,244 bytes) - contains six-axis IMU data; layout v20 (2,140 bytes) contains five repeated optical measurement - blocks; layout v26 contains a 24-sample PPG waveform. The v20 blocks are preserved without - wavelength labels because the current capture does not prove red/IR identity. + contains six-axis IMU data; layout v20 (2,140 bytes) contains five repeated measurement blocks whose + sensor identity remains open; layout v26 contains a 24-sample PPG waveform. The v20 blocks are + preserved without optical/wavelength labels because the current capture does not prove what produced + them, let alone red/IR identity. - **SpO₂ is not “one calibration away.”** The current v20 corpus has three active measurement blocks, but it has not established two separate red and infrared illumination measurements. See [`WHOOP5_OPTICAL_EXPERIMENT.md`](WHOOP5_OPTICAL_EXPERIMENT.md) for the passive controlled experiment From 1971179c511ecb607d194417ca2597feb45a225b Mon Sep 17 00:00:00 2001 From: Bernd Helm Date: Thu, 27 Aug 2026 01:31:34 +0200 Subject: [PATCH 25/45] Refine Android raw capture sessions --- .../Resources/raw_data_collector_parity.json | 4 - android/app/build.gradle.kts | 2 +- .../main/java/com/noop/ble/WhoopBleClient.kt | 26 ++++ .../noop/testcentre/GroundTruthCollector.kt | 123 +++++++++++------- .../noop/testcentre/ImuSessionFileStore.kt | 10 ++ .../com/noop/ui/GroundTruthCollectorScreen.kt | 122 +++++++++++++++-- .../resources/raw_data_collector_parity.json | 4 - android/gradle.properties | 8 +- 8 files changed, 229 insertions(+), 70 deletions(-) diff --git a/StrandTests/Resources/raw_data_collector_parity.json b/StrandTests/Resources/raw_data_collector_parity.json index e3c13ecb28..a7e34bd4ac 100644 --- a/StrandTests/Resources/raw_data_collector_parity.json +++ b/StrandTests/Resources/raw_data_collector_parity.json @@ -17,10 +17,6 @@ "swift": ["func undo(", "Undo last click"], "kotlin": ["fun undo(", "ground_truth_undo"] }, - "exclude_recent_period": { - "swift": ["excludeLast(minutes:", "Ignore recent period"], - "kotlin": ["excludeLastMinutes(", "ground_truth_exclude_recent"] - }, "session_comments": { "swift": ["setComment(", "Session comment"], "kotlin": ["setComment(", "ground_truth_comment"] diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 21857254e3..362d85e4f2 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -26,7 +26,7 @@ android { applicationId = "com.noop.whoop" minSdk = 26 targetSdk = 34 - versionCode = 373 + versionCode = 375 versionName = "10.6.1" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" diff --git a/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt b/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt index 0d58f8393c..3eeed76c53 100644 --- a/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt +++ b/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt @@ -2184,6 +2184,8 @@ class WhoopBleClient( @Volatile private var groundTruthImuCommandAllowed = false private var groundTruthImuSessionId: String? = null + private var groundTruthImuStoppedAtMs = 0L + private var unexpectedImuStopAtMs = 0L /** Start the hardware-confirmed WHOOP 5 realtime IMU mode for an explicit ground-truth session. */ @Synchronized @@ -2203,6 +2205,7 @@ class WhoopBleClient( return false } groundTruthImuSessionId = sessionId + groundTruthImuStoppedAtMs = 0L groundTruthImuCommandAllowed = true try { // The verified bounded raw-capture sequence is START_RAW_DATA followed by the @@ -2231,6 +2234,7 @@ class WhoopBleClient( } log("Ground-truth realtime IMU requested OFF (session=${groundTruthImuSessionId ?: "none"})") groundTruthImuSessionId = null + groundTruthImuStoppedAtMs = System.currentTimeMillis() _groundTruthImuStatus.update { it.copy(requested = false, note = "Stopped; accepting history repair") } } @@ -2264,6 +2268,27 @@ class WhoopBleClient( } } + /** Fail-safe for a producer left armed by a crash, another client, or a lost stop write. */ + @Synchronized + private fun stopUnexpectedRealtimeImu(frame: ByteArray, replayedOffload: Boolean) { + if (connectedFamily != DeviceFamily.WHOOP5 || replayedOffload || frame.size <= 8) return + val type = frame[8].toInt() and 0xFF + if (type != 43 && type != 51) return + if (groundTruthImuSessionId != null || PuffinExperiment.from(context).isCaptureEnabled) return + val now = System.currentTimeMillis() + if (now - groundTruthImuStoppedAtMs < 3_000L || now - unexpectedImuStopAtMs < 30_000L) return + if (gatt == null || cmdCharacteristic == null) return + unexpectedImuStopAtMs = now + groundTruthImuCommandAllowed = true + try { + send(CommandNumber.STOP_RAW_DATA, byteArrayOf(1), withResponse = true) + send(CommandNumber.TOGGLE_IMU_MODE, byteArrayOf(1, 0), withResponse = true) + } finally { + groundTruthImuCommandAllowed = false + } + log("Raw IMU fail-safe: unexpected realtime packet type $type while capture was off; stop requested") + } + /** Frame reassembler for the fragmented custom notify chars (port of Reassembler). Reassigned per * connection with the detected family — WHOOP5/MG frames use a different length encoding. */ private var reassembler = Reassembler() @@ -6045,6 +6070,7 @@ class WhoopBleClient( // handleFrame's replayedOffload gate, so evaluating it twice bounds-checked + indexed // every offloaded frame for nothing. (The Swift 5/MG inbound loop already hoists this.) val offloadFrame = backfilling && isOffloadFrame(frame, connectedFamily) + stopUnexpectedRealtimeImu(frame, offloadFrame) noteWhoop5R22Telemetry(frame, offloadFrame) // #174 // #47: decode this frame ONCE and thread it to both consumers (the router below and the // live collector) instead of each re-parsing it — steady-state drops 2→1 parse per live diff --git a/android/app/src/main/java/com/noop/testcentre/GroundTruthCollector.kt b/android/app/src/main/java/com/noop/testcentre/GroundTruthCollector.kt index 05d4132e91..3356ec8ee1 100644 --- a/android/app/src/main/java/com/noop/testcentre/GroundTruthCollector.kt +++ b/android/app/src/main/java/com/noop/testcentre/GroundTruthCollector.kt @@ -14,6 +14,7 @@ import java.util.zip.ZipOutputStream import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.json.JSONObject +import java.util.UUID /** App-private, explicitly user-started bounded 5/MG raw-data capture with optional manual labels. */ class GroundTruthCollector private constructor(private val context: Context) { @@ -50,17 +51,29 @@ class GroundTruthCollector private constructor(private val context: Context) { val capturedEndedAtMs: Long?, val steps: Int, val stairs: Int, - val excludedWindows: Int, val comment: String, val active: Boolean, val exported: Boolean, + val lastExportedAtMs: Long?, + val markers: List, ) + data class Marker(val id: String, val atMs: Long, val type: String, val text: String) + data class CaptureStats(val bytes: Long, val coveredSeconds: Int, val expectedSeconds: Int) { + val missingSeconds get() = (expectedSeconds - coveredSeconds).coerceAtLeast(0) + val complete get() = expectedSeconds > 0 && missingSeconds == 0 + } + private val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) private val directory = File(context.filesDir, "ground-truth").apply { mkdirs() } - fun realtimeImuBytes(sessionId: String): Long = - File(directory, "realtime-imu-$sessionId.imus").takeIf(File::isFile)?.length() ?: 0L + fun captureStats(session: SessionSummary, nowMs: Long = System.currentTimeMillis()): CaptureStats { + val end = session.endedAtMs ?: nowMs + val from = session.startedAtMs / 1_000L + val to = end / 1_000L + val raw = ImuSessionFileStore(context).stats(session.id, from, to) + return CaptureStats(raw.bytes, raw.coveredSeconds, (to - from + 1).coerceAtLeast(0).toInt()) + } @Synchronized fun snapshot(): Snapshot = Snapshot( @@ -101,19 +114,6 @@ class GroundTruthCollector private constructor(private val context: Context) { return snapshot() } - @Synchronized - fun excludeLastMinutes(minutes: Int, nowMs: Long = System.currentTimeMillis()): Snapshot { - require(minutes in 1..240) { "Minutes must be between 1 and 240" } - val before = snapshot() - if (!before.active || before.sessionId == null) return before - val fromMs = maxOf(before.startedAtMs, nowMs - minutes * 60_000L) - append(before.sessionId, event(nowMs, KIND_EXCLUDE, before.steps, before.stairs, before.lastNoopSteps, null).apply { - put("from_ms", fromMs) - put("to_ms", nowMs) - }) - return snapshot() - } - @Synchronized fun setComment(sessionId: String, comment: String) { prefs.edit().putString(sessionCommentKey(sessionId), comment.take(4_000)).apply() @@ -151,10 +151,14 @@ class GroundTruthCollector private constructor(private val context: Context) { capturedEndedAtMs = capturedEnd.takeIf { hasCapture }, steps = last.optInt("steps_total"), stairs = last.optInt("stairs_total"), - excludedWindows = rows.count { it.optString("kind") == KIND_EXCLUDE }, comment = prefs.getString(sessionCommentKey(id), "").orEmpty(), active = isCurrent && current.active, exported = prefs.getBoolean(sessionExportedKey(id), isCurrent && current.exported), + lastExportedAtMs = prefs.getLong(sessionLastExportKey(id), 0L).takeIf { it > 0 }, + markers = rows.filter { it.optString("kind") == KIND_MARKER }.map { row -> + Marker(row.optString("marker_id"), row.optLong("at_ms"), + row.optString("marker_type", "Moment"), row.optString("text")) + }.sortedBy(Marker::atMs), ) } .sortedByDescending { it.startedAtMs } @@ -183,6 +187,7 @@ class GroundTruthCollector private constructor(private val context: Context) { .remove(sessionEndKey(sessionId)) .remove(sessionCommentKey(sessionId)) .remove(sessionExportedKey(sessionId)) + .remove(sessionLastExportKey(sessionId)) .remove(sessionUsedStartKey(sessionId)) .remove(sessionUsedEndKey(sessionId)) .remove(sessionCaptureKey(sessionId)) @@ -218,6 +223,49 @@ class GroundTruthCollector private constructor(private val context: Context) { return sessions().firstOrNull { it.id == id } } + @Synchronized + fun addMarker(sessionId: String, atMs: Long, type: String, text: String): Marker? { + val session = sessions().firstOrNull { it.id == sessionId } ?: return null + val end = session.endedAtMs ?: System.currentTimeMillis() + val marker = Marker(UUID.randomUUID().toString(), atMs.coerceIn(session.startedAtMs, end), + type.take(40), text.take(500)) + append(sessionId, JSONObject().apply { + put("at_ms", marker.atMs); put("kind", KIND_MARKER); put("marker_id", marker.id) + put("marker_type", marker.type); put("text", marker.text) + put("steps_total", session.steps); put("stairs_total", session.stairs) + }) + return marker + } + + @Synchronized + fun updateMarker(sessionId: String, marker: Marker): Boolean { + val session = sessions().firstOrNull { it.id == sessionId } ?: return false + val end = session.endedAtMs ?: System.currentTimeMillis() + return rewriteEvents(sessionId) { rows -> rows.map { row -> + if (row.optString("kind") == KIND_MARKER && row.optString("marker_id") == marker.id) + JSONObject(row.toString()).apply { + put("at_ms", marker.atMs.coerceIn(session.startedAtMs, end)) + put("marker_type", marker.type.take(40)); put("text", marker.text.take(500)) + } else row + }} + } + + @Synchronized + fun deleteMarker(sessionId: String, markerId: String): Boolean = rewriteEvents(sessionId) { rows -> + rows.filterNot { it.optString("kind") == KIND_MARKER && it.optString("marker_id") == markerId } + } + + private fun rewriteEvents(sessionId: String, transform: (List) -> List): Boolean { + val file = eventFile(sessionId); if (!file.isFile) return false + val rows = file.useLines { it.filter(String::isNotBlank).map(::JSONObject).toList() } + val content = transform(rows).joinToString("\n", postfix = "\n") { it.toString() } + val staged = File(file.parentFile, ".${file.name}.tmp") + return runCatching { + staged.writeText(content) + check(staged.renameTo(file)) { "Could not commit marker edit" } + }.onFailure { staged.delete() }.isSuccess + } + /** Delete every completed capture. Active captures are deliberately retained. */ @Synchronized fun deleteAllSessions(): Int { @@ -296,9 +344,8 @@ class GroundTruthCollector private constructor(private val context: Context) { .toList() } val endMs = summary.endedAtMs ?: events.lastOrNull()?.optLong("at_ms") ?: summary.startedAtMs - val exclusions = exclusionWindows(events) // A raw-data session is useful even without manual step labels. Export its complete bounded - // interval; labels and exclusion windows are optional annotations, not a prerequisite for data. + // interval; labels and markers are optional annotations, not a prerequisite for data. val sensorFrom = if (deviceId == null) 1L else summary.startedAtMs / 1_000L val sensorTo = if (deviceId == null) 0L else endMs / 1_000L val pinnedChunks = if (deviceId == null) emptyList() else @@ -327,7 +374,9 @@ class GroundTruthCollector private constructor(private val context: Context) { put("manual_steps", summary.steps) put("manual_stairs", summary.stairs) put("comment", summary.comment) - put("excluded_windows", summary.excludedWindows) + put("markers", org.json.JSONArray().apply { summary.markers.forEach { marker -> put(JSONObject().apply { + put("id", marker.id); put("at_ms", marker.atMs); put("type", marker.type); put("text", marker.text) + }) } }) if (id == snap.sessionId && snap.noopStepsAtStart != null) put("noop_steps_at_start", snap.noopStepsAtStart) if (id == snap.sessionId && snap.lastNoopSteps != null) put("noop_steps_at_end", snap.lastNoopSteps) put("device_family", "Android") @@ -345,9 +394,6 @@ class GroundTruthCollector private constructor(private val context: Context) { out.putNextEntry(ZipEntry("seconds.csv")) out.writerEntry(secondsCsv(events, sensorFrom * 1_000L, sensorTo * 1_000L)) - out.putNextEntry(ZipEntry("exclusions.csv")) - out.writerEntry(exclusionsCsv(events)) - out.putNextEntry(ZipEntry("algorithm-signals.csv")) out.bufferedWriterNoClose().use { writer -> writeAlgorithmSignalsCsv( @@ -356,7 +402,6 @@ class GroundTruthCollector private constructor(private val context: Context) { deviceId.orEmpty(), sensorFrom, sensorTo, - exclusions, ) writer.flush() } @@ -388,7 +433,8 @@ class GroundTruthCollector private constructor(private val context: Context) { out.closeEntry() } } - prefs.edit().putBoolean(sessionExportedKey(id), true).apply() + val exportedAt = System.currentTimeMillis() + prefs.edit().putBoolean(sessionExportedKey(id), true).putLong(sessionLastExportKey(id), exportedAt).apply() if (imuComplete) { ImuSessionFileStore(context).remove(id) File(directory, "realtime-imu-$id.imus").delete() @@ -444,9 +490,8 @@ class GroundTruthCollector private constructor(private val context: Context) { } private fun secondsCsv(events: List, startMs: Long, endMs: Long): String = buildString { - append("unix_s,manual_steps_delta,manual_stairs_delta,manual_steps_total,manual_stairs_total,noop_steps,excluded\n") + append("unix_s,manual_steps_delta,manual_stairs_delta,manual_steps_total,manual_stairs_total,noop_steps\n") val bySecond = events.groupBy { it.optLong("at_ms") / 1_000L } - val exclusions = exclusionWindows(events) var steps = 0; var stairs = 0; var noop: Int? = null for (second in startMs / 1_000L..endMs / 1_000L) { val rows = bySecond[second].orEmpty() @@ -457,24 +502,10 @@ class GroundTruthCollector private constructor(private val context: Context) { steps = last.optInt("steps_total", steps); stairs = last.optInt("stairs_total", stairs) if (last.has("noop_steps")) noop = last.optInt("noop_steps") } - val excluded = exclusions.any { second * 1_000L <= it.second && second * 1_000L + 999L >= it.first } - append("$second,$stepDelta,$stairDelta,$steps,$stairs,${noop ?: ""},$excluded\n") + append("$second,$stepDelta,$stairDelta,$steps,$stairs,${noop ?: ""}\n") } } - private fun exclusionsCsv(events: List): String = buildString { - append("from_ms,to_ms,from_unix_s,to_unix_s\n") - for ((from, to) in exclusionWindows(events)) append("$from,$to,${from / 1_000L},${to / 1_000L}\n") - } - - private fun exclusionWindows(events: List): List> = events - .filter { it.optString("kind") == KIND_EXCLUDE } - .mapNotNull { row -> - val from = row.optLong("from_ms", -1L) - val to = row.optLong("to_ms", -1L) - if (from >= 0L && to >= from) from to to else null - } - private fun csv(value: String): String = if (value.any { it == ',' || it == '"' || it == '\n' }) "\"${value.replace("\"", "\"\"")}\"" else value @@ -484,9 +515,8 @@ class GroundTruthCollector private constructor(private val context: Context) { deviceId: String, from: Long, to: Long, - exclusions: List>, ) { - out.write("unix_s,counter,activity_class,gravity_x,gravity_y,gravity_z,dynamic_accel,cadence,heart_rate,band_sleep_state,excluded\n") + out.write("unix_s,counter,activity_class,gravity_x,gravity_y,gravity_z,dynamic_accel,cadence,heart_rate,band_sleep_state\n") if (from > to || deviceId.isBlank()) return val seconds = (to - from + 1L).coerceIn(1L, 86_400L).toInt() val steps = repo.stepSamples(deviceId, from, to, seconds).associateBy { it.ts } @@ -498,7 +528,6 @@ class GroundTruthCollector private constructor(private val context: Context) { for (ts in timestamps) { val step = steps[ts] val grav = gravity[ts] - val excluded = exclusions.any { ts * 1_000L <= it.second && ts * 1_000L + 999L >= it.first } out.write(listOf( ts, step?.counter ?: "", @@ -510,7 +539,6 @@ class GroundTruthCollector private constructor(private val context: Context) { cadence[ts] ?: "", heartRate[ts] ?: "", sleepState[ts] ?: "", - excluded, ).joinToString(",")) out.write("\n") } @@ -576,6 +604,7 @@ class GroundTruthCollector private constructor(private val context: Context) { private fun sessionEndKey(id: String) = "session.$id.endedAtMs" private fun sessionCommentKey(id: String) = "session.$id.comment" private fun sessionExportedKey(id: String) = "session.$id.exported" + private fun sessionLastExportKey(id: String) = "session.$id.lastExportedAtMs" private fun sessionUsedStartKey(id: String) = "session.$id.usedStartedAtMs" private fun sessionUsedEndKey(id: String) = "session.$id.usedEndedAtMs" private fun sessionCaptureKey(id: String) = "session.$id.hasRealtimeCapture" @@ -595,7 +624,7 @@ class GroundTruthCollector private constructor(private val context: Context) { private const val KIND_KEY = "key" private const val KIND_UNDO_STEP = "undo_step" private const val KIND_UNDO_STAIR = "undo_stair" - private const val KIND_EXCLUDE = "exclude_window" + private const val KIND_MARKER = "marker" private const val PREFS = "noop_ground_truth_collector" private const val MAX_RANGE_MS = 7L * 24 * 60 * 60 * 1_000 diff --git a/android/app/src/main/java/com/noop/testcentre/ImuSessionFileStore.kt b/android/app/src/main/java/com/noop/testcentre/ImuSessionFileStore.kt index f8312c0c97..f77127f7a7 100644 --- a/android/app/src/main/java/com/noop/testcentre/ImuSessionFileStore.kt +++ b/android/app/src/main/java/com/noop/testcentre/ImuSessionFileStore.kt @@ -11,6 +11,7 @@ import java.util.zip.Inflater /** Routes realtime and delayed history IMU frames into persisted session time windows. */ class ImuSessionFileStore(private val context: Context) { + data class Stats(val bytes: Long, val coveredSeconds: Int) private val prefs = context.getSharedPreferences("imu-session-windows", Context.MODE_PRIVATE) private val directory = File(context.filesDir, "ground-truth").apply { mkdirs() } @@ -37,6 +38,15 @@ class ImuSessionFileStore(private val context: Context) { fun prepareForRead(id: String) = synchronized(lock) { flush(id) } + fun stats(id: String, from: Long, to: Long): Stats = synchronized(lock) { + val file = File(directory, "realtime-imu-$id.imus") + // Do not flush merely for UI telemetry: that would turn the intended 30-frame compression + // blocks into one-frame blocks. Pending raw bytes are a close live size estimate until flush. + val pendingBytes = pending[id].orEmpty().sumOf { it.frame.size.toLong() + 20L } + Stats((file.takeIf(File::isFile)?.length() ?: 0L) + pendingBytes, + timestamps(file).count { it in from..to }) + } + /** * Queue a wire frame for a 30-second compressed block. Delayed history may append older blocks later; * strap timestamps, not file order, are authoritative. diff --git a/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt b/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt index 9df4998215..a3437dd056 100644 --- a/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt +++ b/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt @@ -62,12 +62,17 @@ fun GroundTruthCollectorScreen(vm: AppViewModel) { var exportingSessionId by remember { mutableStateOf(null) } var sessions by remember { mutableStateOf(collector.sessions()) } var latestSensorTs by remember { mutableStateOf(null) } + var nowMs by remember { mutableStateOf(System.currentTimeMillis()) } + var captureStats by remember { mutableStateOf>(emptyMap()) } + var markerEditor by remember { mutableStateOf(null) } var sessionPendingDelete by remember { mutableStateOf(null) } var confirmDeleteAll by remember { mutableStateOf(false) } LaunchedEffect(vm.activeStrapId) { while (true) { + nowMs = System.currentTimeMillis() latestSensorTs = vm.activeStrapId.takeIf(String::isNotBlank)?.let { vm.repo.latestHrSampleTs(it) } + captureStats = sessions.associate { it.id to collector.captureStats(it, nowMs) } delay(1_000) } } @@ -126,7 +131,15 @@ fun GroundTruthCollectorScreen(vm: AppViewModel) { NoopCard { Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { - Text("Capture coverage", style = NoopType.headline, color = Palette.textPrimary) + val activeSession = sessions.firstOrNull { it.active } + val activeStats = activeSession?.let { captureStats[it.id] } + Text(if (activeSession == null) "Capture status" else "Recording · ${formatDuration(nowMs - activeSession.startedAtMs)}", + style = NoopType.headline, color = Palette.textPrimary) + if (activeStats != null) Text( + "IMU ${formatBytes(activeStats.bytes)} · ${coverageText(activeStats)}", + style = NoopType.body, + color = if (activeStats.coveredSeconds > 0) Palette.statusPositive else Palette.textSecondary, + ) Text( if (live.connected) "Band: connected${if (live.bonded) " + paired" else "; pairing"}" else "Band: disconnected${if (live.scanning) "; searching" else ""}", @@ -143,9 +156,9 @@ fun GroundTruthCollectorScreen(vm: AppViewModel) { color = Palette.textSecondary, ) Text( - latestSensorTs?.let { "Sensor data through: ${diagnosticTime(it * 1000)} (${diagnosticAge(it * 1000)} behind)" } - ?: "Sensor data through: none", - style = NoopType.body, + latestSensorTs?.let { "Decoded 1 Hz sensors: ${diagnosticAge(it * 1000)} behind" } + ?: "Decoded 1 Hz sensors: none", + style = NoopType.caption, color = Palette.textSecondary, ) Text( @@ -185,6 +198,12 @@ fun GroundTruthCollectorScreen(vm: AppViewModel) { } if (state.active) { + NoopButton( + text = "Add marker", + kind = NoopButtonKind.Secondary, + fullWidth = true, + onClick = { state.sessionId?.let { markerEditor = MarkerEditor(it, null, nowMs, "Moment", "") } }, + ) Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { NoopButton( text = stringResource(R.string.ground_truth_undo), @@ -240,7 +259,7 @@ fun GroundTruthCollectorScreen(vm: AppViewModel) { SessionCard( session = session, latestSensorTs = latestSensorTs, - realtimeImuBytes = collector.realtimeImuBytes(session.id), + stats = captureStats[session.id], exporting = exportingSessionId == session.id, onComment = { comment -> collector.setComment(session.id, comment) @@ -263,6 +282,8 @@ fun GroundTruthCollectorScreen(vm: AppViewModel) { } }, onDelete = { sessionPendingDelete = session }, + onAddMarker = { markerEditor = MarkerEditor(session.id, null, session.endedAtMs ?: nowMs, "Moment", "") }, + onEditMarker = { marker -> markerEditor = MarkerEditor(session.id, marker.id, marker.atMs, marker.type, marker.text) }, onEditStart = { pickDateTime(context, session.startedAtMs, EARLIEST_EXPORT_MS, session.endedAtMs ?: session.capturedEndedAtMs ?: session.startedAtMs) { value -> @@ -329,17 +350,58 @@ fun GroundTruthCollectorScreen(vm: AppViewModel) { }, ) } + + markerEditor?.let { editor -> + AlertDialog( + onDismissRequest = { markerEditor = null }, + title = { Text(if (editor.markerId == null) "Add marker" else "Edit marker") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text(diagnosticTime(editor.atMs), style = NoopType.headline) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + NoopButton("−10 s", kind = NoopButtonKind.Secondary, modifier = Modifier.weight(1f), + onClick = { markerEditor = editor.copy(atMs = editor.atMs - 10_000) }) + NoopButton("+10 s", kind = NoopButtonKind.Secondary, modifier = Modifier.weight(1f), + onClick = { markerEditor = editor.copy(atMs = editor.atMs + 10_000) }) + } + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) { + listOf("Moment", "Start", "End", "Issue").forEach { type -> + NoopButton(type, kind = if (editor.type == type) NoopButtonKind.Primary else NoopButtonKind.Secondary, + modifier = Modifier.weight(1f), onClick = { markerEditor = editor.copy(type = type) }) + } + } + OutlinedTextField(editor.text, { markerEditor = editor.copy(text = it.take(500)) }, + label = { Text("Marker note") }, modifier = Modifier.fillMaxWidth(), minLines = 2) + } + }, + dismissButton = { + Row { + if (editor.markerId != null) TextButton(onClick = { + collector.deleteMarker(editor.sessionId, editor.markerId); sessions = collector.sessions(); markerEditor = null + }) { Text("Delete", color = Palette.statusCritical) } + TextButton(onClick = { markerEditor = null }) { Text(stringResource(R.string.ground_truth_cancel)) } + } + }, + confirmButton = { TextButton(onClick = { + if (editor.markerId == null) collector.addMarker(editor.sessionId, editor.atMs, editor.type, editor.text) + else collector.updateMarker(editor.sessionId, GroundTruthCollector.Marker(editor.markerId, editor.atMs, editor.type, editor.text)) + sessions = collector.sessions(); markerEditor = null + }) { Text("Save") } }, + ) + } } @Composable private fun SessionCard( session: GroundTruthCollector.SessionSummary, latestSensorTs: Long?, - realtimeImuBytes: Long, + stats: GroundTruthCollector.CaptureStats?, exporting: Boolean, onComment: (String) -> Unit, onExport: () -> Unit, onDelete: () -> Unit, + onAddMarker: () -> Unit, + onEditMarker: (GroundTruthCollector.Marker) -> Unit, onEditStart: () -> Unit, onEditEnd: () -> Unit, ) { @@ -347,18 +409,23 @@ private fun SessionCard( NoopCard { Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { val sensorCovered = session.endedAtMs?.let { end -> latestSensorTs?.times(1000)?.let { it >= end } } == true - val captureReady = sensorCovered && realtimeImuBytes > 0 + val captureReady = stats?.complete == true Text( when { - session.endedAtMs == null -> "Export status: recording" - captureReady -> "Export status: ready (history covered, IMU ${realtimeImuBytes} bytes)" - !sensorCovered -> "Export status: wait for history sync through ${diagnosticTime(requireNotNull(session.endedAtMs))}" - else -> "Export status: no realtime IMU packets captured" + session.endedAtMs == null -> "Recording · ${stats?.let(::coverageText) ?: "waiting for IMU"}" + captureReady -> "Ready to export · complete" + stats != null && stats.coveredSeconds > 0 -> "Ready with ${stats.missingSeconds}s missing · history may repair it" + !sensorCovered -> "Waiting for history through ${diagnosticTime(requireNotNull(session.endedAtMs))}" + else -> "Ready without IMU data" }, style = NoopType.caption, color = if (captureReady) Palette.statusPositive else Palette.statusCritical, ) Text(time, style = NoopType.headline, color = Palette.textPrimary) + Text("${formatDuration((session.endedAtMs ?: System.currentTimeMillis()) - session.startedAtMs)} · IMU ${formatBytes(stats?.bytes ?: 0)}", + style = NoopType.caption, color = Palette.textSecondary) + session.lastExportedAtMs?.let { Text("Last exported ${diagnosticTime(it)} · export remains available", + style = NoopType.caption, color = Palette.statusPositive) } if (!session.active) { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { NoopButton( @@ -384,6 +451,12 @@ private fun SessionCard( style = NoopType.caption, color = Palette.textSecondary, ) + NoopButton("Add marker", kind = NoopButtonKind.Secondary, fullWidth = true, onClick = onAddMarker) + session.markers.forEach { marker -> + TextButton(onClick = { onEditMarker(marker) }, modifier = Modifier.fillMaxWidth()) { + Text("${diagnosticTime(marker.atMs)} · ${marker.type}${marker.text.takeIf(String::isNotBlank)?.let { " · $it" } ?: ""}") + } + } OutlinedTextField( value = session.comment, onValueChange = onComment, @@ -481,4 +554,31 @@ private fun diagnosticAge(epochMs: Long): String { } } +private data class MarkerEditor( + val sessionId: String, + val markerId: String?, + val atMs: Long, + val type: String, + val text: String, +) + +private fun formatDuration(milliseconds: Long): String { + val seconds = milliseconds.coerceAtLeast(0) / 1_000 + val hours = seconds / 3_600 + val minutes = (seconds % 3_600) / 60 + val tail = seconds % 60 + return if (hours > 0) "%d:%02d:%02d".format(hours, minutes, tail) else "%d:%02d".format(minutes, tail) +} + +private fun formatBytes(bytes: Long): String = when { + bytes >= 1024L * 1024L -> "%.1f MB".format(bytes / (1024.0 * 1024.0)) + bytes >= 1024L -> "%.1f KB".format(bytes / 1024.0) + else -> "$bytes B" +} + +private fun coverageText(stats: GroundTruthCollector.CaptureStats): String { + val percent = if (stats.expectedSeconds == 0) 0.0 else 100.0 * stats.coveredSeconds / stats.expectedSeconds + return "${stats.coveredSeconds}/${stats.expectedSeconds}s · %.1f%% · ${stats.missingSeconds}s missing".format(percent) +} + private const val EARLIEST_EXPORT_MS = 946_684_800_000L diff --git a/android/app/src/test/resources/raw_data_collector_parity.json b/android/app/src/test/resources/raw_data_collector_parity.json index e3c13ecb28..a7e34bd4ac 100644 --- a/android/app/src/test/resources/raw_data_collector_parity.json +++ b/android/app/src/test/resources/raw_data_collector_parity.json @@ -17,10 +17,6 @@ "swift": ["func undo(", "Undo last click"], "kotlin": ["fun undo(", "ground_truth_undo"] }, - "exclude_recent_period": { - "swift": ["excludeLast(minutes:", "Ignore recent period"], - "kotlin": ["excludeLastMinutes(", "ground_truth_exclude_recent"] - }, "session_comments": { "swift": ["setComment(", "Session comment"], "kotlin": ["setComment(", "ground_truth_comment"] diff --git a/android/gradle.properties b/android/gradle.properties index 4bfd0fe461..39e2831025 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,7 +1,8 @@ # Project-wide Gradle settings. -# JVM args for the Gradle daemon. 2g heap + file encoding is enough for this single-module app. -org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +# JVM args for the Gradle daemon. The build host has enough memory to keep resource processing and +# parallel verification from squeezing the daemon during staging builds. +org.gradle.jvmargs=-Xmx4096m -Dfile.encoding=UTF-8 # Heap for the KOTLIN COMPILE DAEMON, which is a separate process from the Gradle daemon above and # does NOT inherit org.gradle.jvmargs. Unset, it takes a default that the Compose compiler plugin @@ -11,10 +12,11 @@ org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 # Gradle's own failure hint names this exact property. Raised here rather than lifting # org.gradle.jvmargs so peak memory stays reasonable on a contributor's laptop — Gradle itself was # never the process that ran out. -kotlin.daemon.jvmargs=-Xmx4096m +kotlin.daemon.jvmargs=-Xmx6144m # Run independent tasks in parallel where the module graph allows. org.gradle.parallel=true +org.gradle.workers.max=6 # Reuse outputs from previous builds. org.gradle.caching=true From a699e217e11dc62415e9a16d6d4a910c4f6d5fba Mon Sep 17 00:00:00 2001 From: Bernd Helm Date: Thu, 27 Aug 2026 02:02:47 +0200 Subject: [PATCH 26/45] Measure IMU coverage on complete sensor seconds --- .../noop/testcentre/GroundTruthCollector.kt | 31 +++++++++++++++---- .../noop/testcentre/ImuSessionFileStore.kt | 5 +-- .../com/noop/ui/GroundTruthCollectorScreen.kt | 3 +- 3 files changed, 30 insertions(+), 9 deletions(-) diff --git a/android/app/src/main/java/com/noop/testcentre/GroundTruthCollector.kt b/android/app/src/main/java/com/noop/testcentre/GroundTruthCollector.kt index 3356ec8ee1..078a99e4cb 100644 --- a/android/app/src/main/java/com/noop/testcentre/GroundTruthCollector.kt +++ b/android/app/src/main/java/com/noop/testcentre/GroundTruthCollector.kt @@ -59,7 +59,12 @@ class GroundTruthCollector private constructor(private val context: Context) { ) data class Marker(val id: String, val atMs: Long, val type: String, val text: String) - data class CaptureStats(val bytes: Long, val coveredSeconds: Int, val expectedSeconds: Int) { + data class CaptureStats( + val bytes: Long, + val coveredSeconds: Int, + val expectedSeconds: Int, + val startupSeconds: Int, + ) { val missingSeconds get() = (expectedSeconds - coveredSeconds).coerceAtLeast(0) val complete get() = expectedSeconds > 0 && missingSeconds == 0 } @@ -69,10 +74,14 @@ class GroundTruthCollector private constructor(private val context: Context) { fun captureStats(session: SessionSummary, nowMs: Long = System.currentTimeMillis()): CaptureStats { val end = session.endedAtMs ?: nowMs - val from = session.startedAtMs / 1_000L - val to = end / 1_000L - val raw = ImuSessionFileStore(context).stats(session.id, from, to) - return CaptureStats(raw.bytes, raw.coveredSeconds, (to - from + 1).coerceAtLeast(0).toInt()) + val baseFrom = ceilSecond(session.startedAtMs) + val to = end / 1_000L - 1L + val raw = ImuSessionFileStore(context).stats(session.id, baseFrom, to) + val from = if (session.capturedStartedAtMs != null) maxOf(baseFrom, raw.firstTs ?: baseFrom) else baseFrom + val adjusted = if (from == baseFrom) raw.coveredSeconds else + ImuSessionFileStore(context).stats(session.id, from, to).coveredSeconds + return CaptureStats(raw.bytes, adjusted, (to - from + 1).coerceAtLeast(0).toInt(), + (from - baseFrom).coerceAtLeast(0).toInt()) } @Synchronized @@ -350,7 +359,11 @@ class GroundTruthCollector private constructor(private val context: Context) { val sensorTo = if (deviceId == null) 0L else endMs / 1_000L val pinnedChunks = if (deviceId == null) emptyList() else ImuChunkStore(context, repo).pin(id, deviceId, sensorFrom, sensorTo) - val imuComplete = covers(pinnedChunks, sensorFrom, sensorTo) + val fullFrom = ceilSecond(summary.startedAtMs) + val fullTo = endMs / 1_000L - 1L + val coverageFrom = if (summary.capturedStartedAtMs != null) + maxOf(fullFrom, pinnedChunks.minOfOrNull { it.startTs } ?: fullFrom) else fullFrom + val imuComplete = covers(pinnedChunks, coverageFrom, fullTo) val outDir = File(context.cacheDir, "logs").apply { mkdirs() } val zip = File(outDir, "noop-ground-truth-$id.zip") ZipOutputStream(zip.outputStream().buffered()).use { out -> @@ -363,6 +376,9 @@ class GroundTruthCollector private constructor(private val context: Context) { put("sensor_export_available", deviceId != null) put("imu_100hz_chunk_count", pinnedChunks.size) put("imu_100hz_complete", imuComplete) + put("imu_100hz_required_start_ts", coverageFrom) + put("imu_100hz_required_end_ts", fullTo) + put("imu_100hz_startup_seconds", (coverageFrom - fullFrom).coerceAtLeast(0)) put("imu_100hz_coverage", org.json.JSONArray().apply { pinnedChunks.forEach { chunk -> put(JSONObject().apply { put("start_ts", chunk.startTs); put("end_ts", chunk.endTs) @@ -454,6 +470,7 @@ class GroundTruthCollector private constructor(private val context: Context) { } private fun covers(chunks: List, from: Long, to: Long): Boolean { + if (from > to) return false var cursor = from for (chunk in chunks.sortedBy { it.startTs }) { val start = chunk.startTs; val end = chunk.endTs @@ -465,6 +482,8 @@ class GroundTruthCollector private constructor(private val context: Context) { return cursor > to } + private fun ceilSecond(epochMs: Long): Long = (epochMs + 999L) / 1_000L + private fun eventFile(id: String) = File(directory, "session-$id.jsonl") private fun append(id: String, value: JSONObject) = eventFile(id).appendText(value.toString() + "\n") diff --git a/android/app/src/main/java/com/noop/testcentre/ImuSessionFileStore.kt b/android/app/src/main/java/com/noop/testcentre/ImuSessionFileStore.kt index f77127f7a7..a494841b34 100644 --- a/android/app/src/main/java/com/noop/testcentre/ImuSessionFileStore.kt +++ b/android/app/src/main/java/com/noop/testcentre/ImuSessionFileStore.kt @@ -11,7 +11,7 @@ import java.util.zip.Inflater /** Routes realtime and delayed history IMU frames into persisted session time windows. */ class ImuSessionFileStore(private val context: Context) { - data class Stats(val bytes: Long, val coveredSeconds: Int) + data class Stats(val bytes: Long, val coveredSeconds: Int, val firstTs: Long?) private val prefs = context.getSharedPreferences("imu-session-windows", Context.MODE_PRIVATE) private val directory = File(context.filesDir, "ground-truth").apply { mkdirs() } @@ -43,8 +43,9 @@ class ImuSessionFileStore(private val context: Context) { // Do not flush merely for UI telemetry: that would turn the intended 30-frame compression // blocks into one-frame blocks. Pending raw bytes are a close live size estimate until flush. val pendingBytes = pending[id].orEmpty().sumOf { it.frame.size.toLong() + 20L } + val inWindow = timestamps(file).filter { it in from..to } Stats((file.takeIf(File::isFile)?.length() ?: 0L) + pendingBytes, - timestamps(file).count { it in from..to }) + inWindow.size, inWindow.minOrNull()) } /** diff --git a/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt b/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt index a3437dd056..05cfe53166 100644 --- a/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt +++ b/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt @@ -578,7 +578,8 @@ private fun formatBytes(bytes: Long): String = when { private fun coverageText(stats: GroundTruthCollector.CaptureStats): String { val percent = if (stats.expectedSeconds == 0) 0.0 else 100.0 * stats.coveredSeconds / stats.expectedSeconds - return "${stats.coveredSeconds}/${stats.expectedSeconds}s · %.1f%% · ${stats.missingSeconds}s missing".format(percent) + val startup = if (stats.startupSeconds > 0) " · ${stats.startupSeconds}s startup" else "" + return "${stats.coveredSeconds}/${stats.expectedSeconds}s · %.1f%% · ${stats.missingSeconds}s missing$startup".format(percent) } private const val EARLIEST_EXPORT_MS = 946_684_800_000L From 06a935e995323c8974bfa3c851ba8fa56fa9c9ec Mon Sep 17 00:00:00 2001 From: Bernd Helm Date: Thu, 27 Aug 2026 02:15:20 +0200 Subject: [PATCH 27/45] refactor(android): scope raw collector to capture sessions --- .../Resources/raw_data_collector_parity.json | 8 - android/app/build.gradle.kts | 2 +- .../noop/testcentre/GroundTruthCollector.kt | 184 ++----------- .../com/noop/ui/GroundTruthCollectorScreen.kt | 242 +++++------------- .../app/src/main/res/values-de/strings.xml | 47 +++- android/app/src/main/res/values/strings.xml | 47 +++- .../resources/raw_data_collector_parity.json | 8 - 7 files changed, 166 insertions(+), 372 deletions(-) diff --git a/StrandTests/Resources/raw_data_collector_parity.json b/StrandTests/Resources/raw_data_collector_parity.json index a7e34bd4ac..ed0cbe362b 100644 --- a/StrandTests/Resources/raw_data_collector_parity.json +++ b/StrandTests/Resources/raw_data_collector_parity.json @@ -9,14 +9,6 @@ "swift": ["RawDataSessionStore", "persist(session)"], "kotlin": ["GroundTruthCollector", "sessionStartKey"] }, - "manual_step_and_stair_labels": { - "swift": ["store.record(.step)", "store.record(.stair)"], - "kotlin": ["KIND_STEP", "KIND_STAIR"] - }, - "undo_last_label": { - "swift": ["func undo(", "Undo last click"], - "kotlin": ["fun undo(", "ground_truth_undo"] - }, "session_comments": { "swift": ["setComment(", "Session comment"], "kotlin": ["setComment(", "ground_truth_comment"] diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 362d85e4f2..faf3ec882b 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -26,7 +26,7 @@ android { applicationId = "com.noop.whoop" minSdk = 26 targetSdk = 34 - versionCode = 375 + versionCode = 376 versionName = "10.6.1" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" diff --git a/android/app/src/main/java/com/noop/testcentre/GroundTruthCollector.kt b/android/app/src/main/java/com/noop/testcentre/GroundTruthCollector.kt index 078a99e4cb..c17d8a9e35 100644 --- a/android/app/src/main/java/com/noop/testcentre/GroundTruthCollector.kt +++ b/android/app/src/main/java/com/noop/testcentre/GroundTruthCollector.kt @@ -16,29 +16,14 @@ import kotlinx.coroutines.withContext import org.json.JSONObject import java.util.UUID -/** App-private, explicitly user-started bounded 5/MG raw-data capture with optional manual labels. */ +/** App-private, explicitly user-started bounded 5/MG raw-data capture with annotations. */ class GroundTruthCollector private constructor(private val context: Context) { - data class KeyInfo( - val keyCode: Int, - val keyName: String, - val scanCode: Int, - val deviceId: Int, - val deviceName: String, - val source: Int, - ) - data class Snapshot( val active: Boolean, val sessionId: String?, val deviceId: String?, val startedAtMs: Long, val endedAtMs: Long, - val steps: Int, - val stairs: Int, - val lastKind: String?, - val lastKey: KeyInfo?, - val noopStepsAtStart: Int?, - val lastNoopSteps: Int?, val exported: Boolean, ) @@ -49,8 +34,6 @@ class GroundTruthCollector private constructor(private val context: Context) { val endedAtMs: Long?, val capturedStartedAtMs: Long?, val capturedEndedAtMs: Long?, - val steps: Int, - val stairs: Int, val comment: String, val active: Boolean, val exported: Boolean, @@ -91,17 +74,11 @@ class GroundTruthCollector private constructor(private val context: Context) { deviceId = prefs.getString("deviceId", null), startedAtMs = prefs.getLong("startedAtMs", 0L), endedAtMs = prefs.getLong("endedAtMs", 0L), - steps = prefs.getInt("steps", 0), - stairs = prefs.getInt("stairs", 0), - lastKind = prefs.getString("lastKind", null), - lastKey = prefs.getString("lastKey", null)?.let(::decodeKey), - noopStepsAtStart = prefs.getInt("noopStepsAtStart", Int.MIN_VALUE).takeUnless { it == Int.MIN_VALUE }, - lastNoopSteps = prefs.getInt("lastNoopSteps", Int.MIN_VALUE).takeUnless { it == Int.MIN_VALUE }, exported = prefs.getBoolean("exported", false), ) @Synchronized - fun start(noopSteps: Int?, deviceId: String, nowMs: Long = System.currentTimeMillis()): Snapshot { + fun start(deviceId: String, nowMs: Long = System.currentTimeMillis()): Snapshot { val id = nowMs.toString() prefs.edit() .putBoolean("active", true) @@ -109,16 +86,12 @@ class GroundTruthCollector private constructor(private val context: Context) { .putString("sessionId", id) .putString("deviceId", deviceId) .putLong("startedAtMs", nowMs) - .putInt("steps", 0) - .putInt("stairs", 0) - .applyNoopStart(noopSteps) - .applyNoop(noopSteps) .putString(sessionDeviceKey(id), deviceId) .putLong(sessionStartKey(id), nowMs) .putBoolean(sessionExportedKey(id), false) .putBoolean(sessionCaptureKey(id), true) .apply() - append(id, event(nowMs, "start", 0, 0, noopSteps, null).put("strap_device_id", deviceId)) + append(id, event(nowMs, "start").put("strap_device_id", deviceId)) ImuSessionFileStore(context).start(id, deviceId, nowMs) return snapshot() } @@ -143,7 +116,6 @@ class GroundTruthCollector private constructor(private val context: Context) { } }.getOrNull() ?: return@mapNotNull null val first = rows.firstOrNull() ?: return@mapNotNull null - val last = rows.last() val isCurrent = current.sessionId == id val physicalStart = prefs.getLong(sessionStartKey(id), first.optLong("at_ms")) val capturedEnd = rows.lastOrNull { it.optString("kind") == "stop" }?.optLong("at_ms") @@ -158,15 +130,13 @@ class GroundTruthCollector private constructor(private val context: Context) { endedAtMs = capturedEnd?.let { prefs.getLong(sessionUsedEndKey(id), it) }, capturedStartedAtMs = physicalStart.takeIf { hasCapture }, capturedEndedAtMs = capturedEnd.takeIf { hasCapture }, - steps = last.optInt("steps_total"), - stairs = last.optInt("stairs_total"), comment = prefs.getString(sessionCommentKey(id), "").orEmpty(), active = isCurrent && current.active, exported = prefs.getBoolean(sessionExportedKey(id), isCurrent && current.exported), lastExportedAtMs = prefs.getLong(sessionLastExportKey(id), 0L).takeIf { it > 0 }, markers = rows.filter { it.optString("kind") == KIND_MARKER }.map { row -> Marker(row.optString("marker_id"), row.optLong("at_ms"), - row.optString("marker_type", "Moment"), row.optString("text")) + row.optString("marker_type", "moment"), row.optString("text")) }.sortedBy(Marker::atMs), ) } @@ -226,8 +196,8 @@ class GroundTruthCollector private constructor(private val context: Context) { prefs.edit().putString(sessionDeviceKey(id), deviceId).putLong(sessionStartKey(id), fromMs) .putLong(sessionEndKey(id), toMs).putLong(sessionUsedStartKey(id), fromMs) .putLong(sessionUsedEndKey(id), toMs).putBoolean(sessionCaptureKey(id), false).apply() - append(id, event(fromMs, "start", 0, 0, null, null).put("strap_device_id", deviceId)) - append(id, event(toMs, "stop", 0, 0, null, null)) + append(id, event(fromMs, "start").put("strap_device_id", deviceId)) + append(id, event(toMs, "stop")) ImuSessionFileStore(context).register(id, deviceId, fromMs, toMs) return sessions().firstOrNull { it.id == id } } @@ -241,7 +211,6 @@ class GroundTruthCollector private constructor(private val context: Context) { append(sessionId, JSONObject().apply { put("at_ms", marker.atMs); put("kind", KIND_MARKER); put("marker_id", marker.id) put("marker_type", marker.type); put("text", marker.text) - put("steps_total", session.steps); put("stairs_total", session.stairs) }) return marker } @@ -283,59 +252,14 @@ class GroundTruthCollector private constructor(private val context: Context) { } @Synchronized - fun record(kind: String, key: KeyInfo, noopSteps: Int?, nowMs: Long = System.currentTimeMillis()): Snapshot { - val before = snapshot() - if (!before.active || before.sessionId == null) return before - val steps = before.steps + if (kind == KIND_STEP || kind == KIND_STAIR) 1 else 0 - val stairs = before.stairs + if (kind == KIND_STAIR) 1 else 0 - append(before.sessionId, event(nowMs, kind, steps, stairs, noopSteps, key)) - prefs.edit() - .putInt("steps", steps) - .putInt("stairs", stairs) - .putString("lastKind", kind) - .putString("lastKey", encodeKey(key)) - .applyNoop(noopSteps) - .apply() - return snapshot() - } - - /** Record an unmapped key for diagnosis without changing either ground-truth counter. */ - @Synchronized - fun observeKey(key: KeyInfo, noopSteps: Int?, nowMs: Long = System.currentTimeMillis()): Snapshot { - val before = snapshot() - prefs.edit().putString("lastKey", encodeKey(key)).applyNoop(noopSteps).apply() - if (before.active && before.sessionId != null) { - append(before.sessionId, event(nowMs, KIND_KEY, before.steps, before.stairs, noopSteps, key)) - } - return snapshot() - } - - @Synchronized - fun undo(noopSteps: Int?, nowMs: Long = System.currentTimeMillis()): Snapshot { - val before = snapshot() - if (!before.active || before.sessionId == null) return before - val undoKind = when (before.lastKind) { - KIND_STEP -> KIND_UNDO_STEP - KIND_STAIR -> KIND_UNDO_STAIR - else -> return before - } - val steps = (before.steps - 1).coerceAtLeast(0) - val stairs = (before.stairs - if (undoKind == KIND_UNDO_STAIR) 1 else 0).coerceAtLeast(0) - append(before.sessionId, event(nowMs, undoKind, steps, stairs, noopSteps, null)) - prefs.edit().putInt("steps", steps).putInt("stairs", stairs).remove("lastKind") - .applyNoop(noopSteps).apply() - return snapshot() - } - - @Synchronized - fun stop(noopSteps: Int?, nowMs: Long = System.currentTimeMillis()): Snapshot { + fun stop(nowMs: Long = System.currentTimeMillis()): Snapshot { val before = snapshot() if (!before.active || before.sessionId == null) return before - append(before.sessionId, event(nowMs, "stop", before.steps, before.stairs, noopSteps, null)) + append(before.sessionId, event(nowMs, "stop")) ImuSessionFileStore(context).complete(before.sessionId, nowMs) prefs.edit().putBoolean("active", false).putLong("endedAtMs", nowMs) .putLong(sessionEndKey(before.sessionId), nowMs) - .applyNoop(noopSteps).apply() + .apply() return snapshot() } @@ -353,8 +277,7 @@ class GroundTruthCollector private constructor(private val context: Context) { .toList() } val endMs = summary.endedAtMs ?: events.lastOrNull()?.optLong("at_ms") ?: summary.startedAtMs - // A raw-data session is useful even without manual step labels. Export its complete bounded - // interval; labels and markers are optional annotations, not a prerequisite for data. + // Export the complete bounded interval; markers are optional annotations. val sensorFrom = if (deviceId == null) 1L else summary.startedAtMs / 1_000L val sensorTo = if (deviceId == null) 0L else endMs / 1_000L val pinnedChunks = if (deviceId == null) emptyList() else @@ -387,29 +310,20 @@ class GroundTruthCollector private constructor(private val context: Context) { }) put("started_at_ms", summary.startedAtMs) put("ended_at_ms", endMs) - put("manual_steps", summary.steps) - put("manual_stairs", summary.stairs) put("comment", summary.comment) put("markers", org.json.JSONArray().apply { summary.markers.forEach { marker -> put(JSONObject().apply { put("id", marker.id); put("at_ms", marker.atMs); put("type", marker.type); put("text", marker.text) }) } }) - if (id == snap.sessionId && snap.noopStepsAtStart != null) put("noop_steps_at_start", snap.noopStepsAtStart) - if (id == snap.sessionId && snap.lastNoopSteps != null) put("noop_steps_at_end", snap.lastNoopSteps) put("device_family", "Android") put("app_version", BuildConfig.VERSION_NAME) - put("manual_labels", "optional; JX-05S ZOOM_OUT=step, ZOOM_IN=stair") }.toString(2)) out.putNextEntry(ZipEntry("events.jsonl")) - source.inputStream().use { it.copyTo(out) } - out.closeEntry() + out.writerEntry(eventsJsonl(events)) out.putNextEntry(ZipEntry("events.csv")) out.writerEntry(eventsCsv(events)) - out.putNextEntry(ZipEntry("seconds.csv")) - out.writerEntry(secondsCsv(events, sensorFrom * 1_000L, sensorTo * 1_000L)) - out.putNextEntry(ZipEntry("algorithm-signals.csv")) out.bufferedWriterNoClose().use { writer -> writeAlgorithmSignalsCsv( @@ -487,44 +401,31 @@ class GroundTruthCollector private constructor(private val context: Context) { private fun eventFile(id: String) = File(directory, "session-$id.jsonl") private fun append(id: String, value: JSONObject) = eventFile(id).appendText(value.toString() + "\n") - private fun event( - atMs: Long, kind: String, steps: Int, stairs: Int, noopSteps: Int?, key: KeyInfo?, - ) = JSONObject().apply { - put("at_ms", atMs); put("kind", kind); put("steps_total", steps); put("stairs_total", stairs) - if (noopSteps != null) put("noop_steps", noopSteps) - if (key != null) { - put("key_code", key.keyCode); put("key_name", key.keyName); put("scan_code", key.scanCode) - put("device_id", key.deviceId); put("device_name", key.deviceName); put("source", key.source) + private fun event(atMs: Long, kind: String) = JSONObject().apply { put("at_ms", atMs); put("kind", kind) } + + private fun publicEvents(events: List) = events.mapNotNull { source -> + val kind = source.optString("kind") + if (kind !in setOf("start", "stop", KIND_MARKER)) return@mapNotNull null + JSONObject().apply { + put("at_ms", source.optLong("at_ms")); put("kind", kind) + if (kind == KIND_MARKER) { + put("marker_id", source.optString("marker_id")); put("marker_type", source.optString("marker_type")) + put("text", source.optString("text")) + } + if (kind == "start" && source.has("strap_device_id")) put("strap_device_id", source.optString("strap_device_id")) } } + private fun eventsJsonl(events: List) = publicEvents(events).joinToString("\n", postfix = "\n") + private fun eventsCsv(events: List): String = buildString { - append("at_ms,unix_s,kind,steps_total,stairs_total,noop_steps,key_code,key_name,scan_code,device_id,device_name,source\n") - for (e in events) append(listOf( + append("at_ms,unix_s,kind,marker_id,marker_type,text\n") + for (e in publicEvents(events)) append(listOf( e.optLong("at_ms"), e.optLong("at_ms") / 1_000L, e.optString("kind"), - e.optInt("steps_total"), e.optInt("stairs_total"), e.opt("noop_steps") ?: "", - e.opt("key_code") ?: "", e.optString("key_name"), e.opt("scan_code") ?: "", - e.opt("device_id") ?: "", e.optString("device_name"), e.opt("source") ?: "", + e.optString("marker_id"), e.optString("marker_type"), e.optString("text"), ).joinToString(",") { csv(it.toString()) }).append('\n') } - private fun secondsCsv(events: List, startMs: Long, endMs: Long): String = buildString { - append("unix_s,manual_steps_delta,manual_stairs_delta,manual_steps_total,manual_stairs_total,noop_steps\n") - val bySecond = events.groupBy { it.optLong("at_ms") / 1_000L } - var steps = 0; var stairs = 0; var noop: Int? = null - for (second in startMs / 1_000L..endMs / 1_000L) { - val rows = bySecond[second].orEmpty() - val stepDelta = rows.count { it.optString("kind") == KIND_STEP || it.optString("kind") == KIND_STAIR } - - rows.count { it.optString("kind") == KIND_UNDO_STEP || it.optString("kind") == KIND_UNDO_STAIR } - val stairDelta = rows.count { it.optString("kind") == KIND_STAIR } - rows.count { it.optString("kind") == KIND_UNDO_STAIR } - rows.lastOrNull()?.let { last -> - steps = last.optInt("steps_total", steps); stairs = last.optInt("stairs_total", stairs) - if (last.has("noop_steps")) noop = last.optInt("noop_steps") - } - append("$second,$stepDelta,$stairDelta,$steps,$stairs,${noop ?: ""}\n") - } - } - private fun csv(value: String): String = if (value.any { it == ',' || it == '"' || it == '\n' }) "\"${value.replace("\"", "\"\"")}\"" else value @@ -585,24 +486,6 @@ class GroundTruthCollector private constructor(private val context: Context) { private fun decimal(value: Double): String = String.format(java.util.Locale.US, "%.9f", value) - private fun encodeKey(key: KeyInfo) = JSONObject().apply { - put("keyCode", key.keyCode); put("keyName", key.keyName); put("scanCode", key.scanCode) - put("deviceId", key.deviceId); put("deviceName", key.deviceName); put("source", key.source) - }.toString() - - private fun decodeKey(raw: String): KeyInfo = JSONObject(raw).let { - KeyInfo(it.getInt("keyCode"), it.getString("keyName"), it.getInt("scanCode"), - it.getInt("deviceId"), it.getString("deviceName"), it.getInt("source")) - } - - private fun android.content.SharedPreferences.Editor.applyNoop(value: Int?) = apply { - if (value == null) remove("lastNoopSteps") else putInt("lastNoopSteps", value) - } - - private fun android.content.SharedPreferences.Editor.applyNoopStart(value: Int?) = apply { - if (value == null) remove("noopStepsAtStart") else putInt("noopStepsAtStart", value) - } - private fun android.content.SharedPreferences.Editor.clearCurrentSession() = apply { remove("active") remove("exported") @@ -610,12 +493,6 @@ class GroundTruthCollector private constructor(private val context: Context) { remove("deviceId") remove("startedAtMs") remove("endedAtMs") - remove("steps") - remove("stairs") - remove("lastKind") - remove("lastKey") - remove("noopStepsAtStart") - remove("lastNoopSteps") } private fun sessionDeviceKey(id: String) = "session.$id.deviceId" @@ -638,11 +515,6 @@ class GroundTruthCollector private constructor(private val context: Context) { }.buffered() companion object { - const val KIND_STEP = "step" - const val KIND_STAIR = "stair" - private const val KIND_KEY = "key" - private const val KIND_UNDO_STEP = "undo_step" - private const val KIND_UNDO_STAIR = "undo_stair" private const val KIND_MARKER = "marker" private const val PREFS = "noop_ground_truth_collector" private const val MAX_RANGE_MS = 7L * 24 * 60 * 60 * 1_000 diff --git a/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt b/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt index 05cfe53166..b7a478f612 100644 --- a/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt +++ b/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt @@ -1,41 +1,27 @@ package com.noop.ui -import android.os.Build import android.app.DatePickerDialog import android.app.TimePickerDialog import android.content.Context -import android.view.HapticFeedbackConstants -import android.view.InputDevice -import android.view.KeyEvent import android.widget.Toast -import androidx.compose.foundation.focusable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.AlertDialog import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.noop.R @@ -43,22 +29,15 @@ import com.noop.testcentre.GroundTruthCollector import kotlinx.coroutines.delay import kotlinx.coroutines.launch -/** Bounded 5/MG raw-data capture. Manual clicker labels are an optional fork extension. */ +/** Bounded 5/MG raw-data capture. */ @Composable fun GroundTruthCollectorScreen(vm: AppViewModel) { val context = LocalContext.current val collector = remember { GroundTruthCollector.from(context) } - val focusRequester = remember { FocusRequester() } - val view = LocalView.current val scope = rememberCoroutineScope() - val days by vm.recentDays.collectAsStateWithLifecycle() val live by vm.live.collectAsStateWithLifecycle() val imuStatus by vm.ble.groundTruthImuStatus.collectAsStateWithLifecycle() - // Optional fork overlay only. The raw collector itself must not depend on the still-separate - // physiological day-cycle work; use the latest published total when the step counter is present. - val noopSteps = days.lastOrNull()?.steps var state by remember { mutableStateOf(collector.snapshot()) } - var haptics by remember { mutableStateOf(false) } var exportingSessionId by remember { mutableStateOf(null) } var sessions by remember { mutableStateOf(collector.sessions()) } var latestSensorTs by remember { mutableStateOf(null) } @@ -77,14 +56,6 @@ fun GroundTruthCollectorScreen(vm: AppViewModel) { } } - DisposableEffect(view) { - val oldKeepScreenOn = view.keepScreenOn - view.keepScreenOn = true - onDispose { - view.keepScreenOn = oldKeepScreenOn - } - } - LaunchedEffect(state.active, state.steps, state.stairs) { focusRequester.requestFocus() } // Re-arm after a BLE reconnect or Android process restart while the manual session is still active. LaunchedEffect(state.active, state.sessionId, live.connected) { if (state.active && live.connected) state.sessionId?.let(vm.ble::startGroundTruthImuCapture) @@ -93,142 +64,71 @@ fun GroundTruthCollectorScreen(vm: AppViewModel) { ScreenScaffold( title = stringResource(R.string.ground_truth_title), subtitle = stringResource(R.string.ground_truth_subtitle), - modifier = Modifier - .focusRequester(focusRequester) - .focusable() - .onPreviewKeyEvent { composeEvent -> - val event = composeEvent.nativeKeyEvent - if (event.action != KeyEvent.ACTION_DOWN || event.repeatCount != 0) return@onPreviewKeyEvent false - val key = event.toCollectorKey() - state = when (event.keyCode) { - KeyEvent.KEYCODE_ZOOM_OUT -> collector.record(GroundTruthCollector.KIND_STEP, key, noopSteps) - KeyEvent.KEYCODE_ZOOM_IN -> collector.record(GroundTruthCollector.KIND_STAIR, key, noopSteps) - else -> collector.observeKey(key, noopSteps) - } - val mapped = event.keyCode == KeyEvent.KEYCODE_ZOOM_OUT || event.keyCode == KeyEvent.KEYCODE_ZOOM_IN - if (mapped && haptics) { - view.performHapticFeedback(if (Build.VERSION.SDK_INT >= 30) HapticFeedbackConstants.CONFIRM else HapticFeedbackConstants.KEYBOARD_TAP) - } - mapped - }, ) { - NoopCard(tint = Palette.accent) { - Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { - Row(Modifier.fillMaxWidth()) { - CounterColumn(stringResource(R.string.ground_truth_noop_steps), noopSteps?.toString() ?: "-", Modifier.weight(1f)) - CounterColumn(stringResource(R.string.ground_truth_manual_steps), state.steps.toString(), Modifier.weight(1f)) - CounterColumn(stringResource(R.string.ground_truth_stairs), state.stairs.toString(), Modifier.weight(1f)) - } - val noopSessionSteps = state.noopStepsAtStart?.let { start -> noopSteps?.minus(start) } - val delta = noopSessionSteps?.minus(state.steps) - Text( - text = stringResource(R.string.ground_truth_delta, delta?.toString() ?: "-"), - style = NoopType.subhead, - color = Palette.textSecondary, - ) - } - } - NoopCard { Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { val activeSession = sessions.firstOrNull { it.active } val activeStats = activeSession?.let { captureStats[it.id] } - Text(if (activeSession == null) "Capture status" else "Recording · ${formatDuration(nowMs - activeSession.startedAtMs)}", + Text(if (activeSession == null) stringResource(R.string.ground_truth_capture_status) + else stringResource(R.string.ground_truth_recording, formatDuration(nowMs - activeSession.startedAtMs)), style = NoopType.headline, color = Palette.textPrimary) if (activeStats != null) Text( - "IMU ${formatBytes(activeStats.bytes)} · ${coverageText(activeStats)}", + stringResource(R.string.ground_truth_imu_coverage, formatBytes(activeStats.bytes), coverageText(activeStats)), style = NoopType.body, color = if (activeStats.coveredSeconds > 0) Palette.statusPositive else Palette.textSecondary, ) Text( - if (live.connected) "Band: connected${if (live.bonded) " + paired" else "; pairing"}" - else "Band: disconnected${if (live.scanning) "; searching" else ""}", + stringResource(when { + live.connected && live.bonded -> R.string.ground_truth_band_connected_paired + live.connected -> R.string.ground_truth_band_connected_pairing + live.scanning -> R.string.ground_truth_band_searching + else -> R.string.ground_truth_band_disconnected + }), style = NoopType.body, color = if (live.connected) Palette.statusPositive else Palette.statusCritical, ) Text( when { - live.backfilling -> "History sync: running (${live.syncChunksThisSession} chunks)" - live.lastSyncAt != null -> "History sync completed: ${diagnosticTime(live.lastSyncAt!! * 1000)}" - else -> "History sync: no completed sync recorded" + live.backfilling -> stringResource(R.string.ground_truth_history_running, live.syncChunksThisSession) + live.lastSyncAt != null -> stringResource(R.string.ground_truth_history_completed, diagnosticTime(live.lastSyncAt!! * 1000)) + else -> stringResource(R.string.ground_truth_history_none) }, style = NoopType.body, color = Palette.textSecondary, ) Text( - latestSensorTs?.let { "Decoded 1 Hz sensors: ${diagnosticAge(it * 1000)} behind" } - ?: "Decoded 1 Hz sensors: none", + latestSensorTs?.let { stringResource(R.string.ground_truth_sensors_behind, diagnosticAge(it * 1000)) } + ?: stringResource(R.string.ground_truth_sensors_none), style = NoopType.caption, color = Palette.textSecondary, ) Text( - "Realtime IMU: ${imuStatus.note}; ${imuStatus.packets} packets / ${imuStatus.bytes} bytes" + - (imuStatus.lastPacketAtMs?.let { "; last ${diagnosticAge(it)} ago" } ?: ""), + stringResource(R.string.ground_truth_realtime_imu, imuStatus.note, imuStatus.packets, imuStatus.bytes, + imuStatus.lastPacketAtMs?.let { stringResource(R.string.ground_truth_realtime_last, diagnosticAge(it)) }.orEmpty()), style = NoopType.body, color = if (imuStatus.packets > 0) Palette.statusPositive else Palette.textSecondary, ) } } - NoopCard { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text(stringResource(R.string.ground_truth_mapping_title), style = NoopType.headline, color = Palette.textPrimary) - Text(stringResource(R.string.ground_truth_mapping_body), style = NoopType.body, color = Palette.textSecondary) - val key = state.lastKey - Text( - text = if (key == null) stringResource(R.string.ground_truth_no_key) - else stringResource( - R.string.ground_truth_key_detail, - key.keyName, key.keyCode, key.scanCode, key.deviceName, key.deviceId, key.source, - ), - style = NoopType.caption, - color = Palette.textSecondary, - ) - } - } - - NoopCard { - Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { - Column(Modifier.weight(1f)) { - Text(stringResource(R.string.ground_truth_haptics), style = NoopType.body, color = Palette.textPrimary) - Text(stringResource(R.string.ground_truth_haptics_desc), style = NoopType.caption, color = Palette.textSecondary) - } - Switch(checked = haptics, onCheckedChange = { haptics = it }) - } - } - if (state.active) { NoopButton( - text = "Add marker", + text = stringResource(R.string.ground_truth_add_marker), kind = NoopButtonKind.Secondary, fullWidth = true, - onClick = { state.sessionId?.let { markerEditor = MarkerEditor(it, null, nowMs, "Moment", "") } }, + onClick = { state.sessionId?.let { markerEditor = MarkerEditor(it, null, nowMs, MARKER_MOMENT, "") } }, ) - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { - NoopButton( - text = stringResource(R.string.ground_truth_undo), - kind = NoopButtonKind.Secondary, - modifier = Modifier.weight(1f), - onClick = { state = collector.undo(noopSteps) }, - ) - NoopButton( - text = stringResource(R.string.ground_truth_stop), - kind = NoopButtonKind.Destructive, - modifier = Modifier.weight(1f), - onClick = { - vm.ble.stopGroundTruthImuCapture() - state = collector.stop(noopSteps) - sessions = collector.sessions() - }, - ) - } + NoopButton(text = stringResource(R.string.ground_truth_stop), kind = NoopButtonKind.Destructive, + fullWidth = true, onClick = { + vm.ble.stopGroundTruthImuCapture(); state = collector.stop(); sessions = collector.sessions() + }) } else { NoopButton( text = stringResource(R.string.ground_truth_start), fullWidth = true, enabled = vm.activeStrapId.isNotBlank(), onClick = { - state = collector.start(noopSteps, vm.activeStrapId) + state = collector.start(vm.activeStrapId) sessions = collector.sessions() }, ) @@ -282,7 +182,7 @@ fun GroundTruthCollectorScreen(vm: AppViewModel) { } }, onDelete = { sessionPendingDelete = session }, - onAddMarker = { markerEditor = MarkerEditor(session.id, null, session.endedAtMs ?: nowMs, "Moment", "") }, + onAddMarker = { markerEditor = MarkerEditor(session.id, null, session.endedAtMs ?: nowMs, MARKER_MOMENT, "") }, onEditMarker = { marker -> markerEditor = MarkerEditor(session.id, marker.id, marker.atMs, marker.type, marker.text) }, onEditStart = { pickDateTime(context, session.startedAtMs, EARLIEST_EXPORT_MS, @@ -354,7 +254,7 @@ fun GroundTruthCollectorScreen(vm: AppViewModel) { markerEditor?.let { editor -> AlertDialog( onDismissRequest = { markerEditor = null }, - title = { Text(if (editor.markerId == null) "Add marker" else "Edit marker") }, + title = { Text(stringResource(if (editor.markerId == null) R.string.ground_truth_add_marker else R.string.ground_truth_edit_marker)) }, text = { Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { Text(diagnosticTime(editor.atMs), style = NoopType.headline) @@ -364,21 +264,27 @@ fun GroundTruthCollectorScreen(vm: AppViewModel) { NoopButton("+10 s", kind = NoopButtonKind.Secondary, modifier = Modifier.weight(1f), onClick = { markerEditor = editor.copy(atMs = editor.atMs + 10_000) }) } - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) { - listOf("Moment", "Start", "End", "Issue").forEach { type -> - NoopButton(type, kind = if (editor.type == type) NoopButtonKind.Primary else NoopButtonKind.Secondary, - modifier = Modifier.weight(1f), onClick = { markerEditor = editor.copy(type = type) }) + listOf( + listOf(MARKER_MOMENT to R.string.ground_truth_marker_moment, MARKER_START to R.string.ground_truth_marker_start), + listOf(MARKER_END to R.string.ground_truth_marker_end, MARKER_ISSUE to R.string.ground_truth_marker_issue), + ).forEach { row -> + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + row.forEach { (type, label) -> + NoopButton(stringResource(label), + kind = if (editor.type == type) NoopButtonKind.Primary else NoopButtonKind.Secondary, + modifier = Modifier.weight(1f), onClick = { markerEditor = editor.copy(type = type) }) + } } } OutlinedTextField(editor.text, { markerEditor = editor.copy(text = it.take(500)) }, - label = { Text("Marker note") }, modifier = Modifier.fillMaxWidth(), minLines = 2) + label = { Text(stringResource(R.string.ground_truth_marker_note)) }, modifier = Modifier.fillMaxWidth(), minLines = 2) } }, dismissButton = { Row { if (editor.markerId != null) TextButton(onClick = { collector.deleteMarker(editor.sessionId, editor.markerId); sessions = collector.sessions(); markerEditor = null - }) { Text("Delete", color = Palette.statusCritical) } + }) { Text(stringResource(R.string.ground_truth_delete_confirm), color = Palette.statusCritical) } TextButton(onClick = { markerEditor = null }) { Text(stringResource(R.string.ground_truth_cancel)) } } }, @@ -386,7 +292,7 @@ fun GroundTruthCollectorScreen(vm: AppViewModel) { if (editor.markerId == null) collector.addMarker(editor.sessionId, editor.atMs, editor.type, editor.text) else collector.updateMarker(editor.sessionId, GroundTruthCollector.Marker(editor.markerId, editor.atMs, editor.type, editor.text)) sessions = collector.sessions(); markerEditor = null - }) { Text("Save") } }, + }) { Text(stringResource(R.string.ground_truth_save)) } }, ) } } @@ -412,19 +318,21 @@ private fun SessionCard( val captureReady = stats?.complete == true Text( when { - session.endedAtMs == null -> "Recording · ${stats?.let(::coverageText) ?: "waiting for IMU"}" - captureReady -> "Ready to export · complete" - stats != null && stats.coveredSeconds > 0 -> "Ready with ${stats.missingSeconds}s missing · history may repair it" - !sensorCovered -> "Waiting for history through ${diagnosticTime(requireNotNull(session.endedAtMs))}" - else -> "Ready without IMU data" + session.endedAtMs == null -> stringResource(R.string.ground_truth_recording, + stats?.let { coverageText(it) } ?: stringResource(R.string.ground_truth_waiting_imu)) + captureReady -> stringResource(R.string.ground_truth_ready_complete) + stats != null && stats.coveredSeconds > 0 -> stringResource(R.string.ground_truth_ready_missing, stats.missingSeconds) + !sensorCovered -> stringResource(R.string.ground_truth_waiting_history, diagnosticTime(requireNotNull(session.endedAtMs))) + else -> stringResource(R.string.ground_truth_ready_no_imu) }, style = NoopType.caption, color = if (captureReady) Palette.statusPositive else Palette.statusCritical, ) Text(time, style = NoopType.headline, color = Palette.textPrimary) - Text("${formatDuration((session.endedAtMs ?: System.currentTimeMillis()) - session.startedAtMs)} · IMU ${formatBytes(stats?.bytes ?: 0)}", + Text(stringResource(R.string.ground_truth_duration_size, + formatDuration((session.endedAtMs ?: System.currentTimeMillis()) - session.startedAtMs), formatBytes(stats?.bytes ?: 0)), style = NoopType.caption, color = Palette.textSecondary) - session.lastExportedAtMs?.let { Text("Last exported ${diagnosticTime(it)} · export remains available", + session.lastExportedAtMs?.let { Text(stringResource(R.string.ground_truth_last_exported, diagnosticTime(it)), style = NoopType.caption, color = Palette.statusPositive) } if (!session.active) { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { @@ -442,19 +350,12 @@ private fun SessionCard( ) } } - Text( - stringResource( - R.string.ground_truth_session_summary, - session.steps, - session.stairs, - ), - style = NoopType.caption, - color = Palette.textSecondary, - ) - NoopButton("Add marker", kind = NoopButtonKind.Secondary, fullWidth = true, onClick = onAddMarker) + NoopButton(stringResource(R.string.ground_truth_add_marker), kind = NoopButtonKind.Secondary, fullWidth = true, onClick = onAddMarker) session.markers.forEach { marker -> TextButton(onClick = { onEditMarker(marker) }, modifier = Modifier.fillMaxWidth()) { - Text("${diagnosticTime(marker.atMs)} · ${marker.type}${marker.text.takeIf(String::isNotBlank)?.let { " · $it" } ?: ""}") + Text(stringResource(R.string.ground_truth_marker_row, diagnosticTime(marker.atMs), + markerTypeLabel(marker.type), marker.text.takeIf(String::isNotBlank) + ?.let { stringResource(R.string.ground_truth_marker_note_suffix, it) }.orEmpty())) } } OutlinedTextField( @@ -522,26 +423,6 @@ private fun pickDateTime( dateDialog.show() } -@Composable -private fun CounterColumn(label: String, value: String, modifier: Modifier = Modifier) { - Column(modifier = modifier.padding(horizontal = 4.dp), horizontalAlignment = Alignment.CenterHorizontally) { - Text(value, style = NoopType.number(32f), color = Palette.textPrimary) - Text(label, style = NoopType.footnote, color = Palette.textSecondary, textAlign = TextAlign.Center, maxLines = 2) - } -} - -private fun KeyEvent.toCollectorKey(): GroundTruthCollector.KeyInfo { - val input = InputDevice.getDevice(deviceId) - return GroundTruthCollector.KeyInfo( - keyCode = keyCode, - keyName = KeyEvent.keyCodeToString(keyCode), - scanCode = scanCode, - deviceId = deviceId, - deviceName = input?.name ?: "unknown", - source = source, - ) -} - private fun diagnosticTime(epochMs: Long): String = java.text.SimpleDateFormat("HH:mm:ss", java.util.Locale.getDefault()).format(java.util.Date(epochMs)) @@ -576,10 +457,25 @@ private fun formatBytes(bytes: Long): String = when { else -> "$bytes B" } +@Composable private fun coverageText(stats: GroundTruthCollector.CaptureStats): String { val percent = if (stats.expectedSeconds == 0) 0.0 else 100.0 * stats.coveredSeconds / stats.expectedSeconds - val startup = if (stats.startupSeconds > 0) " · ${stats.startupSeconds}s startup" else "" - return "${stats.coveredSeconds}/${stats.expectedSeconds}s · %.1f%% · ${stats.missingSeconds}s missing$startup".format(percent) + val startup = if (stats.startupSeconds > 0) stringResource(R.string.ground_truth_coverage_startup, stats.startupSeconds) else "" + return stringResource(R.string.ground_truth_coverage, stats.coveredSeconds, stats.expectedSeconds, + percent, stats.missingSeconds, startup) } +@Composable +private fun markerTypeLabel(type: String): String = stringResource(when (type.lowercase()) { + MARKER_START -> R.string.ground_truth_marker_start + MARKER_END -> R.string.ground_truth_marker_end + MARKER_ISSUE -> R.string.ground_truth_marker_issue + else -> R.string.ground_truth_marker_moment +}) + +private const val MARKER_MOMENT = "moment" +private const val MARKER_START = "start" +private const val MARKER_END = "end" +private const val MARKER_ISSUE = "issue" + private const val EARLIEST_EXPORT_MS = 946_684_800_000L diff --git a/android/app/src/main/res/values-de/strings.xml b/android/app/src/main/res/values-de/strings.xml index 2cd61e4fcc..e57b0d44b2 100644 --- a/android/app/src/main/res/values-de/strings.xml +++ b/android/app/src/main/res/values-de/strings.xml @@ -2468,28 +2468,16 @@ Android um Kopplung bitten 5/MG-Rohdaten-Collector Zeichnet eine begrenzte Bewegungssession mit 100 Hz auf, ergänzt Bluetooth-Lücken aus dem Strap-Verlauf und exportiert die vollständige Zeitachse. - Starte und exportiere eine begrenzte 5/MG-Bewegungsaufnahme. Manuelle Schritt- und Stufenmarkierungen sind optional und ändern niemals deine Gesundheitsdaten. + Zeichne eine begrenzte 5/MG-Bewegungsaufnahme mit 100 Hz auf, markiere, vervollständige und exportiere sie. Rohdaten-Collector öffnen - NOOP-Schritte - Manuelle Schritte - Stufen - NOOP − manuell: %1$s - Optionale manuelle Markierungen - Für die Schrittalgorithmus-Forschung markiert Verkleinern einen Schritt und Vergrößern eine Stufe plus einen Schritt. Die Rohdatenaufnahme funktioniert ohne Klicker. - Drücke eine Hardwaretaste, um ihren Tastencode zu sehen. - %1$s · Taste %2$d · Scan %3$d\n%4$s · Gerät %5$d · Quelle 0x%6$x - Bei markiertem Klick vibrieren - Optionale Bestätigung; standardmäßig aus, um bei langen Sessions Akku zu sparen. Rohdaten-Session starten Vergangenes Zeitfenster hinzufügen - Letzten Klick rückgängig Session beenden Session exportieren Export wird erstellt… Session konnte nicht exportiert werden: %1$s Aufgezeichnete Sessions Noch keine Sessions aufgezeichnet. - %1$d Schritte · %2$d Stufen Kommentar zur Session Session löschen Diese Session löschen? @@ -2502,4 +2490,37 @@ Start ändern Ende ändern Ältere Session: Ereignisse können exportiert werden, der zugehörige Strap für Sensordaten ist aber nicht mehr eindeutig. + Aufnahmestatus + Aufnahme · %1$s + IMU %1$s · %2$s + Band: verbunden + gekoppelt + Band: verbunden; Kopplung läuft + Band: getrennt + Band: getrennt; Suche läuft + Verlaufssynchronisierung läuft (%1$d Blöcke) + Verlauf synchronisiert: %1$s + Noch keine abgeschlossene Verlaufssynchronisierung + Dekodierte 1-Hz-Sensoren: %1$s zurück + Dekodierte 1-Hz-Sensoren: keine + Echtzeit-IMU: %1$s; %2$d Pakete / %3$d Bytes%4$s + ; zuletzt vor %1$s + Marker hinzufügen + Marker bearbeiten + Moment + Start + Ende + Problem + Marker-Notiz + Speichern + warte auf IMU + Exportbereit · vollständig + Exportbereit mit %1$d s Lücke · Verlauf kann sie ergänzen + Warte auf Verlauf bis %1$s + Exportbereit ohne IMU-Daten + %1$s · IMU %2$s + Zuletzt exportiert: %1$s · weiterer Export möglich + %1$d/%2$d s · %3$.1f%% · %4$d s fehlen%5$s + · %1$d s Anlauf + %1$s · %2$s%3$s + · %1$s diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 400108be05..957ce9f551 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -2480,28 +2480,16 @@ Your strap banks the raw optical channels, but NOOP does not turn them into a blood oxygen percentage, so this stays empty however long you wear it. Import a WHOOP export in Data Sources to fill it from your account history. 5/MG Raw Data Collector Record a bounded 100 Hz motion session, recover Bluetooth gaps from strap history, and export the complete timeline. - Start and export a bounded 5/MG motion capture. Manual step and stair labels are optional and never change your health data. + Record, annotate, recover, and export a bounded 5/MG 100 Hz motion capture. Open raw-data collector - NOOP steps - Manual steps - Stairs - NOOP − manual: %1$s - Optional manual labels - For step-algorithm research, Zoom Out labels one step and Zoom In labels one stair plus one step. Raw capture works without a clicker. - Press a hardware button to inspect its key code. - %1$s · key %2$d · scan %3$d\n%4$s · device %5$d · source 0x%6$x - Vibrate on a counted press - Optional confirmation; disabled by default to save battery during long sessions. Start raw-data session Add past time window - Undo last click Stop session Export session Building export… Couldn’t export session: %1$s Recorded sessions No sessions recorded yet. - %1$d steps · %2$d stairs Session comment Delete session Delete this session? @@ -2514,4 +2502,37 @@ Edit start Edit end Legacy session: event export is available, but its strap cannot be identified for sensor export. + Capture status + Recording · %1$s + IMU %1$s · %2$s + Band: connected + paired + Band: connected; pairing + Band: disconnected + Band: disconnected; searching + History sync: running (%1$d chunks) + History sync completed: %1$s + History sync: no completed sync recorded + Decoded 1 Hz sensors: %1$s behind + Decoded 1 Hz sensors: none + Realtime IMU: %1$s; %2$d packets / %3$d bytes%4$s + ; last %1$s ago + Add marker + Edit marker + Moment + Start + End + Issue + Marker note + Save + waiting for IMU + Ready to export · complete + Ready with %1$d s missing · history may repair it + Waiting for history through %1$s + Ready without IMU data + %1$s · IMU %2$s + Last exported %1$s · export remains available + %1$d/%2$d s · %3$.1f%% · %4$d s missing%5$s + · %1$d s startup + %1$s · %2$s%3$s + · %1$s diff --git a/android/app/src/test/resources/raw_data_collector_parity.json b/android/app/src/test/resources/raw_data_collector_parity.json index a7e34bd4ac..ed0cbe362b 100644 --- a/android/app/src/test/resources/raw_data_collector_parity.json +++ b/android/app/src/test/resources/raw_data_collector_parity.json @@ -9,14 +9,6 @@ "swift": ["RawDataSessionStore", "persist(session)"], "kotlin": ["GroundTruthCollector", "sessionStartKey"] }, - "manual_step_and_stair_labels": { - "swift": ["store.record(.step)", "store.record(.stair)"], - "kotlin": ["KIND_STEP", "KIND_STAIR"] - }, - "undo_last_label": { - "swift": ["func undo(", "Undo last click"], - "kotlin": ["fun undo(", "ground_truth_undo"] - }, "session_comments": { "swift": ["setComment(", "Session comment"], "kotlin": ["setComment(", "ground_truth_comment"] From 2d3878a82d07d7ad8c9f92e3ec5329a9bd8a4d0c Mon Sep 17 00:00:00 2001 From: Bernd Helm Date: Thu, 27 Aug 2026 19:25:04 +0200 Subject: [PATCH 28/45] fix: harden raw data capture lifecycle --- .../Sources/WhoopStore/Database.swift | 14 +- .../WhoopStore/DeviceRegistryStore.swift | 2 +- .../Sources/WhoopStore/ImuChunkCatalog.swift | 20 ++ .../Sources/WhoopStore/StreamStore.swift | 8 +- Strand/BLE/BLEManager.swift | 25 +++ Strand/BLE/PuffinDeepBufferLog.swift | 6 +- Strand/Collect/Collector.swift | 12 ++ Strand/Collect/ImuChunkArchiveStore.swift | 69 +++++- Strand/Collect/RawDataSessionStore.swift | 158 +++++++------- Strand/Data/DeviceRegistry.swift | 2 + Strand/Resources/Localizable.xcstrings | 66 +++++- Strand/Screens/RawDataCollectorView.swift | 197 +++++++++++++----- Strand/Screens/TestCentreView.swift | 4 +- StrandTests/RawDataCollectorParityTests.swift | 8 + StrandTests/RawDataSessionStoreTests.swift | 147 ++++++++++++- .../Resources/raw_data_collector_parity.json | 6 +- android/app/build.gradle.kts | 2 +- .../java/com/noop/ble/PuffinDeepBufferLog.kt | 4 +- .../main/java/com/noop/ble/WhoopBleClient.kt | 6 +- .../main/java/com/noop/data/DeviceRegistry.kt | 2 + .../java/com/noop/data/DeviceRegistryDao.kt | 2 + .../src/main/java/com/noop/data/WhoopDao.kt | 9 + .../main/java/com/noop/data/WhoopDatabase.kt | 4 +- .../java/com/noop/data/WhoopRepository.kt | 13 +- .../java/com/noop/protocol/Whoop5RawImu.kt | 6 +- .../noop/testcentre/GroundTruthCollector.kt | 168 ++++++--------- .../java/com/noop/testcentre/ImuChunkStore.kt | 69 +++++- .../src/main/java/com/noop/ui/AppViewModel.kt | 10 +- .../com/noop/ui/GroundTruthCollectorScreen.kt | 14 +- .../main/java/com/noop/ui/SettingsScreen.kt | 2 +- .../main/java/com/noop/ui/TestCentreScreen.kt | 26 +-- .../app/src/main/res/values-de/strings.xml | 16 ++ .../app/src/main/res/values-es/strings.xml | 73 +++++++ .../app/src/main/res/values-fr/strings.xml | 73 +++++++ .../app/src/main/res/values-pl/strings.xml | 73 +++++++ .../src/main/res/values-pt-rPT/strings.xml | 73 +++++++ .../app/src/main/res/values-zh/strings.xml | 73 +++++++ android/app/src/main/res/values/strings.xml | 16 ++ .../analytics/RegistryDayOwnerSourceTest.kt | 2 + .../noop/ble/SourceCoordinatorAdoptionTest.kt | 2 + .../java/com/noop/data/DeviceRegistryTest.kt | 4 +- .../java/com/noop/data/RawImuMigrationTest.kt | 2 +- .../RawCaptureExportContractTest.kt | 112 ++++++++++ .../resources/raw_data_collector_parity.json | 6 +- android/gradle.properties | 8 +- docs/RAW_DATA_CAPTURE.md | 25 ++- 46 files changed, 1292 insertions(+), 347 deletions(-) create mode 100644 android/app/src/test/java/com/noop/testcentre/RawCaptureExportContractTest.kt diff --git a/Packages/WhoopStore/Sources/WhoopStore/Database.swift b/Packages/WhoopStore/Sources/WhoopStore/Database.swift index 502a445e82..558f232c82 100644 --- a/Packages/WhoopStore/Sources/WhoopStore/Database.swift +++ b/Packages/WhoopStore/Sources/WhoopStore/Database.swift @@ -572,16 +572,8 @@ extension WhoopStore { // the six wire columns (ax…az,gx…gz). Twin of the Android `rawImuSample` table (MIGRATION_20_21); // same column order + PK so a `.noopbak` round-trips byte-for-byte. // - // CONSUMER STATUS — deliberately none on this platform, in the same shape as the `ppgWaveformSample` - // (v27) and `v18AuxSample` (v31) notes. The writer (`Collector.storeRawImu`) is instrument-first: it - // fires only with raw capture enabled AND a 5/MG deep-data unlock, and is bounded to - // `WhoopStore.rawImuRetentionRows` (3600 one-second buffers, a rolling window — not a corpus). No - // analytic, score, gate, UI or export reads a row: the only SQL is the retention DELETE (`StreamStore`) - // plus a COUNT in the storage-stats readout (`LocalAccessCore`); `ImuFeatureExtractor` takes the - // protocol struct, never this table, so it is not a consumer either. Swift has NO reader yet, on - // purpose — a reader lands WITH a validated consumer, not before ("artifact, not one match", CLAUDE.md). - // Android keeps a dormant `rawImuSamples` reader (zero callers) for the eventual cross-check; do NOT - // "clean up" either side as dead code — the retained rows are the deliverable. Audit: #978. + // Historical schema only. Deployed installations are known not to contain usable rows; v41 removes it + // after the file-backed session format is installed. migrator.registerMigration("v28-raw-imu") { db in try db.create(table: "rawImuSample") { t in t.column("deviceId", .text).notNull() @@ -895,7 +887,7 @@ extension WhoopStore { try db.create(index: "index_imuChunk_deviceId_startTs_endTs", on: "imuChunk", columns: ["deviceId", "startTs", "endTs"]) } - // IMU session payloads are append-only compressed files; SQLite stores only their catalog. + // The retired opt-in cache is known empty in deployed NOOP installations; session capture uses files. migrator.registerMigration("v41-drop-raw-imu-sample") { db in try db.drop(table: "rawImuSample") } diff --git a/Packages/WhoopStore/Sources/WhoopStore/DeviceRegistryStore.swift b/Packages/WhoopStore/Sources/WhoopStore/DeviceRegistryStore.swift index 0793863242..ee14cc898f 100644 --- a/Packages/WhoopStore/Sources/WhoopStore/DeviceRegistryStore.swift +++ b/Packages/WhoopStore/Sources/WhoopStore/DeviceRegistryStore.swift @@ -131,7 +131,7 @@ public struct DeviceRegistryStore: Sendable { // deviceId-keyed exactly like every other per-second stream above — must be cleared too, or a // "delete all of this device's data" leaves the raw waveform behind (the same privacy defect // this list exists to close). - "ppgWaveformSample", + "ppgWaveformSample", "imuChunk", // v28-raw-imu (#423): the opt-in 5/MG raw-IMU offload capture is deviceId-keyed too — "delete all // of this device's data" must clear it, or the raw inertial samples survive deletion (same defect). // v31-deep-capture-channels: the banked 5/MG v18 auxiliary fields are deviceId-keyed per-second diff --git a/Packages/WhoopStore/Sources/WhoopStore/ImuChunkCatalog.swift b/Packages/WhoopStore/Sources/WhoopStore/ImuChunkCatalog.swift index c2bf14ea77..9f79c98cd7 100644 --- a/Packages/WhoopStore/Sources/WhoopStore/ImuChunkCatalog.swift +++ b/Packages/WhoopStore/Sources/WhoopStore/ImuChunkCatalog.swift @@ -59,4 +59,24 @@ extension WhoopStore { } } } + + public func imuChunks(idPrefix: String) async throws -> [ImuChunkMeta] { + try syncRead { db in + try Row.fetchAll(db, sql: "SELECT * FROM imuChunk WHERE id LIKE ? ORDER BY startTs", + arguments: [idPrefix + "%"]).map { row in + ImuChunkMeta(id: row["id"], deviceId: row["deviceId"], startTs: row["startTs"], + endTs: row["endTs"], sampleCount: row["sampleCount"], + sampleRate: row["sampleRate"], formatVersion: row["formatVersion"], + codec: row["codec"], relativePath: row["relativePath"], + byteSize: row["byteSize"], sha256: row["sha256"], + createdAt: row["createdAt"], pinnedUntil: row["pinnedUntil"]) + } + } + } + + public func deleteImuChunk(id: String) async throws { + try syncWrite { db in + try db.execute(sql: "DELETE FROM imuChunk WHERE id = ?", arguments: [id]) + } + } } diff --git a/Packages/WhoopStore/Sources/WhoopStore/StreamStore.swift b/Packages/WhoopStore/Sources/WhoopStore/StreamStore.swift index f83a1d50e3..5d15437044 100644 --- a/Packages/WhoopStore/Sources/WhoopStore/StreamStore.swift +++ b/Packages/WhoopStore/Sources/WhoopStore/StreamStore.swift @@ -60,8 +60,7 @@ extension WhoopStore { /// v31 rolling retention for the v18 aux-slot table (twin of Kotlin `V18_AUX_RETENTION_ROWS`). /// - /// `rawImuSample` is the closest precedent — raw instrumentation banked as a blob, capped rather than - /// unbounded — and the same reasoning applies here: nothing reads these rows yet, so a cap is far + /// Raw instrumentation must be capped rather than unbounded. Nothing reads these rows yet, so a cap is far /// cheaper to RELAX later than to impose once users have a year of history. Unbounded, this table is /// the one genuinely new source of row growth in v31 (the four named channels only WIDEN rows that /// were already being written: ~14 bytes on a `gravitySample`/`skinTempSample`/`sleepStateSample` row @@ -325,9 +324,8 @@ extension WhoopStore { return (hr, rr, ev, bat, spo2, skin, resp, grav) } - // Rolling retention (the `insertRawImu` shape, #423) but AMORTISED. The delete finds the - // Nth-newest row by rank, so it walks up to `v18AuxRetentionRows` index entries; `insertRawImu` - // keeps 3,600 so that is free, this keeps 604,800 and an offload inserts once per chunk. Swept + // Rolling retention is amortised. The delete finds the Nth-newest row by rank, so it walks up to + // `v18AuxRetentionRows` index entries. The 604,800-row cap is swept // once per `v18AuxPruneEveryRows` rows instead, which keeps newest-N-rows exactly (a time window // would not — a sporadically-worn strap's rows span far more than a week, and the census wants // that). Counter is per device because the delete is. diff --git a/Strand/BLE/BLEManager.swift b/Strand/BLE/BLEManager.swift index 988317a57a..b83d6c4bfe 100644 --- a/Strand/BLE/BLEManager.swift +++ b/Strand/BLE/BLEManager.swift @@ -801,6 +801,8 @@ public final class BLEManager: NSObject, ObservableObject { /// Re-entrancy guard for captureRawAccel: true while a bounded on-demand window is running. /// A second tap is a no-op until the active capture's asyncAfter block fires and clears this. private var rawCaptureInFlight = false + private var rawCaptureStoppedAt = Date.distantPast + private var unexpectedImuStopAt = Date.distantPast /// Ordered queue of frames awaiting drain through the serial Backfiller task. private var backfillFrameQueue: [[UInt8]] = [] /// True while the drain task is running (prevents a second drain task from launching). @@ -1808,9 +1810,23 @@ public final class BLEManager: NSObject, ObservableObject { } await collector?.endRawCapture() rawCaptureInFlight = false + rawCaptureStoppedAt = Date() log("Raw-data session: stopped + flushed") } + /// Stop a realtime IMU producer left armed after a crash, lost stop write, or another client. + private func stopUnexpectedRealtimeImu(_ frame: [UInt8], isOffload: Bool, now: Date = Date()) { + guard selectedModel.deviceFamily == .whoop5, !isOffload, frame.count > 8, + frame[8] == 43 || frame[8] == 51, + !rawCaptureInFlight, !UserDefaults.standard.bool(forKey: "enableRawCapture"), + now.timeIntervalSince(rawCaptureStoppedAt) >= 3, + now.timeIntervalSince(unexpectedImuStopAt) >= 30 else { return } + unexpectedImuStopAt = now + send(.stopRawData, payload: [0x01], writeType: .withResponse) + send(.toggleIMUMode, payload: [0x01, 0x00], writeType: .withResponse) + log("Raw IMU fail-safe: unexpected realtime packet type \(frame[8]) while capture was off; stop requested") + } + public func finishGroundTruthRawCapture(sessionId: String) { collector?.finishGroundTruthRawCapture(sessionId: sessionId) } @@ -1837,6 +1853,14 @@ public final class BLEManager: NSObject, ObservableObject { await collector?.upsertImuChunk(chunk) } + public func groundTruthImuChunks(idPrefix: String) async -> [ImuChunkMeta]? { + await collector?.imuChunks(idPrefix: idPrefix) + } + + public func deleteGroundTruthImuChunk(id: String) async -> Bool { + await collector?.deleteImuChunk(id: id) ?? false + } + /// Send a command to the WHOOP strap. /// - Parameters: /// - command: The command to send. @@ -6015,6 +6039,7 @@ extension BLEManager: @preconcurrency CBPeripheralDelegate { // reverse-engineering — its own file the bulk-capture eviction never churns. // BEFORE the offload branch so it catches the burst; no-op unless capture is on. puffinDeepBufferLog.appendIfDeepBuffer(frame: frame, char: characteristic.uuid, isOffload: isOffload) + stopUnexpectedRealtimeImu(frame, isOffload: isOffload) // #423: the queryable twin of that diagnostics line — persist the decoded 100 Hz 6-axis if isOffload { // Same policy as WHOOP4: historical offload frames are bulk sync traffic. diff --git a/Strand/BLE/PuffinDeepBufferLog.swift b/Strand/BLE/PuffinDeepBufferLog.swift index 56b0dff2e3..07c919da26 100644 --- a/Strand/BLE/PuffinDeepBufferLog.swift +++ b/Strand/BLE/PuffinDeepBufferLog.swift @@ -34,9 +34,9 @@ enum PuffinOpticalExperimentPhase: String, CaseIterable, Sendable { /// packets that carry tens-of-Hz sensor data (motion + optical) rather than the 1 Hz historical /// rollup NOOP already decodes (#423). /// -/// On-strap probing (#423) established that the 5/MG has no live raw-IMU stream, but it DOES bank -/// high-rate motion (accel content verified: int16-ish triples sphere-fitting to ~1 g at the 1/4096 -/// scale) and ships it inside big type-0x2F buffers during the connect-time offload burst — 124 B +/// On-strap probing (#423) established that the 5/MG banks high-rate motion and can stream it live +/// during an explicitly armed raw-data session (accel content verified: int16-ish triples +/// sphere-fitting to ~1 g at the 1/4096 scale). History also ships it inside big type-0x2F buffers — 124 B /// (≈1 Hz record), 1244 B and 2140 B (≈32 and ≈59 sub-records per timestamped second). NOOP's /// historical decoder pulls the 1 Hz gravity vector out and DISCARDS the high-rate remainder. Those /// bursts are ephemeral and backlog-gated, so a byte-perfect decoder can't be captured on demand — it diff --git a/Strand/Collect/Collector.swift b/Strand/Collect/Collector.swift index cdce01318d..87cd6e05c5 100644 --- a/Strand/Collect/Collector.swift +++ b/Strand/Collect/Collector.swift @@ -141,6 +141,17 @@ final class Collector { try? await concreteStore?.upsertImuChunk(chunk) } + func imuChunks(idPrefix: String) async -> [ImuChunkMeta]? { + guard let concreteStore else { return nil } + return try? await concreteStore.imuChunks(idPrefix: idPrefix) + } + + func deleteImuChunk(id: String) async -> Bool { + guard let concreteStore else { return false } + do { try await concreteStore.deleteImuChunk(id: id); return true } + catch { return false } + } + /// Max persisted HR sample ts (the biometric "data frontier" for the stuck-strap watchdog). /// nil if there's no concrete store or nothing persisted yet. Mirrors storageStats(). func latestHRSampleTs() async -> Int? { @@ -198,6 +209,7 @@ final class Collector { } } + /// Persist + queue everything buffered. No-op when empty or before a clock ref exists. /// Buffer is snapshotted and cleared SYNCHRONOUSLY before the first await so that any /// concurrent ingest() calls during persistence accumulate into the NEXT batch cleanly. diff --git a/Strand/Collect/ImuChunkArchiveStore.swift b/Strand/Collect/ImuChunkArchiveStore.swift index 66beef77d8..e352bcd498 100644 --- a/Strand/Collect/ImuChunkArchiveStore.swift +++ b/Strand/Collect/ImuChunkArchiveStore.swift @@ -22,11 +22,15 @@ final class ImuChunkArchiveStore { func pin(sessionId: String, deviceId: String, from: Int, to: Int, ble: BLEManager) async -> [ImuChunkMeta] { ImuSessionFileStore.shared.prepareForRead(sessionId) let existing = await ble.groundTruthImuChunks(from: from, to: to) - .filter { $0.pinnedUntil == Int.max && file($0).isFileURL && FileManager.default.fileExists(atPath: file($0).path) } - if existing.contains(where: { $0.startTs <= from && $0.endTs >= to - && $0.sampleCount >= ($0.endTs - $0.startTs + 1) * $0.sampleRate }) { return existing } + .filter { Self.isOwned($0.id, by: sessionId) && $0.pinnedUntil == Int.max + && FileManager.default.fileExists(atPath: file($0).path) } + if let exact = existing.first(where: { $0.startTs == from && $0.endTs == to + && $0.sampleCount >= ($0.endTs - $0.startTs + 1) * $0.sampleRate }) { + _ = await deleteOwned(sessionId: sessionId, keeping: [exact.id], ble: ble) + return [exact] + } let rows = readSessionRows(sessionId: sessionId, from: from, to: to) - guard !rows.isEmpty else { return existing } + guard !rows.isEmpty else { return Self.bounded(existing, from: from, to: to) } let startTs = rows.map(\.ts).min()!, endTs = rows.map(\.ts).max()! var samples = Data() @@ -50,7 +54,7 @@ final class ImuChunkArchiveStore { let staged = FileExport.zipData(entries: [ .init(name: "manifest.json", data: manifestData), .init(name: "samples.bin", data: samples), ], baseName: UUID().uuidString) else { return [] } - let id = UUID().uuidString + let id = Self.prefix(sessionId) + UUID().uuidString let destination = directory.appendingPathComponent("\(id).imuc") do { try FileManager.default.moveItem(at: staged, to: destination) } catch { return [] } let bytes = (try? Data(contentsOf: destination)) ?? Data() @@ -63,8 +67,46 @@ final class ImuChunkArchiveStore { byteSize: bytes.count, sha256: SHA256.hash(data: bytes).map { String(format: "%02x", $0) }.joined(), createdAt: Int(Date().timeIntervalSince1970), pinnedUntil: Int.max) await ble.registerGroundTruthImuChunk(chunk) - return await ble.groundTruthImuChunks(from: from, to: to) - .filter { FileManager.default.fileExists(atPath: file($0).path) } + _ = await deleteOwned(sessionId: sessionId, keeping: [id], ble: ble) + return [chunk] + } + + func deleteOwned(sessionId: String, keeping: Set = [], ble: BLEManager) async -> Bool { + guard let catalog = await ble.groundTruthImuChunks(idPrefix: Self.prefix(sessionId)) else { return false } + let catalogIds = Set(catalog.map(\.id)) + let fileIds = Set((try? FileManager.default.contentsOfDirectory(at: directory, + includingPropertiesForKeys: nil))?.compactMap { url in + url.pathExtension == "imuc" && Self.isOwned(url.deletingPathExtension().lastPathComponent, + by: sessionId) + ? url.deletingPathExtension().lastPathComponent : nil + } ?? []) + for id in catalogIds.union(fileIds).subtracting(keeping) { + let url = directory.appendingPathComponent("\(id).imuc") + guard await Self.deleteRetrySafe( + fileExists: FileManager.default.fileExists(atPath: url.path), + deleteFile: { (try? FileManager.default.removeItem(at: url)) != nil }, + deleteCatalog: { await ble.deleteGroundTruthImuChunk(id: id) } + ) else { return false } + } + return true + } + + func deleteDevice(_ deviceId: String, store: WhoopStore) async -> Bool { + guard let chunks = try? await store.imuChunks(deviceId: deviceId, from: Int.min, to: Int.max) else { + return false + } + for chunk in chunks { + let url = file(chunk) + guard await Self.deleteRetrySafe( + fileExists: FileManager.default.fileExists(atPath: url.path), + deleteFile: { (try? FileManager.default.removeItem(at: url)) != nil }, + deleteCatalog: { + do { try await store.deleteImuChunk(id: chunk.id); return true } + catch { return false } + } + ) else { return false } + } + return true } func file(_ chunk: ImuChunkMeta) -> URL { @@ -72,6 +114,19 @@ final class ImuChunkArchiveStore { return base.appendingPathComponent(chunk.relativePath) } + static func prefix(_ sessionId: String) -> String { "\(sessionId)--" } + static func isOwned(_ chunkId: String, by sessionId: String) -> Bool { + chunkId.hasPrefix(prefix(sessionId)) + } + static func bounded(_ chunks: [ImuChunkMeta], from: Int, to: Int) -> [ImuChunkMeta] { + chunks.filter { $0.startTs >= from && $0.endTs <= to } + } + static func deleteRetrySafe(fileExists: Bool, deleteFile: () -> Bool, + deleteCatalog: () async -> Bool) async -> Bool { + if fileExists && !deleteFile() { return false } + return await deleteCatalog() + } + private func readSessionRows(sessionId: String, from: Int, to: Int) -> [(ts: Int, cols: [Int16])] { let url = ImuSessionFileStore.shared.file(sessionId) guard let data = try? Data(contentsOf: url) else { return [] } diff --git a/Strand/Collect/RawDataSessionStore.swift b/Strand/Collect/RawDataSessionStore.swift index c567b54f38..8aecf33c15 100644 --- a/Strand/Collect/RawDataSessionStore.swift +++ b/Strand/Collect/RawDataSessionStore.swift @@ -3,24 +3,16 @@ import Foundation import WhoopStore /// Persistent, user-controlled 5/MG capture sessions. This is the iOS/macOS twin of Android's -/// GroundTruthCollector: raw capture is useful on its own, while step/stair labels are optional. +/// GroundTruthCollector: it records bounded raw-data windows with optional comments. @MainActor final class RawDataSessionStore: ObservableObject { - enum LabelKind: String, Codable { - case start, stop, step, stair - case undoStep = "undo_step" - case undoStair = "undo_stair" - case excludeWindow = "exclude_window" - } - struct Event: Codable, Identifiable, Equatable { var id = UUID() - let atMs: Int64 - let kind: LabelKind - let stepsTotal: Int - let stairsTotal: Int - var fromMs: Int64? - var toMs: Int64? + var atMs: Int64 + let kind: String + var markerId: String? + var markerType: String? + var text: String? } struct Session: Codable, Identifiable, Equatable { @@ -30,23 +22,11 @@ final class RawDataSessionStore: ObservableObject { var endedAtMs: Int64? var capturedStartedAtMs: Int64? var capturedEndedAtMs: Int64? - var keepRaw: Bool? - var steps: Int - var stairs: Int - var excludedWindows: Int var comment: String var exported: Bool var events: [Event] var active: Bool { endedAtMs == nil } - - static func == (lhs: Session, rhs: Session) -> Bool { - lhs.id == rhs.id && lhs.endedAtMs == rhs.endedAtMs && lhs.steps == rhs.steps - && lhs.stairs == rhs.stairs && lhs.excludedWindows == rhs.excludedWindows - && lhs.comment == rhs.comment && lhs.exported == rhs.exported - && lhs.capturedStartedAtMs == rhs.capturedStartedAtMs - && lhs.capturedEndedAtMs == rhs.capturedEndedAtMs - } } @Published private(set) var sessions: [Session] = [] @@ -79,10 +59,9 @@ final class RawDataSessionStore: ObservableObject { func start(deviceId: String, now: Date = Date()) -> Session? { guard active == nil else { return nil } let millis = Int64(now.timeIntervalSince1970 * 1_000) - let started = Event(atMs: millis, kind: .start, stepsTotal: 0, stairsTotal: 0) + let started = Event(atMs: millis, kind: "start") let session = Session(id: String(millis), deviceId: deviceId, startedAtMs: millis, endedAtMs: nil, capturedStartedAtMs: millis, capturedEndedAtMs: nil, - keepRaw: true, steps: 0, stairs: 0, excludedWindows: 0, comment: "", exported: false, events: [started]) sessions.insert(session, at: 0) persist(session) @@ -96,8 +75,7 @@ final class RawDataSessionStore: ObservableObject { let millis = Int64(now.timeIntervalSince1970 * 1_000) session.endedAtMs = millis session.capturedEndedAtMs = millis - session.events.append(Event(atMs: millis, kind: .stop, - stepsTotal: session.steps, stairsTotal: session.stairs)) + session.events.append(Event(atMs: millis, kind: "stop")) } if let activeId { ImuSessionFileStore.shared.complete(id: activeId, toMs: Int64(now.timeIntervalSince1970 * 1_000)) } } @@ -108,11 +86,9 @@ final class RawDataSessionStore: ObservableObject { let toMs = Int64(to.timeIntervalSince1970 * 1_000) guard validRange(fromMs, toMs) else { return nil } let id = String(Int64(Date().timeIntervalSince1970 * 1_000)) - let events = [Event(atMs: fromMs, kind: .start, stepsTotal: 0, stairsTotal: 0), - Event(atMs: toMs, kind: .stop, stepsTotal: 0, stairsTotal: 0)] + let events = [Event(atMs: fromMs, kind: "start"), Event(atMs: toMs, kind: "stop")] let session = Session(id: id, deviceId: deviceId, startedAtMs: fromMs, endedAtMs: toMs, - capturedStartedAtMs: nil, capturedEndedAtMs: nil, keepRaw: true, - steps: 0, stairs: 0, excludedWindows: 0, comment: "", + capturedStartedAtMs: nil, capturedEndedAtMs: nil, comment: "", exported: false, events: events) sessions.insert(session, at: 0); persist(session) ImuSessionFileStore.shared.register(id: id, deviceId: deviceId, fromMs: fromMs, toMs: toMs) @@ -127,9 +103,7 @@ final class RawDataSessionStore: ObservableObject { guard !session.active else { return } session = Session(id: session.id, deviceId: session.deviceId, startedAtMs: fromMs, endedAtMs: toMs, capturedStartedAtMs: session.capturedStartedAtMs, - capturedEndedAtMs: session.capturedEndedAtMs, keepRaw: session.keepRaw, - steps: session.steps, stairs: session.stairs, - excludedWindows: session.excludedWindows, comment: session.comment, + capturedEndedAtMs: session.capturedEndedAtMs, comment: session.comment, exported: false, events: session.events) } if let session = sessions.first(where: { $0.id == sessionId }) { @@ -137,56 +111,55 @@ final class RawDataSessionStore: ObservableObject { } } - func record(_ kind: LabelKind, now: Date = Date()) { - guard kind == .step || kind == .stair else { return } - mutateActive { session in - session.steps += 1 - if kind == .stair { session.stairs += 1 } - session.events.append(Event(atMs: Int64(now.timeIntervalSince1970 * 1_000), kind: kind, - stepsTotal: session.steps, stairsTotal: session.stairs)) - } + func setComment(_ comment: String, sessionId: String) { + mutate(sessionId) { $0.comment = String(comment.prefix(4_000)) } } - func undo(now: Date = Date()) { - mutateActive { session in - guard let prior = session.events.last, - prior.kind == .step || prior.kind == .stair else { return } - let undo: LabelKind = prior.kind == .stair ? .undoStair : .undoStep - session.steps = max(0, session.steps - 1) - if prior.kind == .stair { session.stairs = max(0, session.stairs - 1) } - session.events.append(Event(atMs: Int64(now.timeIntervalSince1970 * 1_000), kind: undo, - stepsTotal: session.steps, stairsTotal: session.stairs)) + @discardableResult + func addMarker(sessionId: String, at: Date, type: String, text: String) -> Event? { + guard Self.markerTypes.contains(type) else { return nil } + var added: Event? + mutate(sessionId) { session in + let atMs = Self.clamp(Int64(at.timeIntervalSince1970 * 1_000), to: session) + let marker = Event(atMs: atMs, kind: "marker", markerId: UUID().uuidString, + markerType: type, text: String(text.prefix(500))) + session.events.append(marker) + added = marker } + return added } - func excludeLast(minutes: Int, now: Date = Date()) { - guard (1...240).contains(minutes) else { return } - mutateActive { session in - let to = Int64(now.timeIntervalSince1970 * 1_000) - let from = max(session.startedAtMs, to - Int64(minutes) * 60_000) - session.excludedWindows += 1 - session.events.append(Event(atMs: to, kind: .excludeWindow, stepsTotal: session.steps, - stairsTotal: session.stairs, fromMs: from, toMs: to)) + func updateMarker(sessionId: String, markerId: String, at: Date, type: String, text: String) { + guard Self.markerTypes.contains(type) else { return } + mutate(sessionId) { session in + guard let index = session.events.firstIndex(where: { $0.markerId == markerId }) else { return } + session.events[index].atMs = Self.clamp(Int64(at.timeIntervalSince1970 * 1_000), to: session) + session.events[index].markerType = type + session.events[index].text = String(text.prefix(500)) } } - func setComment(_ comment: String, sessionId: String) { - mutate(sessionId) { $0.comment = String(comment.prefix(4_000)) } + func deleteMarker(sessionId: String, markerId: String) { + mutate(sessionId) { $0.events.removeAll { $0.kind == "marker" && $0.markerId == markerId } } } func markExported(_ sessionId: String) { mutate(sessionId) { $0.exported = true } } - func finishImuRecovery(_ sessionId: String) { - ImuSessionFileStore.shared.remove(id: sessionId) - try? FileManager.default.removeItem(at: ImuSessionFileStore.shared.file(sessionId)) - } - - func removeMetadata(_ sessionId: String) { - guard sessions.first(where: { $0.id == sessionId })?.active != true else { return } - try? FileManager.default.removeItem(at: file(sessionId)) - try? FileManager.default.removeItem(at: ImuSessionFileStore.shared.file(sessionId)) + @discardableResult + func removeMetadata(_ sessionId: String, + removeItem: (URL) throws -> Void = { try FileManager.default.removeItem(at: $0) }) -> Bool { + guard sessions.first(where: { $0.id == sessionId })?.active == false else { return false } + let source = ImuSessionFileStore.shared.file(sessionId) + do { + if FileManager.default.fileExists(atPath: source.path) { try removeItem(source) } + } catch { return false } ImuSessionFileStore.shared.remove(id: sessionId) + do { + let metadata = file(sessionId) + if FileManager.default.fileExists(atPath: metadata.path) { try removeItem(metadata) } + } catch { return false } sessions.removeAll { $0.id == sessionId } + return true } private func mutateActive(_ body: (inout Session) -> Void) { @@ -212,27 +185,46 @@ final class RawDataSessionStore: ObservableObject { from > 0 && from <= to && to - from <= 7 * 24 * 60 * 60 * 1_000 } + static let markerTypes = ["moment", "start", "end", "issue"] + + private static func clamp(_ atMs: Int64, to session: Session) -> Int64 { + min(max(atMs, session.startedAtMs), session.endedAtMs ?? Int64(Date().timeIntervalSince1970 * 1_000)) + } + func exportEntries(for session: Session, - raw: [(RawBatchMeta, [[UInt8]])]) -> [FileExport.BundleEntry] { + raw: [(RawBatchMeta, [[UInt8]])], sensorAvailable: Bool? = nil) -> [FileExport.BundleEntry] { var meta: [String: Any] = [ "schema_version": 3, "capture_kind": "whoop_5mg_raw_data", "session_id": session.id, "device_id": session.deviceId, "started_at_ms": session.startedAtMs, "ended_at_ms": session.endedAtMs ?? session.startedAtMs, - "manual_steps": session.steps, "manual_stairs": session.stairs, - "comment": session.comment, "excluded_windows": session.excludedWindows, - "sensor_export_available": !raw.isEmpty, + "comment": session.comment, + "sensor_export_available": sensorAvailable ?? !raw.isEmpty, "device_family": "iOS", "app_version": Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "?", - "manual_labels": "optional; on-screen step and stair controls", + "markers": session.events.filter { + $0.kind == "marker" && $0.atMs >= session.startedAtMs + && $0.atMs <= (session.endedAtMs ?? session.startedAtMs) + }.map { marker in + ["id": marker.markerId ?? "", "at_ms": marker.atMs, + "type": marker.markerType ?? "moment", "text": marker.text ?? ""] as [String: Any] + }, ] if let value = session.capturedStartedAtMs { meta["captured_started_at_ms"] = value } if let value = session.capturedEndedAtMs { meta["captured_ended_at_ms"] = value } let metaData = (try? JSONSerialization.data(withJSONObject: meta, options: [.prettyPrinted, .sortedKeys])) ?? Data() - let eventData = session.events.compactMap { event -> Data? in - var row: [String: Any] = ["at_ms": event.atMs, "kind": event.kind.rawValue, - "steps_total": event.stepsTotal, "stairs_total": event.stairsTotal] - if let from = event.fromMs { row["from_ms"] = from } - if let to = event.toMs { row["to_ms"] = to } + let endMs = session.endedAtMs ?? session.startedAtMs + let publicEvents = [Event(atMs: session.startedAtMs, kind: "start")] + + session.events.filter { + $0.kind == "marker" && $0.atMs >= session.startedAtMs && $0.atMs <= endMs + }.sorted { $0.atMs < $1.atMs } + + [Event(atMs: endMs, kind: "stop")] + let eventData = publicEvents.compactMap { event -> Data? in + var row: [String: Any] = ["at_ms": event.atMs, "kind": event.kind] + if event.kind == "marker" { + if let value = event.markerId { row["marker_id"] = value } + if let value = event.markerType { row["marker_type"] = value } + if let value = event.text { row["text"] = value } + } return try? JSONSerialization.data(withJSONObject: row, options: [.sortedKeys]) }.reduce(into: Data()) { $0.append($1); $0.append(0x0A) } var rawData = Data() diff --git a/Strand/Data/DeviceRegistry.swift b/Strand/Data/DeviceRegistry.swift index 5060fae5d7..461758fc75 100644 --- a/Strand/Data/DeviceRegistry.swift +++ b/Strand/Data/DeviceRegistry.swift @@ -92,6 +92,7 @@ final class DeviceRegistry: ObservableObject { /// Best-effort: a store failure leaves the recordings and published state untouched. Awaits the delete /// BEFORE `reload()` so the refreshed device list reflects the emptied recordings. func deleteDeviceData(_ id: String, store: WhoopStore) async { + guard await ImuChunkArchiveStore().deleteDevice(id, store: store) else { return } do { try await store.deleteAllData(deviceId: id) } catch { @@ -108,6 +109,7 @@ final class DeviceRegistry: ObservableObject { /// and refreshes the published list. Best-effort: if the data wipe fails the registry row is left in /// place (we never leave orphaned recordings behind a removed row). func forget(_ id: String, store: WhoopStore) async { + guard await ImuChunkArchiveStore().deleteDevice(id, store: store) else { return } do { try await store.deleteAllData(deviceId: id) } catch { diff --git a/Strand/Resources/Localizable.xcstrings b/Strand/Resources/Localizable.xcstrings index ac5eaae10c..c5229df06c 100644 --- a/Strand/Resources/Localizable.xcstrings +++ b/Strand/Resources/Localizable.xcstrings @@ -207834,7 +207834,71 @@ } }, "A cardiovascular load from time in heart-rate zones (Edwards TRIMP): each zone is weighted so harder ones count for more, summed into one daily figure. Settings offers an exponential alternative (Banister) that credits short, hard efforts more.": { "localizations": { "de": { "stringUnit": { "state": "translated", "value": "Eine kardiovaskuläre Last aus der Zeit in den Herzfrequenzzonen (Edwards-TRIMP): Jede Zone wird gewichtet, sodass härtere Zonen mehr zählen, aufsummiert zu einer Tageszahl. In den Einstellungen gibt es eine exponentielle Alternative (Banister), die kurze, harte Belastungen stärker anrechnet." } }, "en": { "stringUnit": { "state": "translated", "value": "A cardiovascular load from time in heart-rate zones (Edwards TRIMP): each zone is weighted so harder ones count for more, summed into one daily figure. Settings offers an exponential alternative (Banister) that credits short, hard efforts more." } }, "es": { "stringUnit": { "state": "translated", "value": "Una carga cardiovascular a partir del tiempo en las zonas de frecuencia cardíaca (TRIMP de Edwards): cada zona se pondera para que las más duras cuenten más, sumado en una sola cifra diaria. En Ajustes hay una alternativa exponencial (Banister) que valora más los esfuerzos cortos e intensos." } }, "fr": { "stringUnit": { "state": "translated", "value": "Une charge cardiovasculaire issue du temps passé dans les zones de fréquence cardiaque (TRIMP d’Edwards) : chaque zone est pondérée pour que les plus intenses comptent davantage, additionnée en un seul chiffre journalier. Les Réglages proposent une alternative exponentielle (Banister) qui valorise davantage les efforts courts et intenses." } }, "it": { "stringUnit": { "state": "translated", "value": "Un carico cardiovascolare dato dal tempo nelle zone di frequenza cardiaca (TRIMP di Edwards): ogni zona è ponderata in modo che quelle più dure contino di più, sommato in un unico dato giornaliero. Nelle Impostazioni c’è un’alternativa esponenziale (Banister) che premia di più gli sforzi brevi e intensi." } }, "pl": { "stringUnit": { "state": "translated", "value": "Obciążenie układu krążenia wynikające z czasu w strefach tętna (TRIMP Edwardsa): każda strefa jest ważona tak, aby trudniejsze liczyły się bardziej, zsumowane w jedną wartość dzienną. W Ustawieniach jest alternatywa wykładnicza (Banister), która mocniej docenia krótkie, intensywne wysiłki." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Uma carga cardiovascular a partir do tempo nas zonas de frequência cardíaca (TRIMP de Edwards): cada zona é ponderada para que as mais difíceis contem mais, resumido num valor diário. Nas Definições existe uma alternativa exponencial (Banister) que valoriza mais os esforços curtos e intensos." } }, "ru": { "stringUnit": { "state": "translated", "value": "Сердечно-сосудистая нагрузка по времени в пульсовых зонах (TRIMP Эдвардса): каждая зона взвешивается так, что более тяжёлые учитываются сильнее, и всё суммируется в одно дневное значение. В настройках есть экспоненциальная альтернатива (Banister), которая сильнее засчитывает короткие интенсивные усилия." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "根据你在各心率区间的时长得出的心血管负荷(Edwards TRIMP):区间越高权重越大,汇总为一个每日数字。设置中提供指数式替代方案(Banister),会更看重短时高强度的努力。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "根據你在各心率區間停留時間得出的心血管負荷(Edwards TRIMP):區間越高權重越大,加總成每日一個數字。設定中提供指數式替代方案(Banister),會更看重短時高強度的努力。" } } - } } + } }, + "WHOOP 5.0 / MG supports live data and strap history. Protocol-research tools are available separately in Test Centre.": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"WHOOP 5.0/MG unterstützt Live-Daten und Bandverlauf. Protokollwerkzeuge sind separat im Testcenter."}},"es":{"stringUnit":{"state":"translated","value":"WHOOP 5.0/MG admite datos en directo e historial. Las herramientas de protocolo están aparte en el Centro de pruebas."}},"fr":{"stringUnit":{"state":"translated","value":"WHOOP 5.0/MG gère les données en direct et l’historique. Les outils de protocole sont séparés dans le Centre de test."}},"pt-PT":{"stringUnit":{"state":"translated","value":"WHOOP 5.0/MG suporta dados em direto e histórico. As ferramentas de protocolo estão separadas no Centro de testes."}}}}, + "Delete this session?": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Diese Session löschen?"}},"es":{"stringUnit":{"state":"translated","value":"¿Eliminar esta sesión?"}},"fr":{"stringUnit":{"state":"translated","value":"Supprimer cette session ?"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Eliminar esta sessão?"}}}}, + "The session %@ and its captured raw data will be deleted permanently.": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Die Session %@ und ihre Rohdaten werden dauerhaft gelöscht."}},"es":{"stringUnit":{"state":"translated","value":"La sesión %@ y sus datos se eliminarán permanentemente."}},"fr":{"stringUnit":{"state":"translated","value":"La session %@ et ses données seront définitivement supprimées."}},"pt-PT":{"stringUnit":{"state":"translated","value":"A sessão %@ e os dados serão eliminados permanentemente."}}}}, + "Delete all sessions?": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Alle Sessions löschen?"}},"es":{"stringUnit":{"state":"translated","value":"¿Eliminar todas las sesiones?"}},"fr":{"stringUnit":{"state":"translated","value":"Supprimer toutes les sessions ?"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Eliminar todas as sessões?"}}}}, + "All %lld recorded sessions and their captured raw data will be deleted permanently.": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Alle %lld Sessions und ihre Rohdaten werden dauerhaft gelöscht."}},"es":{"stringUnit":{"state":"translated","value":"Las %lld sesiones y sus datos se eliminarán permanentemente."}},"fr":{"stringUnit":{"state":"translated","value":"Les %lld sessions et leurs données seront définitivement supprimées."}},"pt-PT":{"stringUnit":{"state":"translated","value":"As %lld sessões e os dados serão eliminados permanentemente."}}}}, + "Couldn't export session": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Session konnte nicht exportiert werden"}},"es":{"stringUnit":{"state":"translated","value":"No se pudo exportar la sesión"}},"fr":{"stringUnit":{"state":"translated","value":"Impossible d’exporter la session"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Não foi possível exportar a sessão"}}}}, + "Unknown error": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Unbekannter Fehler"}},"es":{"stringUnit":{"state":"translated","value":"Error desconocido"}},"fr":{"stringUnit":{"state":"translated","value":"Erreur inconnue"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Erro desconhecido"}}}}, + "Capture coverage": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Abdeckung der Aufnahme"}},"es":{"stringUnit":{"state":"translated","value":"Cobertura de la captura"}},"fr":{"stringUnit":{"state":"translated","value":"Couverture de la capture"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Cobertura da captura"}}}}, + "Band: connected%@": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Band: verbunden%@"}},"es":{"stringUnit":{"state":"translated","value":"Pulsera: conectada%@"}},"fr":{"stringUnit":{"state":"translated","value":"Bracelet : connecté%@"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Bracelete: ligada%@"}}}}, + "Band: disconnected": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Band: getrennt"}},"es":{"stringUnit":{"state":"translated","value":"Pulsera: desconectada"}},"fr":{"stringUnit":{"state":"translated","value":"Bracelet : déconnecté"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Bracelete: desligada"}}}}, + "History sync: running (%lld chunks)": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Verlaufssynchronisierung läuft (%lld Blöcke)"}},"es":{"stringUnit":{"state":"translated","value":"Sincronizando historial (%lld bloques)"}},"fr":{"stringUnit":{"state":"translated","value":"Synchronisation de l’historique (%lld blocs)"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Sincronização do histórico (%lld blocos)"}}}}, + "History sync: idle": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Verlaufssynchronisierung inaktiv"}},"es":{"stringUnit":{"state":"translated","value":"Sincronización inactiva"}},"fr":{"stringUnit":{"state":"translated","value":"Synchronisation inactive"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Sincronização inativa"}}}}, + "Realtime IMU: session active since %@": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Echtzeit-IMU: Session aktiv seit %@"}},"es":{"stringUnit":{"state":"translated","value":"IMU en tiempo real: sesión activa desde %@"}},"fr":{"stringUnit":{"state":"translated","value":"IMU en temps réel : session active depuis %@"}},"pt-PT":{"stringUnit":{"state":"translated","value":"IMU em tempo real: sessão ativa desde %@"}}}}, + "Historical export window": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Vergangenes Exportzeitfenster"}},"es":{"stringUnit":{"state":"translated","value":"Intervalo histórico de exportación"}},"fr":{"stringUnit":{"state":"translated","value":"Plage d’export historique"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Intervalo histórico de exportação"}}}}, + "Create a session from synchronized history without starting a live capture. 100 Hz coverage is included wherever it still exists in the rolling buffer.": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Erstellt eine Session aus dem synchronisierten Verlauf. Noch gepufferte 100-Hz-Daten werden einbezogen."}},"es":{"stringUnit":{"state":"translated","value":"Crea una sesión desde el historial. Incluye los datos de 100 Hz aún disponibles."}},"fr":{"stringUnit":{"state":"translated","value":"Crée une session depuis l’historique. Inclut les données 100 Hz encore disponibles."}},"pt-PT":{"stringUnit":{"state":"translated","value":"Cria uma sessão a partir do histórico. Inclui os dados de 100 Hz ainda disponíveis."}}}}, + "Recorded sessions": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Aufgezeichnete Sessions"}},"es":{"stringUnit":{"state":"translated","value":"Sesiones grabadas"}},"fr":{"stringUnit":{"state":"translated","value":"Sessions enregistrées"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Sessões gravadas"}}}}, + "No sessions recorded yet.": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Noch keine Sessions aufgezeichnet."}},"es":{"stringUnit":{"state":"translated","value":"Aún no hay sesiones grabadas."}},"fr":{"stringUnit":{"state":"translated","value":"Aucune session enregistrée."}},"pt-PT":{"stringUnit":{"state":"translated","value":"Ainda não existem sessões gravadas."}}}}, + "Export status: recording": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Exportstatus: Aufnahme läuft"}},"es":{"stringUnit":{"state":"translated","value":"Estado: grabando"}},"fr":{"stringUnit":{"state":"translated","value":"État : enregistrement"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Estado: a gravar"}}}}, + "Export status: %lld raw batches": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Exportstatus: %lld Rohdatenblöcke"}},"es":{"stringUnit":{"state":"translated","value":"Estado: %lld bloques sin procesar"}},"fr":{"stringUnit":{"state":"translated","value":"État : %lld blocs bruts"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Estado: %lld blocos em bruto"}}}}, + "Legacy R22 feature-flag experiment. The strap accepts these writes, but NOOP has not observed them enabling a separate live stream. This is not required for normal WHOOP 5/MG support or for the Raw Data Collector. It writes persistent strap settings and may do nothing on your firmware.": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Altes R22-Experiment. Für normalen 5/MG-Betrieb und den Rohdaten-Collector ist es nicht erforderlich."}},"es":{"stringUnit":{"state":"translated","value":"Experimento R22 antiguo. No es necesario para el uso normal ni para el recopilador de datos."}},"fr":{"stringUnit":{"state":"translated","value":"Ancienne expérience R22. Elle n’est pas requise pour l’usage normal ni pour le collecteur."}},"pt-PT":{"stringUnit":{"state":"translated","value":"Experiência R22 antiga. Não é necessária para o uso normal nem para o coletor."}}}}, + "NOOP estimates your steps from your WHOOP's stored motion, calibrated to your phone's step count. It's an estimate, not a hardware step counter; normal WHOOP 5/MG history sync supplies the motion data.": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"NOOP schätzt Schritte aus gespeicherter Bewegung und kalibriert sie mit dem Telefon. Es ist kein Hardware-Schrittzähler."}},"es":{"stringUnit":{"state":"translated","value":"NOOP estima los pasos desde el movimiento y los calibra con el teléfono. No es un contador físico."}},"fr":{"stringUnit":{"state":"translated","value":"NOOP estime les pas depuis le mouvement et les calibre avec le téléphone. Ce n’est pas un compteur matériel."}},"pt-PT":{"stringUnit":{"state":"translated","value":"O NOOP estima os passos a partir do movimento e calibra-os com o telefone. Não é um contador físico."}}}}, + "5/MG RAW DATA COLLECTOR": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"5/MG-ROHDATEN-COLLECTOR"}},"es":{"stringUnit":{"state":"translated","value":"RECOPILADOR DE DATOS 5/MG"}},"fr":{"stringUnit":{"state":"translated","value":"COLLECTEUR DE DONNÉES 5/MG"}},"pt-PT":{"stringUnit":{"state":"translated","value":"RECOLHA DE DADOS 5/MG"}}}}, + "Record, review, export, and delete bounded 100 Hz motion sessions. Normal sync is unchanged.": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Begrenzte 100-Hz-Sessions aufnehmen, prüfen, exportieren und löschen. Normale Synchronisierung bleibt unverändert."}},"es":{"stringUnit":{"state":"translated","value":"Graba, revisa, exporta y elimina sesiones limitadas a 100 Hz. La sincronización normal no cambia."}},"fr":{"stringUnit":{"state":"translated","value":"Enregistrez, consultez, exportez et supprimez des sessions limitées à 100 Hz. La synchronisation normale ne change pas."}},"pt-PT":{"stringUnit":{"state":"translated","value":"Grave, reveja, exporte e elimine sessões limitadas a 100 Hz. A sincronização normal não muda."}}}}, + "Open raw-data collector": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Rohdaten-Collector öffnen"}},"es":{"stringUnit":{"state":"translated","value":"Abrir recopilador de datos"}},"fr":{"stringUnit":{"state":"translated","value":"Ouvrir le collecteur de données"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Abrir recolha de dados"}}}}, + "WHOOP 5/MG connected.": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"WHOOP 5/MG verbunden."}},"es":{"stringUnit":{"state":"translated","value":"WHOOP 5/MG conectado."}},"fr":{"stringUnit":{"state":"translated","value":"WHOOP 5/MG connecté."}},"pt-PT":{"stringUnit":{"state":"translated","value":"WHOOP 5/MG ligado."}}}}, + "Connect your WHOOP 5/MG to start a capture.": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Verbinde dein WHOOP 5/MG, um eine Aufnahme zu starten."}},"es":{"stringUnit":{"state":"translated","value":"Conecta tu WHOOP 5/MG para iniciar una captura."}},"fr":{"stringUnit":{"state":"translated","value":"Connectez votre WHOOP 5/MG pour lancer une capture."}},"pt-PT":{"stringUnit":{"state":"translated","value":"Ligue o WHOOP 5/MG para iniciar uma captura."}}}}, + "5/MG PROTOCOL DIAGNOSTICS": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"5/MG-PROTOKOLLDIAGNOSE"}},"es":{"stringUnit":{"state":"translated","value":"DIAGNÓSTICO DEL PROTOCOLO 5/MG"}},"fr":{"stringUnit":{"state":"translated","value":"DIAGNOSTICS DU PROTOCOLE 5/MG"}},"pt-PT":{"stringUnit":{"state":"translated","value":"DIAGNÓSTICO DO PROTOCOLO 5/MG"}}}}, + "Developer tools for unmapped protocol features. None of these are required for normal WHOOP 5/MG recording, history sync, or the bounded Raw Data Collector.": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Entwicklerwerkzeuge für nicht zugeordnete Protokollfunktionen. Für normale Aufnahme, Verlauf und Rohdaten-Collector nicht erforderlich."}},"es":{"stringUnit":{"state":"translated","value":"Herramientas para funciones de protocolo sin asignar. No son necesarias para el uso normal."}},"fr":{"stringUnit":{"state":"translated","value":"Outils pour les fonctions de protocole non identifiées. Ils ne sont pas requis pour l’usage normal."}},"pt-PT":{"stringUnit":{"state":"translated","value":"Ferramentas para funções de protocolo não identificadas. Não são necessárias para o uso normal."}}}}, + "Protocol probes": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Protokolltests"}},"es":{"stringUnit":{"state":"translated","value":"Sondas de protocolo"}},"fr":{"stringUnit":{"state":"translated","value":"Sondes de protocole"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Sondas de protocolo"}}}}, + "Sends experimental protocol queries and records the replies in the strap log.": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Sendet experimentelle Abfragen und protokolliert die Antworten."}},"es":{"stringUnit":{"state":"translated","value":"Envía consultas experimentales y registra las respuestas."}},"fr":{"stringUnit":{"state":"translated","value":"Envoie des requêtes expérimentales et consigne les réponses."}},"pt-PT":{"stringUnit":{"state":"translated","value":"Envia consultas experimentais e regista as respostas."}}}}, + "Broadcast heart rate from the strap": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Herzfrequenz vom Band übertragen"}},"es":{"stringUnit":{"state":"translated","value":"Transmitir frecuencia cardíaca desde la pulsera"}},"fr":{"stringUnit":{"state":"translated","value":"Diffuser la fréquence cardiaque du bracelet"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Transmitir frequência cardíaca da bracelete"}}}}, + "Writes the reversible 5/MG advertising flag for Garmin, Zwift, and compatible gym equipment.": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Setzt das umkehrbare 5/MG-Werbe-Flag für kompatible Geräte."}},"es":{"stringUnit":{"state":"translated","value":"Activa el indicador reversible 5/MG para equipos compatibles."}},"fr":{"stringUnit":{"state":"translated","value":"Active l’indicateur réversible 5/MG pour les appareils compatibles."}},"pt-PT":{"stringUnit":{"state":"translated","value":"Ativa o indicador reversível 5/MG para equipamento compatível."}}}}, + "Legacy R22 feature-flag experiment": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Altes R22-Feature-Flag-Experiment"}},"es":{"stringUnit":{"state":"translated","value":"Experimento antiguo de indicadores R22"}},"fr":{"stringUnit":{"state":"translated","value":"Ancienne expérience d’indicateurs R22"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Experiência antiga de indicadores R22"}}}}, + "The strap accepts these writes, but NOOP has not observed them enabling a separate live stream. This is not the Raw Data Collector.": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Das Band akzeptiert die Schreibvorgänge, ein eigener Live-Stream wurde aber nicht beobachtet. Dies ist nicht der Rohdaten-Collector."}},"es":{"stringUnit":{"state":"translated","value":"La pulsera acepta estas escrituras, pero no se ha observado otro flujo. Esto no es el recopilador."}},"fr":{"stringUnit":{"state":"translated","value":"Le bracelet accepte ces écritures, mais aucun flux distinct n’a été observé. Ce n’est pas le collecteur."}},"pt-PT":{"stringUnit":{"state":"translated","value":"A bracelete aceita estas escritas, mas não foi observado outro fluxo. Isto não é a recolha de dados."}}}}, + "WHOOP MG ECG raw-data gate": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"WHOOP-MG-Schalter für ECG-Rohdaten"}},"es":{"stringUnit":{"state":"translated","value":"Control de datos ECG de WHOOP MG"}},"fr":{"stringUnit":{"state":"translated","value":"Contrôle des données ECG WHOOP MG"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Controlo de dados ECG WHOOP MG"}}}}, + "MG-only protocol instrumentation, not a medical ECG feature.": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Nur MG-Protokolltests, keine medizinische ECG-Funktion."}},"es":{"stringUnit":{"state":"translated","value":"Instrumentación solo para MG; no es una función ECG médica."}},"fr":{"stringUnit":{"state":"translated","value":"Instrumentation MG uniquement, pas une fonction ECG médicale."}},"pt-PT":{"stringUnit":{"state":"translated","value":"Instrumentação apenas para MG; não é uma função ECG médica."}}}}, + "Passive history/protocol trace": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Passive Verlaufs-/Protokollaufzeichnung"}},"es":{"stringUnit":{"state":"translated","value":"Registro pasivo de historial/protocolo"}},"fr":{"stringUnit":{"state":"translated","value":"Trace passive d’historique/protocole"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Registo passivo de histórico/protocolo"}}}}, + "Records frames that already arrive. It does not start sensors and can create large files. Use the export section below to save the trace with its strap log.": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Zeichnet bereits eintreffende Frames auf, startet keine Sensoren und kann große Dateien erzeugen. Der Export unten fügt das Bandprotokoll hinzu."}},"es":{"stringUnit":{"state":"translated","value":"Registra las tramas que ya llegan. No inicia sensores y puede crear archivos grandes. El export inferior incluye el registro."}},"fr":{"stringUnit":{"state":"translated","value":"Enregistre les trames déjà reçues. Ne démarre aucun capteur et peut créer de gros fichiers. L’export ci-dessous joint le journal."}},"pt-PT":{"stringUnit":{"state":"translated","value":"Regista os pacotes que já chegam. Não inicia sensores e pode criar ficheiros grandes. A exportação abaixo inclui o registo."}}}}, + "Couldn't delete session": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Session konnte nicht gelöscht werden"}},"es":{"stringUnit":{"state":"translated","value":"No se pudo eliminar la sesión"}},"fr":{"stringUnit":{"state":"translated","value":"Impossible de supprimer la session"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Não foi possível eliminar a sessão"}}}}, + "5/MG Raw Data Collector": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"5/MG-Rohdaten-Collector"}},"es":{"stringUnit":{"state":"translated","value":"Recopilador de datos sin procesar 5/MG"}},"fr":{"stringUnit":{"state":"translated","value":"Collecteur de données brutes 5/MG"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Recolha de dados em bruto 5/MG"}}}}, + "Record a bounded 100 Hz motion session and export its complete timeline.": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Eine begrenzte 100-Hz-Bewegungssession aufnehmen und vollständig exportieren."}},"es":{"stringUnit":{"state":"translated","value":"Graba una sesión de movimiento limitada a 100 Hz y exporta su cronología completa."}},"fr":{"stringUnit":{"state":"translated","value":"Enregistrez une session de mouvement 100 Hz limitée et exportez sa chronologie complète."}},"pt-PT":{"stringUnit":{"state":"translated","value":"Grave uma sessão de movimento limitada a 100 Hz e exporte a cronologia completa."}}}}, + "Start raw-data session": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Rohdaten-Session starten"}},"es":{"stringUnit":{"state":"translated","value":"Iniciar sesión de datos sin procesar"}},"fr":{"stringUnit":{"state":"translated","value":"Démarrer la session de données brutes"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Iniciar sessão de dados em bruto"}}}}, + "Add historical session": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Historische Session hinzufügen"}},"es":{"stringUnit":{"state":"translated","value":"Añadir sesión histórica"}},"fr":{"stringUnit":{"state":"translated","value":"Ajouter une session historique"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Adicionar sessão histórica"}}}}, + "Delete all sessions": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Alle Sessions löschen"}},"es":{"stringUnit":{"state":"translated","value":"Eliminar todas las sesiones"}},"fr":{"stringUnit":{"state":"translated","value":"Supprimer toutes les sessions"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Eliminar todas as sessões"}}}}, + "Session comment": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Session-Kommentar"}},"es":{"stringUnit":{"state":"translated","value":"Comentario de la sesión"}},"fr":{"stringUnit":{"state":"translated","value":"Commentaire de session"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Comentário da sessão"}}}}, + "Building export…": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Export wird erstellt …"}},"es":{"stringUnit":{"state":"translated","value":"Creando exportación…"}},"fr":{"stringUnit":{"state":"translated","value":"Création de l’export…"}},"pt-PT":{"stringUnit":{"state":"translated","value":"A criar exportação…"}}}}, + "Export session": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Session exportieren"}},"es":{"stringUnit":{"state":"translated","value":"Exportar sesión"}},"fr":{"stringUnit":{"state":"translated","value":"Exporter la session"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Exportar sessão"}}}}, + "Delete session": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Session löschen"}},"es":{"stringUnit":{"state":"translated","value":"Eliminar sesión"}},"fr":{"stringUnit":{"state":"translated","value":"Supprimer la session"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Eliminar sessão"}}}}, + "The export file could not be created or shared.": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Die Exportdatei konnte nicht erstellt oder geteilt werden."}},"es":{"stringUnit":{"state":"translated","value":"No se pudo crear ni compartir el archivo de exportación."}},"fr":{"stringUnit":{"state":"translated","value":"Le fichier d’export n’a pas pu être créé ou partagé."}},"pt-PT":{"stringUnit":{"state":"translated","value":"Não foi possível criar ou partilhar o ficheiro de exportação."}}}}, + "To": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Bis"}},"es":{"stringUnit":{"state":"translated","value":"Hasta"}},"fr":{"stringUnit":{"state":"translated","value":"Au"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Até"}}}}, + "0": {"shouldTranslate":false}, + "Connect your WHOOP 5/MG to start a raw-data session.": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Verbinde dein WHOOP 5/MG, um eine Rohdaten-Session zu starten."}},"es":{"stringUnit":{"state":"translated","value":"Conecta tu WHOOP 5/MG para iniciar una sesión de datos sin procesar."}},"fr":{"stringUnit":{"state":"translated","value":"Connectez votre WHOOP 5/MG pour démarrer une session de données brutes."}},"pt-PT":{"stringUnit":{"state":"translated","value":"Ligue o WHOOP 5/MG para iniciar uma sessão de dados em bruto."}}}}, + "Add marker": {"localizations":{"de":{"stringUnit":{"state":"translated","value":"Marker hinzufügen"}},"en":{"stringUnit":{"state":"translated","value":"Add marker"}},"es":{"stringUnit":{"state":"translated","value":"Añadir marcador"}},"fr":{"stringUnit":{"state":"translated","value":"Ajouter un marqueur"}},"it":{"stringUnit":{"state":"translated","value":"Aggiungi indicatore"}},"pl":{"stringUnit":{"state":"translated","value":"Dodaj znacznik"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Adicionar marcador"}},"ru":{"stringUnit":{"state":"translated","value":"Добавить маркер"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"添加标记"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"加入標記"}}}}, + "Edit marker": {"localizations":{"de":{"stringUnit":{"state":"translated","value":"Marker bearbeiten"}},"en":{"stringUnit":{"state":"translated","value":"Edit marker"}},"es":{"stringUnit":{"state":"translated","value":"Editar marcador"}},"fr":{"stringUnit":{"state":"translated","value":"Modifier le marqueur"}},"it":{"stringUnit":{"state":"translated","value":"Modifica indicatore"}},"pl":{"stringUnit":{"state":"translated","value":"Edytuj znacznik"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Editar marcador"}},"ru":{"stringUnit":{"state":"translated","value":"Изменить маркер"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"编辑标记"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"編輯標記"}}}}, + "Delete marker": {"localizations":{"de":{"stringUnit":{"state":"translated","value":"Marker löschen"}},"en":{"stringUnit":{"state":"translated","value":"Delete marker"}},"es":{"stringUnit":{"state":"translated","value":"Eliminar marcador"}},"fr":{"stringUnit":{"state":"translated","value":"Supprimer le marqueur"}},"it":{"stringUnit":{"state":"translated","value":"Elimina indicatore"}},"pl":{"stringUnit":{"state":"translated","value":"Usuń znacznik"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Eliminar marcador"}},"ru":{"stringUnit":{"state":"translated","value":"Удалить маркер"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"删除标记"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"刪除標記"}}}}, + "Marker type": {"localizations":{"de":{"stringUnit":{"state":"translated","value":"Markertyp"}},"en":{"stringUnit":{"state":"translated","value":"Marker type"}},"es":{"stringUnit":{"state":"translated","value":"Tipo de marcador"}},"fr":{"stringUnit":{"state":"translated","value":"Type de marqueur"}},"it":{"stringUnit":{"state":"translated","value":"Tipo di indicatore"}},"pl":{"stringUnit":{"state":"translated","value":"Typ znacznika"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Tipo de marcador"}},"ru":{"stringUnit":{"state":"translated","value":"Тип маркера"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"标记类型"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"標記類型"}}}}, + "Marker note": {"localizations":{"de":{"stringUnit":{"state":"translated","value":"Markernotiz"}},"en":{"stringUnit":{"state":"translated","value":"Marker note"}},"es":{"stringUnit":{"state":"translated","value":"Nota del marcador"}},"fr":{"stringUnit":{"state":"translated","value":"Note du marqueur"}},"it":{"stringUnit":{"state":"translated","value":"Nota indicatore"}},"pl":{"stringUnit":{"state":"translated","value":"Notatka znacznika"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Nota do marcador"}},"ru":{"stringUnit":{"state":"translated","value":"Заметка маркера"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"标记备注"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"標記備註"}}}}, + "Current time: %@": {"localizations":{"de":{"stringUnit":{"state":"translated","value":"Aktuelle Zeit: %@"}},"en":{"stringUnit":{"state":"translated","value":"Current time: %@"}},"es":{"stringUnit":{"state":"translated","value":"Hora actual: %@"}},"fr":{"stringUnit":{"state":"translated","value":"Heure actuelle : %@"}},"it":{"stringUnit":{"state":"translated","value":"Ora attuale: %@"}},"pl":{"stringUnit":{"state":"translated","value":"Bieżący czas: %@"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Hora atual: %@"}},"ru":{"stringUnit":{"state":"translated","value":"Текущее время: %@"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"当前时间:%@"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"目前時間:%@"}}}}, + "Marker: %@": {"localizations":{"de":{"stringUnit":{"state":"translated","value":"Marker: %@"}},"en":{"stringUnit":{"state":"translated","value":"Marker: %@"}},"es":{"stringUnit":{"state":"translated","value":"Marcador: %@"}},"fr":{"stringUnit":{"state":"translated","value":"Marqueur : %@"}},"it":{"stringUnit":{"state":"translated","value":"Indicatore: %@"}},"pl":{"stringUnit":{"state":"translated","value":"Znacznik: %@"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Marcador: %@"}},"ru":{"stringUnit":{"state":"translated","value":"Маркер: %@"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"标记:%@"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"標記:%@"}}}}, + "Moment": {"localizations":{"de":{"stringUnit":{"state":"translated","value":"Moment"}},"en":{"stringUnit":{"state":"translated","value":"Moment"}},"es":{"stringUnit":{"state":"translated","value":"Momento"}},"fr":{"stringUnit":{"state":"translated","value":"Moment"}},"it":{"stringUnit":{"state":"translated","value":"Momento"}},"pl":{"stringUnit":{"state":"translated","value":"Moment"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Momento"}},"ru":{"stringUnit":{"state":"translated","value":"Момент"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"时刻"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"時刻"}}}}, + "Issue": {"localizations":{"de":{"stringUnit":{"state":"translated","value":"Problem"}},"en":{"stringUnit":{"state":"translated","value":"Issue"}},"es":{"stringUnit":{"state":"translated","value":"Problema"}},"fr":{"stringUnit":{"state":"translated","value":"Problème"}},"it":{"stringUnit":{"state":"translated","value":"Problema"}},"pl":{"stringUnit":{"state":"translated","value":"Problem"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Problema"}},"ru":{"stringUnit":{"state":"translated","value":"Проблема"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"问题"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"問題"}}}}, + "−10 s": {"localizations":{"de":{"stringUnit":{"state":"translated","value":"−10 s"}},"en":{"stringUnit":{"state":"translated","value":"−10 s"}},"es":{"stringUnit":{"state":"translated","value":"−10 s"}},"fr":{"stringUnit":{"state":"translated","value":"−10 s"}},"it":{"stringUnit":{"state":"translated","value":"−10 s"}},"pl":{"stringUnit":{"state":"translated","value":"−10 s"}},"pt-PT":{"stringUnit":{"state":"translated","value":"−10 s"}},"ru":{"stringUnit":{"state":"translated","value":"−10 с"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"−10 秒"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"−10 秒"}}}}, + "+10 s": {"localizations":{"de":{"stringUnit":{"state":"translated","value":"+10 s"}},"en":{"stringUnit":{"state":"translated","value":"+10 s"}},"es":{"stringUnit":{"state":"translated","value":"+10 s"}},"fr":{"stringUnit":{"state":"translated","value":"+10 s"}},"it":{"stringUnit":{"state":"translated","value":"+10 s"}},"pl":{"stringUnit":{"state":"translated","value":"+10 s"}},"pt-PT":{"stringUnit":{"state":"translated","value":"+10 s"}},"ru":{"stringUnit":{"state":"translated","value":"+10 с"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"+10 秒"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"+10 秒"}}}}, + " · %@": {"localizations":{"de":{"stringUnit":{"state":"translated","value":" · %@"}},"en":{"stringUnit":{"state":"translated","value":" · %@"}},"es":{"stringUnit":{"state":"translated","value":" · %@"}},"fr":{"stringUnit":{"state":"translated","value":" · %@"}},"it":{"stringUnit":{"state":"translated","value":" · %@"}},"pl":{"stringUnit":{"state":"translated","value":" · %@"}},"pt-PT":{"stringUnit":{"state":"translated","value":" · %@"}},"ru":{"stringUnit":{"state":"translated","value":" · %@"}},"zh-Hans":{"stringUnit":{"state":"translated","value":" · %@"}},"zh-Hant":{"stringUnit":{"state":"translated","value":" · %@"}}}} }, "version": "1.0" } diff --git a/Strand/Screens/RawDataCollectorView.swift b/Strand/Screens/RawDataCollectorView.swift index fd037e2d37..59bc4c00a3 100644 --- a/Strand/Screens/RawDataCollectorView.swift +++ b/Strand/Screens/RawDataCollectorView.swift @@ -13,9 +13,20 @@ struct RawDataCollectorView: View { @State private var deleteCandidate: RawDataSessionStore.Session? @State private var confirmDeleteAll = false @State private var exportError: String? + @State private var deleteError: String? @State private var rawBatchCounts: [String: Int] = [:] @State private var historicalFrom = Date().addingTimeInterval(-3_600) @State private var historicalTo = Date() + @State private var markerDraft: MarkerDraft? + + private struct MarkerDraft: Identifiable { + let id = UUID() + let sessionId: String + let markerId: String? + var at: Date + var type: String + var text: String + } var body: some View { ScreenScaffold( @@ -23,9 +34,7 @@ struct RawDataCollectorView: View { subtitle: "Record a bounded 100 Hz motion session and export its complete timeline." ) { VStack(alignment: .leading, spacing: NoopMetrics.sectionSpacing) { - countersCard coverageCard - labelsCard controls historicalRangeCard sessionsSection @@ -66,16 +75,12 @@ struct RawDataCollectorView: View { )) { Button("OK", role: .cancel) { exportError = nil } } message: { Text(exportError ?? "Unknown error") } - } - - private var countersCard: some View { - StrandCard { - HStack { - counter("NOOP steps", model.repo.days.last?.steps.map(String.init) ?? "–") - counter("Manual steps", String(store.active?.steps ?? 0)) - counter("Stairs", String(store.active?.stairs ?? 0)) - } - } + .alert("Couldn't delete session", isPresented: Binding( + get: { deleteError != nil }, set: { if !$0 { deleteError = nil } } + )) { + Button("OK", role: .cancel) { deleteError = nil } + } message: { Text(deleteError ?? "Unknown error") } + .sheet(item: $markerDraft) { draft in markerSheet(draft) } } private var coverageCard: some View { @@ -98,33 +103,10 @@ struct RawDataCollectorView: View { } } - private var labelsCard: some View { - StrandCard { - VStack(alignment: .leading, spacing: NoopMetrics.space3) { - Text("Optional manual labels").font(StrandFont.headline) - .foregroundStyle(StrandPalette.textPrimary) - Text("For step-algorithm research, label one step or one stair plus one step. Raw capture works without labels.") - .font(StrandFont.caption).foregroundStyle(StrandPalette.textTertiary) - HStack { - NoopButton("Step", systemImage: "figure.walk", kind: .secondary, fullWidth: true) { - store.record(.step) - }.disabled(store.active == nil) - NoopButton("Stair", systemImage: "stairs", kind: .secondary, fullWidth: true) { - store.record(.stair) - }.disabled(store.active == nil) - } - } - } - } - @ViewBuilder private var controls: some View { if store.active != nil { - HStack { - NoopButton("Undo last click", systemImage: "arrow.uturn.backward", kind: .secondary, - fullWidth: true) { store.undo() } - NoopButton("Stop session", systemImage: "stop.fill", kind: .destructive, - fullWidth: true) { Task { await stop() } } - } + NoopButton("Stop session", systemImage: "stop.fill", kind: .destructive, + fullWidth: true) { Task { await stop() } } } else { NoopButton("Start raw-data session", systemImage: "record.circle", kind: .primary, fullWidth: true) { start() } @@ -181,8 +163,6 @@ struct RawDataCollectorView: View { from: Date(timeIntervalSince1970: Double(session.startedAtMs) / 1_000), to: $0) } )) } - Text("\(session.steps) steps · \(session.stairs) stairs") - .font(StrandFont.caption).foregroundStyle(StrandPalette.textSecondary) Text(session.active ? "Export status: recording" : "Export status: \(rawBatchCounts[session.id, default: 0]) raw batches") .font(StrandFont.caption) @@ -192,6 +172,26 @@ struct RawDataCollectorView: View { set: { store.setComment($0, sessionId: session.id) } ), axis: .vertical) .textFieldStyle(.roundedBorder).lineLimit(2...4) + NoopButton("Add marker", systemImage: "mappin.and.ellipse", kind: .secondary, + fullWidth: true) { editMarker(nil, in: session) } + let markers = session.events.filter { $0.kind == "marker" }.sorted { $0.atMs < $1.atMs } + ForEach(markers) { marker in + Button { editMarker(marker, in: session) } label: { + HStack { + VStack(alignment: .leading, spacing: NoopMetrics.space1) { + (Text(Self.markerLabel(marker.markerType)) + + Text(verbatim: " · \(Self.time(marker.atMs))")) + .font(StrandFont.subhead).foregroundStyle(StrandPalette.textPrimary) + if let text = marker.text, !text.isEmpty { + Text(text).font(StrandFont.caption).foregroundStyle(StrandPalette.textSecondary) + } + } + Spacer() + Image(systemName: "chevron.right").foregroundStyle(StrandPalette.textTertiary) + } + } + .buttonStyle(.plain) + } NoopButton(exportingId == session.id ? "Building export…" : "Export session", systemImage: "square.and.arrow.up", kind: .secondary, fullWidth: true) { Task { await export(session) } @@ -204,12 +204,73 @@ struct RawDataCollectorView: View { } } - private func counter(_ label: LocalizedStringKey, _ value: String) -> some View { - VStack(spacing: NoopMetrics.space1) { - Text(value).font(StrandFont.number(28)).foregroundStyle(StrandPalette.textPrimary) - Text(label).font(StrandFont.caption).foregroundStyle(StrandPalette.textSecondary) - .multilineTextAlignment(.center) - }.frame(maxWidth: .infinity) + private func markerSheet(_ initial: MarkerDraft) -> some View { + NavigationStack { + if let binding = Binding($markerDraft) { + let session = store.sessions.first(where: { $0.id == binding.wrappedValue.sessionId }) + Form { + Section { + TimelineView(.periodic(from: .now, by: 1)) { timeline in + let current = Self.markerCurrentTime(session: session, now: timeline.date) + Text("Marker: \(binding.wrappedValue.at.formatted(date: .omitted, time: .standard))") + Text("Current time: \(current.formatted(date: .omitted, time: .standard))") + .foregroundStyle(.secondary) + HStack { + Button { binding.wrappedValue.at.addTimeInterval(-10) } label: { Text(verbatim: "−10 s") } + Spacer() + Button("0") { + binding.wrappedValue.at = Self.markerCurrentTime(session: session, now: Date()) + } + Spacer() + Button { binding.wrappedValue.at.addTimeInterval(10) } label: { Text(verbatim: "+10 s") } + } + } + } + Section("Marker type") { + Picker("Marker type", selection: binding.type) { + ForEach(RawDataSessionStore.markerTypes, id: \.self) { type in + Text(Self.markerLabel(type)).tag(type) + } + }.pickerStyle(.segmented) + } + Section("Marker note") { + TextField("Marker note", text: binding.text, axis: .vertical).lineLimit(2...4) + } + if let markerId = binding.wrappedValue.markerId { + Section { + Button("Delete marker", role: .destructive) { + store.deleteMarker(sessionId: binding.wrappedValue.sessionId, markerId: markerId) + markerDraft = nil + } + } + } + } + .navigationTitle(initial.markerId == nil ? "Add marker" : "Edit marker") + .toolbar { + ToolbarItem(placement: .cancellationAction) { Button("Cancel") { markerDraft = nil } } + ToolbarItem(placement: .confirmationAction) { + Button("Save") { saveMarker(binding.wrappedValue) } + } + } + } + } + } + + private func editMarker(_ marker: RawDataSessionStore.Event?, in session: RawDataSessionStore.Session) { + let currentMs = session.endedAtMs ?? Int64(Date().timeIntervalSince1970 * 1_000) + markerDraft = MarkerDraft(sessionId: session.id, markerId: marker?.markerId, + at: Date(timeIntervalSince1970: Double(marker?.atMs ?? currentMs) / 1_000), + type: marker?.markerType ?? "moment", text: marker?.text ?? "") + } + + private func saveMarker(_ draft: MarkerDraft) { + if let markerId = draft.markerId { + store.updateMarker(sessionId: draft.sessionId, markerId: markerId, + at: draft.at, type: draft.type, text: draft.text) + } else { + store.addMarker(sessionId: draft.sessionId, at: draft.at, type: draft.type, text: draft.text) + } + markerDraft = nil } private func start() { @@ -228,11 +289,16 @@ struct RawDataCollectorView: View { private func export(_ session: RawDataSessionStore.Session) async { guard let end = session.endedAtMs else { return } exportingId = session.id - let from = Int(session.startedAtMs / 1_000), to = Int(end / 1_000) - let chunks = await archive.pin(sessionId: session.id, deviceId: session.deviceId, - from: from, to: to, ble: model.ble) - let history = await model.ble.groundTruthHistoryCSV(from: from, to: to) - var entries = store.exportEntries(for: session, raw: []) + let bounds = Self.fullSecondBounds(fromMs: session.startedAtMs, toMs: end) + let from = bounds?.from ?? 1, to = bounds?.to ?? 0 + let chunks = if let bounds { + await archive.pin(sessionId: session.id, deviceId: session.deviceId, + from: bounds.from, to: bounds.to, ble: model.ble) + } else { [] } + let history = bounds == nil ? Data("stream,unix_s,v1,v2,v3,v4\n".utf8) + : await model.ble.groundTruthHistoryCSV(from: from, to: to) + let sensorAvailable = !chunks.isEmpty || history.split(separator: 0x0A).count > 1 + var entries = store.exportEntries(for: session, raw: [], sensorAvailable: sensorAvailable) entries.append(.init(name: "history-sensors.csv", data: history)) let imuComplete = Self.covers(chunks, from: from, to: to) let coverage: [String: Any] = [ @@ -250,11 +316,10 @@ struct RawDataCollectorView: View { } } let result = await FileExport.exportBundle(entries: entries, - suggestedName: "noop-ground-truth-\(session.id).zip") + suggestedName: "noop-5mg-raw-\(session.id).zip") if result == nil { exportError = "The export file could not be created or shared." } else { store.markExported(session.id) - if imuComplete { store.finishImuRecovery(session.id) } model.ble.finishGroundTruthRawCapture(sessionId: session.id) } exportingId = nil @@ -263,8 +328,14 @@ struct RawDataCollectorView: View { private func delete(_ session: RawDataSessionStore.Session) async { guard session.endedAtMs != nil else { return } model.ble.finishGroundTruthRawCapture(sessionId: session.id) - // Raw chunks may overlap another session; retention cleanup owns physical deletion. - store.removeMetadata(session.id) + guard await archive.deleteOwned(sessionId: session.id, ble: model.ble) else { + deleteError = "The captured data could not be deleted. The session was kept so you can retry." + return + } + guard store.removeMetadata(session.id) else { + deleteError = "The captured data could not be deleted. The session was kept so you can retry." + return + } rawBatchCounts[session.id] = nil } @@ -284,6 +355,15 @@ struct RawDataCollectorView: View { Date(timeIntervalSince1970: Double(ms) / 1_000).formatted(date: .omitted, time: .shortened) } + static func fullSecondBounds(fromMs: Int64, toMs: Int64) -> (from: Int, to: Int)? { + let from = Int((fromMs + 999) / 1_000), to = Int(toMs / 1_000) - 1 + return from <= to ? (from, to) : nil + } + + static func markerCurrentTime(session: RawDataSessionStore.Session?, now: Date) -> Date { + session?.endedAtMs.map { Date(timeIntervalSince1970: Double($0) / 1_000) } ?? now + } + private static func range(_ session: RawDataSessionStore.Session) -> String { let start = Date(timeIntervalSince1970: Double(session.startedAtMs) / 1_000) let date = start.formatted(date: .numeric, time: .omitted) @@ -291,6 +371,15 @@ struct RawDataCollectorView: View { return "\(date) · \(time(session.startedAtMs))–\(end)" } + private static func markerLabel(_ type: String?) -> LocalizedStringKey { + switch type { + case "start": "Start" + case "end": "End" + case "issue": "Issue" + default: "Moment" + } + } + private static func covers(_ chunks: [ImuChunkMeta], from: Int, to: Int) -> Bool { var cursor = from for chunk in chunks.sorted(by: { $0.startTs < $1.startTs }) { diff --git a/Strand/Screens/TestCentreView.swift b/Strand/Screens/TestCentreView.swift index 66b6e925cc..4d951818c2 100644 --- a/Strand/Screens/TestCentreView.swift +++ b/Strand/Screens/TestCentreView.swift @@ -149,7 +149,7 @@ struct TestCentreView: View { Text("5/MG RAW DATA COLLECTOR") .font(StrandFont.overline).tracking(StrandFont.overlineTracking) .foregroundStyle(StrandPalette.textSecondary) - Text("Start, label, review, export, and delete bounded 100 Hz motion sessions. Normal sync is unchanged.") + Text("Record, review, export, and delete bounded 100 Hz motion sessions. Normal sync is unchanged.") .font(StrandFont.caption).foregroundStyle(StrandPalette.textTertiary) .fixedSize(horizontal: false, vertical: true) NavigationLink { @@ -158,7 +158,7 @@ struct TestCentreView: View { Label("Open raw-data collector", systemImage: "waveform.path.ecg") } .buttonStyle(NoopButtonStyle(.primary, fullWidth: true)) - Text(live.connected ? "WHOOP 5/MG connected." : "Connect your WHOOP 5/MG to start a capture.") + Text(live.connected ? "WHOOP 5/MG connected." : "Connect your WHOOP 5/MG to start a raw-data session.") .font(StrandFont.caption) .foregroundStyle(live.connected ? StrandPalette.textSecondary : StrandPalette.statusWarning) } diff --git a/StrandTests/RawDataCollectorParityTests.swift b/StrandTests/RawDataCollectorParityTests.swift index f8a3a1c42e..e44971032e 100644 --- a/StrandTests/RawDataCollectorParityTests.swift +++ b/StrandTests/RawDataCollectorParityTests.swift @@ -48,4 +48,12 @@ final class RawDataCollectorParityTests: XCTestCase { XCTAssertEqual(try oracleData(), try Data(contentsOf: android), "Raw-data collector parity oracle copies must change together") } + + func testAppleKeepsTheOffSessionRealtimeImuFailSafe() throws { + let source = try String(contentsOf: repoRoot.appendingPathComponent("Strand/BLE/BLEManager.swift")) + XCTAssertTrue(source.contains("stopUnexpectedRealtimeImu(frame, isOffload: isOffload)")) + XCTAssertTrue(source.contains("frame[8] == 43 || frame[8] == 51")) + XCTAssertTrue(source.contains("send(.stopRawData, payload: [0x01]")) + XCTAssertTrue(source.contains("send(.toggleIMUMode, payload: [0x01, 0x00]")) + } } diff --git a/StrandTests/RawDataSessionStoreTests.swift b/StrandTests/RawDataSessionStoreTests.swift index 6bb96718cc..b743d6c1b1 100644 --- a/StrandTests/RawDataSessionStoreTests.swift +++ b/StrandTests/RawDataSessionStoreTests.swift @@ -1,5 +1,6 @@ import XCTest @testable import Strand +import WhoopStore @MainActor final class RawDataSessionStoreTests: XCTestCase { @@ -10,27 +11,134 @@ final class RawDataSessionStoreTests: XCTestCase { return url } - func testLifecycleLabelsUndoExclusionAndReload() throws { + func testRawSessionLifecycleAndReload() throws { let directory = try temporaryDirectory() let store = RawDataSessionStore(directory: directory) XCTAssertNotNil(store.start(deviceId: "strap", now: Date(timeIntervalSince1970: 100))) - store.record(.step, now: Date(timeIntervalSince1970: 101)) - store.record(.stair, now: Date(timeIntervalSince1970: 102)) - store.undo(now: Date(timeIntervalSince1970: 103)) - store.undo(now: Date(timeIntervalSince1970: 104)) // the same click cannot be undone twice - store.excludeLast(minutes: 5, now: Date(timeIntervalSince1970: 105)) store.stop(now: Date(timeIntervalSince1970: 106)) let session = try XCTUnwrap(store.sessions.first) - XCTAssertEqual(session.steps, 1) - XCTAssertEqual(session.stairs, 0) - XCTAssertEqual(session.excludedWindows, 1) XCTAssertEqual(session.endedAtMs, 106_000) + XCTAssertEqual(session.events.map(\.kind), ["start", "stop"]) let reloaded = RawDataSessionStore(directory: directory) XCTAssertEqual(reloaded.sessions.first, session) } + func testMarkersCanBeAddedEditedDeletedAndExported() throws { + let store = RawDataSessionStore(directory: try temporaryDirectory()) + let session = try XCTUnwrap(store.start(deviceId: "strap", now: Date(timeIntervalSince1970: 100))) + let marker = try XCTUnwrap(store.addMarker(sessionId: session.id, + at: Date(timeIntervalSince1970: 102), type: "issue", text: "Bluetooth off")) + store.updateMarker(sessionId: session.id, markerId: try XCTUnwrap(marker.markerId), + at: Date(timeIntervalSince1970: 103), type: "moment", text: "Recovered") + store.stop(now: Date(timeIntervalSince1970: 106)) + + let updated = try XCTUnwrap(store.sessions.first) + let saved = try XCTUnwrap(updated.events.first(where: { $0.kind == "marker" })) + XCTAssertEqual(saved.atMs, 103_000) + XCTAssertEqual(saved.markerType, "moment") + XCTAssertEqual(saved.text, "Recovered") + + let events = try XCTUnwrap(store.exportEntries(for: updated, raw: []) + .first(where: { $0.name == "events.jsonl" })) + XCTAssertTrue(String(decoding: events.data, as: UTF8.self).contains("\"marker_type\":\"moment\"")) + let meta = try XCTUnwrap(store.exportEntries(for: updated, raw: []) + .first(where: { $0.name == "meta.json" })) + let object = try XCTUnwrap(JSONSerialization.jsonObject(with: meta.data) as? [String: Any]) + let markers = try XCTUnwrap(object["markers"] as? [[String: Any]]) + XCTAssertEqual(markers.first?["type"] as? String, "moment") + + store.deleteMarker(sessionId: session.id, markerId: try XCTUnwrap(saved.markerId)) + XCTAssertFalse(try XCTUnwrap(store.sessions.first).events.contains(where: { $0.kind == "marker" })) + } + + func testMarkerTimeIsClampedToSession() throws { + let store = RawDataSessionStore(directory: try temporaryDirectory()) + let session = try XCTUnwrap(store.start(deviceId: "strap", now: Date(timeIntervalSince1970: 100))) + store.stop(now: Date(timeIntervalSince1970: 110)) + let marker = try XCTUnwrap(store.addMarker(sessionId: session.id, + at: Date(timeIntervalSince1970: 200), type: "end", text: "")) + XCTAssertEqual(marker.atMs, 110_000) + } + + func testEditedRangeFiltersExportWithoutDestroyingMarkers() throws { + let store = RawDataSessionStore(directory: try temporaryDirectory()) + let session = try XCTUnwrap(store.start(deviceId: "strap", now: Date(timeIntervalSince1970: 100))) + _ = store.addMarker(sessionId: session.id, at: Date(timeIntervalSince1970: 102), + type: "start", text: "outside") + _ = store.addMarker(sessionId: session.id, at: Date(timeIntervalSince1970: 105), + type: "moment", text: "inside") + store.stop(now: Date(timeIntervalSince1970: 110)) + store.setRange(sessionId: session.id, from: Date(timeIntervalSince1970: 104), + to: Date(timeIntervalSince1970: 108)) + + let edited = try XCTUnwrap(store.sessions.first) + XCTAssertEqual(edited.events.filter { $0.kind == "marker" }.count, 2) + let events = try XCTUnwrap(store.exportEntries(for: edited, raw: []) + .first(where: { $0.name == "events.jsonl" })) + let text = String(decoding: events.data, as: UTF8.self) + XCTAssertFalse(text.contains("outside")) + XCTAssertTrue(text.contains("inside")) + } + + func testExportReportsSensorAvailabilityFromAssembledPayload() throws { + let store = RawDataSessionStore(directory: try temporaryDirectory()) + _ = store.start(deviceId: "strap", now: Date(timeIntervalSince1970: 100)) + store.stop(now: Date(timeIntervalSince1970: 110)) + let session = try XCTUnwrap(store.sessions.first) + let meta = try XCTUnwrap(store.exportEntries(for: session, raw: [], sensorAvailable: true) + .first(where: { $0.name == "meta.json" })) + let object = try XCTUnwrap(JSONSerialization.jsonObject(with: meta.data) as? [String: Any]) + XCTAssertEqual(object["sensor_export_available"] as? Bool, true) + } + + func testFullSecondBoundsExcludePartialEndpointSeconds() { + let bounds = RawDataCollectorView.fullSecondBounds(fromMs: 100_001, toMs: 104_999) + XCTAssertEqual(bounds?.from, 101) + XCTAssertEqual(bounds?.to, 103) + XCTAssertNil(RawDataCollectorView.fullSecondBounds(fromMs: 100_001, toMs: 100_999)) + } + + func testChunksAreSessionOwnedAndStrictlyBounded() { + let owned = ImuChunkMeta(id: "one--chunk", deviceId: "strap", startTs: 101, endTs: 103, + sampleCount: 300, sampleRate: 100, formatVersion: 1, codec: "zip-deflate", + relativePath: "owned", byteSize: 1, sha256: "a", createdAt: 0, pinnedUntil: Int.max) + let other = ImuChunkMeta(id: "two--chunk", deviceId: "strap", startTs: 101, endTs: 103, + sampleCount: 300, sampleRate: 100, formatVersion: 1, codec: "zip-deflate", + relativePath: "other", byteSize: 1, sha256: "b", createdAt: 0, pinnedUntil: Int.max) + let broad = ImuChunkMeta(id: "one--broad", deviceId: "strap", startTs: 100, endTs: 104, + sampleCount: 500, sampleRate: 100, formatVersion: 1, codec: "zip-deflate", + relativePath: "broad", byteSize: 1, sha256: "c", createdAt: 0, pinnedUntil: Int.max) + + XCTAssertTrue(ImuChunkArchiveStore.isOwned(owned.id, by: "one")) + XCTAssertFalse(ImuChunkArchiveStore.isOwned(other.id, by: "one")) + XCTAssertEqual(ImuChunkArchiveStore.bounded([owned, broad], from: 101, to: 103), [owned]) + } + + func testArchiveDeleteFailureKeepsCatalogForRetry() async { + var operations: [String] = [] + let deleted = await ImuChunkArchiveStore.deleteRetrySafe(fileExists: true, deleteFile: { + operations.append("file"); return false + }, deleteCatalog: { + operations.append("catalog"); return true + }) + XCTAssertFalse(deleted) + XCTAssertEqual(operations, ["file"]) + } + + func testMarkerCurrentTimeTicksOnlyForActiveSession() throws { + let store = RawDataSessionStore(directory: try temporaryDirectory()) + _ = store.start(deviceId: "strap", now: Date(timeIntervalSince1970: 100)) + let active = try XCTUnwrap(store.sessions.first) + XCTAssertEqual(RawDataCollectorView.markerCurrentTime(session: active, + now: Date(timeIntervalSince1970: 105)).timeIntervalSince1970, 105) + store.stop(now: Date(timeIntervalSince1970: 110)) + let stopped = try XCTUnwrap(store.sessions.first) + XCTAssertEqual(RawDataCollectorView.markerCurrentTime(session: stopped, + now: Date(timeIntervalSince1970: 999)).timeIntervalSince1970, 110) + } + func testActiveSessionCannotBeDeleted() throws { let store = RawDataSessionStore(directory: try temporaryDirectory()) let active = try XCTUnwrap(store.start(deviceId: "strap")) @@ -43,8 +151,27 @@ final class RawDataSessionStoreTests: XCTestCase { let store = RawDataSessionStore(directory: directory) let session = try XCTUnwrap(store.start(deviceId: "strap")) store.stop() - store.removeMetadata(session.id) + XCTAssertTrue(store.removeMetadata(session.id)) XCTAssertTrue(store.sessions.isEmpty) XCTAssertTrue(RawDataSessionStore(directory: directory).sessions.isEmpty) } + + func testFailedSourceDeletionKeepsSessionRetryable() throws { + let directory = try temporaryDirectory() + let store = RawDataSessionStore(directory: directory) + let session = try XCTUnwrap(store.start(deviceId: "strap")) + store.stop() + let source = ImuSessionFileStore.shared.file(session.id) + XCTAssertTrue(FileManager.default.createFile(atPath: source.path, contents: Data("raw".utf8))) + + XCTAssertFalse(store.removeMetadata(session.id) { url in + if url == source { + throw CocoaError(.fileWriteNoPermission) + } + try FileManager.default.removeItem(at: url) + }) + XCTAssertEqual(store.sessions.map(\.id), [session.id]) + XCTAssertEqual(RawDataSessionStore(directory: directory).sessions.map(\.id), [session.id]) + XCTAssertTrue(store.removeMetadata(session.id)) + } } diff --git a/StrandTests/Resources/raw_data_collector_parity.json b/StrandTests/Resources/raw_data_collector_parity.json index ed0cbe362b..f01ff79b83 100644 --- a/StrandTests/Resources/raw_data_collector_parity.json +++ b/StrandTests/Resources/raw_data_collector_parity.json @@ -13,6 +13,10 @@ "swift": ["setComment(", "Session comment"], "kotlin": ["setComment(", "ground_truth_comment"] }, + "session_markers": { + "swift": ["addMarker(sessionId:", "updateMarker(sessionId:", "deleteMarker(sessionId:", "Add marker"], + "kotlin": ["fun addMarker(", "fun updateMarker(", "fun deleteMarker(", "ground_truth_add_marker"] + }, "from_to_time": { "swift": ["func range(", "endedAtMs"], "kotlin": ["sessionTimeRange(", "endedAtMs"] @@ -59,7 +63,7 @@ }, "raw_data_removed_with_session": { "swift": ["deleteRawBatches(deviceId:", "capturedAt >= ? AND capturedAt <= ?"], - "kotlin": ["realtime-imu-$sessionId.imus", "noop-ground-truth-$sessionId.zip"] + "kotlin": ["realtime-imu-$sessionId.imus", "noop-5mg-raw-$sessionId.zip"] } } } diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index faf3ec882b..21857254e3 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -26,7 +26,7 @@ android { applicationId = "com.noop.whoop" minSdk = 26 targetSdk = 34 - versionCode = 376 + versionCode = 373 versionName = "10.6.1" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" diff --git a/android/app/src/main/java/com/noop/ble/PuffinDeepBufferLog.kt b/android/app/src/main/java/com/noop/ble/PuffinDeepBufferLog.kt index b55f51a949..510e9de21f 100644 --- a/android/app/src/main/java/com/noop/ble/PuffinDeepBufferLog.kt +++ b/android/app/src/main/java/com/noop/ble/PuffinDeepBufferLog.kt @@ -11,8 +11,8 @@ import com.noop.protocol.Whoop5RawImu * `PuffinDeepBufferLog` (its pure, unit-testable core); the file I/O + capture-toggle gating live in * [WhoopBleClient.writeWhoop5DeepBufferIfBig], mirroring the existing `whoop5-events.jsonl` writer. * - * On-strap probing (#423) established that the 5/MG has no live raw-IMU stream, but it DOES bank - * high-rate motion and ships it inside big type-0x2F buffers during the connect-time offload burst — + * On-strap probing (#423) established that the 5/MG banks high-rate motion and can stream it live + * during an explicitly armed raw-data session; history also ships it in big type-0x2F offload buffers — * 124 B (~1 Hz record), 1244 B and 2140 B (~32 and ~59 sub-records per timestamped second). NOOP's * historical decoder pulls the 1 Hz gravity vector out and DISCARDS the high-rate remainder. Keeping the * big (>= [minBufferBytes]) type-0x2F buffers raw, in their own file, lets a byte-perfect decoder be diff --git a/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt b/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt index 3eeed76c53..37271b5f93 100644 --- a/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt +++ b/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt @@ -9262,10 +9262,8 @@ class WhoopBleClient( */ /** Debug heartbeat for valid WHOOP 5/MG raw-IMU buffers. Session persistence is file-only. */ private fun storeWhoop5RawImuIfBuffer(frame: ByteArray) { - // The bounded collector owns its own capture gate. It must not require the unrelated passive - // protocol-trace preference: live and history copies then converge on the same keyed raw-IMU - // table, whose primary key deduplicates a Bluetooth-gap repair automatically. - if (!PuffinExperiment.from(context).isCaptureEnabled && groundTruthImuSessionId == null) return + // This compatibility cache keeps its original opt-in gate. Bounded sessions are file-backed. + if (!PuffinExperiment.from(context).isCaptureEnabled) return val cols = Whoop5RawImu.rawColumns(frame) ?: return val baseTs = PuffinDeepBufferLog.strapTs(frame)?.toLong() ?: return // #423 debug heartbeat: confirm the offload IMU is arriving + decoding on-device without pulling the diff --git a/android/app/src/main/java/com/noop/data/DeviceRegistry.kt b/android/app/src/main/java/com/noop/data/DeviceRegistry.kt index 4343190d1e..ba7ee0d39e 100644 --- a/android/app/src/main/java/com/noop/data/DeviceRegistry.kt +++ b/android/app/src/main/java/com/noop/data/DeviceRegistry.kt @@ -99,6 +99,7 @@ class DeviceRegistry( dao.reKeyPpgHr(activeId, serialId); dao.deletePpgHrFor(activeId) dao.reKeyPpgWaveform(activeId, serialId); dao.deletePpgWaveformFor(activeId) dao.reKeyV18Aux(activeId, serialId); dao.deleteV18AuxFor(activeId) + dao.reKeyImuChunks(activeId, serialId); dao.deleteImuChunkCatalogFor(activeId) dao.reKeyEvents(activeId, serialId); dao.deleteEventsFor(activeId) dao.reKeyBattery(activeId, serialId); dao.deleteBatteryFor(activeId) dao.reKeyDailyMetrics(activeId, serialId); dao.deleteDailyMetricsFor(activeId) @@ -188,6 +189,7 @@ class DeviceRegistry( dao.deletePpgHrFor(id) dao.deletePpgWaveformFor(id) dao.deleteV18AuxFor(id) + dao.deleteImuChunkCatalogFor(id) dao.deleteEventsFor(id) dao.deleteBatteryFor(id) dao.deleteDailyMetricsFor(id) diff --git a/android/app/src/main/java/com/noop/data/DeviceRegistryDao.kt b/android/app/src/main/java/com/noop/data/DeviceRegistryDao.kt index 099edec1b6..dc51c62d6f 100644 --- a/android/app/src/main/java/com/noop/data/DeviceRegistryDao.kt +++ b/android/app/src/main/java/com/noop/data/DeviceRegistryDao.kt @@ -86,6 +86,7 @@ interface DeviceRegistryDao { @Query("DELETE FROM ppgHrSample WHERE deviceId = :deviceId") suspend fun deletePpgHrFor(deviceId: String) @Query("DELETE FROM ppgWaveformSample WHERE deviceId = :deviceId") suspend fun deletePpgWaveformFor(deviceId: String) @Query("DELETE FROM v18AuxSample WHERE deviceId = :deviceId") suspend fun deleteV18AuxFor(deviceId: String) + @Query("DELETE FROM imuChunk WHERE deviceId = :deviceId") suspend fun deleteImuChunkCatalogFor(deviceId: String) @Query("DELETE FROM event WHERE deviceId = :deviceId") suspend fun deleteEventsFor(deviceId: String) @Query("DELETE FROM battery WHERE deviceId = :deviceId") suspend fun deleteBatteryFor(deviceId: String) @Query("DELETE FROM dailyMetric WHERE deviceId = :deviceId") suspend fun deleteDailyMetricsFor(deviceId: String) @@ -126,6 +127,7 @@ interface DeviceRegistryDao { @Query("UPDATE OR IGNORE ppgHrSample SET deviceId = :to WHERE deviceId = :from") suspend fun reKeyPpgHr(from: String, to: String) @Query("UPDATE OR IGNORE ppgWaveformSample SET deviceId = :to WHERE deviceId = :from") suspend fun reKeyPpgWaveform(from: String, to: String) @Query("UPDATE OR IGNORE v18AuxSample SET deviceId = :to WHERE deviceId = :from") suspend fun reKeyV18Aux(from: String, to: String) + @Query("UPDATE OR IGNORE imuChunk SET deviceId = :to WHERE deviceId = :from") suspend fun reKeyImuChunks(from: String, to: String) @Query("UPDATE OR IGNORE event SET deviceId = :to WHERE deviceId = :from") suspend fun reKeyEvents(from: String, to: String) @Query("UPDATE OR IGNORE battery SET deviceId = :to WHERE deviceId = :from") suspend fun reKeyBattery(from: String, to: String) @Query("UPDATE OR IGNORE dailyMetric SET deviceId = :to WHERE deviceId = :from") suspend fun reKeyDailyMetrics(from: String, to: String) diff --git a/android/app/src/main/java/com/noop/data/WhoopDao.kt b/android/app/src/main/java/com/noop/data/WhoopDao.kt index 2dbd0c3265..aa772258d0 100644 --- a/android/app/src/main/java/com/noop/data/WhoopDao.kt +++ b/android/app/src/main/java/com/noop/data/WhoopDao.kt @@ -149,9 +149,18 @@ interface WhoopDao : DeviceRegistryDao { @Query("SELECT * FROM imuChunk WHERE deviceId = :deviceId AND endTs >= :from AND startTs <= :to ORDER BY startTs") suspend fun imuChunks(deviceId: String, from: Long, to: Long): List + @Query("SELECT * FROM imuChunk WHERE deviceId = :deviceId ORDER BY startTs") + suspend fun imuChunksForDevice(deviceId: String): List + + @Query("SELECT * FROM imuChunk WHERE id LIKE :idPrefix || '%' ORDER BY startTs") + suspend fun imuChunksByIdPrefix(idPrefix: String): List + @Query("DELETE FROM imuChunk WHERE id = :id") suspend fun deleteImuChunk(id: String) + @Query("DELETE FROM imuChunk WHERE deviceId = :deviceId") + suspend fun deleteImuChunksFor(deviceId: String) + @Query("SELECT * FROM imuChunk WHERE pinnedUntil IS NULL AND endTs < :cutoff ORDER BY endTs") suspend fun expiredImuChunks(cutoff: Long): List diff --git a/android/app/src/main/java/com/noop/data/WhoopDatabase.kt b/android/app/src/main/java/com/noop/data/WhoopDatabase.kt index d1337d1167..c0f1ec41b0 100644 --- a/android/app/src/main/java/com/noop/data/WhoopDatabase.kt +++ b/android/app/src/main/java/com/noop/data/WhoopDatabase.kt @@ -567,9 +567,7 @@ abstract class WhoopDatabase : RoomDatabase() { } } - /** #423: the WHOOP 5/MG raw-IMU offload-capture table. Additive; GRDB twin is `v28-raw-imu` - * (Swift's next slot after v27-ppg-waveform), so the migration COUNTS stay aligned. Column order == - * [RawImuSampleEntity] field order, matching the GRDB schema's t.column(deviceId/ts/samples). */ + /** Historical #423 schema. Known-empty deployments are cleaned up after file-backed capture lands. */ internal val RAW_IMU_MIGRATION_SQL: List = listOf( "CREATE TABLE IF NOT EXISTS `rawImuSample` (`deviceId` TEXT NOT NULL, " + "`ts` INTEGER NOT NULL, `samples` BLOB NOT NULL, PRIMARY KEY(`deviceId`, `ts`))", diff --git a/android/app/src/main/java/com/noop/data/WhoopRepository.kt b/android/app/src/main/java/com/noop/data/WhoopRepository.kt index 6f63e45dcd..4c9c334da5 100644 --- a/android/app/src/main/java/com/noop/data/WhoopRepository.kt +++ b/android/app/src/main/java/com/noop/data/WhoopRepository.kt @@ -561,9 +561,8 @@ class WhoopRepository( } if (rows.isNotEmpty()) { dao.insertV18Aux(rows) - // Rolling retention (the insertRawImu shape, #423) but AMORTISED. The delete finds the - // Nth-newest row by rank, so it walks up to [V18_AUX_RETENTION_ROWS] index entries; - // insertRawImu keeps 3,600 so that is free, this keeps 604,800 and an offload inserts once + // Rolling retention is amortised. The delete finds the Nth-newest row by rank, so it walks up + // to [V18_AUX_RETENTION_ROWS] index entries; this keeps 604,800 and an offload inserts once // per chunk. Swept once per [V18_AUX_PRUNE_EVERY_ROWS] rows instead, which keeps // newest-N-rows exactly (a time window would not — a sporadically-worn strap's rows span // far more than a week, and the census wants that). Counter is per device because the @@ -1003,11 +1002,16 @@ class WhoopRepository( dao.v18AuxSamples(deviceId, from, to, limit) .map { V18AuxCodec.unpack(it.fields, it.ts) } + suspend fun upsertImuChunk(row: ImuChunkEntity) = dao.upsertImuChunk(row) suspend fun imuChunks(deviceId: String, from: Long, to: Long): List = dao.imuChunks(deviceId, from, to) + suspend fun imuChunksForDevice(deviceId: String): List = dao.imuChunksForDevice(deviceId) + suspend fun imuChunksByIdPrefix(idPrefix: String): List = + dao.imuChunksByIdPrefix(idPrefix) suspend fun expiredImuChunks(cutoff: Long): List = dao.expiredImuChunks(cutoff) suspend fun deleteImuChunk(id: String) = dao.deleteImuChunk(id) + suspend fun deleteImuChunksFor(deviceId: String) = dao.deleteImuChunksFor(deviceId) /** Downsampled HR (mean bpm per [bucketSeconds]) for the strap, for the Today 24h trend chart. */ suspend fun hrBuckets(deviceId: String, from: Long, to: Long, bucketSeconds: Long = 300L) = @@ -1952,8 +1956,7 @@ class WhoopRepository( /** * v31: rolling retention for the v18 aux-slot table (Swift twin `WhoopStore.v18AuxRetentionRows`). * - * [RAW_IMU_RETENTION_ROWS] is the closest precedent — raw instrumentation banked as a blob, capped - * rather than unbounded — and the same reasoning applies: nothing reads these rows yet, so a cap + * Raw instrumentation is capped rather than unbounded. Nothing reads these rows yet, so a cap * is far cheaper to RELAX later than to impose once users have a year of history. Unbounded, this * table is the one genuinely new source of row growth in v31; the four named columns only WIDEN * rows that were already being written (~14 B on a gravity/skinTemp/sleepState row that exists diff --git a/android/app/src/main/java/com/noop/protocol/Whoop5RawImu.kt b/android/app/src/main/java/com/noop/protocol/Whoop5RawImu.kt index 16d9b05766..7348577004 100644 --- a/android/app/src/main/java/com/noop/protocol/Whoop5RawImu.kt +++ b/android/app/src/main/java/com/noop/protocol/Whoop5RawImu.kt @@ -9,9 +9,9 @@ package com.noop.protocol // app captures (Asherlc/dofek docs/whoop-ble-protocol.md type-0x2B raw packet) and the same columnar // convention as the 4.0's 1917 REALTIME_RAW_DATA variant. // -// IMPORTANT: the 5/MG's *live* raw-IMU stream is firmware-refused (TOGGLE_IMU_MODE / cmd 106 acks but -// never streams), but the OFFLOAD buffer carries full accel AND gyro — so 100 Hz 6-axis IMU IS -// obtainable on the 5.0 via the historical path. +// Hardware validation on a WHOOP 5/MG established that live raw IMU requires START_RAW_DATA (81) +// followed by TOGGLE_IMU_MODE (106) with payload [1,1]. Opcode 106 on its own acknowledges without +// starting the producer. The same 1244-byte shape is also banked and later returned by history sync. // // Frame layout (reassembled BLE frame = 8-byte puffin envelope + payload; offsets are FRAME-absolute): // @15 u32 LE strap unix seconds for this 1-second frame diff --git a/android/app/src/main/java/com/noop/testcentre/GroundTruthCollector.kt b/android/app/src/main/java/com/noop/testcentre/GroundTruthCollector.kt index c17d8a9e35..103d71ca37 100644 --- a/android/app/src/main/java/com/noop/testcentre/GroundTruthCollector.kt +++ b/android/app/src/main/java/com/noop/testcentre/GroundTruthCollector.kt @@ -12,6 +12,7 @@ import java.io.File import java.util.zip.ZipEntry import java.util.zip.ZipOutputStream import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import org.json.JSONObject import java.util.UUID @@ -148,17 +149,24 @@ class GroundTruthCollector private constructor(private val context: Context) { fun deleteSession(sessionId: String): Boolean { val current = snapshot() if (current.active && current.sessionId == sessionId) return false + if (sessions().none { it.id == sessionId }) return false - val files = listOf( - eventFile(sessionId), + val payloadFiles = listOf( File(directory, "realtime-imu-$sessionId.imus"), - File(context.cacheDir, "logs/noop-ground-truth-$sessionId.zip"), + File(context.cacheDir, "logs/noop-5mg-raw-$sessionId.zip"), ) // Evaluate every deletion even if one fails, so a stale export ZIP never survives merely // because an earlier file could not be removed. - val filesDeleted = files.map { file -> !file.exists() || file.delete() }.all { it } + val chunksDeleted = runBlocking { + ImuChunkStore(context, WhoopRepository.from(context)).deleteOwned(sessionId) + } + // Keep the session discoverable and retryable until its durable archive catalog is clean. + if (!chunksDeleted) return false + if (!payloadFiles.all { file -> !file.exists() || file.delete() }) return false ImuSessionFileStore(context).remove(sessionId) - if (eventFile(sessionId).exists()) return false + // The event file is also the durable session index. Delete it last so any earlier failure + // leaves the session visible and the explicit delete action retryable. + if (eventFile(sessionId).exists() && !eventFile(sessionId).delete()) return false prefs.edit() .remove(sessionDeviceKey(sessionId)) @@ -174,7 +182,7 @@ class GroundTruthCollector private constructor(private val context: Context) { if (current.sessionId == sessionId) clearCurrentSession() } .apply() - return filesDeleted + return true } /** Trim the analysis/export interval without rewriting the physical capture provenance. */ @@ -278,25 +286,41 @@ class GroundTruthCollector private constructor(private val context: Context) { } val endMs = summary.endedAtMs ?: events.lastOrNull()?.optLong("at_ms") ?: summary.startedAtMs // Export the complete bounded interval; markers are optional annotations. - val sensorFrom = if (deviceId == null) 1L else summary.startedAtMs / 1_000L - val sensorTo = if (deviceId == null) 0L else endMs / 1_000L - val pinnedChunks = if (deviceId == null) emptyList() else + val sensorFrom = ceilSecond(summary.startedAtMs) + val sensorTo = endMs / 1_000L - 1L + val pinnedChunks = if (deviceId == null || sensorFrom > sensorTo) emptyList() else ImuChunkStore(context, repo).pin(id, deviceId, sensorFrom, sensorTo) - val fullFrom = ceilSecond(summary.startedAtMs) + val fullFrom = sensorFrom val fullTo = endMs / 1_000L - 1L - val coverageFrom = if (summary.capturedStartedAtMs != null) - maxOf(fullFrom, pinnedChunks.minOfOrNull { it.startTs } ?: fullFrom) else fullFrom + val coverageFrom = sensorFrom val imuComplete = covers(pinnedChunks, coverageFrom, fullTo) val outDir = File(context.cacheDir, "logs").apply { mkdirs() } - val zip = File(outDir, "noop-ground-truth-$id.zip") + val zip = File(outDir, "noop-5mg-raw-$id.zip") ZipOutputStream(zip.outputStream().buffered()).use { out -> + var v18Count = 0 + out.putNextEntry(ZipEntry("v18-aux.csv")) + out.bufferedWriterNoClose().use { writer -> + v18Count = writeV18AuxCsv(writer, repo, deviceId.orEmpty(), sensorFrom, sensorTo) + writer.flush() + } + out.closeEntry() + + var rawCounts: Map = emptyMap() + out.putNextEntry(ZipEntry("raw-sensors.csv")) + out.bufferedWriterNoClose().use { writer -> + rawCounts = RawSensorExport.writeCsv(writer, repo, deviceId.orEmpty(), sensorFrom, sensorTo) + writer.flush() + } + out.closeEntry() + + val sensorAvailable = pinnedChunks.isNotEmpty() || v18Count > 0 || rawCounts.values.any { it > 0 } out.putNextEntry(ZipEntry("meta.json")) out.writerEntry(JSONObject().apply { put("schema_version", 3) put("capture_kind", "whoop_5mg_raw_data") put("session_id", id) if (deviceId != null) put("device_id", deviceId) - put("sensor_export_available", deviceId != null) + put("sensor_export_available", sensorAvailable) put("imu_100hz_chunk_count", pinnedChunks.size) put("imu_100hz_complete", imuComplete) put("imu_100hz_required_start_ts", coverageFrom) @@ -310,8 +334,11 @@ class GroundTruthCollector private constructor(private val context: Context) { }) put("started_at_ms", summary.startedAtMs) put("ended_at_ms", endMs) + summary.capturedStartedAtMs?.let { put("captured_started_at_ms", it) } + summary.capturedEndedAtMs?.let { put("captured_ended_at_ms", it) } put("comment", summary.comment) - put("markers", org.json.JSONArray().apply { summary.markers.forEach { marker -> put(JSONObject().apply { + put("markers", org.json.JSONArray().apply { + summary.markers.filter { it.atMs in summary.startedAtMs..endMs }.forEach { marker -> put(JSONObject().apply { put("id", marker.id); put("at_ms", marker.atMs); put("type", marker.type); put("text", marker.text) }) } }) put("device_family", "Android") @@ -319,41 +346,10 @@ class GroundTruthCollector private constructor(private val context: Context) { }.toString(2)) out.putNextEntry(ZipEntry("events.jsonl")) - out.writerEntry(eventsJsonl(events)) + out.writerEntry(eventsJsonl(publicEvents(events, summary.startedAtMs, endMs, deviceId))) out.putNextEntry(ZipEntry("events.csv")) - out.writerEntry(eventsCsv(events)) - - out.putNextEntry(ZipEntry("algorithm-signals.csv")) - out.bufferedWriterNoClose().use { writer -> - writeAlgorithmSignalsCsv( - writer, - repo, - deviceId.orEmpty(), - sensorFrom, - sensorTo, - ) - writer.flush() - } - out.closeEntry() - - out.putNextEntry(ZipEntry("v18-aux.csv")) - out.bufferedWriterNoClose().use { writer -> - writeV18AuxCsv(writer, repo, deviceId.orEmpty(), sensorFrom, sensorTo) - writer.flush() - } - out.closeEntry() - - out.putNextEntry(ZipEntry("raw-sensors.csv")) - out.bufferedWriterNoClose().use { writer -> - RawSensorExport.writeCsv( - writer, repo, deviceId.orEmpty(), - sensorFrom, - sensorTo, - ) - writer.flush() - } - out.closeEntry() + out.writerEntry(eventsCsv(publicEvents(events, summary.startedAtMs, endMs, deviceId))) for (chunk in pinnedChunks) { val file = ImuChunkStore(context, repo).file(chunk) @@ -365,10 +361,6 @@ class GroundTruthCollector private constructor(private val context: Context) { } val exportedAt = System.currentTimeMillis() prefs.edit().putBoolean(sessionExportedKey(id), true).putLong(sessionLastExportKey(id), exportedAt).apply() - if (imuComplete) { - ImuSessionFileStore(context).remove(id) - File(directory, "realtime-imu-$id.imus").delete() - } if (id == snap.sessionId) prefs.edit().putBoolean("exported", true).apply() zip } @@ -403,24 +395,23 @@ class GroundTruthCollector private constructor(private val context: Context) { private fun event(atMs: Long, kind: String) = JSONObject().apply { put("at_ms", atMs); put("kind", kind) } - private fun publicEvents(events: List) = events.mapNotNull { source -> - val kind = source.optString("kind") - if (kind !in setOf("start", "stop", KIND_MARKER)) return@mapNotNull null - JSONObject().apply { - put("at_ms", source.optLong("at_ms")); put("kind", kind) - if (kind == KIND_MARKER) { - put("marker_id", source.optString("marker_id")); put("marker_type", source.optString("marker_type")) - put("text", source.optString("text")) - } - if (kind == "start" && source.has("strap_device_id")) put("strap_device_id", source.optString("strap_device_id")) - } - } + private fun publicEvents(events: List, fromMs: Long, toMs: Long, deviceId: String?): List = + buildList { + add(event(fromMs, "start").apply { deviceId?.let { put("strap_device_id", it) } }) + events.filter { it.optString("kind") == KIND_MARKER && it.optLong("at_ms") in fromMs..toMs } + .forEach { source -> add(JSONObject().apply { + put("at_ms", source.optLong("at_ms")); put("kind", KIND_MARKER) + put("marker_id", source.optString("marker_id")); put("marker_type", source.optString("marker_type")) + put("text", source.optString("text")) + }) } + add(event(toMs, "stop")) + }.sortedBy { it.optLong("at_ms") } - private fun eventsJsonl(events: List) = publicEvents(events).joinToString("\n", postfix = "\n") + private fun eventsJsonl(events: List) = events.joinToString("\n", postfix = "\n") private fun eventsCsv(events: List): String = buildString { append("at_ms,unix_s,kind,marker_id,marker_type,text\n") - for (e in publicEvents(events)) append(listOf( + for (e in events) append(listOf( e.optLong("at_ms"), e.optLong("at_ms") / 1_000L, e.optString("kind"), e.optString("marker_id"), e.optString("marker_type"), e.optString("text"), ).joinToString(",") { csv(it.toString()) }).append('\n') @@ -429,63 +420,28 @@ class GroundTruthCollector private constructor(private val context: Context) { private fun csv(value: String): String = if (value.any { it == ',' || it == '"' || it == '\n' }) "\"${value.replace("\"", "\"\"")}\"" else value - private suspend fun writeAlgorithmSignalsCsv( - out: java.io.Writer, - repo: WhoopRepository, - deviceId: String, - from: Long, - to: Long, - ) { - out.write("unix_s,counter,activity_class,gravity_x,gravity_y,gravity_z,dynamic_accel,cadence,heart_rate,band_sleep_state\n") - if (from > to || deviceId.isBlank()) return - val seconds = (to - from + 1L).coerceIn(1L, 86_400L).toInt() - val steps = repo.stepSamples(deviceId, from, to, seconds).associateBy { it.ts } - val gravity = repo.gravitySamples(deviceId, from, to, seconds).associateBy { it.ts } - val cadence = repo.v18AuxSamples(deviceId, from, to, seconds).associate { it.ts to it.stepCadence } - val heartRate = repo.rawHrSamples(deviceId, from, to, seconds).associate { it.ts to it.bpm } - val sleepState = repo.sleepStateSamples(deviceId, from, to, seconds).associate { it.ts to it.state } - val timestamps = (steps.keys + gravity.keys + cadence.keys + heartRate.keys + sleepState.keys).sorted() - for (ts in timestamps) { - val step = steps[ts] - val grav = gravity[ts] - out.write(listOf( - ts, - step?.counter ?: "", - step?.activityClass ?: "", - grav?.x?.let(::decimal) ?: "", - grav?.y?.let(::decimal) ?: "", - grav?.z?.let(::decimal) ?: "", - grav?.dynAccel?.let(::decimal) ?: "", - cadence[ts] ?: "", - heartRate[ts] ?: "", - sleepState[ts] ?: "", - ).joinToString(",")) - out.write("\n") - } - } - private suspend fun writeV18AuxCsv( out: java.io.Writer, repo: WhoopRepository, deviceId: String, from: Long, to: Long, - ) { + ): Int { out.write( "unix_s,record_index,rr_count,cardiac_flags,hr_quality_flags,heart_rate_alt,rr_packed," + "cardiac_status,step_cadence,status_word,status_word_1,status_word_2,aux_byte_82," + "optical_baseline_a,optical_baseline_b,optical_amp_a,optical_amp_b,unknown_f32_bits\n", ) - if (from > to || deviceId.isBlank()) return + if (from > to || deviceId.isBlank()) return 0 val seconds = (to - from + 1L).coerceIn(1L, 86_400L).toInt() - for (row in repo.v18AuxSamples(deviceId, from, to, seconds)) { + val rows = repo.v18AuxSamples(deviceId, from, to, seconds) + for (row in rows) { out.write((listOf(row.ts) + row.slotValues.map { it ?: "" }).joinToString(",")) out.write("\n") } + return rows.size } - private fun decimal(value: Double): String = String.format(java.util.Locale.US, "%.9f", value) - private fun android.content.SharedPreferences.Editor.clearCurrentSession() = apply { remove("active") remove("exported") diff --git a/android/app/src/main/java/com/noop/testcentre/ImuChunkStore.kt b/android/app/src/main/java/com/noop/testcentre/ImuChunkStore.kt index 2e96488a35..212c54ee3e 100644 --- a/android/app/src/main/java/com/noop/testcentre/ImuChunkStore.kt +++ b/android/app/src/main/java/com/noop/testcentre/ImuChunkStore.kt @@ -17,6 +17,7 @@ import java.util.zip.Inflater import java.util.zip.ZipEntry import java.util.zip.ZipOutputStream import org.json.JSONObject +import org.json.JSONArray /** Materializes immutable archives from a session file; SQLite is only a short history staging cache. */ class ImuChunkStore(private val context: Context, private val repository: WhoopRepository) { @@ -26,15 +27,20 @@ class ImuChunkStore(private val context: Context, private val repository: WhoopR require(from <= to) ImuSessionFileStore(context).prepareForRead(sessionId) val existing = repository.imuChunks(deviceId, from, to) - .filter { it.pinnedUntil == Long.MAX_VALUE && File(context.filesDir, it.relativePath).isFile } - if (existing.any { it.startTs <= from && it.endTs >= to && - it.sampleCount >= (it.endTs - it.startTs + 1) * it.sampleRate }) return existing + .filter { isOwnedChunk(sessionId, it.id) && + it.pinnedUntil == Long.MAX_VALUE && + File(context.filesDir, it.relativePath).isFile } + existing.firstOrNull { it.startTs == from && it.endTs == to && + it.sampleCount >= (it.endTs - it.startTs + 1) * it.sampleRate }?.let { exact -> + deleteOwned(sessionId, setOf(exact.id)) + return listOf(exact) + } val rows = readSessionRows(sessionId, from, to) - if (rows.isEmpty()) return existing + if (rows.isEmpty()) return boundedExisting(existing, from, to) val startTs = rows.minOf { it.first } val endTs = rows.maxOf { it.first } - val id = UUID.randomUUID().toString() + val id = chunkPrefix(sessionId) + UUID.randomUUID().toString() val final = File(directory, "$id.imuc") val temp = File(directory, "$id.tmp") ZipOutputStream(BufferedOutputStream(FileOutputStream(temp))).use { zip -> @@ -43,7 +49,7 @@ class ImuChunkStore(private val context: Context, private val repository: WhoopR put("format", "NOOPIMU") put("version", FORMAT_VERSION) put("sample_rate_hz", SAMPLE_RATE) - put("axes", listOf("ax", "ay", "az", "gx", "gy", "gz")) + put("axes", JSONArray(listOf("ax", "ay", "az", "gx", "gy", "gz"))) put("layout", "column-major-int16-le") put("ordering", "unspecified") put("timestamp_source", "strap") @@ -73,11 +79,44 @@ class ImuChunkStore(private val context: Context, private val repository: WhoopR pinnedUntil = Long.MAX_VALUE, ) repository.upsertImuChunk(entity) - return coverage(deviceId, from, to) + deleteOwned(sessionId, setOf(id)) + return coverage(sessionId, deviceId, from, to) + } + + suspend fun coverage(sessionId: String, deviceId: String, from: Long, to: Long): List = + repository.imuChunks(deviceId, from, to).filter { + it.id.startsWith(chunkPrefix(sessionId)) && it.startTs >= from && it.endTs <= to && + File(context.filesDir, it.relativePath).isFile + } + + suspend fun deleteOwned( + sessionId: String, + keepIds: Set = emptySet(), + ): Boolean { + val prefix = chunkPrefix(sessionId) + val catalogIds = runCatching { repository.imuChunksByIdPrefix(prefix).map { it.id } } + .getOrElse { return false } + val fileIds = directory.listFiles { file -> + file.extension == "imuc" && isOwnedChunk(sessionId, file.nameWithoutExtension) + }.orEmpty().map { it.nameWithoutExtension } + return (catalogIds + fileIds).toSet().filterNot { it in keepIds }.map { id -> + val file = File(directory, "$id.imuc") + deleteRetrySafe( + fileExists = file.exists(), + deleteFile = file::delete, + deleteCatalog = { repository.deleteImuChunk(id) }, + ) + }.all { it } } - suspend fun coverage(deviceId: String, from: Long, to: Long): List = - repository.imuChunks(deviceId, from, to).filter { File(context.filesDir, it.relativePath).isFile } + suspend fun deleteDevice(deviceId: String): Boolean { + val chunks = runCatching { repository.imuChunksForDevice(deviceId) }.getOrElse { return false } + for (chunk in chunks) { + val file = file(chunk) + if (file.exists() && !file.delete()) return false + } + return runCatching { repository.deleteImuChunksFor(deviceId) }.isSuccess + } fun file(chunk: ImuChunkEntity) = File(context.filesDir, chunk.relativePath) @@ -123,6 +162,18 @@ class ImuChunkStore(private val context: Context, private val repository: WhoopR } companion object { + private fun chunkPrefix(sessionId: String) = "$sessionId--" + internal fun isOwnedChunk(sessionId: String, chunkId: String) = chunkId.startsWith(chunkPrefix(sessionId)) + internal fun boundedExisting(chunks: List, from: Long, to: Long) = + chunks.filter { it.startTs >= from && it.endTs <= to } + internal suspend fun deleteRetrySafe( + fileExists: Boolean, + deleteFile: () -> Boolean, + deleteCatalog: suspend () -> Unit, + ): Boolean { + if (fileExists && !deleteFile()) return false + return runCatching { deleteCatalog() }.isSuccess + } const val FORMAT_VERSION = 1 const val SAMPLE_RATE = 100 const val MAX_EXPORT_SECONDS = 7 * 24 * 60 * 60L diff --git a/android/app/src/main/java/com/noop/ui/AppViewModel.kt b/android/app/src/main/java/com/noop/ui/AppViewModel.kt index a1f8dbdbfa..139ad1b832 100644 --- a/android/app/src/main/java/com/noop/ui/AppViewModel.kt +++ b/android/app/src/main/java/com/noop/ui/AppViewModel.kt @@ -254,12 +254,18 @@ class AppViewModel(app: Application) : AndroidViewModel(app) { noopApp.deviceRegistry.rename(id, nickname) /** Permanently delete all of a device's recorded data (its registry row is kept). */ - suspend fun deletePairedDeviceData(id: String) = noopApp.deviceRegistry.deleteDeviceData(id) + suspend fun deletePairedDeviceData(id: String) { + if (com.noop.testcentre.ImuChunkStore(getApplication(), noopApp.repository).deleteDevice(id)) + noopApp.deviceRegistry.deleteDeviceData(id) + } /** Permanently forget a device: wipe all its recorded data AND remove its registry entry, so a * duplicate/stale strap disappears from the list entirely (an archived row could otherwise only be * re-activated or data-wiped, never purged — #1193). Twin of Swift `DeviceRegistry.forget`. */ - suspend fun forgetPairedDevice(id: String) = noopApp.deviceRegistry.forget(id) + suspend fun forgetPairedDevice(id: String) { + if (com.noop.testcentre.ImuChunkStore(getApplication(), noopApp.repository).deleteDevice(id)) + noopApp.deviceRegistry.forget(id) + } /** * A DISCOVERY-ONLY [StandardHrSource] for the Add-a-strap wizard. It runs its OWN scan and never diff --git a/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt b/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt index b7a478f612..78dcb6d110 100644 --- a/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt +++ b/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt @@ -252,15 +252,27 @@ fun GroundTruthCollectorScreen(vm: AppViewModel) { } markerEditor?.let { editor -> + val markerSession = sessions.firstOrNull { it.id == editor.sessionId } + val markerNowMs = if (markerSession?.active == true) nowMs else markerSession?.endedAtMs ?: nowMs AlertDialog( onDismissRequest = { markerEditor = null }, title = { Text(stringResource(if (editor.markerId == null) R.string.ground_truth_add_marker else R.string.ground_truth_edit_marker)) }, text = { Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { - Text(diagnosticTime(editor.atMs), style = NoopType.headline) + Text( + stringResource( + R.string.ground_truth_marker_times, + diagnosticTime(editor.atMs), + diagnosticTime(markerNowMs), + ), + style = NoopType.headline, + ) Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { NoopButton("−10 s", kind = NoopButtonKind.Secondary, modifier = Modifier.weight(1f), onClick = { markerEditor = editor.copy(atMs = editor.atMs - 10_000) }) + NoopButton(stringResource(R.string.ground_truth_marker_now_button), + kind = NoopButtonKind.Secondary, modifier = Modifier.weight(1f), + onClick = { markerEditor = editor.copy(atMs = markerNowMs) }) NoopButton("+10 s", kind = NoopButtonKind.Secondary, modifier = Modifier.weight(1f), onClick = { markerEditor = editor.copy(atMs = editor.atMs + 10_000) }) } diff --git a/android/app/src/main/java/com/noop/ui/SettingsScreen.kt b/android/app/src/main/java/com/noop/ui/SettingsScreen.kt index 8c7057c4c7..27a3f06b2e 100644 --- a/android/app/src/main/java/com/noop/ui/SettingsScreen.kt +++ b/android/app/src/main/java/com/noop/ui/SettingsScreen.kt @@ -2547,7 +2547,7 @@ fun SettingsScreen( } Text( - "Raw capture and its protocol-trace exports now live in Test Centre → 5/MG protocol diagnostics.", + uiString(R.string.raw_diag_moved), style = NoopType.caption, color = Palette.textTertiary, ) diff --git a/android/app/src/main/java/com/noop/ui/TestCentreScreen.kt b/android/app/src/main/java/com/noop/ui/TestCentreScreen.kt index fefd6aa646..248403d592 100644 --- a/android/app/src/main/java/com/noop/ui/TestCentreScreen.kt +++ b/android/app/src/main/java/com/noop/ui/TestCentreScreen.kt @@ -211,12 +211,12 @@ fun TestCentreScreen(vm: AppViewModel, onOpenGroundTruthCollector: () -> Unit = if (is5MG) { SettingsSectionTC( icon = Icons.Filled.Science, - title = "5/MG protocol diagnostics", + title = stringResource(R.string.raw_diag_title), blurb = "Developer tools for protocol research. These are separate from the bounded Raw Data Collector above.", ) { Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { DeveloperToggleRow( - title = "Protocol probes", + title = stringResource(R.string.raw_diag_protocol_probes), detail = "Sends experimental protocol queries. It is not needed for normal WHOOP 5/MG sync, sleep, recovery, or steps.", checked = protocolProbes, onCheckedChange = { @@ -225,7 +225,7 @@ fun TestCentreScreen(vm: AppViewModel, onOpenGroundTruthCollector: () -> Unit = }, ) DeveloperToggleRow( - title = "Broadcast heart rate from the strap", + title = stringResource(R.string.raw_diag_broadcast_hr), detail = "Writes the reversible WHOOP 5/MG advertising flag for Garmin, Zwift, and gym equipment.", checked = broadcastHr, onCheckedChange = { @@ -235,7 +235,7 @@ fun TestCentreScreen(vm: AppViewModel, onOpenGroundTruthCollector: () -> Unit = }, ) DeveloperToggleRow( - title = "Ask Android to pair", + title = stringResource(R.string.raw_diag_pair), detail = "Experimental explicit Android bonding. Normal 5/MG support does not require this switch.", checked = explicitBond, onCheckedChange = { @@ -244,7 +244,7 @@ fun TestCentreScreen(vm: AppViewModel, onOpenGroundTruthCollector: () -> Unit = }, ) DeveloperToggleRow( - title = "Legacy R22 feature-flag experiment", + title = stringResource(R.string.raw_diag_r22), detail = "Accepted writes have not been shown to enable a separate live stream. Not required for normal sync or raw capture.", checked = deepData, onCheckedChange = { @@ -254,7 +254,7 @@ fun TestCentreScreen(vm: AppViewModel, onOpenGroundTruthCollector: () -> Unit = ) if (deepData) { NoopButton( - text = "Send legacy R22 enable sequence", + text = stringResource(R.string.raw_diag_r22_enable), kind = NoopButtonKind.Secondary, fullWidth = true, enabled = live.encryptedBond && live.worn, @@ -262,7 +262,7 @@ fun TestCentreScreen(vm: AppViewModel, onOpenGroundTruthCollector: () -> Unit = ) } NoopButton( - text = "Clear legacy R22 flags on strap", + text = stringResource(R.string.raw_diag_r22_clear), kind = NoopButtonKind.Secondary, fullWidth = true, enabled = live.encryptedBond && r22DisableReport != WhoopBleClient.WAITING_DEVICE_CONFIG_PROBE, @@ -272,7 +272,7 @@ fun TestCentreScreen(vm: AppViewModel, onOpenGroundTruthCollector: () -> Unit = Text(it, style = NoopType.caption, color = Palette.textSecondary) } DeveloperToggleRow( - title = "WHOOP MG ECG raw-data gate", + title = stringResource(R.string.raw_diag_ecg), detail = "MG-only protocol research. This is instrumentation, not a medical ECG feature.", checked = ecgRawData, onCheckedChange = { @@ -283,13 +283,13 @@ fun TestCentreScreen(vm: AppViewModel, onOpenGroundTruthCollector: () -> Unit = if (ecgRawData) { Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { NoopButton( - text = "Turn ECG gate on", + text = stringResource(R.string.raw_diag_ecg_on), kind = NoopButtonKind.Secondary, enabled = live.bonded && ecgVariant.isMG, onClick = { vm.ble.setEcgRawDataGate(true) }, ) NoopButton( - text = "Turn ECG gate off", + text = stringResource(R.string.raw_diag_ecg_off), kind = NoopButtonKind.Secondary, enabled = live.bonded && ecgVariant.isMG, onClick = { vm.ble.setEcgRawDataGate(false) }, @@ -300,7 +300,7 @@ fun TestCentreScreen(vm: AppViewModel, onOpenGroundTruthCollector: () -> Unit = } } DeveloperToggleRow( - title = "Passive history/protocol trace", + title = stringResource(R.string.raw_diag_passive), detail = "Records frames that already arrive during history sync. It does not start IMU or any other sensor and may create large files.", checked = passiveRawCapture, onCheckedChange = { @@ -309,7 +309,7 @@ fun TestCentreScreen(vm: AppViewModel, onOpenGroundTruthCollector: () -> Unit = }, ) NoopButton( - text = "Share passive raw trace", + text = stringResource(R.string.raw_diag_share), leadingIcon = Icons.Filled.Upload, kind = NoopButtonKind.Secondary, fullWidth = true, @@ -326,7 +326,7 @@ fun TestCentreScreen(vm: AppViewModel, onOpenGroundTruthCollector: () -> Unit = }, ) NoopButton( - text = "Export passive trace + strap log", + text = stringResource(R.string.raw_diag_export_log), leadingIcon = Icons.Filled.Upload, kind = NoopButtonKind.Secondary, fullWidth = true, diff --git a/android/app/src/main/res/values-de/strings.xml b/android/app/src/main/res/values-de/strings.xml index e57b0d44b2..73cc4cd579 100644 --- a/android/app/src/main/res/values-de/strings.xml +++ b/android/app/src/main/res/values-de/strings.xml @@ -2523,4 +2523,20 @@ · %1$d s Anlauf %1$s · %2$s%3$s · %1$s + Marker %1$s · Jetzt %2$s + 0 + 5/MG-Protokolldiagnose + Protokolltests + Herzfrequenz vom Band übertragen + Android um Kopplung bitten + Altes R22-Feature-Flag-Experiment + Alte R22-Aktivierungssequenz senden + R22-Flags auf dem Band löschen + WHOOP-MG-Schalter für ECG-Rohdaten + ECG-Schalter einschalten + ECG-Schalter ausschalten + Passive Verlaufs-/Protokollaufzeichnung + Passive Rohaufzeichnung teilen + Passive Aufzeichnung und Bandprotokoll exportieren + Rohdatenaufnahme und Protokollexporte befinden sich jetzt im Testcenter unter 5/MG-Protokolldiagnose. diff --git a/android/app/src/main/res/values-es/strings.xml b/android/app/src/main/res/values-es/strings.xml index 7519d50f1c..bf3c711099 100644 --- a/android/app/src/main/res/values-es/strings.xml +++ b/android/app/src/main/res/values-es/strings.xml @@ -2452,4 +2452,77 @@ Pedir a Android que vincule (experimental) NOOP siempre ha confiado en que escribir en tu 5.0/MG hiciera que Android lo vinculara. Nunca ocurre. Esta opción se lo pide a Android directamente, por lo que puede mostrar un diálogo de vinculación del sistema. La frecuencia cardíaca en directo sigue funcionando igualmente; desactívala si el diálogo molesta. Pedir a Android que vincule + Recopilador de datos sin procesar 5/MG + Graba una sesión de movimiento limitada a 100 Hz, recupera los cortes de Bluetooth del historial de la pulsera y exporta la cronología completa. + Graba, anota, recupera y exporta una captura de movimiento 5/MG limitada a 100 Hz. + Abrir recopilador de datos + Iniciar sesión de datos + Añadir intervalo pasado + Detener sesión + Exportar sesión + Creando exportación… + No se pudo exportar la sesión: %1$s + Sesiones grabadas + Aún no hay sesiones grabadas. + Comentario de la sesión + Eliminar sesión + ¿Eliminar esta sesión? + La sesión %1$s y sus datos capturados se eliminarán permanentemente. + Eliminar todas las sesiones + ¿Eliminar todas las sesiones? + Las %1$d sesiones grabadas y sus datos capturados se eliminarán permanentemente. + Eliminar + Cancelar + Editar inicio + Editar fin + Sesión antigua: se pueden exportar eventos, pero no se puede identificar su pulsera para exportar sensores. + Estado de captura + Grabando · %1$s + IMU %1$s · %2$s + Pulsera: conectada y vinculada + Pulsera: conectada; vinculando + Pulsera: desconectada + Pulsera: desconectada; buscando + Sincronizando historial (%1$d bloques) + Historial sincronizado: %1$s + No hay ninguna sincronización de historial terminada + Sensores de 1 Hz decodificados: %1$s de retraso + Sensores de 1 Hz decodificados: ninguno + IMU en tiempo real: %1$s; %2$d paquetes/%3$d bytes%4$s + ; último hace %1$s + Añadir marcador + Editar marcador + Momento + Inicio + Fin + Problema + Nota del marcador + Guardar + esperando IMU + Lista para exportar · completa + Lista con %1$d s ausentes · el historial puede recuperarlos + Esperando historial hasta %1$s + Lista sin datos IMU + %1$s · IMU %2$s + Última exportación: %1$s · se puede volver a exportar + %1$d/%2$d s · %3$.1f%% · faltan %4$d s%5$s + · %1$d s de inicio + %1$s · %2$s%3$s + · %1$s + Diagnóstico del protocolo 5/MG + Sondas de protocolo + Transmitir frecuencia cardíaca desde la pulsera + Pedir a Android que vincule + Experimento antiguo de indicadores R22 + Enviar secuencia de activación R22 antigua + Borrar indicadores R22 de la pulsera + Control de datos ECG sin procesar de WHOOP MG + Activar control ECG + Desactivar control ECG + Registro pasivo de historial/protocolo + Compartir registro pasivo + Exportar registro pasivo y registro de la pulsera + La captura y sus exportaciones de protocolo están ahora en Centro de pruebas → Diagnóstico del protocolo 5/MG. + Marcador %1$s · Ahora %2$s + 0 diff --git a/android/app/src/main/res/values-fr/strings.xml b/android/app/src/main/res/values-fr/strings.xml index 0dfe92a673..2055e2ba47 100644 --- a/android/app/src/main/res/values-fr/strings.xml +++ b/android/app/src/main/res/values-fr/strings.xml @@ -2451,4 +2451,77 @@ Demander à Android de s\'appairer (expérimental) NOOP a toujours compté sur l\'écriture vers votre 5.0/MG pour qu\'Android s\'appaire. Cela n\'arrive jamais. Cette option le demande directement à Android et peut afficher une boîte de dialogue d\'appairage du système. La fréquence cardiaque en direct continue de fonctionner dans les deux cas ; désactivez-la si la boîte de dialogue vous gêne. Demander à Android de s\'appairer + Collecteur de données brutes 5/MG + Enregistrez une session de mouvement limitée à 100 Hz, récupérez les coupures Bluetooth depuis l’historique du bracelet et exportez la chronologie complète. + Enregistrez, annotez, récupérez et exportez une capture de mouvement 5/MG limitée à 100 Hz. + Ouvrir le collecteur de données + Démarrer une session de données + Ajouter une plage passée + Arrêter la session + Exporter la session + Création de l’export… + Impossible d’exporter la session : %1$s + Sessions enregistrées + Aucune session enregistrée. + Commentaire de session + Supprimer la session + Supprimer cette session ? + La session %1$s et ses données capturées seront définitivement supprimées. + Supprimer toutes les sessions + Supprimer toutes les sessions ? + Les %1$d sessions enregistrées et leurs données capturées seront définitivement supprimées. + Supprimer + Annuler + Modifier le début + Modifier la fin + Ancienne session : les événements sont exportables, mais le bracelet ne peut pas être identifié pour l’export des capteurs. + État de la capture + Enregistrement · %1$s + IMU %1$s · %2$s + Bracelet : connecté et appairé + Bracelet : connecté ; appairage + Bracelet : déconnecté + Bracelet : déconnecté ; recherche + Synchronisation de l’historique (%1$d blocs) + Historique synchronisé : %1$s + Aucune synchronisation d’historique terminée + Capteurs 1 Hz décodés : retard de %1$s + Capteurs 1 Hz décodés : aucun + IMU en temps réel : %1$s ; %2$d paquets/%3$d octets%4$s +  ; dernier il y a %1$s + Ajouter un repère + Modifier le repère + Moment + Début + Fin + Problème + Note du repère + Enregistrer + en attente de l’IMU + Prêt à exporter · complet + Prêt avec %1$d s manquantes · l’historique peut les récupérer + En attente de l’historique jusqu’à %1$s + Prêt sans données IMU + %1$s · IMU %2$s + Dernier export : %1$s · nouvel export possible + %1$d/%2$d s · %3$.1f%% · %4$d s manquantes%5$s + · %1$d s de démarrage + %1$s · %2$s%3$s + · %1$s + Diagnostics du protocole 5/MG + Sondes de protocole + Diffuser la fréquence cardiaque du bracelet + Demander l’appairage à Android + Ancienne expérience d’indicateurs R22 + Envoyer l’ancienne séquence d’activation R22 + Effacer les indicateurs R22 du bracelet + Contrôle des données ECG brutes WHOOP MG + Activer le contrôle ECG + Désactiver le contrôle ECG + Trace passive d’historique/protocole + Partager la trace passive + Exporter la trace passive et le journal du bracelet + La capture brute et ses exports de protocole se trouvent maintenant dans Centre de test → Diagnostics du protocole 5/MG. + Repère %1$s · Maintenant %2$s + 0 diff --git a/android/app/src/main/res/values-pl/strings.xml b/android/app/src/main/res/values-pl/strings.xml index 693c632e02..ff9dfa71ef 100644 --- a/android/app/src/main/res/values-pl/strings.xml +++ b/android/app/src/main/res/values-pl/strings.xml @@ -2457,4 +2457,77 @@ Poproś Androida o sparowanie (eksperymentalne) NOOP zawsze liczył na to, że zapis do 5.0/MG skłoni Androida do sparowania. To się nigdy nie dzieje. Ta opcja prosi Androida bezpośrednio, więc może pojawić się systemowe okno parowania. Tętno na żywo działa tak czy inaczej; wyłącz, jeśli okno przeszkadza. Poproś Androida o sparowanie + Rejestrator surowych danych 5/MG + Rejestruj ograniczoną sesję ruchu 100 Hz, uzupełniaj przerwy Bluetooth z historii opaski i eksportuj pełną oś czasu. + Rejestruj, opisuj, uzupełniaj i eksportuj ograniczone nagrania ruchu 5/MG 100 Hz. + Otwórz rejestrator danych + Rozpocznij sesję danych + Dodaj wcześniejszy zakres czasu + Zatrzymaj sesję + Eksportuj sesję + Tworzenie eksportu… + Nie udało się wyeksportować sesji: %1$s + Zarejestrowane sesje + Brak zarejestrowanych sesji. + Komentarz do sesji + Usuń sesję + Usunąć tę sesję? + Sesja %1$s i zarejestrowane dane zostaną trwale usunięte. + Usuń wszystkie sesje + Usunąć wszystkie sesje? + Wszystkie zarejestrowane sesje (%1$d) i ich dane zostaną trwale usunięte. + Usuń + Anuluj + Edytuj początek + Edytuj koniec + Starsza sesja: można wyeksportować zdarzenia, ale nie można wskazać opaski do eksportu czujników. + Stan rejestracji + Nagrywanie · %1$s + IMU %1$s · %2$s + Opaska: połączona i sparowana + Opaska: połączona; parowanie + Opaska: rozłączona + Opaska: rozłączona; wyszukiwanie + Synchronizacja historii (%1$d bloków) + Historia zsynchronizowana: %1$s + Brak ukończonej synchronizacji historii + Zdekodowane czujniki 1 Hz: opóźnienie %1$s + Zdekodowane czujniki 1 Hz: brak + IMU w czasie rzeczywistym: %1$s; %2$d pakietów/%3$d bajtów%4$s + ; ostatni %1$s temu + Dodaj znacznik + Edytuj znacznik + Moment + Początek + Koniec + Problem + Notatka znacznika + Zapisz + oczekiwanie na IMU + Gotowe do eksportu · kompletne + Gotowe, brak %1$d s · historia może je uzupełnić + Oczekiwanie na historię do %1$s + Gotowe bez danych IMU + %1$s · IMU %2$s + Ostatni eksport: %1$s · można eksportować ponownie + %1$d/%2$d s · %3$.1f%% · brak %4$d s%5$s + · rozruch %1$d s + %1$s · %2$s%3$s + · %1$s + Diagnostyka protokołu 5/MG + Testy protokołu + Transmituj tętno z opaski + Poproś Androida o sparowanie + Starszy eksperyment flag R22 + Wyślij starszą sekwencję włączenia R22 + Wyczyść flagi R22 na opasce + Przełącznik surowych danych ECG WHOOP MG + Włącz ECG + Wyłącz ECG + Pasywny zapis historii/protokołu + Udostępnij pasywny zapis + Eksportuj pasywny zapis i dziennik opaski + Rejestracja surowych danych i eksport protokołu są teraz w Centrum testowym → Diagnostyka protokołu 5/MG. + Znacznik %1$s · Teraz %2$s + 0 diff --git a/android/app/src/main/res/values-pt-rPT/strings.xml b/android/app/src/main/res/values-pt-rPT/strings.xml index 5c063f9d7b..feae9169cd 100644 --- a/android/app/src/main/res/values-pt-rPT/strings.xml +++ b/android/app/src/main/res/values-pt-rPT/strings.xml @@ -2445,4 +2445,77 @@ Pedir ao Android para emparelhar (experimental) O NOOP sempre contou que escrever na sua 5.0/MG fizesse o Android emparelhar. Isso nunca acontece. Esta opção pede diretamente ao Android, pelo que pode mostrar uma caixa de diálogo de emparelhamento do sistema. A frequência cardíaca em direto continua a funcionar de qualquer forma; desative se a caixa de diálogo incomodar. Pedir ao Android para emparelhar + Recolha de dados em bruto 5/MG + Grave uma sessão de movimento limitada a 100 Hz, recupere falhas de Bluetooth do histórico da bracelete e exporte a cronologia completa. + Grave, anote, recupere e exporte uma captura de movimento 5/MG limitada a 100 Hz. + Abrir recolha de dados + Iniciar sessão de dados + Adicionar intervalo anterior + Parar sessão + Exportar sessão + A criar exportação… + Não foi possível exportar a sessão: %1$s + Sessões gravadas + Ainda não existem sessões gravadas. + Comentário da sessão + Eliminar sessão + Eliminar esta sessão? + A sessão %1$s e os dados capturados serão eliminados permanentemente. + Eliminar todas as sessões + Eliminar todas as sessões? + As %1$d sessões gravadas e os respetivos dados serão eliminados permanentemente. + Eliminar + Cancelar + Editar início + Editar fim + Sessão antiga: os eventos podem ser exportados, mas não é possível identificar a bracelete para exportar sensores. + Estado da captura + A gravar · %1$s + IMU %1$s · %2$s + Bracelete: ligada e emparelhada + Bracelete: ligada; a emparelhar + Bracelete: desligada + Bracelete: desligada; a procurar + Sincronização do histórico (%1$d blocos) + Histórico sincronizado: %1$s + Nenhuma sincronização do histórico concluída + Sensores de 1 Hz descodificados: atraso de %1$s + Sensores de 1 Hz descodificados: nenhum + IMU em tempo real: %1$s; %2$d pacotes/%3$d bytes%4$s + ; último há %1$s + Adicionar marcador + Editar marcador + Momento + Início + Fim + Problema + Nota do marcador + Guardar + a aguardar IMU + Pronta para exportar · completa + Pronta com %1$d s em falta · o histórico pode recuperá-los + A aguardar histórico até %1$s + Pronta sem dados IMU + %1$s · IMU %2$s + Última exportação: %1$s · pode voltar a exportar + %1$d/%2$d s · %3$.1f%% · faltam %4$d s%5$s + · %1$d s de arranque + %1$s · %2$s%3$s + · %1$s + Diagnóstico do protocolo 5/MG + Sondas de protocolo + Transmitir frequência cardíaca da bracelete + Pedir ao Android para emparelhar + Experiência antiga de indicadores R22 + Enviar sequência antiga de ativação R22 + Limpar indicadores R22 da bracelete + Controlo de dados ECG em bruto WHOOP MG + Ativar controlo ECG + Desativar controlo ECG + Registo passivo de histórico/protocolo + Partilhar registo passivo + Exportar registo passivo e registo da bracelete + A captura em bruto e as exportações de protocolo estão agora em Centro de testes → Diagnóstico do protocolo 5/MG. + Marcador %1$s · Agora %2$s + 0 diff --git a/android/app/src/main/res/values-zh/strings.xml b/android/app/src/main/res/values-zh/strings.xml index 7f878733b9..1ad7c6ee56 100644 --- a/android/app/src/main/res/values-zh/strings.xml +++ b/android/app/src/main/res/values-zh/strings.xml @@ -2366,4 +2366,77 @@ 请求 Android 配对(实验性) NOOP 一直寄望于向 5.0/MG 写入数据来促使 Android 配对,但这从未发生。此选项会直接请求 Android,因此可能弹出系统配对对话框。无论哪种方式,实时心率都会继续工作;如果对话框造成困扰,请关闭此选项。 请求 Android 配对 + 5/MG 原始数据采集器 + 记录有限的 100 Hz 运动会话,从手环历史恢复蓝牙缺口,并导出完整时间线。 + 记录、标注、恢复并导出有限的 5/MG 100 Hz 运动捕获。 + 打开原始数据采集器 + 开始原始数据会话 + 添加过去的时间段 + 停止会话 + 导出会话 + 正在创建导出… + 无法导出会话:%1$s + 已记录的会话 + 尚无已记录的会话。 + 会话备注 + 删除会话 + 删除此会话? + 会话 %1$s 及其捕获的原始数据将被永久删除。 + 删除所有会话 + 删除所有会话? + %1$d 个已记录会话及其捕获的数据将被永久删除。 + 删除 + 取消 + 编辑开始时间 + 编辑结束时间 + 旧会话:可导出事件,但无法确定用于传感器导出的手环。 + 捕获状态 + 正在记录 · %1$s + IMU %1$s · %2$s + 手环:已连接并配对 + 手环:已连接;正在配对 + 手环:已断开 + 手环:已断开;正在搜索 + 正在同步历史(%1$d 个块) + 历史同步完成:%1$s + 尚无已完成的历史同步 + 已解码的 1 Hz 传感器:落后 %1$s + 已解码的 1 Hz 传感器:无 + 实时 IMU:%1$s;%2$d 个数据包/%3$d 字节%4$s + ;上次于 %1$s 前 + 添加标记 + 编辑标记 + 时刻 + 开始 + 结束 + 问题 + 标记备注 + 保存 + 正在等待 IMU + 可导出 · 完整 + 可导出,缺少 %1$d 秒 · 历史可能会修复 + 正在等待截至 %1$s 的历史 + 可导出,但没有 IMU 数据 + %1$s · IMU %2$s + 上次导出:%1$s · 仍可再次导出 + %1$d/%2$d 秒 · %3$.1f%% · 缺少 %4$d 秒%5$s + · 启动 %1$d 秒 + %1$s · %2$s%3$s + · %1$s + 5/MG 协议诊断 + 协议探测 + 从手环广播心率 + 请求 Android 配对 + 旧版 R22 功能标志实验 + 发送旧版 R22 启用序列 + 清除手环上的 R22 标志 + WHOOP MG ECG 原始数据开关 + 打开 ECG 开关 + 关闭 ECG 开关 + 被动历史/协议记录 + 分享被动原始记录 + 导出被动记录和手环日志 + 原始数据捕获及协议导出现已移至“测试中心 → 5/MG 协议诊断”。 + 标记 %1$s · 当前 %2$s + 0 diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 957ce9f551..82776c8321 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -2535,4 +2535,20 @@ · %1$d s startup %1$s · %2$s%3$s · %1$s + Marker %1$s · Now %2$s + 0 + 5/MG protocol diagnostics + Protocol probes + Broadcast heart rate from the strap + Ask Android to pair + Legacy R22 feature-flag experiment + Send legacy R22 enable sequence + Clear legacy R22 flags on strap + WHOOP MG ECG raw-data gate + Turn ECG gate on + Turn ECG gate off + Passive history/protocol trace + Share passive raw trace + Export passive trace + strap log + Raw capture and its protocol-trace exports now live in Test Centre → 5/MG protocol diagnostics. diff --git a/android/app/src/test/java/com/noop/analytics/RegistryDayOwnerSourceTest.kt b/android/app/src/test/java/com/noop/analytics/RegistryDayOwnerSourceTest.kt index 78adb90f68..1003f4f928 100644 --- a/android/app/src/test/java/com/noop/analytics/RegistryDayOwnerSourceTest.kt +++ b/android/app/src/test/java/com/noop/analytics/RegistryDayOwnerSourceTest.kt @@ -67,6 +67,7 @@ class RegistryDayOwnerSourceTest { override suspend fun deletePpgHrFor(deviceId: String) {} override suspend fun deletePpgWaveformFor(deviceId: String) {} override suspend fun deleteV18AuxFor(deviceId: String) {} + override suspend fun deleteImuChunkCatalogFor(deviceId: String) {} override suspend fun deleteEventsFor(deviceId: String) {} override suspend fun deleteBatteryFor(deviceId: String) {} override suspend fun deleteDailyMetricsFor(deviceId: String) {} @@ -97,6 +98,7 @@ class RegistryDayOwnerSourceTest { override suspend fun reKeyPpgHr(from: String, to: String) {} override suspend fun reKeyPpgWaveform(from: String, to: String) {} override suspend fun reKeyV18Aux(from: String, to: String) {} + override suspend fun reKeyImuChunks(from: String, to: String) {} override suspend fun reKeyEvents(from: String, to: String) {} override suspend fun reKeyBattery(from: String, to: String) {} override suspend fun reKeyDailyMetrics(from: String, to: String) {} diff --git a/android/app/src/test/java/com/noop/ble/SourceCoordinatorAdoptionTest.kt b/android/app/src/test/java/com/noop/ble/SourceCoordinatorAdoptionTest.kt index 573cd4006b..81a8ece0cb 100644 --- a/android/app/src/test/java/com/noop/ble/SourceCoordinatorAdoptionTest.kt +++ b/android/app/src/test/java/com/noop/ble/SourceCoordinatorAdoptionTest.kt @@ -82,6 +82,7 @@ class SourceCoordinatorAdoptionTest { override suspend fun deletePpgHrFor(deviceId: String) {} override suspend fun deletePpgWaveformFor(deviceId: String) {} override suspend fun deleteV18AuxFor(deviceId: String) {} + override suspend fun deleteImuChunkCatalogFor(deviceId: String) {} override suspend fun deleteEventsFor(deviceId: String) {} override suspend fun deleteBatteryFor(deviceId: String) {} override suspend fun deleteDailyMetricsFor(deviceId: String) {} @@ -114,6 +115,7 @@ class SourceCoordinatorAdoptionTest { override suspend fun reKeyPpgHr(from: String, to: String) {} override suspend fun reKeyPpgWaveform(from: String, to: String) {} override suspend fun reKeyV18Aux(from: String, to: String) {} + override suspend fun reKeyImuChunks(from: String, to: String) {} override suspend fun reKeyEvents(from: String, to: String) {} override suspend fun reKeyBattery(from: String, to: String) {} override suspend fun reKeyDailyMetrics(from: String, to: String) {} diff --git a/android/app/src/test/java/com/noop/data/DeviceRegistryTest.kt b/android/app/src/test/java/com/noop/data/DeviceRegistryTest.kt index 6c747b8520..ba0ee9d6dd 100644 --- a/android/app/src/test/java/com/noop/data/DeviceRegistryTest.kt +++ b/android/app/src/test/java/com/noop/data/DeviceRegistryTest.kt @@ -98,6 +98,7 @@ class DeviceRegistryTest { override suspend fun deletePpgHrFor(deviceId: String) { deletedTables += "ppgHrSample" to deviceId } override suspend fun deletePpgWaveformFor(deviceId: String) { deletedTables += "ppgWaveformSample" to deviceId } override suspend fun deleteV18AuxFor(deviceId: String) { deletedTables += "v18AuxSample" to deviceId } + override suspend fun deleteImuChunkCatalogFor(deviceId: String) { deletedTables += "imuChunk" to deviceId } override suspend fun deleteEventsFor(deviceId: String) { deletedTables += "event" to deviceId } override suspend fun deleteBatteryFor(deviceId: String) { deletedTables += "battery" to deviceId } override suspend fun deleteDailyMetricsFor(deviceId: String) { deletedTables += "dailyMetric" to deviceId } @@ -134,6 +135,7 @@ class DeviceRegistryTest { override suspend fun reKeyPpgHr(from: String, to: String) {} override suspend fun reKeyPpgWaveform(from: String, to: String) {} override suspend fun reKeyV18Aux(from: String, to: String) {} + override suspend fun reKeyImuChunks(from: String, to: String) {} override suspend fun reKeyEvents(from: String, to: String) {} override suspend fun reKeyBattery(from: String, to: String) {} override suspend fun reKeyDailyMetrics(from: String, to: String) {} @@ -310,7 +312,7 @@ class DeviceRegistryTest { // were missing, leaving raw sleep-state, lab markers, live sessions and dismissed markers behind. val expectedTables = setOf( "hrSample", "rrInterval", "spo2Sample", "skinTempSample", "respSample", "gravitySample", - "stepSample", "ppgHrSample", "ppgWaveformSample", "v18AuxSample", + "stepSample", "ppgHrSample", "ppgWaveformSample", "imuChunk", "v18AuxSample", "event", "battery", "dailyMetric", "sleepSession", "journal", "workout", "appleDaily", "metricSeries", "dayOwnership", "scoreInputProvenance", diff --git a/android/app/src/test/java/com/noop/data/RawImuMigrationTest.kt b/android/app/src/test/java/com/noop/data/RawImuMigrationTest.kt index 960bba5ae0..1fd4603e4f 100644 --- a/android/app/src/test/java/com/noop/data/RawImuMigrationTest.kt +++ b/android/app/src/test/java/com/noop/data/RawImuMigrationTest.kt @@ -52,7 +52,7 @@ class RawImuMigrationTest { assertEquals(21, WhoopDatabase.MIGRATION_20_21.endVersion) } - @Test fun obsoleteTable_isDroppedAt35() { + @Test fun obsoleteEmptyTable_isDroppedAt35() { assertEquals(34, WhoopDatabase.MIGRATION_34_35.startVersion) assertEquals(35, WhoopDatabase.MIGRATION_34_35.endVersion) } diff --git a/android/app/src/test/java/com/noop/testcentre/RawCaptureExportContractTest.kt b/android/app/src/test/java/com/noop/testcentre/RawCaptureExportContractTest.kt new file mode 100644 index 0000000000..fe87a5b6b4 --- /dev/null +++ b/android/app/src/test/java/com/noop/testcentre/RawCaptureExportContractTest.kt @@ -0,0 +1,112 @@ +package com.noop.testcentre + +import com.noop.data.ImuChunkEntity +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File +import kotlinx.coroutines.runBlocking + +/** Small source-seam guards for the raw-capture ownership and bounded-export contract. */ +class RawCaptureExportContractTest { + private fun chunk(id: String, from: Long, to: Long) = ImuChunkEntity( + id, "strap", from, to, ((to - from) + 1).toInt() * 100, 100, 1, "zip-deflate", + "imu-chunks/$id.imuc", 1, "sha", 1, Long.MAX_VALUE, + ) + + private fun source(name: String): String { + var root = File(System.getProperty("user.dir") ?: ".").canonicalFile + repeat(4) { + val file = File(root, "android/app/src/main/java/com/noop/testcentre/$name") + if (file.isFile) return file.readText() + root = root.parentFile ?: root + } + error("repository root not found") + } + + @Test fun editedWindowOwnsPublicEventsAndImuBounds() { + val collector = source("GroundTruthCollector.kt") + assertTrue(collector.contains("publicEvents(events, summary.startedAtMs, endMs, deviceId)")) + assertTrue(collector.contains("summary.markers.filter { it.atMs in summary.startedAtMs..endMs }")) + assertTrue(collector.contains("val sensorFrom = ceilSecond(summary.startedAtMs)")) + assertTrue(collector.contains("val sensorTo = endMs / 1_000L - 1L")) + assertTrue(collector.contains("val coverageFrom = sensorFrom")) + assertFalse(collector.contains("maxOf(fullFrom, pinnedChunks.minOfOrNull")) + assertTrue(collector.contains("val sensorAvailable = pinnedChunks.isNotEmpty() ||")) + assertFalse(collector.contains("put(\"sensor_export_available\", deviceId != null)")) + } + + @Test fun sessionPayloadDeletionKeepsDiscoveryFileUntilLast() { + val collector = source("GroundTruthCollector.kt") + assertTrue(collector.contains("val payloadFiles = listOf(")) + assertTrue(collector.contains("if (!payloadFiles.all")) + assertTrue(collector.contains("if (eventFile(sessionId).exists() && !eventFile(sessionId).delete()) return false")) + } + + @Test fun rawCollectorHasNoStepAlgorithmExport() { + val collector = source("GroundTruthCollector.kt") + assertFalse(collector.contains("algorithm-signals.csv")) + assertFalse(collector.contains("writeAlgorithmSignalsCsv")) + assertFalse(collector.contains("stepSamples(")) + } + + @Test fun chunksAreSessionOwnedAndLiveSourceSurvivesExport() { + val chunks = source("ImuChunkStore.kt") + val collector = source("GroundTruthCollector.kt") + assertTrue(chunks.contains("chunkPrefix(sessionId)")) + assertTrue(chunks.contains("put(\"axes\", JSONArray(")) + assertTrue(chunks.contains("suspend fun deleteOwned(")) + assertTrue(collector.contains(".deleteOwned(")) + assertFalse(collector.contains("if (imuComplete) {\n ImuSessionFileStore(context).remove(id)")) + } + + @Test fun emptySelectedSourceNeverReusesAnOverlappingBroadArchive() { + val broad = chunk("first--broad", 100, 199) + assertEquals(emptyList(), ImuChunkStore.boundedExisting(listOf(broad), 120, 140)) + } + + @Test fun ownershipPrefixDoesNotClaimAnOverlappingSession() { + assertTrue(ImuChunkStore.isOwnedChunk("first", "first--archive")) + assertFalse(ImuChunkStore.isOwnedChunk("first", "first-extra--archive")) + } + + @Test fun unrelatedArchivesAreNotClaimedAsSessionOwned() { + assertFalse(ImuChunkStore.isOwnedChunk("first", "550e8400-e29b-41d4-a716-446655440000")) + } + + @Test fun reuseIsStrictlyContainedBySelectedRange() { + val inside = chunk("session--inside", 120, 140) + val broad = chunk("session--broad", 100, 199) + assertEquals(listOf(inside), ImuChunkStore.boundedExisting(listOf(inside, broad), 120, 140)) + } + + @Test fun archiveDeletionFailureLeavesCatalogUntouchedForRetry() = runBlocking { + val operations = mutableListOf() + val deleted = ImuChunkStore.deleteRetrySafe( + fileExists = true, + deleteFile = { operations += "file"; false }, + deleteCatalog = { operations += "catalog" }, + ) + assertFalse(deleted) + assertEquals(listOf("file"), operations) + } + + @Test fun catalogDeletionRunsOnlyAfterArchiveDeletion() = runBlocking { + val operations = mutableListOf() + val deleted = ImuChunkStore.deleteRetrySafe( + fileExists = true, + deleteFile = { operations += "file"; true }, + deleteCatalog = { operations += "catalog"; error("database unavailable") }, + ) + assertFalse(deleted) + assertEquals(listOf("file", "catalog"), operations) + } + + @Test fun deviceDeletionOwnsExternalFilesBeforeDatabaseWipe() { + val chunks = source("ImuChunkStore.kt") + assertTrue(chunks.contains("suspend fun deleteDevice(deviceId: String)")) + assertTrue(chunks.indexOf("if (file.exists() && !file.delete()) return false") < + chunks.indexOf("repository.deleteImuChunksFor(deviceId)")) + } +} diff --git a/android/app/src/test/resources/raw_data_collector_parity.json b/android/app/src/test/resources/raw_data_collector_parity.json index ed0cbe362b..f01ff79b83 100644 --- a/android/app/src/test/resources/raw_data_collector_parity.json +++ b/android/app/src/test/resources/raw_data_collector_parity.json @@ -13,6 +13,10 @@ "swift": ["setComment(", "Session comment"], "kotlin": ["setComment(", "ground_truth_comment"] }, + "session_markers": { + "swift": ["addMarker(sessionId:", "updateMarker(sessionId:", "deleteMarker(sessionId:", "Add marker"], + "kotlin": ["fun addMarker(", "fun updateMarker(", "fun deleteMarker(", "ground_truth_add_marker"] + }, "from_to_time": { "swift": ["func range(", "endedAtMs"], "kotlin": ["sessionTimeRange(", "endedAtMs"] @@ -59,7 +63,7 @@ }, "raw_data_removed_with_session": { "swift": ["deleteRawBatches(deviceId:", "capturedAt >= ? AND capturedAt <= ?"], - "kotlin": ["realtime-imu-$sessionId.imus", "noop-ground-truth-$sessionId.zip"] + "kotlin": ["realtime-imu-$sessionId.imus", "noop-5mg-raw-$sessionId.zip"] } } } diff --git a/android/gradle.properties b/android/gradle.properties index 39e2831025..4bfd0fe461 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,8 +1,7 @@ # Project-wide Gradle settings. -# JVM args for the Gradle daemon. The build host has enough memory to keep resource processing and -# parallel verification from squeezing the daemon during staging builds. -org.gradle.jvmargs=-Xmx4096m -Dfile.encoding=UTF-8 +# JVM args for the Gradle daemon. 2g heap + file encoding is enough for this single-module app. +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 # Heap for the KOTLIN COMPILE DAEMON, which is a separate process from the Gradle daemon above and # does NOT inherit org.gradle.jvmargs. Unset, it takes a default that the Compose compiler plugin @@ -12,11 +11,10 @@ org.gradle.jvmargs=-Xmx4096m -Dfile.encoding=UTF-8 # Gradle's own failure hint names this exact property. Raised here rather than lifting # org.gradle.jvmargs so peak memory stays reasonable on a contributor's laptop — Gradle itself was # never the process that ran out. -kotlin.daemon.jvmargs=-Xmx6144m +kotlin.daemon.jvmargs=-Xmx4096m # Run independent tasks in parallel where the module graph allows. org.gradle.parallel=true -org.gradle.workers.max=6 # Reuse outputs from previous builds. org.gradle.caching=true diff --git a/docs/RAW_DATA_CAPTURE.md b/docs/RAW_DATA_CAPTURE.md index de9d2314d8..1bd21a760e 100644 --- a/docs/RAW_DATA_CAPTURE.md +++ b/docs/RAW_DATA_CAPTURE.md @@ -1,7 +1,7 @@ # 5/MG raw data capture **Status:** experimental, user-initiated, hardware-verified on WHOOP 5/MG. The capture path is a -research/export facility; it does not feed recovery, sleep, steps, or another production score. +research/export facility; it does not feed production health or activity scores. ## What it is for @@ -13,8 +13,8 @@ repaired by a later history sync. It lives in **Test Centre → 5/MG Raw Data Collector** on Android and Apple platforms. A session may be started and stopped live, or created afterwards for an existing time range. Start/end times of a completed session can be edited, individual sessions can be deleted, and all completed sessions can -be deleted after confirmation. The generic collector does not assume a particular labelling task; -manual step/stair controls are optional fork extensions rather than a prerequisite for capture. +be deleted after confirmation. Timestamped markers can identify a moment, start, end, or issue; each +marker may also carry a short note and remains editable after the session stops. ## What was verified on hardware @@ -72,10 +72,10 @@ Instead, storage is split by responsibility: | Data | Storage | Lifetime | |---|---|---| -| Session window, comments, optional labels | Small app-private JSON/JSONL metadata | Until the user deletes the session | -| Incoming raw IMU frames | Append-only `.imus` session file | Until materialised/exported or the session is deleted | +| Session window, comments, and markers | Small app-private JSON/JSONL metadata | Until the user deletes the session | +| Incoming raw IMU frames | Append-only `.imus` session file | Until the user explicitly deletes the session | | Searchable chunk metadata | SQLite (`imuChunk`) | Index/provenance only; no individual samples | -| Immutable analysis/export payload | Compressed `.imuc` file | Pinned to the retained session/export | +| Immutable analysis/export payload | Compressed `.imuc` file | Owned by the retained session | The `.imus` writer batches up to 30 one-second frames into an independently zlib-compressed block. Blocks are appended; a crash can at worst leave a truncated final block, while earlier blocks remain @@ -92,11 +92,14 @@ An `.imuc` file is a ZIP/deflate container with: The format and archive semantics are mirrored on Android and Apple. SHA-256, byte size, codec, coverage, and sample count are stored in `imuChunk`; the payload itself remains outside SQLite. +Chunks are session-owned and materialised for the selected interval. Keeping the `.imus` source until +explicit deletion makes repeated exports safe when that interval is edited; deleting a session also +removes its source and owned chunks. ## Session export The shareable ZIP contains session provenance and all available sensor material for its selected -interval. Android currently includes `meta.json`, label/event JSONL and CSV files, decoded one-second +interval. Android currently includes `meta.json`, event JSONL and CSV files, decoded one-second signals, raw sensor CSV, and `imu/*.imuc`. Apple exports equivalent session metadata/events, `history-sensors.csv`, `imu-coverage.json`, and IMU chunks through its platform export path. Inspect `meta.json` plus the platform's IMU coverage object first: @@ -106,10 +109,10 @@ signals, raw sensor CSV, and `imu/*.imuc`. Apple exports equivalent session meta interval even after the selected interval is edited; - Android's `imu_100hz_coverage` identifies the chunks actually present and `imu_100hz_complete` is the conservative coverage result; Apple carries the same facts in `imu-coverage.json`; -- manual labels, when present, are annotations and never proof of sensor completeness. Exports stay local until the user invokes the operating system's share sheet. Raw captures are not -part of routine cloud sync or telemetry, consistent with NOOP's offline-first privacy model. +part of routine cloud sync or telemetry, consistent with NOOP's offline-first privacy model. The +suggested archive name is `noop-5mg-raw-.zip`. ## Scope and operational limits @@ -120,8 +123,8 @@ part of routine cloud sync or telemetry, consistent with NOOP's offline-first pr - The older passive “Record 5/MG raw capture” protocol trace and the session collector have different jobs. The former preserves broad offload/debug frames; the latter owns a time-bounded, exportable IMU dataset. They may observe the same wire frame, but the session store deduplicates it by strap time. -- The former `rawImuSample` per-sample SQLite design was intentionally removed: no production path had - written it, and high-rate payloads now use file-backed chunks. +- The retired opt-in `rawImuSample` path existed in earlier builds but did not produce usable stored IMU + data in deployment. Session capture therefore has one source of truth: its file-backed chunks. - Do not use arrival order as time, do not fill gaps silently, and do not claim 100 Hz coverage from packet count alone. From d4badebd45b2baceb57cc98a51f945c182077cad Mon Sep 17 00:00:00 2001 From: Bernd Helm Date: Thu, 27 Aug 2026 23:05:25 +0200 Subject: [PATCH 29/45] fix(apple): preserve IMU chunk type inference --- Strand/Screens/RawDataCollectorView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Strand/Screens/RawDataCollectorView.swift b/Strand/Screens/RawDataCollectorView.swift index 59bc4c00a3..e17ba46630 100644 --- a/Strand/Screens/RawDataCollectorView.swift +++ b/Strand/Screens/RawDataCollectorView.swift @@ -291,7 +291,7 @@ struct RawDataCollectorView: View { exportingId = session.id let bounds = Self.fullSecondBounds(fromMs: session.startedAtMs, toMs: end) let from = bounds?.from ?? 1, to = bounds?.to ?? 0 - let chunks = if let bounds { + let chunks: [ImuChunkMeta] = if let bounds { await archive.pin(sessionId: session.id, deviceId: session.deviceId, from: bounds.from, to: bounds.to, ble: model.ble) } else { [] } From 34419db0f449ef92a993738b0374189e80180007 Mon Sep 17 00:00:00 2001 From: Bernd Helm Date: Thu, 27 Aug 2026 23:14:31 +0200 Subject: [PATCH 30/45] test(apple): track current deletion guard --- StrandTests/Resources/raw_data_collector_parity.json | 2 +- android/app/src/test/resources/raw_data_collector_parity.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/StrandTests/Resources/raw_data_collector_parity.json b/StrandTests/Resources/raw_data_collector_parity.json index f01ff79b83..e13709f60a 100644 --- a/StrandTests/Resources/raw_data_collector_parity.json +++ b/StrandTests/Resources/raw_data_collector_parity.json @@ -54,7 +54,7 @@ "kotlin": ["ground_truth_delete_session_title", "ground_truth_delete_all_title", "deleteAllSessions"] }, "active_session_delete_guard": { - "swift": ["active != true", ".disabled(store.active != nil)"], + "swift": ["guard session.endedAtMs != nil else { return }", ".disabled(store.active != nil)"], "kotlin": ["current.active && current.sessionId == sessionId", "sessions.none { it.active }"] }, "bluetooth_reconnect_rearm": { diff --git a/android/app/src/test/resources/raw_data_collector_parity.json b/android/app/src/test/resources/raw_data_collector_parity.json index f01ff79b83..e13709f60a 100644 --- a/android/app/src/test/resources/raw_data_collector_parity.json +++ b/android/app/src/test/resources/raw_data_collector_parity.json @@ -54,7 +54,7 @@ "kotlin": ["ground_truth_delete_session_title", "ground_truth_delete_all_title", "deleteAllSessions"] }, "active_session_delete_guard": { - "swift": ["active != true", ".disabled(store.active != nil)"], + "swift": ["guard session.endedAtMs != nil else { return }", ".disabled(store.active != nil)"], "kotlin": ["current.active && current.sessionId == sessionId", "sessions.none { it.active }"] }, "bluetooth_reconnect_rearm": { From df25f0e3e5ce0735d1160cc3a60acdcb17c7803c Mon Sep 17 00:00:00 2001 From: Bernd Helm Date: Fri, 28 Aug 2026 00:17:55 +0200 Subject: [PATCH 31/45] Store decoded IMU in canonical time segments --- .../Sources/WhoopStore/Database.swift | 22 +- .../WhoopStore/DeviceRegistryStore.swift | 2 +- .../Sources/WhoopStore/ImuChunkCatalog.swift | 82 ------- .../Resources/schema_oracle.json | 23 -- Strand/BLE/BLEManager.swift | 16 -- Strand/Collect/Collector.swift | 20 -- Strand/Collect/ImuChunkArchiveStore.swift | 179 --------------- Strand/Collect/ImuSessionFileStore.swift | 213 ++++++++++------- Strand/Collect/RawDataSessionStore.swift | 5 +- Strand/Data/DeviceRegistry.swift | 4 +- Strand/Screens/RawDataCollectorView.swift | 30 +-- StrandTests/RawDataCollectorParityTests.swift | 2 - StrandTests/RawDataSessionStoreTests.swift | 38 +--- .../Resources/raw_data_collector_parity.json | 18 +- .../main/java/com/noop/data/DeviceRegistry.kt | 2 - .../java/com/noop/data/DeviceRegistryDao.kt | 2 - .../src/main/java/com/noop/data/Entities.kt | 21 -- .../src/main/java/com/noop/data/WhoopDao.kt | 21 -- .../main/java/com/noop/data/WhoopDatabase.kt | 22 +- .../java/com/noop/data/WhoopRepository.kt | 10 - .../noop/testcentre/GroundTruthCollector.kt | 46 ++-- .../java/com/noop/testcentre/ImuChunkStore.kt | 184 --------------- .../noop/testcentre/ImuSessionFileStore.kt | 215 ++++++++++++------ .../src/main/java/com/noop/ui/AppViewModel.kt | 4 +- .../analytics/RegistryDayOwnerSourceTest.kt | 2 - .../noop/ble/SourceCoordinatorAdoptionTest.kt | 2 - .../com/noop/data/DailySdnnMigrationTest.kt | 2 +- .../java/com/noop/data/DeviceRegistryTest.kt | 4 +- .../java/com/noop/data/RawImuMigrationTest.kt | 4 +- .../RawCaptureExportContractTest.kt | 94 ++------ .../testcentre/RawDataCollectorParityTest.kt | 2 +- .../resources/raw_data_collector_parity.json | 18 +- .../app/src/test/resources/schema_oracle.json | 23 -- docs/DATA_MODEL.md | 28 +-- docs/RAW_DATA_CAPTURE.md | 42 ++-- 35 files changed, 373 insertions(+), 1029 deletions(-) delete mode 100644 Packages/WhoopStore/Sources/WhoopStore/ImuChunkCatalog.swift delete mode 100644 Strand/Collect/ImuChunkArchiveStore.swift delete mode 100644 android/app/src/main/java/com/noop/testcentre/ImuChunkStore.kt diff --git a/Packages/WhoopStore/Sources/WhoopStore/Database.swift b/Packages/WhoopStore/Sources/WhoopStore/Database.swift index 558f232c82..a2c8a7a67a 100644 --- a/Packages/WhoopStore/Sources/WhoopStore/Database.swift +++ b/Packages/WhoopStore/Sources/WhoopStore/Database.swift @@ -867,28 +867,8 @@ extension WhoopStore { t.add(column: "burstIndex", .integer) } } - // v40: index immutable 100 Hz archive chunks whose payload lives outside the operational DB. - migrator.registerMigration("v40-imu-chunk-catalog") { db in - try db.create(table: "imuChunk") { t in - t.column("id", .text).primaryKey() - t.column("deviceId", .text).notNull() - t.column("startTs", .integer).notNull() - t.column("endTs", .integer).notNull() - t.column("sampleCount", .integer).notNull() - t.column("sampleRate", .integer).notNull() - t.column("formatVersion", .integer).notNull() - t.column("codec", .text).notNull() - t.column("relativePath", .text).notNull() - t.column("byteSize", .integer).notNull() - t.column("sha256", .text).notNull() - t.column("createdAt", .integer).notNull() - t.column("pinnedUntil", .integer) - } - try db.create(index: "index_imuChunk_deviceId_startTs_endTs", on: "imuChunk", - columns: ["deviceId", "startTs", "endTs"]) - } // The retired opt-in cache is known empty in deployed NOOP installations; session capture uses files. - migrator.registerMigration("v41-drop-raw-imu-sample") { db in + migrator.registerMigration("v40-drop-raw-imu-sample") { db in try db.drop(table: "rawImuSample") } return migrator diff --git a/Packages/WhoopStore/Sources/WhoopStore/DeviceRegistryStore.swift b/Packages/WhoopStore/Sources/WhoopStore/DeviceRegistryStore.swift index ee14cc898f..0793863242 100644 --- a/Packages/WhoopStore/Sources/WhoopStore/DeviceRegistryStore.swift +++ b/Packages/WhoopStore/Sources/WhoopStore/DeviceRegistryStore.swift @@ -131,7 +131,7 @@ public struct DeviceRegistryStore: Sendable { // deviceId-keyed exactly like every other per-second stream above — must be cleared too, or a // "delete all of this device's data" leaves the raw waveform behind (the same privacy defect // this list exists to close). - "ppgWaveformSample", "imuChunk", + "ppgWaveformSample", // v28-raw-imu (#423): the opt-in 5/MG raw-IMU offload capture is deviceId-keyed too — "delete all // of this device's data" must clear it, or the raw inertial samples survive deletion (same defect). // v31-deep-capture-channels: the banked 5/MG v18 auxiliary fields are deviceId-keyed per-second diff --git a/Packages/WhoopStore/Sources/WhoopStore/ImuChunkCatalog.swift b/Packages/WhoopStore/Sources/WhoopStore/ImuChunkCatalog.swift deleted file mode 100644 index 9f79c98cd7..0000000000 --- a/Packages/WhoopStore/Sources/WhoopStore/ImuChunkCatalog.swift +++ /dev/null @@ -1,82 +0,0 @@ -import Foundation -import GRDB - -public struct ImuChunkMeta: Codable, Equatable, Sendable { - public let id: String - public let deviceId: String - public let startTs: Int - public let endTs: Int - public let sampleCount: Int - public let sampleRate: Int - public let formatVersion: Int - public let codec: String - public let relativePath: String - public let byteSize: Int - public let sha256: String - public let createdAt: Int - public let pinnedUntil: Int? - - public init(id: String, deviceId: String, startTs: Int, endTs: Int, sampleCount: Int, - sampleRate: Int, formatVersion: Int, codec: String, relativePath: String, - byteSize: Int, sha256: String, createdAt: Int, pinnedUntil: Int?) { - self.id = id; self.deviceId = deviceId; self.startTs = startTs; self.endTs = endTs - self.sampleCount = sampleCount; self.sampleRate = sampleRate; self.formatVersion = formatVersion - self.codec = codec; self.relativePath = relativePath; self.byteSize = byteSize - self.sha256 = sha256; self.createdAt = createdAt; self.pinnedUntil = pinnedUntil - } -} - -extension WhoopStore { - public func upsertImuChunk(_ chunk: ImuChunkMeta) async throws { - try syncWrite { db in - try db.execute(sql: """ - INSERT INTO imuChunk - (id, deviceId, startTs, endTs, sampleCount, sampleRate, formatVersion, codec, - relativePath, byteSize, sha256, createdAt, pinnedUntil) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(id) DO UPDATE SET - relativePath=excluded.relativePath, byteSize=excluded.byteSize, - sha256=excluded.sha256, pinnedUntil=excluded.pinnedUntil - """, arguments: [chunk.id, chunk.deviceId, chunk.startTs, chunk.endTs, - chunk.sampleCount, chunk.sampleRate, chunk.formatVersion, chunk.codec, - chunk.relativePath, chunk.byteSize, chunk.sha256, chunk.createdAt, - chunk.pinnedUntil]) - } - } - - public func imuChunks(deviceId: String, from: Int, to: Int) async throws -> [ImuChunkMeta] { - try syncRead { db in - try Row.fetchAll(db, sql: """ - SELECT * FROM imuChunk - WHERE deviceId = ? AND endTs >= ? AND startTs <= ? ORDER BY startTs - """, arguments: [deviceId, from, to]).map { row in - ImuChunkMeta(id: row["id"], deviceId: row["deviceId"], startTs: row["startTs"], - endTs: row["endTs"], sampleCount: row["sampleCount"], - sampleRate: row["sampleRate"], formatVersion: row["formatVersion"], - codec: row["codec"], relativePath: row["relativePath"], - byteSize: row["byteSize"], sha256: row["sha256"], - createdAt: row["createdAt"], pinnedUntil: row["pinnedUntil"]) - } - } - } - - public func imuChunks(idPrefix: String) async throws -> [ImuChunkMeta] { - try syncRead { db in - try Row.fetchAll(db, sql: "SELECT * FROM imuChunk WHERE id LIKE ? ORDER BY startTs", - arguments: [idPrefix + "%"]).map { row in - ImuChunkMeta(id: row["id"], deviceId: row["deviceId"], startTs: row["startTs"], - endTs: row["endTs"], sampleCount: row["sampleCount"], - sampleRate: row["sampleRate"], formatVersion: row["formatVersion"], - codec: row["codec"], relativePath: row["relativePath"], - byteSize: row["byteSize"], sha256: row["sha256"], - createdAt: row["createdAt"], pinnedUntil: row["pinnedUntil"]) - } - } - } - - public func deleteImuChunk(id: String) async throws { - try syncWrite { db in - try db.execute(sql: "DELETE FROM imuChunk WHERE id = ?", arguments: [id]) - } - } -} diff --git a/Packages/WhoopStore/Tests/WhoopStoreTests/Resources/schema_oracle.json b/Packages/WhoopStore/Tests/WhoopStoreTests/Resources/schema_oracle.json index 02b401e3e4..06a448d944 100644 --- a/Packages/WhoopStore/Tests/WhoopStoreTests/Resources/schema_oracle.json +++ b/Packages/WhoopStore/Tests/WhoopStoreTests/Resources/schema_oracle.json @@ -1253,29 +1253,6 @@ ], "indices": [] }, - "imuChunk": { - "platform": "both", - "columns": [ - { "name": "id", "affinity": "TEXT", "notNull": false, "default": null, - "android": { "notNull": true, "divergence": "sqlite-text-pk-nullable" } }, - { "name": "deviceId", "affinity": "TEXT", "notNull": true, "default": null }, - { "name": "startTs", "affinity": "INTEGER", "notNull": true, "default": null }, - { "name": "endTs", "affinity": "INTEGER", "notNull": true, "default": null }, - { "name": "sampleCount", "affinity": "INTEGER", "notNull": true, "default": null }, - { "name": "sampleRate", "affinity": "INTEGER", "notNull": true, "default": null }, - { "name": "formatVersion", "affinity": "INTEGER", "notNull": true, "default": null }, - { "name": "codec", "affinity": "TEXT", "notNull": true, "default": null }, - { "name": "relativePath", "affinity": "TEXT", "notNull": true, "default": null }, - { "name": "byteSize", "affinity": "INTEGER", "notNull": true, "default": null }, - { "name": "sha256", "affinity": "TEXT", "notNull": true, "default": null }, - { "name": "createdAt", "affinity": "INTEGER", "notNull": true, "default": null }, - { "name": "pinnedUntil", "affinity": "INTEGER", "notNull": false, "default": null } - ], - "primaryKey": ["id"], - "indices": [ - { "name": "index_imuChunk_deviceId_startTs_endTs", "columns": ["deviceId", "startTs", "endTs"], "unique": false } - ] - }, "respSample": { "platform": "both", "columns": [ diff --git a/Strand/BLE/BLEManager.swift b/Strand/BLE/BLEManager.swift index b83d6c4bfe..7dbd878d96 100644 --- a/Strand/BLE/BLEManager.swift +++ b/Strand/BLE/BLEManager.swift @@ -1845,22 +1845,6 @@ public final class BLEManager: NSObject, ObservableObject { await collector?.deleteRawBatches(from: from, to: to) ?? 0 } - public func groundTruthImuChunks(from: Int, to: Int) async -> [ImuChunkMeta] { - await collector?.imuChunks(from: from, to: to) ?? [] - } - - public func registerGroundTruthImuChunk(_ chunk: ImuChunkMeta) async { - await collector?.upsertImuChunk(chunk) - } - - public func groundTruthImuChunks(idPrefix: String) async -> [ImuChunkMeta]? { - await collector?.imuChunks(idPrefix: idPrefix) - } - - public func deleteGroundTruthImuChunk(id: String) async -> Bool { - await collector?.deleteImuChunk(id: id) ?? false - } - /// Send a command to the WHOOP strap. /// - Parameters: /// - command: The command to send. diff --git a/Strand/Collect/Collector.swift b/Strand/Collect/Collector.swift index 87cd6e05c5..ed6c59487c 100644 --- a/Strand/Collect/Collector.swift +++ b/Strand/Collect/Collector.swift @@ -132,26 +132,6 @@ final class Collector { return (try? await store.deleteRawBatches(deviceId: deviceId, from: from, to: to)) ?? 0 } - func imuChunks(from: Int, to: Int) async -> [ImuChunkMeta] { - guard let store = concreteStore else { return [] } - return (try? await store.imuChunks(deviceId: deviceId, from: from, to: to)) ?? [] - } - - func upsertImuChunk(_ chunk: ImuChunkMeta) async { - try? await concreteStore?.upsertImuChunk(chunk) - } - - func imuChunks(idPrefix: String) async -> [ImuChunkMeta]? { - guard let concreteStore else { return nil } - return try? await concreteStore.imuChunks(idPrefix: idPrefix) - } - - func deleteImuChunk(id: String) async -> Bool { - guard let concreteStore else { return false } - do { try await concreteStore.deleteImuChunk(id: id); return true } - catch { return false } - } - /// Max persisted HR sample ts (the biometric "data frontier" for the stuck-strap watchdog). /// nil if there's no concrete store or nothing persisted yet. Mirrors storageStats(). func latestHRSampleTs() async -> Int? { diff --git a/Strand/Collect/ImuChunkArchiveStore.swift b/Strand/Collect/ImuChunkArchiveStore.swift deleted file mode 100644 index e352bcd498..0000000000 --- a/Strand/Collect/ImuChunkArchiveStore.swift +++ /dev/null @@ -1,179 +0,0 @@ -import CryptoKit -import Compression -import Foundation -import WhoopProtocol -import WhoopStore - -/// Apple twin of Android ImuChunkStore. ZIP entries and samples.bin layout are platform-identical. -@MainActor -final class ImuChunkArchiveStore { - static let formatVersion = 1 - static let sampleRate = 100 - private let directory: URL - - init() { - let base = (try? FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, - appropriateFor: nil, create: true)) - ?? FileManager.default.temporaryDirectory - directory = base.appendingPathComponent("OpenWhoop/imu-chunks", isDirectory: true) - try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - } - - func pin(sessionId: String, deviceId: String, from: Int, to: Int, ble: BLEManager) async -> [ImuChunkMeta] { - ImuSessionFileStore.shared.prepareForRead(sessionId) - let existing = await ble.groundTruthImuChunks(from: from, to: to) - .filter { Self.isOwned($0.id, by: sessionId) && $0.pinnedUntil == Int.max - && FileManager.default.fileExists(atPath: file($0).path) } - if let exact = existing.first(where: { $0.startTs == from && $0.endTs == to - && $0.sampleCount >= ($0.endTs - $0.startTs + 1) * $0.sampleRate }) { - _ = await deleteOwned(sessionId: sessionId, keeping: [exact.id], ble: ble) - return [exact] - } - let rows = readSessionRows(sessionId: sessionId, from: from, to: to) - guard !rows.isEmpty else { return Self.bounded(existing, from: from, to: to) } - let startTs = rows.map(\.ts).min()!, endTs = rows.map(\.ts).max()! - - var samples = Data() - for row in rows { - samples.appendBigEndian(Int64(row.ts)) - samples.appendBigEndian(Int32(row.cols.count * 2)) - for value in row.cols { - samples.append(UInt8(truncatingIfNeeded: value)) - samples.append(UInt8(truncatingIfNeeded: value >> 8)) - } - } - let manifest: [String: Any] = [ - "format": "NOOPIMU", "version": Self.formatVersion, - "sample_rate_hz": Self.sampleRate, - "axes": ["ax", "ay", "az", "gx", "gy", "gz"], - "layout": "column-major-int16-le", "row_count": rows.count, - "ordering": "unspecified", "timestamp_source": "strap", - "start_ts": startTs, "end_ts": endTs, - ] - guard let manifestData = try? JSONSerialization.data(withJSONObject: manifest, options: [.prettyPrinted, .sortedKeys]), - let staged = FileExport.zipData(entries: [ - .init(name: "manifest.json", data: manifestData), .init(name: "samples.bin", data: samples), - ], baseName: UUID().uuidString) else { return [] } - let id = Self.prefix(sessionId) + UUID().uuidString - let destination = directory.appendingPathComponent("\(id).imuc") - do { try FileManager.default.moveItem(at: staged, to: destination) } catch { return [] } - let bytes = (try? Data(contentsOf: destination)) ?? Data() - let base = directory.deletingLastPathComponent().deletingLastPathComponent() - let relative = destination.path.replacingOccurrences(of: base.path + "/", with: "") - let chunk = ImuChunkMeta( - id: id, deviceId: deviceId, startTs: startTs, endTs: endTs, - sampleCount: rows.count * Self.sampleRate, sampleRate: Self.sampleRate, - formatVersion: Self.formatVersion, codec: "zip-deflate", relativePath: relative, - byteSize: bytes.count, sha256: SHA256.hash(data: bytes).map { String(format: "%02x", $0) }.joined(), - createdAt: Int(Date().timeIntervalSince1970), pinnedUntil: Int.max) - await ble.registerGroundTruthImuChunk(chunk) - _ = await deleteOwned(sessionId: sessionId, keeping: [id], ble: ble) - return [chunk] - } - - func deleteOwned(sessionId: String, keeping: Set = [], ble: BLEManager) async -> Bool { - guard let catalog = await ble.groundTruthImuChunks(idPrefix: Self.prefix(sessionId)) else { return false } - let catalogIds = Set(catalog.map(\.id)) - let fileIds = Set((try? FileManager.default.contentsOfDirectory(at: directory, - includingPropertiesForKeys: nil))?.compactMap { url in - url.pathExtension == "imuc" && Self.isOwned(url.deletingPathExtension().lastPathComponent, - by: sessionId) - ? url.deletingPathExtension().lastPathComponent : nil - } ?? []) - for id in catalogIds.union(fileIds).subtracting(keeping) { - let url = directory.appendingPathComponent("\(id).imuc") - guard await Self.deleteRetrySafe( - fileExists: FileManager.default.fileExists(atPath: url.path), - deleteFile: { (try? FileManager.default.removeItem(at: url)) != nil }, - deleteCatalog: { await ble.deleteGroundTruthImuChunk(id: id) } - ) else { return false } - } - return true - } - - func deleteDevice(_ deviceId: String, store: WhoopStore) async -> Bool { - guard let chunks = try? await store.imuChunks(deviceId: deviceId, from: Int.min, to: Int.max) else { - return false - } - for chunk in chunks { - let url = file(chunk) - guard await Self.deleteRetrySafe( - fileExists: FileManager.default.fileExists(atPath: url.path), - deleteFile: { (try? FileManager.default.removeItem(at: url)) != nil }, - deleteCatalog: { - do { try await store.deleteImuChunk(id: chunk.id); return true } - catch { return false } - } - ) else { return false } - } - return true - } - - func file(_ chunk: ImuChunkMeta) -> URL { - let base = directory.deletingLastPathComponent().deletingLastPathComponent() - return base.appendingPathComponent(chunk.relativePath) - } - - static func prefix(_ sessionId: String) -> String { "\(sessionId)--" } - static func isOwned(_ chunkId: String, by sessionId: String) -> Bool { - chunkId.hasPrefix(prefix(sessionId)) - } - static func bounded(_ chunks: [ImuChunkMeta], from: Int, to: Int) -> [ImuChunkMeta] { - chunks.filter { $0.startTs >= from && $0.endTs <= to } - } - static func deleteRetrySafe(fileExists: Bool, deleteFile: () -> Bool, - deleteCatalog: () async -> Bool) async -> Bool { - if fileExists && !deleteFile() { return false } - return await deleteCatalog() - } - - private func readSessionRows(sessionId: String, from: Int, to: Int) -> [(ts: Int, cols: [Int16])] { - let url = ImuSessionFileStore.shared.file(sessionId) - guard let data = try? Data(contentsOf: url) else { return [] } - let bytes = [UInt8](data); var offset = 0 - var rows: [(Int, [Int16])] = [] - while offset + 12 <= bytes.count { - let count = int32(bytes, offset); let rawLength = int32(bytes, offset + 4) - let compressedLength = int32(bytes, offset + 8); offset += 12 - guard count > 0, count <= 30, rawLength > 0, compressedLength > 0, - offset + compressedLength <= bytes.count else { break } - let compressed = Data(bytes[offset.. 0, rawOffset + length <= raw.count else { break } - let frame = Array(raw[rawOffset..= from, ts <= to, - let cols = Whoop5RawImu.rawColumns(frame) else { continue } - rows.append((ts, cols)) - } - } - return rows - } - - private func int32(_ bytes: [UInt8], _ offset: Int) -> Int { - Int(bytes[offset]) << 24 | Int(bytes[offset + 1]) << 16 - | Int(bytes[offset + 2]) << 8 | Int(bytes[offset + 3]) - } -} - -private extension Int64 { - init(bigEndianBytes bytes: [UInt8], at offset: Int) { - self = bytes[offset..<(offset + 8)].reduce(0) { ($0 << 8) | Int64($1) } - } -} - -private extension Data { - mutating func appendBigEndian(_ value: T) { - var v = value.bigEndian - Swift.withUnsafeBytes(of: &v) { append(contentsOf: $0) } - } -} diff --git a/Strand/Collect/ImuSessionFileStore.swift b/Strand/Collect/ImuSessionFileStore.swift index 20b870fc55..0035da322d 100644 --- a/Strand/Collect/ImuSessionFileStore.swift +++ b/Strand/Collect/ImuSessionFileStore.swift @@ -2,18 +2,18 @@ import Compression import Foundation import WhoopProtocol -/// Persisted routing table plus append-only, 30-frame compressed IMU session blocks. +/// Canonical decoded 100 Hz IMU storage: UTC half-hour files with appendable 30-second zlib blocks. @MainActor final class ImuSessionFileStore { + struct Stats { let bytes: Int64; let coveredSeconds: Int; let firstTs: Int64? } + struct ExportSegment { let name: String; let data: Data; let startTs, endTs: Int; let sampleCount: Int } + private struct Window: Codable { let id, deviceId: String; let from: Int64; var to: Int64? } + private struct Record { let ts, receivedAtMs: Int64; let columns: [Int16] } static let shared = ImuSessionFileStore() - - private struct Window: Codable { - let id, deviceId: String - let from: Int64 - var to: Int64? - } - private struct Record { let ts, receivedAtMs: Int64; let frame: [UInt8] } - private static let blockFrames = 30 + static let sampleRate = 100, axes = 6, blockSeconds = 30 + static let segmentSeconds: Int64 = 30 * 60 + private static let payloadBytes = sampleRate * axes * 2 + private static let magic = Data("NOOPIMU2".utf8) private let defaults = UserDefaults.standard private let key = "imu-session-windows-v1" private let directory: URL @@ -24,127 +24,166 @@ final class ImuSessionFileStore { let fm = FileManager.default let base = (try? fm.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)) ?? fm.temporaryDirectory - directory = base.appendingPathComponent("OpenWhoop/RawDataSessions", isDirectory: true) + directory = base.appendingPathComponent("OpenWhoop/RawImuSessions", isDirectory: true) try? fm.createDirectory(at: directory, withIntermediateDirectories: true) } func start(id: String, deviceId: String, fromMs: Int64) { var value = windows().filter { $0.id != id } value.append(Window(id: id, deviceId: deviceId, from: fromMs / 1_000, to: nil)); save(value) + try? FileManager.default.createDirectory(at: sessionDirectory(id), withIntermediateDirectories: true) } - func complete(id: String, toMs: Int64) { - flush(id) - var value = windows() + flushSession(id); var value = windows() if let index = value.firstIndex(where: { $0.id == id }) { value[index].to = toMs / 1_000; save(value) } } - func register(id: String, deviceId: String, fromMs: Int64, toMs: Int64) { var value = windows().filter { $0.id != id } value.append(Window(id: id, deviceId: deviceId, from: fromMs / 1_000, to: toMs / 1_000)); save(value) + try? FileManager.default.createDirectory(at: sessionDirectory(id), withIntermediateDirectories: true) } - func remove(id: String) { - pending[id] = nil + pending.keys.filter { $0.hasPrefix("\(id)/") }.forEach { pending[$0] = nil } + seen.keys.filter { $0.hasPrefix(sessionDirectory(id).path) }.forEach { seen[$0] = nil } save(windows().filter { $0.id != id }) - seen[id] = nil } + func prepareForRead(_ id: String) { flushSession(id) } - func prepareForRead(_ id: String) { flush(id) } + func deleteFiles(_ id: String, removeItem: (URL) throws -> Void = { try FileManager.default.removeItem(at: $0) }) -> Bool { + flushSession(id); let dir = sessionDirectory(id) + guard FileManager.default.fileExists(atPath: dir.path) else { return true } + do { try removeItem(dir); return true } catch { return false } + } + + func deleteDevice(_ deviceId: String) -> Bool { + let owned = windows().filter { $0.deviceId == deviceId }.map(\.id) + guard owned.allSatisfy({ deleteFiles($0) }) else { return false } + owned.forEach { remove(id: $0) }; return true + } @discardableResult func append(deviceId: String, frame: [UInt8], receivedAtMs: Int64) -> Int { - guard let ts = Whoop5RawImu.baseTs(frame), Whoop5RawImu.rawColumns(frame) != nil else { return 0 } + guard let ts = Whoop5RawImu.baseTs(frame), let columns = Whoop5RawImu.rawColumns(frame) else { return 0 } var count = 0 for window in windows() where window.deviceId == deviceId && Int64(ts) >= window.from && (window.to == nil || Int64(ts) <= window.to!) { - var timestamps = seen[window.id] ?? scan(window.id) + let bucket = Self.bucketStart(Int64(ts)), url = segmentFile(window.id, bucket) + var timestamps = seen[url.path] ?? scan(url) guard timestamps.insert(Int64(ts)).inserted else { continue } - seen[window.id] = timestamps - pending[window.id, default: []].append(Record(ts: Int64(ts), receivedAtMs: receivedAtMs, - frame: frame)) - if pending[window.id]!.count >= Self.blockFrames { flush(window.id) } + seen[url.path] = timestamps + let pendingKey = "\(window.id)/\(bucket)" + pending[pendingKey, default: []].append(Record(ts: Int64(ts), receivedAtMs: receivedAtMs, columns: columns)) + if pending[pendingKey]!.count >= Self.blockSeconds { flushKey(pendingKey) } count += 1 } return count } - func file(_ id: String) -> URL { directory.appendingPathComponent("realtime-imu-\(id).imus") } + func stats(_ id: String, from: Int, to: Int) -> Stats { + let records = readRecords(id, from: from, to: to, includePending: true) + let disk = segmentFiles(id).reduce(Int64(0)) { value, url in + value + Int64((try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? 0) + } + let queued = pending.filter { $0.key.hasPrefix("\(id)/") }.values.flatMap { $0 } + .reduce(Int64(0)) { $0 + Int64($1.columns.count * 2 + 20) } + return Stats(bytes: disk + queued, coveredSeconds: Set(records.map(\.ts)).count, + firstTs: records.map(\.ts).min()) + } - private func windows() -> [Window] { - guard let data = defaults.data(forKey: key) else { return [] } - return (try? JSONDecoder().decode([Window].self, from: data)) ?? [] + func exportSegments(_ id: String, from: Int, to: Int) -> [ExportSegment] { + flushSession(id) + let records = Dictionary(grouping: readRecords(id, from: from, to: to, includePending: false), by: \.ts) + .compactMap { $0.value.first }.sorted { $0.ts < $1.ts } + return Dictionary(grouping: records) { Self.bucketStart($0.ts) }.sorted { $0.key < $1.key }.compactMap { element in + let (bucket, rows) = element + guard let first = rows.first, let last = rows.last else { return nil } + return ExportSegment(name: "imu-\(Self.utcName(bucket)).imus", data: encode(bucket: bucket, records: rows), + startTs: Int(first.ts), endTs: Int(last.ts), sampleCount: rows.count * Self.sampleRate) + } + } + + private func readRecords(_ id: String, from: Int, to: Int, includePending: Bool) -> [Record] { + var rows = segmentFiles(id).flatMap { decode((try? Data(contentsOf: $0)) ?? Data()) } + .filter { $0.ts >= from && $0.ts <= to } + if includePending { rows += pending.filter { $0.key.hasPrefix("\(id)/") }.values.flatMap { $0 } + .filter { $0.ts >= from && $0.ts <= to } } + return rows } - private func save(_ value: [Window]) { defaults.set(try? JSONEncoder().encode(value), forKey: key) } - private func scan(_ id: String) -> Set { - guard let data = try? Data(contentsOf: file(id)) else { return [] } - var result = Set(), offset = 0 - while offset + 12 <= data.count { - let count: Int32 = data.bigEndian(at: offset) - let rawSize: Int32 = data.bigEndian(at: offset + 4) - let compressedSize: Int32 = data.bigEndian(at: offset + 8) + private func encode(bucket: Int64, records: [Record]) -> Data { + var result = header(bucket) + for start in stride(from: 0, to: records.count, by: Self.blockSeconds) { + result.append(block(Array(records[start.. [Record] { + guard data.count >= 24, data.prefix(8) == Self.magic else { return [] } + let bytes = [UInt8](data); var offset = 8 + _ = Int64(bigEndianBytes: bytes, at: offset); offset += 8 + guard int32(bytes, offset) == Self.sampleRate, int32(bytes, offset + 4) == Self.axes else { return [] } + offset += 8; var result: [Record] = [] + while offset + 12 <= bytes.count { + let count = int32(bytes, offset), rawSize = int32(bytes, offset + 4), compressedSize = int32(bytes, offset + 8) offset += 12 - guard count > 0, count <= Self.blockFrames, rawSize > 0, compressedSize > 0, - offset + Int(compressedSize) <= data.count, - let raw = Self.inflate(data[offset..<(offset + Int(compressedSize))], size: Int(rawSize)) else { break } - offset += Int(compressedSize) - var rawOffset = 0 - for _ in 0.. 0, rawOffset + 20 + Int(length) <= raw.count else { break } - result.insert(ts); rawOffset += 20 + Int(length) + guard count > 0, count <= Self.blockSeconds, rawSize > 0, compressedSize > 0, + offset + compressedSize <= bytes.count, + let raw = inflate(Data(bytes[offset.. Data { + var data = Self.magic; data.appendBigEndian(bucket); data.appendBigEndian(Int32(Self.sampleRate)); data.appendBigEndian(Int32(Self.axes)); return data + } + private func block(_ records: [Record]) -> Data { var raw = Data() for record in records { - raw.appendBigEndian(record.ts); raw.appendBigEndian(record.receivedAtMs) - raw.appendBigEndian(Int32(record.frame.count)); raw.append(contentsOf: record.frame) + raw.appendBigEndian(record.ts); raw.appendBigEndian(record.receivedAtMs); raw.appendBigEndian(Int32(Self.payloadBytes)) + for value in record.columns { raw.append(UInt8(truncatingIfNeeded: value)); raw.append(UInt8(truncatingIfNeeded: value >> 8)) } } - guard let compressed = Self.deflate(raw) else { pending[id] = records; return } - var block = Data() - block.appendBigEndian(Int32(records.count)); block.appendBigEndian(Int32(raw.count)) - block.appendBigEndian(Int32(compressed.count)); block.append(compressed) - let url = file(id) - if !FileManager.default.fileExists(atPath: url.path) { FileManager.default.createFile(atPath: url.path, contents: nil) } - guard let handle = try? FileHandle(forWritingTo: url) else { pending[id] = records; return } - do { - try handle.seekToEnd(); try handle.write(contentsOf: block); try handle.close() - } catch { try? handle.close(); pending[id] = records } + guard let compressed = deflate(raw) else { return Data() } + var data = Data(); data.appendBigEndian(Int32(records.count)); data.appendBigEndian(Int32(raw.count)) + data.appendBigEndian(Int32(compressed.count)); data.append(compressed); return data } - private static func deflate(_ input: Data) -> Data? { - let capacity = max(input.count + 128, 256); var output = Data(count: capacity) - let written = output.withUnsafeMutableBytes { dst in input.withUnsafeBytes { src in - compression_encode_buffer(dst.bindMemory(to: UInt8.self).baseAddress!, capacity, - src.bindMemory(to: UInt8.self).baseAddress!, input.count, - nil, COMPRESSION_ZLIB) - }} - guard written > 0 else { return nil }; output.count = written; return output - } - - private static func inflate(_ input: Data.SubSequence, size: Int) -> Data? { - var output = Data(count: size) - let written = output.withUnsafeMutableBytes { dst in input.withUnsafeBytes { src in - compression_decode_buffer(dst.bindMemory(to: UInt8.self).baseAddress!, size, - src.bindMemory(to: UInt8.self).baseAddress!, input.count, - nil, COMPRESSION_ZLIB) - }} - return written == size ? output : nil - } + private func windows() -> [Window] { defaults.data(forKey: key).flatMap { try? JSONDecoder().decode([Window].self, from: $0) } ?? [] } + private func save(_ value: [Window]) { defaults.set(try? JSONEncoder().encode(value), forKey: key) } + private func sessionDirectory(_ id: String) -> URL { directory.appendingPathComponent(id, isDirectory: true) } + private func segmentFile(_ id: String, _ bucket: Int64) -> URL { sessionDirectory(id).appendingPathComponent("imu-\(Self.utcName(bucket)).imus") } + private func segmentFiles(_ id: String) -> [URL] { ((try? FileManager.default.contentsOfDirectory(at: sessionDirectory(id), includingPropertiesForKeys: nil)) ?? []).filter { $0.pathExtension == "imus" }.sorted { $0.lastPathComponent < $1.lastPathComponent } } + private func scan(_ url: URL) -> Set { Set(decode((try? Data(contentsOf: url)) ?? Data()).map(\.ts)) } + private static func bucketStart(_ ts: Int64) -> Int64 { ts >= 0 ? ts / segmentSeconds * segmentSeconds : ((ts - segmentSeconds + 1) / segmentSeconds) * segmentSeconds } + private static func utcName(_ ts: Int64) -> String { let f = DateFormatter(); f.locale = Locale(identifier: "en_US_POSIX"); f.timeZone = TimeZone(secondsFromGMT: 0); f.dateFormat = "yyyyMMdd'T'HHmmss'Z'"; return f.string(from: Date(timeIntervalSince1970: TimeInterval(ts))) } + private func int32(_ bytes: [UInt8], _ offset: Int) -> Int { Int(bytes[offset]) << 24 | Int(bytes[offset + 1]) << 16 | Int(bytes[offset + 2]) << 8 | Int(bytes[offset + 3]) } + private func deflate(_ input: Data) -> Data? { let capacity = input.count + 128; var output = Data(count: capacity); let written = output.withUnsafeMutableBytes { dst in input.withUnsafeBytes { src in compression_encode_buffer(dst.bindMemory(to: UInt8.self).baseAddress!, capacity, src.bindMemory(to: UInt8.self).baseAddress!, input.count, nil, COMPRESSION_ZLIB) } }; guard written > 0 else { return nil }; output.count = written; return output } + private func inflate(_ input: Data, size: Int) -> Data? { var output = Data(count: size); let written = output.withUnsafeMutableBytes { dst in input.withUnsafeBytes { src in compression_decode_buffer(dst.bindMemory(to: UInt8.self).baseAddress!, size, src.bindMemory(to: UInt8.self).baseAddress!, input.count, nil, COMPRESSION_ZLIB) } }; return written == size ? output : nil } } -private extension Data { - mutating func appendBigEndian(_ value: T) { - var value = value.bigEndian; Swift.withUnsafeBytes(of: &value) { append(contentsOf: $0) } - } - func bigEndian(at offset: Int) -> T { - self[offset..<(offset + MemoryLayout.size)].reduce(T.zero) { ($0 << 8) | T($1) } - } -} +private extension Int64 { init(bigEndianBytes bytes: [UInt8], at offset: Int) { self = bytes[offset..<(offset + 8)].reduce(0) { ($0 << 8) | Int64($1) } } } +private extension Data { mutating func appendBigEndian(_ value: T) { var value = value.bigEndian; Swift.withUnsafeBytes(of: &value) { append(contentsOf: $0) } } } diff --git a/Strand/Collect/RawDataSessionStore.swift b/Strand/Collect/RawDataSessionStore.swift index 8aecf33c15..15e579bbb7 100644 --- a/Strand/Collect/RawDataSessionStore.swift +++ b/Strand/Collect/RawDataSessionStore.swift @@ -149,10 +149,7 @@ final class RawDataSessionStore: ObservableObject { func removeMetadata(_ sessionId: String, removeItem: (URL) throws -> Void = { try FileManager.default.removeItem(at: $0) }) -> Bool { guard sessions.first(where: { $0.id == sessionId })?.active == false else { return false } - let source = ImuSessionFileStore.shared.file(sessionId) - do { - if FileManager.default.fileExists(atPath: source.path) { try removeItem(source) } - } catch { return false } + guard ImuSessionFileStore.shared.deleteFiles(sessionId, removeItem: removeItem) else { return false } ImuSessionFileStore.shared.remove(id: sessionId) do { let metadata = file(sessionId) diff --git a/Strand/Data/DeviceRegistry.swift b/Strand/Data/DeviceRegistry.swift index 461758fc75..032d70a1c3 100644 --- a/Strand/Data/DeviceRegistry.swift +++ b/Strand/Data/DeviceRegistry.swift @@ -92,7 +92,7 @@ final class DeviceRegistry: ObservableObject { /// Best-effort: a store failure leaves the recordings and published state untouched. Awaits the delete /// BEFORE `reload()` so the refreshed device list reflects the emptied recordings. func deleteDeviceData(_ id: String, store: WhoopStore) async { - guard await ImuChunkArchiveStore().deleteDevice(id, store: store) else { return } + guard ImuSessionFileStore.shared.deleteDevice(id) else { return } do { try await store.deleteAllData(deviceId: id) } catch { @@ -109,7 +109,7 @@ final class DeviceRegistry: ObservableObject { /// and refreshes the published list. Best-effort: if the data wipe fails the registry row is left in /// place (we never leave orphaned recordings behind a removed row). func forget(_ id: String, store: WhoopStore) async { - guard await ImuChunkArchiveStore().deleteDevice(id, store: store) else { return } + guard ImuSessionFileStore.shared.deleteDevice(id) else { return } do { try await store.deleteAllData(deviceId: id) } catch { diff --git a/Strand/Screens/RawDataCollectorView.swift b/Strand/Screens/RawDataCollectorView.swift index e17ba46630..dab3b32f90 100644 --- a/Strand/Screens/RawDataCollectorView.swift +++ b/Strand/Screens/RawDataCollectorView.swift @@ -7,7 +7,6 @@ struct RawDataCollectorView: View { @EnvironmentObject private var model: AppModel @EnvironmentObject private var live: LiveState @StateObject private var store = RawDataSessionStore() - private let archive = ImuChunkArchiveStore() @State private var exportingId: String? @State private var deleteCandidate: RawDataSessionStore.Session? @@ -291,30 +290,25 @@ struct RawDataCollectorView: View { exportingId = session.id let bounds = Self.fullSecondBounds(fromMs: session.startedAtMs, toMs: end) let from = bounds?.from ?? 1, to = bounds?.to ?? 0 - let chunks: [ImuChunkMeta] = if let bounds { - await archive.pin(sessionId: session.id, deviceId: session.deviceId, - from: bounds.from, to: bounds.to, ble: model.ble) - } else { [] } + let segments = bounds.map { + ImuSessionFileStore.shared.exportSegments(session.id, from: $0.from, to: $0.to) + } ?? [] let history = bounds == nil ? Data("stream,unix_s,v1,v2,v3,v4\n".utf8) : await model.ble.groundTruthHistoryCSV(from: from, to: to) - let sensorAvailable = !chunks.isEmpty || history.split(separator: 0x0A).count > 1 + let sensorAvailable = !segments.isEmpty || history.split(separator: 0x0A).count > 1 var entries = store.exportEntries(for: session, raw: [], sensorAvailable: sensorAvailable) entries.append(.init(name: "history-sensors.csv", data: history)) - let imuComplete = Self.covers(chunks, from: from, to: to) + let imuComplete = Self.covers(segments, from: from, to: to) let coverage: [String: Any] = [ "requested_start_ts": from, "requested_end_ts": to, "complete": imuComplete, - "chunks": chunks.map { ["start_ts": $0.startTs, "end_ts": $0.endTs, - "sample_count": $0.sampleCount, "sha256": $0.sha256] } + "segments": segments.map { ["file": $0.name, "start_ts": $0.startTs, "end_ts": $0.endTs, + "sample_count": $0.sampleCount] } ] if let data = try? JSONSerialization.data(withJSONObject: coverage, options: [.prettyPrinted, .sortedKeys]) { entries.append(.init(name: "imu-coverage.json", data: data)) } - for chunk in chunks { - if let data = try? Data(contentsOf: archive.file(chunk)) { - entries.append(.init(name: "imu/\(chunk.id).imuc", data: data)) - } - } + for segment in segments { entries.append(.init(name: "imu/\(segment.name)", data: segment.data)) } let result = await FileExport.exportBundle(entries: entries, suggestedName: "noop-5mg-raw-\(session.id).zip") if result == nil { exportError = "The export file could not be created or shared." } @@ -328,10 +322,6 @@ struct RawDataCollectorView: View { private func delete(_ session: RawDataSessionStore.Session) async { guard session.endedAtMs != nil else { return } model.ble.finishGroundTruthRawCapture(sessionId: session.id) - guard await archive.deleteOwned(sessionId: session.id, ble: model.ble) else { - deleteError = "The captured data could not be deleted. The session was kept so you can retry." - return - } guard store.removeMetadata(session.id) else { deleteError = "The captured data could not be deleted. The session was kept so you can retry." return @@ -380,11 +370,11 @@ struct RawDataCollectorView: View { } } - private static func covers(_ chunks: [ImuChunkMeta], from: Int, to: Int) -> Bool { + private static func covers(_ chunks: [ImuSessionFileStore.ExportSegment], from: Int, to: Int) -> Bool { var cursor = from for chunk in chunks.sorted(by: { $0.startTs < $1.startTs }) { let start = chunk.startTs, end = chunk.endTs - if chunk.sampleCount < (end - start + 1) * chunk.sampleRate { continue } + if chunk.sampleCount < (end - start + 1) * ImuSessionFileStore.sampleRate { continue } if start > cursor { return false } if end >= cursor { cursor = end + 1 } if cursor > to { return true } diff --git a/StrandTests/RawDataCollectorParityTests.swift b/StrandTests/RawDataCollectorParityTests.swift index e44971032e..f00a0703d2 100644 --- a/StrandTests/RawDataCollectorParityTests.swift +++ b/StrandTests/RawDataCollectorParityTests.swift @@ -27,10 +27,8 @@ final class RawDataCollectorParityTests: XCTestCase { let paths = [ "Strand/Collect/RawDataSessionStore.swift", "Strand/Collect/Collector.swift", "Strand/BLE/BLEManager.swift", "Strand/Screens/RawDataCollectorView.swift", - "Strand/Collect/ImuChunkArchiveStore.swift", "Strand/Collect/ImuSessionFileStore.swift", "Packages/WhoopStore/Sources/WhoopStore/Database.swift", - "Packages/WhoopStore/Sources/WhoopStore/ImuChunkCatalog.swift", "Packages/WhoopStore/Sources/WhoopStore/StreamStore.swift", "Packages/WhoopStore/Sources/WhoopStore/RawOutbox.swift", ] diff --git a/StrandTests/RawDataSessionStoreTests.swift b/StrandTests/RawDataSessionStoreTests.swift index b743d6c1b1..382cb93525 100644 --- a/StrandTests/RawDataSessionStoreTests.swift +++ b/StrandTests/RawDataSessionStoreTests.swift @@ -100,33 +100,6 @@ final class RawDataSessionStoreTests: XCTestCase { XCTAssertNil(RawDataCollectorView.fullSecondBounds(fromMs: 100_001, toMs: 100_999)) } - func testChunksAreSessionOwnedAndStrictlyBounded() { - let owned = ImuChunkMeta(id: "one--chunk", deviceId: "strap", startTs: 101, endTs: 103, - sampleCount: 300, sampleRate: 100, formatVersion: 1, codec: "zip-deflate", - relativePath: "owned", byteSize: 1, sha256: "a", createdAt: 0, pinnedUntil: Int.max) - let other = ImuChunkMeta(id: "two--chunk", deviceId: "strap", startTs: 101, endTs: 103, - sampleCount: 300, sampleRate: 100, formatVersion: 1, codec: "zip-deflate", - relativePath: "other", byteSize: 1, sha256: "b", createdAt: 0, pinnedUntil: Int.max) - let broad = ImuChunkMeta(id: "one--broad", deviceId: "strap", startTs: 100, endTs: 104, - sampleCount: 500, sampleRate: 100, formatVersion: 1, codec: "zip-deflate", - relativePath: "broad", byteSize: 1, sha256: "c", createdAt: 0, pinnedUntil: Int.max) - - XCTAssertTrue(ImuChunkArchiveStore.isOwned(owned.id, by: "one")) - XCTAssertFalse(ImuChunkArchiveStore.isOwned(other.id, by: "one")) - XCTAssertEqual(ImuChunkArchiveStore.bounded([owned, broad], from: 101, to: 103), [owned]) - } - - func testArchiveDeleteFailureKeepsCatalogForRetry() async { - var operations: [String] = [] - let deleted = await ImuChunkArchiveStore.deleteRetrySafe(fileExists: true, deleteFile: { - operations.append("file"); return false - }, deleteCatalog: { - operations.append("catalog"); return true - }) - XCTAssertFalse(deleted) - XCTAssertEqual(operations, ["file"]) - } - func testMarkerCurrentTimeTicksOnlyForActiveSession() throws { let store = RawDataSessionStore(directory: try temporaryDirectory()) _ = store.start(deviceId: "strap", now: Date(timeIntervalSince1970: 100)) @@ -161,14 +134,9 @@ final class RawDataSessionStoreTests: XCTestCase { let store = RawDataSessionStore(directory: directory) let session = try XCTUnwrap(store.start(deviceId: "strap")) store.stop() - let source = ImuSessionFileStore.shared.file(session.id) - XCTAssertTrue(FileManager.default.createFile(atPath: source.path, contents: Data("raw".utf8))) - - XCTAssertFalse(store.removeMetadata(session.id) { url in - if url == source { - throw CocoaError(.fileWriteNoPermission) - } - try FileManager.default.removeItem(at: url) + + XCTAssertFalse(store.removeMetadata(session.id) { _ in + throw CocoaError(.fileWriteNoPermission) }) XCTAssertEqual(store.sessions.map(\.id), [session.id]) XCTAssertEqual(RawDataSessionStore(directory: directory).sessions.map(\.id), [session.id]) diff --git a/StrandTests/Resources/raw_data_collector_parity.json b/StrandTests/Resources/raw_data_collector_parity.json index e13709f60a..40901f9dc2 100644 --- a/StrandTests/Resources/raw_data_collector_parity.json +++ b/StrandTests/Resources/raw_data_collector_parity.json @@ -34,16 +34,16 @@ "kotlin": ["fun setSessionRange(", "ground_truth_edit_start"] }, "file_only_imu_storage": { - "swift": ["v41-drop-raw-imu-sample"], - "kotlin": ["MIGRATION_34_35", "DROP TABLE IF EXISTS `rawImuSample`"] + "swift": ["v40-drop-raw-imu-sample"], + "kotlin": ["MIGRATION_33_34", "DROP TABLE IF EXISTS `rawImuSample`"] }, "session_bound_imu_file": { - "swift": ["ImuSessionFileStore", "realtime-imu-\\(id).imus"], - "kotlin": ["ImuSessionFileStore", "realtime-imu-$id.imus"] + "swift": ["ImuSessionFileStore", "segmentSeconds: Int64 = 30 * 60", "NOOPIMU2"], + "kotlin": ["ImuSessionFileStore", "SEGMENT_SECONDS = 30 * 60L", "NOOPIMU2"] }, - "durable_imu_chunk_catalog": { - "swift": ["ImuChunkArchiveStore", "ImuChunkMeta"], - "kotlin": ["ImuChunkStore", "ImuChunkEntity"] + "decoded_segment_export": { + "swift": ["exportSegments", "imu/\\(segment.name)"], + "kotlin": ["exportSegments", "imu/${segment.name}"] }, "explicit_imu_coverage": { "swift": ["imu-coverage.json", "private static func covers"], @@ -62,8 +62,8 @@ "kotlin": ["LaunchedEffect(state.active, state.sessionId, live.connected)", "startGroundTruthImuCapture"] }, "raw_data_removed_with_session": { - "swift": ["deleteRawBatches(deviceId:", "capturedAt >= ? AND capturedAt <= ?"], - "kotlin": ["realtime-imu-$sessionId.imus", "noop-5mg-raw-$sessionId.zip"] + "swift": ["deleteFiles(sessionId", "capturedAt >= ? AND capturedAt <= ?"], + "kotlin": ["deleteFiles(sessionId)", "noop-5mg-raw-$sessionId.zip"] } } } diff --git a/android/app/src/main/java/com/noop/data/DeviceRegistry.kt b/android/app/src/main/java/com/noop/data/DeviceRegistry.kt index ba7ee0d39e..4343190d1e 100644 --- a/android/app/src/main/java/com/noop/data/DeviceRegistry.kt +++ b/android/app/src/main/java/com/noop/data/DeviceRegistry.kt @@ -99,7 +99,6 @@ class DeviceRegistry( dao.reKeyPpgHr(activeId, serialId); dao.deletePpgHrFor(activeId) dao.reKeyPpgWaveform(activeId, serialId); dao.deletePpgWaveformFor(activeId) dao.reKeyV18Aux(activeId, serialId); dao.deleteV18AuxFor(activeId) - dao.reKeyImuChunks(activeId, serialId); dao.deleteImuChunkCatalogFor(activeId) dao.reKeyEvents(activeId, serialId); dao.deleteEventsFor(activeId) dao.reKeyBattery(activeId, serialId); dao.deleteBatteryFor(activeId) dao.reKeyDailyMetrics(activeId, serialId); dao.deleteDailyMetricsFor(activeId) @@ -189,7 +188,6 @@ class DeviceRegistry( dao.deletePpgHrFor(id) dao.deletePpgWaveformFor(id) dao.deleteV18AuxFor(id) - dao.deleteImuChunkCatalogFor(id) dao.deleteEventsFor(id) dao.deleteBatteryFor(id) dao.deleteDailyMetricsFor(id) diff --git a/android/app/src/main/java/com/noop/data/DeviceRegistryDao.kt b/android/app/src/main/java/com/noop/data/DeviceRegistryDao.kt index dc51c62d6f..099edec1b6 100644 --- a/android/app/src/main/java/com/noop/data/DeviceRegistryDao.kt +++ b/android/app/src/main/java/com/noop/data/DeviceRegistryDao.kt @@ -86,7 +86,6 @@ interface DeviceRegistryDao { @Query("DELETE FROM ppgHrSample WHERE deviceId = :deviceId") suspend fun deletePpgHrFor(deviceId: String) @Query("DELETE FROM ppgWaveformSample WHERE deviceId = :deviceId") suspend fun deletePpgWaveformFor(deviceId: String) @Query("DELETE FROM v18AuxSample WHERE deviceId = :deviceId") suspend fun deleteV18AuxFor(deviceId: String) - @Query("DELETE FROM imuChunk WHERE deviceId = :deviceId") suspend fun deleteImuChunkCatalogFor(deviceId: String) @Query("DELETE FROM event WHERE deviceId = :deviceId") suspend fun deleteEventsFor(deviceId: String) @Query("DELETE FROM battery WHERE deviceId = :deviceId") suspend fun deleteBatteryFor(deviceId: String) @Query("DELETE FROM dailyMetric WHERE deviceId = :deviceId") suspend fun deleteDailyMetricsFor(deviceId: String) @@ -127,7 +126,6 @@ interface DeviceRegistryDao { @Query("UPDATE OR IGNORE ppgHrSample SET deviceId = :to WHERE deviceId = :from") suspend fun reKeyPpgHr(from: String, to: String) @Query("UPDATE OR IGNORE ppgWaveformSample SET deviceId = :to WHERE deviceId = :from") suspend fun reKeyPpgWaveform(from: String, to: String) @Query("UPDATE OR IGNORE v18AuxSample SET deviceId = :to WHERE deviceId = :from") suspend fun reKeyV18Aux(from: String, to: String) - @Query("UPDATE OR IGNORE imuChunk SET deviceId = :to WHERE deviceId = :from") suspend fun reKeyImuChunks(from: String, to: String) @Query("UPDATE OR IGNORE event SET deviceId = :to WHERE deviceId = :from") suspend fun reKeyEvents(from: String, to: String) @Query("UPDATE OR IGNORE battery SET deviceId = :to WHERE deviceId = :from") suspend fun reKeyBattery(from: String, to: String) @Query("UPDATE OR IGNORE dailyMetric SET deviceId = :to WHERE deviceId = :from") suspend fun reKeyDailyMetrics(from: String, to: String) diff --git a/android/app/src/main/java/com/noop/data/Entities.kt b/android/app/src/main/java/com/noop/data/Entities.kt index 5d1ac18c74..d3d57a3493 100644 --- a/android/app/src/main/java/com/noop/data/Entities.kt +++ b/android/app/src/main/java/com/noop/data/Entities.kt @@ -656,27 +656,6 @@ data class PpgWaveformSampleEntity( } } -/** Catalog row for one immutable, app-private IMU archive chunk. Payload bytes live outside SQLite. */ -@Entity( - tableName = "imuChunk", - indices = [Index(value = ["deviceId", "startTs", "endTs"])], -) -data class ImuChunkEntity( - @PrimaryKey val id: String, - val deviceId: String, - val startTs: Long, - val endTs: Long, - val sampleCount: Int, - val sampleRate: Int, - val formatVersion: Int, - val codec: String, - val relativePath: String, - val byteSize: Long, - val sha256: String, - val createdAt: Long, - val pinnedUntil: Long?, -) - /** * One Live Session (silent guardian) record (v22 / MIGRATION_15_16). Natural key (deviceId, startTs). * `endTs` is null while the session is still in progress. Fields are declared in the SAME order as the diff --git a/android/app/src/main/java/com/noop/data/WhoopDao.kt b/android/app/src/main/java/com/noop/data/WhoopDao.kt index aa772258d0..6579a8634b 100644 --- a/android/app/src/main/java/com/noop/data/WhoopDao.kt +++ b/android/app/src/main/java/com/noop/data/WhoopDao.kt @@ -143,27 +143,6 @@ interface WhoopDao : DeviceRegistryDao { ) suspend fun pruneV18Aux(deviceId: String, keep: Int) - @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun upsertImuChunk(row: ImuChunkEntity) - - @Query("SELECT * FROM imuChunk WHERE deviceId = :deviceId AND endTs >= :from AND startTs <= :to ORDER BY startTs") - suspend fun imuChunks(deviceId: String, from: Long, to: Long): List - - @Query("SELECT * FROM imuChunk WHERE deviceId = :deviceId ORDER BY startTs") - suspend fun imuChunksForDevice(deviceId: String): List - - @Query("SELECT * FROM imuChunk WHERE id LIKE :idPrefix || '%' ORDER BY startTs") - suspend fun imuChunksByIdPrefix(idPrefix: String): List - - @Query("DELETE FROM imuChunk WHERE id = :id") - suspend fun deleteImuChunk(id: String) - - @Query("DELETE FROM imuChunk WHERE deviceId = :deviceId") - suspend fun deleteImuChunksFor(deviceId: String) - - @Query("SELECT * FROM imuChunk WHERE pinnedUntil IS NULL AND endTs < :cutoff ORDER BY endTs") - suspend fun expiredImuChunks(cutoff: Long): List - // MARK: - Server-derived caches (latest value wins) @Upsert diff --git a/android/app/src/main/java/com/noop/data/WhoopDatabase.kt b/android/app/src/main/java/com/noop/data/WhoopDatabase.kt index c0f1ec41b0..f5256a2851 100644 --- a/android/app/src/main/java/com/noop/data/WhoopDatabase.kt +++ b/android/app/src/main/java/com/noop/data/WhoopDatabase.kt @@ -49,11 +49,10 @@ import androidx.sqlite.db.SupportSQLiteDatabase LabMarkerRow::class, LiveSessionRow::class, PpgWaveformSampleEntity::class, - ImuChunkEntity::class, V18AuxSampleEntity::class, AppleStepHour::class, ], - version = 35, + version = 34, // #775: ON so Room's KSP processor writes the generated schema (every table's exact `CREATE TABLE`, // columns in declaration order with affinity/NOT NULL/default, PK and indices) as JSON. That export // is what lets a plain JVM test — no device, no Robolectric — read Android's REAL schema and compare @@ -70,7 +69,7 @@ abstract class WhoopDatabase : RoomDatabase() { const val DB_NAME = "noop_whoop.db" /** Room schema version — MUST equal the `@Database(version = …)` above. Surfaced in the backup * manifest (#1410) so an export states its schema. Bump both together on a migration. */ - const val SCHEMA_VERSION = 35 + const val SCHEMA_VERSION = 34 @Volatile private var instance: WhoopDatabase? = null @@ -897,22 +896,7 @@ abstract class WhoopDatabase : RoomDatabase() { } } - internal val IMU_CHUNK_MIGRATION_SQL: List = listOf( - "CREATE TABLE IF NOT EXISTS `imuChunk` (`id` TEXT NOT NULL, `deviceId` TEXT NOT NULL, " + - "`startTs` INTEGER NOT NULL, `endTs` INTEGER NOT NULL, `sampleCount` INTEGER NOT NULL, " + - "`sampleRate` INTEGER NOT NULL, `formatVersion` INTEGER NOT NULL, `codec` TEXT NOT NULL, " + - "`relativePath` TEXT NOT NULL, `byteSize` INTEGER NOT NULL, `sha256` TEXT NOT NULL, " + - "`createdAt` INTEGER NOT NULL, `pinnedUntil` INTEGER, PRIMARY KEY(`id`))", - "CREATE INDEX IF NOT EXISTS `index_imuChunk_deviceId_startTs_endTs` ON `imuChunk` (`deviceId`, `startTs`, `endTs`)", - ) - internal val MIGRATION_33_34 = object : Migration(33, 34) { - override fun migrate(db: SupportSQLiteDatabase) { - for (stmt in IMU_CHUNK_MIGRATION_SQL) db.execSQL(stmt) - } - } - - internal val MIGRATION_34_35 = object : Migration(34, 35) { override fun migrate(db: SupportSQLiteDatabase) { db.execSQL("DROP TABLE IF EXISTS `rawImuSample`") } } @@ -941,7 +925,7 @@ abstract class WhoopDatabase : RoomDatabase() { MIGRATION_18_19, MIGRATION_19_20, MIGRATION_20_21, MIGRATION_21_22, MIGRATION_22_23, MIGRATION_23_24, MIGRATION_24_25, MIGRATION_25_26, MIGRATION_26_27, MIGRATION_27_28, MIGRATION_28_29, MIGRATION_29_30, - MIGRATION_30_31, MIGRATION_31_32, MIGRATION_32_33, MIGRATION_33_34, MIGRATION_34_35, + MIGRATION_30_31, MIGRATION_31_32, MIGRATION_32_33, MIGRATION_33_34, ) diff --git a/android/app/src/main/java/com/noop/data/WhoopRepository.kt b/android/app/src/main/java/com/noop/data/WhoopRepository.kt index 4c9c334da5..6ea07e43a1 100644 --- a/android/app/src/main/java/com/noop/data/WhoopRepository.kt +++ b/android/app/src/main/java/com/noop/data/WhoopRepository.kt @@ -1003,16 +1003,6 @@ class WhoopRepository( .map { V18AuxCodec.unpack(it.fields, it.ts) } - suspend fun upsertImuChunk(row: ImuChunkEntity) = dao.upsertImuChunk(row) - suspend fun imuChunks(deviceId: String, from: Long, to: Long): List = - dao.imuChunks(deviceId, from, to) - suspend fun imuChunksForDevice(deviceId: String): List = dao.imuChunksForDevice(deviceId) - suspend fun imuChunksByIdPrefix(idPrefix: String): List = - dao.imuChunksByIdPrefix(idPrefix) - suspend fun expiredImuChunks(cutoff: Long): List = dao.expiredImuChunks(cutoff) - suspend fun deleteImuChunk(id: String) = dao.deleteImuChunk(id) - suspend fun deleteImuChunksFor(deviceId: String) = dao.deleteImuChunksFor(deviceId) - /** Downsampled HR (mean bpm per [bucketSeconds]) for the strap, for the Today 24h trend chart. */ suspend fun hrBuckets(deviceId: String, from: Long, to: Long, bucketSeconds: Long = 300L) = dao.hrBuckets(deviceId, from, to, bucketSeconds) diff --git a/android/app/src/main/java/com/noop/testcentre/GroundTruthCollector.kt b/android/app/src/main/java/com/noop/testcentre/GroundTruthCollector.kt index 103d71ca37..949815324e 100644 --- a/android/app/src/main/java/com/noop/testcentre/GroundTruthCollector.kt +++ b/android/app/src/main/java/com/noop/testcentre/GroundTruthCollector.kt @@ -12,7 +12,6 @@ import java.io.File import java.util.zip.ZipEntry import java.util.zip.ZipOutputStream import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import org.json.JSONObject import java.util.UUID @@ -120,8 +119,7 @@ class GroundTruthCollector private constructor(private val context: Context) { val isCurrent = current.sessionId == id val physicalStart = prefs.getLong(sessionStartKey(id), first.optLong("at_ms")) val capturedEnd = rows.lastOrNull { it.optString("kind") == "stop" }?.optLong("at_ms") - val hasCapture = prefs.getBoolean(sessionCaptureKey(id), - File(directory, "realtime-imu-$id.imus").isFile) + val hasCapture = prefs.getBoolean(sessionCaptureKey(id), false) SessionSummary( id = id, deviceId = prefs.getString(sessionDeviceKey(id), null) @@ -151,19 +149,13 @@ class GroundTruthCollector private constructor(private val context: Context) { if (current.active && current.sessionId == sessionId) return false if (sessions().none { it.id == sessionId }) return false - val payloadFiles = listOf( - File(directory, "realtime-imu-$sessionId.imus"), - File(context.cacheDir, "logs/noop-5mg-raw-$sessionId.zip"), - ) + val payloadFiles = listOf(File(context.cacheDir, "logs/noop-5mg-raw-$sessionId.zip")) // Evaluate every deletion even if one fails, so a stale export ZIP never survives merely // because an earlier file could not be removed. - val chunksDeleted = runBlocking { - ImuChunkStore(context, WhoopRepository.from(context)).deleteOwned(sessionId) - } - // Keep the session discoverable and retryable until its durable archive catalog is clean. - if (!chunksDeleted) return false + val imuStore = ImuSessionFileStore(context) + if (!imuStore.deleteFiles(sessionId)) return false if (!payloadFiles.all { file -> !file.exists() || file.delete() }) return false - ImuSessionFileStore(context).remove(sessionId) + imuStore.remove(sessionId) // The event file is also the durable session index. Delete it last so any earlier failure // leaves the session visible and the explicit delete action retryable. if (eventFile(sessionId).exists() && !eventFile(sessionId).delete()) return false @@ -288,12 +280,12 @@ class GroundTruthCollector private constructor(private val context: Context) { // Export the complete bounded interval; markers are optional annotations. val sensorFrom = ceilSecond(summary.startedAtMs) val sensorTo = endMs / 1_000L - 1L - val pinnedChunks = if (deviceId == null || sensorFrom > sensorTo) emptyList() else - ImuChunkStore(context, repo).pin(id, deviceId, sensorFrom, sensorTo) + val imuSegments = if (deviceId == null || sensorFrom > sensorTo) emptyList() else + ImuSessionFileStore(context).exportSegments(id, sensorFrom, sensorTo) val fullFrom = sensorFrom val fullTo = endMs / 1_000L - 1L val coverageFrom = sensorFrom - val imuComplete = covers(pinnedChunks, coverageFrom, fullTo) + val imuComplete = covers(imuSegments, coverageFrom, fullTo) val outDir = File(context.cacheDir, "logs").apply { mkdirs() } val zip = File(outDir, "noop-5mg-raw-$id.zip") ZipOutputStream(zip.outputStream().buffered()).use { out -> @@ -313,7 +305,7 @@ class GroundTruthCollector private constructor(private val context: Context) { } out.closeEntry() - val sensorAvailable = pinnedChunks.isNotEmpty() || v18Count > 0 || rawCounts.values.any { it > 0 } + val sensorAvailable = imuSegments.isNotEmpty() || v18Count > 0 || rawCounts.values.any { it > 0 } out.putNextEntry(ZipEntry("meta.json")) out.writerEntry(JSONObject().apply { put("schema_version", 3) @@ -321,15 +313,15 @@ class GroundTruthCollector private constructor(private val context: Context) { put("session_id", id) if (deviceId != null) put("device_id", deviceId) put("sensor_export_available", sensorAvailable) - put("imu_100hz_chunk_count", pinnedChunks.size) + put("imu_100hz_segment_count", imuSegments.size) put("imu_100hz_complete", imuComplete) put("imu_100hz_required_start_ts", coverageFrom) put("imu_100hz_required_end_ts", fullTo) put("imu_100hz_startup_seconds", (coverageFrom - fullFrom).coerceAtLeast(0)) put("imu_100hz_coverage", org.json.JSONArray().apply { - pinnedChunks.forEach { chunk -> put(JSONObject().apply { - put("start_ts", chunk.startTs); put("end_ts", chunk.endTs) - put("sample_count", chunk.sampleCount); put("sha256", chunk.sha256) + imuSegments.forEach { segment -> put(JSONObject().apply { + put("file", segment.name); put("start_ts", segment.startTs); put("end_ts", segment.endTs) + put("sample_count", segment.sampleCount) }) } }) put("started_at_ms", summary.startedAtMs) @@ -351,11 +343,9 @@ class GroundTruthCollector private constructor(private val context: Context) { out.putNextEntry(ZipEntry("events.csv")) out.writerEntry(eventsCsv(publicEvents(events, summary.startedAtMs, endMs, deviceId))) - for (chunk in pinnedChunks) { - val file = ImuChunkStore(context, repo).file(chunk) - if (!file.isFile) continue - out.putNextEntry(ZipEntry("imu/${chunk.id}.imuc")) - file.inputStream().use { it.copyTo(out) } + for (segment in imuSegments) { + out.putNextEntry(ZipEntry("imu/${segment.name}")) + out.write(segment.data) out.closeEntry() } } @@ -375,12 +365,12 @@ class GroundTruthCollector private constructor(private val context: Context) { }, "Export raw-data session").addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) } - private fun covers(chunks: List, from: Long, to: Long): Boolean { + private fun covers(chunks: List, from: Long, to: Long): Boolean { if (from > to) return false var cursor = from for (chunk in chunks.sortedBy { it.startTs }) { val start = chunk.startTs; val end = chunk.endTs - if (chunk.sampleCount < (end - start + 1) * chunk.sampleRate) continue + if (chunk.sampleCount < (end - start + 1) * ImuSessionFileStore.SAMPLE_RATE) continue if (start > cursor) return false if (end >= cursor) cursor = end + 1 if (cursor > to) return true diff --git a/android/app/src/main/java/com/noop/testcentre/ImuChunkStore.kt b/android/app/src/main/java/com/noop/testcentre/ImuChunkStore.kt deleted file mode 100644 index 212c54ee3e..0000000000 --- a/android/app/src/main/java/com/noop/testcentre/ImuChunkStore.kt +++ /dev/null @@ -1,184 +0,0 @@ -package com.noop.testcentre - -import android.content.Context -import com.noop.data.ImuChunkEntity -import com.noop.data.StreamPersistence -import com.noop.data.WhoopRepository -import com.noop.protocol.Whoop5RawImu -import java.io.BufferedOutputStream -import java.io.DataInputStream -import java.io.DataOutputStream -import java.io.EOFException -import java.io.File -import java.io.FileOutputStream -import java.security.MessageDigest -import java.util.UUID -import java.util.zip.Inflater -import java.util.zip.ZipEntry -import java.util.zip.ZipOutputStream -import org.json.JSONObject -import org.json.JSONArray - -/** Materializes immutable archives from a session file; SQLite is only a short history staging cache. */ -class ImuChunkStore(private val context: Context, private val repository: WhoopRepository) { - private val directory = File(context.filesDir, "imu-chunks").apply { mkdirs() } - - suspend fun pin(sessionId: String, deviceId: String, from: Long, to: Long): List { - require(from <= to) - ImuSessionFileStore(context).prepareForRead(sessionId) - val existing = repository.imuChunks(deviceId, from, to) - .filter { isOwnedChunk(sessionId, it.id) && - it.pinnedUntil == Long.MAX_VALUE && - File(context.filesDir, it.relativePath).isFile } - existing.firstOrNull { it.startTs == from && it.endTs == to && - it.sampleCount >= (it.endTs - it.startTs + 1) * it.sampleRate }?.let { exact -> - deleteOwned(sessionId, setOf(exact.id)) - return listOf(exact) - } - val rows = readSessionRows(sessionId, from, to) - if (rows.isEmpty()) return boundedExisting(existing, from, to) - val startTs = rows.minOf { it.first } - val endTs = rows.maxOf { it.first } - - val id = chunkPrefix(sessionId) + UUID.randomUUID().toString() - val final = File(directory, "$id.imuc") - val temp = File(directory, "$id.tmp") - ZipOutputStream(BufferedOutputStream(FileOutputStream(temp))).use { zip -> - zip.putNextEntry(ZipEntry("manifest.json")) - zip.write(JSONObject().apply { - put("format", "NOOPIMU") - put("version", FORMAT_VERSION) - put("sample_rate_hz", SAMPLE_RATE) - put("axes", JSONArray(listOf("ax", "ay", "az", "gx", "gy", "gz"))) - put("layout", "column-major-int16-le") - put("ordering", "unspecified") - put("timestamp_source", "strap") - put("row_count", rows.size) - put("start_ts", startTs) - put("end_ts", endTs) - }.toString(2).toByteArray()) - zip.closeEntry() - zip.putNextEntry(ZipEntry("samples.bin")) - DataOutputStream(zip).let { payload -> - for ((ts, columns) in rows) { - val packed = StreamPersistence.packImuColumns(columns) - payload.writeLong(ts) - payload.writeInt(packed.size) - payload.write(packed) - } - } - zip.closeEntry() - } - check(temp.renameTo(final)) { "Could not commit IMU chunk" } - val entity = ImuChunkEntity( - id = id, deviceId = deviceId, startTs = startTs, endTs = endTs, - sampleCount = rows.size * SAMPLE_RATE, sampleRate = SAMPLE_RATE, - formatVersion = FORMAT_VERSION, codec = "zip-deflate", - relativePath = final.relativeTo(context.filesDir).path, byteSize = final.length(), - sha256 = sha256(final), createdAt = System.currentTimeMillis() / 1_000L, - pinnedUntil = Long.MAX_VALUE, - ) - repository.upsertImuChunk(entity) - deleteOwned(sessionId, setOf(id)) - return coverage(sessionId, deviceId, from, to) - } - - suspend fun coverage(sessionId: String, deviceId: String, from: Long, to: Long): List = - repository.imuChunks(deviceId, from, to).filter { - it.id.startsWith(chunkPrefix(sessionId)) && it.startTs >= from && it.endTs <= to && - File(context.filesDir, it.relativePath).isFile - } - - suspend fun deleteOwned( - sessionId: String, - keepIds: Set = emptySet(), - ): Boolean { - val prefix = chunkPrefix(sessionId) - val catalogIds = runCatching { repository.imuChunksByIdPrefix(prefix).map { it.id } } - .getOrElse { return false } - val fileIds = directory.listFiles { file -> - file.extension == "imuc" && isOwnedChunk(sessionId, file.nameWithoutExtension) - }.orEmpty().map { it.nameWithoutExtension } - return (catalogIds + fileIds).toSet().filterNot { it in keepIds }.map { id -> - val file = File(directory, "$id.imuc") - deleteRetrySafe( - fileExists = file.exists(), - deleteFile = file::delete, - deleteCatalog = { repository.deleteImuChunk(id) }, - ) - }.all { it } - } - - suspend fun deleteDevice(deviceId: String): Boolean { - val chunks = runCatching { repository.imuChunksForDevice(deviceId) }.getOrElse { return false } - for (chunk in chunks) { - val file = file(chunk) - if (file.exists() && !file.delete()) return false - } - return runCatching { repository.deleteImuChunksFor(deviceId) }.isSuccess - } - - fun file(chunk: ImuChunkEntity) = File(context.filesDir, chunk.relativePath) - - private fun readSessionRows(sessionId: String, from: Long, to: Long): List> { - val source = File(context.filesDir, "ground-truth/realtime-imu-$sessionId.imus") - if (!source.isFile) return emptyList() - val rows = ArrayList>() - DataInputStream(source.inputStream().buffered()).use { input -> - while (true) { - try { - val count = input.readInt(); val rawSize = input.readInt(); val compressedLength = input.readInt() - if (count !in 1..BLOCK_FRAMES || rawSize !in 1..MAX_BLOCK_BYTES || - compressedLength !in 1..MAX_BLOCK_BYTES) break - val compressed = ByteArray(compressedLength); input.readFully(compressed) - val inflater = Inflater(); inflater.setInput(compressed) - val raw = ByteArray(rawSize); val inflated = inflater.inflate(raw); inflater.end() - if (inflated != rawSize) continue - DataInputStream(raw.inputStream()).use { block -> for (index in 0 until count) { - val ts = block.readLong(); block.readLong(); val length = block.readInt() - if (length !in 1..MAX_FRAME_BYTES) break - val frame = ByteArray(length); block.readFully(frame) - val decoded = Whoop5RawImu.decode(frame) ?: continue - if (ts == decoded.baseTs && ts in from..to) - Whoop5RawImu.rawColumns(frame)?.let { rows += ts to it } - }} - } catch (_: EOFException) { break } - } - } - return rows - } - - private fun sha256(file: File): String { - val digest = MessageDigest.getInstance("SHA-256") - file.inputStream().buffered().use { input -> - val buffer = ByteArray(64 * 1024) - while (true) { - val count = input.read(buffer) - if (count < 0) break - digest.update(buffer, 0, count) - } - } - return digest.digest().joinToString("") { "%02x".format(it) } - } - - companion object { - private fun chunkPrefix(sessionId: String) = "$sessionId--" - internal fun isOwnedChunk(sessionId: String, chunkId: String) = chunkId.startsWith(chunkPrefix(sessionId)) - internal fun boundedExisting(chunks: List, from: Long, to: Long) = - chunks.filter { it.startTs >= from && it.endTs <= to } - internal suspend fun deleteRetrySafe( - fileExists: Boolean, - deleteFile: () -> Boolean, - deleteCatalog: suspend () -> Unit, - ): Boolean { - if (fileExists && !deleteFile()) return false - return runCatching { deleteCatalog() }.isSuccess - } - const val FORMAT_VERSION = 1 - const val SAMPLE_RATE = 100 - const val MAX_EXPORT_SECONDS = 7 * 24 * 60 * 60L - const val MAX_FRAME_BYTES = 1024 * 1024 - const val BLOCK_FRAMES = 30 - const val MAX_BLOCK_BYTES = BLOCK_FRAMES * (MAX_FRAME_BYTES + 20) - } -} diff --git a/android/app/src/main/java/com/noop/testcentre/ImuSessionFileStore.kt b/android/app/src/main/java/com/noop/testcentre/ImuSessionFileStore.kt index a494841b34..5aba19470a 100644 --- a/android/app/src/main/java/com/noop/testcentre/ImuSessionFileStore.kt +++ b/android/app/src/main/java/com/noop/testcentre/ImuSessionFileStore.kt @@ -1,128 +1,197 @@ package com.noop.testcentre import android.content.Context +import com.noop.data.StreamPersistence import com.noop.protocol.Whoop5RawImu -import java.io.BufferedOutputStream +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.DataInputStream import java.io.DataOutputStream +import java.io.EOFException import java.io.File import java.io.FileOutputStream import java.util.zip.Deflater import java.util.zip.Inflater -/** Routes realtime and delayed history IMU frames into persisted session time windows. */ +/** Stores decoded 100 Hz IMU in fixed UTC half-hour segments owned by one capture session. */ class ImuSessionFileStore(private val context: Context) { data class Stats(val bytes: Long, val coveredSeconds: Int, val firstTs: Long?) + data class ExportSegment(val name: String, val data: ByteArray, val startTs: Long, val endTs: Long, + val sampleCount: Int) + private data class Record(val ts: Long, val receivedAtMs: Long, val columns: ShortArray) private val prefs = context.getSharedPreferences("imu-session-windows", Context.MODE_PRIVATE) - private val directory = File(context.filesDir, "ground-truth").apply { mkdirs() } + private val directory = File(context.filesDir, "raw-imu-sessions").apply { mkdirs() } fun start(id: String, deviceId: String, fromMs: Long) = synchronized(lock) { - prefs.edit().putStringSet("ids", prefs.getStringSet("ids", emptySet()).orEmpty() + id) - .putString("$id.device", deviceId).putLong("$id.from", fromMs / 1_000L).remove("$id.to").apply() + prefs.edit().putStringSet("ids", ids() + id).putString("$id.device", deviceId) + .putLong("$id.from", fromMs / 1_000L).remove("$id.to").apply() + sessionDir(id).mkdirs() } fun complete(id: String, toMs: Long) = synchronized(lock) { - flush(id) - if (id in prefs.getStringSet("ids", emptySet()).orEmpty()) - prefs.edit().putLong("$id.to", toMs / 1_000L).apply() + flushSession(id) + if (id in ids()) prefs.edit().putLong("$id.to", toMs / 1_000L).apply() } fun register(id: String, deviceId: String, fromMs: Long, toMs: Long) = synchronized(lock) { start(id, deviceId, fromMs); complete(id, toMs) } + /** Remove routing only. Files are removed separately so callers can keep deletion retryable. */ fun remove(id: String) = synchronized(lock) { - pending.remove(id) - prefs.edit().putStringSet("ids", prefs.getStringSet("ids", emptySet()).orEmpty() - id) - .remove("$id.device").remove("$id.from").remove("$id.to").apply() + pending.keys.filter { it.startsWith("$id/") }.forEach(pending::remove) + seen.keys.filter { it.startsWith(sessionDir(id).absolutePath) }.forEach(seen::remove) + prefs.edit().putStringSet("ids", ids() - id).remove("$id.device").remove("$id.from").remove("$id.to").apply() } - fun prepareForRead(id: String) = synchronized(lock) { flush(id) } + fun prepareForRead(id: String) = synchronized(lock) { flushSession(id) } + + fun deleteFiles(id: String): Boolean = synchronized(lock) { + flushSession(id) + val dir = sessionDir(id) + if (dir.listFiles().orEmpty().any { it.isDirectory || !it.delete() }) return@synchronized false + !dir.exists() || dir.delete() + } + + fun deleteDevice(deviceId: String): Boolean = synchronized(lock) { + val owned = ids().filter { prefs.getString("$it.device", null) == deviceId } + if (owned.any { !deleteFiles(it) }) return@synchronized false + owned.forEach(::remove) + true + } fun stats(id: String, from: Long, to: Long): Stats = synchronized(lock) { - val file = File(directory, "realtime-imu-$id.imus") - // Do not flush merely for UI telemetry: that would turn the intended 30-frame compression - // blocks into one-frame blocks. Pending raw bytes are a close live size estimate until flush. - val pendingBytes = pending[id].orEmpty().sumOf { it.frame.size.toLong() + 20L } - val inWindow = timestamps(file).filter { it in from..to } - Stats((file.takeIf(File::isFile)?.length() ?: 0L) + pendingBytes, - inWindow.size, inWindow.minOrNull()) - } - - /** - * Queue a wire frame for a 30-second compressed block. Delayed history may append older blocks later; - * strap timestamps, not file order, are authoritative. - */ + val records = readRecords(id, from, to, includePending = true) + val disk = segmentFiles(id).sumOf { it.length() } + val pendingBytes = pending.filterKeys { it.startsWith("$id/") }.values.flatten() + .sumOf { it.columns.size.toLong() * 2 + RECORD_HEADER_BYTES } + Stats(disk + pendingBytes, records.map { it.ts }.distinct().size, records.minOfOrNull { it.ts }) + } + fun append(deviceId: String, frame: ByteArray, receivedAtMs: Long = System.currentTimeMillis()): Int = synchronized(lock) { - val ts = Whoop5RawImu.decode(frame)?.baseTs ?: return 0 + val decoded = Whoop5RawImu.decode(frame) ?: return 0 + val columns = Whoop5RawImu.rawColumns(frame) ?: return 0 + val ts = decoded.baseTs var writes = 0 - for (id in prefs.getStringSet("ids", emptySet()).orEmpty()) { + for (id in ids()) { if (prefs.getString("$id.device", null) != deviceId) continue val from = prefs.getLong("$id.from", Long.MAX_VALUE) val to = if (prefs.contains("$id.to")) prefs.getLong("$id.to", Long.MIN_VALUE) else Long.MAX_VALUE if (ts !in from..to) continue - val file = File(directory, "realtime-imu-$id.imus") - val timestamps = timestamps(file) - if (ts in timestamps) continue - timestamps += ts - pending.getOrPut(id) { mutableListOf() } += Record(ts, receivedAtMs, frame.copyOf()) - if (pending[id]!!.size >= BLOCK_FRAMES) flush(id) + val bucket = bucketStart(ts); val file = segmentFile(id, bucket) + if (!timestamps(file).add(ts)) continue + val key = "$id/$bucket" + pending.getOrPut(key) { mutableListOf() } += Record(ts, receivedAtMs, columns) + if (pending[key]!!.size >= BLOCK_SECONDS) flushKey(key) writes++ } - return writes + writes } - private fun timestamps(file: File): MutableSet = seen.getOrPut(file.absolutePath) { - val result = mutableSetOf() - if (!file.exists()) return@getOrPut result - java.io.DataInputStream(file.inputStream().buffered()).use { input -> - while (input.available() >= 12) { + /** Exact selected interval, still in the canonical decoded .imus format. */ + fun exportSegments(id: String, from: Long, to: Long): List = synchronized(lock) { + flushSession(id) + readRecords(id, from, to, includePending = false).distinctBy { it.ts }.sortedBy { it.ts } + .groupBy { bucketStart(it.ts) }.toSortedMap().map { (bucket, records) -> + ExportSegment("imu-${utcName(bucket)}.imus", encodeFile(bucket, records), records.first().ts, + records.last().ts, records.size * SAMPLE_RATE) + } + } + + private fun readRecords(id: String, from: Long, to: Long, includePending: Boolean): List { + val rows = segmentFiles(id).flatMap { decodeFile(it.readBytes()) }.filter { it.ts in from..to }.toMutableList() + if (includePending) rows += pending.filterKeys { it.startsWith("$id/") }.values.flatten().filter { it.ts in from..to } + return rows + } + + private fun encodeFile(bucket: Long, records: List): ByteArray { + val output = ByteArrayOutputStream() + DataOutputStream(output).use { out -> + writeHeader(out, bucket); records.chunked(BLOCK_SECONDS).forEach { writeBlock(out, it) } + } + return output.toByteArray() + } + + private fun decodeFile(bytes: ByteArray): List { + if (bytes.size < FILE_HEADER_BYTES || !bytes.copyOfRange(0, MAGIC.size).contentEquals(MAGIC)) return emptyList() + val rows = mutableListOf() + DataInputStream(ByteArrayInputStream(bytes)).use { input -> + input.skipBytes(MAGIC.size); input.readLong() + if (input.readInt() != SAMPLE_RATE || input.readInt() != AXES) return emptyList() + while (true) try { val count = input.readInt(); val rawSize = input.readInt(); val compressedSize = input.readInt() - if (count !in 1..BLOCK_FRAMES || rawSize !in 1..MAX_BLOCK_BYTES || - compressedSize !in 1..MAX_BLOCK_BYTES || input.available() < compressedSize) break + if (count !in 1..BLOCK_SECONDS || rawSize !in 1..MAX_BLOCK_BYTES || compressedSize !in 1..MAX_BLOCK_BYTES) break val compressed = ByteArray(compressedSize); input.readFully(compressed) val raw = inflate(compressed, rawSize) ?: break - java.io.DataInputStream(raw.inputStream()).use { block -> - for (index in 0 until count) { - result += block.readLong(); block.readLong() - val length = block.readInt(); if (length !in 1..MAX_FRAME_BYTES) break - block.skipBytes(length) - } - } - } + var valid = true + DataInputStream(ByteArrayInputStream(raw)).use { block -> for (index in 0 until count) { + val ts = block.readLong(); val received = block.readLong(); val length = block.readInt() + if (length != PAYLOAD_BYTES) { valid = false; break } + val packed = ByteArray(length); block.readFully(packed) + rows += Record(ts, received, unpackColumns(packed)) + }} + if (!valid) break + } catch (_: EOFException) { break } } - result + return rows } - private fun flush(id: String) { - val records = pending.remove(id).orEmpty() - if (records.isEmpty()) return - val rawBytes = java.io.ByteArrayOutputStream() - DataOutputStream(rawBytes).use { out -> records.forEach { record -> - out.writeLong(record.ts); out.writeLong(record.receivedAtMs) - out.writeInt(record.frame.size); out.write(record.frame) - }} - val raw = rawBytes.toByteArray() - val deflater = Deflater(); deflater.setInput(raw); deflater.finish() - val compressed = ByteArray(raw.size + 128); val size = deflater.deflate(compressed); deflater.end() - DataOutputStream(BufferedOutputStream(FileOutputStream(File(directory, "realtime-imu-$id.imus"), true))).use { - it.writeInt(records.size); it.writeInt(raw.size); it.writeInt(size); it.write(compressed, 0, size) + private fun flushSession(id: String) = pending.keys.filter { it.startsWith("$id/") }.toList().forEach(::flushKey) + private fun flushKey(key: String) { + val records = pending.remove(key).orEmpty(); if (records.isEmpty()) return + val id = key.substringBefore('/'); val bucket = key.substringAfter('/').toLong() + val file = segmentFile(id, bucket); file.parentFile?.mkdirs(); val newFile = !file.exists() + DataOutputStream(FileOutputStream(file, true).buffered()).use { out -> + if (newFile) writeHeader(out, bucket) + writeBlock(out, records) } } - private fun inflate(compressed: ByteArray, size: Int): ByteArray? { - val inflater = Inflater(); inflater.setInput(compressed) - val raw = ByteArray(size); val written = runCatching { inflater.inflate(raw) }.getOrDefault(0); inflater.end() - return raw.takeIf { written == size } + private fun writeHeader(out: DataOutputStream, bucket: Long) { + out.write(MAGIC); out.writeLong(bucket); out.writeInt(SAMPLE_RATE); out.writeInt(AXES) + } + + private fun writeBlock(out: DataOutputStream, records: List) { + val rawBytes = ByteArrayOutputStream() + DataOutputStream(rawBytes).use { block -> records.forEach { record -> + val packed = StreamPersistence.packImuColumns(record.columns) + block.writeLong(record.ts); block.writeLong(record.receivedAtMs); block.writeInt(packed.size); block.write(packed) + }} + val raw = rawBytes.toByteArray(); val compressed = deflate(raw) + out.writeInt(records.size); out.writeInt(raw.size); out.writeInt(compressed.size); out.write(compressed) } - private data class Record(val ts: Long, val receivedAtMs: Long, val frame: ByteArray) + private fun timestamps(file: File): MutableSet = seen.getOrPut(file.absolutePath) { + decodeFile(file.takeIf(File::isFile)?.readBytes() ?: byteArrayOf()).mapTo(mutableSetOf()) { it.ts } + } + private fun ids() = prefs.getStringSet("ids", emptySet()).orEmpty() + private fun sessionDir(id: String) = File(directory, id) + private fun segmentFiles(id: String) = sessionDir(id).listFiles { f -> f.isFile && f.extension == "imus" } + .orEmpty().sortedBy { it.name } + private fun segmentFile(id: String, bucket: Long) = File(sessionDir(id), "imu-${utcName(bucket)}.imus") + + private fun deflate(raw: ByteArray): ByteArray { + val d = Deflater(); d.setInput(raw); d.finish(); val out = ByteArray(raw.size + 128) + val size = d.deflate(out); d.end(); return out.copyOf(size) + } + private fun inflate(compressed: ByteArray, size: Int): ByteArray? { + val i = Inflater(); i.setInput(compressed); val out = ByteArray(size) + val written = runCatching { i.inflate(out) }.getOrDefault(0); i.end(); return out.takeIf { written == size } + } + private fun unpackColumns(bytes: ByteArray) = ShortArray(bytes.size / 2) { index -> + ((bytes[index * 2].toInt() and 0xff) or (bytes[index * 2 + 1].toInt() shl 8)).toShort() + } companion object { - private val lock = Any() - private val seen = mutableMapOf>() + private val lock = Any(); private val seen = mutableMapOf>() private val pending = mutableMapOf>() - private const val BLOCK_FRAMES = 30 - private const val MAX_FRAME_BYTES = 1_048_576 - private const val MAX_BLOCK_BYTES = BLOCK_FRAMES * (MAX_FRAME_BYTES + 20) + private val MAGIC = "NOOPIMU2".toByteArray(Charsets.US_ASCII) + const val SAMPLE_RATE = 100; const val AXES = 6; const val BLOCK_SECONDS = 30 + const val SEGMENT_SECONDS = 30 * 60L; const val PAYLOAD_BYTES = SAMPLE_RATE * AXES * 2 + private const val RECORD_HEADER_BYTES = 20L; private const val FILE_HEADER_BYTES = 24 + private const val MAX_BLOCK_BYTES = BLOCK_SECONDS * (PAYLOAD_BYTES + 20) + internal fun bucketStart(ts: Long) = Math.floorDiv(ts, SEGMENT_SECONDS) * SEGMENT_SECONDS + internal fun utcName(ts: Long): String = java.time.format.DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss'Z'") + .withZone(java.time.ZoneOffset.UTC).format(java.time.Instant.ofEpochSecond(ts)) } } diff --git a/android/app/src/main/java/com/noop/ui/AppViewModel.kt b/android/app/src/main/java/com/noop/ui/AppViewModel.kt index 139ad1b832..b1b83e8477 100644 --- a/android/app/src/main/java/com/noop/ui/AppViewModel.kt +++ b/android/app/src/main/java/com/noop/ui/AppViewModel.kt @@ -255,7 +255,7 @@ class AppViewModel(app: Application) : AndroidViewModel(app) { /** Permanently delete all of a device's recorded data (its registry row is kept). */ suspend fun deletePairedDeviceData(id: String) { - if (com.noop.testcentre.ImuChunkStore(getApplication(), noopApp.repository).deleteDevice(id)) + if (com.noop.testcentre.ImuSessionFileStore(getApplication()).deleteDevice(id)) noopApp.deviceRegistry.deleteDeviceData(id) } @@ -263,7 +263,7 @@ class AppViewModel(app: Application) : AndroidViewModel(app) { * duplicate/stale strap disappears from the list entirely (an archived row could otherwise only be * re-activated or data-wiped, never purged — #1193). Twin of Swift `DeviceRegistry.forget`. */ suspend fun forgetPairedDevice(id: String) { - if (com.noop.testcentre.ImuChunkStore(getApplication(), noopApp.repository).deleteDevice(id)) + if (com.noop.testcentre.ImuSessionFileStore(getApplication()).deleteDevice(id)) noopApp.deviceRegistry.forget(id) } diff --git a/android/app/src/test/java/com/noop/analytics/RegistryDayOwnerSourceTest.kt b/android/app/src/test/java/com/noop/analytics/RegistryDayOwnerSourceTest.kt index 1003f4f928..78adb90f68 100644 --- a/android/app/src/test/java/com/noop/analytics/RegistryDayOwnerSourceTest.kt +++ b/android/app/src/test/java/com/noop/analytics/RegistryDayOwnerSourceTest.kt @@ -67,7 +67,6 @@ class RegistryDayOwnerSourceTest { override suspend fun deletePpgHrFor(deviceId: String) {} override suspend fun deletePpgWaveformFor(deviceId: String) {} override suspend fun deleteV18AuxFor(deviceId: String) {} - override suspend fun deleteImuChunkCatalogFor(deviceId: String) {} override suspend fun deleteEventsFor(deviceId: String) {} override suspend fun deleteBatteryFor(deviceId: String) {} override suspend fun deleteDailyMetricsFor(deviceId: String) {} @@ -98,7 +97,6 @@ class RegistryDayOwnerSourceTest { override suspend fun reKeyPpgHr(from: String, to: String) {} override suspend fun reKeyPpgWaveform(from: String, to: String) {} override suspend fun reKeyV18Aux(from: String, to: String) {} - override suspend fun reKeyImuChunks(from: String, to: String) {} override suspend fun reKeyEvents(from: String, to: String) {} override suspend fun reKeyBattery(from: String, to: String) {} override suspend fun reKeyDailyMetrics(from: String, to: String) {} diff --git a/android/app/src/test/java/com/noop/ble/SourceCoordinatorAdoptionTest.kt b/android/app/src/test/java/com/noop/ble/SourceCoordinatorAdoptionTest.kt index 81a8ece0cb..573cd4006b 100644 --- a/android/app/src/test/java/com/noop/ble/SourceCoordinatorAdoptionTest.kt +++ b/android/app/src/test/java/com/noop/ble/SourceCoordinatorAdoptionTest.kt @@ -82,7 +82,6 @@ class SourceCoordinatorAdoptionTest { override suspend fun deletePpgHrFor(deviceId: String) {} override suspend fun deletePpgWaveformFor(deviceId: String) {} override suspend fun deleteV18AuxFor(deviceId: String) {} - override suspend fun deleteImuChunkCatalogFor(deviceId: String) {} override suspend fun deleteEventsFor(deviceId: String) {} override suspend fun deleteBatteryFor(deviceId: String) {} override suspend fun deleteDailyMetricsFor(deviceId: String) {} @@ -115,7 +114,6 @@ class SourceCoordinatorAdoptionTest { override suspend fun reKeyPpgHr(from: String, to: String) {} override suspend fun reKeyPpgWaveform(from: String, to: String) {} override suspend fun reKeyV18Aux(from: String, to: String) {} - override suspend fun reKeyImuChunks(from: String, to: String) {} override suspend fun reKeyEvents(from: String, to: String) {} override suspend fun reKeyBattery(from: String, to: String) {} override suspend fun reKeyDailyMetrics(from: String, to: String) {} diff --git a/android/app/src/test/java/com/noop/data/DailySdnnMigrationTest.kt b/android/app/src/test/java/com/noop/data/DailySdnnMigrationTest.kt index f7dd1b1823..3cea8f4609 100644 --- a/android/app/src/test/java/com/noop/data/DailySdnnMigrationTest.kt +++ b/android/app/src/test/java/com/noop/data/DailySdnnMigrationTest.kt @@ -22,7 +22,7 @@ class DailySdnnMigrationTest { fun migrationAndEntityPreserveOldRowsAsNull() { assertEquals(31, WhoopDatabase.MIGRATION_31_32.startVersion) assertEquals(32, WhoopDatabase.MIGRATION_31_32.endVersion) - assertEquals(33, WhoopDatabase.SCHEMA_VERSION) + assertEquals(34, WhoopDatabase.SCHEMA_VERSION) val old = DailyMetric(deviceId = "my-whoop", day = "2026-08-22", avgHrv = 44.0) assertNull(old.avgSdnn) assertEquals(88.4, old.copy(avgSdnn = 88.4).avgSdnn!!, 0.0) diff --git a/android/app/src/test/java/com/noop/data/DeviceRegistryTest.kt b/android/app/src/test/java/com/noop/data/DeviceRegistryTest.kt index ba0ee9d6dd..6c747b8520 100644 --- a/android/app/src/test/java/com/noop/data/DeviceRegistryTest.kt +++ b/android/app/src/test/java/com/noop/data/DeviceRegistryTest.kt @@ -98,7 +98,6 @@ class DeviceRegistryTest { override suspend fun deletePpgHrFor(deviceId: String) { deletedTables += "ppgHrSample" to deviceId } override suspend fun deletePpgWaveformFor(deviceId: String) { deletedTables += "ppgWaveformSample" to deviceId } override suspend fun deleteV18AuxFor(deviceId: String) { deletedTables += "v18AuxSample" to deviceId } - override suspend fun deleteImuChunkCatalogFor(deviceId: String) { deletedTables += "imuChunk" to deviceId } override suspend fun deleteEventsFor(deviceId: String) { deletedTables += "event" to deviceId } override suspend fun deleteBatteryFor(deviceId: String) { deletedTables += "battery" to deviceId } override suspend fun deleteDailyMetricsFor(deviceId: String) { deletedTables += "dailyMetric" to deviceId } @@ -135,7 +134,6 @@ class DeviceRegistryTest { override suspend fun reKeyPpgHr(from: String, to: String) {} override suspend fun reKeyPpgWaveform(from: String, to: String) {} override suspend fun reKeyV18Aux(from: String, to: String) {} - override suspend fun reKeyImuChunks(from: String, to: String) {} override suspend fun reKeyEvents(from: String, to: String) {} override suspend fun reKeyBattery(from: String, to: String) {} override suspend fun reKeyDailyMetrics(from: String, to: String) {} @@ -312,7 +310,7 @@ class DeviceRegistryTest { // were missing, leaving raw sleep-state, lab markers, live sessions and dismissed markers behind. val expectedTables = setOf( "hrSample", "rrInterval", "spo2Sample", "skinTempSample", "respSample", "gravitySample", - "stepSample", "ppgHrSample", "ppgWaveformSample", "imuChunk", "v18AuxSample", + "stepSample", "ppgHrSample", "ppgWaveformSample", "v18AuxSample", "event", "battery", "dailyMetric", "sleepSession", "journal", "workout", "appleDaily", "metricSeries", "dayOwnership", "scoreInputProvenance", diff --git a/android/app/src/test/java/com/noop/data/RawImuMigrationTest.kt b/android/app/src/test/java/com/noop/data/RawImuMigrationTest.kt index 1fd4603e4f..87e4d9f7f6 100644 --- a/android/app/src/test/java/com/noop/data/RawImuMigrationTest.kt +++ b/android/app/src/test/java/com/noop/data/RawImuMigrationTest.kt @@ -53,8 +53,8 @@ class RawImuMigrationTest { } @Test fun obsoleteEmptyTable_isDroppedAt35() { - assertEquals(34, WhoopDatabase.MIGRATION_34_35.startVersion) - assertEquals(35, WhoopDatabase.MIGRATION_34_35.endVersion) + assertEquals(33, WhoopDatabase.MIGRATION_33_34.startVersion) + assertEquals(34, WhoopDatabase.MIGRATION_33_34.endVersion) } // MARK: - Packed-BLOB encoding (byte-identical i16 LE) diff --git a/android/app/src/test/java/com/noop/testcentre/RawCaptureExportContractTest.kt b/android/app/src/test/java/com/noop/testcentre/RawCaptureExportContractTest.kt index fe87a5b6b4..39d1e63762 100644 --- a/android/app/src/test/java/com/noop/testcentre/RawCaptureExportContractTest.kt +++ b/android/app/src/test/java/com/noop/testcentre/RawCaptureExportContractTest.kt @@ -1,20 +1,12 @@ package com.noop.testcentre -import com.noop.data.ImuChunkEntity import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test import java.io.File -import kotlinx.coroutines.runBlocking -/** Small source-seam guards for the raw-capture ownership and bounded-export contract. */ class RawCaptureExportContractTest { - private fun chunk(id: String, from: Long, to: Long) = ImuChunkEntity( - id, "strap", from, to, ((to - from) + 1).toInt() * 100, 100, 1, "zip-deflate", - "imu-chunks/$id.imuc", 1, "sha", 1, Long.MAX_VALUE, - ) - private fun source(name: String): String { var root = File(System.getProperty("user.dir") ?: ".").canonicalFile repeat(4) { @@ -25,88 +17,42 @@ class RawCaptureExportContractTest { error("repository root not found") } + @Test fun halfHourBucketsAreStableUtcEpochBuckets() { + assertEquals(0, ImuSessionFileStore.bucketStart(1_799)) + assertEquals(1_800, ImuSessionFileStore.bucketStart(1_800)) + assertEquals(1_800, ImuSessionFileStore.bucketStart(3_599)) + assertEquals("19700101T003000Z", ImuSessionFileStore.utcName(1_800)) + } + + @Test fun collectorExportsCanonicalImusWithoutDerivedImuc() { + val collector = source("GroundTruthCollector.kt") + val store = source("ImuSessionFileStore.kt") + assertTrue(collector.contains("exportSegments(id, sensorFrom, sensorTo)")) + assertTrue(collector.contains("ZipEntry(\"imu/\${segment.name}\")")) + assertTrue(store.contains("Whoop5RawImu.rawColumns(frame)")) + assertTrue(store.contains("SEGMENT_SECONDS = 30 * 60L")) + assertFalse(collector.contains(".imuc")) + assertFalse(store.contains("frame.copyOf()")) + } + @Test fun editedWindowOwnsPublicEventsAndImuBounds() { val collector = source("GroundTruthCollector.kt") assertTrue(collector.contains("publicEvents(events, summary.startedAtMs, endMs, deviceId)")) assertTrue(collector.contains("summary.markers.filter { it.atMs in summary.startedAtMs..endMs }")) assertTrue(collector.contains("val sensorFrom = ceilSecond(summary.startedAtMs)")) assertTrue(collector.contains("val sensorTo = endMs / 1_000L - 1L")) - assertTrue(collector.contains("val coverageFrom = sensorFrom")) - assertFalse(collector.contains("maxOf(fullFrom, pinnedChunks.minOfOrNull")) - assertTrue(collector.contains("val sensorAvailable = pinnedChunks.isNotEmpty() ||")) - assertFalse(collector.contains("put(\"sensor_export_available\", deviceId != null)")) + assertTrue(collector.contains("val sensorAvailable = imuSegments.isNotEmpty() ||")) } @Test fun sessionPayloadDeletionKeepsDiscoveryFileUntilLast() { val collector = source("GroundTruthCollector.kt") - assertTrue(collector.contains("val payloadFiles = listOf(")) - assertTrue(collector.contains("if (!payloadFiles.all")) + assertTrue(collector.contains("if (!imuStore.deleteFiles(sessionId)) return false")) assertTrue(collector.contains("if (eventFile(sessionId).exists() && !eventFile(sessionId).delete()) return false")) } @Test fun rawCollectorHasNoStepAlgorithmExport() { val collector = source("GroundTruthCollector.kt") assertFalse(collector.contains("algorithm-signals.csv")) - assertFalse(collector.contains("writeAlgorithmSignalsCsv")) assertFalse(collector.contains("stepSamples(")) } - - @Test fun chunksAreSessionOwnedAndLiveSourceSurvivesExport() { - val chunks = source("ImuChunkStore.kt") - val collector = source("GroundTruthCollector.kt") - assertTrue(chunks.contains("chunkPrefix(sessionId)")) - assertTrue(chunks.contains("put(\"axes\", JSONArray(")) - assertTrue(chunks.contains("suspend fun deleteOwned(")) - assertTrue(collector.contains(".deleteOwned(")) - assertFalse(collector.contains("if (imuComplete) {\n ImuSessionFileStore(context).remove(id)")) - } - - @Test fun emptySelectedSourceNeverReusesAnOverlappingBroadArchive() { - val broad = chunk("first--broad", 100, 199) - assertEquals(emptyList(), ImuChunkStore.boundedExisting(listOf(broad), 120, 140)) - } - - @Test fun ownershipPrefixDoesNotClaimAnOverlappingSession() { - assertTrue(ImuChunkStore.isOwnedChunk("first", "first--archive")) - assertFalse(ImuChunkStore.isOwnedChunk("first", "first-extra--archive")) - } - - @Test fun unrelatedArchivesAreNotClaimedAsSessionOwned() { - assertFalse(ImuChunkStore.isOwnedChunk("first", "550e8400-e29b-41d4-a716-446655440000")) - } - - @Test fun reuseIsStrictlyContainedBySelectedRange() { - val inside = chunk("session--inside", 120, 140) - val broad = chunk("session--broad", 100, 199) - assertEquals(listOf(inside), ImuChunkStore.boundedExisting(listOf(inside, broad), 120, 140)) - } - - @Test fun archiveDeletionFailureLeavesCatalogUntouchedForRetry() = runBlocking { - val operations = mutableListOf() - val deleted = ImuChunkStore.deleteRetrySafe( - fileExists = true, - deleteFile = { operations += "file"; false }, - deleteCatalog = { operations += "catalog" }, - ) - assertFalse(deleted) - assertEquals(listOf("file"), operations) - } - - @Test fun catalogDeletionRunsOnlyAfterArchiveDeletion() = runBlocking { - val operations = mutableListOf() - val deleted = ImuChunkStore.deleteRetrySafe( - fileExists = true, - deleteFile = { operations += "file"; true }, - deleteCatalog = { operations += "catalog"; error("database unavailable") }, - ) - assertFalse(deleted) - assertEquals(listOf("file", "catalog"), operations) - } - - @Test fun deviceDeletionOwnsExternalFilesBeforeDatabaseWipe() { - val chunks = source("ImuChunkStore.kt") - assertTrue(chunks.contains("suspend fun deleteDevice(deviceId: String)")) - assertTrue(chunks.indexOf("if (file.exists() && !file.delete()) return false") < - chunks.indexOf("repository.deleteImuChunksFor(deviceId)")) - } } diff --git a/android/app/src/test/java/com/noop/testcentre/RawDataCollectorParityTest.kt b/android/app/src/test/java/com/noop/testcentre/RawDataCollectorParityTest.kt index 5c222e0ed2..0fe1afac2e 100644 --- a/android/app/src/test/java/com/noop/testcentre/RawDataCollectorParityTest.kt +++ b/android/app/src/test/java/com/noop/testcentre/RawDataCollectorParityTest.kt @@ -25,7 +25,7 @@ class RawDataCollectorParityTest { val root = repoRoot() val source = listOf( "android/app/src/main/java/com/noop/testcentre/GroundTruthCollector.kt", - "android/app/src/main/java/com/noop/testcentre/ImuChunkStore.kt", + "android/app/src/main/java/com/noop/testcentre/ImuSessionFileStore.kt", "android/app/src/main/java/com/noop/testcentre/ImuSessionFileStore.kt", "android/app/src/main/java/com/noop/data/WhoopDatabase.kt", "android/app/src/main/java/com/noop/data/Entities.kt", diff --git a/android/app/src/test/resources/raw_data_collector_parity.json b/android/app/src/test/resources/raw_data_collector_parity.json index e13709f60a..40901f9dc2 100644 --- a/android/app/src/test/resources/raw_data_collector_parity.json +++ b/android/app/src/test/resources/raw_data_collector_parity.json @@ -34,16 +34,16 @@ "kotlin": ["fun setSessionRange(", "ground_truth_edit_start"] }, "file_only_imu_storage": { - "swift": ["v41-drop-raw-imu-sample"], - "kotlin": ["MIGRATION_34_35", "DROP TABLE IF EXISTS `rawImuSample`"] + "swift": ["v40-drop-raw-imu-sample"], + "kotlin": ["MIGRATION_33_34", "DROP TABLE IF EXISTS `rawImuSample`"] }, "session_bound_imu_file": { - "swift": ["ImuSessionFileStore", "realtime-imu-\\(id).imus"], - "kotlin": ["ImuSessionFileStore", "realtime-imu-$id.imus"] + "swift": ["ImuSessionFileStore", "segmentSeconds: Int64 = 30 * 60", "NOOPIMU2"], + "kotlin": ["ImuSessionFileStore", "SEGMENT_SECONDS = 30 * 60L", "NOOPIMU2"] }, - "durable_imu_chunk_catalog": { - "swift": ["ImuChunkArchiveStore", "ImuChunkMeta"], - "kotlin": ["ImuChunkStore", "ImuChunkEntity"] + "decoded_segment_export": { + "swift": ["exportSegments", "imu/\\(segment.name)"], + "kotlin": ["exportSegments", "imu/${segment.name}"] }, "explicit_imu_coverage": { "swift": ["imu-coverage.json", "private static func covers"], @@ -62,8 +62,8 @@ "kotlin": ["LaunchedEffect(state.active, state.sessionId, live.connected)", "startGroundTruthImuCapture"] }, "raw_data_removed_with_session": { - "swift": ["deleteRawBatches(deviceId:", "capturedAt >= ? AND capturedAt <= ?"], - "kotlin": ["realtime-imu-$sessionId.imus", "noop-5mg-raw-$sessionId.zip"] + "swift": ["deleteFiles(sessionId", "capturedAt >= ? AND capturedAt <= ?"], + "kotlin": ["deleteFiles(sessionId)", "noop-5mg-raw-$sessionId.zip"] } } } diff --git a/android/app/src/test/resources/schema_oracle.json b/android/app/src/test/resources/schema_oracle.json index 02b401e3e4..06a448d944 100644 --- a/android/app/src/test/resources/schema_oracle.json +++ b/android/app/src/test/resources/schema_oracle.json @@ -1253,29 +1253,6 @@ ], "indices": [] }, - "imuChunk": { - "platform": "both", - "columns": [ - { "name": "id", "affinity": "TEXT", "notNull": false, "default": null, - "android": { "notNull": true, "divergence": "sqlite-text-pk-nullable" } }, - { "name": "deviceId", "affinity": "TEXT", "notNull": true, "default": null }, - { "name": "startTs", "affinity": "INTEGER", "notNull": true, "default": null }, - { "name": "endTs", "affinity": "INTEGER", "notNull": true, "default": null }, - { "name": "sampleCount", "affinity": "INTEGER", "notNull": true, "default": null }, - { "name": "sampleRate", "affinity": "INTEGER", "notNull": true, "default": null }, - { "name": "formatVersion", "affinity": "INTEGER", "notNull": true, "default": null }, - { "name": "codec", "affinity": "TEXT", "notNull": true, "default": null }, - { "name": "relativePath", "affinity": "TEXT", "notNull": true, "default": null }, - { "name": "byteSize", "affinity": "INTEGER", "notNull": true, "default": null }, - { "name": "sha256", "affinity": "TEXT", "notNull": true, "default": null }, - { "name": "createdAt", "affinity": "INTEGER", "notNull": true, "default": null }, - { "name": "pinnedUntil", "affinity": "INTEGER", "notNull": false, "default": null } - ], - "primaryKey": ["id"], - "indices": [ - { "name": "index_imuChunk_deviceId_startTs_endTs", "columns": ["deviceId", "startTs", "endTs"], "unique": false } - ] - }, "respSample": { "platform": "both", "columns": [ diff --git a/docs/DATA_MODEL.md b/docs/DATA_MODEL.md index 18a08e9fbf..43b5d2b134 100644 --- a/docs/DATA_MODEL.md +++ b/docs/DATA_MODEL.md @@ -297,30 +297,10 @@ inserts, identical range-read shape). **Primary key:** `(deviceId, ts)`. -This is the low-rate decoded vector used by normal analytics, not the 100 Hz six-axis capture. High-rate -IMU payloads deliberately remain outside the per-sample SQLite schema: SQLite stores only searchable -`imuChunk` metadata, while compressed `.imus`/`.imuc` files hold the original frames and materialised -sample columns. See [5/MG raw data capture](RAW_DATA_CAPTURE.md#storage-design). - -#### `imuChunk` — file-backed high-rate IMU catalog - -| Column | Type | Notes | -| --- | --- | --- | -| `id` | TEXT NOT NULL | Primary key and `.imuc` filename stem. | -| `deviceId` | TEXT NOT NULL | Owning strap. | -| `startTs` / `endTs` | INTEGER NOT NULL | Inclusive strap Unix-second coverage. | -| `sampleCount` | INTEGER NOT NULL | Decoded axis-sample count represented by the chunk. | -| `sampleRate` | INTEGER NOT NULL | Currently 100 Hz. | -| `formatVersion` | INTEGER NOT NULL | `NOOPIMU` container version. | -| `codec` | TEXT NOT NULL | Currently `zip-deflate`. | -| `relativePath` | TEXT NOT NULL | App-private path to the payload outside SQLite. | -| `byteSize` | INTEGER NOT NULL | Stored file length. | -| `sha256` | TEXT NOT NULL | Payload integrity/provenance hash. | -| `createdAt` | INTEGER NOT NULL | Unix seconds. | -| `pinnedUntil` | INTEGER | Retention marker; permanent session chunks use the platform maximum. | - -The index is `(deviceId, startTs, endTs)`. A catalog row without its file is not coverage; readers -always verify that `relativePath` exists. +This is the low-rate decoded vector used by normal analytics, not the 100 Hz six-axis capture. +High-rate IMU deliberately remains outside SQLite. Capture sessions own canonical, decoded `.imus` +files split into fixed 30-minute UTC segments with independently compressed 30-second blocks. See +[5/MG raw data capture](RAW_DATA_CAPTURE.md#storage-design). --- diff --git a/docs/RAW_DATA_CAPTURE.md b/docs/RAW_DATA_CAPTURE.md index 1bd21a760e..ff0c55cf5e 100644 --- a/docs/RAW_DATA_CAPTURE.md +++ b/docs/RAW_DATA_CAPTURE.md @@ -73,41 +73,33 @@ Instead, storage is split by responsibility: | Data | Storage | Lifetime | |---|---|---| | Session window, comments, and markers | Small app-private JSON/JSONL metadata | Until the user deletes the session | -| Incoming raw IMU frames | Append-only `.imus` session file | Until the user explicitly deletes the session | -| Searchable chunk metadata | SQLite (`imuChunk`) | Index/provenance only; no individual samples | -| Immutable analysis/export payload | Compressed `.imuc` file | Owned by the retained session | +| Decoded 100 Hz IMU | App-private `.imus` segment files | Until the user explicitly deletes the session | -The `.imus` writer batches up to 30 one-second frames into an independently zlib-compressed block. -Blocks are appended; a crash can at worst leave a truncated final block, while earlier blocks remain -readable. Pending blocks are flushed when a session stops or before it is read. Each record keeps the -strap timestamp, phone receive time, and original frame bytes. This preserves evidence for a future -decoder instead of committing early to today's interpretation. +Each session owns fixed 30-minute UTC `.imus` segments. Late history data is routed by its strap +timestamp, so reconnecting after a workout does not put old samples into the current file. A segment +contains decoded, column-major, little-endian signed 16-bit accelerometer and gyroscope samples plus +the strap timestamp and phone receive time. Up to 30 one-second frames form an independently +zlib-compressed append block. A truncated final block after a crash does not make earlier blocks +unreadable. -An `.imuc` file is a ZIP/deflate container with: - -- `manifest.json`: `NOOPIMU`, format version, 100 Hz sample rate, axes, timestamp source, coverage, - ordering contract, and row count; -- `samples.bin`: repeated big-endian timestamp and byte-length headers followed by column-major, - little-endian signed 16-bit axis samples. - -The format and archive semantics are mirrored on Android and Apple. SHA-256, byte size, codec, -coverage, and sample count are stored in `imuChunk`; the payload itself remains outside SQLite. -Chunks are session-owned and materialised for the selected interval. Keeping the `.imus` source until -explicit deletion makes repeated exports safe when that interval is edited; deleting a session also -removes its source and owned chunks. +There is no high-rate SQLite table or separate derived archive format. The time-addressable `.imus` +segments are both the retained source and the canonical analysis format. Readers must still sort and +deduplicate by strap timestamp because history frames can arrive late. Exports clip the first and last +segment to the selected interval without changing the retained source, so the same session can be +exported repeatedly after its window is edited. ## Session export The shareable ZIP contains session provenance and all available sensor material for its selected interval. Android currently includes `meta.json`, event JSONL and CSV files, decoded one-second -signals, raw sensor CSV, and `imu/*.imuc`. Apple exports equivalent session metadata/events, -`history-sensors.csv`, `imu-coverage.json`, and IMU chunks through its platform export path. Inspect +signals, raw sensor CSV, and `imu/*.imus`. Apple exports equivalent session metadata/events, +`history-sensors.csv`, `imu-coverage.json`, and IMU segments through its platform export path. Inspect `meta.json` plus the platform's IMU coverage object first: - `started_at_ms` / `ended_at_ms` are the selected analysis interval; - `captured_started_at_ms` / `captured_ended_at_ms`, when present, preserve the physical recording interval even after the selected interval is edited; -- Android's `imu_100hz_coverage` identifies the chunks actually present and `imu_100hz_complete` is +- Android's `imu_100hz_coverage` identifies the segments actually present and `imu_100hz_complete` is the conservative coverage result; Apple carries the same facts in `imu-coverage.json`; Exports stay local until the user invokes the operating system's share sheet. Raw captures are not @@ -124,7 +116,7 @@ suggested archive name is `noop-5mg-raw-.zip`. jobs. The former preserves broad offload/debug frames; the latter owns a time-bounded, exportable IMU dataset. They may observe the same wire frame, but the session store deduplicates it by strap time. - The retired opt-in `rawImuSample` path existed in earlier builds but did not produce usable stored IMU - data in deployment. Session capture therefore has one source of truth: its file-backed chunks. + data in deployment. Session capture therefore has one source of truth: its file-backed segments. - Do not use arrival order as time, do not fill gaps silently, and do not claim 100 Hz coverage from packet count alone. @@ -136,5 +128,5 @@ suggested archive name is `noop-5mg-raw-.zip`. | Session metadata | `RawDataSessionStore` | `GroundTruthCollector` | | Live command path | `BLEManager.startGroundTruthRawCapture` | `WhoopBleClient.startGroundTruthImuCapture` | | Append/recovery window | `ImuSessionFileStore` | `ImuSessionFileStore` | -| Immutable chunk export | `ImuChunkArchiveStore` | `ImuChunkStore` | +| Canonical segmented storage/export | `ImuSessionFileStore` | `ImuSessionFileStore` | | Raw decoder | `Whoop5RawImu` in `WhoopProtocol` | `Whoop5RawImu` in `com.noop.protocol` | From abae701326861bd439bfbe081e17f17bcaad9d92 Mon Sep 17 00:00:00 2001 From: Bernd Helm Date: Fri, 28 Aug 2026 00:57:20 +0200 Subject: [PATCH 32/45] Keep staging database upgrades monotonic --- .../WhoopStoreTests/Resources/schema_oracle.json | 2 +- .../app/src/main/java/com/noop/data/WhoopDatabase.kt | 12 +++++++++--- .../java/com/noop/data/DailySdnnMigrationTest.kt | 2 +- .../test/java/com/noop/data/RawImuMigrationTest.kt | 2 ++ android/app/src/test/resources/schema_oracle.json | 2 +- 5 files changed, 14 insertions(+), 6 deletions(-) diff --git a/Packages/WhoopStore/Tests/WhoopStoreTests/Resources/schema_oracle.json b/Packages/WhoopStore/Tests/WhoopStoreTests/Resources/schema_oracle.json index 06a448d944..d3f0623325 100644 --- a/Packages/WhoopStore/Tests/WhoopStoreTests/Resources/schema_oracle.json +++ b/Packages/WhoopStore/Tests/WhoopStoreTests/Resources/schema_oracle.json @@ -1,6 +1,6 @@ { "_readme": "SHARED Room<->GRDB SCHEMA ORACLE (#775). Two byte-identical copies: Packages/WhoopStore/Tests/WhoopStoreTests/Resources/schema_oracle.json and android/app/src/test/resources/schema_oracle.json. SchemaOracleTests.swift compares GRDB's PRAGMA table_info/index_list against it; SchemaOracleTest.kt compares Room's exported schema JSON against it. `columns` is the iOS/GRDB shape in GRDB column order (macOS is the reference implementation); every way Android differs is spelled out in an `android` / `iosAbsent` / `androidColumnOrder` override naming a key in `divergenceReasons`. Adding a column, reordering one, or changing a type/nullability on one platform only fails both suites until it is either fixed or written down here.", - "roomVersion": 34, + "roomVersion": 35, "grdbMigrations": [ "v1", "v2", diff --git a/android/app/src/main/java/com/noop/data/WhoopDatabase.kt b/android/app/src/main/java/com/noop/data/WhoopDatabase.kt index f5256a2851..e0f8296547 100644 --- a/android/app/src/main/java/com/noop/data/WhoopDatabase.kt +++ b/android/app/src/main/java/com/noop/data/WhoopDatabase.kt @@ -52,7 +52,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase V18AuxSampleEntity::class, AppleStepHour::class, ], - version = 34, + version = 35, // #775: ON so Room's KSP processor writes the generated schema (every table's exact `CREATE TABLE`, // columns in declaration order with affinity/NOT NULL/default, PK and indices) as JSON. That export // is what lets a plain JVM test — no device, no Robolectric — read Android's REAL schema and compare @@ -69,7 +69,7 @@ abstract class WhoopDatabase : RoomDatabase() { const val DB_NAME = "noop_whoop.db" /** Room schema version — MUST equal the `@Database(version = …)` above. Surfaced in the backup * manifest (#1410) so an export states its schema. Bump both together on a migration. */ - const val SCHEMA_VERSION = 34 + const val SCHEMA_VERSION = 35 @Volatile private var instance: WhoopDatabase? = null @@ -900,6 +900,12 @@ abstract class WhoopDatabase : RoomDatabase() { override fun migrate(db: SupportSQLiteDatabase) { db.execSQL("DROP TABLE IF EXISTS `rawImuSample`") } } + // Schema 35 already shipped in staging builds with the now-retired, always-empty IMU catalog. + // Keep the version monotonic so those installations upgrade instead of crashing on downgrade. + internal val MIGRATION_34_35 = object : Migration(34, 35) { + override fun migrate(db: SupportSQLiteDatabase) { db.execSQL("DROP TABLE IF EXISTS `imuChunk`") } + } + /** * Every migration the builder registers, as a VALUE rather than an argument list. * @@ -925,7 +931,7 @@ abstract class WhoopDatabase : RoomDatabase() { MIGRATION_18_19, MIGRATION_19_20, MIGRATION_20_21, MIGRATION_21_22, MIGRATION_22_23, MIGRATION_23_24, MIGRATION_24_25, MIGRATION_25_26, MIGRATION_26_27, MIGRATION_27_28, MIGRATION_28_29, MIGRATION_29_30, - MIGRATION_30_31, MIGRATION_31_32, MIGRATION_32_33, MIGRATION_33_34, + MIGRATION_30_31, MIGRATION_31_32, MIGRATION_32_33, MIGRATION_33_34, MIGRATION_34_35, ) diff --git a/android/app/src/test/java/com/noop/data/DailySdnnMigrationTest.kt b/android/app/src/test/java/com/noop/data/DailySdnnMigrationTest.kt index 3cea8f4609..83517c15c6 100644 --- a/android/app/src/test/java/com/noop/data/DailySdnnMigrationTest.kt +++ b/android/app/src/test/java/com/noop/data/DailySdnnMigrationTest.kt @@ -22,7 +22,7 @@ class DailySdnnMigrationTest { fun migrationAndEntityPreserveOldRowsAsNull() { assertEquals(31, WhoopDatabase.MIGRATION_31_32.startVersion) assertEquals(32, WhoopDatabase.MIGRATION_31_32.endVersion) - assertEquals(34, WhoopDatabase.SCHEMA_VERSION) + assertEquals(35, WhoopDatabase.SCHEMA_VERSION) val old = DailyMetric(deviceId = "my-whoop", day = "2026-08-22", avgHrv = 44.0) assertNull(old.avgSdnn) assertEquals(88.4, old.copy(avgSdnn = 88.4).avgSdnn!!, 0.0) diff --git a/android/app/src/test/java/com/noop/data/RawImuMigrationTest.kt b/android/app/src/test/java/com/noop/data/RawImuMigrationTest.kt index 87e4d9f7f6..52717ca43e 100644 --- a/android/app/src/test/java/com/noop/data/RawImuMigrationTest.kt +++ b/android/app/src/test/java/com/noop/data/RawImuMigrationTest.kt @@ -55,6 +55,8 @@ class RawImuMigrationTest { @Test fun obsoleteEmptyTable_isDroppedAt35() { assertEquals(33, WhoopDatabase.MIGRATION_33_34.startVersion) assertEquals(34, WhoopDatabase.MIGRATION_33_34.endVersion) + assertEquals(34, WhoopDatabase.MIGRATION_34_35.startVersion) + assertEquals(35, WhoopDatabase.MIGRATION_34_35.endVersion) } // MARK: - Packed-BLOB encoding (byte-identical i16 LE) diff --git a/android/app/src/test/resources/schema_oracle.json b/android/app/src/test/resources/schema_oracle.json index 06a448d944..d3f0623325 100644 --- a/android/app/src/test/resources/schema_oracle.json +++ b/android/app/src/test/resources/schema_oracle.json @@ -1,6 +1,6 @@ { "_readme": "SHARED Room<->GRDB SCHEMA ORACLE (#775). Two byte-identical copies: Packages/WhoopStore/Tests/WhoopStoreTests/Resources/schema_oracle.json and android/app/src/test/resources/schema_oracle.json. SchemaOracleTests.swift compares GRDB's PRAGMA table_info/index_list against it; SchemaOracleTest.kt compares Room's exported schema JSON against it. `columns` is the iOS/GRDB shape in GRDB column order (macOS is the reference implementation); every way Android differs is spelled out in an `android` / `iosAbsent` / `androidColumnOrder` override naming a key in `divergenceReasons`. Adding a column, reordering one, or changing a type/nullability on one platform only fails both suites until it is either fixed or written down here.", - "roomVersion": 34, + "roomVersion": 35, "grdbMigrations": [ "v1", "v2", From e1dcafd0b49456042040379598716583bf3f5ba3 Mon Sep 17 00:00:00 2001 From: Bernd Helm Date: Fri, 28 Aug 2026 02:43:27 +0200 Subject: [PATCH 33/45] Keep preview migration out of upstream feature --- .../WhoopStoreTests/Resources/schema_oracle.json | 2 +- .../app/src/main/java/com/noop/data/WhoopDatabase.kt | 12 +++--------- .../java/com/noop/data/DailySdnnMigrationTest.kt | 2 +- .../test/java/com/noop/data/RawImuMigrationTest.kt | 2 -- android/app/src/test/resources/schema_oracle.json | 2 +- 5 files changed, 6 insertions(+), 14 deletions(-) diff --git a/Packages/WhoopStore/Tests/WhoopStoreTests/Resources/schema_oracle.json b/Packages/WhoopStore/Tests/WhoopStoreTests/Resources/schema_oracle.json index d3f0623325..06a448d944 100644 --- a/Packages/WhoopStore/Tests/WhoopStoreTests/Resources/schema_oracle.json +++ b/Packages/WhoopStore/Tests/WhoopStoreTests/Resources/schema_oracle.json @@ -1,6 +1,6 @@ { "_readme": "SHARED Room<->GRDB SCHEMA ORACLE (#775). Two byte-identical copies: Packages/WhoopStore/Tests/WhoopStoreTests/Resources/schema_oracle.json and android/app/src/test/resources/schema_oracle.json. SchemaOracleTests.swift compares GRDB's PRAGMA table_info/index_list against it; SchemaOracleTest.kt compares Room's exported schema JSON against it. `columns` is the iOS/GRDB shape in GRDB column order (macOS is the reference implementation); every way Android differs is spelled out in an `android` / `iosAbsent` / `androidColumnOrder` override naming a key in `divergenceReasons`. Adding a column, reordering one, or changing a type/nullability on one platform only fails both suites until it is either fixed or written down here.", - "roomVersion": 35, + "roomVersion": 34, "grdbMigrations": [ "v1", "v2", diff --git a/android/app/src/main/java/com/noop/data/WhoopDatabase.kt b/android/app/src/main/java/com/noop/data/WhoopDatabase.kt index e0f8296547..f5256a2851 100644 --- a/android/app/src/main/java/com/noop/data/WhoopDatabase.kt +++ b/android/app/src/main/java/com/noop/data/WhoopDatabase.kt @@ -52,7 +52,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase V18AuxSampleEntity::class, AppleStepHour::class, ], - version = 35, + version = 34, // #775: ON so Room's KSP processor writes the generated schema (every table's exact `CREATE TABLE`, // columns in declaration order with affinity/NOT NULL/default, PK and indices) as JSON. That export // is what lets a plain JVM test — no device, no Robolectric — read Android's REAL schema and compare @@ -69,7 +69,7 @@ abstract class WhoopDatabase : RoomDatabase() { const val DB_NAME = "noop_whoop.db" /** Room schema version — MUST equal the `@Database(version = …)` above. Surfaced in the backup * manifest (#1410) so an export states its schema. Bump both together on a migration. */ - const val SCHEMA_VERSION = 35 + const val SCHEMA_VERSION = 34 @Volatile private var instance: WhoopDatabase? = null @@ -900,12 +900,6 @@ abstract class WhoopDatabase : RoomDatabase() { override fun migrate(db: SupportSQLiteDatabase) { db.execSQL("DROP TABLE IF EXISTS `rawImuSample`") } } - // Schema 35 already shipped in staging builds with the now-retired, always-empty IMU catalog. - // Keep the version monotonic so those installations upgrade instead of crashing on downgrade. - internal val MIGRATION_34_35 = object : Migration(34, 35) { - override fun migrate(db: SupportSQLiteDatabase) { db.execSQL("DROP TABLE IF EXISTS `imuChunk`") } - } - /** * Every migration the builder registers, as a VALUE rather than an argument list. * @@ -931,7 +925,7 @@ abstract class WhoopDatabase : RoomDatabase() { MIGRATION_18_19, MIGRATION_19_20, MIGRATION_20_21, MIGRATION_21_22, MIGRATION_22_23, MIGRATION_23_24, MIGRATION_24_25, MIGRATION_25_26, MIGRATION_26_27, MIGRATION_27_28, MIGRATION_28_29, MIGRATION_29_30, - MIGRATION_30_31, MIGRATION_31_32, MIGRATION_32_33, MIGRATION_33_34, MIGRATION_34_35, + MIGRATION_30_31, MIGRATION_31_32, MIGRATION_32_33, MIGRATION_33_34, ) diff --git a/android/app/src/test/java/com/noop/data/DailySdnnMigrationTest.kt b/android/app/src/test/java/com/noop/data/DailySdnnMigrationTest.kt index 83517c15c6..3cea8f4609 100644 --- a/android/app/src/test/java/com/noop/data/DailySdnnMigrationTest.kt +++ b/android/app/src/test/java/com/noop/data/DailySdnnMigrationTest.kt @@ -22,7 +22,7 @@ class DailySdnnMigrationTest { fun migrationAndEntityPreserveOldRowsAsNull() { assertEquals(31, WhoopDatabase.MIGRATION_31_32.startVersion) assertEquals(32, WhoopDatabase.MIGRATION_31_32.endVersion) - assertEquals(35, WhoopDatabase.SCHEMA_VERSION) + assertEquals(34, WhoopDatabase.SCHEMA_VERSION) val old = DailyMetric(deviceId = "my-whoop", day = "2026-08-22", avgHrv = 44.0) assertNull(old.avgSdnn) assertEquals(88.4, old.copy(avgSdnn = 88.4).avgSdnn!!, 0.0) diff --git a/android/app/src/test/java/com/noop/data/RawImuMigrationTest.kt b/android/app/src/test/java/com/noop/data/RawImuMigrationTest.kt index 52717ca43e..87e4d9f7f6 100644 --- a/android/app/src/test/java/com/noop/data/RawImuMigrationTest.kt +++ b/android/app/src/test/java/com/noop/data/RawImuMigrationTest.kt @@ -55,8 +55,6 @@ class RawImuMigrationTest { @Test fun obsoleteEmptyTable_isDroppedAt35() { assertEquals(33, WhoopDatabase.MIGRATION_33_34.startVersion) assertEquals(34, WhoopDatabase.MIGRATION_33_34.endVersion) - assertEquals(34, WhoopDatabase.MIGRATION_34_35.startVersion) - assertEquals(35, WhoopDatabase.MIGRATION_34_35.endVersion) } // MARK: - Packed-BLOB encoding (byte-identical i16 LE) diff --git a/android/app/src/test/resources/schema_oracle.json b/android/app/src/test/resources/schema_oracle.json index d3f0623325..06a448d944 100644 --- a/android/app/src/test/resources/schema_oracle.json +++ b/android/app/src/test/resources/schema_oracle.json @@ -1,6 +1,6 @@ { "_readme": "SHARED Room<->GRDB SCHEMA ORACLE (#775). Two byte-identical copies: Packages/WhoopStore/Tests/WhoopStoreTests/Resources/schema_oracle.json and android/app/src/test/resources/schema_oracle.json. SchemaOracleTests.swift compares GRDB's PRAGMA table_info/index_list against it; SchemaOracleTest.kt compares Room's exported schema JSON against it. `columns` is the iOS/GRDB shape in GRDB column order (macOS is the reference implementation); every way Android differs is spelled out in an `android` / `iosAbsent` / `androidColumnOrder` override naming a key in `divergenceReasons`. Adding a column, reordering one, or changing a type/nullability on one platform only fails both suites until it is either fixed or written down here.", - "roomVersion": 35, + "roomVersion": 34, "grdbMigrations": [ "v1", "v2", From 7321baf8bb9795c92521c497323ccee384c391d1 Mon Sep 17 00:00:00 2001 From: Bernd Helm Date: Fri, 28 Aug 2026 03:04:24 +0200 Subject: [PATCH 34/45] Fix Apple IMU segment parsing --- Strand/Collect/ImuSessionFileStore.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Strand/Collect/ImuSessionFileStore.swift b/Strand/Collect/ImuSessionFileStore.swift index 0035da322d..26cf0b254f 100644 --- a/Strand/Collect/ImuSessionFileStore.swift +++ b/Strand/Collect/ImuSessionFileStore.swift @@ -150,7 +150,7 @@ final class ImuSessionFileStore { private func flushKey(_ key: String) { guard let records = pending.removeValue(forKey: key), !records.isEmpty, let tail = key.split(separator: "/").last, - let bucket = tail.flatMap({ Int64(String($0)) }) else { return } + let bucket = Int64(String(tail)) else { return } let id = String(key.split(separator: "/")[0]), url = segmentFile(id, bucket) try? FileManager.default.createDirectory(at: sessionDirectory(id), withIntermediateDirectories: true) if !FileManager.default.fileExists(atPath: url.path) { FileManager.default.createFile(atPath: url.path, contents: header(bucket)) } From bc697b5b2a4867b5c730bcfb96317d9d42fea2a1 Mon Sep 17 00:00:00 2001 From: Bernd Helm Date: Fri, 28 Aug 2026 03:13:23 +0200 Subject: [PATCH 35/45] Report live IMU startup coverage accurately --- Strand/Screens/RawDataCollectorView.swift | 8 +++++++- .../java/com/noop/testcentre/GroundTruthCollector.kt | 9 +++++++-- .../com/noop/testcentre/RawCaptureExportContractTest.kt | 2 ++ 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/Strand/Screens/RawDataCollectorView.swift b/Strand/Screens/RawDataCollectorView.swift index dab3b32f90..18c4be8606 100644 --- a/Strand/Screens/RawDataCollectorView.swift +++ b/Strand/Screens/RawDataCollectorView.swift @@ -298,9 +298,15 @@ struct RawDataCollectorView: View { let sensorAvailable = !segments.isEmpty || history.split(separator: 0x0A).count > 1 var entries = store.exportEntries(for: session, raw: [], sensorAvailable: sensorAvailable) entries.append(.init(name: "history-sensors.csv", data: history)) - let imuComplete = Self.covers(segments, from: from, to: to) + // A live capture can only produce its first complete one-second frame after startup. + // Historical windows must still cover the exact requested start. + let firstImuTs = segments.map(\.startTs).min().flatMap { $0 <= from + 1 ? $0 : nil } + let coverageFrom = session.capturedStartedAtMs == nil ? from : max(from, firstImuTs ?? from) + let imuComplete = Self.covers(segments, from: coverageFrom, to: to) let coverage: [String: Any] = [ "requested_start_ts": from, "requested_end_ts": to, + "required_start_ts": coverageFrom, + "startup_seconds": max(0, coverageFrom - from), "complete": imuComplete, "segments": segments.map { ["file": $0.name, "start_ts": $0.startTs, "end_ts": $0.endTs, "sample_count": $0.sampleCount] } diff --git a/android/app/src/main/java/com/noop/testcentre/GroundTruthCollector.kt b/android/app/src/main/java/com/noop/testcentre/GroundTruthCollector.kt index 949815324e..e89ff34a20 100644 --- a/android/app/src/main/java/com/noop/testcentre/GroundTruthCollector.kt +++ b/android/app/src/main/java/com/noop/testcentre/GroundTruthCollector.kt @@ -60,7 +60,8 @@ class GroundTruthCollector private constructor(private val context: Context) { val baseFrom = ceilSecond(session.startedAtMs) val to = end / 1_000L - 1L val raw = ImuSessionFileStore(context).stats(session.id, baseFrom, to) - val from = if (session.capturedStartedAtMs != null) maxOf(baseFrom, raw.firstTs ?: baseFrom) else baseFrom + val first = raw.firstTs?.takeIf { it <= baseFrom + 1 } + val from = if (session.capturedStartedAtMs != null) maxOf(baseFrom, first ?: baseFrom) else baseFrom val adjusted = if (from == baseFrom) raw.coveredSeconds else ImuSessionFileStore(context).stats(session.id, from, to).coveredSeconds return CaptureStats(raw.bytes, adjusted, (to - from + 1).coerceAtLeast(0).toInt(), @@ -284,7 +285,11 @@ class GroundTruthCollector private constructor(private val context: Context) { ImuSessionFileStore(context).exportSegments(id, sensorFrom, sensorTo) val fullFrom = sensorFrom val fullTo = endMs / 1_000L - 1L - val coverageFrom = sensorFrom + // A live capture can only produce its first complete one-second frame after startup. + // Historical windows must still cover the exact requested start. + val firstImuTs = imuSegments.minOfOrNull { it.startTs }?.takeIf { it <= fullFrom + 1 } + val coverageFrom = if (summary.capturedStartedAtMs != null) maxOf(fullFrom, firstImuTs ?: fullFrom) + else fullFrom val imuComplete = covers(imuSegments, coverageFrom, fullTo) val outDir = File(context.cacheDir, "logs").apply { mkdirs() } val zip = File(outDir, "noop-5mg-raw-$id.zip") diff --git a/android/app/src/test/java/com/noop/testcentre/RawCaptureExportContractTest.kt b/android/app/src/test/java/com/noop/testcentre/RawCaptureExportContractTest.kt index 39d1e63762..2549348d2a 100644 --- a/android/app/src/test/java/com/noop/testcentre/RawCaptureExportContractTest.kt +++ b/android/app/src/test/java/com/noop/testcentre/RawCaptureExportContractTest.kt @@ -41,6 +41,8 @@ class RawCaptureExportContractTest { assertTrue(collector.contains("summary.markers.filter { it.atMs in summary.startedAtMs..endMs }")) assertTrue(collector.contains("val sensorFrom = ceilSecond(summary.startedAtMs)")) assertTrue(collector.contains("val sensorTo = endMs / 1_000L - 1L")) + assertTrue(collector.contains("summary.capturedStartedAtMs != null")) + assertTrue(collector.contains("imuSegments.minOfOrNull { it.startTs }")) assertTrue(collector.contains("val sensorAvailable = imuSegments.isNotEmpty() ||")) } From 2d47e06ff240c115ac9289df4ceb19e63a2eb821 Mon Sep 17 00:00:00 2001 From: Bernd Helm Date: Fri, 28 Aug 2026 03:21:04 +0200 Subject: [PATCH 36/45] Keep raw sessions on one canonical IMU path --- Strand/BLE/BLEManager.swift | 15 -------- Strand/Collect/Collector.swift | 34 ------------------- Strand/Collect/RawDataSessionStore.swift | 18 ++-------- Strand/Screens/RawDataCollectorView.swift | 28 ++++++++------- StrandTests/RawDataSessionStoreTests.swift | 9 ++--- .../Resources/raw_data_collector_parity.json | 2 +- .../resources/raw_data_collector_parity.json | 2 +- docs/RAW_DATA_CAPTURE.md | 14 ++------ 8 files changed, 28 insertions(+), 94 deletions(-) diff --git a/Strand/BLE/BLEManager.swift b/Strand/BLE/BLEManager.swift index 7dbd878d96..416aa3910d 100644 --- a/Strand/BLE/BLEManager.swift +++ b/Strand/BLE/BLEManager.swift @@ -1791,7 +1791,6 @@ public final class BLEManager: NSObject, ObservableObject { public func startGroundTruthRawCapture(sessionId: String) -> Bool { guard !rawCaptureInFlight else { return false } rawCaptureInFlight = true - collector?.beginGroundTruthRawCapture(sessionId: sessionId) send(.startRawData, payload: [0x01], writeType: .withResponse) send(.toggleIMUMode, payload: selectedModel.deviceFamily == .whoop5 ? [0x01, 0x01] : [0x01], @@ -1808,7 +1807,6 @@ public final class BLEManager: NSObject, ObservableObject { send(.toggleIMUMode, payload: [0x01, 0x00], writeType: .withResponse) } } - await collector?.endRawCapture() rawCaptureInFlight = false rawCaptureStoppedAt = Date() log("Raw-data session: stopped + flushed") @@ -1827,24 +1825,11 @@ public final class BLEManager: NSObject, ObservableObject { log("Raw IMU fail-safe: unexpected realtime packet type \(frame[8]) while capture was off; stop requested") } - public func finishGroundTruthRawCapture(sessionId: String) { - collector?.finishGroundTruthRawCapture(sessionId: sessionId) - } - - public func groundTruthRawBatches(from: Int, to: Int) async -> [(RawBatchMeta, [[UInt8]])] { - await collector?.rawBatches(from: from, to: to) ?? [] - } - public func groundTruthHistoryCSV(from: Int, to: Int) async -> Data { await collector?.historySensorsCSV(from: from, to: to) ?? Data("stream,unix_s,v1,v2,v3,v4\n".utf8) } - @discardableResult - public func deleteGroundTruthRawBatches(from: Int, to: Int) async -> Int { - await collector?.deleteRawBatches(from: from, to: to) ?? 0 - } - /// Send a command to the WHOOP strap. /// - Parameters: /// - command: The command to send. diff --git a/Strand/Collect/Collector.swift b/Strand/Collect/Collector.swift index ed6c59487c..c2cbdeef84 100644 --- a/Strand/Collect/Collector.swift +++ b/Strand/Collect/Collector.swift @@ -63,7 +63,6 @@ final class Collector { /// activity sample" action can persist raw even when `enableRawCapture` is off. The window's /// monotonic deadline auto-expires so a missed stop callback can't leak raw forever. private var rawCapture = RawCaptureWindow() - private var groundTruthSessionId: String? /// #47: buffer the (raw frame, pre-parsed) pair. The raw bytes are still needed for the raw-capture /// outbox; the parse is the one the BLE seam already did, so `flush` doesn't re-decode the batch. private var buffer: [(frame: [UInt8], parsed: ParsedFrame)] = [] @@ -92,18 +91,6 @@ final class Collector { return try? await s.storageStats() } - func rawBatches(from: Int, to: Int) async -> [(RawBatchMeta, [[UInt8]])] { - guard let store = concreteStore, - let metas = try? await store.rawBatches(deviceId: deviceId, from: from, to: to) else { return [] } - var result: [(RawBatchMeta, [[UInt8]])] = [] - for meta in metas { - if let frames = try? await store.rawFrames(batchId: meta.batchId) { - result.append((meta, frames)) - } - } - return result - } - /// Decoded history rows in a stable long-form CSV for arbitrary user-selected export windows. func historySensorsCSV(from: Int, to: Int) async -> Data { guard let store = concreteStore, from <= to else { return Data("stream,unix_s,v1,v2,v3,v4\n".utf8) } @@ -126,12 +113,6 @@ final class Collector { return Data((lines.joined(separator: "\n") + "\n").utf8) } - @discardableResult - func deleteRawBatches(from: Int, to: Int) async -> Int { - guard let store = concreteStore else { return 0 } - return (try? await store.deleteRawBatches(deviceId: deviceId, from: from, to: to)) ?? 0 - } - /// Max persisted HR sample ts (the biometric "data frontier" for the stuck-strap watchdog). /// nil if there's no concrete store or nothing persisted yet. Mirrors storageStats(). func latestHRSampleTs() async -> Int? { @@ -163,11 +144,6 @@ final class Collector { ingest(frame: frame, parsed: parseFrame(frame, family: family)) } - /// #423: persist the WHOOP 5/MG raw-IMU offload buffer NOOP already decodes for the deep-buffer log — - /// the queryable twin of that (table-less) diagnostics line. Same `noopPuffinCapture` gate; only the - /// 1244-B 6-axis buffer decodes (rawColumns nil otherwise). Fire-and-forget into the store, bounded by - /// a rolling retention prune. Raw i16, no downstream consumer yet. Twin of Android - /// `WhoopBleClient.storeWhoop5RawImuIfBuffer`. /// Buffer one complete frame + its pre-parsed decode (synchronous: preserves delegate arrival order). /// Auto-flushes via a detached Task when the cadence threshold is hit (flush is async). (#47) func ingest(frame: [UInt8], parsed: ParsedFrame) { @@ -260,16 +236,6 @@ final class Collector { rawCapture.open(at: monotonic(), duration: seconds) } - func beginGroundTruthRawCapture(sessionId: String) { - rawCapture.open(at: monotonic(), duration: RawCaptureWindow.maxSeconds) - groundTruthSessionId = sessionId - } - - func finishGroundTruthRawCapture(sessionId: String) { - guard groundTruthSessionId == sessionId else { return } - groundTruthSessionId = nil - } - private func recordGroundTruthImu(_ frame: [UInt8]) { _ = ImuSessionFileStore.shared.append(deviceId: deviceId, frame: frame, receivedAtMs: Int64(Date().timeIntervalSince1970 * 1_000)) diff --git a/Strand/Collect/RawDataSessionStore.swift b/Strand/Collect/RawDataSessionStore.swift index 15e579bbb7..7f972ac4cb 100644 --- a/Strand/Collect/RawDataSessionStore.swift +++ b/Strand/Collect/RawDataSessionStore.swift @@ -188,14 +188,13 @@ final class RawDataSessionStore: ObservableObject { min(max(atMs, session.startedAtMs), session.endedAtMs ?? Int64(Date().timeIntervalSince1970 * 1_000)) } - func exportEntries(for session: Session, - raw: [(RawBatchMeta, [[UInt8]])], sensorAvailable: Bool? = nil) -> [FileExport.BundleEntry] { + func exportEntries(for session: Session, sensorAvailable: Bool = false) -> [FileExport.BundleEntry] { var meta: [String: Any] = [ "schema_version": 3, "capture_kind": "whoop_5mg_raw_data", "session_id": session.id, "device_id": session.deviceId, "started_at_ms": session.startedAtMs, "ended_at_ms": session.endedAtMs ?? session.startedAtMs, "comment": session.comment, - "sensor_export_available": sensorAvailable ?? !raw.isEmpty, + "sensor_export_available": sensorAvailable, "device_family": "iOS", "app_version": Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "?", "markers": session.events.filter { @@ -224,22 +223,9 @@ final class RawDataSessionStore: ObservableObject { } return try? JSONSerialization.data(withJSONObject: row, options: [.sortedKeys]) }.reduce(into: Data()) { $0.append($1); $0.append(0x0A) } - var rawData = Data() - for (meta, frames) in raw { - for (index, frame) in frames.enumerated() { - let row: [String: Any] = ["batch_id": meta.batchId, "captured_at": meta.capturedAt, - "start_ts": meta.startTs, "end_ts": meta.endTs, - "frame_index": index, - "hex": frame.map { String(format: "%02x", $0) }.joined()] - if let data = try? JSONSerialization.data(withJSONObject: row, options: [.sortedKeys]) { - rawData.append(data); rawData.append(0x0A) - } - } - } return [ .init(name: "meta.json", data: metaData), .init(name: "events.jsonl", data: eventData), - .init(name: "raw-frames.jsonl", data: rawData), ] } } diff --git a/Strand/Screens/RawDataCollectorView.swift b/Strand/Screens/RawDataCollectorView.swift index 18c4be8606..b34000403e 100644 --- a/Strand/Screens/RawDataCollectorView.swift +++ b/Strand/Screens/RawDataCollectorView.swift @@ -13,7 +13,7 @@ struct RawDataCollectorView: View { @State private var confirmDeleteAll = false @State private var exportError: String? @State private var deleteError: String? - @State private var rawBatchCounts: [String: Int] = [:] + @State private var imuCoverage: [String: String] = [:] @State private var historicalFrom = Date().addingTimeInterval(-3_600) @State private var historicalTo = Date() @State private var markerDraft: MarkerDraft? @@ -43,7 +43,7 @@ struct RawDataCollectorView: View { // Restore a session after navigation/process lifecycle changes. The BLE layer rejects a // duplicate arm, so this is safe when capture never stopped. if let active = store.active, live.bonded { _ = model.ble.startGroundTruthRawCapture(sessionId: active.id) } - await refreshRawCounts() + await refreshImuCoverage() } .onChangeCompat(of: live.bonded) { bonded in if bonded, let active = store.active { _ = model.ble.startGroundTruthRawCapture(sessionId: active.id) } @@ -163,7 +163,7 @@ struct RawDataCollectorView: View { )) } Text(session.active ? "Export status: recording" - : "Export status: \(rawBatchCounts[session.id, default: 0]) raw batches") + : "IMU: \(imuCoverage[session.id, default: "no complete seconds")") .font(StrandFont.caption) .foregroundStyle(session.active ? StrandPalette.statusWarning : StrandPalette.statusPositive) TextField("Session comment", text: Binding( @@ -282,7 +282,7 @@ struct RawDataCollectorView: View { private func stop() async { await model.ble.stopGroundTruthRawCapture() store.stop() - await refreshRawCounts() + await refreshImuCoverage() } private func export(_ session: RawDataSessionStore.Session) async { @@ -296,7 +296,7 @@ struct RawDataCollectorView: View { let history = bounds == nil ? Data("stream,unix_s,v1,v2,v3,v4\n".utf8) : await model.ble.groundTruthHistoryCSV(from: from, to: to) let sensorAvailable = !segments.isEmpty || history.split(separator: 0x0A).count > 1 - var entries = store.exportEntries(for: session, raw: [], sensorAvailable: sensorAvailable) + var entries = store.exportEntries(for: session, sensorAvailable: sensorAvailable) entries.append(.init(name: "history-sensors.csv", data: history)) // A live capture can only produce its first complete one-second frame after startup. // Historical windows must still cover the exact requested start. @@ -320,30 +320,34 @@ struct RawDataCollectorView: View { if result == nil { exportError = "The export file could not be created or shared." } else { store.markExported(session.id) - model.ble.finishGroundTruthRawCapture(sessionId: session.id) } exportingId = nil } private func delete(_ session: RawDataSessionStore.Session) async { guard session.endedAtMs != nil else { return } - model.ble.finishGroundTruthRawCapture(sessionId: session.id) guard store.removeMetadata(session.id) else { deleteError = "The captured data could not be deleted. The session was kept so you can retry." return } - rawBatchCounts[session.id] = nil + imuCoverage[session.id] = nil } private func deleteAll() async { for session in store.sessions where !session.active { await delete(session) } } - private func refreshRawCounts() async { + private func refreshImuCoverage() async { for session in store.sessions { - guard let end = session.endedAtMs else { continue } - rawBatchCounts[session.id] = await model.ble.groundTruthRawBatches( - from: Int(session.startedAtMs / 1_000), to: Int(end / 1_000)).count + guard let end = session.endedAtMs, + let bounds = Self.fullSecondBounds(fromMs: session.startedAtMs, toMs: end) else { continue } + let stats = ImuSessionFileStore.shared.stats(session.id, from: bounds.from, to: bounds.to) + let first = stats.firstTs.flatMap { $0 <= Int64(bounds.from + 1) ? Int($0) : nil } + let from = session.capturedStartedAtMs == nil ? bounds.from : max(bounds.from, first ?? bounds.from) + let expected = max(0, bounds.to - from + 1) + let bytes = ByteCountFormatter.string(fromByteCount: stats.bytes, countStyle: .file) + let readiness = expected > 0 && stats.coveredSeconds == expected ? "ready" : "incomplete" + imuCoverage[session.id] = "\(stats.coveredSeconds)/\(expected) s · \(bytes) · \(readiness)" } } diff --git a/StrandTests/RawDataSessionStoreTests.swift b/StrandTests/RawDataSessionStoreTests.swift index 382cb93525..2e5ef75eff 100644 --- a/StrandTests/RawDataSessionStoreTests.swift +++ b/StrandTests/RawDataSessionStoreTests.swift @@ -40,10 +40,10 @@ final class RawDataSessionStoreTests: XCTestCase { XCTAssertEqual(saved.markerType, "moment") XCTAssertEqual(saved.text, "Recovered") - let events = try XCTUnwrap(store.exportEntries(for: updated, raw: []) + let events = try XCTUnwrap(store.exportEntries(for: updated) .first(where: { $0.name == "events.jsonl" })) XCTAssertTrue(String(decoding: events.data, as: UTF8.self).contains("\"marker_type\":\"moment\"")) - let meta = try XCTUnwrap(store.exportEntries(for: updated, raw: []) + let meta = try XCTUnwrap(store.exportEntries(for: updated) .first(where: { $0.name == "meta.json" })) let object = try XCTUnwrap(JSONSerialization.jsonObject(with: meta.data) as? [String: Any]) let markers = try XCTUnwrap(object["markers"] as? [[String: Any]]) @@ -75,7 +75,7 @@ final class RawDataSessionStoreTests: XCTestCase { let edited = try XCTUnwrap(store.sessions.first) XCTAssertEqual(edited.events.filter { $0.kind == "marker" }.count, 2) - let events = try XCTUnwrap(store.exportEntries(for: edited, raw: []) + let events = try XCTUnwrap(store.exportEntries(for: edited) .first(where: { $0.name == "events.jsonl" })) let text = String(decoding: events.data, as: UTF8.self) XCTAssertFalse(text.contains("outside")) @@ -87,10 +87,11 @@ final class RawDataSessionStoreTests: XCTestCase { _ = store.start(deviceId: "strap", now: Date(timeIntervalSince1970: 100)) store.stop(now: Date(timeIntervalSince1970: 110)) let session = try XCTUnwrap(store.sessions.first) - let meta = try XCTUnwrap(store.exportEntries(for: session, raw: [], sensorAvailable: true) + let meta = try XCTUnwrap(store.exportEntries(for: session, sensorAvailable: true) .first(where: { $0.name == "meta.json" })) let object = try XCTUnwrap(JSONSerialization.jsonObject(with: meta.data) as? [String: Any]) XCTAssertEqual(object["sensor_export_available"] as? Bool, true) + XCTAssertFalse(store.exportEntries(for: session).contains { $0.name == "raw-frames.jsonl" }) } func testFullSecondBoundsExcludePartialEndpointSeconds() { diff --git a/StrandTests/Resources/raw_data_collector_parity.json b/StrandTests/Resources/raw_data_collector_parity.json index 40901f9dc2..f560d9c82d 100644 --- a/StrandTests/Resources/raw_data_collector_parity.json +++ b/StrandTests/Resources/raw_data_collector_parity.json @@ -50,7 +50,7 @@ "kotlin": ["imu_100hz_complete", "private fun covers"] }, "delete_one_and_all_with_confirmation": { - "swift": ["Delete this session?", "Delete all sessions?", "deleteGroundTruthRawBatches"], + "swift": ["Delete this session?", "Delete all sessions?", "removeMetadata(session.id)"], "kotlin": ["ground_truth_delete_session_title", "ground_truth_delete_all_title", "deleteAllSessions"] }, "active_session_delete_guard": { diff --git a/android/app/src/test/resources/raw_data_collector_parity.json b/android/app/src/test/resources/raw_data_collector_parity.json index 40901f9dc2..f560d9c82d 100644 --- a/android/app/src/test/resources/raw_data_collector_parity.json +++ b/android/app/src/test/resources/raw_data_collector_parity.json @@ -50,7 +50,7 @@ "kotlin": ["imu_100hz_complete", "private fun covers"] }, "delete_one_and_all_with_confirmation": { - "swift": ["Delete this session?", "Delete all sessions?", "deleteGroundTruthRawBatches"], + "swift": ["Delete this session?", "Delete all sessions?", "removeMetadata(session.id)"], "kotlin": ["ground_truth_delete_session_title", "ground_truth_delete_all_title", "deleteAllSessions"] }, "active_session_delete_guard": { diff --git a/docs/RAW_DATA_CAPTURE.md b/docs/RAW_DATA_CAPTURE.md index ff0c55cf5e..89d41e493b 100644 --- a/docs/RAW_DATA_CAPTURE.md +++ b/docs/RAW_DATA_CAPTURE.md @@ -36,12 +36,6 @@ by a strap Unix timestamp. Accelerometer scale is `1/4096 g/LSB`; gyroscope scal `0.06104 deg/s/LSB`. See [BLE reverse engineering](BLE_REVERSE_ENGINEERING.md#4-the-realtime-r10r11-raw-stream-type-43) for the byte layout and validation evidence. -An Android Bluetooth HCI capture of the official WHOOP app around a Strength Trainer session was the -useful behavioural reference: high-rate collection was session-oriented rather than a permanently -enabled background stream. It helped identify what to test, but NOOP's implementation and validation -remain clean-room and use only independently implemented protocol commands/decoders. The observation -also does not establish the battery cost of keeping the mode enabled beyond a workout-sized window. - ## Live capture and history repair A live BLE connection is not assumed to be lossless. Each session is a time window tied to one strap, @@ -112,11 +106,9 @@ suggested archive name is `noop-5mg-raw-.zip`. flash-retention, thermal, or BLE-airtime costs for continuous 24/7 100 Hz operation. - A one-hour workout/research capture succeeding does not establish that a 36-hour rolling recorder is safe. Any future rolling buffer needs hardware measurements and an explicit retention policy. -- The older passive “Record 5/MG raw capture” protocol trace and the session collector have different - jobs. The former preserves broad offload/debug frames; the latter owns a time-bounded, exportable IMU - dataset. They may observe the same wire frame, but the session store deduplicates it by strap time. -- The retired opt-in `rawImuSample` path existed in earlier builds but did not produce usable stored IMU - data in deployment. Session capture therefore has one source of truth: its file-backed segments. +- The separately enabled protocol trace remains a general diagnostics tool. Starting a Raw Data + Collector session does not enable it or duplicate its transport frames into the raw outbox. +- Session capture has one source of truth for high-rate motion: its file-backed `.imus` segments. - Do not use arrival order as time, do not fill gaps silently, and do not claim 100 Hz coverage from packet count alone. From af0bb51cfd1f36359ef1af0e492d31c77bbb9d7a Mon Sep 17 00:00:00 2001 From: Bernd Helm Date: Fri, 28 Aug 2026 03:25:53 +0200 Subject: [PATCH 37/45] Fix Apple coverage label syntax --- Strand/Screens/RawDataCollectorView.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Strand/Screens/RawDataCollectorView.swift b/Strand/Screens/RawDataCollectorView.swift index b34000403e..5c115d2ab3 100644 --- a/Strand/Screens/RawDataCollectorView.swift +++ b/Strand/Screens/RawDataCollectorView.swift @@ -147,6 +147,7 @@ struct RawDataCollectorView: View { } private func sessionCard(_ session: RawDataSessionStore.Session) -> some View { + let coverageText = imuCoverage[session.id, default: "no complete seconds"] StrandCard { VStack(alignment: .leading, spacing: NoopMetrics.space3) { Text(Self.range(session)).font(StrandFont.headline).foregroundStyle(StrandPalette.textPrimary) @@ -163,7 +164,7 @@ struct RawDataCollectorView: View { )) } Text(session.active ? "Export status: recording" - : "IMU: \(imuCoverage[session.id, default: "no complete seconds")") + : "IMU: \(coverageText)") .font(StrandFont.caption) .foregroundStyle(session.active ? StrandPalette.statusWarning : StrandPalette.statusPositive) TextField("Session comment", text: Binding( From 2fe700e5cd93c2b026bc181b4e4c60aba84b482b Mon Sep 17 00:00:00 2001 From: Bernd Helm Date: Fri, 28 Aug 2026 03:31:03 +0200 Subject: [PATCH 38/45] Return Apple session coverage card --- Strand/Screens/RawDataCollectorView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Strand/Screens/RawDataCollectorView.swift b/Strand/Screens/RawDataCollectorView.swift index 5c115d2ab3..2bc49ca131 100644 --- a/Strand/Screens/RawDataCollectorView.swift +++ b/Strand/Screens/RawDataCollectorView.swift @@ -148,7 +148,7 @@ struct RawDataCollectorView: View { private func sessionCard(_ session: RawDataSessionStore.Session) -> some View { let coverageText = imuCoverage[session.id, default: "no complete seconds"] - StrandCard { + return StrandCard { VStack(alignment: .leading, spacing: NoopMetrics.space3) { Text(Self.range(session)).font(StrandFont.headline).foregroundStyle(StrandPalette.textPrimary) if !session.active, let endMs = session.endedAtMs { From 2f217804baefcf39bee8fb9d10738ee496d00fe0 Mon Sep 17 00:00:00 2001 From: Bernd Helm Date: Fri, 28 Aug 2026 12:34:10 +0200 Subject: [PATCH 39/45] Localize Apple IMU coverage label --- Strand/Resources/Localizable.xcstrings | 1 + Strand/Screens/RawDataCollectorView.swift | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/Strand/Resources/Localizable.xcstrings b/Strand/Resources/Localizable.xcstrings index 5e499790f7..af6777ee1d 100644 --- a/Strand/Resources/Localizable.xcstrings +++ b/Strand/Resources/Localizable.xcstrings @@ -208237,6 +208237,7 @@ "Recorded sessions": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Aufgezeichnete Sessions"}},"es":{"stringUnit":{"state":"translated","value":"Sesiones grabadas"}},"fr":{"stringUnit":{"state":"translated","value":"Sessions enregistrées"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Sessões gravadas"}}}}, "No sessions recorded yet.": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Noch keine Sessions aufgezeichnet."}},"es":{"stringUnit":{"state":"translated","value":"Aún no hay sesiones grabadas."}},"fr":{"stringUnit":{"state":"translated","value":"Aucune session enregistrée."}},"pt-PT":{"stringUnit":{"state":"translated","value":"Ainda não existem sessões gravadas."}}}}, "Export status: recording": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Exportstatus: Aufnahme läuft"}},"es":{"stringUnit":{"state":"translated","value":"Estado: grabando"}},"fr":{"stringUnit":{"state":"translated","value":"État : enregistrement"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Estado: a gravar"}}}}, + "IMU: %@": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"IMU: %@"}},"es":{"stringUnit":{"state":"translated","value":"IMU: %@"}},"fr":{"stringUnit":{"state":"translated","value":"IMU : %@"}},"pt-PT":{"stringUnit":{"state":"translated","value":"IMU: %@"}}}}, "Export status: %lld raw batches": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Exportstatus: %lld Rohdatenblöcke"}},"es":{"stringUnit":{"state":"translated","value":"Estado: %lld bloques sin procesar"}},"fr":{"stringUnit":{"state":"translated","value":"État : %lld blocs bruts"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Estado: %lld blocos em bruto"}}}}, "Legacy R22 feature-flag experiment. The strap accepts these writes, but NOOP has not observed them enabling a separate live stream. This is not required for normal WHOOP 5/MG support or for the Raw Data Collector. It writes persistent strap settings and may do nothing on your firmware.": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Altes R22-Experiment. Für normalen 5/MG-Betrieb und den Rohdaten-Collector ist es nicht erforderlich."}},"es":{"stringUnit":{"state":"translated","value":"Experimento R22 antiguo. No es necesario para el uso normal ni para el recopilador de datos."}},"fr":{"stringUnit":{"state":"translated","value":"Ancienne expérience R22. Elle n’est pas requise pour l’usage normal ni pour le collecteur."}},"pt-PT":{"stringUnit":{"state":"translated","value":"Experiência R22 antiga. Não é necessária para o uso normal nem para o coletor."}}}}, "NOOP estimates your steps from your WHOOP's stored motion, calibrated to your phone's step count. It's an estimate, not a hardware step counter; normal WHOOP 5/MG history sync supplies the motion data.": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"NOOP schätzt Schritte aus gespeicherter Bewegung und kalibriert sie mit dem Telefon. Es ist kein Hardware-Schrittzähler."}},"es":{"stringUnit":{"state":"translated","value":"NOOP estima los pasos desde el movimiento y los calibra con el teléfono. No es un contador físico."}},"fr":{"stringUnit":{"state":"translated","value":"NOOP estime les pas depuis le mouvement et les calibre avec le téléphone. Ce n’est pas un compteur matériel."}},"pt-PT":{"stringUnit":{"state":"translated","value":"O NOOP estima os passos a partir do movimento e calibra-os com o telefone. Não é um contador físico."}}}}, diff --git a/Strand/Screens/RawDataCollectorView.swift b/Strand/Screens/RawDataCollectorView.swift index 2bc49ca131..6361a712e9 100644 --- a/Strand/Screens/RawDataCollectorView.swift +++ b/Strand/Screens/RawDataCollectorView.swift @@ -163,8 +163,8 @@ struct RawDataCollectorView: View { from: Date(timeIntervalSince1970: Double(session.startedAtMs) / 1_000), to: $0) } )) } - Text(session.active ? "Export status: recording" - : "IMU: \(coverageText)") + Text(session.active ? String(localized: "Export status: recording") + : String(localized: "IMU: \(coverageText)")) .font(StrandFont.caption) .foregroundStyle(session.active ? StrandPalette.statusWarning : StrandPalette.statusPositive) TextField("Session comment", text: Binding( From d23690b61f5f5026c74405570cf01bd188149880 Mon Sep 17 00:00:00 2001 From: Bernd Helm Date: Fri, 28 Aug 2026 12:41:50 +0200 Subject: [PATCH 40/45] Remove abandoned raw outbox capture API --- .../Sources/WhoopStore/RawOutbox.swift | 27 ------------------- .../Tests/WhoopStoreTests/PruneTests.swift | 26 ------------------ 2 files changed, 53 deletions(-) diff --git a/Packages/WhoopStore/Sources/WhoopStore/RawOutbox.swift b/Packages/WhoopStore/Sources/WhoopStore/RawOutbox.swift index 4834619926..a08a53a9aa 100644 --- a/Packages/WhoopStore/Sources/WhoopStore/RawOutbox.swift +++ b/Packages/WhoopStore/Sources/WhoopStore/RawOutbox.swift @@ -164,33 +164,6 @@ extension WhoopStore { } } - /// Raw batches overlapping a user-selected capture interval, oldest first. - public func rawBatches(deviceId: String, from: Int, to: Int) async throws -> [RawBatchMeta] { - guard from <= to else { return [] } - return try syncRead { db in - try Row.fetchAll(db, sql: """ - SELECT batchId, deviceId, capturedAt, deviceClockRef, wallClockRef, - startTs, endTs, frameCount, byteSize - FROM rawBatch - WHERE deviceId = ? AND capturedAt >= ? AND capturedAt <= ? - ORDER BY capturedAt ASC, rowid ASC - """, arguments: [deviceId, from, to]).map(WhoopStore.metaFromRow) - } - } - - /// Remove raw batches belonging to a deleted user capture interval. - @discardableResult - public func deleteRawBatches(deviceId: String, from: Int, to: Int) async throws -> Int { - guard from <= to else { return 0 } - return try syncWrite { db in - try db.execute(sql: """ - DELETE FROM rawBatch - WHERE deviceId = ? AND capturedAt >= ? AND capturedAt <= ? - """, arguments: [deviceId, from, to]) - return db.changesCount - } - } - /// Mark a batch synced (timestamp in unix seconds). /// /// #981 — NO production caller, and by design: see the dormancy note on `pruneRaw`'s Policy 1. Kept diff --git a/Packages/WhoopStore/Tests/WhoopStoreTests/PruneTests.swift b/Packages/WhoopStore/Tests/WhoopStoreTests/PruneTests.swift index d054375f67..4bedfed80f 100644 --- a/Packages/WhoopStore/Tests/WhoopStoreTests/PruneTests.swift +++ b/Packages/WhoopStore/Tests/WhoopStoreTests/PruneTests.swift @@ -78,30 +78,4 @@ final class PruneTests: XCTestCase { XCTAssertEqual(pruned, 0) } - func testCaptureIntervalListsOnlyMatchingDeviceAndWallTime() async throws { - let store = try await WhoopStore.inMemory() - try await store.upsertDevice(id: "dev1", mac: nil, name: nil) - try await store.upsertDevice(id: "dev2", mac: nil, name: nil) - try await store.enqueueRawBatch(meta("before", capturedAt: 99, bytes: 10), frames: frames) - try await store.enqueueRawBatch(meta("inside", capturedAt: 100, bytes: 10), frames: frames) - let other = RawBatchMeta(batchId: "other", deviceId: "dev2", clockRef: .init(device: 0, wall: 0), - capturedAt: 100, startTs: 0, endTs: 0, - frameCount: frames.count, byteSize: 10) - try await store.enqueueRawBatch(other, frames: frames) - - let rows = try await store.rawBatches(deviceId: "dev1", from: 100, to: 200) - XCTAssertEqual(rows.map(\.batchId), ["inside"]) - } - - func testDeletingCaptureIntervalDoesNotTouchOtherRaw() async throws { - let store = try await WhoopStore.inMemory() - try await store.upsertDevice(id: "dev1", mac: nil, name: nil) - try await store.enqueueRawBatch(meta("before", capturedAt: 99, bytes: 10), frames: frames) - try await store.enqueueRawBatch(meta("first", capturedAt: 100, bytes: 10), frames: frames) - try await store.enqueueRawBatch(meta("last", capturedAt: 200, bytes: 10), frames: frames) - try await store.enqueueRawBatch(meta("after", capturedAt: 201, bytes: 10), frames: frames) - - XCTAssertEqual(try await store.deleteRawBatches(deviceId: "dev1", from: 100, to: 200), 2) - XCTAssertEqual(try await store.allBatchIdsForTest(), ["before", "after"]) - } } From 7206f385c94f6832edee88db0bfd6d3880312ad7 Mon Sep 17 00:00:00 2001 From: Bernd Helm Date: Fri, 28 Aug 2026 12:43:49 +0200 Subject: [PATCH 41/45] Match Apple export status with Android --- Strand/Collect/RawDataSessionStore.swift | 14 ++++++++++---- Strand/Resources/Localizable.xcstrings | 1 + Strand/Screens/RawDataCollectorView.swift | 5 +++++ StrandTests/RawDataSessionStoreTests.swift | 12 ++++++++++++ 4 files changed, 28 insertions(+), 4 deletions(-) diff --git a/Strand/Collect/RawDataSessionStore.swift b/Strand/Collect/RawDataSessionStore.swift index 7f972ac4cb..5e962858d3 100644 --- a/Strand/Collect/RawDataSessionStore.swift +++ b/Strand/Collect/RawDataSessionStore.swift @@ -24,6 +24,7 @@ final class RawDataSessionStore: ObservableObject { var capturedEndedAtMs: Int64? var comment: String var exported: Bool + var lastExportedAtMs: Int64? var events: [Event] var active: Bool { endedAtMs == nil } @@ -62,7 +63,7 @@ final class RawDataSessionStore: ObservableObject { let started = Event(atMs: millis, kind: "start") let session = Session(id: String(millis), deviceId: deviceId, startedAtMs: millis, endedAtMs: nil, capturedStartedAtMs: millis, capturedEndedAtMs: nil, - comment: "", exported: false, events: [started]) + comment: "", exported: false, lastExportedAtMs: nil, events: [started]) sessions.insert(session, at: 0) persist(session) ImuSessionFileStore.shared.start(id: session.id, deviceId: deviceId, fromMs: millis) @@ -89,7 +90,7 @@ final class RawDataSessionStore: ObservableObject { let events = [Event(atMs: fromMs, kind: "start"), Event(atMs: toMs, kind: "stop")] let session = Session(id: id, deviceId: deviceId, startedAtMs: fromMs, endedAtMs: toMs, capturedStartedAtMs: nil, capturedEndedAtMs: nil, comment: "", - exported: false, events: events) + exported: false, lastExportedAtMs: nil, events: events) sessions.insert(session, at: 0); persist(session) ImuSessionFileStore.shared.register(id: id, deviceId: deviceId, fromMs: fromMs, toMs: toMs) return session @@ -104,7 +105,7 @@ final class RawDataSessionStore: ObservableObject { session = Session(id: session.id, deviceId: session.deviceId, startedAtMs: fromMs, endedAtMs: toMs, capturedStartedAtMs: session.capturedStartedAtMs, capturedEndedAtMs: session.capturedEndedAtMs, comment: session.comment, - exported: false, events: session.events) + exported: false, lastExportedAtMs: nil, events: session.events) } if let session = sessions.first(where: { $0.id == sessionId }) { ImuSessionFileStore.shared.register(id: sessionId, deviceId: session.deviceId, fromMs: fromMs, toMs: toMs) @@ -143,7 +144,12 @@ final class RawDataSessionStore: ObservableObject { mutate(sessionId) { $0.events.removeAll { $0.kind == "marker" && $0.markerId == markerId } } } - func markExported(_ sessionId: String) { mutate(sessionId) { $0.exported = true } } + func markExported(_ sessionId: String, now: Date = Date()) { + mutate(sessionId) { + $0.exported = true + $0.lastExportedAtMs = Int64(now.timeIntervalSince1970 * 1_000) + } + } @discardableResult func removeMetadata(_ sessionId: String, diff --git a/Strand/Resources/Localizable.xcstrings b/Strand/Resources/Localizable.xcstrings index af6777ee1d..489ecc9e9e 100644 --- a/Strand/Resources/Localizable.xcstrings +++ b/Strand/Resources/Localizable.xcstrings @@ -208238,6 +208238,7 @@ "No sessions recorded yet.": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Noch keine Sessions aufgezeichnet."}},"es":{"stringUnit":{"state":"translated","value":"Aún no hay sesiones grabadas."}},"fr":{"stringUnit":{"state":"translated","value":"Aucune session enregistrée."}},"pt-PT":{"stringUnit":{"state":"translated","value":"Ainda não existem sessões gravadas."}}}}, "Export status: recording": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Exportstatus: Aufnahme läuft"}},"es":{"stringUnit":{"state":"translated","value":"Estado: grabando"}},"fr":{"stringUnit":{"state":"translated","value":"État : enregistrement"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Estado: a gravar"}}}}, "IMU: %@": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"IMU: %@"}},"es":{"stringUnit":{"state":"translated","value":"IMU: %@"}},"fr":{"stringUnit":{"state":"translated","value":"IMU : %@"}},"pt-PT":{"stringUnit":{"state":"translated","value":"IMU: %@"}}}}, + "Last exported %@ · export remains available": {"localizations":{"de":{"stringUnit":{"state":"translated","value":"Zuletzt exportiert: %@ · Export bleibt verfügbar"}},"es":{"stringUnit":{"state":"translated","value":"Última exportación: %@ · la exportación sigue disponible"}},"fr":{"stringUnit":{"state":"translated","value":"Dernier export : %@ · l’export reste disponible"}},"it":{"stringUnit":{"state":"translated","value":"Ultima esportazione: %@ · l’esportazione resta disponibile"}},"pl":{"stringUnit":{"state":"translated","value":"Ostatnio wyeksportowano: %@ · eksport pozostaje dostępny"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Última exportação: %@ · a exportação continua disponível"}},"ru":{"stringUnit":{"state":"translated","value":"Последний экспорт: %@ · экспорт остаётся доступен"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"上次导出:%@ · 仍可再次导出"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"上次匯出:%@ · 仍可再次匯出"}}}}, "Export status: %lld raw batches": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Exportstatus: %lld Rohdatenblöcke"}},"es":{"stringUnit":{"state":"translated","value":"Estado: %lld bloques sin procesar"}},"fr":{"stringUnit":{"state":"translated","value":"État : %lld blocs bruts"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Estado: %lld blocos em bruto"}}}}, "Legacy R22 feature-flag experiment. The strap accepts these writes, but NOOP has not observed them enabling a separate live stream. This is not required for normal WHOOP 5/MG support or for the Raw Data Collector. It writes persistent strap settings and may do nothing on your firmware.": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Altes R22-Experiment. Für normalen 5/MG-Betrieb und den Rohdaten-Collector ist es nicht erforderlich."}},"es":{"stringUnit":{"state":"translated","value":"Experimento R22 antiguo. No es necesario para el uso normal ni para el recopilador de datos."}},"fr":{"stringUnit":{"state":"translated","value":"Ancienne expérience R22. Elle n’est pas requise pour l’usage normal ni pour le collecteur."}},"pt-PT":{"stringUnit":{"state":"translated","value":"Experiência R22 antiga. Não é necessária para o uso normal nem para o coletor."}}}}, "NOOP estimates your steps from your WHOOP's stored motion, calibrated to your phone's step count. It's an estimate, not a hardware step counter; normal WHOOP 5/MG history sync supplies the motion data.": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"NOOP schätzt Schritte aus gespeicherter Bewegung und kalibriert sie mit dem Telefon. Es ist kein Hardware-Schrittzähler."}},"es":{"stringUnit":{"state":"translated","value":"NOOP estima los pasos desde el movimiento y los calibra con el teléfono. No es un contador físico."}},"fr":{"stringUnit":{"state":"translated","value":"NOOP estime les pas depuis le mouvement et les calibre avec le téléphone. Ce n’est pas un compteur matériel."}},"pt-PT":{"stringUnit":{"state":"translated","value":"O NOOP estima os passos a partir do movimento e calibra-os com o telefone. Não é um contador físico."}}}}, diff --git a/Strand/Screens/RawDataCollectorView.swift b/Strand/Screens/RawDataCollectorView.swift index 6361a712e9..4cf3785217 100644 --- a/Strand/Screens/RawDataCollectorView.swift +++ b/Strand/Screens/RawDataCollectorView.swift @@ -167,6 +167,11 @@ struct RawDataCollectorView: View { : String(localized: "IMU: \(coverageText)")) .font(StrandFont.caption) .foregroundStyle(session.active ? StrandPalette.statusWarning : StrandPalette.statusPositive) + if let exportedAt = session.lastExportedAtMs { + Text(String(localized: "Last exported \(Self.time(exportedAt)) · export remains available")) + .font(StrandFont.caption) + .foregroundStyle(StrandPalette.textSecondary) + } TextField("Session comment", text: Binding( get: { store.sessions.first(where: { $0.id == session.id })?.comment ?? session.comment }, set: { store.setComment($0, sessionId: session.id) } diff --git a/StrandTests/RawDataSessionStoreTests.swift b/StrandTests/RawDataSessionStoreTests.swift index 2e5ef75eff..38c2c54714 100644 --- a/StrandTests/RawDataSessionStoreTests.swift +++ b/StrandTests/RawDataSessionStoreTests.swift @@ -120,6 +120,18 @@ final class RawDataSessionStoreTests: XCTestCase { XCTAssertEqual(store.sessions.map(\.id), [active.id]) } + func testLastExportTimePersists() throws { + let directory = try temporaryDirectory() + let store = RawDataSessionStore(directory: directory) + let session = try XCTUnwrap(store.start(deviceId: "strap")) + store.stop() + store.markExported(session.id, now: Date(timeIntervalSince1970: 123)) + + let restored = try XCTUnwrap(RawDataSessionStore(directory: directory).sessions.first) + XCTAssertTrue(restored.exported) + XCTAssertEqual(restored.lastExportedAtMs, 123_000) + } + func testCompletedSessionCanBeDeleted() throws { let directory = try temporaryDirectory() let store = RawDataSessionStore(directory: directory) From 200f881ed46af919a2bd3753b9c29267c7b9e74e Mon Sep 17 00:00:00 2001 From: Bernd Helm Date: Fri, 28 Aug 2026 21:49:20 +0200 Subject: [PATCH 42/45] perf(capture): speed IMU transfer and coverage UI --- .../main/java/com/noop/ble/WhoopBleClient.kt | 23 ++++++++++- .../noop/testcentre/ImuSessionFileStore.kt | 38 ++++++++++++++++--- .../com/noop/ui/GroundTruthCollectorScreen.kt | 8 +++- .../com/noop/ble/ConnectionPriorityTest.kt | 7 ++++ .../RawCaptureExportContractTest.kt | 7 ++++ docs/RAW_DATA_CAPTURE.md | 7 ++++ 6 files changed, 81 insertions(+), 9 deletions(-) diff --git a/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt b/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt index 45487d2b7b..dea4c8a5aa 100644 --- a/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt +++ b/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt @@ -646,6 +646,11 @@ class WhoopBleClient( else -> BluetoothGatt.CONNECTION_PRIORITY_BALANCED } + /** Bounded 100 Hz work gets HIGH independently of the global experiment. A completed capture + * only holds it while an offload is actively repairing missing seconds. */ + fun rawCaptureHighPriority(captureActive: Boolean, backfilling: Boolean, needsRepair: Boolean): Boolean = + captureActive || (backfilling && needsRepair) + /** * The throughput summary for one offload burst (#1007), pure so it is testable without a BLE stack. * @@ -1958,6 +1963,9 @@ class WhoopBleClient( * the drop-risk is confined to when the user actually wants power saving. Set on the main looper via * [setConnectionPriorityManagement]. */ @Volatile private var connectionPriorityEnabled: Boolean = false + /** Android-only transport lease for bounded 100 Hz capture and delayed history repair. CoreBluetooth + * chooses connection parameters itself and exposes no equivalent app-side request. */ + @Volatile private var rawCapturePriorityApplied: Boolean = false /** Battery-% at/below which the LOW_POWER idle throttle engages while the STRAP is discharging; * 0 = never (safe half only). * @@ -2155,7 +2163,13 @@ class WhoopBleClient( /** (Re)apply the GATT connection priority for the current link state (#477). Idempotent + cheap: OFF * or disconnected -> no BLE op. Called on connect-established and whenever offload / live-HR toggles. */ private fun refreshConnectionPriority() { - if (!connectionPriorityEnabled) return + val rawCaptureHigh = connectedFamily == DeviceFamily.WHOOP5 && rawCaptureHighPriority( + captureActive = groundTruthImuSessionId != null, + backfilling = backfilling, + needsRepair = ImuSessionFileStore(context).needsHighThroughput(deviceId), + ) + // Preserve the zero-op default path, but release a completed capture's HIGH lease once. + if (!connectionPriorityEnabled && !rawCaptureHigh && !rawCapturePriorityApplied) return val ops = gattOps ?: return // Only read the battery when the RISKY idle throttle is actually armed (threshold > 0); the SAFE // HIGH-escalation half doesn't need it, so safe-half-only mode issues no battery read. @@ -2166,7 +2180,7 @@ class WhoopBleClient( // Read the authoritative INTERNAL flags (both set synchronously on this looper), not the // published LiveState mirror, which `exitBackfilling` may update a beat later. val priority = connectionPriorityFor( - offloadActive = backfilling, + offloadActive = rawCaptureHigh || (connectionPriorityEnabled && backfilling), // #533: gated — the live stream does NOT escalate by default. See [escalateForLiveHr]: the // overnight continuous-HRV window keeps this armed for hours, and a 1 Hz stream gains nothing // from HIGH. The offload burst below is the case that actually wants the shorter interval. @@ -2178,6 +2192,7 @@ class WhoopBleClient( // binder is handled by the next real op, and skipping a priority request costs nothing. Swallow. try { ops.requestConnectionPriorityCompat(priority) + rawCapturePriorityApplied = rawCaptureHigh } catch (t: Throwable) { log("connection-priority request failed (${t.javaClass.simpleName}); skipped") } @@ -2211,6 +2226,7 @@ class WhoopBleClient( groundTruthImuSessionId = sessionId groundTruthImuStoppedAtMs = 0L groundTruthImuCommandAllowed = true + refreshConnectionPriority() try { // The verified bounded raw-capture sequence is START_RAW_DATA followed by the // realtime IMU selector. Opcode 106 alone ACKs but does not start the producer. @@ -2240,6 +2256,9 @@ class WhoopBleClient( groundTruthImuSessionId = null groundTruthImuStoppedAtMs = System.currentTimeMillis() _groundTruthImuStatus.update { it.copy(requested = false, note = "Stopped; accepting history repair") } + // The collector closes its window before this call. A running history repair keeps HIGH; + // otherwise release now and let the next offload acquire a fresh bounded lease. + refreshConnectionPriority() } @Synchronized diff --git a/android/app/src/main/java/com/noop/testcentre/ImuSessionFileStore.kt b/android/app/src/main/java/com/noop/testcentre/ImuSessionFileStore.kt index 5aba19470a..c7312bf6fc 100644 --- a/android/app/src/main/java/com/noop/testcentre/ImuSessionFileStore.kt +++ b/android/app/src/main/java/com/noop/testcentre/ImuSessionFileStore.kt @@ -24,13 +24,14 @@ class ImuSessionFileStore(private val context: Context) { fun start(id: String, deviceId: String, fromMs: Long) = synchronized(lock) { prefs.edit().putStringSet("ids", ids() + id).putString("$id.device", deviceId) - .putLong("$id.from", fromMs / 1_000L).remove("$id.to").apply() + .putLong("$id.from", fromMs / 1_000L).putLong("$id.fromMs", fromMs) + .remove("$id.to").remove("$id.toMs").apply() sessionDir(id).mkdirs() } fun complete(id: String, toMs: Long) = synchronized(lock) { flushSession(id) - if (id in ids()) prefs.edit().putLong("$id.to", toMs / 1_000L).apply() + if (id in ids()) prefs.edit().putLong("$id.to", toMs / 1_000L).putLong("$id.toMs", toMs).apply() } fun register(id: String, deviceId: String, fromMs: Long, toMs: Long) = synchronized(lock) { @@ -41,7 +42,28 @@ class ImuSessionFileStore(private val context: Context) { fun remove(id: String) = synchronized(lock) { pending.keys.filter { it.startsWith("$id/") }.forEach(pending::remove) seen.keys.filter { it.startsWith(sessionDir(id).absolutePath) }.forEach(seen::remove) - prefs.edit().putStringSet("ids", ids() - id).remove("$id.device").remove("$id.from").remove("$id.to").apply() + prefs.edit().putStringSet("ids", ids() - id).remove("$id.device").remove("$id.from") + .remove("$id.fromMs").remove("$id.to").remove("$id.toMs").apply() + } + + /** True while a bounded capture is active or still missing seconds recoverable from strap history. */ + fun needsHighThroughput(deviceId: String): Boolean = synchronized(lock) { + ids().any { id -> + if (prefs.getString("$id.device", null) != deviceId) return@any false + if (!prefs.contains("$id.to")) return@any true + val fromMs = prefs.getLong("$id.fromMs", prefs.getLong("$id.from", 0L) * 1_000L) + val toMs = prefs.getLong("$id.toMs", prefs.getLong("$id.to", 0L) * 1_000L) + val baseFrom = (fromMs + 999L) / 1_000L + val to = toMs / 1_000L - 1L + if (baseFrom > to) return@any false + val present = buildSet { + segmentFiles(id).forEach { addAll(timestamps(it)) } + pending.filterKeys { it.startsWith("$id/") }.values.flatten().forEach { add(it.ts) } + } + // Match export's one-second producer-start allowance, without hiding any later gap. + val first = present.minOrNull()?.takeIf { it <= baseFrom + 1 } ?: baseFrom + (first..to).any { it !in present } + } } fun prepareForRead(id: String) = synchronized(lock) { flushSession(id) } @@ -61,11 +83,17 @@ class ImuSessionFileStore(private val context: Context) { } fun stats(id: String, from: Long, to: Long): Stats = synchronized(lock) { - val records = readRecords(id, from, to, includePending = true) + // Coverage needs timestamps, not decoded 6-axis payloads. Reuse the per-file timestamp index + // instead of inflating every .imus block on each one-second UI refresh. + val covered = buildSet { + segmentFiles(id).forEach { file -> addAll(timestamps(file).filter { it in from..to }) } + pending.filterKeys { it.startsWith("$id/") }.values.flatten() + .forEach { record -> if (record.ts in from..to) add(record.ts) } + } val disk = segmentFiles(id).sumOf { it.length() } val pendingBytes = pending.filterKeys { it.startsWith("$id/") }.values.flatten() .sumOf { it.columns.size.toLong() * 2 + RECORD_HEADER_BYTES } - Stats(disk + pendingBytes, records.map { it.ts }.distinct().size, records.minOfOrNull { it.ts }) + Stats(disk + pendingBytes, covered.size, covered.minOrNull()) } fun append(deviceId: String, frame: ByteArray, receivedAtMs: Long = System.currentTimeMillis()): Int = synchronized(lock) { diff --git a/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt b/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt index 78dcb6d110..05bc2cad59 100644 --- a/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt +++ b/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt @@ -26,8 +26,10 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.noop.R import com.noop.testcentre.GroundTruthCollector +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext /** Bounded 5/MG raw-data capture. */ @Composable @@ -51,7 +53,9 @@ fun GroundTruthCollectorScreen(vm: AppViewModel) { while (true) { nowMs = System.currentTimeMillis() latestSensorTs = vm.activeStrapId.takeIf(String::isNotBlank)?.let { vm.repo.latestHrSampleTs(it) } - captureStats = sessions.associate { it.id to collector.captureStats(it, nowMs) } + captureStats = withContext(Dispatchers.IO) { + sessions.associate { it.id to collector.captureStats(it, nowMs) } + } delay(1_000) } } @@ -120,7 +124,7 @@ fun GroundTruthCollectorScreen(vm: AppViewModel) { ) NoopButton(text = stringResource(R.string.ground_truth_stop), kind = NoopButtonKind.Destructive, fullWidth = true, onClick = { - vm.ble.stopGroundTruthImuCapture(); state = collector.stop(); sessions = collector.sessions() + state = collector.stop(); vm.ble.stopGroundTruthImuCapture(); sessions = collector.sessions() }) } else { NoopButton( diff --git a/android/app/src/test/java/com/noop/ble/ConnectionPriorityTest.kt b/android/app/src/test/java/com/noop/ble/ConnectionPriorityTest.kt index 885a436bfb..a5ae2db449 100644 --- a/android/app/src/test/java/com/noop/ble/ConnectionPriorityTest.kt +++ b/android/app/src/test/java/com/noop/ble/ConnectionPriorityTest.kt @@ -16,6 +16,13 @@ import org.junit.Test */ class ConnectionPriorityTest { + @Test fun rawCapturePriorityIsBoundedToCaptureAndRepairTraffic() { + assertTrue(WhoopBleClient.rawCaptureHighPriority(captureActive = true, backfilling = false, needsRepair = false)) + assertTrue(WhoopBleClient.rawCaptureHighPriority(captureActive = false, backfilling = true, needsRepair = true)) + assertFalse(WhoopBleClient.rawCaptureHighPriority(captureActive = false, backfilling = false, needsRepair = true)) + assertFalse(WhoopBleClient.rawCaptureHighPriority(captureActive = false, backfilling = true, needsRepair = false)) + } + @Test fun activeWorkIsAlwaysHigh() { // offload OR live-HR → HIGH, and the idle throttle can't override active work assertEquals( diff --git a/android/app/src/test/java/com/noop/testcentre/RawCaptureExportContractTest.kt b/android/app/src/test/java/com/noop/testcentre/RawCaptureExportContractTest.kt index 2549348d2a..28282a4726 100644 --- a/android/app/src/test/java/com/noop/testcentre/RawCaptureExportContractTest.kt +++ b/android/app/src/test/java/com/noop/testcentre/RawCaptureExportContractTest.kt @@ -35,6 +35,13 @@ class RawCaptureExportContractTest { assertFalse(store.contains("frame.copyOf()")) } + @Test fun liveCoverageUsesTimestampIndexInsteadOfDecodingPayloads() { + val store = source("ImuSessionFileStore.kt") + val stats = store.substringAfter("fun stats(").substringBefore("fun append(") + assertTrue(stats.contains("timestamps(file)")) + assertFalse(stats.contains("readRecords(")) + } + @Test fun editedWindowOwnsPublicEventsAndImuBounds() { val collector = source("GroundTruthCollector.kt") assertTrue(collector.contains("publicEvents(events, summary.startedAtMs, endMs, deviceId)")) diff --git a/docs/RAW_DATA_CAPTURE.md b/docs/RAW_DATA_CAPTURE.md index 89d41e493b..d2165d86b9 100644 --- a/docs/RAW_DATA_CAPTURE.md +++ b/docs/RAW_DATA_CAPTURE.md @@ -43,6 +43,13 @@ and incoming IMU buffers are routed by **strap timestamp**, not by arrival time. the phone is disconnected during part of a recording, matching delayed buffers from a later historical offload can still be appended to the session. Duplicate timestamps are discarded. +On Android, an active 100 Hz capture temporarily requests the high-throughput GATT connection +priority. A later historical offload does the same while it repairs an incomplete capture, then returns +the link to the balanced priority. This bounded lease is independent of the global experimental +history-speed preference. Apple's CoreBluetooth chooses connection parameters itself and exposes no +equivalent app-side priority request, so iOS keeps the same capture and repair lifecycle without a +non-functional transport toggle. + Consequences for consumers: - file order is not chronological: repaired older history may be appended after newer live data; From da9e2b2180e6a06f64a9cf7fdf48e9eed7513e1b Mon Sep 17 00:00:00 2001 From: Bernd Helm Date: Sat, 29 Aug 2026 00:00:18 +0200 Subject: [PATCH 43/45] docs(imu): record observed 100 Hz battery drain --- docs/RAW_DATA_CAPTURE.md | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/docs/RAW_DATA_CAPTURE.md b/docs/RAW_DATA_CAPTURE.md index d2165d86b9..e03115f6c4 100644 --- a/docs/RAW_DATA_CAPTURE.md +++ b/docs/RAW_DATA_CAPTURE.md @@ -109,8 +109,16 @@ suggested archive name is `noop-5mg-raw-.zip`. ## Scope and operational limits -- Capture is deliberately bounded and explicit. The current evidence does **not** establish battery, - flash-retention, thermal, or BLE-airtime costs for continuous 24/7 100 Hz operation. +- Capture is deliberately bounded and explicit. One uncontrolled WHOOP 5/MG discharge trace provides + a useful order of magnitude, not a benchmark: a 5 h 47 min overnight capture consumed about + `0.71 percentage points/hour`, and two later 42–45 minute captures each consumed about + `0.57 percentage points/hour`. Nearby non-capture periods in the same discharge averaged about + `0.31 percentage points/hour`; a separate 5.6-day pre-capture baseline averaged about + `0.46 percentage points/hour`. These single-band observations suggest roughly 1.5–2.3× the normal + drain while 100 Hz is active, depending on the chosen baseline. They do **not** establish a general + runtime guarantee or isolate producer, BLE, and history-repair costs. +- The current evidence does **not** establish flash-retention, thermal, or BLE-airtime costs for + continuous 24/7 100 Hz operation. - A one-hour workout/research capture succeeding does not establish that a 36-hour rolling recorder is safe. Any future rolling buffer needs hardware measurements and an explicit retention policy. - The separately enabled protocol trace remains a general diagnostics tool. Starting a Raw Data From 7f03a2eef6613489cfae2a6f0c42ba0b0dadc314 Mon Sep 17 00:00:00 2001 From: Fanboynz Date: Sat, 29 Aug 2026 13:39:11 +1200 Subject: [PATCH 44/45] Localize the Raw Data Collector into the five ungated iOS locales MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fifty-two of the branch's new iOS strings carried only de/es/fr/pt-PT, so Italian, Polish, Russian and both Chinese locales would have rendered the English source. The Android side of this branch is already complete: all seven locale files gain the same 67 keys. Tools/i18n_audit.py did not catch it. Its hard gate covers the focus set (de/es/fr/pt-PT) and the remaining five are governed by a ratchet that only forbids NEW gaps against a tracked baseline, so a large block of untranslated strings can land green. Worth knowing when reading the audit's OK. Terminology follows the catalogue rather than being coined here: strap is fascia/pasek/браслет/手环/手環, Session is Sessione/Sesja/Сессия/会话/工作階段, and Test Centre is Centro test/Centrum Testowe/Тестовый центр/测试中心/測試中心. The bare "0" entry is left as the author wrote it. It carries no localizations and is flagged shouldTranslate:false, so five identical numerals would be noise, not coverage. Edited line-scoped: each entry is parsed from its own line and re-emitted compact. Re-serialising the catalogue expands every hand-compacted entry and turns this into a several-thousand-line diff; the change is 52 lines. --- Strand/Resources/Localizable.xcstrings | 104 ++++++++++++------------- 1 file changed, 52 insertions(+), 52 deletions(-) diff --git a/Strand/Resources/Localizable.xcstrings b/Strand/Resources/Localizable.xcstrings index d3af8b2a86..497d39c6ee 100644 --- a/Strand/Resources/Localizable.xcstrings +++ b/Strand/Resources/Localizable.xcstrings @@ -208219,60 +208219,60 @@ "A cardiovascular load from time in heart-rate zones (Edwards TRIMP): each zone is weighted so harder ones count for more, summed into one daily figure. Settings offers an exponential alternative (Banister) that credits short, hard efforts more.": { "localizations": { "de": { "stringUnit": { "state": "translated", "value": "Eine kardiovaskuläre Last aus der Zeit in den Herzfrequenzzonen (Edwards-TRIMP): Jede Zone wird gewichtet, sodass härtere Zonen mehr zählen, aufsummiert zu einer Tageszahl. In den Einstellungen gibt es eine exponentielle Alternative (Banister), die kurze, harte Belastungen stärker anrechnet." } }, "en": { "stringUnit": { "state": "translated", "value": "A cardiovascular load from time in heart-rate zones (Edwards TRIMP): each zone is weighted so harder ones count for more, summed into one daily figure. Settings offers an exponential alternative (Banister) that credits short, hard efforts more." } }, "es": { "stringUnit": { "state": "translated", "value": "Una carga cardiovascular a partir del tiempo en las zonas de frecuencia cardíaca (TRIMP de Edwards): cada zona se pondera para que las más duras cuenten más, sumado en una sola cifra diaria. En Ajustes hay una alternativa exponencial (Banister) que valora más los esfuerzos cortos e intensos." } }, "fr": { "stringUnit": { "state": "translated", "value": "Une charge cardiovasculaire issue du temps passé dans les zones de fréquence cardiaque (TRIMP d’Edwards) : chaque zone est pondérée pour que les plus intenses comptent davantage, additionnée en un seul chiffre journalier. Les Réglages proposent une alternative exponentielle (Banister) qui valorise davantage les efforts courts et intenses." } }, "it": { "stringUnit": { "state": "translated", "value": "Un carico cardiovascolare dato dal tempo nelle zone di frequenza cardiaca (TRIMP di Edwards): ogni zona è ponderata in modo che quelle più dure contino di più, sommato in un unico dato giornaliero. Nelle Impostazioni c’è un’alternativa esponenziale (Banister) che premia di più gli sforzi brevi e intensi." } }, "pl": { "stringUnit": { "state": "translated", "value": "Obciążenie układu krążenia wynikające z czasu w strefach tętna (TRIMP Edwardsa): każda strefa jest ważona tak, aby trudniejsze liczyły się bardziej, zsumowane w jedną wartość dzienną. W Ustawieniach jest alternatywa wykładnicza (Banister), która mocniej docenia krótkie, intensywne wysiłki." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Uma carga cardiovascular a partir do tempo nas zonas de frequência cardíaca (TRIMP de Edwards): cada zona é ponderada para que as mais difíceis contem mais, resumido num valor diário. Nas Definições existe uma alternativa exponencial (Banister) que valoriza mais os esforços curtos e intensos." } }, "ru": { "stringUnit": { "state": "translated", "value": "Сердечно-сосудистая нагрузка по времени в пульсовых зонах (TRIMP Эдвардса): каждая зона взвешивается так, что более тяжёлые учитываются сильнее, и всё суммируется в одно дневное значение. В настройках есть экспоненциальная альтернатива (Banister), которая сильнее засчитывает короткие интенсивные усилия." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "根据你在各心率区间的时长得出的心血管负荷(Edwards TRIMP):区间越高权重越大,汇总为一个每日数字。设置中提供指数式替代方案(Banister),会更看重短时高强度的努力。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "根據你在各心率區間停留時間得出的心血管負荷(Edwards TRIMP):區間越高權重越大,加總成每日一個數字。設定中提供指數式替代方案(Banister),會更看重短時高強度的努力。" } } } }, - "WHOOP 5.0 / MG supports live data and strap history. Protocol-research tools are available separately in Test Centre.": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"WHOOP 5.0/MG unterstützt Live-Daten und Bandverlauf. Protokollwerkzeuge sind separat im Testcenter."}},"es":{"stringUnit":{"state":"translated","value":"WHOOP 5.0/MG admite datos en directo e historial. Las herramientas de protocolo están aparte en el Centro de pruebas."}},"fr":{"stringUnit":{"state":"translated","value":"WHOOP 5.0/MG gère les données en direct et l’historique. Les outils de protocole sont séparés dans le Centre de test."}},"pt-PT":{"stringUnit":{"state":"translated","value":"WHOOP 5.0/MG suporta dados em direto e histórico. As ferramentas de protocolo estão separadas no Centro de testes."}}}}, - "Delete this session?": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Diese Session löschen?"}},"es":{"stringUnit":{"state":"translated","value":"¿Eliminar esta sesión?"}},"fr":{"stringUnit":{"state":"translated","value":"Supprimer cette session ?"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Eliminar esta sessão?"}}}}, - "The session %@ and its captured raw data will be deleted permanently.": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Die Session %@ und ihre Rohdaten werden dauerhaft gelöscht."}},"es":{"stringUnit":{"state":"translated","value":"La sesión %@ y sus datos se eliminarán permanentemente."}},"fr":{"stringUnit":{"state":"translated","value":"La session %@ et ses données seront définitivement supprimées."}},"pt-PT":{"stringUnit":{"state":"translated","value":"A sessão %@ e os dados serão eliminados permanentemente."}}}}, - "Delete all sessions?": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Alle Sessions löschen?"}},"es":{"stringUnit":{"state":"translated","value":"¿Eliminar todas las sesiones?"}},"fr":{"stringUnit":{"state":"translated","value":"Supprimer toutes les sessions ?"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Eliminar todas as sessões?"}}}}, - "All %lld recorded sessions and their captured raw data will be deleted permanently.": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Alle %lld Sessions und ihre Rohdaten werden dauerhaft gelöscht."}},"es":{"stringUnit":{"state":"translated","value":"Las %lld sesiones y sus datos se eliminarán permanentemente."}},"fr":{"stringUnit":{"state":"translated","value":"Les %lld sessions et leurs données seront définitivement supprimées."}},"pt-PT":{"stringUnit":{"state":"translated","value":"As %lld sessões e os dados serão eliminados permanentemente."}}}}, - "Couldn't export session": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Session konnte nicht exportiert werden"}},"es":{"stringUnit":{"state":"translated","value":"No se pudo exportar la sesión"}},"fr":{"stringUnit":{"state":"translated","value":"Impossible d’exporter la session"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Não foi possível exportar a sessão"}}}}, - "Unknown error": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Unbekannter Fehler"}},"es":{"stringUnit":{"state":"translated","value":"Error desconocido"}},"fr":{"stringUnit":{"state":"translated","value":"Erreur inconnue"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Erro desconhecido"}}}}, - "Capture coverage": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Abdeckung der Aufnahme"}},"es":{"stringUnit":{"state":"translated","value":"Cobertura de la captura"}},"fr":{"stringUnit":{"state":"translated","value":"Couverture de la capture"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Cobertura da captura"}}}}, - "Band: connected%@": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Band: verbunden%@"}},"es":{"stringUnit":{"state":"translated","value":"Pulsera: conectada%@"}},"fr":{"stringUnit":{"state":"translated","value":"Bracelet : connecté%@"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Bracelete: ligada%@"}}}}, - "Band: disconnected": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Band: getrennt"}},"es":{"stringUnit":{"state":"translated","value":"Pulsera: desconectada"}},"fr":{"stringUnit":{"state":"translated","value":"Bracelet : déconnecté"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Bracelete: desligada"}}}}, - "History sync: running (%lld chunks)": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Verlaufssynchronisierung läuft (%lld Blöcke)"}},"es":{"stringUnit":{"state":"translated","value":"Sincronizando historial (%lld bloques)"}},"fr":{"stringUnit":{"state":"translated","value":"Synchronisation de l’historique (%lld blocs)"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Sincronização do histórico (%lld blocos)"}}}}, - "History sync: idle": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Verlaufssynchronisierung inaktiv"}},"es":{"stringUnit":{"state":"translated","value":"Sincronización inactiva"}},"fr":{"stringUnit":{"state":"translated","value":"Synchronisation inactive"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Sincronização inativa"}}}}, - "Realtime IMU: session active since %@": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Echtzeit-IMU: Session aktiv seit %@"}},"es":{"stringUnit":{"state":"translated","value":"IMU en tiempo real: sesión activa desde %@"}},"fr":{"stringUnit":{"state":"translated","value":"IMU en temps réel : session active depuis %@"}},"pt-PT":{"stringUnit":{"state":"translated","value":"IMU em tempo real: sessão ativa desde %@"}}}}, - "Historical export window": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Vergangenes Exportzeitfenster"}},"es":{"stringUnit":{"state":"translated","value":"Intervalo histórico de exportación"}},"fr":{"stringUnit":{"state":"translated","value":"Plage d’export historique"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Intervalo histórico de exportação"}}}}, - "Create a session from synchronized history without starting a live capture. 100 Hz coverage is included wherever it still exists in the rolling buffer.": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Erstellt eine Session aus dem synchronisierten Verlauf. Noch gepufferte 100-Hz-Daten werden einbezogen."}},"es":{"stringUnit":{"state":"translated","value":"Crea una sesión desde el historial. Incluye los datos de 100 Hz aún disponibles."}},"fr":{"stringUnit":{"state":"translated","value":"Crée une session depuis l’historique. Inclut les données 100 Hz encore disponibles."}},"pt-PT":{"stringUnit":{"state":"translated","value":"Cria uma sessão a partir do histórico. Inclui os dados de 100 Hz ainda disponíveis."}}}}, - "Recorded sessions": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Aufgezeichnete Sessions"}},"es":{"stringUnit":{"state":"translated","value":"Sesiones grabadas"}},"fr":{"stringUnit":{"state":"translated","value":"Sessions enregistrées"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Sessões gravadas"}}}}, - "No sessions recorded yet.": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Noch keine Sessions aufgezeichnet."}},"es":{"stringUnit":{"state":"translated","value":"Aún no hay sesiones grabadas."}},"fr":{"stringUnit":{"state":"translated","value":"Aucune session enregistrée."}},"pt-PT":{"stringUnit":{"state":"translated","value":"Ainda não existem sessões gravadas."}}}}, - "Export status: recording": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Exportstatus: Aufnahme läuft"}},"es":{"stringUnit":{"state":"translated","value":"Estado: grabando"}},"fr":{"stringUnit":{"state":"translated","value":"État : enregistrement"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Estado: a gravar"}}}}, - "IMU: %@": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"IMU: %@"}},"es":{"stringUnit":{"state":"translated","value":"IMU: %@"}},"fr":{"stringUnit":{"state":"translated","value":"IMU : %@"}},"pt-PT":{"stringUnit":{"state":"translated","value":"IMU: %@"}}}}, + "WHOOP 5.0 / MG supports live data and strap history. Protocol-research tools are available separately in Test Centre.": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"WHOOP 5.0/MG unterstützt Live-Daten und Bandverlauf. Protokollwerkzeuge sind separat im Testcenter."}},"es":{"stringUnit":{"state":"translated","value":"WHOOP 5.0/MG admite datos en directo e historial. Las herramientas de protocolo están aparte en el Centro de pruebas."}},"fr":{"stringUnit":{"state":"translated","value":"WHOOP 5.0/MG gère les données en direct et l’historique. Les outils de protocole sont séparés dans le Centre de test."}},"pt-PT":{"stringUnit":{"state":"translated","value":"WHOOP 5.0/MG suporta dados em direto e histórico. As ferramentas de protocolo estão separadas no Centro de testes."}},"it":{"stringUnit":{"state":"translated","value":"WHOOP 5.0/MG supporta i dati in tempo reale e la cronologia della fascia. Gli strumenti di ricerca sul protocollo sono separati nel Centro test."}},"pl":{"stringUnit":{"state":"translated","value":"WHOOP 5.0/MG obsługuje dane na żywo i historię paska. Narzędzia badawcze protokołu są dostępne osobno w Centrum Testowym."}},"ru":{"stringUnit":{"state":"translated","value":"WHOOP 5.0/MG поддерживает данные в реальном времени и историю браслета. Инструменты исследования протокола вынесены в Тестовый центр."}},"zh-Hans":{"stringUnit":{"state":"translated","value":"WHOOP 5.0/MG 支持实时数据和手环历史。协议研究工具单独位于测试中心。"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"WHOOP 5.0/MG 支援即時資料與手環歷史。協定研究工具另置於測試中心。"}}}}, + "Delete this session?": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Diese Session löschen?"}},"es":{"stringUnit":{"state":"translated","value":"¿Eliminar esta sesión?"}},"fr":{"stringUnit":{"state":"translated","value":"Supprimer cette session ?"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Eliminar esta sessão?"}},"it":{"stringUnit":{"state":"translated","value":"Eliminare questa sessione?"}},"pl":{"stringUnit":{"state":"translated","value":"Usunąć tę sesję?"}},"ru":{"stringUnit":{"state":"translated","value":"Удалить эту сессию?"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"要删除此会话吗?"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"要刪除此工作階段嗎?"}}}}, + "The session %@ and its captured raw data will be deleted permanently.": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Die Session %@ und ihre Rohdaten werden dauerhaft gelöscht."}},"es":{"stringUnit":{"state":"translated","value":"La sesión %@ y sus datos se eliminarán permanentemente."}},"fr":{"stringUnit":{"state":"translated","value":"La session %@ et ses données seront définitivement supprimées."}},"pt-PT":{"stringUnit":{"state":"translated","value":"A sessão %@ e os dados serão eliminados permanentemente."}},"it":{"stringUnit":{"state":"translated","value":"La sessione %@ e i suoi dati grezzi verranno eliminati definitivamente."}},"pl":{"stringUnit":{"state":"translated","value":"Sesja %@ i jej surowe dane zostaną trwale usunięte."}},"ru":{"stringUnit":{"state":"translated","value":"Сессия %@ и её сырые данные будут удалены безвозвратно."}},"zh-Hans":{"stringUnit":{"state":"translated","value":"会话 %@ 及其采集的原始数据将被永久删除。"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"工作階段 %@ 及其擷取的原始資料將被永久刪除。"}}}}, + "Delete all sessions?": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Alle Sessions löschen?"}},"es":{"stringUnit":{"state":"translated","value":"¿Eliminar todas las sesiones?"}},"fr":{"stringUnit":{"state":"translated","value":"Supprimer toutes les sessions ?"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Eliminar todas as sessões?"}},"it":{"stringUnit":{"state":"translated","value":"Eliminare tutte le sessioni?"}},"pl":{"stringUnit":{"state":"translated","value":"Usunąć wszystkie sesje?"}},"ru":{"stringUnit":{"state":"translated","value":"Удалить все сессии?"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"要删除所有会话吗?"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"要刪除所有工作階段嗎?"}}}}, + "All %lld recorded sessions and their captured raw data will be deleted permanently.": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Alle %lld Sessions und ihre Rohdaten werden dauerhaft gelöscht."}},"es":{"stringUnit":{"state":"translated","value":"Las %lld sesiones y sus datos se eliminarán permanentemente."}},"fr":{"stringUnit":{"state":"translated","value":"Les %lld sessions et leurs données seront définitivement supprimées."}},"pt-PT":{"stringUnit":{"state":"translated","value":"As %lld sessões e os dados serão eliminados permanentemente."}},"it":{"stringUnit":{"state":"translated","value":"Tutte le %lld sessioni registrate e i loro dati grezzi verranno eliminati definitivamente."}},"pl":{"stringUnit":{"state":"translated","value":"Wszystkie zarejestrowane sesje (%lld) i ich surowe dane zostaną trwale usunięte."}},"ru":{"stringUnit":{"state":"translated","value":"Все записанные сессии (%lld) и их сырые данные будут удалены безвозвратно."}},"zh-Hans":{"stringUnit":{"state":"translated","value":"全部 %lld 个已记录会话及其采集的原始数据将被永久删除。"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"全部 %lld 個已記錄工作階段及其擷取的原始資料將被永久刪除。"}}}}, + "Couldn't export session": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Session konnte nicht exportiert werden"}},"es":{"stringUnit":{"state":"translated","value":"No se pudo exportar la sesión"}},"fr":{"stringUnit":{"state":"translated","value":"Impossible d’exporter la session"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Não foi possível exportar a sessão"}},"it":{"stringUnit":{"state":"translated","value":"Impossibile esportare la sessione"}},"pl":{"stringUnit":{"state":"translated","value":"Nie można wyeksportować sesji"}},"ru":{"stringUnit":{"state":"translated","value":"Не удалось экспортировать сессию"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"无法导出会话"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"無法匯出工作階段"}}}}, + "Unknown error": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Unbekannter Fehler"}},"es":{"stringUnit":{"state":"translated","value":"Error desconocido"}},"fr":{"stringUnit":{"state":"translated","value":"Erreur inconnue"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Erro desconhecido"}},"it":{"stringUnit":{"state":"translated","value":"Errore sconosciuto"}},"pl":{"stringUnit":{"state":"translated","value":"Nieznany błąd"}},"ru":{"stringUnit":{"state":"translated","value":"Неизвестная ошибка"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"未知错误"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"未知錯誤"}}}}, + "Capture coverage": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Abdeckung der Aufnahme"}},"es":{"stringUnit":{"state":"translated","value":"Cobertura de la captura"}},"fr":{"stringUnit":{"state":"translated","value":"Couverture de la capture"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Cobertura da captura"}},"it":{"stringUnit":{"state":"translated","value":"Copertura dell'acquisizione"}},"pl":{"stringUnit":{"state":"translated","value":"Pokrycie nagrania"}},"ru":{"stringUnit":{"state":"translated","value":"Покрытие записи"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"采集覆盖率"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"擷取涵蓋率"}}}}, + "Band: connected%@": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Band: verbunden%@"}},"es":{"stringUnit":{"state":"translated","value":"Pulsera: conectada%@"}},"fr":{"stringUnit":{"state":"translated","value":"Bracelet : connecté%@"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Bracelete: ligada%@"}},"it":{"stringUnit":{"state":"translated","value":"Fascia: collegata%@"}},"pl":{"stringUnit":{"state":"translated","value":"Pasek: połączono%@"}},"ru":{"stringUnit":{"state":"translated","value":"Браслет: подключено%@"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"手环:已连接%@"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"手環:已連接%@"}}}}, + "Band: disconnected": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Band: getrennt"}},"es":{"stringUnit":{"state":"translated","value":"Pulsera: desconectada"}},"fr":{"stringUnit":{"state":"translated","value":"Bracelet : déconnecté"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Bracelete: desligada"}},"it":{"stringUnit":{"state":"translated","value":"Fascia: disconnessa"}},"pl":{"stringUnit":{"state":"translated","value":"Pasek: rozłączono"}},"ru":{"stringUnit":{"state":"translated","value":"Браслет: отключено"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"手环:已断开"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"手環:已中斷連接"}}}}, + "History sync: running (%lld chunks)": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Verlaufssynchronisierung läuft (%lld Blöcke)"}},"es":{"stringUnit":{"state":"translated","value":"Sincronizando historial (%lld bloques)"}},"fr":{"stringUnit":{"state":"translated","value":"Synchronisation de l’historique (%lld blocs)"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Sincronização do histórico (%lld blocos)"}},"it":{"stringUnit":{"state":"translated","value":"Sincronizzazione cronologia: in corso (%lld blocchi)"}},"pl":{"stringUnit":{"state":"translated","value":"Synchronizacja historii: w toku (%lld fragmentów)"}},"ru":{"stringUnit":{"state":"translated","value":"Синхронизация истории: выполняется (%lld фрагментов)"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"历史同步:进行中(%lld 个数据块)"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"歷史同步:進行中(%lld 個資料塊)"}}}}, + "History sync: idle": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Verlaufssynchronisierung inaktiv"}},"es":{"stringUnit":{"state":"translated","value":"Sincronización inactiva"}},"fr":{"stringUnit":{"state":"translated","value":"Synchronisation inactive"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Sincronização inativa"}},"it":{"stringUnit":{"state":"translated","value":"Sincronizzazione cronologia: inattiva"}},"pl":{"stringUnit":{"state":"translated","value":"Synchronizacja historii: bezczynna"}},"ru":{"stringUnit":{"state":"translated","value":"Синхронизация истории: простой"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"历史同步:空闲"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"歷史同步:閒置"}}}}, + "Realtime IMU: session active since %@": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Echtzeit-IMU: Session aktiv seit %@"}},"es":{"stringUnit":{"state":"translated","value":"IMU en tiempo real: sesión activa desde %@"}},"fr":{"stringUnit":{"state":"translated","value":"IMU en temps réel : session active depuis %@"}},"pt-PT":{"stringUnit":{"state":"translated","value":"IMU em tempo real: sessão ativa desde %@"}},"it":{"stringUnit":{"state":"translated","value":"IMU in tempo reale: sessione attiva da %@"}},"pl":{"stringUnit":{"state":"translated","value":"IMU w czasie rzeczywistym: sesja aktywna od %@"}},"ru":{"stringUnit":{"state":"translated","value":"IMU в реальном времени: сессия активна с %@"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"实时 IMU:会话自 %@ 起活动"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"即時 IMU:工作階段自 %@ 起進行中"}}}}, + "Historical export window": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Vergangenes Exportzeitfenster"}},"es":{"stringUnit":{"state":"translated","value":"Intervalo histórico de exportación"}},"fr":{"stringUnit":{"state":"translated","value":"Plage d’export historique"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Intervalo histórico de exportação"}},"it":{"stringUnit":{"state":"translated","value":"Finestra di esportazione storica"}},"pl":{"stringUnit":{"state":"translated","value":"Okno eksportu historycznego"}},"ru":{"stringUnit":{"state":"translated","value":"Окно исторического экспорта"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"历史导出时间窗"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"歷史匯出時間範圍"}}}}, + "Create a session from synchronized history without starting a live capture. 100 Hz coverage is included wherever it still exists in the rolling buffer.": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Erstellt eine Session aus dem synchronisierten Verlauf. Noch gepufferte 100-Hz-Daten werden einbezogen."}},"es":{"stringUnit":{"state":"translated","value":"Crea una sesión desde el historial. Incluye los datos de 100 Hz aún disponibles."}},"fr":{"stringUnit":{"state":"translated","value":"Crée une session depuis l’historique. Inclut les données 100 Hz encore disponibles."}},"pt-PT":{"stringUnit":{"state":"translated","value":"Cria uma sessão a partir do histórico. Inclui os dados de 100 Hz ainda disponíveis."}},"it":{"stringUnit":{"state":"translated","value":"Crea una sessione dalla cronologia sincronizzata senza avviare un'acquisizione dal vivo. La copertura a 100 Hz è inclusa dove è ancora presente nel buffer."}},"pl":{"stringUnit":{"state":"translated","value":"Utwórz sesję ze zsynchronizowanej historii bez uruchamiania nagrywania na żywo. Pokrycie 100 Hz jest dołączane tam, gdzie wciąż istnieje w buforze."}},"ru":{"stringUnit":{"state":"translated","value":"Создать сессию из синхронизированной истории без запуска живой записи. Покрытие 100 Гц включается там, где оно ещё есть в буфере."}},"zh-Hans":{"stringUnit":{"state":"translated","value":"从已同步的历史创建会话,无需启动实时采集。滚动缓冲区中仍存在的 100 Hz 数据会一并包含。"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"從已同步的歷史建立工作階段,無需啟動即時擷取。輪替緩衝區中仍存在的 100 Hz 資料會一併包含。"}}}}, + "Recorded sessions": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Aufgezeichnete Sessions"}},"es":{"stringUnit":{"state":"translated","value":"Sesiones grabadas"}},"fr":{"stringUnit":{"state":"translated","value":"Sessions enregistrées"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Sessões gravadas"}},"it":{"stringUnit":{"state":"translated","value":"Sessioni registrate"}},"pl":{"stringUnit":{"state":"translated","value":"Zarejestrowane sesje"}},"ru":{"stringUnit":{"state":"translated","value":"Записанные сессии"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"已记录会话"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"已記錄工作階段"}}}}, + "No sessions recorded yet.": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Noch keine Sessions aufgezeichnet."}},"es":{"stringUnit":{"state":"translated","value":"Aún no hay sesiones grabadas."}},"fr":{"stringUnit":{"state":"translated","value":"Aucune session enregistrée."}},"pt-PT":{"stringUnit":{"state":"translated","value":"Ainda não existem sessões gravadas."}},"it":{"stringUnit":{"state":"translated","value":"Nessuna sessione registrata."}},"pl":{"stringUnit":{"state":"translated","value":"Nie zarejestrowano jeszcze żadnych sesji."}},"ru":{"stringUnit":{"state":"translated","value":"Сессии ещё не записаны."}},"zh-Hans":{"stringUnit":{"state":"translated","value":"尚未记录任何会话。"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"尚未記錄任何工作階段。"}}}}, + "Export status: recording": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Exportstatus: Aufnahme läuft"}},"es":{"stringUnit":{"state":"translated","value":"Estado: grabando"}},"fr":{"stringUnit":{"state":"translated","value":"État : enregistrement"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Estado: a gravar"}},"it":{"stringUnit":{"state":"translated","value":"Stato esportazione: registrazione"}},"pl":{"stringUnit":{"state":"translated","value":"Stan eksportu: nagrywanie"}},"ru":{"stringUnit":{"state":"translated","value":"Состояние экспорта: запись"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"导出状态:正在记录"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"匯出狀態:記錄中"}}}}, + "IMU: %@": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"IMU: %@"}},"es":{"stringUnit":{"state":"translated","value":"IMU: %@"}},"fr":{"stringUnit":{"state":"translated","value":"IMU : %@"}},"pt-PT":{"stringUnit":{"state":"translated","value":"IMU: %@"}},"it":{"stringUnit":{"state":"translated","value":"IMU: %@"}},"pl":{"stringUnit":{"state":"translated","value":"IMU: %@"}},"ru":{"stringUnit":{"state":"translated","value":"IMU: %@"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"IMU:%@"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"IMU:%@"}}}}, "Last exported %@ · export remains available": {"localizations":{"de":{"stringUnit":{"state":"translated","value":"Zuletzt exportiert: %@ · Export bleibt verfügbar"}},"es":{"stringUnit":{"state":"translated","value":"Última exportación: %@ · la exportación sigue disponible"}},"fr":{"stringUnit":{"state":"translated","value":"Dernier export : %@ · l’export reste disponible"}},"it":{"stringUnit":{"state":"translated","value":"Ultima esportazione: %@ · l’esportazione resta disponibile"}},"pl":{"stringUnit":{"state":"translated","value":"Ostatnio wyeksportowano: %@ · eksport pozostaje dostępny"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Última exportação: %@ · a exportação continua disponível"}},"ru":{"stringUnit":{"state":"translated","value":"Последний экспорт: %@ · экспорт остаётся доступен"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"上次导出:%@ · 仍可再次导出"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"上次匯出:%@ · 仍可再次匯出"}}}}, - "Export status: %lld raw batches": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Exportstatus: %lld Rohdatenblöcke"}},"es":{"stringUnit":{"state":"translated","value":"Estado: %lld bloques sin procesar"}},"fr":{"stringUnit":{"state":"translated","value":"État : %lld blocs bruts"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Estado: %lld blocos em bruto"}}}}, - "Legacy R22 feature-flag experiment. The strap accepts these writes, but NOOP has not observed them enabling a separate live stream. This is not required for normal WHOOP 5/MG support or for the Raw Data Collector. It writes persistent strap settings and may do nothing on your firmware.": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Altes R22-Experiment. Für normalen 5/MG-Betrieb und den Rohdaten-Collector ist es nicht erforderlich."}},"es":{"stringUnit":{"state":"translated","value":"Experimento R22 antiguo. No es necesario para el uso normal ni para el recopilador de datos."}},"fr":{"stringUnit":{"state":"translated","value":"Ancienne expérience R22. Elle n’est pas requise pour l’usage normal ni pour le collecteur."}},"pt-PT":{"stringUnit":{"state":"translated","value":"Experiência R22 antiga. Não é necessária para o uso normal nem para o coletor."}}}}, - "NOOP estimates your steps from your WHOOP's stored motion, calibrated to your phone's step count. It's an estimate, not a hardware step counter; normal WHOOP 5/MG history sync supplies the motion data.": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"NOOP schätzt Schritte aus gespeicherter Bewegung und kalibriert sie mit dem Telefon. Es ist kein Hardware-Schrittzähler."}},"es":{"stringUnit":{"state":"translated","value":"NOOP estima los pasos desde el movimiento y los calibra con el teléfono. No es un contador físico."}},"fr":{"stringUnit":{"state":"translated","value":"NOOP estime les pas depuis le mouvement et les calibre avec le téléphone. Ce n’est pas un compteur matériel."}},"pt-PT":{"stringUnit":{"state":"translated","value":"O NOOP estima os passos a partir do movimento e calibra-os com o telefone. Não é um contador físico."}}}}, - "5/MG RAW DATA COLLECTOR": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"5/MG-ROHDATEN-COLLECTOR"}},"es":{"stringUnit":{"state":"translated","value":"RECOPILADOR DE DATOS 5/MG"}},"fr":{"stringUnit":{"state":"translated","value":"COLLECTEUR DE DONNÉES 5/MG"}},"pt-PT":{"stringUnit":{"state":"translated","value":"RECOLHA DE DADOS 5/MG"}}}}, - "Record, review, export, and delete bounded 100 Hz motion sessions. Normal sync is unchanged.": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Begrenzte 100-Hz-Sessions aufnehmen, prüfen, exportieren und löschen. Normale Synchronisierung bleibt unverändert."}},"es":{"stringUnit":{"state":"translated","value":"Graba, revisa, exporta y elimina sesiones limitadas a 100 Hz. La sincronización normal no cambia."}},"fr":{"stringUnit":{"state":"translated","value":"Enregistrez, consultez, exportez et supprimez des sessions limitées à 100 Hz. La synchronisation normale ne change pas."}},"pt-PT":{"stringUnit":{"state":"translated","value":"Grave, reveja, exporte e elimine sessões limitadas a 100 Hz. A sincronização normal não muda."}}}}, - "Open raw-data collector": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Rohdaten-Collector öffnen"}},"es":{"stringUnit":{"state":"translated","value":"Abrir recopilador de datos"}},"fr":{"stringUnit":{"state":"translated","value":"Ouvrir le collecteur de données"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Abrir recolha de dados"}}}}, - "WHOOP 5/MG connected.": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"WHOOP 5/MG verbunden."}},"es":{"stringUnit":{"state":"translated","value":"WHOOP 5/MG conectado."}},"fr":{"stringUnit":{"state":"translated","value":"WHOOP 5/MG connecté."}},"pt-PT":{"stringUnit":{"state":"translated","value":"WHOOP 5/MG ligado."}}}}, - "Connect your WHOOP 5/MG to start a capture.": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Verbinde dein WHOOP 5/MG, um eine Aufnahme zu starten."}},"es":{"stringUnit":{"state":"translated","value":"Conecta tu WHOOP 5/MG para iniciar una captura."}},"fr":{"stringUnit":{"state":"translated","value":"Connectez votre WHOOP 5/MG pour lancer une capture."}},"pt-PT":{"stringUnit":{"state":"translated","value":"Ligue o WHOOP 5/MG para iniciar uma captura."}}}}, - "5/MG PROTOCOL DIAGNOSTICS": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"5/MG-PROTOKOLLDIAGNOSE"}},"es":{"stringUnit":{"state":"translated","value":"DIAGNÓSTICO DEL PROTOCOLO 5/MG"}},"fr":{"stringUnit":{"state":"translated","value":"DIAGNOSTICS DU PROTOCOLE 5/MG"}},"pt-PT":{"stringUnit":{"state":"translated","value":"DIAGNÓSTICO DO PROTOCOLO 5/MG"}}}}, - "Developer tools for unmapped protocol features. None of these are required for normal WHOOP 5/MG recording, history sync, or the bounded Raw Data Collector.": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Entwicklerwerkzeuge für nicht zugeordnete Protokollfunktionen. Für normale Aufnahme, Verlauf und Rohdaten-Collector nicht erforderlich."}},"es":{"stringUnit":{"state":"translated","value":"Herramientas para funciones de protocolo sin asignar. No son necesarias para el uso normal."}},"fr":{"stringUnit":{"state":"translated","value":"Outils pour les fonctions de protocole non identifiées. Ils ne sont pas requis pour l’usage normal."}},"pt-PT":{"stringUnit":{"state":"translated","value":"Ferramentas para funções de protocolo não identificadas. Não são necessárias para o uso normal."}}}}, - "Protocol probes": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Protokolltests"}},"es":{"stringUnit":{"state":"translated","value":"Sondas de protocolo"}},"fr":{"stringUnit":{"state":"translated","value":"Sondes de protocole"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Sondas de protocolo"}}}}, - "Sends experimental protocol queries and records the replies in the strap log.": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Sendet experimentelle Abfragen und protokolliert die Antworten."}},"es":{"stringUnit":{"state":"translated","value":"Envía consultas experimentales y registra las respuestas."}},"fr":{"stringUnit":{"state":"translated","value":"Envoie des requêtes expérimentales et consigne les réponses."}},"pt-PT":{"stringUnit":{"state":"translated","value":"Envia consultas experimentais e regista as respostas."}}}}, - "Broadcast heart rate from the strap": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Herzfrequenz vom Band übertragen"}},"es":{"stringUnit":{"state":"translated","value":"Transmitir frecuencia cardíaca desde la pulsera"}},"fr":{"stringUnit":{"state":"translated","value":"Diffuser la fréquence cardiaque du bracelet"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Transmitir frequência cardíaca da bracelete"}}}}, - "Writes the reversible 5/MG advertising flag for Garmin, Zwift, and compatible gym equipment.": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Setzt das umkehrbare 5/MG-Werbe-Flag für kompatible Geräte."}},"es":{"stringUnit":{"state":"translated","value":"Activa el indicador reversible 5/MG para equipos compatibles."}},"fr":{"stringUnit":{"state":"translated","value":"Active l’indicateur réversible 5/MG pour les appareils compatibles."}},"pt-PT":{"stringUnit":{"state":"translated","value":"Ativa o indicador reversível 5/MG para equipamento compatível."}}}}, - "Legacy R22 feature-flag experiment": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Altes R22-Feature-Flag-Experiment"}},"es":{"stringUnit":{"state":"translated","value":"Experimento antiguo de indicadores R22"}},"fr":{"stringUnit":{"state":"translated","value":"Ancienne expérience d’indicateurs R22"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Experiência antiga de indicadores R22"}}}}, - "The strap accepts these writes, but NOOP has not observed them enabling a separate live stream. This is not the Raw Data Collector.": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Das Band akzeptiert die Schreibvorgänge, ein eigener Live-Stream wurde aber nicht beobachtet. Dies ist nicht der Rohdaten-Collector."}},"es":{"stringUnit":{"state":"translated","value":"La pulsera acepta estas escrituras, pero no se ha observado otro flujo. Esto no es el recopilador."}},"fr":{"stringUnit":{"state":"translated","value":"Le bracelet accepte ces écritures, mais aucun flux distinct n’a été observé. Ce n’est pas le collecteur."}},"pt-PT":{"stringUnit":{"state":"translated","value":"A bracelete aceita estas escritas, mas não foi observado outro fluxo. Isto não é a recolha de dados."}}}}, - "WHOOP MG ECG raw-data gate": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"WHOOP-MG-Schalter für ECG-Rohdaten"}},"es":{"stringUnit":{"state":"translated","value":"Control de datos ECG de WHOOP MG"}},"fr":{"stringUnit":{"state":"translated","value":"Contrôle des données ECG WHOOP MG"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Controlo de dados ECG WHOOP MG"}}}}, - "MG-only protocol instrumentation, not a medical ECG feature.": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Nur MG-Protokolltests, keine medizinische ECG-Funktion."}},"es":{"stringUnit":{"state":"translated","value":"Instrumentación solo para MG; no es una función ECG médica."}},"fr":{"stringUnit":{"state":"translated","value":"Instrumentation MG uniquement, pas une fonction ECG médicale."}},"pt-PT":{"stringUnit":{"state":"translated","value":"Instrumentação apenas para MG; não é uma função ECG médica."}}}}, - "Passive history/protocol trace": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Passive Verlaufs-/Protokollaufzeichnung"}},"es":{"stringUnit":{"state":"translated","value":"Registro pasivo de historial/protocolo"}},"fr":{"stringUnit":{"state":"translated","value":"Trace passive d’historique/protocole"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Registo passivo de histórico/protocolo"}}}}, - "Records frames that already arrive. It does not start sensors and can create large files. Use the export section below to save the trace with its strap log.": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Zeichnet bereits eintreffende Frames auf, startet keine Sensoren und kann große Dateien erzeugen. Der Export unten fügt das Bandprotokoll hinzu."}},"es":{"stringUnit":{"state":"translated","value":"Registra las tramas que ya llegan. No inicia sensores y puede crear archivos grandes. El export inferior incluye el registro."}},"fr":{"stringUnit":{"state":"translated","value":"Enregistre les trames déjà reçues. Ne démarre aucun capteur et peut créer de gros fichiers. L’export ci-dessous joint le journal."}},"pt-PT":{"stringUnit":{"state":"translated","value":"Regista os pacotes que já chegam. Não inicia sensores e pode criar ficheiros grandes. A exportação abaixo inclui o registo."}}}}, - "Couldn't delete session": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Session konnte nicht gelöscht werden"}},"es":{"stringUnit":{"state":"translated","value":"No se pudo eliminar la sesión"}},"fr":{"stringUnit":{"state":"translated","value":"Impossible de supprimer la session"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Não foi possível eliminar a sessão"}}}}, - "5/MG Raw Data Collector": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"5/MG-Rohdaten-Collector"}},"es":{"stringUnit":{"state":"translated","value":"Recopilador de datos sin procesar 5/MG"}},"fr":{"stringUnit":{"state":"translated","value":"Collecteur de données brutes 5/MG"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Recolha de dados em bruto 5/MG"}}}}, - "Record a bounded 100 Hz motion session and export its complete timeline.": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Eine begrenzte 100-Hz-Bewegungssession aufnehmen und vollständig exportieren."}},"es":{"stringUnit":{"state":"translated","value":"Graba una sesión de movimiento limitada a 100 Hz y exporta su cronología completa."}},"fr":{"stringUnit":{"state":"translated","value":"Enregistrez une session de mouvement 100 Hz limitée et exportez sa chronologie complète."}},"pt-PT":{"stringUnit":{"state":"translated","value":"Grave uma sessão de movimento limitada a 100 Hz e exporte a cronologia completa."}}}}, - "Start raw-data session": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Rohdaten-Session starten"}},"es":{"stringUnit":{"state":"translated","value":"Iniciar sesión de datos sin procesar"}},"fr":{"stringUnit":{"state":"translated","value":"Démarrer la session de données brutes"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Iniciar sessão de dados em bruto"}}}}, - "Add historical session": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Historische Session hinzufügen"}},"es":{"stringUnit":{"state":"translated","value":"Añadir sesión histórica"}},"fr":{"stringUnit":{"state":"translated","value":"Ajouter une session historique"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Adicionar sessão histórica"}}}}, - "Delete all sessions": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Alle Sessions löschen"}},"es":{"stringUnit":{"state":"translated","value":"Eliminar todas las sesiones"}},"fr":{"stringUnit":{"state":"translated","value":"Supprimer toutes les sessions"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Eliminar todas as sessões"}}}}, - "Session comment": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Session-Kommentar"}},"es":{"stringUnit":{"state":"translated","value":"Comentario de la sesión"}},"fr":{"stringUnit":{"state":"translated","value":"Commentaire de session"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Comentário da sessão"}}}}, - "Building export…": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Export wird erstellt …"}},"es":{"stringUnit":{"state":"translated","value":"Creando exportación…"}},"fr":{"stringUnit":{"state":"translated","value":"Création de l’export…"}},"pt-PT":{"stringUnit":{"state":"translated","value":"A criar exportação…"}}}}, - "Export session": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Session exportieren"}},"es":{"stringUnit":{"state":"translated","value":"Exportar sesión"}},"fr":{"stringUnit":{"state":"translated","value":"Exporter la session"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Exportar sessão"}}}}, - "Delete session": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Session löschen"}},"es":{"stringUnit":{"state":"translated","value":"Eliminar sesión"}},"fr":{"stringUnit":{"state":"translated","value":"Supprimer la session"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Eliminar sessão"}}}}, - "The export file could not be created or shared.": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Die Exportdatei konnte nicht erstellt oder geteilt werden."}},"es":{"stringUnit":{"state":"translated","value":"No se pudo crear ni compartir el archivo de exportación."}},"fr":{"stringUnit":{"state":"translated","value":"Le fichier d’export n’a pas pu être créé ou partagé."}},"pt-PT":{"stringUnit":{"state":"translated","value":"Não foi possível criar ou partilhar o ficheiro de exportação."}}}}, - "To": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Bis"}},"es":{"stringUnit":{"state":"translated","value":"Hasta"}},"fr":{"stringUnit":{"state":"translated","value":"Au"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Até"}}}}, + "Export status: %lld raw batches": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Exportstatus: %lld Rohdatenblöcke"}},"es":{"stringUnit":{"state":"translated","value":"Estado: %lld bloques sin procesar"}},"fr":{"stringUnit":{"state":"translated","value":"État : %lld blocs bruts"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Estado: %lld blocos em bruto"}},"it":{"stringUnit":{"state":"translated","value":"Stato esportazione: %lld blocchi di dati grezzi"}},"pl":{"stringUnit":{"state":"translated","value":"Stan eksportu: %lld partii surowych danych"}},"ru":{"stringUnit":{"state":"translated","value":"Состояние экспорта: %lld пакетов сырых данных"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"导出状态:%lld 批原始数据"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"匯出狀態:%lld 批原始資料"}}}}, + "Legacy R22 feature-flag experiment. The strap accepts these writes, but NOOP has not observed them enabling a separate live stream. This is not required for normal WHOOP 5/MG support or for the Raw Data Collector. It writes persistent strap settings and may do nothing on your firmware.": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Altes R22-Experiment. Für normalen 5/MG-Betrieb und den Rohdaten-Collector ist es nicht erforderlich."}},"es":{"stringUnit":{"state":"translated","value":"Experimento R22 antiguo. No es necesario para el uso normal ni para el recopilador de datos."}},"fr":{"stringUnit":{"state":"translated","value":"Ancienne expérience R22. Elle n’est pas requise pour l’usage normal ni pour le collecteur."}},"pt-PT":{"stringUnit":{"state":"translated","value":"Experiência R22 antiga. Não é necessária para o uso normal nem para o coletor."}},"it":{"stringUnit":{"state":"translated","value":"Vecchio esperimento con i flag R22. La fascia accetta queste scritture, ma NOOP non ha osservato l'attivazione di un flusso dal vivo separato. Non è necessario per il normale supporto WHOOP 5/MG né per il Raccoglitore dati grezzi. Scrive impostazioni persistenti sulla fascia e potrebbe non avere effetto sul tuo firmware."}},"pl":{"stringUnit":{"state":"translated","value":"Dawny eksperyment z flagami R22. Pasek przyjmuje te zapisy, ale NOOP nie zaobserwował uruchomienia osobnego strumienia na żywo. Nie jest to wymagane do normalnej obsługi WHOOP 5/MG ani do Kolektora danych surowych. Zapisuje trwałe ustawienia paska i może nie działać na Twoim oprogramowaniu."}},"ru":{"stringUnit":{"state":"translated","value":"Старый эксперимент с флагами R22. Браслет принимает эти записи, но NOOP не наблюдал включения отдельного живого потока. Для обычной поддержки WHOOP 5/MG и Сборщика сырых данных это не требуется. Записывает постоянные настройки браслета и может ничего не дать на вашей прошивке."}},"zh-Hans":{"stringUnit":{"state":"translated","value":"旧的 R22 功能标记实验。手环会接受这些写入,但 NOOP 未观察到由此启用独立的实时数据流。正常的 WHOOP 5/MG 支持和原始数据采集器都不需要它。它会写入持久的手环设置,在你的固件上可能毫无作用。"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"舊的 R22 功能標記實驗。手環會接受這些寫入,但 NOOP 未觀察到因此啟用獨立的即時資料流。正常的 WHOOP 5/MG 支援與原始資料收集器都不需要它。它會寫入持久的手環設定,在你的韌體上可能毫無作用。"}}}}, + "NOOP estimates your steps from your WHOOP's stored motion, calibrated to your phone's step count. It's an estimate, not a hardware step counter; normal WHOOP 5/MG history sync supplies the motion data.": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"NOOP schätzt Schritte aus gespeicherter Bewegung und kalibriert sie mit dem Telefon. Es ist kein Hardware-Schrittzähler."}},"es":{"stringUnit":{"state":"translated","value":"NOOP estima los pasos desde el movimiento y los calibra con el teléfono. No es un contador físico."}},"fr":{"stringUnit":{"state":"translated","value":"NOOP estime les pas depuis le mouvement et les calibre avec le téléphone. Ce n’est pas un compteur matériel."}},"pt-PT":{"stringUnit":{"state":"translated","value":"O NOOP estima os passos a partir do movimento e calibra-os com o telefone. Não é um contador físico."}},"it":{"stringUnit":{"state":"translated","value":"NOOP stima i tuoi passi dal movimento memorizzato dal WHOOP, calibrato sul contapassi del telefono. È una stima, non un contapassi hardware; i dati di movimento arrivano dalla normale sincronizzazione della cronologia WHOOP 5/MG."}},"pl":{"stringUnit":{"state":"translated","value":"NOOP szacuje kroki na podstawie ruchu zapisanego przez WHOOP, kalibrując je licznikiem kroków telefonu. To oszacowanie, a nie sprzętowy krokomierz; dane o ruchu pochodzą ze zwykłej synchronizacji historii WHOOP 5/MG."}},"ru":{"stringUnit":{"state":"translated","value":"NOOP оценивает шаги по сохранённому движению WHOOP, калибруя их по шагомеру телефона. Это оценка, а не аппаратный шагомер; данные о движении приходят из обычной синхронизации истории WHOOP 5/MG."}},"zh-Hans":{"stringUnit":{"state":"translated","value":"NOOP 根据 WHOOP 存储的运动数据估算步数,并以手机步数进行校准。这是估算值,而非硬件计步器;运动数据来自正常的 WHOOP 5/MG 历史同步。"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"NOOP 根據 WHOOP 儲存的運動資料估算步數,並以手機步數進行校準。這是估算值,而非硬體計步器;運動資料來自正常的 WHOOP 5/MG 歷史同步。"}}}}, + "5/MG RAW DATA COLLECTOR": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"5/MG-ROHDATEN-COLLECTOR"}},"es":{"stringUnit":{"state":"translated","value":"RECOPILADOR DE DATOS 5/MG"}},"fr":{"stringUnit":{"state":"translated","value":"COLLECTEUR DE DONNÉES 5/MG"}},"pt-PT":{"stringUnit":{"state":"translated","value":"RECOLHA DE DADOS 5/MG"}},"it":{"stringUnit":{"state":"translated","value":"RACCOGLITORE DATI GREZZI 5/MG"}},"pl":{"stringUnit":{"state":"translated","value":"KOLEKTOR DANYCH SUROWYCH 5/MG"}},"ru":{"stringUnit":{"state":"translated","value":"СБОРЩИК СЫРЫХ ДАННЫХ 5/MG"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"5/MG 原始数据采集器"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"5/MG 原始資料收集器"}}}}, + "Record, review, export, and delete bounded 100 Hz motion sessions. Normal sync is unchanged.": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Begrenzte 100-Hz-Sessions aufnehmen, prüfen, exportieren und löschen. Normale Synchronisierung bleibt unverändert."}},"es":{"stringUnit":{"state":"translated","value":"Graba, revisa, exporta y elimina sesiones limitadas a 100 Hz. La sincronización normal no cambia."}},"fr":{"stringUnit":{"state":"translated","value":"Enregistrez, consultez, exportez et supprimez des sessions limitées à 100 Hz. La synchronisation normale ne change pas."}},"pt-PT":{"stringUnit":{"state":"translated","value":"Grave, reveja, exporte e elimine sessões limitadas a 100 Hz. A sincronização normal não muda."}},"it":{"stringUnit":{"state":"translated","value":"Registra, esamina, esporta ed elimina sessioni di movimento a 100 Hz delimitate. La sincronizzazione normale non cambia."}},"pl":{"stringUnit":{"state":"translated","value":"Nagrywaj, przeglądaj, eksportuj i usuwaj ograniczone sesje ruchu 100 Hz. Zwykła synchronizacja pozostaje bez zmian."}},"ru":{"stringUnit":{"state":"translated","value":"Записывайте, просматривайте, экспортируйте и удаляйте ограниченные сессии движения 100 Гц. Обычная синхронизация не меняется."}},"zh-Hans":{"stringUnit":{"state":"translated","value":"记录、查看、导出和删除有时长限制的 100 Hz 运动会话。正常同步不受影响。"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"記錄、檢視、匯出並刪除有時長限制的 100 Hz 運動工作階段。正常同步不受影響。"}}}}, + "Open raw-data collector": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Rohdaten-Collector öffnen"}},"es":{"stringUnit":{"state":"translated","value":"Abrir recopilador de datos"}},"fr":{"stringUnit":{"state":"translated","value":"Ouvrir le collecteur de données"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Abrir recolha de dados"}},"it":{"stringUnit":{"state":"translated","value":"Apri il raccoglitore dati grezzi"}},"pl":{"stringUnit":{"state":"translated","value":"Otwórz kolektor danych surowych"}},"ru":{"stringUnit":{"state":"translated","value":"Открыть сборщик сырых данных"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"打开原始数据采集器"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"開啟原始資料收集器"}}}}, + "WHOOP 5/MG connected.": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"WHOOP 5/MG verbunden."}},"es":{"stringUnit":{"state":"translated","value":"WHOOP 5/MG conectado."}},"fr":{"stringUnit":{"state":"translated","value":"WHOOP 5/MG connecté."}},"pt-PT":{"stringUnit":{"state":"translated","value":"WHOOP 5/MG ligado."}},"it":{"stringUnit":{"state":"translated","value":"WHOOP 5/MG collegato."}},"pl":{"stringUnit":{"state":"translated","value":"WHOOP 5/MG połączony."}},"ru":{"stringUnit":{"state":"translated","value":"WHOOP 5/MG подключён."}},"zh-Hans":{"stringUnit":{"state":"translated","value":"WHOOP 5/MG 已连接。"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"WHOOP 5/MG 已連接。"}}}}, + "Connect your WHOOP 5/MG to start a capture.": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Verbinde dein WHOOP 5/MG, um eine Aufnahme zu starten."}},"es":{"stringUnit":{"state":"translated","value":"Conecta tu WHOOP 5/MG para iniciar una captura."}},"fr":{"stringUnit":{"state":"translated","value":"Connectez votre WHOOP 5/MG pour lancer une capture."}},"pt-PT":{"stringUnit":{"state":"translated","value":"Ligue o WHOOP 5/MG para iniciar uma captura."}},"it":{"stringUnit":{"state":"translated","value":"Collega il tuo WHOOP 5/MG per avviare un'acquisizione."}},"pl":{"stringUnit":{"state":"translated","value":"Połącz WHOOP 5/MG, aby rozpocząć nagrywanie."}},"ru":{"stringUnit":{"state":"translated","value":"Подключите WHOOP 5/MG, чтобы начать запись."}},"zh-Hans":{"stringUnit":{"state":"translated","value":"连接你的 WHOOP 5/MG 以开始采集。"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"連接你的 WHOOP 5/MG 以開始擷取。"}}}}, + "5/MG PROTOCOL DIAGNOSTICS": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"5/MG-PROTOKOLLDIAGNOSE"}},"es":{"stringUnit":{"state":"translated","value":"DIAGNÓSTICO DEL PROTOCOLO 5/MG"}},"fr":{"stringUnit":{"state":"translated","value":"DIAGNOSTICS DU PROTOCOLE 5/MG"}},"pt-PT":{"stringUnit":{"state":"translated","value":"DIAGNÓSTICO DO PROTOCOLO 5/MG"}},"it":{"stringUnit":{"state":"translated","value":"DIAGNOSTICA PROTOCOLLO 5/MG"}},"pl":{"stringUnit":{"state":"translated","value":"DIAGNOSTYKA PROTOKOŁU 5/MG"}},"ru":{"stringUnit":{"state":"translated","value":"ДИАГНОСТИКА ПРОТОКОЛА 5/MG"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"5/MG 协议诊断"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"5/MG 協定診斷"}}}}, + "Developer tools for unmapped protocol features. None of these are required for normal WHOOP 5/MG recording, history sync, or the bounded Raw Data Collector.": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Entwicklerwerkzeuge für nicht zugeordnete Protokollfunktionen. Für normale Aufnahme, Verlauf und Rohdaten-Collector nicht erforderlich."}},"es":{"stringUnit":{"state":"translated","value":"Herramientas para funciones de protocolo sin asignar. No son necesarias para el uso normal."}},"fr":{"stringUnit":{"state":"translated","value":"Outils pour les fonctions de protocole non identifiées. Ils ne sont pas requis pour l’usage normal."}},"pt-PT":{"stringUnit":{"state":"translated","value":"Ferramentas para funções de protocolo não identificadas. Não são necessárias para o uso normal."}},"it":{"stringUnit":{"state":"translated","value":"Strumenti per sviluppatori per funzioni di protocollo non mappate. Nessuno è necessario per la normale registrazione WHOOP 5/MG, la sincronizzazione della cronologia o il Raccoglitore dati grezzi."}},"pl":{"stringUnit":{"state":"translated","value":"Narzędzia deweloperskie do niezmapowanych funkcji protokołu. Żadne z nich nie są wymagane do zwykłego nagrywania WHOOP 5/MG, synchronizacji historii ani Kolektora danych surowych."}},"ru":{"stringUnit":{"state":"translated","value":"Инструменты разработчика для неразобранных функций протокола. Ничего из этого не требуется для обычной записи WHOOP 5/MG, синхронизации истории или Сборщика сырых данных."}},"zh-Hans":{"stringUnit":{"state":"translated","value":"用于未映射协议功能的开发者工具。正常的 WHOOP 5/MG 记录、历史同步和原始数据采集器都不需要这些。"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"用於未對應協定功能的開發者工具。正常的 WHOOP 5/MG 記錄、歷史同步與原始資料收集器都不需要這些。"}}}}, + "Protocol probes": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Protokolltests"}},"es":{"stringUnit":{"state":"translated","value":"Sondas de protocolo"}},"fr":{"stringUnit":{"state":"translated","value":"Sondes de protocole"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Sondas de protocolo"}},"it":{"stringUnit":{"state":"translated","value":"Sonde di protocollo"}},"pl":{"stringUnit":{"state":"translated","value":"Sondy protokołu"}},"ru":{"stringUnit":{"state":"translated","value":"Пробы протокола"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"协议探测"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"協定探測"}}}}, + "Sends experimental protocol queries and records the replies in the strap log.": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Sendet experimentelle Abfragen und protokolliert die Antworten."}},"es":{"stringUnit":{"state":"translated","value":"Envía consultas experimentales y registra las respuestas."}},"fr":{"stringUnit":{"state":"translated","value":"Envoie des requêtes expérimentales et consigne les réponses."}},"pt-PT":{"stringUnit":{"state":"translated","value":"Envia consultas experimentais e regista as respostas."}},"it":{"stringUnit":{"state":"translated","value":"Invia query sperimentali e registra le risposte nel log della fascia."}},"pl":{"stringUnit":{"state":"translated","value":"Wysyła eksperymentalne zapytania protokołu i zapisuje odpowiedzi w dzienniku paska."}},"ru":{"stringUnit":{"state":"translated","value":"Отправляет экспериментальные запросы протокола и записывает ответы в журнал браслета."}},"zh-Hans":{"stringUnit":{"state":"translated","value":"发送实验性协议查询,并将回复记录到手环日志中。"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"傳送實驗性協定查詢,並將回覆記錄到手環日誌中。"}}}}, + "Broadcast heart rate from the strap": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Herzfrequenz vom Band übertragen"}},"es":{"stringUnit":{"state":"translated","value":"Transmitir frecuencia cardíaca desde la pulsera"}},"fr":{"stringUnit":{"state":"translated","value":"Diffuser la fréquence cardiaque du bracelet"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Transmitir frequência cardíaca da bracelete"}},"it":{"stringUnit":{"state":"translated","value":"Trasmetti la frequenza cardiaca dalla fascia"}},"pl":{"stringUnit":{"state":"translated","value":"Nadawaj tętno z paska"}},"ru":{"stringUnit":{"state":"translated","value":"Транслировать пульс с браслета"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"从手环广播心率"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"從手環廣播心率"}}}}, + "Writes the reversible 5/MG advertising flag for Garmin, Zwift, and compatible gym equipment.": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Setzt das umkehrbare 5/MG-Werbe-Flag für kompatible Geräte."}},"es":{"stringUnit":{"state":"translated","value":"Activa el indicador reversible 5/MG para equipos compatibles."}},"fr":{"stringUnit":{"state":"translated","value":"Active l’indicateur réversible 5/MG pour les appareils compatibles."}},"pt-PT":{"stringUnit":{"state":"translated","value":"Ativa o indicador reversível 5/MG para equipamento compatível."}},"it":{"stringUnit":{"state":"translated","value":"Scrive il flag di advertising 5/MG reversibile per Garmin, Zwift e attrezzature da palestra compatibili."}},"pl":{"stringUnit":{"state":"translated","value":"Zapisuje odwracalną flagę rozgłaszania 5/MG dla Garmina, Zwifta i zgodnego sprzętu na siłowni."}},"ru":{"stringUnit":{"state":"translated","value":"Записывает обратимый флаг вещания 5/MG для Garmin, Zwift и совместимых тренажёров."}},"zh-Hans":{"stringUnit":{"state":"translated","value":"写入可撤销的 5/MG 广播标记,供 Garmin、Zwift 及兼容健身器材使用。"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"寫入可撤銷的 5/MG 廣播標記,供 Garmin、Zwift 及相容健身器材使用。"}}}}, + "Legacy R22 feature-flag experiment": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Altes R22-Feature-Flag-Experiment"}},"es":{"stringUnit":{"state":"translated","value":"Experimento antiguo de indicadores R22"}},"fr":{"stringUnit":{"state":"translated","value":"Ancienne expérience d’indicateurs R22"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Experiência antiga de indicadores R22"}},"it":{"stringUnit":{"state":"translated","value":"Vecchio esperimento con i flag R22"}},"pl":{"stringUnit":{"state":"translated","value":"Dawny eksperyment z flagami R22"}},"ru":{"stringUnit":{"state":"translated","value":"Старый эксперимент с флагами R22"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"旧的 R22 功能标记实验"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"舊的 R22 功能標記實驗"}}}}, + "The strap accepts these writes, but NOOP has not observed them enabling a separate live stream. This is not the Raw Data Collector.": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Das Band akzeptiert die Schreibvorgänge, ein eigener Live-Stream wurde aber nicht beobachtet. Dies ist nicht der Rohdaten-Collector."}},"es":{"stringUnit":{"state":"translated","value":"La pulsera acepta estas escrituras, pero no se ha observado otro flujo. Esto no es el recopilador."}},"fr":{"stringUnit":{"state":"translated","value":"Le bracelet accepte ces écritures, mais aucun flux distinct n’a été observé. Ce n’est pas le collecteur."}},"pt-PT":{"stringUnit":{"state":"translated","value":"A bracelete aceita estas escritas, mas não foi observado outro fluxo. Isto não é a recolha de dados."}},"it":{"stringUnit":{"state":"translated","value":"La fascia accetta queste scritture, ma NOOP non ha osservato l'attivazione di un flusso dal vivo separato. Questo non è il Raccoglitore dati grezzi."}},"pl":{"stringUnit":{"state":"translated","value":"Pasek przyjmuje te zapisy, ale NOOP nie zaobserwował uruchomienia osobnego strumienia na żywo. To nie jest Kolektor danych surowych."}},"ru":{"stringUnit":{"state":"translated","value":"Браслет принимает эти записи, но NOOP не наблюдал включения отдельного живого потока. Это не Сборщик сырых данных."}},"zh-Hans":{"stringUnit":{"state":"translated","value":"手环会接受这些写入,但 NOOP 未观察到由此启用独立的实时数据流。这不是原始数据采集器。"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"手環會接受這些寫入,但 NOOP 未觀察到因此啟用獨立的即時資料流。這不是原始資料收集器。"}}}}, + "WHOOP MG ECG raw-data gate": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"WHOOP-MG-Schalter für ECG-Rohdaten"}},"es":{"stringUnit":{"state":"translated","value":"Control de datos ECG de WHOOP MG"}},"fr":{"stringUnit":{"state":"translated","value":"Contrôle des données ECG WHOOP MG"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Controlo de dados ECG WHOOP MG"}},"it":{"stringUnit":{"state":"translated","value":"Interruttore dati grezzi ECG per WHOOP MG"}},"pl":{"stringUnit":{"state":"translated","value":"Przełącznik surowych danych EKG WHOOP MG"}},"ru":{"stringUnit":{"state":"translated","value":"Переключатель сырых данных ЭКГ WHOOP MG"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"WHOOP MG 心电原始数据开关"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"WHOOP MG 心電原始資料開關"}}}}, + "MG-only protocol instrumentation, not a medical ECG feature.": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Nur MG-Protokolltests, keine medizinische ECG-Funktion."}},"es":{"stringUnit":{"state":"translated","value":"Instrumentación solo para MG; no es una función ECG médica."}},"fr":{"stringUnit":{"state":"translated","value":"Instrumentation MG uniquement, pas une fonction ECG médicale."}},"pt-PT":{"stringUnit":{"state":"translated","value":"Instrumentação apenas para MG; não é uma função ECG médica."}},"it":{"stringUnit":{"state":"translated","value":"Strumentazione di protocollo solo per MG, non una funzione ECG medica."}},"pl":{"stringUnit":{"state":"translated","value":"Instrumentacja protokołu tylko dla MG, nie jest to medyczna funkcja EKG."}},"ru":{"stringUnit":{"state":"translated","value":"Протокольная инструментовка только для MG, а не медицинская функция ЭКГ."}},"zh-Hans":{"stringUnit":{"state":"translated","value":"仅限 MG 的协议检测,并非医疗心电功能。"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"僅限 MG 的協定檢測,並非醫療心電功能。"}}}}, + "Passive history/protocol trace": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Passive Verlaufs-/Protokollaufzeichnung"}},"es":{"stringUnit":{"state":"translated","value":"Registro pasivo de historial/protocolo"}},"fr":{"stringUnit":{"state":"translated","value":"Trace passive d’historique/protocole"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Registo passivo de histórico/protocolo"}},"it":{"stringUnit":{"state":"translated","value":"Traccia passiva di cronologia/protocollo"}},"pl":{"stringUnit":{"state":"translated","value":"Pasywny zapis historii/protokołu"}},"ru":{"stringUnit":{"state":"translated","value":"Пассивная трассировка истории/протокола"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"被动历史/协议追踪"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"被動歷史/協定追蹤"}}}}, + "Records frames that already arrive. It does not start sensors and can create large files. Use the export section below to save the trace with its strap log.": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Zeichnet bereits eintreffende Frames auf, startet keine Sensoren und kann große Dateien erzeugen. Der Export unten fügt das Bandprotokoll hinzu."}},"es":{"stringUnit":{"state":"translated","value":"Registra las tramas que ya llegan. No inicia sensores y puede crear archivos grandes. El export inferior incluye el registro."}},"fr":{"stringUnit":{"state":"translated","value":"Enregistre les trames déjà reçues. Ne démarre aucun capteur et peut créer de gros fichiers. L’export ci-dessous joint le journal."}},"pt-PT":{"stringUnit":{"state":"translated","value":"Regista os pacotes que já chegam. Não inicia sensores e pode criar ficheiros grandes. A exportação abaixo inclui o registo."}},"it":{"stringUnit":{"state":"translated","value":"Registra i frame che arrivano già. Non avvia i sensori e può creare file di grandi dimensioni. Usa la sezione di esportazione qui sotto per salvare la traccia con il log della fascia."}},"pl":{"stringUnit":{"state":"translated","value":"Zapisuje ramki, które i tak przychodzą. Nie uruchamia czujników i może tworzyć duże pliki. Użyj sekcji eksportu poniżej, aby zapisać zapis wraz z dziennikiem paska."}},"ru":{"stringUnit":{"state":"translated","value":"Записывает кадры, которые и так приходят. Не запускает датчики и может создавать большие файлы. Используйте раздел экспорта ниже, чтобы сохранить трассировку вместе с журналом браслета."}},"zh-Hans":{"stringUnit":{"state":"translated","value":"记录本就到达的数据帧。它不会启动传感器,并可能生成很大的文件。请使用下方的导出部分,将追踪记录连同手环日志一起保存。"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"記錄本就到達的資料框。它不會啟動感測器,並可能產生很大的檔案。請使用下方的匯出區塊,將追蹤記錄連同手環日誌一起儲存。"}}}}, + "Couldn't delete session": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Session konnte nicht gelöscht werden"}},"es":{"stringUnit":{"state":"translated","value":"No se pudo eliminar la sesión"}},"fr":{"stringUnit":{"state":"translated","value":"Impossible de supprimer la session"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Não foi possível eliminar a sessão"}},"it":{"stringUnit":{"state":"translated","value":"Impossibile eliminare la sessione"}},"pl":{"stringUnit":{"state":"translated","value":"Nie można usunąć sesji"}},"ru":{"stringUnit":{"state":"translated","value":"Не удалось удалить сессию"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"无法删除会话"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"無法刪除工作階段"}}}}, + "5/MG Raw Data Collector": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"5/MG-Rohdaten-Collector"}},"es":{"stringUnit":{"state":"translated","value":"Recopilador de datos sin procesar 5/MG"}},"fr":{"stringUnit":{"state":"translated","value":"Collecteur de données brutes 5/MG"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Recolha de dados em bruto 5/MG"}},"it":{"stringUnit":{"state":"translated","value":"Raccoglitore dati grezzi 5/MG"}},"pl":{"stringUnit":{"state":"translated","value":"Kolektor danych surowych 5/MG"}},"ru":{"stringUnit":{"state":"translated","value":"Сборщик сырых данных 5/MG"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"5/MG 原始数据采集器"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"5/MG 原始資料收集器"}}}}, + "Record a bounded 100 Hz motion session and export its complete timeline.": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Eine begrenzte 100-Hz-Bewegungssession aufnehmen und vollständig exportieren."}},"es":{"stringUnit":{"state":"translated","value":"Graba una sesión de movimiento limitada a 100 Hz y exporta su cronología completa."}},"fr":{"stringUnit":{"state":"translated","value":"Enregistrez une session de mouvement 100 Hz limitée et exportez sa chronologie complète."}},"pt-PT":{"stringUnit":{"state":"translated","value":"Grave uma sessão de movimento limitada a 100 Hz e exporte a cronologia completa."}},"it":{"stringUnit":{"state":"translated","value":"Registra una sessione di movimento a 100 Hz delimitata ed esportane la cronologia completa."}},"pl":{"stringUnit":{"state":"translated","value":"Nagraj ograniczoną sesję ruchu 100 Hz i wyeksportuj jej pełną oś czasu."}},"ru":{"stringUnit":{"state":"translated","value":"Запишите ограниченную сессию движения 100 Гц и экспортируйте её полную временную шкалу."}},"zh-Hans":{"stringUnit":{"state":"translated","value":"记录一段有时长限制的 100 Hz 运动会话,并导出其完整时间轴。"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"記錄一段有時長限制的 100 Hz 運動工作階段,並匯出其完整時間軸。"}}}}, + "Start raw-data session": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Rohdaten-Session starten"}},"es":{"stringUnit":{"state":"translated","value":"Iniciar sesión de datos sin procesar"}},"fr":{"stringUnit":{"state":"translated","value":"Démarrer la session de données brutes"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Iniciar sessão de dados em bruto"}},"it":{"stringUnit":{"state":"translated","value":"Avvia sessione dati grezzi"}},"pl":{"stringUnit":{"state":"translated","value":"Rozpocznij sesję danych surowych"}},"ru":{"stringUnit":{"state":"translated","value":"Начать сессию сырых данных"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"开始原始数据会话"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"開始原始資料工作階段"}}}}, + "Add historical session": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Historische Session hinzufügen"}},"es":{"stringUnit":{"state":"translated","value":"Añadir sesión histórica"}},"fr":{"stringUnit":{"state":"translated","value":"Ajouter une session historique"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Adicionar sessão histórica"}},"it":{"stringUnit":{"state":"translated","value":"Aggiungi sessione storica"}},"pl":{"stringUnit":{"state":"translated","value":"Dodaj sesję historyczną"}},"ru":{"stringUnit":{"state":"translated","value":"Добавить историческую сессию"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"添加历史会话"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"加入歷史工作階段"}}}}, + "Delete all sessions": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Alle Sessions löschen"}},"es":{"stringUnit":{"state":"translated","value":"Eliminar todas las sesiones"}},"fr":{"stringUnit":{"state":"translated","value":"Supprimer toutes les sessions"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Eliminar todas as sessões"}},"it":{"stringUnit":{"state":"translated","value":"Elimina tutte le sessioni"}},"pl":{"stringUnit":{"state":"translated","value":"Usuń wszystkie sesje"}},"ru":{"stringUnit":{"state":"translated","value":"Удалить все сессии"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"删除所有会话"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"刪除所有工作階段"}}}}, + "Session comment": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Session-Kommentar"}},"es":{"stringUnit":{"state":"translated","value":"Comentario de la sesión"}},"fr":{"stringUnit":{"state":"translated","value":"Commentaire de session"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Comentário da sessão"}},"it":{"stringUnit":{"state":"translated","value":"Commento della sessione"}},"pl":{"stringUnit":{"state":"translated","value":"Komentarz do sesji"}},"ru":{"stringUnit":{"state":"translated","value":"Комментарий к сессии"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"会话备注"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"工作階段備註"}}}}, + "Building export…": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Export wird erstellt …"}},"es":{"stringUnit":{"state":"translated","value":"Creando exportación…"}},"fr":{"stringUnit":{"state":"translated","value":"Création de l’export…"}},"pt-PT":{"stringUnit":{"state":"translated","value":"A criar exportação…"}},"it":{"stringUnit":{"state":"translated","value":"Creazione esportazione…"}},"pl":{"stringUnit":{"state":"translated","value":"Tworzenie eksportu…"}},"ru":{"stringUnit":{"state":"translated","value":"Формирование экспорта…"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"正在生成导出…"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"正在建立匯出…"}}}}, + "Export session": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Session exportieren"}},"es":{"stringUnit":{"state":"translated","value":"Exportar sesión"}},"fr":{"stringUnit":{"state":"translated","value":"Exporter la session"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Exportar sessão"}},"it":{"stringUnit":{"state":"translated","value":"Esporta sessione"}},"pl":{"stringUnit":{"state":"translated","value":"Eksportuj sesję"}},"ru":{"stringUnit":{"state":"translated","value":"Экспортировать сессию"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"导出会话"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"匯出工作階段"}}}}, + "Delete session": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Session löschen"}},"es":{"stringUnit":{"state":"translated","value":"Eliminar sesión"}},"fr":{"stringUnit":{"state":"translated","value":"Supprimer la session"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Eliminar sessão"}},"it":{"stringUnit":{"state":"translated","value":"Elimina sessione"}},"pl":{"stringUnit":{"state":"translated","value":"Usuń sesję"}},"ru":{"stringUnit":{"state":"translated","value":"Удалить сессию"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"删除会话"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"刪除工作階段"}}}}, + "The export file could not be created or shared.": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Die Exportdatei konnte nicht erstellt oder geteilt werden."}},"es":{"stringUnit":{"state":"translated","value":"No se pudo crear ni compartir el archivo de exportación."}},"fr":{"stringUnit":{"state":"translated","value":"Le fichier d’export n’a pas pu être créé ou partagé."}},"pt-PT":{"stringUnit":{"state":"translated","value":"Não foi possível criar ou partilhar o ficheiro de exportação."}},"it":{"stringUnit":{"state":"translated","value":"Impossibile creare o condividere il file di esportazione."}},"pl":{"stringUnit":{"state":"translated","value":"Nie udało się utworzyć ani udostępnić pliku eksportu."}},"ru":{"stringUnit":{"state":"translated","value":"Не удалось создать файл экспорта или поделиться им."}},"zh-Hans":{"stringUnit":{"state":"translated","value":"无法创建或共享导出文件。"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"無法建立或分享匯出檔案。"}}}}, + "To": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Bis"}},"es":{"stringUnit":{"state":"translated","value":"Hasta"}},"fr":{"stringUnit":{"state":"translated","value":"Au"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Até"}},"it":{"stringUnit":{"state":"translated","value":"A"}},"pl":{"stringUnit":{"state":"translated","value":"Do"}},"ru":{"stringUnit":{"state":"translated","value":"До"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"至"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"至"}}}}, "0": {"shouldTranslate":false}, - "Connect your WHOOP 5/MG to start a raw-data session.": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Verbinde dein WHOOP 5/MG, um eine Rohdaten-Session zu starten."}},"es":{"stringUnit":{"state":"translated","value":"Conecta tu WHOOP 5/MG para iniciar una sesión de datos sin procesar."}},"fr":{"stringUnit":{"state":"translated","value":"Connectez votre WHOOP 5/MG pour démarrer une session de données brutes."}},"pt-PT":{"stringUnit":{"state":"translated","value":"Ligue o WHOOP 5/MG para iniciar uma sessão de dados em bruto."}}}}, + "Connect your WHOOP 5/MG to start a raw-data session.": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Verbinde dein WHOOP 5/MG, um eine Rohdaten-Session zu starten."}},"es":{"stringUnit":{"state":"translated","value":"Conecta tu WHOOP 5/MG para iniciar una sesión de datos sin procesar."}},"fr":{"stringUnit":{"state":"translated","value":"Connectez votre WHOOP 5/MG pour démarrer une session de données brutes."}},"pt-PT":{"stringUnit":{"state":"translated","value":"Ligue o WHOOP 5/MG para iniciar uma sessão de dados em bruto."}},"it":{"stringUnit":{"state":"translated","value":"Collega il tuo WHOOP 5/MG per avviare una sessione dati grezzi."}},"pl":{"stringUnit":{"state":"translated","value":"Połącz WHOOP 5/MG, aby rozpocząć sesję danych surowych."}},"ru":{"stringUnit":{"state":"translated","value":"Подключите WHOOP 5/MG, чтобы начать сессию сырых данных."}},"zh-Hans":{"stringUnit":{"state":"translated","value":"连接你的 WHOOP 5/MG 以开始原始数据会话。"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"連接你的 WHOOP 5/MG 以開始原始資料工作階段。"}}}}, "Add marker": {"localizations":{"de":{"stringUnit":{"state":"translated","value":"Marker hinzufügen"}},"en":{"stringUnit":{"state":"translated","value":"Add marker"}},"es":{"stringUnit":{"state":"translated","value":"Añadir marcador"}},"fr":{"stringUnit":{"state":"translated","value":"Ajouter un marqueur"}},"it":{"stringUnit":{"state":"translated","value":"Aggiungi indicatore"}},"pl":{"stringUnit":{"state":"translated","value":"Dodaj znacznik"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Adicionar marcador"}},"ru":{"stringUnit":{"state":"translated","value":"Добавить маркер"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"添加标记"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"加入標記"}}}}, "Edit marker": {"localizations":{"de":{"stringUnit":{"state":"translated","value":"Marker bearbeiten"}},"en":{"stringUnit":{"state":"translated","value":"Edit marker"}},"es":{"stringUnit":{"state":"translated","value":"Editar marcador"}},"fr":{"stringUnit":{"state":"translated","value":"Modifier le marqueur"}},"it":{"stringUnit":{"state":"translated","value":"Modifica indicatore"}},"pl":{"stringUnit":{"state":"translated","value":"Edytuj znacznik"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Editar marcador"}},"ru":{"stringUnit":{"state":"translated","value":"Изменить маркер"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"编辑标记"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"編輯標記"}}}}, "Delete marker": {"localizations":{"de":{"stringUnit":{"state":"translated","value":"Marker löschen"}},"en":{"stringUnit":{"state":"translated","value":"Delete marker"}},"es":{"stringUnit":{"state":"translated","value":"Eliminar marcador"}},"fr":{"stringUnit":{"state":"translated","value":"Supprimer le marqueur"}},"it":{"stringUnit":{"state":"translated","value":"Elimina indicatore"}},"pl":{"stringUnit":{"state":"translated","value":"Usuń znacznik"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Eliminar marcador"}},"ru":{"stringUnit":{"state":"translated","value":"Удалить маркер"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"删除标记"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"刪除標記"}}}}, From 27e82ed73de544e75ad93ad56d4b6c574a4d1af1 Mon Sep 17 00:00:00 2001 From: Bernd Helm Date: Sat, 29 Aug 2026 10:29:10 +0200 Subject: [PATCH 45/45] fix(raw-capture): align parity guard with file storage --- Packages/WhoopStore/Sources/WhoopStore/Database.swift | 6 +++--- StrandTests/Resources/raw_data_collector_parity.json | 6 +++--- android/app/src/main/java/com/noop/data/WhoopDatabase.kt | 3 ++- .../app/src/test/java/com/noop/data/RawImuMigrationTest.kt | 2 +- .../app/src/test/resources/raw_data_collector_parity.json | 6 +++--- 5 files changed, 12 insertions(+), 11 deletions(-) diff --git a/Packages/WhoopStore/Sources/WhoopStore/Database.swift b/Packages/WhoopStore/Sources/WhoopStore/Database.swift index 62d1c99021..b43dc9cb1f 100644 --- a/Packages/WhoopStore/Sources/WhoopStore/Database.swift +++ b/Packages/WhoopStore/Sources/WhoopStore/Database.swift @@ -572,8 +572,8 @@ extension WhoopStore { // the six wire columns (ax…az,gx…gz). Twin of the Android `rawImuSample` table (MIGRATION_20_21); // same column order + PK so a `.noopbak` round-trips byte-for-byte. // - // Historical schema only. Deployed installations are known not to contain usable rows; v41 removes it - // after the file-backed session format is installed. + // Historical rolling cache. Its opt-in writer retained at most 3,600 one-second rows, but no analytics, + // UI or export consumed them. v41 retires those legacy rows after file-backed capture replaces the cache. migrator.registerMigration("v28-raw-imu") { db in try db.create(table: "rawImuSample") { t in t.column("deviceId", .text).notNull() @@ -879,7 +879,7 @@ extension WhoopStore { t.add(column: "skinTempC", .double) } } - // The retired opt-in cache is known empty in deployed NOOP installations; session capture uses files. + // Retire the bounded, write-only legacy cache. Session-owned IMU now lives in the file-backed store. migrator.registerMigration("v41-drop-raw-imu-sample") { db in try db.drop(table: "rawImuSample") } diff --git a/StrandTests/Resources/raw_data_collector_parity.json b/StrandTests/Resources/raw_data_collector_parity.json index 0cd3c0454e..a843cb951b 100644 --- a/StrandTests/Resources/raw_data_collector_parity.json +++ b/StrandTests/Resources/raw_data_collector_parity.json @@ -61,9 +61,9 @@ "swift": ["onChangeCompat(of: live.bonded)", "rawCaptureInFlight = false"], "kotlin": ["LaunchedEffect(state.active, state.sessionId, live.connected)", "startGroundTruthImuCapture"] }, - "raw_data_removed_with_session": { - "swift": ["deleteFiles(sessionId", "capturedAt >= ? AND capturedAt <= ?"], - "kotlin": ["deleteFiles(sessionId)", "noop-5mg-raw-$sessionId.zip"] + "session_files_removed_with_session": { + "swift": ["deleteFiles(sessionId", "removeItem(metadata)"], + "kotlin": ["deleteFiles(sessionId)", "eventFile(sessionId).delete()"] } } } diff --git a/android/app/src/main/java/com/noop/data/WhoopDatabase.kt b/android/app/src/main/java/com/noop/data/WhoopDatabase.kt index 32d226ccd0..0792761b26 100644 --- a/android/app/src/main/java/com/noop/data/WhoopDatabase.kt +++ b/android/app/src/main/java/com/noop/data/WhoopDatabase.kt @@ -570,7 +570,8 @@ abstract class WhoopDatabase : RoomDatabase() { } } - /** Historical #423 schema. Known-empty deployments are cleaned up after file-backed capture lands. */ + /** Historical #423 rolling cache. Its bounded, write-only rows are retired by MIGRATION_34_35 + * after session-owned IMU moves to the file-backed store. */ internal val RAW_IMU_MIGRATION_SQL: List = listOf( "CREATE TABLE IF NOT EXISTS `rawImuSample` (`deviceId` TEXT NOT NULL, " + "`ts` INTEGER NOT NULL, `samples` BLOB NOT NULL, PRIMARY KEY(`deviceId`, `ts`))", diff --git a/android/app/src/test/java/com/noop/data/RawImuMigrationTest.kt b/android/app/src/test/java/com/noop/data/RawImuMigrationTest.kt index 1fd4603e4f..62a6e575e4 100644 --- a/android/app/src/test/java/com/noop/data/RawImuMigrationTest.kt +++ b/android/app/src/test/java/com/noop/data/RawImuMigrationTest.kt @@ -52,7 +52,7 @@ class RawImuMigrationTest { assertEquals(21, WhoopDatabase.MIGRATION_20_21.endVersion) } - @Test fun obsoleteEmptyTable_isDroppedAt35() { + @Test fun obsoleteBoundedCache_isDroppedAt35() { assertEquals(34, WhoopDatabase.MIGRATION_34_35.startVersion) assertEquals(35, WhoopDatabase.MIGRATION_34_35.endVersion) } diff --git a/android/app/src/test/resources/raw_data_collector_parity.json b/android/app/src/test/resources/raw_data_collector_parity.json index 0cd3c0454e..a843cb951b 100644 --- a/android/app/src/test/resources/raw_data_collector_parity.json +++ b/android/app/src/test/resources/raw_data_collector_parity.json @@ -61,9 +61,9 @@ "swift": ["onChangeCompat(of: live.bonded)", "rawCaptureInFlight = false"], "kotlin": ["LaunchedEffect(state.active, state.sessionId, live.connected)", "startGroundTruthImuCapture"] }, - "raw_data_removed_with_session": { - "swift": ["deleteFiles(sessionId", "capturedAt >= ? AND capturedAt <= ?"], - "kotlin": ["deleteFiles(sessionId)", "noop-5mg-raw-$sessionId.zip"] + "session_files_removed_with_session": { + "swift": ["deleteFiles(sessionId", "removeItem(metadata)"], + "kotlin": ["deleteFiles(sessionId)", "eventFile(sessionId).delete()"] } } }