Skip to content

Add experimental AirPods heart-rate monitoring and RSSI - #702

Open
thibaup wants to merge 19 commits into
librepods-org:mainfrom
thibaup:heart-rate-monitoring
Open

thibaup wants to merge 19 commits into
librepods-org:mainfrom
thibaup:heart-rate-monitoring

Conversation

@thibaup

@thibaup thibaup commented Aug 3, 2026

Copy link
Copy Markdown

Adds an experimental Heart-rate test under Settings. It initializes the RTBuddy HEARTRATE stream and shows structurally validated samples in a live graph based on the protocol work in https://github.com/tomppi/airpods_rtbuddy_v37_probe. I also fixed a compatibility issue found during testing.

Tested with AirPods Pro 3 on a OnePlus 15

Edit: This branch also adds experimental RSSI-based proximity estimation

arctumn pushed a commit to arctumn/librepods that referenced this pull request Aug 7, 2026
…epods-org#702)

hr.rs = a faithful Rust port of RtBuddyHeartRateDecoder (frame reassembly across
chunks + a bounded protobuf/varint parser + the full HEARTRATE validation: log
types {1,3}, service 19, 18-byte payload, known status tail @15, BPM @offset 1 in
30..=220). aap.rs gets the enable sequence (AACP 1.3 CONNECT/CAPABILITIES init +
HRM_STATE 0x30 + the RTBuddy START/STOP frames). IPC gains Snapshot.heart_rate +
Command::SetHeartRate{on}. run_receiver feeds each recv chunk to the decoder only
when HR is on (opt-in, off by default — it drains battery), publishes new BPM via
push_state, and re-arms/clears across reconnects. Pro 3 only; experimental —
needs on-hardware verification of the init timing + the stop path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
arctumn pushed a commit to arctumn/librepods that referenced this pull request Aug 7, 2026
…epods-org#702)

The RTBuddy stream almost never starts on the first enable (frame_prefix_hits=0 on
hardware). Mirror the Android HeartRateMonitor: on SetHeartRate{on}, a dedicated
retry thread runs a 15s campaign — each attempt STOP-resets + re-sends the init
frames + HRM_STATE + START, then polls hr_got_sample for 8s; on the first decoded
BPM it goes live (run_receiver keeps decoding), else it backs off (500/1000/2000ms)
and retries until the window is spent. hr_got_sample is the run_receiver<->retry
rendezvous; hr_retrying guards against stacking two campaigns. No L2CAP reconnect
(service-level re-enable only). Reconnect re-arm goes through the same path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
arctumn pushed a commit to arctumn/librepods that referenced this pull request Aug 7, 2026
Captured from a real iOS Fitness/Strava HR session via PacketLogger: at workout
start the iPhone sends 04 00 04 00 44 00 04 00 02 00 03 07 (opcode 0x44, value
0x07) alongside the SensorDataWX frames — a command LibrePods never mapped. The
PR librepods-org#702 enable (HRM_STATE + START) only yields empty status heartbeats; this is
the suspected 'enter workout sampling mode' trigger. Sent right before START,
mirroring the iPhone ordering. Experimental — validating on hardware.
arctumn added a commit to arctumn/librepods that referenced this pull request Aug 8, 2026
Cross-checked against upstream PR librepods-org#702, the Android implementation tested on
AirPods Pro 3 and the only client known to actually produce heart-rate samples.
Its start frame is:

  17 00 00 00 10 00 10 00 08 E3 46 42 0B 08 13 10 02 1A 05 01 40 42 0F 00

That is form A with the bare 0x13 — identical to the fresh-session iOS capture
and to the original constant in this file, differing only in the sequence
varint. Three independent sources now agree on the frame.

But its enable sequence has a step this daemon lost:

  connect0 -> caps0 -> connect4 -> caps4 -> HRM_STATE(0x30) -> start frame

39fde48 removed HRM_STATE, justified by my finding that control id 0x30 appears
zero times in the iOS captures. The observation is correct and the inference was
wrong: iOS is a reference for the wire format, Android is the reference for a
working client. Restored, in Android's position.

Also noted for follow-up, not implemented here: Android stops head tracking and
waits 220 ms before the first heart-rate start, because both live on the 0x17
service. This daemon has no head-tracking conflict handling at all.

NOT COMPILED -- cargo is unavailable here and the daemon is Windows-only.
@arctumn

arctumn commented Aug 8, 2026

Copy link
Copy Markdown

Hi @thibaup — thanks a lot for this PR, it's been an excellent reference.

