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
3 changes: 3 additions & 0 deletions Strand/App/StrandApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ struct StrandApp: App {
// #1008: pin the pre-change Overnight-only default for existing installs before
// anything reads it. Idempotent; a no-op on fresh installs and after the first launch.
PuffinExperiment.migrateContinuousHrvOvernightDefault()
// Mirror mode: swap in the iPhone's newest backup BEFORE `model` (and with it the store) is
// created below; the restore replaces the SQLite file. A no-op when mirror mode is off.
MirrorMode.restoreNewestOnLaunch()
#if DEBUG
// DEBUG-only promo-screenshot harness: when launched with `--demo-hour <Int>`, pin the Today
// screen to that hour's day-cycle scene + a plausible per-hour stat frame. Runs synchronously
Expand Down
58 changes: 54 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 @@ -1327,6 +1334,8 @@ public final class BLEManager: NSObject, ObservableObject {
/// The last entry point `whoopConnectAllowed` turned away, so the block is logged once per path
/// rather than on every tick of the family-rotation timer.
private var lastBlockedConnectReason: String?
/// Mirror mode's refusal is logged once per launch (see `mirrorModeRefuses`).
private var mirrorRefusalLogged = false

/// #1635: one `ScanAdvertisementSummary` line per SCAN, not per process — the question it answers
/// is only visible by comparing a scan before the strap was put in pairing mode against one after,
Expand Down Expand Up @@ -1380,7 +1389,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 +1604,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 @@ -1652,6 +1667,7 @@ public final class BLEManager: NSObject, ObservableObject {
}

private func connectCore(model: WhoopModel) {
guard !mirrorModeRefuses("connect") else { return }
intentionalDisconnect = false
// Connection test mode: stamp when this connect attempt began so didConnect can report the connect
// latency. A plain Date() assignment, no behaviour change; only read behind the .connection gate.
Expand Down Expand Up @@ -2086,6 +2102,19 @@ public final class BLEManager: NSObject, ObservableObject {
: "WHOOP is no longer the active device — not scanning or connecting until it is again (#1881)")
}

/// Mirror mode (Mac) refuses every scan and connect: this app shows a copy of the iPhone's data and
/// must never take the strap's single bond away from the phone. Checked by `whoopConnectAllowed` and
/// by the user-initiated entry points that bypass it (`connectCore`, `scanForWhoops`, `startScan`,
/// the discovery connect). Logged once per launch.
private func mirrorModeRefuses(_ reason: String) -> Bool {
guard MirrorMode.isEnabled else { return false }
if !mirrorRefusalLogged {
mirrorRefusalLogged = true
log("Mirror mode: \(reason) refused. This Mac mirrors the iPhone's data and never connects to the strap.")
}
return true
}

/// The one gate every WHOOP scan/connect entry point passes through (#1881).
///
/// Deliberately at the CONNECT sites rather than only at `connectCore`: there are five
Expand All @@ -2098,6 +2127,7 @@ public final class BLEManager: NSObject, ObservableObject {
/// says which path tried. Logged only on the transition into blocking, so a rotation timer cannot
/// flood the log.
private func whoopConnectAllowed(_ reason: String) -> Bool {
if mirrorModeRefuses(reason) { return false }
if whoopIsActiveDevice { return true }
// The flag is a CACHE of a registry fact, and the registry is the authority. Re-validate before
// refusing, so the gate can never latch: it is set from the coordinator's stop/start closures and
Expand Down Expand Up @@ -2153,6 +2183,7 @@ public final class BLEManager: NSObject, ObservableObject {
/// The wizard MUST call `stopWhoopScan()` before any normal connect resumes — this mode owns the
/// central while active. No-op-to-the-connect-path: it never touches `peripheral`/bond state.
public func scanForWhoops() {
guard !mirrorModeRefuses("add-a-WHOOP scan") else { return }
guard central.state == .poweredOn else {
log("Add-a-WHOOP scan: Bluetooth not powered on (state=\(central.state.rawValue))")
return
Expand Down Expand Up @@ -2279,7 +2310,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 +2458,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 @@ -5094,6 +5126,7 @@ public final class BLEManager: NSObject, ObservableObject {
/// from the already-gated `connectFromSystem` — and this method's own family-rotation timer, which
/// cannot start a scan that one of those did not. Gating here as well would block the user's Connect.
private func startScan(for model: WhoopModel, allowFallback: Bool) {
guard !mirrorModeRefuses("scan") else { return }
advertisementLogged = false
cancelScanFallback()
selectedModel = model
Expand Down Expand Up @@ -5592,6 +5625,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 @@ -5939,6 +5987,7 @@ extension BLEManager: @preconcurrency CBCentralManagerDelegate {
}
// No gate here for the same reason as `startScan`: reaching a discovery means a scan is running,
// and a scan only runs because the user asked or because the gated system entry allowed it.
guard !mirrorModeRefuses("discovery connect") else { central.stopScan(); return }
cancelScanFallback()
persistSelectedModel(selectedModel)
log("Discovered \(safeName) (rssi \(RSSI)) — connecting")
Expand Down Expand Up @@ -7313,6 +7362,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
3 changes: 3 additions & 0 deletions Strand/BLE/SourceCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,9 @@ final class SourceCoordinator: ObservableObject {
/// default preferred peripheral (nil) — no scan/disconnect churn. The connected-uuid sink drives
/// first-connect identity adoption.
func start() {
// Mirror mode (Mac): never start any live source. The mirrored registry lists the iPhone's
// devices, and connecting to them would take the strap's single bond away from the phone.
guard !MirrorMode.isEnabled else { return }
registry.$activeDeviceId
.removeDuplicates()
.sink { [weak self] id in self?.activeDeviceChanged(to: id) }
Expand Down
Loading