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
52 changes: 34 additions & 18 deletions Strand/BLE/BLEManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1056,6 +1056,9 @@ public final class BLEManager: NSObject, ObservableObject {

private var disSerial: String?
private var disHwRev: String?
/// DIS 0x2A24, the strap's own model number — the authoritative variant signal (#520). A real MG
/// reports "MG" here while its serial prefix and hardware revision match neither heuristic.
private var disModelNumber: String?
/// #1635 follow-up: the DIS identity extras (firmware, manufacturer, model, software revision) as
/// discovered. Held as a list rather than one property each because nothing branches on them
/// individually — they are read as a set and reported as a set.
Expand Down Expand Up @@ -1298,7 +1301,7 @@ public final class BLEManager: NSObject, ObservableObject {
/// off until the hardware actually attests to itself.
var whoop5Variant: Whoop5Variant {
guard selectedModel.deviceFamily == .whoop5 else { return .unknown }
return Whoop5Variant.from(serial: disSerial, hardwareRevision: disHwRev)
return Whoop5Variant.from(serial: disSerial, hardwareRevision: disHwRev, modelNumber: disModelNumber)
}

/// True only for a POSITIVELY identified WHOOP MG — the one variant with ECG electrodes. Gates the
Expand Down Expand Up @@ -5081,6 +5084,7 @@ public final class BLEManager: NSObject, ObservableObject {
disRead = false
disSerial = nil
disHwRev = nil
disModelNumber = nil
disFirmware = nil
whoop5NotifyCharacteristics.removeAll()
}
Expand Down Expand Up @@ -5293,7 +5297,7 @@ public final class BLEManager: NSObject, ObservableObject {
/// The serial is a device identifier, so ONLY its 3-character prefix is logged (that is the entire
/// information content here) — never the full string, which would land in a shareable strap log.
private func noteWhoop5VariantFromDIS() {
let variant = Whoop5Variant.from(serial: disSerial, hardwareRevision: disHwRev)
let variant = Whoop5Variant.from(serial: disSerial, hardwareRevision: disHwRev, modelNumber: disModelNumber)
let prefix = (disSerial?.trimmingCharacters(in: .whitespacesAndNewlines).uppercased())
.map { String($0.prefix(3)) } ?? "?"
log("DIS: serialPrefix=\(prefix) hwRev=\(disHwRev ?? "?") -> variant=\(variant.label)")
Expand Down Expand Up @@ -5385,10 +5389,9 @@ public final class BLEManager: NSObject, ObservableObject {
// alongside a 5/MG and leaves the 4.0 active — at which point relabelling by status rewrites the
// 4.0's row as "WHOOP 5.0 / MG" on the strength of the OTHER strap's DIS block.
//
// Unreachable today, because `Whoop5Variant.from` here is never given a model number and a real MG
// reports a serial prefix and hardware revision matching neither heuristic — so this returns on
// `.unknown` every time. Fixed anyway: the Android twin's identical bug went live the moment its
// resolver was widened, and twinning the DIS model-number read would do exactly that here (#520).
// Live since the DIS model-number read was twinned from Android (#520): a real MG reports a serial
// prefix and hardware revision matching neither heuristic, so only its 2A24 "MG" resolves it, and
// that is exactly the path on which attributing to the wrong row would corrupt a 4.0 alongside it.
let devices = (try? rs.all())?.filter { $0.status != .archived } ?? []
let byId = devices.first { $0.peripheralId?.caseInsensitiveCompare(attestingId) == .orderedSame }
// The sole non-archived device is not a guess: there is nothing else the attestation could have
Expand Down Expand Up @@ -5892,16 +5895,6 @@ extension BLEManager: @preconcurrency CBCentralManagerDelegate {
advertisementData[CBAdvertisementDataLocalNameKey] as? String),
connectable: (advertisementData[CBAdvertisementDataIsConnectable] as? NSNumber)?.boolValue ?? true))
}
// #716: the seeded "my-whoop" device has model "WHOOP" (no generation). Once a live scan
// confirms which service family the strap advertises, stamp the correct model so
// forRegistryModel returns the right DeviceFamily (fixes skin-temp ADC scale + display).
if !modelStamped, let rs = registryStore,
let stale = try? rs.all().first(where: { $0.status == .active && $0.model == "WHOOP" }) {
let correct = selectedModel == .whoop4 ? "WHOOP 4.0" : "WHOOP 5.0 / MG"
try? rs.setModel(stale.id, model: correct)
log("Updated device model from \"WHOOP\" to \"\(correct)\" (#716)")
modelStamped = true
}
let advertisedServiceUUIDs = (advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID] ?? [])
.map { $0.uuidString.lowercased() }
let scanDecision = whoopGattScanDecision(
Expand Down Expand Up @@ -5941,12 +5934,28 @@ extension BLEManager: @preconcurrency CBCentralManagerDelegate {
// and a scan only runs because the user asked or because the gated system entry allowed it.
cancelScanFallback()
persistSelectedModel(selectedModel)
stampSeededModelIfNeeded()
log("Discovered \(safeName) (rssi \(RSSI)) — connecting")
central.stopScan()
preparePeripheral(peripheral)
central.connect(peripheral, options: nil)
}

/// #716: the seeded "my-whoop" device has model "WHOOP" (no generation). Stamp the family of the
/// strap NOOP is about to connect to, so `forRegistryModel` returns the right DeviceFamily (skin-temp
/// ADC scale + display). Called only once the scan has accepted a strap for `selectedModel`: stamping
/// on ANY advertisement labelled a 5/MG install "WHOOP 4.0" whenever an unsupported advertiser
/// (e.g. service 1150) was heard during the auto-scan's 4.0 leg, before the 5/MG leg even ran.
private func stampSeededModelIfNeeded() {
guard !modelStamped, let rs = registryStore,
let stale = try? rs.all().first(where: { $0.status == .active && $0.model == "WHOOP" })
else { return }
let correct = selectedModel == .whoop4 ? "WHOOP 4.0" : "WHOOP 5.0 / MG"
try? rs.setModel(stale.id, model: correct)
log("Updated device model from \"WHOOP\" to \"\(correct)\" (#716)")
modelStamped = true
}

public func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
cancelScanFallback()
cancelPendingConnectProbe() // #730: the connect resolved; no pending-connect log needed
Expand Down Expand Up @@ -7342,11 +7351,18 @@ extension BLEManager: @preconcurrency CBPeripheralDelegate {
log("DIS: firmware=\(shown) not published — a decoded value already stands")
}
case BLEManager.disManufacturerChar, BLEManager.disModelNumberChar, BLEManager.disSwRevChar:
// Diagnostic only: nothing gates on these, but they cost one read each and are exactly what
// is missing when someone reports an unidentified strap.
// Diagnostic for manufacturer / software revision: they cost one read each and are exactly
// what is missing when someone reports an unidentified strap. The model number also resolves
// the 5/MG variant (below).
let v = String(decoding: bytes, as: UTF8.self)
.trimmingCharacters(in: CharacterSet(charactersIn: "\0").union(.whitespacesAndNewlines))
if !v.isEmpty { log("DIS: \(characteristic.uuid.uuidString) = \(v)") }
// #520: the model number is the ONE extra that is not merely diagnostic — it resolves an MG
// whose serial prefix / hardware revision match no heuristic (Android twin: WhoopBleClient).
if characteristic.uuid == BLEManager.disModelNumberChar {
disModelNumber = v.isEmpty ? nil : v
noteWhoop5VariantFromDIS()
}
case BLEManager.dataNotifyChar,
BLEManager.cmdNotifyChar,
BLEManager.eventNotifyChar:
Expand Down
40 changes: 24 additions & 16 deletions android/app/src/main/java/com/noop/ble/WhoopBleClient.kt
Original file line number Diff line number Diff line change
Expand Up @@ -6537,22 +6537,6 @@ class WhoopBleClient(
),
)
}
// #716: the seeded "my-whoop" device has model "WHOOP" (no generation). Once a live
// scan confirms which service family the strap advertises, stamp the correct model so
// forRegistryModel returns the right DeviceFamily (fixes skin-temp ADC scale + display).
if (!modelStamped) {
modelStamped = true
ioScope.launch {
val stale = repository.pairedDevices().firstOrNull {
it.status == "active" && it.model == "WHOOP"
}
if (stale != null) {
val correct = if (selectedModel == WhoopModel.WHOOP4) "WHOOP 4.0" else "WHOOP 5.0 / MG"
repository.setDeviceModel(stale.id, correct)
log("Updated device model from \"WHOOP\" to \"$correct\" (#716)")
}
}
}
val advertisedServiceUuids = result.scanRecord?.serviceUuids
?.map { it.uuid.toString().lowercase() }
.orEmpty()
Expand Down Expand Up @@ -6596,12 +6580,36 @@ class WhoopBleClient(
// Persist the family that actually advertised so the next scan starts on the right service —
// this is what makes a one-time rotation stick after a stale-preference reconnect. (PR#195)
persistSelectedModel(selectedModel)
stampSeededModelIfNeeded()
_state.update { it.copy(statusNote = "Found $name, connecting…") }
// Port of didDiscover: stop scanning, then connect to this peripheral.
stopScan()
connectToDevice(device, alreadyAuthorised = true) // #1881: a scan result is already authorised
}

/**
* #716: the seeded "my-whoop" device has model "WHOOP" (no generation). Stamp the family of the
* strap NOOP is about to connect to, so forRegistryModel returns the right DeviceFamily (skin-temp
* ADC scale + display). Called only once the scan has accepted a strap for [selectedModel]:
* stamping on ANY scan result labelled a 5/MG install "WHOOP 4.0" whenever an unsupported
* advertiser (e.g. service 1150) was heard during the auto-scan's 4.0 leg.
*/
private fun stampSeededModelIfNeeded() {
if (modelStamped) return
modelStamped = true
val family = selectedModel
ioScope.launch {
val stale = repository.pairedDevices().firstOrNull {
it.status == "active" && it.model == "WHOOP"
}
if (stale != null) {
val correct = if (family == WhoopModel.WHOOP4) "WHOOP 4.0" else "WHOOP 5.0 / MG"
repository.setDeviceModel(stale.id, correct)
log("Updated device model from \"WHOOP\" to \"$correct\" (#716)")
}
}
}

override fun onScanFailed(errorCode: Int) {
scanning = false
log("Scan failed: ${scanFailureLabel(errorCode)}")
Expand Down
Loading