Skip to content

fix: YCBT spot-HR completion (#59), delete individual readings (#60), R100 / CRP temperature history (#58) - #62

Open
foureight84 wants to merge 10 commits into
mainfrom
fix/issues-58-59-60
Open

fix: YCBT spot-HR completion (#59), delete individual readings (#60), R100 / CRP temperature history (#58)#62
foureight84 wants to merge 10 commits into
mainfrom
fix/issues-58-59-60

Conversation

@foureight84

@foureight84 foureight84 commented Sep 3, 2026

Copy link
Copy Markdown
Owner

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)

  • SpO₂ settles on the last plausible sample, not the median. Five captures read back against the ring's own history matched the last sample 5/5 (median 4/5), and the vendor app never settles at all — it shows each frame and re-reads history on 04 0e. Band 70–100 as the vendor uses. YCBT SpO₂ ceiling is 75 s (04 0e lands at t+63 s).
  • The ring owns a spot reading it logged itself. Our settled row is stored as 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.
  • The live-sample gate is a bus event, ordered against the samples it governs, instead of a flag the lagging persistence collector read at write time.
  • BP and HRV no longer treat the ring's 04 0e success as a failure. Diagnostics masking keeps the header for RingDeviceType.YCBT too.
  • Only one sleep session per night is shown when the ring records several #63 — a split night keeps every session. A complete YCBT sleep record now retires only the contiguous run of blocks its interval sits in, not every block of every row it overlaps; the second record of a night split by a 3-minute wake used to wipe the first's three hours on re-sync. The 05 13 parser resyncs on af fa.
  • rc5, from a review pass. The ring-copy rule is gated at write time to rings that report their own completion — it was running for every family, and on CRP/Colmi's five-minute all-day grid it would have deleted spot readings in favour of unrelated grid samples. trigger_measurement now uses what the leg returned rather than the live mirror (which is not the settled value and is not cleared on failure). A 0x78 status 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.fromHistory now asks the same question the delete path does.
  • rc6, from the rc5 feedback and a review pass. HR on a completion-signalling ring settles on the last plausible sample (vendor band 40–220), because the reporter read three spot measurements back out of the ring's memory and it stored the last sample 3/3 while the tail rule landed below it; families without the signal keep the tail rule. A night's totalMinutes is time asleep (every stage except awake), with the span shown separately — the vendor builds deep + light + rem and 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. The spot marking 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 Colmi 0x78 frame 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.
  • [BUG] Reduced frequency of Heart Rate measurements in Colmi R09 #64 — Colmi workouts run as a ring-side sport session (0x77 01 <type>, the QRing app's live-activity protocol) and decode the ring's 0x78 telemetry, instead of chaining one-shot 0x69 measurements 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 rejects 0x77 stays 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 0e is a completion push; the leg idled out its whole window after the ring had already gone quiet. Now decoded into RingDecodedEvent.MeasurementComplete. The layout is read off the vendor app rather than inferred — BaseMeasureActivity.onDataResponse requires length > 1, matches bArr[0] against the screen's own measurement type (the same mode byte 03 2f sent), and switches on bArr[1]: 1 success, 2 failed, anything else cancelled. It reads no value out of the frame — on success it calls syncData() and pulls the reading from history — so neither do we. SpotMeasurementGate honours 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. HRSampleWindow now 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 DELETE alone would not have held. History rows are keyed history:<kind>:<timestamp> and written with upsert, 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. So measurement_deletions (schema v24) remembers the deletion, one gate (upsertUnlessDeleted) sits in front of every deterministic-id write, and MeasurementDeletion owns 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 CRPProtocol had carried as unconfirmed for months.

Temperature history (group 2 / cmd 22) now decodes. It 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. From the capture:

day 0, frame 0: 0,0,0,0,363,0,0,0,0,0,0,0,0,0,358,0,0,0,0,362,…
                          36.3°C          35.8°C      36.2°C

The missing decode cost more than the samples. With no TimingHistoryFrame marker 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 fdda re-route. The name pattern is deliberately loose about the suffix (R100 or R100_<hex>), because a redacted diagnostics report strips a _<hex> serial and that's the only capture we have; it can't collide with the Colmi R10_xxxx pattern 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/6 HR → 05, 2/7 HRV → 05, 2/8 SpO₂ → 05, 2/21 temp → 06). @scufitarosie, the stress in 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, the 04 0e decode 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:

  • A spot HR measurement now finishing with a plausible number rather than the warm-up value. It should end when the ring does (~35 s), not run the full 45 s.
  • Whether that holds across several attempts, and what it reports when the ring genuinely isn't reading (loose, hand moving) — that should still fail honestly rather than settle on something.
  • Yes please to the SpO₂ instrumentation you offered. Spo2Complete has the same gap, and I'd rather fix it from a capture than by symmetry with HR.
  • For [Feature Request] Allow deleting individual readings from history #60: the Readings list on a metric's detail screen, and — the part worth checking specifically — that a deleted reading from ring history stays deleted after a sync of that same day.

