Repository navigation
feat(imu): unlock WHOOP 5/MG 100 Hz capture and labeled recording sessions - #1709
Conversation
…collector # Conflicts: # Packages/WhoopStore/Sources/WhoopStore/Database.swift # Packages/WhoopStore/Tests/WhoopStoreTests/Resources/schema_oracle.json # android/app/src/main/java/com/noop/ble/WhoopBleClient.kt # android/app/src/main/java/com/noop/ui/AppRoot.kt # android/app/src/main/res/values-de/strings.xml # android/app/src/main/res/values-es/strings.xml # android/app/src/main/res/values-fr/strings.xml # android/app/src/main/res/values-pl/strings.xml # android/app/src/main/res/values-pt-rPT/strings.xml # android/app/src/main/res/values-zh/strings.xml # android/app/src/main/res/values/strings.xml # android/app/src/test/java/com/noop/data/DailySdnnMigrationTest.kt # android/app/src/test/resources/schema_oracle.json
Only conflict was Strand/Resources/Localizable.xcstrings, where both sides appended entries to the catalogue: main added two strings for the strap readback check, this branch added sixty-six for the Raw Data Collector. The keys are disjoint, so both sets are kept verbatim and the entry that had been last on this branch gains its trailing comma. Resolved as TEXT, not by re-serialising: parsing and re-emitting this catalogue expands its hand-compacted entries and turns a few added strings into a several-thousand-line diff. The result is 66 lines against main.
Fifty-two of the branch's new iOS strings carried only de/es/fr/pt-PT, so Italian, Polish, Russian and both Chinese locales would have rendered the English source. The Android side of this branch is already complete: all seven locale files gain the same 67 keys. Tools/i18n_audit.py did not catch it. Its hard gate covers the focus set (de/es/fr/pt-PT) and the remaining five are governed by a ratchet that only forbids NEW gaps against a tracked baseline, so a large block of untranslated strings can land green. Worth knowing when reading the audit's OK. Terminology follows the catalogue rather than being coined here: strap is fascia/pasek/браслет/手环/手環, Session is Sessione/Sesja/Сессия/会话/工作階段, and Test Centre is Centro test/Centrum Testowe/Тестовый центр/测试中心/測試中心. The bare "0" entry is left as the author wrote it. It carries no localizations and is flagged shouldTranslate:false, so five identical numerals would be noise, not coverage. Edited line-scoped: each entry is parsed from its own line and re-emitted compact. Re-serialising the catalogue expands every hand-compacted entry and turns this into a several-thousand-line diff; the change is 52 lines.
|
@bhelm — I pushed two commits to this branch (maintainer edits were on) and dispatched the app-build gate against it. All 15 normal checks are green; the app-build macOS leg is not, and the one failure is yours to settle rather than mine. What I pushedMerged current main in. Only conflict was I did not rebase. The branch is 44 commits and the first one already conflicted against current main, so a rebase means resolving 43 intermediate states that the squash-merge discards anyway. Merging main in resolves once and leaves your commits intact. Localized the collector into the five ungated iOS locales. 52 of the new iOS strings carried only de/es/fr/pt-PT, so Italian, Polish, Russian and both Chinese locales would have rendered the English source. Your Android side was already complete — all seven locale files gain the same 67 keys. Worth knowing why this passed review: Terminology follows what the catalogue already uses rather than anything I coined — strap is The app-build failure — a question for you
Not caused by the merge — I checked that marker across every scanned Swift file at your original head Exactly one marker is missing across all capabilities, and the two readings point at different fixes:
Neither platform deletes rows on session delete; both delete files. So either the marker is stale, left from the SQLite-backed design before session storage moved to files, and the fix is to drop it from both oracle copies — or it is aspirational, raw-outbox rows whose I did not want to delete a requirement you may have meant, so I left it. If it is the second reading, note the Kotlin markers do not require that deletion either, so the oracle is holding the two platforms to different standards. One other thingThe migration comment says deployed installations are "known not to contain usable rows", and on main Separately, your note that iOS is unvalidated against real hardware still stands — the app-build gate is a compile-and-unit-test check, not a device test. |
|
Thanks for catching these — both points are fixed in
Verification after the fix:
The upstream app-build workflow is currently One process note: keeping this kind of cross-platform contract current would be a little easier once the parity guard itself lives on |
Build 382 crashed on launch for anyone with an existing database. #1709 removed RawImuSampleEntity from the @database entities list, registered MIGRATION_34_35 and bumped SCHEMA_VERSION to 35, but left @database(version = 34). Room hashes the declared entity set into room_master_table and re-checks it on open, so a changed schema under an unchanged version throws "Room cannot verify the data integrity" before any UI exists. A FRESH install writes the hash from the new schema and runs perfectly, which is why 15 checks, 4807 unit tests, the macOS and iOS app builds and every review pass stayed green: the failure needs a device that already has data, and CI never has one. Three numbers have to agree and only two were tied together. WhoopDatabaseMigration ChainTest asserted chain-end == SCHEMA_VERSION; SchemaOracleTest asserted the oracle's roomVersion == the exported annotation version. Both passed, because the annotation agreed with the oracle at 34 while SCHEMA_VERSION sat at 35 and nothing compared those two. The chain test's own doc block called SCHEMA_VERSION "the database's declared version", which is the conflation that hid it. theRoomAnnotationVersionMatchesSchemaVersion closes that third leg, read from source text because androidx.room.Database is CLASS-retention and a reflective read would return null and silently assert nothing. Mutation-verified: restoring version = 34 fails it and only it. The oracle's roomVersion moves to 35 in both byte-identical copies, and the migration drops with IF EXISTS, so a database that did reach 34 under the new entity set migrates cleanly either way.
Review of this PR found the one thing hardware validation would have surfaced as data loss. Every strap owns TWO ids: the pairing's own, and a computed sibling keyed `<deviceId>-noop` holding the scored days, detected workouts and metric series the engine derives. adoptSerialIdentity re-keys by exact match on activeId, and `activeId-noop` never equals activeId - so the computed half was left behind while the next scoring pass wrote under `<serialId>-noop`. The old computed history then sits under an id nothing reads again: precisely the orphaned history #1303 exists to prevent, displaced onto the derived side. On a real install that is not hypothetical. The field log from build 384 shows 25 computed day rows under `whoop-FD:..-noop`; adopting the serial would have stranded all of them. Why the "already proven, already shipped" argument did not cover it: the migration IS proven, for the ring. A ring has no computed sibling to strand. The WHOOP path introduces a second id shape the Oura path never had, so reuse of the proven migration is exactly where the gap opened. Both platforms now re-key both id pairs in the same transaction, registry rows touched once for the real pairing only (a computed sibling has no pairedDevice row). Kotlin's 25 explicit re-key calls move into a helper called twice rather than being duplicated. Two tests: the sibling travels, and a PK clash on the computed side resolves canonical-wins like the real id - reachable only after a re-pair that already scored days under the serial's own sibling. Rebased onto current main, which also proves the point about drift: the re-key list had 26 entries when this PR was opened and has 25 now, because #1709 dropped rawImuSample. A hardcoded copy of that list would have silently mis-migrated. Verified: 450 WhoopStore tests and 4862 Android tests, zero failures, doc lint clean. Still DO NOT MERGE pending the hardware night - this fixes a defect in it, it does not validate it.
707 lines of private declarations with no reader left, across 11 files. Swift's private is file-scoped, so each was checked by name within its own file, and the compiler confirms none was still needed. Most of it is Settings' WHOOP 5/MG research card (fiveMGCard) and what only it used: the ECG raw-data gate readouts, the deep-data (R22) button state, the capture exports and their @AppStorage mirrors. ryanbr#1709 removed the card's only call site (`if showFiveMGControls { fiveMGCard }`) on 29 Aug when the IMU capture sessions replaced it, and left the body behind. Their UserDefaults keys and the features behind them are untouched; only unreachable UI code goes. The rest are small leftovers: helpers, a formatter, a colour, a placeholder button and a first-run card on Today that nothing renders. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
707 lines of private declarations with no reader left, across 11 files. Swift's private is file-scoped, so each was checked by name within its own file, and the compiler confirms none was still needed. Most of it is Settings' WHOOP 5/MG research card (fiveMGCard) and what only it used: the ECG raw-data gate readouts, the deep-data (R22) button state, the capture exports and their @AppStorage mirrors. ryanbr#1709 removed the card's only call site (`if showFiveMGControls { fiveMGCard }`) on 29 Aug when the IMU capture sessions replaced it, and left the body behind. Their UserDefaults keys and the features behind them are untouched; only unreachable UI code goes. The rest are small leftovers: helpers, a formatter, a colour, a placeholder button and a first-run card on Today that nothing renders. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
707 lines of private declarations that nothing reads, across 11 app files. Swift's private is file-scoped, so each was found by its name having no other use in its own file, and both app targets confirm none was still needed. Nothing a user can reach changes. Most of it is Settings' WHOOP 5/MG research card and what only that card used: the ECG raw-data gate readouts, the deep-data button state, the capture exports and their AppStorage mirrors. #1709 removed the card's only call site when the IMU capture sessions replaced it, and the body stayed behind. The UserDefaults keys and the features behind them are untouched; only unreachable UI goes. The rest are small leftovers across RawHistoryArchive, BiofeedbackController, FullDayChartView, SleepView, LiveWorkoutView, TodayView, LiveView, IntervalTimerView, TrendsView and WeeklyDigestView. Deliberately kept: the UIApplicationDelegateAdaptor and DomainTheme's colorScheme, which are unread by name but make SwiftUI host the delegate and re-render; and LiftLogView.unitSystem, whose AppStorage re-render was not settled, so that file is untouched. Verified beyond the compile: every removed declaration was checked for a surviving reference in the file it came from, which is the scope that decides it for a private symbol. The conditional-compilation gap was checked too, since a use the built configuration never sees would pass a compile: the only conditions in these files are os(iOS), os(macOS), DEBUG and canImport(UIKit), all compiled by at least one leg, and the three release-only else branches, all in TodayView, name none of the removed symbols. Authored by @UtkuDenizAltiok.
WHOOP 5/MG finally has a working 100 Hz raw-IMU capture workflow
This closes the reverse-engineering loop opened in #423 and turns the result into something developers can build on.
NOOP already understood the WHOOP 5/MG's 100 Hz, 6-axis IMU format, but it could not reliably start and record that stream on demand. Several individually plausible commands acknowledged successfully without producing data.
Hardware validation established that the producer requires an ordered sequence:
START_RAW_DATA(81)TOGGLE_IMU_MODE(106) with[1, 1]With that sequence, a regular WHOOP 5/MG emits continuous type-43/type-51 packets containing 100 Hz accelerometer and gyroscope data. No research-band firmware or developer-only hardware is required.
This PR does more than expose that command sequence. It introduces a complete, bounded raw-data capture workflow for collecting, annotating, retaining and exporting labeled sensor data.
A reusable Raw Data Collector
The new Raw Data Collector lets developers create intentional recording sessions instead of relying on incidental diagnostic logs.
A recording session can:
Markers make the collector useful for gathering labeled datasets while an activity is happening. Starts, stops, transitions, mistakes and notable moments can be recorded without modifying the sensor stream itself.
This provides the missing foundation for future motion-based features. A module could, for example, use the existing low-rate motion stream for inexpensive continuous activity detection and enable 100 Hz capture only around a candidate activity.
High-rate windows could then support swimming detection, lap and stroke counting, gait analysis, workout classification, exercise and repetition recognition, posture or movement analysis, and locally evaluated activity models.
Those recognizers are deliberately not part of this PR. This PR provides the capture, labeling and export infrastructure required to develop and validate them.
Storage designed for longer recordings
High-rate IMU samples are stored outside the operational SQLite sample tables.
Each session owns a set of decoded
.imusfiles:The 30-minute segmentation limits the amount of data that future analysis modules must open at once, while the internal 30-second blocks keep recording append-oriented and resilient to interruption.
Exports remain convenient for users: the selected
.imussegments, metadata, markers and accompanying sensor data are delivered together in one ZIP archive.The design intentionally stops short of implementing an always-on rolling buffer today. It nevertheless supports later features such as bounded workout recordings, deferred processing while the phone is charging, or a rolling activity-detection buffer without requiring a replacement storage format.
Observed storage and battery use
One uncontrolled Android test recorded approximately 5 hours and 47 minutes of continuous 100 Hz IMU data:
.imussegments occupied approximately 11.5 MB;During the same session, the band battery indication decreased from 76% to 72%. A later database-backed discharge trace provides a more useful order of magnitude across three recordings:
These are observations from one band under uncontrolled conditions, not storage or battery benchmarks. Depending on the comparison baseline, active 100 Hz capture used roughly 1.5–2.3 times the normal drain in this trace. Compression depends on movement, and the battery series does not isolate IMU production, BLE transport, or subsequent history repair. The measurements provide a planning estimate for bounded recordings, not a continuous-capture runtime guarantee.
Honest capture coverage
Coverage is calculated from the IMU seconds actually stored, rather than inferred from connection state or ordinary low-rate sensor freshness.
The collector and export metadata distinguish between the requested interval, the first and last stored IMU second, the number of covered seconds, startup delay, internal gaps, and complete or incomplete recordings.
A recent 5-hour-47-minute test retained 20,783 of 20,803 requested seconds, corresponding to 99.904% coverage. The export was correctly marked incomplete because of startup, tail and isolated internal gaps.
This does not claim that Bluetooth transport can guarantee lossless capture. It ensures that downstream developers can determine exactly what was recorded and do not unknowingly treat partial data as complete.
Test Centre cleanup and platform parity
The existing WHOOP 5/MG raw-data controls were experimental developer tooling, but they were spread through ordinary Settings and did not provide a coherent recording workflow.
This PR:
rawImuSamplepath with session-owned file storage;.imuc,.imus, raw-outbox or SQLite storage;The collector also includes an off-session fail-safe. If unexpected realtime type-43/type-51 packets arrive while no recording or intentional diagnostics capture is active, NOOP disarms the producer after a short grace period. History packets are not stopped, and cooldown handling prevents a delayed packet from creating a stop-command loop.
Platform status
Android
The complete workflow has been exercised on a physical Android device with a regular WHOOP 5/MG.
Validation included starting and stopping the producer, multi-hour recording, temporary Bluetooth interruption, later packet delivery, editable bounds, markers, repeated export, deletion, compressed segment parsing, sample-rate and payload checks, coverage and gap detection, and verification that only the canonical
.imuspath stores session IMU data.Apple platforms
The macOS application and iOS Simulator target compile successfully, and the shared Strand test suite passes in CI.
Android and Apple implement the same session model, marker workflow, editable time ranges, coverage reporting, deletion behavior and export structure. Their parity is checked with the project's parity contract and test toolkit.
The implementation has not been exercised on a physical iPhone or against a real strap. In particular, iOS BLE capture, background behavior, filesystem behavior, Share Sheet export and the final on-device user experience remain unvalidated until someone tests them on real hardware.
History
This work builds on:
rawImuSamplepath remained dormant.The earlier work established that the data existed and could be decoded. This PR supplies the missing on-demand producer sequence and turns those findings into a usable, cross-platform recording workflow.
Builds on #454 and #455.
Supersedes the dormant raw-IMU storage documented in #978 and #1345.
Related to the broader WHOOP 5/MG deep-data roadmap in #761.
Addresses #423.