I'm working on the cross-platform (Windows) port of LibrePods (#710) and tried to bring up heart-rate using the exact sequence from this branch. I can now see both sides of the conversation, and I'm stuck at a point where the AirPods accept the stream but never send a reading — so I wanted to compare notes with someone who has it working.

Setup

  • AirPods Pro 3 (A3063), AAP Firmware Version (Active) = 81.2675000075000000.6877 (Marketing Version 8454800), standalone — iPhone powered off, no other Apple device connected.
  • On Windows the AAP channel (L2CAP PSM 0x1001) is reached through a small kernel profile driver (the MS Bluetooth stack doesn't allow a user-mode L2CAP client connect), but everything above the transport is identical to the Android/Linux path.
  • Captured with btvs.exe (Microsoft Bluetooth Virtual Sniffer) → Wireshark, using the pabloaul/apple-wireshark aacp + rtbuddy dissectors, so I can read both directions.

What I send (mirroring HeartRateMonitor + AACPManager)

  1. connect_service_0capabilities_service_0
  2. connect_service_4capabilities_service_4
  3. stop head-tracking (SensorDataWX service 0x0e, period 0) — as beforeFirstStart does
  4. HRM_STATE (control 0x30) = true04 00 04 00 09 00 30 01 00 00 00
  5. HEART_RATE_START_1S04 00 04 00 17 00 00001000 1000 08 <seq> 42 0b 08 13 10 02 1a 05 01 40 42 0f 00

The dissector confirms all of these go out, in this order, including the 0x30 control ("Heart Rate Monitor").

What comes back

The AirPods accept everything but never emit a sample:

  • They return the descriptor dump (AccessoryService: devmotion6, HID report descriptor, etc.).
  • They ACK the head-tracking stop: SensorDataWX … Field(9): 08 0e (service 14).
  • They ACK the heart-rate stream: SensorDataWX … Field(9): 08 13 (service 19) — every single enable cycle.
  • But across ~9 minutes and several enable/disable cycles, zero SensorDataWX data frames arrive — every service-19 frame is the ~8-byte ACK, never a sample frame. Decoder reports type19 = 0, no BPM.

So the session, descriptors, and stream request are all accepted; the sensor just never streams.

Questions

  1. On your working setup, do samples start flowing immediately after the 08 13 ACK, or is there another trigger/frame between the ACK and the first data frame?
  2. Does it need an active session/foreground state on a host (HealthKit / a running consumer), or does it truly stream standalone with just HRM_STATE + START?
  3. Any in-ear / fit gating before samples appear — does it need both buds worn, still, skin contact?
  4. Roughly how long from START to the first sample on the OnePlus, and are the values sustained or intermittent?
  5. Anything you send that isn't obvious from this branch (a periodic keepalive, a second control command, a capabilities re-query) that could be the missing piece?

I can share the pcaps / exact byte sequences if that helps. Thanks again for the great work!

@thibaup

thibaup commented Aug 8, 2026

Copy link
Copy Markdown
Author

@arctumn

  1. There can be a small delay after the 08 13 ACK before the first value comes in. Usually it's pretty quick though. That's why I currently use an 8 second window before considering the start failed.

  2. It doesn't need Health Connect or anything like that running. That only receives the HR values afterwards. The AirPods stream directly to LibrePods.

  3. At least one airpod has to be in-ear.

  4. Normally I get the first value within those 8 seconds, usually quite a bit faster. After that the values are sustained, roughly following the 1 second interval.

  5. I'm not sending any extra keepalive or another HR command after START.

if you're getting the 08 13 ACK every time but never any actual samples, a full disconnect/reconnect might be worth trying as well.

Feel free to send the pcap/bytes too, I can compare them with what I'm doing.

@arctumn

arctumn commented Aug 8, 2026

Copy link
Copy Markdown

Thanks, that's really helpful — especially points 2 and 3.

I implemented and tested your suggestions:

  • Aligned my sequence to yours, including stopping head tracking up front (like beforeFirstStart) before the session init.
  • Added the full disconnect/reconnect escalation: after a few enable retries that only get the ACK, I tear down and reopen the L2CAP channel, re-run the whole handshake, and re-arm HR on the fresh channel (repeating every ~45 s).
  • Tested with at least one bud firmly in-ear, iPhone powered off, no other Apple device connected.

Result: across ~3 minutes and several full channel rebuilds, the AirPods ACK the heart-rate stream (SensorDataWX … Field(9) TypeAck: 08 13) on every cycle, but never send a single data frame — no SensorDataWX with the cmd field (tag 3a, service 19 + payload). type19 stays 0 the whole time.

So the enable sequence, the in-ear requirement, and the disconnect/reconnect are all covered, and it still won't stream on this unit.

Here's my exact on-the-wire enable sequence (raw L2CAP payloads from a Windows btvs capture) — as far as I can tell it's byte-identical to your AACPManager constants apart from the seq varint, but you can compare directly:

# TX  stop head-tracking  (SensorDataWX, service 0x0e, period 0)  -- beforeFirstStart
04 00 04 00 17 00 00 00 10 00 10 00 08 86 01 42 0b 08 0e 10 02 1a 05 01 00 00 00 00
# TX  connect_service_0
00 00 00 00 01 00 03 00 00 00 00 00 00 00 00 00
# TX  capabilities_service_0
04 00 00 00 01 00 00
# TX  connect_service_4
00 00 04 00 01 00 03 00 00 00 00 00 00 00 00 00
# TX  capabilities_service_4
04 00 04 00 01 00 00
# TX  HRM_STATE 0x30 = on
04 00 04 00 09 00 30 01 00 00 00
# TX  HEART_RATE_START_1S  (SensorDataWX, service 0x13, period 1s)
04 00 04 00 17 00 00 00 10 00 10 00 08 87 01 42 0b 08 13 10 02 1a 05 01 40 42 0f 00

And what the AirPods send back — ACK only, then silence:

# RX  ACK service 0x0e (head-tracking)
04 00 04 00 17 00 00 00 10 00 08 00 08 0a 10 01 4a 02 08 0e
# RX  ACK service 0x13 (heart-rate)   <-- every cycle, then no data
04 00 04 00 17 00 00 00 10 00 08 00 08 0b 10 01 4a 02 08 13

My unit is an AirPods Pro 3 (A3063), firmware 81.2675000075000000.6877 — do you happen to know your firmware version? Given everything else matches, a firmware difference is my leading suspect now.

I have a sanitized pcap of one enable cycle (just the L2CAP frames, no identifiers) if you'd like to diff it against yours.
hr-enable-clean-zipped-because-gh-limitations.zip

@arctumn

arctumn commented Aug 8, 2026

Copy link
Copy Markdown

I bet is some windows weird bluetooth black magic happening here because everything mimics my iphone, even when checked on my mac tracing the bluetooth packages between the airpod and the iphone.

And your android solution jsut working first time, this feels like a whole disaster from my side. already taked more time here thanm porting from linux, doing windows drivers for bluetooth and hi-rez audio.
😞

arctumn pushed a commit to arctumn/librepods that referenced this pull request Aug 8, 2026
…_descriptors

Match the working Android client (upstream PR librepods-org#702): the heart-rate enable now
sends control 0x30 (HRM_STATE=on) between the AACP 1.3 session init and the
stream start, and drops the request_all_descriptors frames (an iOS-capture
guess that never helped and Android does not send).

A btvs + Wireshark capture (rtbuddy/aacp dissector) confirms the AirPods ACK
service 19 every cycle but emit zero data frames on the A3063 test unit even
with 0x30 on the wire — so 0x30 is necessary to match Android but not
sufficient standalone here; the remaining gap is non-protocol (fit / another
Apple host holding the service / firmware). Kept because it mirrors the
working client.
arctumn pushed a commit to arctumn/librepods that referenced this pull request Aug 8, 2026
Mirror the working Android HeartRateMonitor loop, which the PR librepods-org#702 author
confirmed: the enable is complete (no keepalive, no HealthKit), at least one bud
must be in-ear, and a full disconnect/reconnect is worth trying.

- Only succeed on a decoded reading; ignore the bare service-19 ACKs (the failure
  mode is ACK-but-no-data), and re-send the full enable every ~8 s while HR is on
  instead of stopping after one attempt.
- After 4 ACK-only attempts, ask run_receiver to rebuild the L2CAP channel (drop +
  reopen in-process, so no driver code-38) and re-arm HR on it — the Android
  requestTransportRecovery.
- Cap that at 3 rebuilds, then give up ("Heart rate unavailable") so we don't
  reconnect forever; reset the budget when HR is re-toggled or on a new connect.
- Stop head tracking up front (beforeFirstStart), matching the author's order.

Tested (bud in-ear, iPhone off): the AirPods ACK service 19 every cycle but never
stream a data frame across several rebuilds — type19 stays 0. Firmware is the
leading suspect vs the author's working unit.
@thibaup

thibaup commented Aug 8, 2026

Copy link
Copy Markdown
Author

I wouldn't be surprised if this is some windows bluetooth weirdness.

There is one small difference though that might be worth testing:

Your HR start packet has:
... 10 00 08 87 01 42 0b 08 13 10 02 1a 05 01 40 42 0f 00

while the constant I'm currently sending is:
... 10 00 08 e3 46 42 0b 08 13 10 02 1a 05 01 40 42 0f 00

So the full packet on my side is basically:
04 00 04 00 17 00 00 00 10 00 10 00 08 e3 46 42 0b 08 13 10 02 1a 05 01 40 42 0f 00

I assumed 08 e3 46 is just the protobuf sequence/request ID, so in theory changing it shouldn't matter

@arctumn

arctumn commented Aug 8, 2026

Copy link
Copy Markdown

Tried it — I set my HR start seq to your exact 08 e3 46 (9059), so my packet is now byte-for-byte identical to yours:

04 00 04 00 17 00 00 00 10 00 10 00 08 e3 46 42 0b 08 13 10 02 1a 05 01 40 42 0f 00

Same result: the AirPods ACK service 19 on every cycle (and every channel rebuild), but never send a data frame — type19 stays 0. So it wasn't the sequence id.

At this point everything above the transport is identical to your client — session init, HRM_STATE, START (identical bytes), in-ear, disconnect/reconnect — and it still won't stream on my unit. Since the enable bytes match exactly, if there's still a difference it has to be below the protocol: either the L2CAP negotiation the Windows Bluetooth stack does (vs Android's), or a firmware difference on my unit.

Two things would help me nail down which, if you have a moment:

  1. Your firmware version — mine reports 81.2675000075000000.6877. If yours differs, that alone could explain it.

  2. A btsnoop HCI log of a working HR session on your OnePlus, if that's easy for you: enable Developer options → Enable Bluetooth HCI snoop log, start HR, let a few readings come in, then grab the log (via a bug report, or adb shell dumpsys bluetooth_manager / the btsnoop_hci.log). With that I can diff the L2CAP setup (MTU / mode / channel config) and the exact frame timing between your working stack and the Windows one — the enable is identical, so any remaining difference should show up there.

No pressure at all — you've already been incredibly helpful. Even just the firmware version would be a great data point. Thanks again!

@arctumn

arctumn commented Aug 8, 2026

Copy link
Copy Markdown

I think I might found something nice from the Android buetooth stack that might be a possible issue from my kernel.
My driver does not use ERTM (Enhanced Retransmission Mode) like the android one does!! Linux uses the same isntructions as us but android uses other thigns, let me rebuild my windows driver.
I'm going to try to implemnt ERTM from this lets 🙏 it works!
https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/bthddi/ns-bthddi-_brb_l2ca_open_enhanced_channel.

Quick update after going a fair bit deeper.

Since everything above the transport was byte-identical to your client, I chased the one thing left below it: the
L2CAP channel mode. Android/Fluoride opens its classic L2CAP sockets in ERTM (obex_l2c_etm_opt), whereas the Windows
side was negotiating plain Basic mode (a btvs capture confirmed our config carried only MTU + Flush Timeout, no
Retransmission-and-Flow option). So my theory was: maybe the AirPods only stream the continuous HR sensor data over an
ERTM channel.

I modified our kernel profile driver to open the channel with BRB_L2CA_OPEN_ENHANCED_CHANNEL requesting ERTM
(CM_RETRANSMISSION_AND_FLOW), test-signed it and captured the result. Two conclusions, both killing the theory:

  1. Windows bthport never actually puts the ERTM option on the wire when a profile driver requests it as an L2CAP
    client. Across two builds (retransmission/monitor timeouts at 0, then at 2000/12000 with a zeroed config and MPS > 0),
    our Configure Request still went out Basic-only, and the enhanced-open connect just fails. Looks like a hard
    limitation of the MS stack's client-side ERTM support.
  2. Even in the capture where the negotiation ran, the AirPods counter-propose Basic mode, so they're happy on Basic.
    Which means your Android side is almost certainly running AAP over Basic too (its ERTM request gets rejected and falls
    back), i.e. ERTM isn't the differentiator.

So I've now ruled out the enable sequence, the sequence id, in-ear, disconnect/reconnect, and the L2CAP mode. At this
point I'm fairly convinced it's a firmware difference on my specific unit rather than anything in the protocol or the
host. If you ever get a moment to check your firmware version I'd still love to compare, mine reports
81.2675000075000000.6877.

Either way, thanks a lot for all the pointers, the protocol side is 100% confirmed correct thanks to your help

arctumn pushed a commit to arctumn/librepods that referenced this pull request Aug 9, 2026
The upstream PR librepods-org#702 author flagged our incrementing sequence id as the one
remaining byte-level difference from his HEART_RATE_START_1S. Pin it to his exact
value (0x2363 = varint e3 46) so our start frame is byte-for-byte identical, and
so retries re-send the same constant like his client does. Tested: no change
(still ACK-only), but it removes the last byte difference.
@thibaup
thibaup force-pushed the heart-rate-monitoring branch from a9a91bf to 5e20986 Compare August 12, 2026 10:04
…ion to send out the heart rate data as an Android BLE server
@thibaup thibaup changed the title Add experimental AirPods heart-rate monitoring Add experimental AirPods heart-rate monitoring and RSSI Aug 12, 2026
android: add batched and configurable Health Connect heart-rate export
@SAGIRIxr

Copy link
Copy Markdown

Heart rate is streaming here on AirPods Pro 3 (A3065), so adding a few data points — one of them is probably the thing blocking @arctumn.

Setup: Xiaomi 14 Pro, HyperOS / Android 14, Snapdragon 8 Gen 3 (libbluetooth_qti.so), Magisk + LSPosed. This unit needs #785 before the AAP channel survives at all, so the transport here is a patched Fluoride rather than a native one — which turned out to be useful, because I had HCI captures of both the working and non-working cases.

ACK is not a useful success signal

I spent a while in exactly @arctumn's state — 4a 02 08 13 on every cycle, never a data frame. What separates my failing runs from my working ones is not anything in the enable sequence:

Heart rate only streams on a channel where RTBuddy has already produced DATA frames for some other service.

  • Failing runs: head-tracking START → ACK only, zero DATA → heart-rate START → ACK only, zero DATA.
  • Working runs: head-tracking ACK and DATA flowing → heart-rate START → ACK → first sample ~2 s later.

I originally thought the fix was to start head tracking manually first, but that's wrong — I ran it both ways (with and without an explicit head-tracking start) and both produced 39 samples, because the app starts head tracking on connect anyway. The condition is that RTBuddy actually came alive on that channel, not who woke it.

@arctumn — this is the bit I'd check first: does head tracking produce real DATA frames on your Windows build, or only the 4a 02 08 0e ACK? If RTBuddy never streams anything, heart rate won't either, and the heart-rate path isn't where the bug is.

There is a dead window right after connect

The first RTBuddy commands after a fresh channel are dropped without even an ACK:

05:44:15  TX START svc=16 interval=40000     (no response)
05:44:16  TX START svc=16 interval=40000     (no response)
05:44:20  TX STOP  svc=16 interval=0         (no response)
05:44:45  TX START svc=16 interval=40000
05:44:45  RX ACK   svc=16
05:44:45  RX DATA  svc=16   × 123

~30 s of silence, then it works. Worth knowing for FIRST_SAMPLE_TIMEOUT_MILLIS — an attempt that lands in that window fails in a way that looks identical to a rejected stream, and retrying is the right response.

Service IDs are per-unit, don't hardcode them

@arctumn's trace uses service 0x0e for head tracking; on this unit head tracking is service 16 and heart rate is 19. The metadata frame names them explicitly, so the discovery path is the reliable one. From the service descriptors in the RTBuddy metadata here (field 5 repeated, 5.1 = id, 5.2 = blob):

service 16  ...  devmotion6
service 19  ...  com.apple.hid.heartrate-access, HeartRateService, HIDServiceAccessEntitlement

So on this unit 19 isn't a legacy fallback, it's what the accessory advertises. Worth keeping the metadata path as primary.

ERTM: ruled out, with the bytes

@arctumn already concluded this from the Windows side, but here's the direct evidence from the L2CAP information exchange, in case it saves anyone else the detour:

RX InfoRsp ExtFeatures mask=0x00000280   -> FixedChannels, UnicastConnectionlessReception
TX InfoRsp ExtFeatures mask=0x000004b8   -> ERTM, Streaming, FCS, FixedChannels

The AirPods don't advertise ERTM or Streaming at all, so no host can be using it with them — the channel is Basic everywhere, including on the working Android setups. For completeness, the negotiated AAP channel here:

RX ConfigReq  MTU=2582 Flush=30
TX ConfigReq  MTU=1691 RFC[Basic]
TX ConfigRsp  result=0 Success
RX ConfigRsp  result=0 Success  MTU=1691 RFC[Basic]

Two more fields in the 18-byte payload

Beyond HEART_RATE_BPM_OFFSET = 1, two bytes look decodable (BPM masked below, but the columns are real):

 BPM  byte2  byte3   bytes15..17
  XX    20     0     10 82 81      <- warmup
  XX    20     1     10 02 81
  XX    20     2     10 02 81
  XX    20     3     10 02 81
  XX   185     4     10 00 00      <- locked
  XX   224     5     10 00 00
  XX   238    33     10 00 00
  • byte 2 looks like a confidence/quality value — pinned at 20 through the warmup samples, then jumps to 185 and settles around 238.
  • byte 3 is a per-sample counter (0,1,2,…), usable for gap detection.
  • The status tail walks 10 82 8110 02 8110 00 00 as it locks.

If byte 2 really is confidence, gating on it would be sturdier than the fixed WARMUP_SAMPLE_COUNT = 4: the warmup length here tracked how well the bud was seated, so a fixed count will sometimes keep a bad sample and sometimes throw away a good one. The first two warmup samples were wildly out of range on every run, and the 30..220 filter alone doesn't catch them — one of them was 169.

Happy to run more captures on this unit if any of it would help; it's a patched-transport device, so it's a useful third data point next to a native Android stack and the Windows driver.

@SAGIRIxr

Copy link
Copy Markdown

Follow-up from the same unit (AirPods Pro 3 / A3065, Xiaomi 14 Pro). I went after the power side, since this streams continuously while enabled and Apple only samples during an active workout — 8 h with ANC vs 6.5 h with heart rate on, by their own numbers.

Bursts work: idle does not put the sensor back to sleep

The thing I expected to block duty-cycled sampling was a cold-start penalty on every burst. It isn't there, as long as the AACP channel stays up:

ACK first frame first locked sample samples in a 25 s window
immediately after warm-up 0.01 s 1.75 s 5.72 s 24
after 60 s idle 0.02 s 1.67 s 5.67 s 24
after 300 s idle 0.02 s 1.61 s 5.58 s 24

No degradation, and 1 Hz with no gaps once it locks. Head tracking produced zero frames during those idle windows, so the fast restart isn't an artifact of something else holding RTBuddy open.

The one hard constraint: don't tear the channel down between bursts. On a cold channel RTBuddy drops commands for ~30 s without even acking them, which costs far more than the duty cycle saves.

Running it that way here: 5 samples per burst, burst takes 13.7 s end to end, so a five minute gap is roughly a 4% duty cycle. Over four consecutive cycles three were clean and one tripped STREAM_STALLED and recovered on the retry 3 s later.

Byte 2 is worth using for the warm-up cut

I mentioned this last time; having now gated on it, the reason it matters is sharper than I put it. The warm-up readings are not out-of-range — the first two samples of a burst were 169 and 137 here — so MIN_BPM..MAX_BPM passes them and only WARMUP_SAMPLE_COUNT is holding them back. Byte 2 reads 20 through warm-up and 185-238 once locked, which separates them directly:

 BPM  byte2  seq
  XX    20     0     <- warm-up, inside 30..220
  XX    20     1
  XX    20     2
  XX    20     3
  XX   185     4     <- locked
  XX   238    33

Gating on confidence >= 0x80 with the fixed count kept as a floor has been stable across every burst since. Worth considering given warm-up length tracks how well the bud is seated, so a fixed four will sometimes cut a good sample and sometimes keep a bad one.

Small thing: the heart-rate screens bypass the resource system

HeartRateCard, HeartRateStatusChip, HeartRateTestScreen and HealthConnectSettingsScreen pass literals straight to text/label/title, so the whole feature renders in English on a non-English device even where the rest of the app is translated. About 35 strings, including the status labels and the duration helpers that assemble "$minutes min $remainingSeconds sec".

Two of them need a small signature change: healthConnectExportSummary and formatHealthConnectInterval have to become @Composable to call stringResource. Both are only reached from composables today, so it is contained.

Happy to send that as a PR against your branch if it's useful — or leave it, if you would rather land the feature first and sweep strings later.

@arctumn

arctumn commented Sep 20, 2026

Copy link
Copy Markdown

I cant test your theory rn as I'm not in a test environment on windows and haven't being able to be on test mode, so I'll give feedback later, but good someone had the same issue as me and manage to fix it

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants