Skip to content

Alarm: defer the re-arm-on-bond call so it doesn't race the connect handshake (#34) - #340

Merged
ryanbr merged 2 commits into
ryanbr:mainfrom
digitalerdude:fix/34-alarm-not-buzzing
Jul 12, 2026
Merged

ryanbr merged 2 commits into
ryanbr:mainfrom
digitalerdude:fix/34-alarm-not-buzzing

Conversation

@digitalerdude

Copy link
Copy Markdown

Summary

  • Fixes [master] Alarm not buzzing #34 (alarm not buzzing): armStrapAlarm() was re-armed on every (re)bond via a Combine sink on state.bonded that fires synchronously, nested inside BLEManager's own connect-handshake continuation (the bonding-confirm write's didWriteValueFor). That put the alarm's SET_CLOCK/SET_ALARM_TIME/GET_ALARM_TIME burst in the middle of that continuation — ahead of the handshake's own clock-set, and before the cmd-notify characteristic was resubscribed post-bond.
  • A v8.6.2 strap log attached to [master] Alarm not buzzing #34 confirmed the effect: the alarm's GET_ALARM_TIME readback got no reply at all (dropped, not just stale) in that session.
  • Fix: dispatch the re-arm one runloop turn later (DispatchQueue.main.async) so the connect handshake finishes first — clock set once, notify channel confirmed active — before the alarm arms on a settled link. No BLE command bytes change, only send order.

Diagnosis detail (for reference): the same log also showed a "strap clock 720 days in the future" warning, which I first read as a second strap-side clock fault. That turned out to be a decode bug in that build's dataRangeNewestUnix (fixed-grid scan straddling the real timestamp) — already fixed independently in #307/#308, unrelated to this fix. The actual current-time bytes in the raw GET_DATA_RANGE frame decode correctly, so there's no evidence of a genuinely broken strap RTC in that session.

Test plan

  • xcodegen generate && xcodebuild -project Strand.xcodeproj -scheme Strand -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO build — BUILD SUCCEEDED
  • xcodebuild -project Strand.xcodeproj -scheme NOOPiOS -destination 'generic/platform=iOS Simulator' CODE_SIGNING_ALLOWED=NO build — BUILD SUCCEEDED (AppModel.swift is shared with iOS)
  • xcodebuild … test -only-testing:StrandTests/SmartAlarmWeekdayTests -only-testing:StrandTests/AlarmReadbackDecodeTests — all pass (one unrelated locale-dependent failure reproduced and cleared with -testLanguage en -testRegion US)
  • On-device confirmation on a real WHOOP 4.0 strap — this is a BLE timing change and needs hardware validation, which I don't have for this issue. Requesting the reporter retest once merged.

This is a pure ordering/timing change (no protocol bytes, no schema, no analytics) so it's not a cross-platform parity item; Android's equivalent re-arm (AppViewModel.kt) is already dispatched via a coroutine StateFlow collector decoupled from the BLE callback thread, so it doesn't have this specific synchronous-nesting failure mode.

