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)