You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Hourly SpO2 on this YCBT ring looks computed from heart rate #103
Version: 2.10.0+60-rc1, YCBT ring (TRINITY, Ale-Hop2211).
What I found:
I read the ring's memory back directly and went through all 304 hourly 05 18 records (follow-up to #69).
The same heart rate always comes with the same SpO2, without a single exception:
So the hourly SpO2 looks like a fixed function of heart rate, not a separate measurement...
The night dips the app shows (down to 88) just mirror a low heart rate during sleep.
Spot SpO2 is different:
A spot SpO2 lights a red LED (green for heart rate), and the ring logs it in 05 18 with HR and respiration at 0, only SpO2 filled in.
Past spot readings have also ended at 99, which the hourly records never reach (they top out at 98).
Link with #66:
This would explain why the SpO2 interval never takes.
There's no automatic SpO2 measurement to schedule: the ring measures heart rate and derives SpO2 from it.
Suggestions:
Mark the hourly SpO2 as estimated on this ring, since the app currently presents it as measured.
If doable, (and would be really better ^^) : the app could trigger a real spot SpO2 itself on the SpO2 interval. It would have to stay clear of the ring's own hourly HR measurement: I started a spot HR and a spot SpO2 one second apart, in both orders, and the ring only ran the last one asked, dropping the first without a completion event. Running SpO2 at half past, for example, would keep them apart. Some battery cost, of course.
Screenshots: Heart rate and Blood oxygen, week view.
@foureight84, Small follow-up with yesterday's data, still on 2.11.0 (75):
Hourly SpO2 was 98 at every reading from 4 PM to 9 PM, with HR between 82 and 89. The one dip (94 at 11 AM) matches HR 57. Overnight: HR 53 gave 92, HR 50 gave 90, HR 58 gave 95. Same HR to SpO2 mapping as before, no exceptions. So on this ring, the hourly SpO2 isn't a real measurement.
What would really help: have the app start a real spot SpO2 on its own, like the manual button does (red LED, logged with SpO2 only). The SpO2 slider in Measurement Frequency could drive it, since the ring ignores it today.
Timing: the ring only runs one spot measurement at a time. When I started two 1 s apart, it dropped the first one. Running SpO2 at half past would keep it clear of the ring's hourly HR reading.
If that's not doable, an "estimated" label would at least stop the flat line from looking like measured values. Happy to test either way.
Hourly SpO2 is marked Estimated (chip and note on the Vitals card, a caption on the Today tile, and the amber note on the chart) when your ring's own data shows the pattern you found. Each hourly reading is paired with heart rate, and if no heart rate ever maps to two different SpO2 values across enough pairs (at least 24, over the last 7 days), it's labelled. Spot readings are never labelled.
One thing I couldn't verify without your ring: the app doesn't store the heart rate inside the 05 18 record, so it pairs each hourly SpO2 with the heart-rate history the ring logs separately (05 15), within 2 minutes. If those don't line up on your ring, you'll simply see no label, the same as before. Could you tell me whether the label shows up?
Keeping this open for the bigger part: having the app start a real spot SpO2 on the interval, at half past the hour as you suggested.
@foureight84, Tested on 2.11.1 (76) RC1: the Estimated label shows up.
On the pairing you couldn't check: on my ring each hourly 05 18 has a matching 05 15 heart rate within 0 to 1 s (263 out of 263 on one dump, 221 out of 221 on another), so the 2 minute window is plenty.
For the automatic spot SpO2, one thing I checked today: the connection has to stay up for the whole measurement. I sent the start command (03 2f 01 02), the ring accepted it, and I disconnected 3 s later without a stop command. The red LED went off right away and nothing was stored. So the app would need to stay connected until the ring's 04 0e, about 50 s for SpO2 here.
Timing: the ring's own hourly reading lands just after the hour (06:00:42, 07:00:39, 08:00:26, 09:00:35 this morning), so half past still looks like a safe slot. It doesn't need to be exact, as long as it stays a few minutes away from the top of the hour.
@Albabit thanks, that settles both open points: the label works on your ring, and the 0–1 s 05 15/05 18 match means the 2 minute window has plenty of margin.
Your disconnect test was the key detail for the next part. The automatic spot SpO2 is built the way you described, and will be in the next RC:
Settings → Measurement Frequency → Measure SpO₂ from the app (off by default, shown for your ring when SpO₂ is on).
The app starts a real spot SpO2 halfway between the ring's own readings: :30 at a 60 min SpO2 interval, :15 and :45 at 30 min.
It keeps the connection open until the ring's 04 0e (up to 75 s), then stores the reading as a spot reading, the same as the manual button.
It's best effort. Android can delay background work while the phone sleeps, and a run that would start within 5 minutes of the ring's own reading is skipped instead.
@foureight84 Thanks a lot, that's exactly what I had in mind. I'll test it as soon as the RC is up.
One small request while you're on it: could the sliders go beyond 60 min?
SpO2 from the app: since the app starts each reading itself, it doesn't depend on what the ring accepts. Each spot reading keeps the red LED on and the connection open for about 50 s, so being able to pick every 2 h or 3 h would help with battery, and mean fewer LED flashes at night.
Heart rate: I checked whether the ring takes more than 60 min for its own readings. With PulseLoop kept off the ring, I sent 01 0c 01 78 (120 min) at 09:06 this morning. The ring answered OK and logged readings at 10:00 and 12:00, with nothing at 11:00. So 120 min works on this ring. I only tried 120, nothing longer.
@Albabit yes to both — they're in the build now: v2.11.1+77-rc2 installs over 2.11.1 (76).
SpO₂ from the app now has an interval of its own: every hour, 2 h, or 3 h (Settings → Measurement Frequency, under the toggle). The ring's own SpO₂ setting is untouched either way — the app keeps aiming between its readings, just less often. At 2 h that's 12 readings a day instead of 24, each ~50 s of LED, so the night should be quieter.
Heart rate goes to 120 min for your ring's family — exactly because you tested that value. The slider stops there until something longer is tried on hardware. Everything else (blood pressure, temperature, HRV, and the ring's own SpO₂ interval) stays capped at 60: your test covered the heart-rate command only, and the ring's SpO₂ grid is what the app's spot readings schedule themselves around.
Two things to try, whenever you get to it:
SpO₂ from the app at 2–3 h: slots land, the connection holds to the ring's 04 0e, and the readings store as spot readings.
Heart rate at 120 min through the app's own settings — same command you sent by hand, but worth confirming end to end.
@foureight84 Installed 2.11.1 (77) this morning, but neither option shows up on my ring: no "Measure SpO₂ from the app" toggle, and the heart rate slider still stops at 60 min (and starts at 5 min).
Looks like the app doesn't see my ring as YCBT. The data export lists it as deviceTypeRaw COLMI_SMART_HEALTH, wearableModelID colmi-smarthealth, with manualSpo2 in its capabilities. In the RC2 code, both the 120 min option (maximumHrInterval in SettingsSubScreens.kt) and the app SpO2 (ScheduledSpo2Prefs.isSupported) only check for RingDeviceType.YCBT.
The 120 min test from yesterday was on this same ring, so it's confirmed on a COLMI_SMART_HEALTH ring. Happy to test both as soon as they're enabled for this type.
RC3 is up: v2.11.1+78-rc3 — @Albabit, this one should fix what you saw.
Your ring is catalogued as COLMI_SMART_HEALTH (model colmi-smarthealth). It runs the same Yucheng/YCBT protocol as the rings the controls were built against, but it's its own device type, and the feature flags in RC2 only checked for YCBT — so both controls stayed hidden. RC3 enables both for COLMI_SMART_HEALTH:
"Measure SpO₂ from the app" toggle now appears (still gated on the manualSpo2 capability your export shows, and still off by default — enable it under Measurement Frequency).
Heart-rate slider now spans 30–120 min instead of 5–60.
One related change while there: the stress toggle stays hidden on these rings too, since the YCBT wire format has no stress-monitor setting for either family — it would have been a toggle the ring never actually received.
Same install path as before (prerelease, so grab it from the GitHub release, not the in-app updater).
Thanks for RC3! Both options show up now. Here's what I saw this afternoon and evening.
SpO₂ from the app, every 2 hours:
Works. Slots landed at :15 rather than :30, since my ring SpO₂ slider was at 30 min.
14:15: connected 14:15:03, measurement started 14:15:22, ring sent 04 0e at 14:16:25 (63 s), 98%. Red LED on during the measurement.
16:15: same, 04 0e after 64 s, 98%.
Both are stored in the ring as spot readings (05 18 with HR 0), and the 14:16 one shows up in the Vitals list.
Heart rate at 120 min:
Works. Ring readings at 14:00, 16:00 and 18:00, nothing at 15:00 or 17:00. The app sends the ring SpO₂ monitor at 30 min (01 26 01 1e), and the ring took no extra reading at 14:30 or 15:00.
Small slider bug:
"Measure SpO₂ from the app" has 4 stops (60, 100, 140, 180 with steps = 2), which read as 1h, 1h, 2h, 3h. Once saved at 2 h, the thumb sits on the 2nd stop (screenshot attached). steps = 1 should fix it.
Missed slot at 18:15:
The background sync held the ring from 18:14:48 to 18:15:04. The SpO₂ worker got Connection skipped: another PulseLoop BLE client owns GATT at 18:15:01, gave up 20 s later without logging anything, and moved on to 20:15. No 03 2f was sent. The sync drifts about 20 s per cycle (16:13, 16:43, 17:14, 17:44, 18:14), so this will happen again now and then. Could the worker wait for the sync to finish (15 to 25 s) and then take the ring?
Turning off "Blood oxygen" doesn't stop the estimate:
I'd like to see only real SpO₂ readings, so I turned off "Blood oxygen (SpO₂)" and saved at 16:35. The app sent 01 26 00 (and again on each reconnect), but the 18:00 hourly reading still came with SpO₂ 98 (HR 83). So this ring seems to ignore 01 26 completely, both the on/off and the interval. A manual SpO₂ at 16:36 still worked and was stored. On this ring, the switch only ends up disabling the app's SpO₂ option.
Request, real vs estimated readings:
Since the ring can't be told to stop, could the app tell them apart? The real ones are already stored as spot readings, and in the ring history they're the 05 18 records with HR 0. Right now the 14:16 reading sits on the same curve as the estimates with no marker, and the Estimated badge covers the whole card.
What I'd like most is a way for the app not to show the estimated SpO₂ at all, only the real readings (as an option or by default, either works for me). If that's not possible, smaller ideas:
mark real readings on the chart and in the list
the Estimated label per reading rather than for the whole card
Health Connect:
If the estimates are hidden, please don't send them to Health Connect either. Right now VitalsExporter writes every SpO₂ row as an OxygenSaturationRecord, so with Health Connect on, the estimates would land there looking like real measurements.
Same for respiratory rate: the app stores one with each hourly reading (782 rows in my Oct 22 export), but on this ring it's only derived from heart rate (0.19 to 0.21 × HR on all 748 pairs), and the ring doesn't list it in its capabilities. It still gets exported when that Health Connect type is checked. Could it be unchecked by default, or greyed out, for rings that don't report it, the way the stress toggle is now hidden for this ring type?
Night results on RC3 (app SpO₂ every 2 hours, slots at :15, heart rate at 120 min):
Heart rate at 120 min:
Held all night: ring readings at 20:00, 22:00, 00:00, 02:00, 04:00 and 06:00, nothing at odd hours.
App SpO₂ overnight:
2 of the 6 slots worked, a few minutes late (Doze), both stored as spot readings and shown in Vitals:
00:15: connected 00:18:55, 98% at 00:20:17
02:15: connected 02:20:21, 98% at 02:21:43
No logcat for the night (the buffer only went back to 07:29), so for the other four I matched the Bluetooth connection history and the screen events against the code:
20:15 and 04:15: the phone was idle, so the SpO₂ run was most likely released together with the background sync, at 20:52:30 (when I unlocked the phone) and in the 05:15 maintenance window. Only the usual 19 s sync connection shows up each time, so probably the same collision as 18:15. With Doze lining the two workers up, this may happen more often than the 20 s drift alone suggests.
22:15 and 06:15: held until I unlocked the phone at 22:25:12 and 07:01:15. From the code, isClear then skips them as too close to 22:30 and 07:00 on the ring's 30 min SpO₂ grid. But my ring doesn't read on that grid: it ignores 01 26 and only reads with the heart rate (even hours here), so there was no ring reading at 22:30 or 07:00. The 07:01 run queued 08:15 right away, which fits a skip.
Two ideas for the slots:
For my ring, base the slot grid and isClear on the heart rate interval. With HR at 120 min, slots would sit at odd hours and a late run would rarely be skipped.
When the sync holds the ring, wait for it to finish and retry instead of giving up (same as yesterday).
Real vs estimated:
I also updated the request at the end of my previous comment: hiding the estimated SpO₂ is what matters most to me, plus two Health Connect points (estimated SpO₂ and respiratory rate).
RC4 is up: v2.11.1+79-rc4 — @Albabit, thank you for the detailed breakdown! This release addresses all the points you raised from your afternoon and overnight runs:
Slider stops: Changed steps = 1 for the app SpO₂ slider so it has 3 clean, unambiguous stops: 1h (60 min), 2h (120 min), and 3h (180 min).
GATT lock contention with background sync: When Android Doze wakes background sync and the SpO₂ worker simultaneously, the SpO₂ worker now polls and waits up to 45 s for background sync to release the GATT lock rather than bailing on the first second.
Slot grid & isClear on HR interval: For YCBT/SmartHealth rings, slot offsets and isClear collision checks are now anchored to the active heart-rate interval (e.g. 120 min) instead of a phantom 30 min SpO₂ grid. A run delayed by Doze (such as at 22:25) will no longer be falsely skipped.
Decoupled from ring SpO₂ toggle: You can now keep the app's spot SpO₂ active even when the ring's built-in "Blood oxygen" toggle is turned off.
Real vs. Estimated SpO₂ display:
Added a "Hide estimated SpO₂" toggle under Measurement Frequency (enabled by default). When active, hourly estimates derived from heart rate are filtered out, displaying only genuine spot readings and removing the card-wide "ESTIMATED" badge.
In the Vital Detail reading list, individual reading rows now show an Estimated tag when derived from heart rate.
Health Connect export filtering:
Hourly SpO₂ estimates derived from heart rate are no longer exported to Health Connect as OxygenSaturationRecord. Only genuine spot readings export.
Respiratory rate is blocked from exporting for YCBT/SmartHealth rings that lack a native sensor and derive it as ~0.2 × HR, and is greyed out in the Health Connect settings screen.
You can download v2.11.1+79-rc4 directly from the GitHub releases page. Looking forward to your feedback on these adjustments!
@foureight84 Thanks for RC4! I tested it yesterday afternoon and overnight (HR at 120 min, app SpO₂ every 2 hours).
Settings and display:
Measurement Frequency: 3 clean stops, Save works, app SpO₂ slots moved to the odd hours as expected.
Hide estimated SpO₂ ON: the Today and Vitals lists only show the real readings, no badge. OFF: "Estimated" label on each estimated row, not on the real ones.
One bug: the Blood oxygen tile still uses the estimated values:
With Hide estimated ON, the tile on Today (and Vitals) still plots and averages the estimated readings (about 14 points, some low ones in orange, "97% avg"), while the detail screen only shows the 4 real ones (98% avg). Since the badge is gone, they now look like real readings. It looks like buildState filters spo2Samples and latestSpo2, but spo2Series is still built from the unfiltered spo2 (ViewModels.kt, around line 819). Screenshots below, all taken yesterday with Hide estimated ON: settings (13:16), detail screen (13:24), Today tile (13:25).
App SpO₂ every 2 hours:
6 of 8 slots since 15:00 yesterday: 15:02, 17:07, 19:08, 21:04, 03:10 and 05:12 (96 to 99%), a few minutes late because of Doze. All stored in the ring as spot readings.
The wait for background sync works: at 05:11 Doze released both workers in the same second, sync held the ring from 05:11:09 to 05:11:33, then SpO₂ reconnected at 05:11:37 and measured.
01:00: released at 01:58, 89 s before the ring's 02:00 reading, so isClear skipped it. Makes sense.
23:00: released at 23:52:31, which isClear should allow (7.5 min before 00:00), but there was only one 37 s connection and no reading. No logcat for the night, so this is a guess: sync took the ring first and ran longer than usual, and since the 45 s connect timeout counts from the start of the worker, it ran out a few seconds after sync let go. Maybe start the 45 s once sync releases the ring?
(Connection times come from Android's battery history, dumpsys batterystats --history.)
Unrelated: the DST night:
We switched from CEST to CET last night (03:00 back to 02:00). The ring stayed on summer time until a background sync at 02:06 CET set it to winter time, so setTime does the right thing. But the night's second record spans the change: header 22:52:00 (CEST) to 06:09:39 (CET), so 8 h 17 of real time, while its segments only cover 7 h 17 (they add up to end minus start in ring time). The app places 438 minutes after 22:52 CEST, so the night ends at 05:10 instead of about 06:10. I woke up at 05:52 (Bedtime off at 05:52:40) and got up around 06:10, so with Bedtime only it should have ended at 05:53. Once a year, so low priority. Maybe convert the header end on its own with YCBTBytes.date() so the night at least ends at the right time? Not sure what's best for the hour the segments don't cover.
Also for #83: I fell asleep around 21:30, before turning Bedtime on at 22:50. The ring recorded 21:30 to 22:49 as a separate part, and the app kept it whole, which was right since I really was asleep.
Two small suggestions:
Chart lines: with HR at 120 min, no two points get connected anymore in day view (the 90 min limit in ZoneLineSplitter.maxGapMs). A limit tied to the measurement interval (say 1.5x) would keep the lines and still break them on real gaps (charging, ring off). The ring also misses a few daytime readings, like it did at 60 min (14:00, 16:00 and 20:00 yesterday), and each miss now leaves a 4 hour gap.
RC5 is up: v2.11.1+79-rc5 — @Albabit, thank you for the spot-on breakdown!
This release addresses the points from your testing:
Blood oxygen tile & cards filtered: You nailed it — `spo2Series` in `ViewModels.kt` was still being built from raw `spo2` instead of `displayedSpo2`. We fixed this so the tile on Today/Vitals and the home screen widgets now cleanly exclude the estimated hourly readings when "Hide estimated SpO₂" is on, eliminating the orange dips and reflecting your genuine ~98% average.
GATT lock coordination & timeout: In `ScheduledSpo2Worker`, the 45 s connect timeout is now paused while background sync is holding or connecting to GATT (with a wait buffer of up to 60 s), giving the SpO₂ worker its full 45 s connection window after sync releases the ring. In addition, background sync now yields its periodic run if the SpO₂ worker is currently measuring (since the SpO₂ worker runs the full startup sync sequence anyway).
Chart lines for wider intervals: Scaled `ZoneLineSplitter.maxGapMs` in day view to `max(90m, interval × 1.5)`. At a 120 min interval, the threshold expands to 180 min, so consecutive readings stay connected with continuous lines while still breaking across real gaps (missed readings, charging, or ring off).
@foureight84, Thanks for RC5! I installed it yesterday at 09:03.
Blood oxygen tile:
Fixed. With Hide estimated ON, the Today tile only shows the real readings now (98% avg, no more low estimated points).
Bug: a cancelled app SpO₂ run leaves its connection open:
Yesterday the 13:00 slot started at 13:05:54 (Doze delay). Its client (generation=1 in the logs) connected and finished the startup sync at 13:06:15. Then Android went into Doze at 13:07:01 and released the job's wakelock 0.1 s later, so the job was most likely stopped in the middle of the measurement. No reading was stored.
That client never disconnected. It reconnected by itself after every drop (generation 2 to 15) and held the ring for 18 hours, until I force stopped the app this morning at 07:08. Turning Bluetooth off and on didn't help: its watchdog got the ring back first (OS reports GATT not connected in logcat). What I saw:
Settings said Disconnected while the Today chip said Connected, and Reconnect last ring did nothing.
None of the app SpO₂ slots from 13:00 to 05:00 gave a reading. HR history still came in, since that client synced on each reconnect.
Ring battery went from 78% (13:06) to 17% (06:56). It usually loses about 12% a day.
After the force stop, everything went back to normal.
I think it's the finally in ScheduledSpo2Worker.measureInternal: withContext(Dispatchers.Main) { coordinator.stop() } runs before client.destroy(). Once the worker is cancelled, withContext throws CancellationException right away, so destroy() is never reached and the client's watchdog keeps going. RC3 and RC4 have the same order, so it's not new in RC5, it just needs Doze to land during a run. RingSyncWorker calls destroy() first, so it looks fine. Maybe call destroy() first, or run the cleanup in withContext(NonCancellable)? The enqueueNext in doWork's finally has the same pattern.
I've turned app SpO₂ off until the next build.
Chart lines:
Still broken for heart rate on my ring. The threshold uses the smallest gap between two points, and the ring often writes an extra record right after midnight (6 of the last 8 nights). Today it's at 00:00:01, 22 s before the real 00:00:23 reading, so the smallest gap is 22 s and the limit falls back to 90 min. In the day view only that pair is joined (the tiny vertical line on the left), and the Today tile has no lines at all. A manual reading would do the same. Maybe use the configured HR interval, or the median gap, instead of the smallest one?
Screenshots from this morning (07:15): Today, and the Heart rate day view.
@foureight84, a quick check-in, plus one small thing I ran into this morning.
App SpO₂:
Any news on the cancelled run that leaves its connection open (my Oct 26 comment)? I've kept "Measure SpO₂ from the app" off since then, so scheduled SpO₂ is on hold on my side. Happy to test a build whenever it's ready, no rush.
Single-part night can't be extended (#106):
Last night the ring recorded one part, 00:13 to 07:34. I got up briefly around 07:35, went back to bed and slept until about 08:49, but the ring never opened a new record. No steps until 08:30, HR 53 at 08:00, and Bedtime stayed on until 08:49. The app shows 7h 20m, which matches what the ring has.
That's exactly the case the extension from #106 is for, but I can't reach it: there's no pencil, because the Records card only shows when the night has more than one part (if (runs.size > 1) in SleepScreen.kt). On a one-part night there's no way to edit, extend or delete it. Maybe an edit button on the top card for single-part nights? It would keep the page clean and still give access to the edit dialog.
Screenshot from this morning: one part, no Records card.
This release addresses all three items from your testing:
BLE watchdog connection leak on cancellation:
Wrapped ScheduledSpo2Worker cleanup in withContext(NonCancellable) and called client.destroy() before coordinator.stop(). If Android Doze interrupts the job and cancels the worker, the BLE client and its watchdog are immediately torn down so it cannot hold GATT open or drain the ring's battery.
enqueueNext is also protected with NonCancellable so the next slot is scheduled reliably even after a cancelled run.
Chart lines for 120m HR:
Replaced the minimum delta with the median delta (ZoneLineSplitter.sampleIntervalMs). Occasional duplicate/spot records at midnight (e.g. 22s apart) no longer collapse the interval to seconds; 120m readings maintain their 180m threshold and remain connected.
Updated SleepScreen.kt to surface the Records card whenever runs.isNotEmpty(), giving you the edit pencil on single-record nights so you can extend sleep when the ring stops tracking early.
@foureight84, thanks for RC6. Installed this morning (versionCode 80), and all three fixes work on my ring.
Cancelled app SpO₂ run:
To force a cancel in the middle of a reading, I faked a low battery with adb 45 s into the 10:30 run (that trips the battery-not-low constraint), then reset it 20 s later.
10:30:47.7: worker cancelled, disconnect() 13 ms later, link down at 10:30:51. The red LED went off at 10:30:48.
No reconnect afterwards.
10:31:08: once the battery was back, WorkManager reran the same work. Reading 98 at 10:32:30, link closed at 10:32:34.
Nothing left connected after that. The normal 09:00 run also closed right after its reading (98).
So the Oct 26 leak looks fixed.
Chart lines:
HR points at 120 min are connected again in the day view.
Single-part night (#106):
The Records card now shows on a one-part night. I extended Nov 2-3 from 07:35 to 08:49: 8h 34m, score unchanged, the added part is hatched and not staged. Screenshot below.
One small thing:
The cancelled run still schedules the next slot from its finally, and then the rerun does it again when it finishes, so the chain ends up with one extra run.
The successor created at the cancel had a 1h 59m 12s delay (12:30 minus 10:30:47), but it stayed blocked until the rerun finished at 10:32:30 and only started counting then. So the 12:30 run actually started at 12:31:43 (reading 99, link closed at 12:33:10).
When it finished, the next job came up with a 1h 57m 29s delay: that's 12:30 minus 10:32:30, so it's the one the rerun added. It's due around 14:30:35, and the later slots should keep drifting a little.
A Save in settings should reset the chain (REPLACE). Maybe skip enqueueNext when isStopped? WorkManager reruns a stopped worker, and the rerun schedules its own successor anyway.
Activity
Albabit commentedon Oct 17, 2026
@foureight84, Small follow-up with yesterday's data, still on 2.11.0 (75):
Hourly SpO2 was 98 at every reading from 4 PM to 9 PM, with HR between 82 and 89. The one dip (94 at 11 AM) matches HR 57. Overnight: HR 53 gave 92, HR 50 gave 90, HR 58 gave 95. Same HR to SpO2 mapping as before, no exceptions. So on this ring, the hourly SpO2 isn't a real measurement.
What would really help: have the app start a real spot SpO2 on its own, like the manual button does (red LED, logged with SpO2 only). The SpO2 slider in Measurement Frequency could drive it, since the ring ignores it today.
Timing: the ring only runs one spot measurement at a time. When I started two 1 s apart, it dropped the first one. Running SpO2 at half past would keep it clear of the ring's hourly HR reading.
If that's not doable, an "estimated" label would at least stop the flat line from looking like measured values. Happy to test either way.
Merge pull request #129 from foureight84/fix/103-estimated-hourly-spo2
foureight84 commentedon Oct 20, 2026
@Albabit the label is in:
📦 PulseLoop 2.11.1 (76) RC1 installs over 2.11.0 (75).
Hourly SpO2 is marked Estimated (chip and note on the Vitals card, a caption on the Today tile, and the amber note on the chart) when your ring's own data shows the pattern you found. Each hourly reading is paired with heart rate, and if no heart rate ever maps to two different SpO2 values across enough pairs (at least 24, over the last 7 days), it's labelled. Spot readings are never labelled.
One thing I couldn't verify without your ring: the app doesn't store the heart rate inside the
05 18record, so it pairs each hourly SpO2 with the heart-rate history the ring logs separately (05 15), within 2 minutes. If those don't line up on your ring, you'll simply see no label, the same as before. Could you tell me whether the label shows up?Keeping this open for the bigger part: having the app start a real spot SpO2 on the interval, at half past the hour as you suggested.
Albabit commentedon Oct 21, 2026
@foureight84, Tested on 2.11.1 (76) RC1: the Estimated label shows up.
On the pairing you couldn't check: on my ring each hourly 05 18 has a matching 05 15 heart rate within 0 to 1 s (263 out of 263 on one dump, 221 out of 221 on another), so the 2 minute window is plenty.
For the automatic spot SpO2, one thing I checked today: the connection has to stay up for the whole measurement. I sent the start command (03 2f 01 02), the ring accepted it, and I disconnected 3 s later without a stop command. The red LED went off right away and nothing was stored. So the app would need to stay connected until the ring's 04 0e, about 50 s for SpO2 here.
Timing: the ring's own hourly reading lands just after the hour (06:00:42, 07:00:39, 08:00:26, 09:00:35 this morning), so half past still looks like a safe slot. It doesn't need to be exact, as long as it stays a few minutes away from the top of the hour.
Happy to test whenever you have something.
foureight84 commentedon Oct 21, 2026
@Albabit thanks, that settles both open points: the label works on your ring, and the 0–1 s
05 15/05 18match means the 2 minute window has plenty of margin.Your disconnect test was the key detail for the next part. The automatic spot SpO2 is built the way you described, and will be in the next RC:
04 0e(up to 75 s), then stores the reading as a spot reading, the same as the manual button.I'll post here when the build is up.
Albabit commentedon Oct 22, 2026
@foureight84 Thanks a lot, that's exactly what I had in mind. I'll test it as soon as the RC is up.
One small request while you're on it: could the sliders go beyond 60 min?
SpO2 from the app: since the app starts each reading itself, it doesn't depend on what the ring accepts. Each spot reading keeps the red LED on and the connection open for about 50 s, so being able to pick every 2 h or 3 h would help with battery, and mean fewer LED flashes at night.
Heart rate: I checked whether the ring takes more than 60 min for its own readings. With PulseLoop kept off the ring, I sent 01 0c 01 78 (120 min) at 09:06 this morning. The ring answered OK and logged readings at 10:00 and 12:00, with nothing at 11:00. So 120 min works on this ring. I only tried 120, nothing longer.
foureight84 commentedon Oct 23, 2026
@Albabit yes to both — they're in the build now: v2.11.1+77-rc2 installs over 2.11.1 (76).
SpO₂ from the app now has an interval of its own: every hour, 2 h, or 3 h (Settings → Measurement Frequency, under the toggle). The ring's own SpO₂ setting is untouched either way — the app keeps aiming between its readings, just less often. At 2 h that's 12 readings a day instead of 24, each ~50 s of LED, so the night should be quieter.
Heart rate goes to 120 min for your ring's family — exactly because you tested that value. The slider stops there until something longer is tried on hardware. Everything else (blood pressure, temperature, HRV, and the ring's own SpO₂ interval) stays capped at 60: your test covered the heart-rate command only, and the ring's SpO₂ grid is what the app's spot readings schedule themselves around.
Two things to try, whenever you get to it:
04 0e, and the readings store as spot readings.Albabit commentedon Oct 23, 2026
@foureight84 Installed 2.11.1 (77) this morning, but neither option shows up on my ring: no "Measure SpO₂ from the app" toggle, and the heart rate slider still stops at 60 min (and starts at 5 min).
Looks like the app doesn't see my ring as YCBT. The data export lists it as deviceTypeRaw COLMI_SMART_HEALTH, wearableModelID colmi-smarthealth, with manualSpo2 in its capabilities. In the RC2 code, both the 120 min option (maximumHrInterval in SettingsSubScreens.kt) and the app SpO2 (ScheduledSpo2Prefs.isSupported) only check for RingDeviceType.YCBT.
The 120 min test from yesterday was on this same ring, so it's confirmed on a COLMI_SMART_HEALTH ring. Happy to test both as soon as they're enabled for this type.
fix(spo2): offer both #103 controls to COLMI_SMART_HEALTH rings too
foureight84 commentedon Oct 23, 2026
RC3 is up:
v2.11.1+78-rc3— @Albabit, this one should fix what you saw.Your ring is catalogued as
COLMI_SMART_HEALTH(modelcolmi-smarthealth). It runs the same Yucheng/YCBT protocol as the rings the controls were built against, but it's its own device type, and the feature flags in RC2 only checked forYCBT— so both controls stayed hidden. RC3 enables both forCOLMI_SMART_HEALTH:manualSpo2capability your export shows, and still off by default — enable it under Measurement Frequency).One related change while there: the stress toggle stays hidden on these rings too, since the YCBT wire format has no stress-monitor setting for either family — it would have been a toggle the ring never actually received.
Same install path as before (prerelease, so grab it from the GitHub release, not the in-app updater).
Albabit commentedon Oct 23, 2026
Thanks for RC3! Both options show up now. Here's what I saw this afternoon and evening.
SpO₂ from the app, every 2 hours:
Works. Slots landed at :15 rather than :30, since my ring SpO₂ slider was at 30 min.
04 0eat 14:16:25 (63 s), 98%. Red LED on during the measurement.04 0eafter 64 s, 98%.Both are stored in the ring as spot readings (
05 18with HR 0), and the 14:16 one shows up in the Vitals list.Heart rate at 120 min:
Works. Ring readings at 14:00, 16:00 and 18:00, nothing at 15:00 or 17:00. The app sends the ring SpO₂ monitor at 30 min (
01 26 01 1e), and the ring took no extra reading at 14:30 or 15:00.Small slider bug:
"Measure SpO₂ from the app" has 4 stops (60, 100, 140, 180 with
steps = 2), which read as 1h, 1h, 2h, 3h. Once saved at 2 h, the thumb sits on the 2nd stop (screenshot attached).steps = 1should fix it.Missed slot at 18:15:
The background sync held the ring from 18:14:48 to 18:15:04. The SpO₂ worker got
Connection skipped: another PulseLoop BLE client owns GATTat 18:15:01, gave up 20 s later without logging anything, and moved on to 20:15. No03 2fwas sent. The sync drifts about 20 s per cycle (16:13, 16:43, 17:14, 17:44, 18:14), so this will happen again now and then. Could the worker wait for the sync to finish (15 to 25 s) and then take the ring?Turning off "Blood oxygen" doesn't stop the estimate:
I'd like to see only real SpO₂ readings, so I turned off "Blood oxygen (SpO₂)" and saved at 16:35. The app sent
01 26 00(and again on each reconnect), but the 18:00 hourly reading still came with SpO₂ 98 (HR 83). So this ring seems to ignore01 26completely, both the on/off and the interval. A manual SpO₂ at 16:36 still worked and was stored. On this ring, the switch only ends up disabling the app's SpO₂ option.Request, real vs estimated readings:
Since the ring can't be told to stop, could the app tell them apart? The real ones are already stored as spot readings, and in the ring history they're the
05 18records with HR 0. Right now the 14:16 reading sits on the same curve as the estimates with no marker, and the Estimated badge covers the whole card.What I'd like most is a way for the app not to show the estimated SpO₂ at all, only the real readings (as an option or by default, either works for me). If that's not possible, smaller ideas:
Health Connect:
If the estimates are hidden, please don't send them to Health Connect either. Right now
VitalsExporterwrites every SpO₂ row as anOxygenSaturationRecord, so with Health Connect on, the estimates would land there looking like real measurements.Same for respiratory rate: the app stores one with each hourly reading (782 rows in my Oct 22 export), but on this ring it's only derived from heart rate (0.19 to 0.21 × HR on all 748 pairs), and the ring doesn't list it in its capabilities. It still gets exported when that Health Connect type is checked. Could it be unchecked by default, or greyed out, for rings that don't report it, the way the stress toggle is now hidden for this ring type?
I'll report on the night slots tomorrow.
Albabit commentedon Oct 24, 2026
@foureight84,
(I've updated my comment from yesterday 👆).
Night results on RC3 (app SpO₂ every 2 hours, slots at :15, heart rate at 120 min):
Heart rate at 120 min:
Held all night: ring readings at 20:00, 22:00, 00:00, 02:00, 04:00 and 06:00, nothing at odd hours.
App SpO₂ overnight:
2 of the 6 slots worked, a few minutes late (Doze), both stored as spot readings and shown in Vitals:
No logcat for the night (the buffer only went back to 07:29), so for the other four I matched the Bluetooth connection history and the screen events against the code:
isClearthen skips them as too close to 22:30 and 07:00 on the ring's 30 min SpO₂ grid. But my ring doesn't read on that grid: it ignores01 26and only reads with the heart rate (even hours here), so there was no ring reading at 22:30 or 07:00. The 07:01 run queued 08:15 right away, which fits a skip.Two ideas for the slots:
isClearon the heart rate interval. With HR at 120 min, slots would sit at odd hours and a late run would rarely be skipped.Real vs estimated:
I also updated the request at the end of my previous comment: hiding the estimated SpO₂ is what matters most to me, plus two Health Connect points (estimated SpO₂ and respiratory rate).
fix(sync): persist background-sync data in a process the UI never sta…
feat(spo2): opt-in app-started spot SpO2 on the SpO2 interval (#103)
3 remaining items
test(spo2): verify interval calculation, slot clearing, HC filtering,…
foureight84 commentedon Oct 24, 2026
RC4 is up:
v2.11.1+79-rc4— @Albabit, thank you for the detailed breakdown! This release addresses all the points you raised from your afternoon and overnight runs:steps = 1for the app SpO₂ slider so it has 3 clean, unambiguous stops: 1h (60 min), 2h (120 min), and 3h (180 min).isClearon HR interval: For YCBT/SmartHealth rings, slot offsets andisClearcollision checks are now anchored to the active heart-rate interval (e.g. 120 min) instead of a phantom 30 min SpO₂ grid. A run delayed by Doze (such as at 22:25) will no longer be falsely skipped.Estimatedtag when derived from heart rate.OxygenSaturationRecord. Only genuine spot readings export.~0.2 × HR, and is greyed out in the Health Connect settings screen.You can download
v2.11.1+79-rc4directly from the GitHub releases page. Looking forward to your feedback on these adjustments!Albabit commentedon Oct 25, 2026
@foureight84 Thanks for RC4! I tested it yesterday afternoon and overnight (HR at 120 min, app SpO₂ every 2 hours).
Settings and display:
One bug: the Blood oxygen tile still uses the estimated values:
With Hide estimated ON, the tile on Today (and Vitals) still plots and averages the estimated readings (about 14 points, some low ones in orange, "97% avg"), while the detail screen only shows the 4 real ones (98% avg). Since the badge is gone, they now look like real readings. It looks like
buildStatefiltersspo2SamplesandlatestSpo2, butspo2Seriesis still built from the unfilteredspo2(ViewModels.kt, around line 819). Screenshots below, all taken yesterday with Hide estimated ON: settings (13:16), detail screen (13:24), Today tile (13:25).App SpO₂ every 2 hours:
6 of 8 slots since 15:00 yesterday: 15:02, 17:07, 19:08, 21:04, 03:10 and 05:12 (96 to 99%), a few minutes late because of Doze. All stored in the ring as spot readings.
isClearskipped it. Makes sense.isClearshould allow (7.5 min before 00:00), but there was only one 37 s connection and no reading. No logcat for the night, so this is a guess: sync took the ring first and ran longer than usual, and since the 45 s connect timeout counts from the start of the worker, it ran out a few seconds after sync let go. Maybe start the 45 s once sync releases the ring?(Connection times come from Android's battery history,
dumpsys batterystats --history.)Unrelated: the DST night:
We switched from CEST to CET last night (03:00 back to 02:00). The ring stayed on summer time until a background sync at 02:06 CET set it to winter time, so
setTimedoes the right thing. But the night's second record spans the change: header 22:52:00 (CEST) to 06:09:39 (CET), so 8 h 17 of real time, while its segments only cover 7 h 17 (they add up to end minus start in ring time). The app places 438 minutes after 22:52 CEST, so the night ends at 05:10 instead of about 06:10. I woke up at 05:52 (Bedtime off at 05:52:40) and got up around 06:10, so with Bedtime only it should have ended at 05:53. Once a year, so low priority. Maybe convert the header end on its own withYCBTBytes.date()so the night at least ends at the right time? Not sure what's best for the hour the segments don't cover.Also for #83: I fell asleep around 21:30, before turning Bedtime on at 22:50. The ring recorded 21:30 to 22:49 as a separate part, and the app kept it whole, which was right since I really was asleep.
Two small suggestions:
ZoneLineSplitter.maxGapMs). A limit tied to the measurement interval (say 1.5x) would keep the lines and still break them on real gaps (charging, ring off). The ring also misses a few daytime readings, like it did at 60 min (14:00, 16:00 and 20:00 yesterday), and each miss now leaves a 4 hour gap.fix(spo2): filter series on tile, coordinate sync gatt, connect 120m …
foureight84 commentedon Oct 25, 2026
RC5 is up:
v2.11.1+79-rc5— @Albabit, thank you for the spot-on breakdown!This release addresses the points from your testing:
The build is available on the Releases page (APK should finish attaching in a few minutes).
fix(ycbt): label hourly SpO2 derived from heart rate as estimated (fo…
Albabit commentedon Oct 26, 2026
@foureight84, Thanks for RC5! I installed it yesterday at 09:03.
Blood oxygen tile:
Fixed. With Hide estimated ON, the Today tile only shows the real readings now (98% avg, no more low estimated points).
Bug: a cancelled app SpO₂ run leaves its connection open:
Yesterday the 13:00 slot started at 13:05:54 (Doze delay). Its client (generation=1 in the logs) connected and finished the startup sync at 13:06:15. Then Android went into Doze at 13:07:01 and released the job's wakelock 0.1 s later, so the job was most likely stopped in the middle of the measurement. No reading was stored.
That client never disconnected. It reconnected by itself after every drop (generation 2 to 15) and held the ring for 18 hours, until I force stopped the app this morning at 07:08. Turning Bluetooth off and on didn't help: its watchdog got the ring back first (
OS reports GATT not connectedin logcat). What I saw:I think it's the
finallyinScheduledSpo2Worker.measureInternal:withContext(Dispatchers.Main) { coordinator.stop() }runs beforeclient.destroy(). Once the worker is cancelled,withContextthrowsCancellationExceptionright away, sodestroy()is never reached and the client's watchdog keeps going. RC3 and RC4 have the same order, so it's not new in RC5, it just needs Doze to land during a run.RingSyncWorkercallsdestroy()first, so it looks fine. Maybe calldestroy()first, or run the cleanup inwithContext(NonCancellable)? TheenqueueNextindoWork'sfinallyhas the same pattern.I've turned app SpO₂ off until the next build.
Chart lines:
Still broken for heart rate on my ring. The threshold uses the smallest gap between two points, and the ring often writes an extra record right after midnight (6 of the last 8 nights). Today it's at 00:00:01, 22 s before the real 00:00:23 reading, so the smallest gap is 22 s and the limit falls back to 90 min. In the day view only that pair is joined (the tiny vertical line on the left), and the Today tile has no lines at all. A manual reading would do the same. Maybe use the configured HR interval, or the median gap, instead of the smallest one?
Screenshots from this morning (07:15): Today, and the Heart rate day view.
Albabit commentedon Nov 3, 2026
@foureight84, a quick check-in, plus one small thing I ran into this morning.
App SpO₂:
Any news on the cancelled run that leaves its connection open (my Oct 26 comment)? I've kept "Measure SpO₂ from the app" off since then, so scheduled SpO₂ is on hold on my side. Happy to test a build whenever it's ready, no rush.
Single-part night can't be extended (#106):
Last night the ring recorded one part, 00:13 to 07:34. I got up briefly around 07:35, went back to bed and slept until about 08:49, but the ring never opened a new record. No steps until 08:30, HR 53 at 08:00, and Bedtime stayed on until 08:49. The app shows 7h 20m, which matches what the ring has.
That's exactly the case the extension from #106 is for, but I can't reach it: there's no pencil, because the Records card only shows when the night has more than one part (
if (runs.size > 1)inSleepScreen.kt). On a one-part night there's no way to edit, extend or delete it. Maybe an edit button on the top card for single-part nights? It would keep the page clean and still give access to the edit dialog.Screenshot from this morning: one part, no Records card.
fix(spo2): prevent worker cancellation BLE leak, use median chart gap…
foureight84 commentedon Nov 4, 2026
@Albabit RC6 is up: v2.11.1+80-rc6 (versionCode 80).
This release addresses all three items from your testing:
ScheduledSpo2Workercleanup inwithContext(NonCancellable)and calledclient.destroy()beforecoordinator.stop(). If Android Doze interrupts the job and cancels the worker, the BLE client and its watchdog are immediately torn down so it cannot hold GATT open or drain the ring's battery.enqueueNextis also protected withNonCancellableso the next slot is scheduled reliably even after a cancelled run.ZoneLineSplitter.sampleIntervalMs). Occasional duplicate/spot records at midnight (e.g. 22s apart) no longer collapse the interval to seconds; 120m readings maintain their 180m threshold and remain connected.SleepScreen.ktto surface the Records card wheneverruns.isNotEmpty(), giving you the edit pencil on single-record nights so you can extend sleep when the ring stops tracking early.Merge pull request #134 from foureight84/feat/103-longer-spo2-intervals
foureight84 commentedon Nov 4, 2026
Merged into main via PR #134 (and released in test build v2.11.1+80-rc6).
Albabit commentedon Nov 4, 2026
@foureight84, thanks for RC6. Installed this morning (versionCode 80), and all three fixes work on my ring.
Cancelled app SpO₂ run:
To force a cancel in the middle of a reading, I faked a low battery with adb 45 s into the 10:30 run (that trips the battery-not-low constraint), then reset it 20 s later.
disconnect()13 ms later, link down at 10:30:51. The red LED went off at 10:30:48.So the Oct 26 leak looks fixed.
Chart lines:
HR points at 120 min are connected again in the day view.
Single-part night (#106):
The Records card now shows on a one-part night. I extended Nov 2-3 from 07:35 to 08:49: 8h 34m, score unchanged, the added part is hatched and not staged. Screenshot below.
One small thing:
The cancelled run still schedules the next slot from its
finally, and then the rerun does it again when it finishes, so the chain ends up with one extra run.A Save in settings should reset the chain (
REPLACE). Maybe skipenqueueNextwhenisStopped? WorkManager reruns a stopped worker, and the rerun schedules its own successor anyway.fix(spo2): skip successor scheduling when worker is stopped to preven…