Skip to content
Merged
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
114 changes: 114 additions & 0 deletions Packages/WhoopProtocol/Sources/WhoopProtocol/DeviceFamily.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,120 @@ public enum HeaderCRCKind: String, Sendable, CaseIterable {
case crc16Modbus
}

/// WHOOP custom GATT service families visible in advertisements.
///
/// Only `.whoop4` and `.maverickGooseFD4B` are connectable in NOOP today. The other services are
/// protocol facts from reverse engineering and are diagnostic-only until their connection framing is
/// mapped and hardware-tested. `.puffin1150` is intentionally qualified because NOOP already uses
/// "puffin" for the fd4b/Maverick-Goose packet framing.
public enum WhoopGattServiceFamily: String, Sendable, CaseIterable {
case whoop4
case maverickGooseFD4B
case puffin1150
case monument
case symphony

public var displayName: String {
switch self {
case .whoop4: return "WHOOP 4.0"
case .maverickGooseFD4B: return "WHOOP 5.0 / MG fd4b (Maverick/Goose)"
case .puffin1150: return "WHOOP PUFFIN service 1150"
case .monument: return "WHOOP MONUMENT"
case .symphony: return "WHOOP SYMPHONY"
}
}

public var serviceUUIDString: String {
switch self {
case .whoop4: return "61080001-8d6d-82b8-614a-1c8cb0f8dcc6"
case .maverickGooseFD4B: return "fd4b0001-cce1-4033-93ce-002d5875f58a"
case .puffin1150: return "11500001-6215-11ee-8c99-0242ac120002"
case .monument: return "8a580001-2fe8-4796-9267-b87a2b0c8234"
case .symphony: return "59830001-5955-419b-bb8d-c8262926af23"
}
}

public var characteristicUUIDStrings: [String] {
switch self {
case .whoop4: return DeviceFamily.whoop4.characteristicUUIDStrings
case .maverickGooseFD4B: return DeviceFamily.whoop5.characteristicUUIDStrings
case .puffin1150:
return Self.unsupportedCharacteristicUUIDStrings(
prefix: "1150", suffix: "6215-11ee-8c99-0242ac120002")
case .monument:
return Self.unsupportedCharacteristicUUIDStrings(
prefix: "8a58", suffix: "2fe8-4796-9267-b87a2b0c8234")
case .symphony:
return Self.unsupportedCharacteristicUUIDStrings(
prefix: "5983", suffix: "5955-419b-bb8d-c8262926af23")
}
}

public var connectableDeviceFamily: DeviceFamily? {
switch self {
case .whoop4: return .whoop4
case .maverickGooseFD4B: return .whoop5
case .puffin1150, .monument, .symphony: return nil
}
}

public var isConnectable: Bool { connectableDeviceFamily != nil }

public var diagnosticUnsupportedMessage: String {
"\(displayName) detected but unsupported; NOOP will not connect or send commands."
}

public static var unsupportedFamilies: [WhoopGattServiceFamily] {
allCases.filter { !$0.isConnectable }
}

public static var unsupportedServiceUUIDStrings: [String] {
unsupportedFamilies.map(\.serviceUUIDString)
}

public static func forServiceUUIDString(_ uuid: String?) -> WhoopGattServiceFamily? {
guard let normalized = uuid?.lowercased() else { return nil }
return allCases.first { $0.serviceUUIDString == normalized }
}

public static func firstUnsupported(in serviceUUIDStrings: [String]) -> WhoopGattServiceFamily? {
serviceUUIDStrings.compactMap { forServiceUUIDString($0) }.first { !$0.isConnectable }
}

private static func unsupportedCharacteristicUUIDStrings(prefix: String, suffix: String) -> [String] {
["0002", "0003", "0004", "0005", "0007"].map { "\(prefix)\($0)-\(suffix)" }
}
}

public struct WhoopGattScanDecision: Equatable, Sendable {
public var shouldConnect: Bool
public var unsupportedFamily: WhoopGattServiceFamily?

public init(shouldConnect: Bool, unsupportedFamily: WhoopGattServiceFamily? = nil) {
self.shouldConnect = shouldConnect
self.unsupportedFamily = unsupportedFamily
}
}

