Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,13 @@ public enum MaverickHaptics {
/// `UInt8(clamping:)` admitted 255, a value nothing has ever observed on the wire.
///
/// Kotlin twin: `WhoopBleClient.maverickHapticBody`.
public static func notificationBuzz(loops: Int) -> [UInt8] {
[0x01] + waveformEffects + [0x00, 0x00, UInt8(min(max(loops, 1), 8) - 1)]
public static func notificationBuzz(loops: Int, effect: UInt8 = 47) -> [UInt8] {
// `effect` swaps the first waveform slot. The strap's haptic driver is a TI DRV2625 (it names it
// in its own log), whose ROM library has 47 = "Buzz 1 – 100%" and 48…51 = 80/60/40/20%. The
// second slot (152 = 0x80 | 24) is a 240 ms pause, kept as is. Only 47 is hardware-confirmed.
var effects = waveformEffects
effects[0] = effect
return [0x01] + effects + [0x00, 0x00, UInt8(min(max(loops, 1), 8) - 1)]
}
}

Expand Down
38 changes: 31 additions & 7 deletions Strand/AI/Providers/Custom.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,13 +47,37 @@ struct CustomClient: AIProviderClient {
var wire: [[String: Any]] = [["role": "system", "content": systemPrompt]]
for m in messages { wire.append(["role": m.role.rawValue, "content": m.content]) }

let body: [String: Any] = [
"model": model,
"messages": wire,
"temperature": 0.6,
"max_tokens": 4096,
"stream": true
]
// Same modern-params retry as `send`: the 400 arrives before any delta, so re-streaming is safe.
do {
try await streamChat(key: key, model: model, wire: wire, modernParams: false,
session: session, onDelta: onDelta)
} catch let AICoachError.server(code, detail) where code == 400 {
let d = detail.lowercased()
if d.contains("max_completion_tokens") || d.contains("max_tokens")
|| d.contains("temperature") || d.contains("unsupported") {
try await streamChat(key: key, model: model, wire: wire, modernParams: true,
session: session, onDelta: onDelta)
return
}
throw AICoachError.server(code, detail)
}
}

private func streamChat(
key: String,
model: String,
wire: [[String: Any]],
modernParams: Bool,
session: URLSession,
onDelta: (String) -> Void
) async throws {
var body: [String: Any] = ["model": model, "messages": wire, "stream": true]
if modernParams {
body["max_completion_tokens"] = 4096
} else {
body["temperature"] = 0.6
body["max_tokens"] = 4096
}

var req = URLRequest(url: AIProvider.custom.endpoint)
req.httpMethod = "POST"
Expand Down
32 changes: 30 additions & 2 deletions Strand/AI/Providers/OpenAI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,37 @@ struct OpenAIClient: AIProviderClient {
var wire: [[String: Any]] = [["role": "system", "content": systemPrompt]]
for m in messages { wire.append(["role": m.role.rawValue, "content": m.content]) }

// Same modern-params retry as `send`: the 400 arrives before any delta, so re-streaming is safe.
do {
try await streamChat(key: key, model: model, wire: wire, modernParams: false,
session: session, onDelta: onDelta)
} catch let AICoachError.server(code, detail) where code == 400 {
let d = detail.lowercased()
if d.contains("max_completion_tokens") || d.contains("max_tokens")
|| d.contains("temperature") || d.contains("unsupported") {
try await streamChat(key: key, model: model, wire: wire, modernParams: true,
session: session, onDelta: onDelta)
return
}
throw AICoachError.server(code, detail)
}
}

private func streamChat(
key: String,
model: String,
wire: [[String: Any]],
modernParams: Bool,
session: URLSession,
onDelta: (String) -> Void
) async throws {
var body: [String: Any] = ["model": model, "messages": wire, "stream": true]
body["temperature"] = 0.6
body["max_tokens"] = 4096
if modernParams {
body["max_completion_tokens"] = 4096
} else {
body["temperature"] = 0.6
body["max_tokens"] = 4096
}

var req = URLRequest(url: AIProvider.openAI.endpoint)
req.httpMethod = "POST"
Expand Down
29 changes: 29 additions & 0 deletions Strand/App/AppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,10 @@ final class AppModel: ObservableObject {
let live: LiveState
/// CoreBluetooth engine , scans, connects, bonds, streams.
let ble: BLEManager
#if os(iOS)
/// Buzzes the strap while an incoming call rings (Settings → Buzz strap for incoming calls).
private var callBuzz: CallBuzzMonitor?
#endif
/// Read model over the on-device store (dashboard + detail screens).
let repo: Repository
/// User profile (age/sex/body/HR-max) for zones, calories, baselines.
Expand Down Expand Up @@ -542,6 +546,22 @@ final class AppModel: ObservableObject {
try? await Task.sleep(nanoseconds: 1_800_000_000_000) // 30 min backstop (#836 battery)
}
}

#if os(iOS)
// Incoming-call wrist alert. Created at launch (including a Bluetooth state-restoration relaunch
// into the background) so it is watching before any screen opens.
let callBuzz = CallBuzzMonitor(
buzz: { [ble, live] in
guard live.connected else { return false }
ble.buzzCallAlert()
return true
},
log: { [live] line in live.append(log: line) })
self.callBuzz = callBuzz
ble.onInboundFrame = { [weak callBuzz] in callBuzz?.poll() }
// Keep the link free while a call buzzes, so a double-tap to silence it isn't queued behind a sync.
ble.holdStrapEventSync = { [weak callBuzz] in callBuzz?.isAlerting ?? false }
#endif
}

/// Build the device registry + source coordinator once the store is open, then start observing.
Expand Down Expand Up @@ -1794,6 +1814,15 @@ final class AppModel: ObservableObject {

private func handleDoubleTap() {
let now = Date()
#if os(iOS)
// A buzzing incoming-call alert claims the double-tap ahead of everything else (debounce, Lift
// Log, the configured action): two taps silence it. A tap with no call buzzing falls through.
if callBuzz?.dismissFromStrap() == true {
lastDoubleTapAt = now
live.append(log: Self.stamped("Double-tap → stop call buzz"))
return
}
#endif
let since = now.timeIntervalSince(lastDoubleTapAt)
guard since > 1.2 else { // debounce repeats
live.append(log: Self.stamped(String(format: "Double-tap ignored: %.1f s after the previous one (debounce 1.2 s)", since)))
Expand Down
38 changes: 34 additions & 4 deletions Strand/BLE/BLEManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -577,6 +577,13 @@ public final class BLEManager: NSObject, ObservableObject {
/// (`DeviceRegistry`, and the coordinator that watches it) follows too — the store write above is not
/// observable, so without this the UI would keep showing the old id until relaunch.
var onSerialIdentityAdopted: ((String) -> Void)?
/// Fired on every inbound notification. Each one is a moment iOS lets a backgrounded app run, so
/// the incoming-call alert uses it to re-check for a ringing call (`CallBuzzMonitor.poll`).
var onInboundFrame: (() -> Void)?
/// Asked before a strap-pushed EVENT kicks a history sync; true holds that sync off. AppModel points
/// it at the incoming-call alert: a sync started by the alert's own HAPTICS_FIRED events delayed the
/// strap's DOUBLE_TAP by ~1.5 s on hardware. Held syncs aren't lost; the next trigger or tick runs one.
var holdStrapEventSync: (() -> Bool)?
private var registryStore: DeviceRegistryStore?
/// #716: true once the seeded "WHOOP" model has been stamped to the correct family.
private var modelStamped = false
Expand Down Expand Up @@ -1380,7 +1387,10 @@ public final class BLEManager: NSObject, ObservableObject {
central = CBCentralManager(delegate: self, queue: .main)
#endif
// Strap-as-clock: an incoming EVENT packet kicks a rate-limited catch-up sync.
router.onSyncTrigger = { [weak self] in self?.requestSync(.strap) }
router.onSyncTrigger = { [weak self] in
guard let self, self.holdStrapEventSync?() != true else { return }
self.requestSync(.strap)
}
router.onStrapSerial = { [weak self] serial in self?.noteHarvardSerial(serial) } // #1193
// #78 hole-4: a paused-for-bond-loop strap gets one bounded salvage attempt per app-foreground.
installForegroundSalvageProbe()
Expand Down Expand Up @@ -1592,7 +1602,10 @@ public final class BLEManager: NSObject, ObservableObject {
central = CBCentralManager(delegate: self, queue: .main)
#endif
// Strap-as-clock: an incoming EVENT packet kicks a rate-limited catch-up sync.
router.onSyncTrigger = { [weak self] in self?.requestSync(.strap) }
router.onSyncTrigger = { [weak self] in
guard let self, self.holdStrapEventSync?() != true else { return }
self.requestSync(.strap)
}
router.onStrapSerial = { [weak self] serial in self?.noteHarvardSerial(serial) } // #1193
// #78 hole-4: a paused-for-bond-loop strap gets one bounded salvage attempt per app-foreground.
installForegroundSalvageProbe()
Expand Down Expand Up @@ -2279,7 +2292,8 @@ public final class BLEManager: NSObject, ObservableObject {
/// - writeType: BLE write type; defaults to `.withoutResponse` so all existing call
/// sites are unaffected. Pass `.withResponse` for acked commands (e.g. historicalDataResult).
public func send(_ command: WhoopCommand, payload: [UInt8] = [0x00],
writeType: CBCharacteristicWriteType = .withoutResponse) {
writeType: CBCharacteristicWriteType = .withoutResponse,
maverickEffect: UInt8 = 47) { // 5/MG buzz strength, see MaverickHaptics.notificationBuzz
// #314 parity: CoreBluetooth already covers both Android defects here — this `p.state == .connected`
// guard makes a write a no-op once the radio powers off (no DeadObjectException to crash on), and
// centralManagerDidUpdateState publishes state.connected = false on .poweredOff, so the iOS/macOS UI
Expand Down Expand Up @@ -2426,7 +2440,7 @@ public final class BLEManager: NSObject, ObservableObject {
// that asked for one. Kotlin twin: `WhoopBleClient.maverickHapticBody`.
let buzzLoops = payload.count >= 2 ? Int(payload[1]) : 1
let puffinPayload: [UInt8] = isHaptics
? MaverickHaptics.notificationBuzz(loops: buzzLoops) : payload
? MaverickHaptics.notificationBuzz(loops: buzzLoops, effect: maverickEffect) : payload
seq = seq &+ 1
let frame = puffinCommandFrame(cmd: puffinCmd, seq: seq, payload: puffinPayload)
p.writeValue(Data(frame), for: ch, type: writeType)
Expand Down Expand Up @@ -5592,6 +5606,21 @@ public final class BLEManager: NSObject, ObservableObject {
log("Buzz: one-shot fired (patternId=2 loops=3 + runAlarm, acked)")
}

/// Incoming-call wrist alert (`CallBuzzMonitor`). On a 5/MG this is the maverick notify buzz alone
/// (the same hardware-confirmed one-shot Haptic Clock uses), acknowledged so a backgrounded link
/// can't silently drop it, and without RUN_ALARM so a repeated call buzz never starts the alarm.
/// One notch softer than other buzzes: DRV2625 effect 48 ("Buzz 2 – 80%") instead of 47 (100%).
/// Other families take the full one-shot sequence, since a bare pattern write was seen ignored on a
/// WHOOP 4.0 (#921).
func buzzCallAlert() {
guard selectedModel.deviceFamily == .whoop5 else {
buzzStrapOnce()
return
}
// 2 pulses (~1.3 s): short, so little of a burst already sent plays on after a double-tap.
send(.runHapticsPattern, payload: [2, 2, 0, 0, 0], writeType: .withResponse, maverickEffect: 48)
}

/// Haptic Clock (#460): buzz the current wall-clock time out on the strap so the user can read it
/// off their wrist without a screen. The pure, unit-tested `HapticClock` encoder turns now into an
/// ordered pulse list (long = a "ten", short = a "unit", in HH-tens / HH-units / MM-tens / MM-units
Expand Down Expand Up @@ -7313,6 +7342,7 @@ extension BLEManager: @preconcurrency CBPeripheralDelegate {
guard let data = characteristic.value else { return }
let bytes = [UInt8](data)
lastDataAt = Date() // feed the liveness watchdog on every notification
onInboundFrame?()
// #1809: count BEFORE the per-characteristic switch below, so the tally covers every inbound
// frame including ones no branch consumes - the epitaph must answer "did anything arrive at all".
inboundFrames += 1
Expand Down
23 changes: 23 additions & 0 deletions Strand/Screens/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,10 @@ struct SettingsView: View {
/// sustained-elevated window and offers — via a single dismissible card — to save it as a workout.
/// Nothing is ever created automatically. Mirrors the Android `NoopPrefs.KEY_AUTO_DETECT_WORKOUTS`.
@AppStorage(PuffinExperiment.autoDetectWorkoutsKey) private var autoDetectWorkoutsEnabled = false
#if os(iOS)
/// Buzz the strap while an incoming call rings (`CallBuzzMonitor`). Default off.
@AppStorage(CallBuzzMonitor.enabledKey) private var callBuzzEnabled = false
#endif

/// "Journal reminder" (#627, default ON). When ON, Today shows the persistent journal widget
/// (last-7-days strip + tap-through). Mirrors the Android `NoopPrefs.KEY_JOURNAL_REMINDER_ENABLED`.
Expand Down Expand Up @@ -1716,6 +1720,25 @@ struct SettingsView: View {
.font(StrandFont.caption)
.foregroundStyle(StrandPalette.textTertiary)
.fixedSize(horizontal: false, vertical: true)
#if os(iOS)
rowDivider
Toggle(isOn: $callBuzzEnabled) {
Text("Buzz strap for incoming calls")
.font(StrandFont.subhead)
.foregroundStyle(StrandPalette.textPrimary)
}
.toggleStyle(.switch)
.tint(StrandPalette.accent)
.accessibilityHint("Buzzes your strap while a call is ringing")
Text("Buzzes your strap while a call rings: phone calls, FaceTime, and apps that ring like a phone call, such as WhatsApp voice and video. Keeps buzzing until you answer or decline, or the call ends. Double-tap the strap to stop the buzzing (the call itself keeps ringing on your phone). NOOP only sees that a call is ringing, never who is calling.")
.font(StrandFont.caption)
.foregroundStyle(StrandPalette.textTertiary)
.fixedSize(horizontal: false, vertical: true)
Text("iOS only lets NOOP react while it is running. With the app in the background it relies on the strap waking it, so turn on Continuous HRV capture (and turn off Overnight only) for alerts all day. Does not work after NOOP is swiped away in the app switcher.")
.font(StrandFont.caption)
.foregroundStyle(StrandPalette.textTertiary)
.fixedSize(horizontal: false, vertical: true)
#endif

rowDivider

Expand Down
Loading