…andshake (ryanbr#34)

state.bonded is set from inside BLEManager's connect-handshake continuation, and
Combine delivered the bonded sink synchronously on that same call stack — so
armStrapAlarm()'s SET_CLOCK/SET_ALARM_TIME/GET_ALARM_TIME burst landed in the
middle of the handshake, ahead of its own clock-set and before the cmd-notify
characteristic was resubscribed post-bond. A v8.6.2 strap log (issue ryanbr#34)
confirmed the result: the alarm's GET_ALARM_TIME readback got no reply at all.
Deferring one runloop turn lets the handshake finish first, so the alarm arms
on a settled, fully-subscribed link. No BLE bytes/commands change, only order.
@digitalerdude digitalerdude mentioned this pull request Jul 12, 2026
5 tasks done
@ryanbr

ryanbr commented Jul 12, 2026

Copy link
Copy Markdown
Owner

Reviewed — the diagnosis is right and well-evidenced (the dropped GET_ALARM_TIME readback in the #34 log is the tell), and the parity call checks out: Android arms the strap alarm too (WhoopBleClient.armStrapAlarm), but its re-arm is triggered from a viewModelScope collector on Dispatchers.Main off a StateFlow that's set on the GATT binder thread — a thread boundary that decouples it from the callback — plus a connectHandshakeDone guard. So iOS-only is correct.

One suggested refinement to the fix itself. DispatchQueue.main.async gets the arm out of the nested didWriteValueFor continuation, which is the right instinct — but a single runloop turn only guarantees you've left the current callback, not that the connect handshake has finished. The handshake's clock-set and notify-resubscribe complete over later callbacks (their ACKs / didUpdateNotificationStateFor land in subsequent delegate turns), so a one-turn defer can still fire the alarm's GET_ALARM_TIME before the cmd-notify characteristic is confirmed subscribed — which is exactly the "no reply at all" the log showed.

There's already a precise signal for "handshake settled": BLEManager.connectHandshakeDone (BLEManager.swift:608), which beginBackfill already gates on:

guard connectHandshakeDone else {
    log("Backfill: deferred — connect handshake not done yet")
    ...
}

Suggested fix: arm the strap alarm off that same "handshake complete" signal rather than off raw bonded. Concretely — expose a published/observable handshake-complete (or invoke the re-arm from the point where connectHandshakeDone is set, after clock-set + notify-resubscribe), and have the AppModel sink arm on THAT. That guarantees the SET_CLOCK/SET_ALARM_TIME/GET_ALARM_TIME burst goes out on a confirmed-ready link — closing the notify-not-subscribed-yet gap the log evidenced, and mirroring how Android already re-arms only after its handshake settles. The .main.async version is a reasonable band-aid (it does break the synchronous nesting, and the send is bond-gated + re-reads state so it can't misfire on a dropped link), but gating on connectHandshakeDone targets the root cause directly instead of relying on one runloop turn being "enough".

Either way this is a BLE-timing change that compile + unit tests can't validate — it needs an on-device retest on a real WHOOP 4.0 before #34 is called fixed. Thanks for the sharp write-up, including catching that the "720 days in the future" line was the separate dataRangeNewestUnix decode bug (#307/#308), not a real RTC fault.

…anbr#34)

Review feedback on ryanbr#340: a one-turn `.main.async` defer breaks the synchronous
nesting inside BLEManager's connect-handshake continuation, but doesn't
guarantee the cmd-notify characteristic has actually confirmed subscribed by
the time the deferred alarm arm sends GET_ALARM_TIME — that confirmation
(didUpdateNotificationStateFor) can land over a later delegate callback, past
a single runloop turn.

Replaces the defer with a precise signal: LiveState.connectSettled, a counter
BLEManager bumps only once BOTH connectHandshakeDone (hello + SET_CLOCK sent)
AND the cmd-notify characteristic's subscription is CONFIRMED active are true
- whichever lands second. AppModel's alarm re-arm sink now keys off
`live.$connectSettled` instead of `live.$bonded`, so SET_ALARM_TIME/
GET_ALARM_TIME always go out on a link whose reply channel is confirmed live.
WHOOP 5/MG (which never sends GET_ALARM_TIME and stays behind the Experimental
toggle) signals settled directly off its own handshake completion, preserving
its prior re-arm-on-bond timing unchanged.
@digitalerdude

digitalerdude commented Jul 12, 2026 •

Copy link
Copy Markdown
Author

Addressed in 2f6dc01 — replaced the .main.async defer with a precise signal.

You're right that a single runloop turn only guarantees leaving the nested didWriteValueFor call, not that the handshake has settled. New approach: LiveState.connectSettled, a counter BLEManager bumps only once both connectHandshakeDone (hello + SET_CLOCK sent) and the cmd-notify characteristic's subscription is confirmed active (didUpdateNotificationStateFor fired with isNotifying == true) — whichever lands second, since the log showed the notify confirmation can arrive after the handshake body finishes. AppModel's re-arm sink now keys off live.$connectSettled instead of live.$bonded.

WHOOP 5/MG (Experimental, never sends GET_ALARM_TIME) signals settled directly off its own handshake completion, so its re-arm timing is unchanged — it doesn't wait on the WHOOP4-only cmd-notify gate.

Rebuilt + retested: macOS Strand and iOS NOOPiOS both BUILD SUCCEEDED, SmartAlarmWeekdayTests + AlarmReadbackDecodeTests pass.

Still needs the on-device retest from the #34 reporter — this remains a BLE-timing change compile/unit tests can't validate on their own.

@ryanbr
ryanbr merged commit b0dc0c6 into ryanbr:main Jul 12, 2026
2 checks passed
DX23876 pushed a commit to DX23876/noop that referenced this pull request Jul 22, 2026
…andshake (#34) (ryanbr#340)

* Alarm: defer the re-arm-on-bond call so it doesn't race the connect handshake (#34)

state.bonded is set from inside BLEManager's connect-handshake continuation, and
Combine delivered the bonded sink synchronously on that same call stack — so
armStrapAlarm()'s SET_CLOCK/SET_ALARM_TIME/GET_ALARM_TIME burst landed in the
middle of the handshake, ahead of its own clock-set and before the cmd-notify
characteristic was resubscribed post-bond. A v8.6.2 strap log (issue #34)
confirmed the result: the alarm's GET_ALARM_TIME readback got no reply at all.
Deferring one runloop turn lets the handshake finish first, so the alarm arms
on a settled, fully-subscribed link. No BLE bytes/commands change, only order.

* Alarm: gate the re-arm-on-bond on connect-settled, not raw bonded (#34)

Review feedback on ryanbr#340: a one-turn `.main.async` defer breaks the synchronous
nesting inside BLEManager's connect-handshake continuation, but doesn't
guarantee the cmd-notify characteristic has actually confirmed subscribed by
the time the deferred alarm arm sends GET_ALARM_TIME — that confirmation
(didUpdateNotificationStateFor) can land over a later delegate callback, past
a single runloop turn.

Replaces the defer with a precise signal: LiveState.connectSettled, a counter
BLEManager bumps only once BOTH connectHandshakeDone (hello + SET_CLOCK sent)
AND the cmd-notify characteristic's subscription is CONFIRMED active are true
- whichever lands second. AppModel's alarm re-arm sink now keys off
`live.$connectSettled` instead of `live.$bonded`, so SET_ALARM_TIME/
GET_ALARM_TIME always go out on a link whose reply channel is confirmed live.
WHOOP 5/MG (which never sends GET_ALARM_TIME and stays behind the Experimental
toggle) signals settled directly off its own handshake completion, preserving
its prior re-arm-on-bond timing unchanged.
simoncad7 pushed a commit to simoncad7/noop that referenced this pull request Aug 17, 2026
…andshake (ryanbr#34) (ryanbr#340)

* Alarm: defer the re-arm-on-bond call so it doesn't race the connect handshake (ryanbr#34)

state.bonded is set from inside BLEManager's connect-handshake continuation, and
Combine delivered the bonded sink synchronously on that same call stack — so
armStrapAlarm()'s SET_CLOCK/SET_ALARM_TIME/GET_ALARM_TIME burst landed in the
middle of the handshake, ahead of its own clock-set and before the cmd-notify
characteristic was resubscribed post-bond. A v8.6.2 strap log (issue ryanbr#34)
confirmed the result: the alarm's GET_ALARM_TIME readback got no reply at all.
Deferring one runloop turn lets the handshake finish first, so the alarm arms
on a settled, fully-subscribed link. No BLE bytes/commands change, only order.

* Alarm: gate the re-arm-on-bond on connect-settled, not raw bonded (ryanbr#34)

Review feedback on ryanbr#340: a one-turn `.main.async` defer breaks the synchronous
nesting inside BLEManager's connect-handshake continuation, but doesn't
guarantee the cmd-notify characteristic has actually confirmed subscribed by
the time the deferred alarm arm sends GET_ALARM_TIME — that confirmation
(didUpdateNotificationStateFor) can land over a later delegate callback, past
a single runloop turn.

Replaces the defer with a precise signal: LiveState.connectSettled, a counter
BLEManager bumps only once BOTH connectHandshakeDone (hello + SET_CLOCK sent)
AND the cmd-notify characteristic's subscription is CONFIRMED active are true
- whichever lands second. AppModel's alarm re-arm sink now keys off
`live.$connectSettled` instead of `live.$bonded`, so SET_ALARM_TIME/
GET_ALARM_TIME always go out on a link whose reply channel is confirmed live.
WHOOP 5/MG (which never sends GET_ALARM_TIME and stays behind the Experimental
toggle) signals settled directly off its own handshake completion, preserving
its prior re-arm-on-bond timing unchanged.
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.

[master] Alarm not buzzing

2 participants