feat(store): persist the WHOOP 5.0 v26 raw PPG waveform (#156 follow-up) - #415
Merged
Conversation
The strap's 24 Hz optical PPG buffer (type-47 v26 records) was fully decoded (ppg_waveform, 24 i16 ADC samples/record) but only ever used to derive a per-second HR estimate (ppgHrSample, v12) — the waveform itself was discarded right after, and explicitly excluded from the undecodable-record reject archive as "known-and-unstored by design". This reverses that: the samples now survive in their own durable stream. - WhoopProtocol: Streams gains ppgWaveform: [PpgWaveformSample] (ts + raw i16 samples), populated by extractHistoricalStreams alongside the existing ppgHr derivation. isEmpty/CodingKeys/decoder updated so a waveform-only decode (too little context for a confident HR estimate) still counts as decoded data, and older JSON fixtures without the key still decode. - WhoopStore: new v27 migration creates ppgWaveformSample (deviceId, ts, samples BLOB), one row per second like every sibling stream, but with the 24 samples packed as compact little-endian i16 (2 bytes/sample) instead of 24 scalar rows — a v26-heavy night costs about the same order of magnitude as one extra per-second stream, not 24x that. Wired into the existing insert(_:deviceId:) choke point (persist-only, mirrors steps/sleepState/ ppgHr) so the Collector/Backfiller paths pick it up with no changes of their own. Added ppgWaveformSample to DeviceRegistryStore.deviceScopedTables so "delete all of this device's data" clears it too (caught by the existing coverage test). - No new pruning: matches every other durable per-second table (hrSample, spo2Sample, ... are never pruned either). This is decoded biometric history, not the transient raw outbox — PrunePolicy's ~50 MB cap governs only rawBatch and is untouched. - Android's Kotlin HistoricalStreams.kt/PpgHr.kt has the identical decode-then-discard (only ppgHrSample persists); no Room table or entity exists for the raw waveform. Left as a follow-up, not implemented here. swift test is green in both packages (WhoopProtocol 281 tests, WhoopStore 266 tests), and the macOS Strand scheme builds clean.
…with PR #415) Ports the durable WHOOP 5.0 v26 optical-PPG waveform storage from WhoopStore (issue #156 follow-up). WHOOP straps connect on Android, so this twins BOTH the Room schema AND the store-write plumbing: - Room migration v19 -> v20 + @entity ppgWaveformSample (deviceId, ts, samples BLOB; PK deviceId, ts), matching the GRDB v27-ppg-waveform table. Pinned by PpgWaveformMigrationTest. (v19 is the efficiency-heal on this branch, so the PPG table takes v20, twinning Swift's v27 = next slot after v26.) - StreamPersistence.packPpgSamples/unpackPpgSamples: little-endian i16, BYTE-identical to the Swift WhoopStore packer so a .noopbak round-trips. - extractHistoricalStreams now also collects the raw per-second waveform into StreamBatch.ppgWaveform; WhoopRepository.insert packs + persists it (IGNORE-dedupe by deviceId, ts); ppgWaveformSamples() reads it back. - DeviceRegistry device-data wipe clears ppgWaveformSample too (privacy parity). Tests: PpgWaveformMigrationTest (schema + pack/unpack + insert wiring) and Whoop5PpgWaveformStreamTest (extraction over the same real v26 fixture the Swift Whoop5PpgWaveformTests uses).
digitalerdude
added a commit
to digitalerdude/noop
that referenced
this pull request
Jul 17, 2026
Upstream landed feat(store): persist v26 raw PPG waveform (ryanbr#415), which took GRDB v27-ppg-waveform and Room MIGRATION_19_20 — the exact slots this PR previously used. Renumbered the ppgRespSample migration to GRDB v28-ppg-resp-sample / Room MIGRATION_20_21 (schemaVersion 21), keeping both platforms' migration counts aligned, and updated the two migration tests' version-pair assertions + doc comments to match. The waveform table (ryanbr#415) and this resp table are three streams off the same v26 buffer, each in its own table, no schema overlap.
digitalerdude
added a commit
to digitalerdude/noop
that referenced
this pull request
Jul 17, 2026
Upstream landed feat(store): persist v26 raw PPG waveform (ryanbr#415), which took GRDB v27-ppg-waveform and Room MIGRATION_19_20 — the exact slots this PR previously used. Renumbered the ppgRespSample migration to GRDB v28-ppg-resp-sample / Room MIGRATION_20_21 (schemaVersion 21), keeping both platforms' migration counts aligned, and updated the two migration tests' version-pair assertions + doc comments to match. The waveform table (ryanbr#415) and this resp table are three streams off the same v26 buffer, each in its own table, no schema overlap.
DX23876
pushed a commit
to DX23876/noop
that referenced
this pull request
Jul 22, 2026
…ollow-up) (ryanbr#415) * feat(store): persist the WHOOP 5.0 v26 raw PPG waveform (ryanbr#156 follow-up) The strap's 24 Hz optical PPG buffer (type-47 v26 records) was fully decoded (ppg_waveform, 24 i16 ADC samples/record) but only ever used to derive a per-second HR estimate (ppgHrSample, v12) — the waveform itself was discarded right after, and explicitly excluded from the undecodable-record reject archive as "known-and-unstored by design". This reverses that: the samples now survive in their own durable stream. - WhoopProtocol: Streams gains ppgWaveform: [PpgWaveformSample] (ts + raw i16 samples), populated by extractHistoricalStreams alongside the existing ppgHr derivation. isEmpty/CodingKeys/decoder updated so a waveform-only decode (too little context for a confident HR estimate) still counts as decoded data, and older JSON fixtures without the key still decode. - WhoopStore: new v27 migration creates ppgWaveformSample (deviceId, ts, samples BLOB), one row per second like every sibling stream, but with the 24 samples packed as compact little-endian i16 (2 bytes/sample) instead of 24 scalar rows — a v26-heavy night costs about the same order of magnitude as one extra per-second stream, not 24x that. Wired into the existing insert(_:deviceId:) choke point (persist-only, mirrors steps/sleepState/ ppgHr) so the Collector/Backfiller paths pick it up with no changes of their own. Added ppgWaveformSample to DeviceRegistryStore.deviceScopedTables so "delete all of this device's data" clears it too (caught by the existing coverage test). - No new pruning: matches every other durable per-second table (hrSample, spo2Sample, ... are never pruned either). This is decoded biometric history, not the transient raw outbox — PrunePolicy's ~50 MB cap governs only rawBatch and is untouched. - Android's Kotlin HistoricalStreams.kt/PpgHr.kt has the identical decode-then-discard (only ppgHrSample persists); no Room table or entity exists for the raw waveform. Left as a follow-up, not implemented here. swift test is green in both packages (WhoopProtocol 281 tests, WhoopStore 266 tests), and the macOS Strand scheme builds clean. * feat(android): Kotlin twin of the v26 raw PPG waveform store (parity with PR ryanbr#415) Ports the durable WHOOP 5.0 v26 optical-PPG waveform storage from WhoopStore (issue ryanbr#156 follow-up). WHOOP straps connect on Android, so this twins BOTH the Room schema AND the store-write plumbing: - Room migration v19 -> v20 + @entity ppgWaveformSample (deviceId, ts, samples BLOB; PK deviceId, ts), matching the GRDB v27-ppg-waveform table. Pinned by PpgWaveformMigrationTest. (v19 is the efficiency-heal on this branch, so the PPG table takes v20, twinning Swift's v27 = next slot after v26.) - StreamPersistence.packPpgSamples/unpackPpgSamples: little-endian i16, BYTE-identical to the Swift WhoopStore packer so a .noopbak round-trips. - extractHistoricalStreams now also collects the raw per-second waveform into StreamBatch.ppgWaveform; WhoopRepository.insert packs + persists it (IGNORE-dedupe by deviceId, ts); ppgWaveformSamples() reads it back. - DeviceRegistry device-data wipe clears ppgWaveformSample too (privacy parity). Tests: PpgWaveformMigrationTest (schema + pack/unpack + insert wiring) and Whoop5PpgWaveformStreamTest (extraction over the same real v26 fixture the Swift Whoop5PpgWaveformTests uses).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
The WHOOP 5.0 strap sends a ~24 Hz raw optical PPG waveform in type-47 v26 records during v26-heavy stretches of a night. NOOP already fully decodes it (
ppg_waveform, 24 little-endian i16 ADC samples/record — seedecodeWhoop5HistoricalV26inInterpreter.swift), but the samples were only ever used to derive a per-second HR estimate (PpgHr.derivePpgHr→ppgHrSample, v12) and then discarded.rejectedHistoricalRecordseven explicitly excludes v26 from the undecodable-record reject archive as "known-and-unstored by design" — so a fully-decoded biometric signal had no durable home at all.This is a storage-only change: no BLE subscription/command is added or modified. The waveform is already received and decoded; this just stops throwing it away.
Storage approach
A new table (
ppgWaveformSample), not the reject archive:RawHistoryArchive/rejectedHistoricalRecords) exists for genuinely-undecodable frames (CRC failure, unmapped layout). A v26 record that decodes fine was never one of those, so re-routing it there would be a category error — and it's why v26 is excluded from that path unconditionally (even on a CRC failure), independent of this change.ppgWaveformSamples(deviceId:from:to:)) for a future re-analysis (a better HR estimator, HRV-from-PPG, a waveform viewer) without re-parsing raw frames.(deviceId, ts)— the same key as every sibling per-second stream (hrSample,spo2Sample,ppgHrSample, ...) — but the 24 samples are packed into a compact BLOB (little-endian i16, 2 bytes/sample) instead of 24 scalar rows. That keeps a v26-heavy night to roughly the same order of magnitude as one extra per-second stream (~50 bytes/row), not 24x that.Retention
No new pruning — this matches every other durable per-second table (
hrSample,spo2Sample, etc. are never pruned either); it's decoded biometric history, not the transient raw outbox.PrunePolicy's ~50 MB cap governs onlyrawBatch(pre-decode raw frames kept for re-sync) and is untouched by and unrelated to this table. Growth is bounded by how much v26 data a strap actually emits (firmware picks v26 vs v18 per second; not every night is v26-heavy), not by an artificial cap, and the compact packed-BLOB encoding keeps the per-record cost small regardless.Changes
Packages/WhoopProtocol:StreamsgainsppgWaveform: [PpgWaveformSample](ts+ rawsamples: [Int]).extractHistoricalStreamspopulates it alongside the existingppgHrderivation — same(ts, samples)the HR estimator already collects, just no longer discarded after.isEmpty/CodingKeys/the custom decoder are updated so a waveform-only decode (too little context for a confident HR estimate —PpgHr.derivePpgHrneeds >= 3 consecutive seconds) still counts as decoded data for the Backfiller's silent-data-loss diagnostic, and older JSON fixtures without the key still decode (decodeIfPresent ... ?? [], mirroring every prior stream addition).Packages/WhoopStore: new migrationv27-ppg-waveformcreatesppgWaveformSample(deviceId,ts,samplesBLOB, PK(deviceId, ts)).packPpgSamples/unpackPpgSamplesdo the little-endian i16 packing (handles any sample count — a truncated frame can decode fewer than 24 — and drops a corrupt trailing odd byte on read rather than throwing). Wired into the existinginsert(_:deviceId:)choke point as a persist-only block (mirrorssteps/sleepState/ppgHr: not added to the 8-field return tuple), soCollector/Backfiller/RawHistoryArchive.replayall pick it up with no changes of their own — they already just passStreamsstraight through tostore.insert.ppgWaveformSampletoDeviceRegistryStore.deviceScopedTables— caught by the existingtestDeviceScopedTablesCoversEveryDeviceIdKeyedTablecoverage test, since without it "delete all of this device's data" would silently leave orphaned waveform rows behind.HistoricalStreams.swift(rejectedHistoricalRecords) to reflect that the waveform now has its own durable stream.Android parity
Kotlin twin included + gradle-tested. Android straps connect, so this twins BOTH the schema and the store-write plumbing:
PpgWaveformSampleEntity(deviceId,ts,samplesBLOB; PK(deviceId, ts); column order == entity field order) mirror the GRDBv27-ppg-waveformtable so the.noopbakschema stays byte-identical, pinned byPpgWaveformMigrationTest. (On the Android branch v19 is already the efficiency-heal migration = Swift v26, so the PPG table takes v20 = Swift's v27 — the migration shape, not the number, is the contract.)StreamPersistence.packPpgSamples/unpackPpgSamplesdo the little-endian i16 packing, byte-identical to the Swift packer, so the stored BLOB round-trips a.noopbakacross platforms.extractHistoricalStreamsnow also collects the raw per-second waveform intoStreamBatch.ppgWaveform;WhoopRepository.insertpacks + persists it (IGNORE-dedupe by(deviceId, ts));ppgWaveformSamples(...)reads it back.ppgWaveformSampleis added to the device-data wipe (privacy parity).Tests:
PpgWaveformMigrationTest(schema + pack/unpack + insert wiring) andWhoop5PpgWaveformStreamTest(extraction over the same real v26 fixture the SwiftWhoop5PpgWaveformTestsuses)../gradlew :app:testFullDebugUnitTestgreen.Tests
Packages/WhoopProtocol/Tests/WhoopProtocolTests/Whoop5PpgWaveformTests.swift:extractHistoricalStreamspopulatesppgWaveformfrom a real captured v26 frame even when the HR estimator has too little context to produce anything (proving the fix — before this change that record would have vanished entirely);Streams.isEmptycorrectly treats a waveform-only decode as non-empty; JSON decode tolerates a missing/presentppg_waveformkey and round-trips (including negative AC-coupled samples).Packages/WhoopStore/Tests/WhoopStoreTests/PpgWaveformSampleTests.swift: the v27 migration creates the table with the right PK/columns; insert + read round-trips a real captured waveform exactly; re-insert is idempotent (ON CONFLICT DO NOTHING); range/device-scoping is respected; a short (< 24) sample array round-trips (the format isn't hardcoded to a fixed count); the pack/unpack codec round-trips signed extremes (-32768/32767) and drops a corrupt trailing odd byte instead of crashing.swift testis green in both packages: WhoopProtocol 281 tests, WhoopStore 266 tests (0 failures).xcodegen generate && xcodebuild -scheme Strand -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO build→ BUILD SUCCEEDED, no errors. (No app-target/Collect code needed to change — the Collector/Backfiller already passStreamsgenerically tostore.insert.)Test plan
cd Packages/WhoopProtocol && swift test— 281 tests, 0 failurescd Packages/WhoopStore && swift test— 266 tests, 0 failuresxcodegen generate && xcodebuild -scheme Strand -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO build— BUILD SUCCEEDEDppgWaveformSample(this PR was built/tested in an isolated worktree without a paired strap; welcome for a maintainer or follow-up to confirm on hardware)