fix: YCBT spot-HR completion (#59), delete individual readings (#60), R100 / CRP temperature history (#58) - #62
fix: YCBT spot-HR completion (#59), delete individual readings (#60), R100 / CRP temperature history (#58)#62foureight84 wants to merge 10 commits into
Conversation
…rs in reports Issue #58. The R100 is a white-label CRP ring on the same Moyoung "Da Rings" firmware as the Colmi R11, and its reporter's diagnostics turn out to be the first non-empty temperature-history capture anyone has sent us. Temperature history (group 2 / cmd 22) now decodes. The layout matches the vendor parser `e1/m` byte for byte: `[day][frameIndex]` then little-endian 2-byte tenths of a degree Celsius per 5-minute slot, 72 slots per frame, terminal index 3, with the vendor's 28.0-50.0 C clamp meaning "no reading" outside it. `CRPProtocol` had carried this as unconfirmed since every R11 capture came back empty. The missing decode cost more than the samples: with no `TimingHistoryFrame` marker emitted, `CRPSyncEngine` never advanced the cursor, so the ring was asked for frame 0 on every pass and never for frames 1-3 — 18:00 onward of every day was unreachable. Also adds a catalog card for the R100 so it matches at scan by name instead of requiring the user to pick the R11 card and rely on the post-connect re-route. Its blurb omits stress: the ring answers neither the stress history query (2/47) nor the stress monitor-state read-back (2/45), 22 sends and 0 replies. Diagnostics masking now keeps a health frame's routing header — 6 bytes for CRP, 4 for YCBT, 1 elsewhere — instead of only byte 0. Masking from byte 1 made every health frame in a report indistinguishable, which is why this capture could not answer whether an all-day SpO2 reply carried samples. Those bytes are the same ones the app writes when it asks for the record, and outbound queries are already exported unmasked. The inverse failure showed up here too: temperature frames were exported with their values intact, because an undecoded frame fell through to `command_ack`, which is not in HEALTH_KINDS. A decode gap had silently become a privacy gap.
…ting readings Issues #59 and #60. Fixed together because the first is what produced the readings the second is asking to clean up. #59 — a spot heart-rate measurement on a YCBT ring always failed with "couldn't get a steady reading" while showing a bpm the user never had. The reporter's instrumented capture shows three separate causes, all confirmed: * The ring ends the measurement itself with `04 0e [mode, status]` and we ignored it, so the leg idled out its whole window after the ring had gone quiet. Now decoded — layout read off the vendor app, which matches bArr[0] against the measurement type and switches on bArr[1] (1 success, 2 failed, else cancelled) and reads no value from the frame. `SpotMeasurementGate` honours it by token, so a completion can only end the measurement it names. * The settle took a median over the whole window. That ring's PPG spends its first ~26 s on a pre-converged plateau (47 47 47, then 46 46 46) against a real rate of 81, and the plateau is both the majority of the window and the most self-consistent thing in it — so the median picked it every time. The settle now looks at the tail of the window instead. * The contact-lost gap was 3 s, but these rings stream in bursts of three about a second apart separated by 4-6 s of silence. It fired mid-measurement on a ring that was working perfectly, aborting the leg before the sensor had converged at all. Now 8 s. The HR window ceiling is per family (`RingSyncEngine.spotHeartRateSeconds`): 45 s for YCBT, whose ring self-terminates at ~35 s, and 30 s for everyone else. Safe only because the leg now ends on the ring's own signal. #60 — readings can be deleted, from a new list on each metric's detail screen, one at a time and behind a confirmation. Deletion only; a recorded health value is never edited into a different number. A DELETE alone would not have held. History rows are keyed `history:<kind>:<timestamp>` and written with upsert so a re-synced day is idempotent, which is exactly what would put a deleted reading back on the next sync. `measurement_deletions` (schema v24) remembers the deletion, and every deterministic-id write goes through one gate that consults it. Tombstones ride in the archive too, since a restore wipes every table first. Also fixes the reason there was so much to delete: a spot measurement's output is one reading, but every intermediate estimate it settled from was stored as its own row stamped with the moment it arrived — so one failed measurement left a whole train of readings that were never the user's heart rate. The live stream is now suppressed while a measurement settles and the settled value is published once. A live workout is the opposite case and suppresses nothing. Known limit: a reading already exported to Health Connect stays there. The export does not retain HC record ids, so there is nothing to delete against.
…countdown RC-1 feedback on #59 and #60. @Albabit's SpO2 capture from the same ring shows every property differing from heart rate: samples start at t+13s, there is a 24s silence in the middle (against HR's 4-6s), the run lasts 50s (against 35), and values rise to a peak of 99 then decline to 94 rather than converging. None of his three specific worries applied — the SpO2 leg uses neither the contact-gap rule nor the HR window nor the tail settle — but the capture exposes a different defect he didn't name: the leg returned the FIRST plausible sample and stopped. On his capture that is 96% at t+13s, handed back 37 seconds before the ring finished and before nine further samples arrived. It also made the `04 0e` completion added in rc1 unreachable for this leg. The leg now collects the run and settles it. `Spo2SampleWindow` takes the median deliberately: one capture cannot say whether the peak or the declining tail is the honest number, and the median privileges neither. Marked provisional pending repeated captures — that is why it is a separate tested class rather than three lines inline. Collecting a whole run is gated on `signalsMeasurementCompletion`, true only for YCBT. A leg that waits for a signal no family sends would idle out its window, and the CRP R11 answers a spot SpO2 with one value after ~48s of silence and nothing more; waiting past it would turn a working measurement into a minute-long stare at a progress bar. Other families keep first-value-wins. Extends #60's one-reading-per-measurement rule to SpO2, which now matters more: the captured run streams twelve values over ~50s, every one of which would otherwise be stored as its own reading. Also makes the Vitals countdown sum only the legs that will actually run, gated on the same capabilities as the sweep. It told a tester his measurement would take 188s when his ring runs two of the four legs; a countdown that overstates by 80s is worse than none, because it reads as a promise.
RC-1 tested on hardware — one more fix folded in (rc2)Both reporters tested v2.8.0+41-rc1 and confirmed the fixes. rc2 is v2.8.0+42-rc2. Confirmed on hardware:
What rc2 adds, from @Albabit's SpO₂ capture on the same ring: None of the three risks he raised applied — the SpO₂ leg used neither the contact-gap rule, nor the HR window, nor the tail settle — but the capture exposed a defect underneath them: the leg returned the first plausible sample and stopped. On his run that's 96 % at t+13 s, handed back 37 seconds before the ring finished and before nine further samples arrived. It also made rc1's The leg now collects the run, ends on Collecting a whole run is gated on SpO₂ also picks up #60's one-reading-per-measurement rule, which matters more now that the leg streams twelve values instead of returning after one. And the Vitals countdown sums only the legs that will actually run: it told a tester 188 s for a ring that runs two of the four. Deliberately not folded in — both need evidence I don't have:
Follow-ups for separate issues: splitting the HR and SpO₂ measure buttons (35 s vs 50 s with very different sample spacing is not one measurement), and deriving stress from HRV app-side for rings whose hardware never answers a stress query. 1262 tests, 0 failures. |
…gate rides the bus RC-2 feedback on #59 / #60, plus review findings on PR #62. - SpO2 settles on the last plausible sample (vendor band 70-100). Three sources agree: five captures read back against the ring's own history (last sample 5/5, median 4/5), the collapsing run the ring itself stores as 87, and the vendor app, which never settles and re-reads history on `04 0e`. - YCBT SpO2 ceiling is 75 s (`spotSpo2Seconds`): every capture's `04 0e` landed at t+63.1 s, three seconds after the old window gave up. Countdown follows it. - BP and HRV legs no longer treat a `04 0e` *success* as an abort; only the ring's failure verdict ends them early. HRV has no live-value frame, so success was reported as a failed measurement. - The live-sample gate is a bus event (`LiveSampleGate`), ordered against the samples it governs, instead of a flag the lagging persistence collector read at write time. The settled reading is tagged `spot` and stored with that source. - The HR leg publishes no settled row during a streaming workout, where the stream already stored the reading. - The ring owns a spot reading it logged itself: when a history sample of the same kind lands within 90 s of one of our `spot` rows, ours is deleted and the ring's regenerable row stays. This is the doubled 79/82 pair from RC-1. - Diagnostics masking keeps the 4-byte header for `RingDeviceType.YCBT` (R10M), not just TK5 / COLMI_SMART_HEALTH. - SpotMeasurementGate and HRSampleWindow are synchronised: verdicts land on Main while the coach's measurement tools poll from IO.
…lmi workouts as a sport session (#64) - A complete YCBT sleep record now retires only the contiguous run of stored blocks its interval sits in (`completeSessionSurvivors`), not every block of every row it overlaps. A night the ring split in two (3-minute gap) was merged into one row by segmentation, then the second record's re-sync wiped the first's three hours: 4h06 shown for a 6h08 night. Shortened re-sends still retire their own stale head/tail. The `05 13` parser also resyncs on the `af fa` magic so a bad declared length can't swallow later sessions. - Colmi live workouts start a ring-side sport session (`0x77 01 <type>`, the QRing app's whole live-activity protocol) and decode the ring's `0x78` telemetry (bpm at payload byte 4) instead of chaining one-shot `0x69` measurements re-armed after 30 s of silence. Restarts after spot measures are no-ops so the ring's record isn't reset; silence gets one resume, then the workout falls back to the old stream, as does a ring that rejects `0x77` or ends the session itself. New `startWorkoutHeartRate` / `stopWorkoutHeartRate` on the engine interface keep a spot measure's stop from ending the sport session.
… sport mode after a ring-ended session Review findings on this PR. - `adoptRingsCopy` ran for every family, but only a ring that reports its own completion logs a spot measurement into its history. CRP and Colmi record all-day HR/SpO2 on a five-minute grid, so an unrelated grid sample landing within the 90 s match window would delete the reading the user asked for. The gate is now at write time: `ringWillLogIt` rides the settled-reading event, and only then is the row marked `"spot"`. Every other family stores a plain live row, as before this PR. - `trigger_measurement` read `latestHRValue` / `latestSpO2Value` after calling the leg. Those hold the last raw streamed sample, are deliberately not the settled reading, and are not cleared on failure — so a failed measurement reported the pre-converged plateau as completed. It now uses what the leg returned. - A `0x78` status 3 is the ring saying this sport session finished, not that it cannot run one. It no longer sets the sticky `sportRejected`: the rest of that workout runs on the plain stream and the next workout opens a fresh session. The refusal reply and exhausted watchdog silence stay sticky. - The R100 name pattern matched `R100 1A2B`, which is the SmartHealth space-separated serial convention, and the R100 card precedes the SmartHealth one — so a SmartHealth ring would have taken the CRP driver and never synced. Tightened to the `_<hex>` serial the reports actually show. - `Reading.fromHistory` came from `sourceRaw`, but Colmi stress and temperature history rows carry other sources while still using regenerable `history:` ids. The delete confirmation dropped its "stays deleted" line for exactly those. It now asks the same question the delete path does.
…ding (#59) The reporter captured three spot measurements with no stop command and read each one back out of the ring's memory before any app touched it. The ring stored the last streamed sample three times out of three (65, 58, 72), and the run is still climbing when it stops — so every tail-weighted rule lands below the ring's answer, by up to 18 bpm on those runs and by 1 bpm on rc5 in the field (app 94, ring 93). Disagreeing with the ring is not a better number, it is a second number: the ring's copy arrives on the next sync and ours yields to it (#60), so a settle that disagrees shows the user one value and then stores another. The vendor's measure screen doesn't settle either — HeartRateMeasureActivity.onEvent overwrites the display with every frame, dropping only values outside HEART_RATE_VISIBLE_MIN..MAX (40..220), and re-reads history on 04 0e success. So HRSampleWindow.settled(ringChoosesLastSample) takes the last plausible sample for a family that reports its own completion, and keeps the tail rule for one that never does — nothing chose that ring's last sample, its leg just ran out of window. Gated on signalsMeasurementCompletion, the same property that gates the ring-copy rule, rather than widened to every family: that over-generalisation is what rc5 had to correct. The three read-backs are the regression tests, along with the counterpart asserting that the tail rule still disagrees with the ring on a climbing run — so "just use the tail everywhere" reads as a deliberate change, not a tidy-up.
#63) Merging a split night into one row is right, but it also made the row's span cover the minutes between the two records. The reporter's night read 8 h 10 (23:51–08:02) against the 268 + 140 minutes its two records declared, 6 h 48. He then read the vendor app rather than leave the number hanging, and it draws the distinction explicitly: SleepActivity:695 builds each history entry as deepSleepTotal + lightSleepTotal + remTotal, carries wakeDuration separately, and takes startTime from the first record of the day and endTime from the last. Confirmed against the decompile here. It matches what he saw on 30 Aug, where the vendor showed 7 h 08 for a night spanning 7 h 21. asleepMinutes(blocks) is now the single definition of a session's totalMinutes and spanMinutes is the other number; the sleep card already showed both, so only the headline changes. Implemented as "every stage except AWAKE" rather than naming three stages: SleepStage.UNKNOWN is the else branch of every decoder here, an unrecognised stage byte inside a record the ring called sleep, and those minutes were slept. Two consequences handled. The hypnogram's x axis scaled by totalMinutes and would have compressed and mislabelled every tick, so it takes spanMinutes now. And ring history only reaches back about a week, so a re-sync would leave older nights reading the old way forever — DataRepairs.repairSleepDurationsIfNeeded restates stored rows once from their own blocks, skipping a session with none rather than zeroing it, since byDay and earliestDay both filter totalMinutes>0. Also fixed alongside, found while tracing the stage sums: the coach's sleep summaries filtered stage blocks against lowercase "deep"/"light"/"awake" while stageRaw is persisted as the uppercase enum name, so every sleep summary the coach has ever been handed reported no deep, light or awake sleep at all. Not addressed here, and a fair ask: showing a split night's two records separately as well as merged.
…nd the branch From a review pass over fix/issues-58-59-60. Five findings fixed here; the rest are recorded on the PR. The ring's verdict on THIS run, not the family, owns the reading (#59/#60). A YCBT HR run that hit the 45 s ceiling without a 04 0e was settled on its last sample and stored as "spot", so the next sync could delete it in favour of an unrelated all-day grid sample — the exact failure the ringWillLogIt gate exists to prevent. The last-sample rule and the "spot" marking now both derive from the run's own completion; a run the ring never ended falls back to the consistency gate. The SpO2 leg gets the same ownership rule. A tombstoned history sample adopts nothing. upsertUnlessDeleted skipped the write but adoptRingsCopy still ran, so a deleted sample re-sent on every sync kept retiring any spot reading within 90 s of it. Sleep score denominators follow the two numbers a session now carries (#63). Stage shares and the duration band are of time asleep; the awake share and the "does this ring label awake" coverage heuristic are of the span — judged against asleep time that heuristic was true for every ring. The one-time repair recomputes the stored score with the duration, and runs as a single transaction so it cannot overwrite a night the first sync just reconciled. Archive restore restates every night from its own blocks the same way, since a pre-#63 backup carries span totals and the repair will not run again. Every Colmi 0x78 sport frame is tagged for masking (#64). A warm-up frame with bpm 0 decoded to nothing, fell through to "unknown", and exported the workout's live steps, distance and calories in clear.
Review pass on rc6 — five findings fixed in
|
…#63) A YCBT sleep record carries its own bounds in the header — DataUnpack reads `startTime` at +4 and `endTime` at +8 — and every segment carries its own `sleepStartTime`. We read neither: segment timestamps were used to de-duplicate and then discarded, and the timeline was concatenated as `round(seconds / 60)` minutes per segment from the first segment's start. That drifts. On the captured night already in the tests the header declares a 474-minute span and totals 473.7 minutes of sleep; concatenation stored 470, on a night with no wake at all. The ring leaves a one-second gap between every consecutive segment (49 of them there) and each segment rounds independently against a one-minute floor, so the derived end lands wherever the rounding takes it — short there, long elsewhere. One minute of long drift loses a whole session. `completeSessionSurvivors` grows its retirement run across blocks that abut end-to-start exactly, so a record ending at 05:58 instead of its declared 05:57 stops reading as a session across a one-minute gap and becomes the block immediately before the next one: the run swallows it, and the interval replace puts back only the later record. That is the two-record night reported on rc6, where 5 h 22 went missing while nights with nine- and twenty-minute gaps still merged correctly. Each segment is now placed at its own `sleepStartTime` for its own `sleepLen`, across a run spanning exactly the header's bounds. Minutes no segment claims read as awake, which is honest — the ring reports wake as its own segment type (0xf4). A record whose header carries no usable bounds keeps the concatenated reading, there being nothing better to place against. The captured night decodes to 94/251/129 deep/light/rem against the fixture's 93/249/130: the timeline is unchanged, only its endpoints are now the ring's. The merge rule itself is left alone. It is defensible once its input is accurate, and loosening it would mask this rather than fix it. Claude-Session: https://claude.ai/code/session_01VuWpdVvATRkaSja3SFFTQj
Closes #58, closes #59, closes #60, closes #63, closes #64.
Three reported issues, fixed together because they turned out to be connected: the measurement bug in #59 is what produced the readings #60 is asking to clean up. Two more (#63, #64) were reported while this was in test and are folded in as rc4.
Hardware status: #58, #59, #60 and #63 are confirmed on hardware through rc5 by both reporters (see the issue threads); #64 has a first-look pass on rc5 with a full test promised. rc6's HR and sleep-duration changes are not yet confirmed.
Since the original description (rc2 → rc4)
04 0e. Band 70–100 as the vendor uses. YCBT SpO₂ ceiling is 75 s (04 0elands at t+63 s).spot; when a later sync brings the ring's copy (same kind, within 90 s) ours yields to it. This is the doubled 79/82 pair from rc1, mechanism confirmed by the reporter reading the ring's memory.04 0esuccess as a failure. Diagnostics masking keeps the header forRingDeviceType.YCBTtoo.05 13parser resyncs onaf fa.trigger_measurementnow uses what the leg returned rather than the live mirror (which is not the settled value and is not cleared on failure). A0x78status 3 ends that workout's sport session without disabling the protocol for later ones. The R100 pattern no longer reaches the SmartHealth space-separated serial convention.Reading.fromHistorynow asks the same question the delete path does.totalMinutesis time asleep (every stage except awake), with the span shown separately — the vendor buildsdeep + light + remand carries wake duration apart (SleepActivity:695); stored nights are restated once on first launch, archive restore restates too, and the hypnogram axis uses the span. Thespotmarking and the last-sample rule now follow the ring's verdict on that run, not the family, so a timed-out run can no longer be deleted by the next sync's grid sample. A tombstoned history sample no longer adopts anything. Every Colmi0x78frame gets a masked diagnostics kind. The coach's sleep summaries matched lowercase stage names against uppercase storage and reported zero deep/light/awake since they were added — fixed. Follow-ups filed: Split the HR and SpO₂ measure buttons #66 split measure buttons, Derive stress from HRV for rings whose hardware never answers a stress query #67 derived stress, Show a split night's individual sleep records as well as the merged session #68 split-night records.0x77 01 <type>, the QRing app's live-activity protocol) and decode the ring's0x78telemetry, instead of chaining one-shot0x69measurements re-armed after 30 s of silence. Restarts mid-workout are no-ops; silence gets one resume then falls back to the old stream; a ring that rejects0x77stays on the old stream.#59 — spot heart rate never completes on a YCBT ring
@Albabit's instrumented capture showed three separate causes, and all three are real in the code:
The ring ends the measurement itself and we ignored it.
04 0eis a completion push; the leg idled out its whole window after the ring had already gone quiet. Now decoded intoRingDecodedEvent.MeasurementComplete. The layout is read off the vendor app rather than inferred —BaseMeasureActivity.onDataResponserequireslength > 1, matchesbArr[0]against the screen's own measurement type (the same mode byte03 2fsent), and switches onbArr[1]:1success,2failed, anything else cancelled. It reads no value out of the frame — on success it callssyncData()and pulls the reading from history — so neither do we.SpotMeasurementGatehonours it by token, so a completion can only ever end the measurement it names.The settle took a median over the whole window. That ring's PPG spends its first ~26 s on a pre-converged plateau — 47 47 47, then 46 46 46 — against a real rate of 81. The plateau is both the majority of the window and the most self-consistent thing in it, so a whole-window median picked it every time and returned a confident number that was never this user's heart rate.
HRSampleWindownow settles on the tail of the window. Later samples are strictly better evidence on a sensor that is still converging, and this says so without guessing where convergence happened. It costs nothing on a ring that streams a steady rate throughout — its tail agrees with its head.Worth separating two things that looked alike: the existing 5 s discard is for the cached echo (the ring answering instantly with a stored bpm). This ring sent nothing at all for 14 s. A pre-converged plateau is a different problem and needed a different rule.
The contact-lost abort fired on a working ring. The gap was 3 s, but these rings stream in bursts of three about a second apart separated by 4–6 s of silence. It aborted the leg at ~t+19 s, before the sensor had converged at all — this alone reproduces both reported symptoms (the failure message, and the stale 46 left on the card). Now 8 s, sized to the burstiest cadence in the capture rather than the average one.
The HR ceiling is now per family (
RingSyncEngine.spotHeartRateSeconds): 45 s for YCBT, whose ring self-terminates at ~35 s, and 30 s for everyone else. That is only safe because the leg now ends on the ring's own signal — raising the default for families that never send one would make every other ring's measurement visibly slower for nothing.Also: the warm-up echo no longer reaches the on-screen live value, which is how a rejected sample ended up displayed next to a failure message.
Regression tests replay the capture verbatim — it settles ≥ 80, and the burst cadence no longer reads as lost contact.
#60 — delete individual readings
A Readings section on each metric's detail screen lists the window's individual measurements, newest first, each deletable behind a confirmation. Deletion only, as requested — a recorded health value can be removed but never edited into a different number. Paged at 50, since a month of 5-minute all-day history runs to thousands of rows.
A
DELETEalone would not have held. History rows are keyedhistory:<kind>:<timestamp>and written withupsert, deliberately, so re-syncing a day the ring still holds updates the same row instead of duplicating it. That idempotence is exactly what would undo a deletion — the row goes, and the next sync writes it straight back with nothing failing anywhere. Someasurement_deletions(schema v24) remembers the deletion, one gate (upsertUnlessDeleted) sits in front of every deterministic-id write, andMeasurementDeletionowns the two rules callers shouldn't have to remember: tombstone anything the ring can regenerate, and delete a blood-pressure reading as both of its rows. A live reading's id is a fresh UUID nothing regenerates, so it needs no tombstone.Tombstones ride in the archive (
PulseArchive.measurementDeletions) too, because a restore wipes every table first — otherwise a backup round trip would forget the deletions while the ring still held the days behind them.And the reason there was so much to delete. @Albabit noted every failed attempt left a bogus 46 bpm. It was worse than that: every intermediate estimate a measurement settled from was stored as its own row stamped with the moment it arrived, so one failed measurement left a whole train of readings that were never the user's heart rate. A spot measurement's output is one reading — the live stream is now suppressed while it settles and the settled value is published once at the end. A live workout is the opposite case (there the stream is the data) and suppresses nothing.
Known limit, stated plainly: a reading already exported to Health Connect stays there. The export doesn't retain HC record ids, so there is nothing to delete against.
#58 — R100 support
@scufitarosie's diagnostics turned out to be the first non-empty temperature-history capture anyone has sent us, which settled a layout
CRPProtocolhad carried as unconfirmed for months.Temperature history (group 2 / cmd 22) now decodes. It matches the vendor parser
e1/mbyte for byte:[day][frameIndex]then little-endian 2-byte tenths of a degree Celsius per 5-minute slot, 72 slots per frame, terminal index 3, with the vendor's 28.0–50.0 °C clamp meaning "no reading" outside it. From the capture:The missing decode cost more than the samples. With no
TimingHistoryFramemarker emitted, the sync engine never advanced its cursor — so the ring was asked for frame 0 on every pass and never for frames 1–3. 18:00 onward of every day was unreachable, for every CRP ring, not just the R100.The R100 gets its own pairing card, so it matches at scan by name instead of requiring you to know to pick the R11 card and rely on the post-connect
fddare-route. The name pattern is deliberately loose about the suffix (R100orR100_<hex>), because a redacted diagnostics report strips a_<hex>serial and that's the only capture we have; it can't collide with the ColmiR10_xxxxpattern or with the space-suffixed SmartHealth convention, and there's a test for that.On the two things actually asked for:
SpO₂ history already works — the ring just has almost nothing to give. Correlating each reply against its query: HR (
2/15) returned 21 frames with data / 22 empty, HRV (2/16) 30 / 54, and SpO₂ (2/17) 2 / 44. The pipeline is fine; the ring records very little all-day SpO₂. Its SpO₂ monitor-state read-back (2/8) says enabled at a 5-minute interval, so this isn't a switched-off monitor either.Stress is not fixable from here. The R100 answers neither the stress history query (
2/47) nor the stress monitor-state read-back (2/45) — 22 sends, 0 replies, across 22 sync passes, while every other state query answered (2/6HR →05,2/7HRV →05,2/8SpO₂ →05,2/21temp →06). @scufitarosie, thestressin the capability list your report showed is a static per-family constant in PulseLoop, not something your ring confirmed — so it isn't evidence the hardware has it. If stress does appear in the Da Rings app on your ring, a capture of that app talking to it would show which opcode it uses and we can add it.Diagnostics masking got better, in both directions. A masked health frame kept only byte 0, which threw away the group/command — that's why this report couldn't say whether an all-day SpO₂ reply carried samples. It now keeps the routing header (6 bytes for CRP, 4 for YCBT, 1 elsewhere); those are the same bytes the app writes when it asks for the record, and outbound queries were already exported unmasked, so it costs no privacy. The inverse showed up here too: temperature frames were exported with their values intact, because an undecoded frame fell through to
command_ack, which isn't in the masked set. A decode gap had silently become a privacy gap.Testing
./gradlew testDebugUnitTest— 1256 tests, 0 failures. New coverage: the #59 capture replay, the04 0edecode against the vendor layout, the gate's completion ownership rules, the real R100 temperature frame, the temp frame-advance walk, the R100 name-collision cases, the tombstone id-prefix tripwire, and the redactor's header rule.A signed prerelease APK is attached to the release tagged alongside this PR. It's flagged prerelease, so the in-app updater won't serve it to anyone else.
@Albabit — what would confirm #59:
Spo2Completehas the same gap, and I'd rather fix it from a capture than by symmetry with HR.@scufitarosie — what would confirm #58: