Split out of #66 so it isn't lost when that closes with the 2.9.0 release.
Reported by @Albabit on v2.9.0+52-rc1, YCBT ring (Ale-Hop2211 E1C7):
- Off the finger entirely, the app reported a successful reading of 88 and 85 bpm, and 98 % SpO₂.
- His own Python client against the same ring, same situations, shows the ring declaring failure plainly:
- Off the finger:
t+1.1s 04 0e {00 02} — status 02, failure, no samples at all.
- On the finger with contact coming and going: plausible samples 86–88 bpm from t+13.1s, then
t+20.2s 04 0e {00 02}.
86–88 is exactly the range the app showed as a successful reading.
What the code actually does
The failure chain reads correct end to end, so his diagnosis — that scoping the failure check to the legs each run starts stopped consulting it — is not what the diff did:
YCBTDecoder.measurementResultEvents maps 04 0e {00 02} to MeasurementComplete(mode = 0x00, success = false).
YCBTDriver.ingest passes it through (isSupported has else -> true).
RingSyncCoordinator calls spot.noteCompleted(mode, false); the gate records FAILED.
measureHR's poll loop reads completed != null → aborted = true → result = null, hrState = FAILED, and nothing is published.
- The Vitals HR button's failure predicate is
coordinator.hrState == failedState.
That chain also predates #71 (it came in with #59/#60), so this is not a regression introduced by the split-buttons work.
Two candidate mechanisms
- The live-sample gate reopens before the ring stops streaming.
measureHR's finally calls engine?.stopHeartRate(), which only enqueues the stop, then immediately reopens the gate. Every 04 13 sample arriving between the reopen and the ring honouring the stop passes EventPersistenceSubscriber with the gate open and is stored as an ordinary live heart-rate row. On an aborted run those are precisely the junk samples. This fits the moved-around case.
- Nothing was stored by the measurement at all, and the card is showing the newest row the next history sync pulled in — indistinguishable to the user.
Neither explains the off-the-finger case, where the ring sent no samples and the app still showed 88 and 85.
Why nothing caught it
RingSyncCoordinator takes concrete RingBLEClient and PulseLoopDatabase, so the measurement legs have no test harness. 9da8532 added failure-verdict coverage to SpotMeasurementGateTest, but that exercises the gate class in isolation — the coordinator's abort branch is still executed by no test. Building that harness is probably the first move here.
Next
Waiting on a diagnostics export from @Albabit taken straight after a failed measurement (asked for in #66 (comment)): the timestamps of the heart-rate rows stored around that minute, and whether they carry the spot flag.
Split out of #66 so it isn't lost when that closes with the 2.9.0 release.
Reported by @Albabit on
v2.9.0+52-rc1, YCBT ring (Ale-Hop2211 E1C7):t+1.1s 04 0e {00 02}— status02, failure, no samples at all.t+20.2s 04 0e {00 02}.86–88 is exactly the range the app showed as a successful reading.
What the code actually does
The failure chain reads correct end to end, so his diagnosis — that scoping the failure check to the legs each run starts stopped consulting it — is not what the diff did:
YCBTDecoder.measurementResultEventsmaps04 0e {00 02}toMeasurementComplete(mode = 0x00, success = false).YCBTDriver.ingestpasses it through (isSupportedhaselse -> true).RingSyncCoordinatorcallsspot.noteCompleted(mode, false); the gate recordsFAILED.measureHR's poll loop readscompleted != null→aborted = true→result = null,hrState = FAILED, and nothing is published.coordinator.hrState == failedState.That chain also predates #71 (it came in with #59/#60), so this is not a regression introduced by the split-buttons work.
Two candidate mechanisms
measureHR'sfinallycallsengine?.stopHeartRate(), which only enqueues the stop, then immediately reopens the gate. Every04 13sample arriving between the reopen and the ring honouring the stop passesEventPersistenceSubscriberwith the gate open and is stored as an ordinary live heart-rate row. On an aborted run those are precisely the junk samples. This fits the moved-around case.Neither explains the off-the-finger case, where the ring sent no samples and the app still showed 88 and 85.
Why nothing caught it
RingSyncCoordinatortakes concreteRingBLEClientandPulseLoopDatabase, so the measurement legs have no test harness.9da8532added failure-verdict coverage toSpotMeasurementGateTest, but that exercises the gate class in isolation — the coordinator's abort branch is still executed by no test. Building that harness is probably the first move here.Next
Waiting on a diagnostics export from @Albabit taken straight after a failed measurement (asked for in #66 (comment)): the timestamps of the heart-rate rows stored around that minute, and whether they carry the spot flag.