Repository navigation
iOS: buzz the strap for incoming calls (double-tap to silence) + fix Coach 400 on newer OpenAI models - #2812
Open
ahmadssadiq wants to merge 4 commits into
Open
ahmadssadiq wants to merge 4 commits into
ahmadssadiq wants to merge 4 commits into
Conversation
The Coach streams its replies, and the streaming path in the OpenAI and Custom providers always sent `max_tokens` + `temperature`. Newer OpenAI models (the reasoning / GPT-5 families) reject that with: 400 - Unsupported parameter: 'max_tokens' is not supported with this model. Use 'max_completion_tokens' instead. `send()` already retried that 400 with the modern parameter shape, but `stream()` had no retry (its comment said it fell back to `send`'s retry, which nothing did), so the Coach surfaced the raw provider error. `stream()` now mirrors `send()`: try the standard shape first, and on a 400 that mentions max_tokens / max_completion_tokens / temperature / "unsupported", re-stream with `max_completion_tokens` and no temperature. The 400 arrives before any delta is emitted, so re-streaming can't duplicate text. The body builder moved into a private `streamChat(...)` with a `modernParams` flag, matching the existing `chat(...)` helper. Builds and runs on an iPhone 16 Pro (iOS 26); not yet verified against a live OpenAI reasoning model. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
iOS counterpart of the Android incoming-call wrist alert (ryanbr#66): when a call rings on the iPhone, the WHOOP strap buzzes, like an Apple Watch, until the call is answered, declined or ends. Detection: CallKit's CXCallObserver. It sees every CallKit call on the phone (regular phone calls, FaceTime, and VoIP apps that ring through CallKit, e.g. WhatsApp voice and video) and needs no permission or entitlement. A call counts as ringing when it is incoming, not yet connected and not ended. Only that state is read: never the number, the caller or the app. Why not ANCS: ANCS has the accessory (the strap) subscribe to the iPhone's notification service, which needs WHOOP firmware support. The strap doesn't do that, so the phone-side app has to relay the call. Background behaviour: iOS only delivers CXCallObserver changes while the app runs. A backgrounded NOOP runs when the strap wakes it with a BLE frame, so BLEManager now fires `onInboundFrame` on every notification and the monitor re-checks the call list there (throttled to 1 Hz). With Settings > Continuous HRV capture on (and "Overnight only" off) the strap sends a frame every second, so alerts fire within about a second with the app in the foreground, in the background, or with the phone locked. A short beginBackgroundTask covers the start of each ring. It cannot work after the app is force-quit from the app switcher (iOS does not relaunch it). The monitor is created in AppModel.init, so it is live after a CoreBluetooth state-restoration relaunch too. Cadence: one burst (3 pulses, ~2 s on the strap per its own log) every 3 s while ringing, stopping within ~1 s of the call ending. A 90 s ceiling self-heals a call whose end is never observed. This differs from Android, which stops after 4 buzzes at 8 s intervals. Haptics (5/MG): the call buzz is the hardware-confirmed maverick notify buzz (cmd 0x13), written with response so a backgrounded link can't drop it, and without RUN_ALARM so repeats never start the alarm. It is one notch softer than other buzzes: the strap's driver is a TI DRV2625 (named in the strap log), and the call buzz uses ROM effect 48 ("Buzz 2 - 80%") instead of 47 ("Buzz 1 - 100%"). To support that: - MaverickHaptics.notificationBuzz(loops:effect:) takes the first waveform effect (default 47, so existing bodies are byte-identical; the second slot, 152 = 0x80|24, is a 240 ms pause and is unchanged). - BLEManager.send(_:payload:writeType:maverickEffect:) passes it through (default 47, so every existing caller is unchanged). Other strap families use the existing buzzStrapOnce() sequence. Settings: new "Buzz strap for incoming calls" toggle (default off, key notif.calls.enabled) under Auto-detect workouts, with captions covering what rings it, the privacy line, and the Continuous HRV capture requirement for background alerts. Tested on an iPhone 16 Pro (iOS 26) + WHOOP 5.0: the first version (single burst every few seconds) buzzed for phone calls with the app open, in the background and with the phone locked. The strap log showed the 0x13 frame and "HAPTICS: Sequence fired". The continuous cadence and the 80% effect are built and installed but not yet confirmed by feel. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`xcodegen generate` adds $(PRODUCT_BUNDLE_IDENTIFIER).stalebattery to BGTaskSchedulerPermittedIdentifiers, because project.yml already declares it, but the committed Info.plist didn't have it. Committing the generated result so a fresh generate leaves the tree clean. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
While the strap is buzzing for a ringing call, a strap double-tap stops
the buzzing for that call (the call itself keeps ringing on the phone).
A double-tap with no call buzzing does whatever it normally does.
- CallBuzzMonitor.dismissFromStrap(): marks the current call as
silenced (no more bursts for that call UUID even if it keeps
ringing; cleared when it stops ringing) and returns true so the tap
is consumed.
- AppModel.handleDoubleTap: the call alert claims the double-tap
first, ahead of the 1.2 s debounce, a Lift Log override and the
user's configured double-tap action.
Making one double-tap enough: on hardware the first double-tap seemed
to need a second try. Decoding the strap's event frames showed the strap
had sensed it (DOUBLE_TAP, strap timestamp ~2 s into the ring), but the
phone received it ~1.5 s late, because the first buzz's HAPTICS_FIRED
event had kicked a strap-event history sync (~4 s offload). Meanwhile
the next 3-pulse burst (~2 s) had already been sent and played on. Two
fixes:
- BLEManager.holdStrapEventSync: asked before a strap-pushed EVENT
kicks a sync. AppModel points it at CallBuzzMonitor.isAlerting, so
no offload starts while a call is buzzing. Held syncs aren't lost:
the next trigger or the 15-min tick runs one.
- Shorter bursts: 2 pulses (~1.3 s) every 2 s instead of 3 pulses
every 3 s, so at most ~1.3 s of an in-flight burst plays on after a
double-tap.
Ending the call: researched, not possible. The tap also sends
CallKit's CXEndCallAction for the ringing call and logs iOS's answer. On
an iPhone 16 Pro (iOS 26) for a phone call, iOS refused with
com.apple.CallKit.error.requesttransaction 1 (unknownCallProvider): an
app can only end calls its own CXProvider reported, and no public API
ends or declines another app's or a carrier call. The sanctioned
accessory route is ANCS's negative action, which needs the strap's
firmware to be the ANCS client. The request is kept (harmless, and the
log line documents the outcome).
Settings caption: "Double-tap the strap to stop the buzzing (the call
itself keeps ringing on your phone)."
Tested on an iPhone 16 Pro (iOS 26) + WHOOP 5/MG with phone calls: one
double-tap now stops the buzzing straight away. Before the sync hold
and shorter bursts it needed two. The strap log shows "Double-tap → stop
call buzz" and the CallKit refusal above.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this PR does
Four commits: the incoming-call strap buzz plus a follow-up that lets a strap double-tap silence it, an independent Coach fix, and a generated-file sync. Happy to split them into separate PRs if you prefer one concern per PR.
1.
feat(ios): buzz the strap while an incoming call ringsThe iOS counterpart of the Android incoming-call wrist alert (#66). When a call rings on the iPhone, the strap buzzes like an Apple Watch until the call is answered, declined or ends.
CXCallObserver, which sees every CallKit call on the phone: regular calls, FaceTime, and VoIP apps that ring through CallKit (WhatsApp voice/video etc.). No permission or entitlement needed. A call is "ringing" when it is incoming, not connected and not ended. Only that state is read, never the number, the caller or the app.CXCallObserverchanges while the app runs. A backgrounded NOOP runs when the strap wakes it with a BLE frame, soBLEManagergains anonInboundFramehook and the monitor re-checks the call list there (throttled to 1 Hz). With Continuous HRV capture on (and Overnight only off) the strap sends a frame every second, so alerts fire with the app in the foreground, in the background, or with the phone locked. AbeginBackgroundTaskcovers the start of each ring. It can't work after a force-quit from the app switcher. The monitor is created inAppModel.init, so it also runs after a CoreBluetooth state-restoration relaunch.0x13), written with response, withoutRUN_ALARM. It's one notch softer than other buzzes. The strap's driver is a TI DRV2625 (it names it in its own log), and the call buzz uses ROM effect 48 ("Buzz 2 – 80%") instead of 47 ("Buzz 1 – 100%"). To allow that:MaverickHaptics.notificationBuzz(loops:effect:)takes the first waveform effect. It defaults to 47, so existing bodies are byte-identical.BLEManager.send(_:payload:writeType:maverickEffect:)passes it through. It also defaults to 47, so existing callers are unchanged.buzzStrapOnce()sequence.notif.calls.enabled) under Auto-detect workouts. Its captions cover what triggers it, the privacy line, and the Continuous HRV requirement for background alerts.2.
feat(ios): double-tap the strap to silence the call buzzWhile the strap is buzzing for a ringing call, a strap double-tap stops the buzzing for that call (the call itself keeps ringing on the phone). With no call buzzing, a double-tap does what it normally does.
CallBuzzMonitor.dismissFromStrap()marks the call as silenced (no more bursts for that call UUID; cleared when it stops ringing).AppModel.handleDoubleTapchecks it first, ahead of the 1.2 s debounce, a Lift Log override and the user's configured double-tap action.DOUBLE_TAP, ~2 s into the ring), but the phone received it ~1.5 s late. The first buzz'sHAPTICS_FIREDevent had started a strap-event history sync (~4 s offload), and the next ~2 s burst was already playing. Two fixes:BLEManager.holdStrapEventSync, consulted before a strap-pushed EVENT kicks a sync.AppModelpoints it atCallBuzzMonitor.isAlerting, so no offload starts while a call is buzzing. Held syncs aren't lost: the next trigger or the 15-min tick runs one.CXEndCallActionfor the ringing call and logs iOS's answer. For a phone call on iOS 26, iOS refused withcom.apple.CallKit.error.requesttransaction 1(unknownCallProvider). An app can only end calls its ownCXProviderreported, and no public API ends or declines another app's or a carrier call. The sanctioned accessory route is ANCS's negative action, which needs the strap firmware to be the ANCS client. The request is kept: it's harmless, and the log line documents the outcome.3.
fix(coach): retry streamed replies withmax_completion_tokensThe Coach streams, and
stream()in the OpenAI and Custom providers always sentmax_tokens+temperature. Newer OpenAI models reject that with 400 – Unsupported parameter: 'max_tokens' … use 'max_completion_tokens'.send()already retried that 400 with the modern shape, butstream()didn't, despite its comment.stream()now mirrorssend(): on that 400 it re-streams withmax_completion_tokensand no temperature. The 400 arrives before any delta, so no text is duplicated.4.
chore(ios): syncInfo.plistwithproject.ymlxcodegen generateadds thestalebatteryBGTask identifier thatproject.ymlalready declares. Committing it keeps a fresh generate clean.Type of change
How it was tested
Real hardware: iPhone 16 Pro (iOS 26) with a WHOOP 5/MG-family strap (
whoop5device family), NOOPiOS Release build.→ Run Haptics Pattern … (puffin cmd=0x13),RUN_HAPTIC_PATTERN_MAVERICK(19) → PENDING(2), then the strap's ownHAPTICS: Sequence fired./SEQ done.Double-tap → stop call buzz, then the CallKit refusal quoted above.NOOPiOS, Release, built with the watch-app embed disabled only for the check, because that Mac has no watchOS SDK; the branch keeps the embed) and for macOS (Strand). The double-tap commit was also compiled on its own in a clean worktree.Checklist
swift testinPackages/WhoopProtocol: 816 tests, 0 failures, 1 skipped)android/(N/A:android/untouched)StrandDesigntokens (StrandFont/StrandPalette)MaverickHaptics, but the effect id48is passed as a literal fromBLEManager.buzzCallAlert(). Happy to move it into a named constant inMaverickHaptics.docs/CONTRIBUTING.mdStrand.xcodeproj/) or any secrets/keystoresRelated issues
Refs #66 (Android incoming-call buzz)
Contributed by @ahmadssadiq.
🤖 Generated with Claude Code