From e971c5832047a014c4a23157c828f06c66c039f8 Mon Sep 17 00:00:00 2001 From: JohanStefanes Date: Thu, 15 Oct 2026 19:48:57 +0200 Subject: [PATCH] fix(ble): Apple one-shot buzz on WHOOP 5/MG no longer sends RUN_ALARM On a 5/MG, BLEManager.buzzStrapOnce sent RUN_ALARM REVISION_2 [0x02, 0x01] after the maverick 0x13 buzz. Per docs/PROTOCOL_ALARMS.md, RUN [2, ID] runs the alarm stored in that slot and then clears it, so pressing "Buzz strap" (or running the Buzz Strap shortcut) could fire and remove an armed wake alarm in slot 1. The maverick buzz is hardware-confirmed on its own (#926), and Android already stops there on a 5/MG. This removes one write and adds none. The WHOOP 4.0 sequence (79 + 68 [0x01]) is unchanged. The golden test now pins only the buzz frame for 5/MG; the runAlarmRev2 encoder stays pinned in the parity goldens. Refs #2796, Refs #921, Refs #926 Co-Authored-By: Claude Opus 5.5 --- .../DeviceFamilyFramingTests.swift | 13 +++++-------- Strand/App/AppModel.swift | 9 +++++---- Strand/BLE/BLEManager.swift | 13 +++++++++---- 3 files changed, 19 insertions(+), 16 deletions(-) diff --git a/Packages/WhoopProtocol/Tests/WhoopProtocolTests/DeviceFamilyFramingTests.swift b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/DeviceFamilyFramingTests.swift index 5191638817..a6390ec5b9 100644 --- a/Packages/WhoopProtocol/Tests/WhoopProtocolTests/DeviceFamilyFramingTests.swift +++ b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/DeviceFamilyFramingTests.swift @@ -357,17 +357,14 @@ final class DeviceFamilyFramingTests: XCTestCase { } func testPuffinOneShotBuzzSequenceGoldens() { - // #921 one-shot buzz (BLEManager.buzzStrapOnce): on a 5/MG the confirmed sequence is the - // maverick 0x13 notify buzz followed by RUN_ALARM(68) REVISION_2, on consecutive seq bytes. - // Golden hexes generated independently (Python: zlib CRC-32, CRC16-Modbus) and cross-checked - // against the Android FramingTest vectors, so a drift in either frame fails here. (Android - // stops at the maverick buzz on a 5/MG: its allow-list excludes RUN_ALARM for that family.) + // #921 one-shot buzz (BLEManager.buzzStrapOnce): on a 5/MG the sequence is the maverick 0x13 + // notify buzz alone, as on Android. RUN_ALARM(68) REVISION_2 is deliberately not sent: it runs + // and clears the alarm stored in its slot (docs/PROTOCOL_ALARMS.md). Its encoder stays pinned + // in testPuffinAlarmFramesMatchKotlinParityGoldens. Golden hex generated independently + // (Python: zlib CRC-32, CRC16-Modbus) and cross-checked against the Android FramingTest vectors. let buzz = puffinCommandFrame(cmd: 0x13, seq: 1, payload: [0x01, 47, 152, 0, 0, 0, 0, 0, 0, 0, 0, 0]) XCTAssertEqual(buzz, Self.hex("aa0114000001e1e1230113012f980000000000000000000098cb83a5")) - let runAlarm = puffinCommandFrame(cmd: 68, seq: 2, payload: AlarmPayload.runAlarmRev2()) - XCTAssertEqual(runAlarm, Self.hex("aa010c000001e74123024402010000008ad7f1d3")) XCTAssertTrue(verifyFrame(buzz, family: .whoop5).ok) - XCTAssertTrue(verifyFrame(runAlarm, family: .whoop5).ok) } } diff --git a/Strand/App/AppModel.swift b/Strand/App/AppModel.swift index b477b8b992..a57ff3335a 100644 --- a/Strand/App/AppModel.swift +++ b/Strand/App/AppModel.swift @@ -1452,10 +1452,11 @@ final class AppModel: ObservableObject { if HapticPrefs.enabled(gate) { buzz(loops: loops) } } - /// One-shot user buzz (#921): the on-device-confirmed pattern (patternId=2, 3 loops) followed by - /// RUN_ALARM, both written acknowledged. A bare RUN_HAPTICS_PATTERN write can be silently ignored - /// (WHOOP 4.0 via the Siri shortcut) or dropped unacked on a busy link, so the Live "Buzz strap" - /// button and the Buzz Strap App Intent both route through this single sequence. + /// One-shot user buzz (#921): the on-device-confirmed pattern (patternId=2, 3 loops), followed on + /// a WHOOP 4.0 by RUN_ALARM, all written acknowledged. A bare RUN_HAPTICS_PATTERN write can be + /// silently ignored (WHOOP 4.0 via the Siri shortcut) or dropped unacked on a busy link, so the + /// Live "Buzz strap" button and the Buzz Strap App Intent both route through this single sequence. + /// On a 5/MG it is the maverick buzz alone (RUN_ALARM there would run and clear a stored alarm). func buzzStrapOnce() { ble.buzzStrapOnce() } diff --git a/Strand/BLE/BLEManager.swift b/Strand/BLE/BLEManager.swift index 690e394194..8e01b38879 100644 --- a/Strand/BLE/BLEManager.swift +++ b/Strand/BLE/BLEManager.swift @@ -5573,8 +5573,13 @@ public final class BLEManager: NSObject, ObservableObject { /// /// Both writes are ACKNOWLEDGED (.withResponse): a backgrounded shortcut on a busy link can /// silently drop an unacknowledged write, which is how #921 logged "Run Haptics" with no - /// vibration. On a 5/MG, send() remaps cmd 79 to the maverick 0x13 notify buzz and RUN_ALARM - /// carries the REVISION_2 body; both are already on the 5/MG command allowlist. + /// vibration. + /// + /// On a 5/MG, send() remaps cmd 79 to the maverick 0x13 notify buzz, which is hardware-confirmed + /// to buzz on its own (#926), and nothing else is sent. RUN_ALARM is NOT a buzz helper there: + /// its REVISION_2 form `[2, ID]` runs the alarm stored in that slot and then clears it + /// (docs/PROTOCOL_ALARMS.md), so pairing it with a one-shot buzz would fire and remove an armed + /// wake alarm in slot 1. This matches the Android twin (`WhoopBleClient.buzzStrapOnce`). /// /// Alternative waveform form (12-byte): /// [wfe1=47, wfe2=152, 0,0,0,0,0,0, loop u16=0, overall_loop=7, dur=30] @@ -5584,8 +5589,8 @@ public final class BLEManager: NSObject, ObservableObject { func buzzStrapOnce() { send(.runHapticsPattern, payload: [2, 3, 0, 0, 0], writeType: .withResponse) // patternId=2, 3 loops (5/MG: send() remaps to the maverick notify buzz) if selectedModel.deviceFamily == .whoop5 { - send(.runAlarm, payload: AlarmPayload.runAlarmRev2(), writeType: .withResponse) // REVISION_2 [0x02, alarmId] - log("Buzz: one-shot fired (5/MG maverick buzz + runAlarm rev2, acked)") + // No RUN_ALARM on a 5/MG: RUN [2, ID] runs and clears the stored alarm in that slot. + log("Buzz: one-shot fired (5/MG maverick buzz, acked)") return } send(.runAlarm, payload: [0x01], writeType: .withResponse)