@scufitarosie — what would confirm #58:

  • Whether the R100 now appears as its own card in the pairing list.
  • Whether a temperature chart populates, and whether it looks right beyond ~18:00 (that's the part that was unreachable before).
  • If you're willing, a fresh diagnostics export after a sync — with the masking fix it will say much more than the first one could.

…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.
@foureight84

Copy link
Copy Markdown
Owner Author

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 04 0e handling unreachable for that leg, since it had already returned.

The leg now collects the run, ends on 04 0e, and settles. Spo2SampleWindow takes the median deliberately: the run peaks at 99 and declines to 94, one capture cannot say which end is honest, and the median privileges neither. It's marked provisional and lives in its own tested class so it's cheap to replace once repeated captures show whether that shape is consistent.

Collecting a whole run is gated on signalsMeasurementCompletion, true only for YCBT. Widening it would make CRP/Colmi SpO₂ — one value after ~48 s of silence and nothing more — into a minute-long wait for no gain.

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:

  • Doubled HR readings. A tester saw two rows per measurement, same minute, sometimes differing (79/82). Likely the ring logging the spot reading into its own history (the vendor's whole reaction to 04 0e success is syncData()), which a later sync then imports alongside ours — consistent with it not reproducing on a measurement with no sync after it. It's a question of who owns the reading, not a patch. Recorded as open in AGENTS.md.
  • SpO₂ settle rule. Median stands until more captures arrive.

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.
@foureight84

Copy link
Copy Markdown
Owner Author

Review pass on rc6 — five findings fixed in 904682b, five recorded here

Fixed: a YCBT run that hit the ceiling without 04 0e was stored as spot and could be deleted by the next sync's grid sample; a tombstoned history sample still ran adoptRingsCopy; sleep score denominators after the time-asleep change; archive restore carrying span totals through; Colmi 0x78 warm-up frames exporting steps/distance/calories unmasked.

Not fixed, pre-existing on the branch or main, each needs its own decision:

  1. Deleting a spot row before the ring's copy arrives leaves no tombstone (MeasurementDeletionDao.record, Daos.kt). Only history: ids are tombstoned, so a YCBT spot reading deleted before the next sync comes back under the ring's history: id and must be deleted twice. Fix shape: a timestamp-range tombstone for a spot row, honoured by upsertUnlessDeleted and adoptRingsCopy.
  2. ownsStream is sampled once at leg start (RingSyncCoordinator.measureHR). A workout starting or stopping inside the 30–60 s leg leaves the live-sample gate in the wrong state for the rest of it: stream samples dropped, or converging samples stored as live rows and the settled value never published. Fix shape: refuse a spot measure while a workout is active, and cancel an in-flight leg on workout stop.
  3. Diagnostics masking is applied per BLE chunk with the current device's header length (DiagnosticsRedactor.maskPacketHex). Intermediate CRP chunks are stored unknown and export unmasked; a report exported after switching ring families masks the old family's packets with the wrong header length. Fix shape: record a payload offset or device type on RawPacketEntity at decode time, and give assembler chunks a masked kind.
  4. A Colmi ring that accepts 0x77 01 but never pushes 0x78 is blind for ~120 s at the start of the first workout on every connection (ColmiSyncEngine.sportWatchdogTick, 60 s resume then 120 s give-up). Plausible on older firmware; no vendor support bit exists to gate on. Options: a shorter first-frame deadline, or run the plain HR stream in parallel until the first 0x78.
  5. Two sleep records that meet with zero gap read as one run in completeSessionSurvivors; the abut rule has no record identity, so the second record persisted retires the first's whole span. The Only one sleep session per night is shown when the ring records several #63 capture had a 3-minute gap so it did not show. Fix shape: require strict overlap for retirement, or carry the record id on blocks.

Lower-severity notes from the same pass, for a later cleanup: adoptRingsCopy's range delete can collapse two spot readings taken in the same minute; YCBTHealthRecords.sleep hard-requires the af fa prefix the vendor parser never checks; the repair zeroes an all-AWAKE session so it vanishes behind totalMinutes > 0; the per-process spotReadings cache misses archive-imported spot rows; the duplicated poll loop in settleSpO2/measureHR; and the unused MeasurementDeletion.deleteBloodPressureAt.

…#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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment