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/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/Packages/WhoopStore/Sources/WhoopStore/Database.swift b/Packages/WhoopStore/Sources/WhoopStore/Database.swift index 88f8ead082..b43dc9cb1f 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 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() @@ -887,6 +879,10 @@ extension WhoopStore { t.add(column: "skinTempC", .double) } } + // 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") + } 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 43bf0b7fe4..5d15437044 100644 --- a/Packages/WhoopStore/Sources/WhoopStore/StreamStore.swift +++ b/Packages/WhoopStore/Sources/WhoopStore/StreamStore.swift @@ -58,14 +58,9 @@ extension WhoopStore { return out } - /// #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 - /// 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 @@ -102,27 +97,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]) - } - } - /// Idempotent upsert of decoded streams by natural key. Returns the number of rows /// ACTUALLY inserted per stream (0 for rows that already existed). /// @@ -350,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/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/PruneTests.swift b/Packages/WhoopStore/Tests/WhoopStoreTests/PruneTests.swift index 1ba9dcf511..4bedfed80f 100644 --- a/Packages/WhoopStore/Tests/WhoopStoreTests/PruneTests.swift +++ b/Packages/WhoopStore/Tests/WhoopStoreTests/PruneTests.swift @@ -77,4 +77,5 @@ final class PruneTests: XCTestCase { maxUnsyncedBytes: 1_000_000) XCTAssertEqual(pruned, 0) } + } 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 4cb35172f1..64fd84960b 100644 --- a/Packages/WhoopStore/Tests/WhoopStoreTests/Resources/schema_oracle.json +++ b/Packages/WhoopStore/Tests/WhoopStoreTests/Resources/schema_oracle.json @@ -41,7 +41,8 @@ "v37-daily-avg-sdnn", "v38-apple-step-hour", "v39-ppg-burst-index", - "v40-daily-skin-temp-absolute" + "v40-daily-skin-temp-absolute", + "v41-drop-raw-imu-sample" ], "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 — the per-row upload flag belongs to an upload path that does not exist there — so it is dead width, not divergent data. Removing it needs a Room table rebuild.", @@ -1259,34 +1260,6 @@ ], "indices": [] }, - "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/README.md b/README.md index 21385fce50..d4749ec2f9 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ 👽 Reddit · Features · Protocol · + Raw data capture ·

