Tags: foureight84/PulseLoopAndroid
Tags
fix(ycbt): the terminal block decides a transfer, not the header's es… …timate (#69) Hourly SpO2 stopped importing on a reporter's COLMI_SMART_HEALTH ring while HR from the same hours stayed complete. Their diagnostics export has the answer, and it is not where either of us looked first. `handleTerminal` required the terminal block's packet count to equal the one the header declared. This ring's firmware contradicts its own estimate one frame later: the `05 09` header declares **5** packets for 840 bytes and the ring then sends **6**, because it packs whole 20-byte records into each frame (7 x 20 = 140) rather than filling it, so the header's `ceil(bytes / frame)` is one short. Bytes and CRC were correct on all six transfers in the export. We nacked every one, retried once, and skipped the type. What that cost is larger than it sounds. The composite `05 18` record is the only source of SpO2 history on this family — the dedicated `05 1a` query was sent ten times in that session and never answered once — so SpO2 disappeared while HR survived from `05 15`. Respiratory rate, HRV, blood pressure, temperature and blood sugar ride in the same record. It only bites above one packet, which is why the record imported while the day was young and stopped once it had grown past one frame, and why it looked like a regression from a release that had touched nothing nearby. Neither rc6 nor rc7 changed this path; the reporter was right to doubt the timing. The vendor agrees with the ring rather than with us: at `Sync_Block_Verify` (128) `DataUnpack` reads the two count bytes into locals it never compares, sizes its buffer from the *terminal's* length, and accepts on `crc16_compute(...) == crc` alone. So the header's totals now only size the buffer, and a transfer is judged on the terminal's byte count against what arrived, and then the CRC. A short transfer still nacks — that test is kept and covered. Worth recording for the next one of these: the frames were in the log the whole time, marked `unknown`, which is what every accumulating history frame looks like — 427 sleep frames in the same export say `unknown` and sleep imports fine. A silent reject-and-skip on a self-consistency check leaves no failure anywhere. When one metric is missing and its neighbours are not, look at what the transfer did with the block before suspecting the parser. 1312 tests, 0 failures. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
fix(measure,sleep,diag): four review findings from the rc6 pass (#59, #… …60, #63) Recorded as open on PR #62 and each needing its own decision rather than being a follow-on from the rc6 changes. Four of the five are fixed here; the fifth is left alone deliberately (see below). **A deleted `spot` reading came back under the ring's id (#60).** A `spot` row carries a UUID, so `record` filed no tombstone for it — but that row exists precisely because the ring logged the measurement itself and hands it back as `history:<kind>:<ts>` on the next sync, so the reading returned and had to be deleted twice. The ring stamps its log to the minute rather than to our settled instant, so the tombstone cannot name the id it must suppress: a `spot:<kind>:<ts>` row records the kind and our timestamp, and `isSpotDeleted` matches it the way `adoptRingsCopy` matches the pair it reconciles. A retake inside the window inherits the suppression — stored and displayed, never adopting the ring's copy — which is the same ±90 s ambiguity `adoptRingsCopy` already carries, failing towards keeping a reading the user asked for. **A spot HR leg and a workout cannot share the stream (#59).** The gate is one switch per kind and the leg sampled it once at the start, so a workout starting or stopping inside the leg left it wrong for the rest: the workout's samples dropped, or the leg's converging samples stored as workout rows with its settled value never published. A spot measure is refused while a workout runs, a workout starting mid-leg aborts it, and the gate is now reopened unconditionally — the leg closed it, so it owes the reopen even when it aborts. No unit test: `RingSyncCoordinator` needs a BLE client and has no harness. **Two sleep records meeting with no gap read as one run (#63).** The retirement run grew across any block abutting it end-to-start so a shortened re-send could retire its own stale tail, but a block has no record identity — `sessionId` is the merged row — so that is the same shape as the neighbouring record of a split night. The reporter's ring closes one record and opens the next 33 seconds later; whether they round to the same minute is a coin toss and losing it wiped five hours. Retirement now requires real overlap. The trade is a stale tail surviving a genuine shortening: minutes rather than hours, and rarer than it was now that a re-send reproduces the record's declared bounds instead of a drifted end. **Diagnostics masking leaked two ways (#58).** It used the *connected* ring's family for every stored packet, so a report exported after switching rings masked the old family's frames with the wrong header length — CRP's is the longest, so a Colmi frame masked as CRP kept five bytes of samples. The family is recorded at capture time now (`raw_packets.deviceTypeRaw`, v25); a row from before it existed masks from byte 1, which is less useful and never wrong in the direction that leaks. Separately, a CRP reply spanning several notifications only decodes when its last chunk lands, so the earlier chunks reached the log as `unknown` — deliberately exported whole, since control and pairing frames decode to nothing and are what most connection reports are taken for. `RingDecodedEvent.FramePending` names them: `frame_start` keeps its family's header, `frame_chunk` keeps byte 0 and nothing else, because the middle of a frame is payload from byte 0 on. **Not fixed: the Colmi sport-session blind window.** A ring that accepts `0x77 01` but never pushes `0x78` shows nothing for ~120 s at the first workout of a connection. No vendor support bit exists to gate on and no hardware here has the firmware, and the reporter on #64 is mid-test on that exact path — shortening the deadline now would invalidate the run without telling us anything. It waits for their diagnostics export. 1310 tests, 0 failures. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
fix(sleep): a zero-length segment no longer shadows a real one (#63) The vendor de-duplicates a sleep record's stage segments on `sleepStartTime` (`DataUnpack` case 4 keeps the first it sees for a given start), and the ring emits zero-length segments. So a zero-length segment sharing a start time with a real one took its place and the real segment was dropped. Every duplicate start in the reporter's fifteen-frame, thirteen-record night dump is that exact shape — a zero-length LIGHT immediately ahead of a real 76–105 s segment — 351 s across the dump. Since `placeStages` reads a minute no segment claims as wake, the dropped minutes did not merely go missing: they surfaced as wake. That is the whole of the residual gap on the reporter's Sept 9, where three of the four awake minutes the card showed are this artifact and the ring's own `wakeDuration` for the night is 1.6 minutes. The vendor de-duplicates the same way and does not care, because its headline comes from the header's own totals (`SleepActivity:695` sums `deepSleepTotal + lightSleepTotal + remTotal`) rather than from the segment array. Ours is counted off the timeline, so it cannot afford to lose a segment. A zero-length segment claims no minute either way, so skipping it before the de-duplication costs nothing and keeps the vendor's rule for every segment that does. Also recorded in AGENTS.md, from reading the dump against the vendor source: when `deepSleepCount` (+12) reads `0xffff` the three following u16s are seconds in the order rem (+14), deep (+16), light (+18) — REM first — and on the dump deep+light+rem agrees with the timeline we count to within a minute on 12 of 13 records, which says the counted timeline is sound. The record length at +2 is bytes, not minutes; it reads plausibly as minutes (244, 164, 268, 140) and was misread that way for several rounds of this issue. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
fix(sleep): place stage segments against the record's declared bounds (… …#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
fix(measure,sleep,diag): address review findings on the rc6 changes a… …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.
fix(measure,workout): gate the ring-copy rule to rings that log, keep… … 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.
PreviousNext