Skip to content

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
ryanbr:mainfrom
ahmadssadiq:ios-call-alert-and-coach-stream-fix
Open

ahmadssadiq wants to merge 4 commits into
ryanbr:mainfrom
ahmadssadiq:ios-call-alert-and-coach-stream-fix

Conversation

@ahmadssadiq

@ahmadssadiq ahmadssadiq commented Oct 19, 2026 •

Copy link
Copy Markdown

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 rings

The 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.

  • Detection: CallKit's 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.
  • Why not ANCS: with ANCS the accessory subscribes to the phone's notification service, which needs strap firmware support. The phone-side app has to relay instead.
  • Background: iOS only delivers CXCallObserver changes while the app runs. A backgrounded NOOP runs when the strap wakes it with a BLE frame, so BLEManager gains an onInboundFrame hook 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. A beginBackgroundTask covers the start of each ring. It can't work after a force-quit from the app switcher. The monitor is created in AppModel.init, so it also runs after a CoreBluetooth state-restoration relaunch.
  • Cadence: a short burst repeated for as long as the call rings, stopping within ~1 s of it being answered, declined or ended. A 90 s ceiling self-heals a missed end. Android stops after 4 buzzes at 8 s; this follows the Apple Watch behaviour instead. This commit uses 3 pulses every 3 s; commit 2 shortens that to 2 pulses (~1.3 s) every 2 s.
  • Haptics (5/MG): the hardware-confirmed maverick notify buzz (cmd 0x13), written with response, without RUN_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.
    • Other strap families use the existing buzzStrapOnce() sequence.
  • Settings: a new "Buzz strap for incoming calls" toggle (default off, key 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 buzz

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). 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.handleDoubleTap checks it 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, ~2 s into the ring), but the phone received it ~1.5 s late. The first buzz's HAPTICS_FIRED event 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. 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 every 2 s, so at most ~1.3 s of an in-flight burst plays on after the tap.
  • Ending the call isn't possible. The tap also sends CallKit's CXEndCallAction for the ringing call and logs iOS's answer. For a phone call on iOS 26, 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 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 with max_completion_tokens

The Coach streams, and stream() in the OpenAI and Custom providers always sent max_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, but stream() didn't, despite its comment. stream() now mirrors send(): on that 400 it re-streams with max_completion_tokens and no temperature. The 400 arrives before any delta, so no text is duplicated.

4. chore(ios): sync Info.plist with project.yml

xcodegen generate adds the stalebattery BGTask identifier that project.yml already declares. Committing it keeps a fresh generate clean.

Type of change

  • Bug fix
  • New feature
  • Refactor / cleanup
  • Documentation
  • CI / tooling

How it was tested

Real hardware: iPhone 16 Pro (iOS 26) with a WHOOP 5/MG-family strap (whoop5 device family), NOOPiOS Release build.

  • Call buzz: buzzed the strap for incoming phone calls with the app in the foreground, in the background and with the phone locked (Continuous HRV capture on). It keeps buzzing for the whole ring and stops when the call is answered or ends; one test call logged 6 bursts, then "stopped". The strap log for each call showed → Run Haptics Pattern … (puffin cmd=0x13), RUN_HAPTIC_PATTERN_MAVERICK(19) → PENDING(2), then the strap's own HAPTICS: Sequence fired. / SEQ done.
  • Double-tap: one double-tap during the buzz now stops it straight away. Before the sync hold and shorter bursts it took two. The strap log shows Double-tap → stop call buzz, then the CallKit refusal quoted above.
  • Strength: effect 48 (80%) is what's in use on the strap, at my request for something one notch softer than 47; it hasn't been compared more formally.
  • Not yet tested: WhatsApp and FaceTime calls go through CallKit and are expected to work the same way, but haven't been tried.
  • Coach: builds and runs; not yet verified against a live OpenAI reasoning model.
  • Builds: this branch's head compiles for iOS (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 package tests pass for any package I touched (swift test in Packages/WhoopProtocol: 816 tests, 0 failures, 1 skipped)
  • Android unit tests pass if I touched android/ (N/A: android/ untouched)
  • No new build warnings introduced (every warning in the build log checked against the changed lines: none)
  • UI changes use only StrandDesign tokens (StrandFont / StrandPalette)
  • No hardcoded hex frame bytes; protocol facts live in the schema / decoders. The payload is built in MaverickHaptics, but the effect id 48 is passed as a literal from BLEManager.buzzCallAlert(). Happy to move it into a named constant in MaverickHaptics.
  • Follows the conventions in docs/CONTRIBUTING.md
  • I did not commit generated output (Strand.xcodeproj/) or any secrets/keystores

Related issues

Refs #66 (Android incoming-call buzz)

Contributed by @ahmadssadiq.

🤖 Generated with Claude Code

ahmadssadiq and others added 4 commits October 19, 2026 16:25
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>
@ahmadssadiq ahmadssadiq changed the title iOS: buzz the strap for incoming calls + fix Coach 400 on newer OpenAI models iOS: buzz the strap for incoming calls (double-tap to silence) + fix Coach 400 on newer OpenAI models Oct 20, 2026
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.

1 participant