diff --git a/Strand/BLE/BLEManager.swift b/Strand/BLE/BLEManager.swift index ee5dc7b6ad..c42a01a8b9 100644 --- a/Strand/BLE/BLEManager.swift +++ b/Strand/BLE/BLEManager.swift @@ -847,6 +847,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). @@ -1840,8 +1842,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 } @@ -1849,7 +1857,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 @@ -1859,6 +1870,50 @@ 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(sessionId: String) -> Bool { + guard !rawCaptureInFlight else { return false } + rawCaptureInFlight = true + 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) + } + } + 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 groundTruthHistoryCSV(from: Int, to: Int) async -> Data { + await collector?.historySensorsCSV(from: from, to: to) + ?? Data("stream,unix_s,v1,v2,v3,v4\n".utf8) + } + /// Send a command to the WHOOP strap. /// - Parameters: /// - command: The command to send. @@ -1941,6 +1996,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 @@ -5275,6 +5334,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 @@ -6274,9 +6336,8 @@ 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 - // 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/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 ad92438137..4b7c56d138 100644 --- a/Strand/Collect/Collector.swift +++ b/Strand/Collect/Collector.swift @@ -13,12 +13,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 {} @@ -118,6 +112,28 @@ final class Collector { return try? await s.storageStats() } + /// 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($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($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($0) } ?? "")" } + return Data((lines.joined(separator: "\n") + "\n").utf8) + } + /// 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? { @@ -149,21 +165,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`. - 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) { @@ -171,6 +172,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 — @@ -184,6 +186,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. @@ -282,6 +285,11 @@ final class Collector { rawCapture.open(at: monotonic(), duration: seconds) } + 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/ImuSessionFileStore.swift b/Strand/Collect/ImuSessionFileStore.swift new file mode 100644 index 0000000000..26cf0b254f --- /dev/null +++ b/Strand/Collect/ImuSessionFileStore.swift @@ -0,0 +1,189 @@ +import Compression +import Foundation +import WhoopProtocol + +/// 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() + 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 + private var seen: [String: Set] = [:] + private var pending: [String: [Record]] = [:] + + 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/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) { + 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.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 }) + } + func prepareForRead(_ id: String) { flushSession(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), 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!) { + 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[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 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()) + } + + 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 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.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(Self.payloadBytes)) + for value in record.columns { raw.append(UInt8(truncatingIfNeeded: value)); raw.append(UInt8(truncatingIfNeeded: value >> 8)) } + } + 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 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 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/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..5e962858d3 --- /dev/null +++ b/Strand/Collect/RawDataSessionStore.swift @@ -0,0 +1,237 @@ +import Combine +import Foundation +import WhoopStore + +/// Persistent, user-controlled 5/MG capture sessions. This is the iOS/macOS twin of Android's +/// GroundTruthCollector: it records bounded raw-data windows with optional comments. +@MainActor +final class RawDataSessionStore: ObservableObject { + struct Event: Codable, Identifiable, Equatable { + var id = UUID() + var atMs: Int64 + let kind: String + var markerId: String? + var markerType: String? + var text: String? + } + + struct Session: Codable, Identifiable, Equatable { + let id: String + let deviceId: String + let startedAtMs: Int64 + var endedAtMs: Int64? + var capturedStartedAtMs: Int64? + var capturedEndedAtMs: Int64? + var comment: String + var exported: Bool + var lastExportedAtMs: Int64? + var events: [Event] + + var active: Bool { endedAtMs == nil } + } + + @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") + let session = Session(id: String(millis), deviceId: deviceId, startedAtMs: millis, + endedAtMs: nil, capturedStartedAtMs: millis, capturedEndedAtMs: nil, + comment: "", exported: false, lastExportedAtMs: nil, 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 + session.capturedEndedAtMs = millis + session.events.append(Event(atMs: millis, kind: "stop")) + } + if let activeId { ImuSessionFileStore.shared.complete(id: activeId, toMs: Int64(now.timeIntervalSince1970 * 1_000)) } + } + + @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"), Event(atMs: toMs, kind: "stop")] + let session = Session(id: id, deviceId: deviceId, startedAtMs: fromMs, endedAtMs: toMs, + capturedStartedAtMs: nil, capturedEndedAtMs: nil, comment: "", + 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 + } + + 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, comment: session.comment, + 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) + } + } + + func setComment(_ comment: String, sessionId: String) { + mutate(sessionId) { $0.comment = String(comment.prefix(4_000)) } + } + + @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 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 deleteMarker(sessionId: String, markerId: String) { + mutate(sessionId) { $0.events.removeAll { $0.kind == "marker" && $0.markerId == markerId } } + } + + func markExported(_ sessionId: String, now: Date = Date()) { + mutate(sessionId) { + $0.exported = true + $0.lastExportedAtMs = Int64(now.timeIntervalSince1970 * 1_000) + } + } + + @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 } + guard ImuSessionFileStore.shared.deleteFiles(sessionId, removeItem: removeItem) else { 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) { + 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") } + + private func validRange(_ from: Int64, _ to: Int64) -> Bool { + 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, 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, + "device_family": "iOS", + "app_version": Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "?", + "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 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) } + return [ + .init(name: "meta.json", data: metaData), + .init(name: "events.jsonl", data: eventData), + ] + } +} diff --git a/Strand/Data/DeviceRegistry.swift b/Strand/Data/DeviceRegistry.swift index 5060fae5d7..032d70a1c3 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 ImuSessionFileStore.shared.deleteDevice(id) 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 ImuSessionFileStore.shared.deleteDevice(id) else { return } do { try await store.deleteAllData(deviceId: id) } catch { diff --git a/Strand/Resources/Localizable.xcstrings b/Strand/Resources/Localizable.xcstrings index 1ad4bf61c4..497d39c6ee 100644 --- a/Strand/Resources/Localizable.xcstrings +++ b/Strand/Resources/Localizable.xcstrings @@ -208219,6 +208219,72 @@ "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."}},"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"}},"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."}},"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":"刪除標記"}}}}, + "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":" · %@"}}}}, "Check what the strap has stored": { "localizations": { "de": { "stringUnit": { "state": "translated", "value": "Prüfen, was das Band gespeichert hat" } }, "en": { "stringUnit": { "state": "translated", "value": "Check what the strap has stored" } }, "es": { "stringUnit": { "state": "translated", "value": "Comprobar qué tiene guardado la correa" } }, "fr": { "stringUnit": { "state": "translated", "value": "Vérifier ce que le bracelet a enregistré" } }, "it": { "stringUnit": { "state": "translated", "value": "Controlla cosa ha memorizzato il braccialetto" } }, "pl": { "stringUnit": { "state": "translated", "value": "Sprawdź, co zapisała opaska" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Verificar o que a pulseira tem guardado" } }, "ru": { "stringUnit": { "state": "translated", "value": "Проверить, что сохранено на ремешке" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "查看手环已存储的闹钟" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "查看手環已儲存的鬧鐘" } } } }, 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/RawDataCollectorView.swift b/Strand/Screens/RawDataCollectorView.swift new file mode 100644 index 0000000000..4cf3785217 --- /dev/null +++ b/Strand/Screens/RawDataCollectorView.swift @@ -0,0 +1,400 @@ +import SwiftUI +import StrandDesign +import WhoopStore + +/// 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 exportingId: String? + @State private var deleteCandidate: RawDataSessionStore.Session? + @State private var confirmDeleteAll = false + @State private var exportError: String? + @State private var deleteError: String? + @State private var imuCoverage: [String: String] = [:] + @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( + 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) { + coverageCard + controls + historicalRangeCard + 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 let active = store.active, live.bonded { _ = model.ble.startGroundTruthRawCapture(sessionId: active.id) } + await refreshImuCoverage() + } + .onChangeCompat(of: live.bonded) { bonded in + 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 } } + ), 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") } + .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 { + 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) + } + } + + @ViewBuilder private var controls: some View { + if store.active != nil { + 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() } + .disabled(!live.bonded) + } + } + + 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) + 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 { + let coverageText = imuCoverage[session.id, default: "no complete seconds"] + 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 { + 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.active ? String(localized: "Export status: recording") + : 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) } + ), 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) } + } + .disabled(session.active || exportingId != nil) + NoopButton("Delete session", systemImage: "trash", kind: .destructive, + fullWidth: true) { deleteCandidate = session } + .disabled(session.active || exportingId != nil) + } + } + } + + 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() { + 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 + } + } + + private func stop() async { + await model.ble.stopGroundTruthRawCapture() + store.stop() + await refreshImuCoverage() + } + + private func export(_ session: RawDataSessionStore.Session) async { + guard let end = session.endedAtMs else { return } + exportingId = session.id + let bounds = Self.fullSecondBounds(fromMs: session.startedAtMs, toMs: end) + let from = bounds?.from ?? 1, to = bounds?.to ?? 0 + 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 = !segments.isEmpty || history.split(separator: 0x0A).count > 1 + 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. + 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] } + ] + if let data = try? JSONSerialization.data(withJSONObject: coverage, options: [.prettyPrinted, .sortedKeys]) { + entries.append(.init(name: "imu-coverage.json", 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." } + else { + store.markExported(session.id) + } + exportingId = nil + } + + private func delete(_ session: RawDataSessionStore.Session) async { + guard session.endedAtMs != nil else { return } + guard store.removeMetadata(session.id) else { + deleteError = "The captured data could not be deleted. The session was kept so you can retry." + return + } + imuCoverage[session.id] = nil + } + + private func deleteAll() async { + for session in store.sessions where !session.active { await delete(session) } + } + + private func refreshImuCoverage() async { + for session in store.sessions { + 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)" + } + } + + private static func time(_ ms: Int64) -> String { + 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) + let end = session.endedAtMs.map(time) ?? "…" + 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: [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) * ImuSessionFileStore.sampleRate { continue } + if start > cursor { return false } + if end >= cursor { cursor = end + 1 } + if cursor > to { return true } + } + return cursor > to + } +} diff --git a/Strand/Screens/SettingsView.swift b/Strand/Screens/SettingsView.swift index 856d4710f3..aaeab206be 100644 --- a/Strand/Screens/SettingsView.swift +++ b/Strand/Screens/SettingsView.swift @@ -1734,7 +1734,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 @@ -1891,7 +1892,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) { @@ -1920,7 +1921,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) @@ -3383,7 +3384,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) @@ -3424,7 +3425,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.") } @@ -3432,7 +3433,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 ec5a195605..4d951818c2 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). /// @@ -36,6 +37,14 @@ struct TestCentreView: View { @State private var debugExportKeep = ScheduledDebugExport.keepCount @State private var showClearExportsConfirm = 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 @@ -77,6 +86,8 @@ struct TestCentreView: View { VStack(alignment: .leading, spacing: NoopMetrics.sectionSpacing) { 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) } @@ -132,6 +143,100 @@ 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("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 { + RawDataCollectorView() + } label: { + 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 raw-data session.") + .font(StrandFont.caption) + .foregroundStyle(live.connected ? StrandPalette.textSecondary : StrandPalette.statusWarning) + } + } + } + + @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/StrandTests/RawDataCollectorParityTests.swift b/StrandTests/RawDataCollectorParityTests.swift new file mode 100644 index 0000000000..f00a0703d2 --- /dev/null +++ b/StrandTests/RawDataCollectorParityTests.swift @@ -0,0 +1,57 @@ +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/ImuSessionFileStore.swift", + "Packages/WhoopStore/Sources/WhoopStore/Database.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(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 new file mode 100644 index 0000000000..38c2c54714 --- /dev/null +++ b/StrandTests/RawDataSessionStoreTests.swift @@ -0,0 +1,158 @@ +import XCTest +@testable import Strand +import WhoopStore + +@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 testRawSessionLifecycleAndReload() throws { + let directory = try temporaryDirectory() + let store = RawDataSessionStore(directory: directory) + XCTAssertNotNil(store.start(deviceId: "strap", now: Date(timeIntervalSince1970: 100))) + store.stop(now: Date(timeIntervalSince1970: 106)) + + let session = try XCTUnwrap(store.sessions.first) + 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) + .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) + .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) + .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, 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() { + 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 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")) + store.removeMetadata(active.id) + 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) + let session = try XCTUnwrap(store.start(deviceId: "strap")) + store.stop() + 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() + + 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]) + XCTAssertTrue(store.removeMetadata(session.id)) + } +} diff --git a/StrandTests/Resources/raw_data_collector_parity.json b/StrandTests/Resources/raw_data_collector_parity.json new file mode 100644 index 0000000000..a843cb951b --- /dev/null +++ b/StrandTests/Resources/raw_data_collector_parity.json @@ -0,0 +1,69 @@ +{ + "schema_version": 1, + "capabilities": { + "start_stop": { + "swift": ["startGroundTruthRawCapture", "stopGroundTruthRawCapture"], + "kotlin": ["startGroundTruthImuCapture", "stopGroundTruthImuCapture"] + }, + "persisted_sessions": { + "swift": ["RawDataSessionStore", "persist(session)"], + "kotlin": ["GroundTruthCollector", "sessionStartKey"] + }, + "session_comments": { + "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"] + }, + "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"] + }, + "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", "segmentSeconds: Int64 = 30 * 60", "NOOPIMU2"], + "kotlin": ["ImuSessionFileStore", "SEGMENT_SECONDS = 30 * 60L", "NOOPIMU2"] + }, + "decoded_segment_export": { + "swift": ["exportSegments", "imu/\\(segment.name)"], + "kotlin": ["exportSegments", "imu/${segment.name}"] + }, + "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?", "removeMetadata(session.id)"], + "kotlin": ["ground_truth_delete_session_title", "ground_truth_delete_all_title", "deleteAllSessions"] + }, + "active_session_delete_guard": { + "swift": ["guard session.endedAtMs != nil else { return }", ".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"] + }, + "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/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 08e42424f6..c0f603c91e 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 @@ -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, /** @@ -632,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. * @@ -1767,6 +1786,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) @@ -1941,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). * @@ -2138,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. @@ -2149,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. @@ -2161,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") } @@ -2169,6 +2201,117 @@ 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 groundTruthImuStoppedAtMs = 0L + private var unexpectedImuStopAtMs = 0L + + /** 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 + } + 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. + send(CommandNumber.START_RAW_DATA, byteArrayOf(1), withResponse = true) + 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.STOP_RAW_DATA, byteArrayOf(1), withResponse = true) + send(CommandNumber.TOGGLE_IMU_MODE, byteArrayOf(1, 0), withResponse = true) + } finally { + groundTruthImuCommandAllowed = false + } + } + 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") } + // 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 + 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 + // 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( + packets = it.packets + 1, + bytes = it.bytes + frame.size, + lastPacketAtMs = now, + note = "Receiving IMU packet type $packetType", + ) + } + } + }.onFailure { + log("Ground-truth IMU append failed (${it.javaClass.simpleName})") + _groundTruthImuStatus.update { status -> status.copy(note = "Write failed: ${it.javaClass.simpleName}") } + } + } + + /** 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() @@ -3459,7 +3602,13 @@ 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 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 // ONLY while one actually is, so a default install can never form these bytes on a 5/MG — @@ -5977,12 +6126,14 @@ 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 // frame. Family-aware, so it's correct for WHOOP4 and 5/MG alike. val parsed = Framing.parseFrame(frame, connectedFamily) // A frame replayed as part of the historical offload (type 47/48/… during a backfill) + recordGroundTruthImuFrame(frame) // must not drive LIVE-only state (the charging pill). (PR #568 reimpl) // // NOT the same shape as iOS, despite what this said before. THIS side calls the handler @@ -6166,7 +6317,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 — @@ -9320,27 +9470,19 @@ 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) { + // 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 - 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 cbfa83259d..9e4bcfad05 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 @@ -662,42 +663,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 - } -} - /** * 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..6579a8634b 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 - // MARK: - Server-derived caches (latest value wins) @Upsert @@ -960,7 +940,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 @@ -1023,8 +1002,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 895f4bfc35..0792761b26 100644 --- a/android/app/src/main/java/com/noop/data/WhoopDatabase.kt +++ b/android/app/src/main/java/com/noop/data/WhoopDatabase.kt @@ -50,7 +50,6 @@ import androidx.sqlite.db.SupportSQLiteDatabase LabMarkerRow::class, LiveSessionRow::class, PpgWaveformSampleEntity::class, - RawImuSampleEntity::class, V18AuxSampleEntity::class, AppleStepHour::class, ], @@ -74,7 +73,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 @@ -571,9 +570,8 @@ 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 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`))", @@ -925,6 +923,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. * @@ -950,7 +952,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 dc062368d5..033249998a 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 @@ -795,7 +794,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,22 +1002,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) } /** 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) = @@ -1586,9 +1568,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()) @@ -1994,16 +1976,10 @@ 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. */ - const val RAW_IMU_RETENTION_ROWS = 3600 - /** * 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/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/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/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 new file mode 100644 index 0000000000..e89ff34a20 --- /dev/null +++ b/android/app/src/main/java/com/noop/testcentre/GroundTruthCollector.kt @@ -0,0 +1,475 @@ +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 com.noop.protocol.Whoop5RawImu +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 +import java.util.UUID + +/** App-private, explicitly user-started bounded 5/MG raw-data capture with annotations. */ +class GroundTruthCollector private constructor(private val context: Context) { + data class Snapshot( + val active: Boolean, + val sessionId: String?, + val deviceId: String?, + val startedAtMs: Long, + val endedAtMs: Long, + val exported: Boolean, + ) + + data class SessionSummary( + val id: String, + val deviceId: String?, + val startedAtMs: Long, + val endedAtMs: Long?, + val capturedStartedAtMs: Long?, + val capturedEndedAtMs: Long?, + 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 startupSeconds: 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 captureStats(session: SessionSummary, nowMs: Long = System.currentTimeMillis()): CaptureStats { + val end = session.endedAtMs ?: nowMs + val baseFrom = ceilSecond(session.startedAtMs) + val to = end / 1_000L - 1L + val raw = ImuSessionFileStore(context).stats(session.id, baseFrom, to) + 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(), + (from - baseFrom).coerceAtLeast(0).toInt()) + } + + @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), + exported = prefs.getBoolean("exported", false), + ) + + @Synchronized + fun start(deviceId: String, nowMs: Long = System.currentTimeMillis()): Snapshot { + val id = nowMs.toString() + prefs.edit() + .putBoolean("active", true) + .putBoolean("exported", false) + .putString("sessionId", id) + .putString("deviceId", deviceId) + .putLong("startedAtMs", nowMs) + .putString(sessionDeviceKey(id), deviceId) + .putLong(sessionStartKey(id), nowMs) + .putBoolean(sessionExportedKey(id), false) + .putBoolean(sessionCaptureKey(id), true) + .apply() + append(id, event(nowMs, "start").put("strap_device_id", deviceId)) + ImuSessionFileStore(context).start(id, deviceId, 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 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), false) + SessionSummary( + id = id, + deviceId = prefs.getString(sessionDeviceKey(id), null) + ?: first.optString("strap_device_id").takeIf(String::isNotBlank) + ?: current.deviceId.takeIf { isCurrent }, + startedAtMs = prefs.getLong(sessionUsedStartKey(id), physicalStart), + endedAtMs = capturedEnd?.let { prefs.getLong(sessionUsedEndKey(id), it) }, + capturedStartedAtMs = physicalStart.takeIf { hasCapture }, + capturedEndedAtMs = capturedEnd.takeIf { hasCapture }, + 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 } + } + + /** 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 + if (sessions().none { it.id == sessionId }) return false + + 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 imuStore = ImuSessionFileStore(context) + if (!imuStore.deleteFiles(sessionId)) return false + if (!payloadFiles.all { file -> !file.exists() || file.delete() }) return false + 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 + + prefs.edit() + .remove(sessionDeviceKey(sessionId)) + .remove(sessionStartKey(sessionId)) + .remove(sessionEndKey(sessionId)) + .remove(sessionCommentKey(sessionId)) + .remove(sessionExportedKey(sessionId)) + .remove(sessionLastExportKey(sessionId)) + .remove(sessionUsedStartKey(sessionId)) + .remove(sessionUsedEndKey(sessionId)) + .remove(sessionCaptureKey(sessionId)) + .apply { + if (current.sessionId == sessionId) clearCurrentSession() + } + .apply() + return true + } + + /** 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() + val deviceId = session.deviceId ?: return false + ImuSessionFileStore(context).register(sessionId, deviceId, fromMs, toMs) + 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").put("strap_device_id", deviceId)) + append(id, event(toMs, "stop")) + ImuSessionFileStore(context).register(id, deviceId, fromMs, toMs) + 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) + }) + 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 { + val completedIds = sessions().filterNot(SessionSummary::active).map(SessionSummary::id) + return completedIds.count(::deleteSession) + } + + @Synchronized + fun stop(nowMs: Long = System.currentTimeMillis()): Snapshot { + val before = snapshot() + if (!before.active || before.sessionId == null) return before + 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) + .apply() + return snapshot() + } + + suspend fun export(repo: WhoopRepository, sessionId: String? = null): File = withContext(Dispatchers.IO) { + val snap = snapshot() + 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() } + .mapNotNull { line -> runCatching { JSONObject(line) }.getOrNull() } + .toList() + } + val endMs = summary.endedAtMs ?: events.lastOrNull()?.optLong("at_ms") ?: summary.startedAtMs + // Export the complete bounded interval; markers are optional annotations. + val sensorFrom = ceilSecond(summary.startedAtMs) + val sensorTo = endMs / 1_000L - 1L + val imuSegments = if (deviceId == null || sensorFrom > sensorTo) emptyList() else + ImuSessionFileStore(context).exportSegments(id, sensorFrom, sensorTo) + val fullFrom = sensorFrom + val fullTo = endMs / 1_000L - 1L + // 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") + 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 = imuSegments.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", sensorAvailable) + 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 { + 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) + 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.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") + put("app_version", BuildConfig.VERSION_NAME) + }.toString(2)) + + out.putNextEntry(ZipEntry("events.jsonl")) + out.writerEntry(eventsJsonl(publicEvents(events, summary.startedAtMs, endMs, deviceId))) + + out.putNextEntry(ZipEntry("events.csv")) + out.writerEntry(eventsCsv(publicEvents(events, summary.startedAtMs, endMs, deviceId))) + + for (segment in imuSegments) { + out.putNextEntry(ZipEntry("imu/${segment.name}")) + out.write(segment.data) + out.closeEntry() + } + } + val exportedAt = System.currentTimeMillis() + prefs.edit().putBoolean(sessionExportedKey(id), true).putLong(sessionLastExportKey(id), exportedAt).apply() + if (id == snap.sessionId) 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 5/MG raw-data session") + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + }, "Export raw-data session").addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) + } + + 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) * ImuSessionFileStore.SAMPLE_RATE) continue + if (start > cursor) return false + if (end >= cursor) cursor = end + 1 + if (cursor > to) return true + } + 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") + + private fun event(atMs: Long, kind: String) = JSONObject().apply { put("at_ms", atMs); put("kind", kind) } + + 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) = 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 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') + } + + private fun csv(value: String): String = if (value.any { it == ',' || it == '"' || it == '\n' }) + "\"${value.replace("\"", "\"\"")}\"" else value + + 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 0 + val seconds = (to - from + 1L).coerceIn(1L, 86_400L).toInt() + 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 android.content.SharedPreferences.Editor.clearCurrentSession() = apply { + remove("active") + remove("exported") + remove("sessionId") + remove("deviceId") + remove("startedAtMs") + remove("endedAtMs") + } + + 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 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" + + 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 { + 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 + + fun from(context: Context) = GroundTruthCollector(context.applicationContext) + } +} 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..c7312bf6fc --- /dev/null +++ b/android/app/src/main/java/com/noop/testcentre/ImuSessionFileStore.kt @@ -0,0 +1,225 @@ +package com.noop.testcentre + +import android.content.Context +import com.noop.data.StreamPersistence +import com.noop.protocol.Whoop5RawImu +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 + +/** 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, "raw-imu-sessions").apply { mkdirs() } + + 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).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).putLong("$id.toMs", toMs).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.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.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) } + + 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) { + // 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, covered.size, covered.minOrNull()) + } + + fun append(deviceId: String, frame: ByteArray, receivedAtMs: Long = System.currentTimeMillis()): Int = synchronized(lock) { + val decoded = Whoop5RawImu.decode(frame) ?: return 0 + val columns = Whoop5RawImu.rawColumns(frame) ?: return 0 + val ts = decoded.baseTs + var writes = 0 + 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 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++ + } + writes + } + + /** 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_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 + 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 } + } + return rows + } + + 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 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 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 pending = mutableMapOf>() + 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/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/AppRoot.kt b/android/app/src/main/java/com/noop/ui/AppRoot.kt index c7b4984632..d0b077f9f9 100644 --- a/android/app/src/main/java/com/noop/ui/AppRoot.kt +++ b/android/app/src/main/java/com/noop/ui/AppRoot.kt @@ -182,6 +182,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. @@ -473,7 +474,12 @@ fun AppRoot(viewModel: AppViewModel = viewModel()) { ) } composable(Destination.SelfHostedPush.route) { SelfHostedPushScreen() } - 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/AppViewModel.kt b/android/app/src/main/java/com/noop/ui/AppViewModel.kt index 9be92ab20f..127e3818de 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.ImuSessionFileStore(getApplication()).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.ImuSessionFileStore(getApplication()).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 new file mode 100644 index 0000000000..05bc2cad59 --- /dev/null +++ b/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt @@ -0,0 +1,497 @@ +package com.noop.ui + +import android.app.DatePickerDialog +import android.app.TimePickerDialog +import android.content.Context +import android.widget.Toast +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.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.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.Modifier +import androidx.compose.ui.platform.LocalContext +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.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** Bounded 5/MG raw-data capture. */ +@Composable +fun GroundTruthCollectorScreen(vm: AppViewModel) { + val context = LocalContext.current + val collector = remember { GroundTruthCollector.from(context) } + val scope = rememberCoroutineScope() + val live by vm.live.collectAsStateWithLifecycle() + val imuStatus by vm.ble.groundTruthImuStatus.collectAsStateWithLifecycle() + var state by remember { mutableStateOf(collector.snapshot()) } + 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 = withContext(Dispatchers.IO) { + sessions.associate { it.id to collector.captureStats(it, nowMs) } + } + delay(1_000) + } + } + + // 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), + subtitle = stringResource(R.string.ground_truth_subtitle), + ) { + NoopCard { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + val activeSession = sessions.firstOrNull { it.active } + val activeStats = activeSession?.let { captureStats[it.id] } + 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( + 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( + 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 -> 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 { stringResource(R.string.ground_truth_sensors_behind, diagnosticAge(it * 1000)) } + ?: stringResource(R.string.ground_truth_sensors_none), + style = NoopType.caption, + color = Palette.textSecondary, + ) + Text( + 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, + ) + } + } + + if (state.active) { + NoopButton( + text = stringResource(R.string.ground_truth_add_marker), + kind = NoopButtonKind.Secondary, + fullWidth = true, + onClick = { state.sessionId?.let { markerEditor = MarkerEditor(it, null, nowMs, MARKER_MOMENT, "") } }, + ) + NoopButton(text = stringResource(R.string.ground_truth_stop), kind = NoopButtonKind.Destructive, + fullWidth = true, onClick = { + state = collector.stop(); vm.ble.stopGroundTruthImuCapture(); sessions = collector.sessions() + }) + } else { + NoopButton( + text = stringResource(R.string.ground_truth_start), + fullWidth = true, + enabled = vm.activeStrapId.isNotBlank(), + onClick = { + state = collector.start(vm.activeStrapId) + sessions = collector.sessions() + }, + ) + } + 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) + } 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, + latestSensorTs = latestSensorTs, + stats = captureStats[session.id], + 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)) + vm.ble.finishGroundTruthImuCapture(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 + } + } + }, + onDelete = { sessionPendingDelete = session }, + 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, + 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() + } + }, + ) + } + } + } + + 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 = { + vm.ble.finishGroundTruthImuCapture(session.id) + 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 = { + sessions.forEach { vm.ble.finishGroundTruthImuCapture(it.id) } + collector.deleteAllSessions() + sessions = collector.sessions() + state = collector.snapshot() + confirmDeleteAll = false + }) { + Text(stringResource(R.string.ground_truth_delete_confirm), color = Palette.statusCritical) + } + }, + ) + } + + 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( + 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) }) + } + 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(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(stringResource(R.string.ground_truth_delete_confirm), 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(stringResource(R.string.ground_truth_save)) } }, + ) + } +} + +@Composable +private fun SessionCard( + session: GroundTruthCollector.SessionSummary, + latestSensorTs: Long?, + stats: GroundTruthCollector.CaptureStats?, + exporting: Boolean, + onComment: (String) -> Unit, + onExport: () -> Unit, + onDelete: () -> Unit, + onAddMarker: () -> Unit, + onEditMarker: (GroundTruthCollector.Marker) -> Unit, + onEditStart: () -> Unit, + onEditEnd: () -> Unit, +) { + 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 + val captureReady = stats?.complete == true + Text( + when { + 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(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(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)) { + 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, + ) + } + } + 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(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( + 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 = 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)}" + } +} + +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() +} + +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" + } +} + +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" +} + +@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) 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/java/com/noop/ui/SettingsScreen.kt b/android/app/src/main/java/com/noop/ui/SettingsScreen.kt index 225c67ef47..4c886c341a 100644 --- a/android/app/src/main/java/com/noop/ui/SettingsScreen.kt +++ b/android/app/src/main/java/com/noop/ui/SettingsScreen.kt @@ -529,8 +529,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 @@ -573,7 +571,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 @@ -2245,11 +2242,13 @@ fun SettingsScreen( ) } // --- 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), - 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( @@ -2596,86 +2595,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), + uiString(R.string.raw_diag_moved), 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 77e9480a5a..248403d592 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 @@ -97,6 +98,18 @@ fun TestCentreScreen(vm: AppViewModel) { // 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 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) } // A report awaiting the mandatory review-before-share gate (spec section 12). Non-null shows the // review dialog; confirming runs TestReportFlow.run. @@ -182,6 +195,159 @@ 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, + ) + } + + if (is5MG) { + SettingsSectionTC( + icon = Icons.Filled.Science, + 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 = 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 = { + protocolProbes = it + puffinExperiment.isEnabled = it + }, + ) + DeveloperToggleRow( + 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 = { + broadcastHr = it + puffinExperiment.broadcastHr = it + vm.ble.setBroadcastHr(it) + }, + ) + DeveloperToggleRow( + title = stringResource(R.string.raw_diag_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 = 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 = { + deepData = it + puffinExperiment.isDeepDataEnabled = it + }, + ) + if (deepData) { + NoopButton( + text = stringResource(R.string.raw_diag_r22_enable), + kind = NoopButtonKind.Secondary, + fullWidth = true, + enabled = live.encryptedBond && live.worn, + onClick = { vm.ble.enableWhoop5DeepData() }, + ) + } + NoopButton( + text = stringResource(R.string.raw_diag_r22_clear), + 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 = stringResource(R.string.raw_diag_ecg), + 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 = stringResource(R.string.raw_diag_ecg_on), + kind = NoopButtonKind.Secondary, + enabled = live.bonded && ecgVariant.isMG, + onClick = { vm.ble.setEcgRawDataGate(true) }, + ) + NoopButton( + text = stringResource(R.string.raw_diag_ecg_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 = 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 = { + passiveRawCapture = it + puffinExperiment.isCaptureEnabled = it + }, + ) + NoopButton( + text = stringResource(R.string.raw_diag_share), + 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 = stringResource(R.string.raw_diag_export_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, @@ -230,6 +396,26 @@ fun TestCentreScreen(vm: AppViewModel) { } } +@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 114ed45fd2..94161e153a 100644 --- a/android/app/src/main/res/values-de/strings.xml +++ b/android/app/src/main/res/values-de/strings.xml @@ -2552,6 +2552,79 @@ 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. + Zeichne eine begrenzte 5/MG-Bewegungsaufnahme mit 100 Hz auf, markiere, vervollständige und exportiere sie. + Rohdaten-Collector öffnen + Rohdaten-Session starten + Vergangenes Zeitfenster hinzufügen + Session beenden + Session exportieren + Export wird erstellt… + Session konnte nicht exportiert werden: %1$s + Aufgezeichnete Sessions + Noch keine Sessions aufgezeichnet. + 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 + 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 + 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. Hello trotz Kopplungsverweigerung senden (experimentell) Hello trotz Kopplungsverweigerung senden Automatisch prüfen diff --git a/android/app/src/main/res/values-es/strings.xml b/android/app/src/main/res/values-es/strings.xml index b1fa136941..836f035550 100644 --- a/android/app/src/main/res/values-es/strings.xml +++ b/android/app/src/main/res/values-es/strings.xml @@ -2538,6 +2538,79 @@ 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 Enviar hello pese al rechazo de vinculación (experimental) Enviar hello pese al rechazo de vinculación Comprobar automáticamente diff --git a/android/app/src/main/res/values-fr/strings.xml b/android/app/src/main/res/values-fr/strings.xml index b971d2bf06..ac0f6eb733 100644 --- a/android/app/src/main/res/values-fr/strings.xml +++ b/android/app/src/main/res/values-fr/strings.xml @@ -2537,6 +2537,79 @@ 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 Envoyer le hello malgré le refus d\'appairage (expérimental) Envoyer le hello malgré le refus d\'appairage Vérifier automatiquement diff --git a/android/app/src/main/res/values-pl/strings.xml b/android/app/src/main/res/values-pl/strings.xml index 625efd6959..bdb996980e 100644 --- a/android/app/src/main/res/values-pl/strings.xml +++ b/android/app/src/main/res/values-pl/strings.xml @@ -2543,6 +2543,79 @@ 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 Wyślij hello mimo odmowy parowania (eksperymentalne) Wyślij hello mimo odmowy parowania Sprawdzaj automatycznie 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 28cbb2ad93..05cd47599e 100644 --- a/android/app/src/main/res/values-pt-rPT/strings.xml +++ b/android/app/src/main/res/values-pt-rPT/strings.xml @@ -2531,6 +2531,79 @@ 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 Enviar hello apesar da recusa de emparelhamento (experimental) Enviar hello apesar da recusa de emparelhamento Verificar automaticamente diff --git a/android/app/src/main/res/values-zh/strings.xml b/android/app/src/main/res/values-zh/strings.xml index 25b1396b8a..ff7578969a 100644 --- a/android/app/src/main/res/values-zh/strings.xml +++ b/android/app/src/main/res/values-zh/strings.xml @@ -2452,6 +2452,79 @@ 请求 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 即使拒绝配对也发送 hello(实验性) 即使拒绝配对也发送 hello 自动检查 diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index b65532a0ea..c68fae9f0c 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -1686,7 +1686,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). @@ -2565,6 +2565,79 @@ 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. + 5/MG Raw Data Collector + Record a bounded 100 Hz motion session, recover Bluetooth gaps from strap history, and export the complete timeline. + Record, annotate, recover, and export a bounded 5/MG 100 Hz motion capture. + Open raw-data collector + Start raw-data session + Add past time window + Stop session + Export session + Building export… + Couldn’t export session: %1$s + Recorded sessions + No sessions recorded yet. + 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 + 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 + 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. Send hello despite bond refusal (experimental) Send hello despite bond refusal Check automatically 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/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/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..62a6e575e4 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 obsoleteBoundedCache_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/RawCaptureExportContractTest.kt b/android/app/src/test/java/com/noop/testcentre/RawCaptureExportContractTest.kt new file mode 100644 index 0000000000..28282a4726 --- /dev/null +++ b/android/app/src/test/java/com/noop/testcentre/RawCaptureExportContractTest.kt @@ -0,0 +1,67 @@ +package com.noop.testcentre + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File + +class RawCaptureExportContractTest { + 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 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 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)")) + 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() ||")) + } + + @Test fun sessionPayloadDeletionKeepsDiscoveryFileUntilLast() { + val collector = source("GroundTruthCollector.kt") + 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("stepSamples(")) + } +} 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..0fe1afac2e --- /dev/null +++ b/android/app/src/test/java/com/noop/testcentre/RawDataCollectorParityTest.kt @@ -0,0 +1,51 @@ +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/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", + "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..a843cb951b --- /dev/null +++ b/android/app/src/test/resources/raw_data_collector_parity.json @@ -0,0 +1,69 @@ +{ + "schema_version": 1, + "capabilities": { + "start_stop": { + "swift": ["startGroundTruthRawCapture", "stopGroundTruthRawCapture"], + "kotlin": ["startGroundTruthImuCapture", "stopGroundTruthImuCapture"] + }, + "persisted_sessions": { + "swift": ["RawDataSessionStore", "persist(session)"], + "kotlin": ["GroundTruthCollector", "sessionStartKey"] + }, + "session_comments": { + "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"] + }, + "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"] + }, + "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", "segmentSeconds: Int64 = 30 * 60", "NOOPIMU2"], + "kotlin": ["ImuSessionFileStore", "SEGMENT_SECONDS = 30 * 60L", "NOOPIMU2"] + }, + "decoded_segment_export": { + "swift": ["exportSegments", "imu/\\(segment.name)"], + "kotlin": ["exportSegments", "imu/${segment.name}"] + }, + "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?", "removeMetadata(session.id)"], + "kotlin": ["ground_truth_delete_session_title", "ground_truth_delete_all_title", "deleteAllSessions"] + }, + "active_session_delete_guard": { + "swift": ["guard session.endedAtMs != nil else { return }", ".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"] + }, + "session_files_removed_with_session": { + "swift": ["deleteFiles(sessionId", "removeItem(metadata)"], + "kotlin": ["deleteFiles(sessionId)", "eventFile(sessionId).delete()"] + } + } +} diff --git a/android/app/src/test/resources/schema_oracle.json b/android/app/src/test/resources/schema_oracle.json index 4cb35172f1..64fd84960b 100644 --- a/android/app/src/test/resources/schema_oracle.json +++ b/android/app/src/test/resources/schema_oracle.json @@ -41,7 +41,8 @@ "v37-daily-avg-sdnn", "v38-apple-step-hour", "v39-ppg-burst-index", - "v40-daily-skin-temp-absolute" + "v40-daily-skin-temp-absolute", + "v41-drop-raw-imu-sample" ], "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 — the per-row upload flag belongs to an upload path that does not exist there — so it is dead width, not divergent data. Removing it needs a Room table rebuild.", @@ -1259,34 +1260,6 @@ ], "indices": [] }, - "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/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..43b5d2b134 100644 --- a/docs/DATA_MODEL.md +++ b/docs/DATA_MODEL.md @@ -297,6 +297,11 @@ 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 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). + --- ## 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..e03115f6c4 --- /dev/null +++ b/docs/RAW_DATA_CAPTURE.md @@ -0,0 +1,139 @@ +# 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 production health or activity scores. + +## 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. 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 + +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. + +## 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. + +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; +- 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, and markers | Small app-private JSON/JSONL metadata | Until the user deletes the session | +| Decoded 100 Hz IMU | App-private `.imus` segment files | Until the user explicitly deletes the session | + +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. + +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/*.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 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 +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 + +- 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 + 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. + +## 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` | +| Canonical segmented storage/export | `ImuSessionFileStore` | `ImuSessionFileStore` | +| 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