/// Decide whether an advertisement found by the broadened diagnostic scan should enter GATT.
///
/// Empty service lists preserve the pre-diagnostic behaviour because some platform callbacks omit the
/// advertised service UUIDs even though the service-filtered scan matched. When services are present,
/// only the selected connectable service may connect; unsupported families are reported for logging.
public func whoopGattScanDecision(
selectedServiceUUIDString: String,
advertisedServiceUUIDStrings: [String]
) -> WhoopGattScanDecision {
let advertised = Set(advertisedServiceUUIDStrings.map { $0.lowercased() })
if advertised.isEmpty || advertised.contains(selectedServiceUUIDString.lowercased()) {
return WhoopGattScanDecision(shouldConnect: true)
}
return WhoopGattScanDecision(
shouldConnect: false,
unsupportedFamily: WhoopGattServiceFamily.firstUnsupported(in: Array(advertised))
)
}

public extension DeviceFamily {
/// Resolve a device-registry `model` label to the strap family that wrote its rows (#171).
///
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,60 @@ final class DeviceFamilyFramingTests: XCTestCase {
XCTAssertEqual(DeviceFamily.allCases, [.whoop4, .whoop5])
}

func testDiagnosticGattFamiliesAreMetadataOnly() {
XCTAssertEqual(WhoopGattServiceFamily.unsupportedServiceUUIDStrings, [
"11500001-6215-11ee-8c99-0242ac120002",
"8a580001-2fe8-4796-9267-b87a2b0c8234",
"59830001-5955-419b-bb8d-c8262926af23",
])

for family in WhoopGattServiceFamily.unsupportedFamilies {
XCTAssertFalse(family.isConnectable)
XCTAssertNil(family.connectableDeviceFamily)
XCTAssertTrue(family.diagnosticUnsupportedMessage.contains("will not connect or send commands"))
XCTAssertEqual(family.characteristicUUIDStrings.count, 5)
}
}

func testSupportedGattFamiliesRemainTheOnlyConnectableFamilies() {
XCTAssertEqual(WhoopGattServiceFamily.whoop4.connectableDeviceFamily, .whoop4)
XCTAssertEqual(WhoopGattServiceFamily.maverickGooseFD4B.connectableDeviceFamily, .whoop5)
XCTAssertEqual(
WhoopGattServiceFamily.maverickGooseFD4B.serviceUUIDString,
"fd4b0001-cce1-4033-93ce-002d5875f58a"
)
}

func testUnsupportedAdvertisementsDoNotConnect() {
let decision = whoopGattScanDecision(
selectedServiceUUIDString: DeviceFamily.whoop5.serviceUUIDString,
advertisedServiceUUIDStrings: ["8A580001-2FE8-4796-9267-B87A2B0C8234"]
)

XCTAssertFalse(decision.shouldConnect)
XCTAssertEqual(decision.unsupportedFamily, .monument)
}

func testSelectedServiceAdvertisementsStillConnect() {
let decision = whoopGattScanDecision(
selectedServiceUUIDString: DeviceFamily.whoop4.serviceUUIDString,
advertisedServiceUUIDStrings: [DeviceFamily.whoop4.serviceUUIDString]
)

XCTAssertTrue(decision.shouldConnect)
XCTAssertNil(decision.unsupportedFamily)
}

func testEmptyAdvertisementServiceListPreservesLegacyConnectPath() {
let decision = whoopGattScanDecision(
selectedServiceUUIDString: DeviceFamily.whoop4.serviceUUIDString,
advertisedServiceUUIDStrings: []
)

XCTAssertTrue(decision.shouldConnect)
XCTAssertNil(decision.unsupportedFamily)
}

// MARK: - Puffin packet type names

func testPuffinTypeNamesAliased() {
Expand Down
18 changes: 17 additions & 1 deletion Strand/BLE/BLEManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2719,8 +2719,10 @@ public final class BLEManager: NSObject, ObservableObject {
configureCollectorFamily()
central.stopScan()
log("Scanning for \(model.displayName)…")
let diagnosticServices = [model.scanService] + WhoopGattServiceFamily.unsupportedServiceUUIDStrings
.map { CBUUID(string: $0) }
central.scanForPeripherals(
withServices: [model.scanService],
withServices: diagnosticServices,
options: [CBCentralManagerScanOptionAllowDuplicatesKey: false]
)
guard allowFallback else { return }
Expand Down Expand Up @@ -3176,6 +3178,20 @@ extension BLEManager: @preconcurrency CBCentralManagerDelegate {
advertisementData: [String: Any],
rssi RSSI: NSNumber) {
let name = (advertisementData[CBAdvertisementDataLocalNameKey] as? String) ?? peripheral.name ?? "unknown"
let advertisedServiceUUIDs = (advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID] ?? [])
.map { $0.uuidString.lowercased() }
let scanDecision = whoopGattScanDecision(
selectedServiceUUIDString: selectedModel.scanService.uuidString,
advertisedServiceUUIDStrings: advertisedServiceUUIDs
)
if !scanDecision.shouldConnect {
if let family = scanDecision.unsupportedFamily {
log("Discovered \(name) (rssi \(RSSI)) — \(family.diagnosticUnsupportedMessage)")
return
}
log("Discovered \(name) (rssi \(RSSI)) without \(selectedModel.displayName) service — ignoring")
return
}
// Multi-WHOOP present-scan (Add-a-WHOOP wizard): collect the strap, do NOT auto-connect, and
// return before touching the connect flow. Only reachable when the wizard explicitly turned on
// `isPresentingScan` via scanForWhoops(); on the default path (false) this branch is skipped
Expand Down
28 changes: 23 additions & 5 deletions android/app/src/main/java/com/noop/ble/WhoopBleClient.kt
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ import com.noop.protocol.RebootProbeVariant
import com.noop.protocol.Streams
import com.noop.protocol.Whoop5Config
import com.noop.protocol.extractStreams
import com.noop.protocol.WhoopGattServiceFamily
import com.noop.protocol.whoopGattScanDecision
import com.noop.analytics.Baselines
import com.noop.analytics.BatterySocLine
import com.noop.analytics.IntelligenceEngine
Expand Down Expand Up @@ -2236,11 +2238,14 @@ class WhoopBleClient(
_state.update { it.copy(scanning = false, statusNote = "Bluetooth isn't ready yet. Try again in a moment.") }
return
}
// Filter to the strap we're targeting — a single service, so a WHOOP 4.0
// scan never lingers on a WHOOP 5/MG wrist (or the reverse).
val filters = listOf(
ScanFilter.Builder().setServiceUuid(ParcelUuid(model.service)).build(),
)
// Filter to the strap we're targeting plus diagnostic-only WHOOP service families. The callback
// explicitly refuses unsupported families before any persist/connect path, so this broadens
// visibility without routing unknown framing into GATT.
val filters = (listOf(model.service.toString()) + WhoopGattServiceFamily.unsupportedServiceUuidStrings)
.distinct()
.map { uuid ->
ScanFilter.Builder().setServiceUuid(ParcelUuid(UUID.fromString(uuid))).build()
}
// LOW_LATENCY for a snappy first connect, mirroring the desktop app's eager scan — but PR #588:
// a SUSTAINED involuntary-reconnect streak ([failedReconnectAttempts] past the threshold) drops to
// the lower-power BALANCED mode so an out-of-range strap stops pinning the radio at full power. A
Expand Down Expand Up @@ -3254,6 +3259,19 @@ class WhoopBleClient(
override fun onScanResult(callbackType: Int, result: ScanResult) {
val device: BluetoothDevice = result.device
val name = result.scanRecord?.deviceName ?: device.name ?: "unknown"
val advertisedServiceUuids = result.scanRecord?.serviceUuids
?.map { it.uuid.toString().lowercase() }
.orEmpty()
val scanDecision = whoopGattScanDecision(selectedModel.service.toString(), advertisedServiceUuids)
if (!scanDecision.shouldConnect) {
scanDecision.unsupportedFamily?.let { family ->
log("Discovered $name (rssi ${result.rssi}) — ${family.diagnosticUnsupportedMessage}")
_state.update { it.copy(statusNote = family.diagnosticUnsupportedMessage) }
return
}
log("Discovered $name (rssi ${result.rssi}) without ${selectedModel.displayName} service — ignoring")
return
}
// Multi-WHOOP present-scan (Add-a-device wizard, MW-4): accumulate the strap, do NOT
// auto-connect, and return before touching the connect flow. Only reachable when the wizard
// turned on [scanningForList] via scanForWhoops(); on the default path this branch is skipped
Expand Down
104 changes: 104 additions & 0 deletions android/app/src/main/java/com/noop/protocol/DeviceFamily.kt
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,110 @@ enum class DeviceFamily {
}
}

/**
* WHOOP custom GATT service families visible in advertisements.
*
* Only `WHOOP4` and `MAVERICK_GOOSE_FD4B` are connectable in NOOP today. The other services are
* protocol facts from reverse engineering and are diagnostic-only until their connection framing is
* mapped and hardware-tested. In particular, [PUFFIN_1150] is intentionally named with its UUID
* prefix because NOOP already uses "puffin" for the fd4b/Maverick-Goose packet framing.
*/
private fun unsupportedWhoopCharacteristicUuidStrings(prefix: String, suffix: String): List<String> =
listOf("0002", "0003", "0004", "0005", "0007").map { "$prefix$it-$suffix" }

enum class WhoopGattServiceFamily(
val displayName: String,
val serviceUuidString: String,
val characteristicUuidStrings: List<String>,
val connectableDeviceFamily: DeviceFamily?,
) {
WHOOP4(
displayName = "WHOOP 4.0",
serviceUuidString = "61080001-8d6d-82b8-614a-1c8cb0f8dcc6",
characteristicUuidStrings = DeviceFamily.WHOOP4.characteristicUuidStrings,
connectableDeviceFamily = DeviceFamily.WHOOP4,
),
MAVERICK_GOOSE_FD4B(
displayName = "WHOOP 5.0 / MG fd4b (Maverick/Goose)",
serviceUuidString = "fd4b0001-cce1-4033-93ce-002d5875f58a",
characteristicUuidStrings = DeviceFamily.WHOOP5.characteristicUuidStrings,
connectableDeviceFamily = DeviceFamily.WHOOP5,
),
PUFFIN_1150(
displayName = "WHOOP PUFFIN service 1150",
serviceUuidString = "11500001-6215-11ee-8c99-0242ac120002",
characteristicUuidStrings = unsupportedWhoopCharacteristicUuidStrings(
"1150",
"6215-11ee-8c99-0242ac120002",
),
connectableDeviceFamily = null,
),
MONUMENT(
displayName = "WHOOP MONUMENT",
serviceUuidString = "8a580001-2fe8-4796-9267-b87a2b0c8234",
characteristicUuidStrings = unsupportedWhoopCharacteristicUuidStrings(
"8a58",
"2fe8-4796-9267-b87a2b0c8234",
),
connectableDeviceFamily = null,
),
SYMPHONY(
displayName = "WHOOP SYMPHONY",
serviceUuidString = "59830001-5955-419b-bb8d-c8262926af23",
characteristicUuidStrings = unsupportedWhoopCharacteristicUuidStrings(
"5983",
"5955-419b-bb8d-c8262926af23",
),
connectableDeviceFamily = null,
);

val isConnectable: Boolean get() = connectableDeviceFamily != null

val diagnosticUnsupportedMessage: String
get() = "$displayName detected but unsupported; NOOP will not connect or send commands."

companion object {
val unsupportedFamilies: List<WhoopGattServiceFamily> =
entries.filter { !it.isConnectable }

val unsupportedServiceUuidStrings: List<String> =
unsupportedFamilies.map { it.serviceUuidString }

fun forServiceUuidString(uuid: String?): WhoopGattServiceFamily? {
val normalized = uuid?.lowercase() ?: return null
return entries.firstOrNull { it.serviceUuidString == normalized }
}

fun firstUnsupportedIn(serviceUuidStrings: Iterable<String>): WhoopGattServiceFamily? =
serviceUuidStrings.mapNotNull { forServiceUuidString(it) }
.firstOrNull { !it.isConnectable }
}
}

data class WhoopGattScanDecision(
val shouldConnect: Boolean,
val unsupportedFamily: WhoopGattServiceFamily? = null,
)

/**
* Decide whether an advertisement found by the broadened diagnostic scan should enter GATT.
*
* Empty service lists preserve the pre-diagnostic behaviour because some platform callbacks omit the
* advertised service UUIDs even though the service-filtered scan matched. When services are present,
* only the selected connectable service may connect; unsupported families are reported for logging.
*/
fun whoopGattScanDecision(
selectedServiceUuidString: String,
advertisedServiceUuidStrings: Iterable<String>,
): WhoopGattScanDecision {
val advertised = advertisedServiceUuidStrings.map { it.lowercase() }.toSet()
if (advertised.isEmpty() || selectedServiceUuidString.lowercase() in advertised) {
return WhoopGattScanDecision(shouldConnect = true)
}
val unsupported = WhoopGattServiceFamily.firstUnsupportedIn(advertised)
return WhoopGattScanDecision(shouldConnect = false, unsupportedFamily = unsupported)
}

/**
* Whoop 5.0 "puffin" packet types mirror existing 4.0 types on the new transport. These map onto
* the canonical base type names so they decode like their 4.0 counterparts instead of falling
Expand Down
Loading
Loading