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
69 changes: 69 additions & 0 deletions Packages/WhoopStore/Sources/WhoopStore/WhoopSerialIdentity.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import Foundation

/// The stable WHOOP device id derived from the strap's own serial (#1303).
///
/// WHOOP strap identity is otherwise a TRANSIENT CoreBluetooth UUID: a re-pair or factory reset mints a
/// fresh one, so the same physical strap forks into a second registry row and orphans its history (#1193).
/// The ring path already solved this — `DeviceRegistryStore.adoptSerialIdentity` re-points a provisional id
/// onto a serial id and migrates every device-scoped row — and this is the WHOOP half of the same idea.
///
/// Pure and store-free so both platforms can pin the composition and, more importantly, the REFUSALS: a
/// blank or implausible serial must yield nil and leave the existing id alone. Adopting onto a junk id
/// would be worse than not adopting at all, because the migration moves every row onto it.
///
/// Kotlin twin: `WhoopSerialIdentity`.
public enum WhoopSerialIdentity {

/// The one place the WHOOP id namespace is spelled. `AddDeviceWizard` mints `whoop-<CB-UUID>` and
/// `DeviceRegistryStore` classifies on the same prefix; a serial id joins the same namespace so every
/// existing prefix check keeps working unchanged.
public static let idPrefix = "whoop"

/// Shortest serial worth adopting. A 5.0/MG DIS serial is far longer; this only rejects a truncated or
/// placeholder read, which a partial GATT response can produce.
public static let minSerialLength = 6

/// The `whoop-<serial>` id for a strap serial, or nil when the serial cannot be trusted to identify it.
///
/// Refuses blank/whitespace, anything under `minSerialLength`, and any serial carrying a character
/// outside `[A-Z0-9-]` after upper-casing — a DIS read that returns a descriptive string rather than a
/// serial should never become a device id. Upper-cased so the same strap read twice, in either case,
/// resolves to ONE id rather than two.
public static func adoptedId(serial: String?) -> String? {
guard let raw = serial?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { return nil }
let up = raw.uppercased()
guard up.count >= minSerialLength else { return nil }
let allowed = Set("ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-")
guard up.allSatisfy({ allowed.contains($0) }) else { return nil }
return "\(idPrefix)-\(up)"
}

/// Whether this pairing's id may be re-pointed onto a serial id at all.
///
/// ONLY a provisional `whoop-<CB-UUID>` id qualifies. The legacy `my-whoop` seed is deliberately
/// EXCLUDED, and that exclusion is what makes this safe to ship before #1304: every existing
/// single-WHOOP install is still on that seed, ~47 code paths still read the literal `"my-whoop"`
/// directly, and `WhoopBleClient.deviceId` documents that the single-WHOOP path never reassigns it.
/// Adopting it would migrate the whole history onto `whoop-<serial>` while new samples kept being
/// written under `my-whoop` — a split history that reads as data loss.
///
/// The legacy seed joins this path as part of #1304, once the literals no longer assume it.
public static func mayAdopt(currentId: String) -> Bool {
currentId.hasPrefix("\(idPrefix)-")
}

/// True when `id` is already the serial id for `serial` — the steady state on every reconnect after the
/// first adoption, and the cheap early-out that keeps re-adoption from doing database work per connect.
public static func isAlreadyAdopted(id: String, serial: String?) -> Bool {
guard let target = adoptedId(serial: serial) else { return false }
return id == target
}

/// What may be written to a SHAREABLE strap log. The serial identifies the device, so only its leading
/// characters are ever logged — the same rule `noteWhoop5VariantFromDIS` already applies to the variant
/// line. Never log `adoptedId`'s result directly.
public static func logSafe(serial: String?) -> String {
guard let raw = serial?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { return "?" }
return String(raw.uppercased().prefix(3)) + "…"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import XCTest
@testable import WhoopStore

/// The REFUSALS matter more than the composition: adoption migrates every device-scoped row onto the id
/// this returns, so a junk serial must yield nil and leave the strap on its existing id rather than move a
/// history onto a garbage key (#1303). Kotlin twin: `WhoopSerialIdentityTest`.
final class WhoopSerialIdentityTests: XCTestCase {

func testComposesTheSerialId() {
XCTAssertEqual(WhoopSerialIdentity.adoptedId(serial: "5AG12345678"), "whoop-5AG12345678")
}

func testUpperCasesSoOneStrapCannotBecomeTwoIds() {
XCTAssertEqual(WhoopSerialIdentity.adoptedId(serial: "5ag12345678"),
WhoopSerialIdentity.adoptedId(serial: "5AG12345678"))
}

func testTrimsSurroundingWhitespaceFromTheGattString() {
XCTAssertEqual(WhoopSerialIdentity.adoptedId(serial: " 5AG12345678\n"), "whoop-5AG12345678")
}

func testRefusesBlankOrMissing() {
XCTAssertNil(WhoopSerialIdentity.adoptedId(serial: nil))
XCTAssertNil(WhoopSerialIdentity.adoptedId(serial: ""))
XCTAssertNil(WhoopSerialIdentity.adoptedId(serial: " \n "))
}

func testRefusesATruncatedRead() {
// A partial GATT response must not become an id: it would collide across straps.
XCTAssertNil(WhoopSerialIdentity.adoptedId(serial: "5AG"))
}

func testRefusesADescriptiveStringThatIsNotASerial() {
// Some peripherals answer DIS with prose. Never let that become a device id.
XCTAssertNil(WhoopSerialIdentity.adoptedId(serial: "Not Available"))
XCTAssertNil(WhoopSerialIdentity.adoptedId(serial: "serial#1234"))
}

func testAlreadyAdoptedIsTheReconnectEarlyOut() {
XCTAssertTrue(WhoopSerialIdentity.isAlreadyAdopted(id: "whoop-5AG12345678", serial: "5AG12345678"))
XCTAssertFalse(WhoopSerialIdentity.isAlreadyAdopted(id: "whoop-ABCDEF-0123", serial: "5AG12345678"))
// An unusable serial is never "already adopted" — otherwise a junk read would silently suppress a
// later good one.
XCTAssertFalse(WhoopSerialIdentity.isAlreadyAdopted(id: "whoop-5AG12345678", serial: " "))
}

/// The guard that makes this safe to ship before #1304. Every existing single-WHOOP install is on the
/// legacy `my-whoop` seed, ~47 code paths read that literal directly, and `WhoopBleClient` never
/// reassigns its deviceId on the single-WHOOP path — so adopting it would migrate the history onto
/// `whoop-<serial>` while new samples kept landing under `my-whoop`. A split history reads as data loss.
func testRefusesToAdoptTheLegacySingleWhoopSeed() {
XCTAssertFalse(WhoopSerialIdentity.mayAdopt(currentId: "my-whoop"))
// A provisional pairing id IS adoptable — that is the multi-strap case this ships for.
XCTAssertTrue(WhoopSerialIdentity.mayAdopt(currentId: "whoop-6B9F2C11-0000-4000-8000-0000000000AA"))
// An already-adopted serial id stays adoptable; the equality check upstream stops the re-migration.
XCTAssertTrue(WhoopSerialIdentity.mayAdopt(currentId: "whoop-5AG12345678"))
// Another brand's id is never touched by the WHOOP path.
XCTAssertFalse(WhoopSerialIdentity.mayAdopt(currentId: "oura-2H3B2405003655"))
}

func testLogSafeNeverLeaksTheFullSerial() {
XCTAssertEqual(WhoopSerialIdentity.logSafe(serial: "5AG12345678"), "5AG…")
XCTAssertEqual(WhoopSerialIdentity.logSafe(serial: nil), "?")
XCTAssertFalse(WhoopSerialIdentity.logSafe(serial: "5AG12345678").contains("12345678"))
}
}
6 changes: 6 additions & 0 deletions Strand/App/AppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -526,6 +526,12 @@ final class AppModel: ObservableObject {
})
coordinator.start()
self.deviceRegistry = registry
// #1303: adoption re-points the strap onto its stable `whoop-<serial>` id inside BLEManager (which
// holds only the non-observable store), so mirror it onto the OBSERVABLE registry here or the
// Devices screen and the source coordinator keep watching an id that no longer exists.
self.ble.onSerialIdentityAdopted = { [weak registry] serialId in
registry?.setActive(serialId)
}
self.sourceCoordinator = coordinator
// #814 READ SPINE (HIGH-1): drive the read side off the registry's `activeDeviceId` for the WHOLE
// session, exactly as SourceCoordinator drives the WRITE side off the SAME publisher. A Devices-
Expand Down
49 changes: 49 additions & 0 deletions Strand/BLE/BLEManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -495,6 +495,11 @@ public final class BLEManager: NSObject, ObservableObject {
private let router: FrameRouter
private var collector: Collector?
/// #716: stored on bootstrap so the scan callback can fix the seeded "WHOOP" model label.
/// #1303: fired after the strap's history has been re-pointed onto its stable `whoop-<serial>` id.
/// The live persist paths are already re-pointed inline by then; this exists so the OBSERVABLE spine
/// (`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)?
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 @@ -4131,6 +4136,50 @@ public final class BLEManager: NSObject, ObservableObject {
// existing consumer — nothing about framing or decode reads this.
state.whoop5Variant = variant.label
reconcileModelFromAttestation(variant)
adoptWhoopSerialIdentity()
}

/// #1303: the 5/MG DIS read hands us the strap's OWN serial, so re-point this pairing from its
/// transient CoreBluetooth-UUID id onto a stable `whoop-<serial>` id — through the SAME generic
/// migration the ring already uses (`adoptSerialIdentity`, #771), so a re-pair or factory reset stops
/// forking one physical strap into a second row and orphaning its history (#1193).
///
/// A WHOOP 4.0 is deliberately untouched: it does not expose a DIS serial (the read above is gated
/// `!= .whoop4`) and the 4.0 serial's source on the wire is not yet identified, so there is nothing
/// honest to adopt onto here.
///
/// Deferred to the next main-loop turn, mirroring `adoptOuraSerial`: adoption re-points the ACTIVE
/// device, and the observers that react tear down and rebuild the very connection this callback is
/// running inside, so the current BLE callback must return first. Idempotent — after the first
/// adoption the id already equals the serial id, so every later reconnect costs one string compare and
/// touches no database. A serial `WhoopSerialIdentity` refuses (blank, truncated, non-serial prose)
/// leaves the strap on its existing id: adopting onto a junk id would migrate every device-scoped row
/// onto a garbage key, which is worse than not adopting.
private func adoptWhoopSerialIdentity() {
guard let rs = registryStore,
let serialId = WhoopSerialIdentity.adoptedId(serial: disSerial),
let active = try? rs.all().first(where: { $0.status == .active }),
WhoopSerialIdentity.mayAdopt(currentId: active.id),
active.id != serialId
else { return }
let currentId = active.id
Task { @MainActor [weak self] in
guard let self, let rs = self.registryStore,
(try? rs.all().first(where: { $0.status == .active }))?.id == currentId
else { return }
guard (try? rs.adoptSerialIdentity(from: currentId, to: serialId)) == true else { return }
try? rs.setActive(serialId)
// The rows have MOVED to the serial id and the old registry row is gone, so the live persist
// paths must follow in the same turn: `deviceId`, the Collector and the Backfiller all stamp
// rows at write time, and leaving them on the now-deleted id would write new samples into a
// second, orphaned history — the same split this phase exists to prevent, just on the
// provisional path instead of the legacy one. Kotlin reaches the identical call through
// `SourceCoordinator.pointWhoop`, which re-points any non-legacy id.
self.setActiveDeviceId(serialId)
// Prefix only. `serialId` embeds the full serial, which must never reach a shareable log.
self.log("Adopted stable serial identity (serialPrefix=\(WhoopSerialIdentity.logSafe(serial: self.disSerial))) - history re-pointed off the transient pairing id (#1303)")
self.onSerialIdentityAdopted?(serialId)
}
}

/// The strap's own DIS attestation is ground truth (a WHOOP 4.0 never attests a 5AM/5AG serial). When
Expand Down
22 changes: 22 additions & 0 deletions android/app/src/main/java/com/noop/ble/WhoopBleClient.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2542,6 +2542,17 @@ class WhoopBleClient(
// #520 DIS identity — read ONCE per connection, post-handshake, 5/MG only. Serial and hardware
// revision are immutable, so they are never re-polled (unlike the battery). Reset on disconnect.
private var disRead = false
/**
* #1303: the strap's DIS serial, once read (5.0/MG only — a 4.0 does not expose one).
*
* KNOWN ASYMMETRY with the Swift twin, which adopts inline in BLEManager and uses its closure only
* to notify. Here the adoption itself lives in the observer (AppViewModel owns the registry handle
* and a scope; SourceCoordinator is inert on the WHOOP path by design), so a connect that completes
* before any observer is wired emits into a null callback and does not adopt. It is deferred, not
* lost: DIS is read on every connect, so the next one with an observer alive adopts. Worth knowing
* when reading a capture where iOS shows the adoption line and Android does not.
*/
var onSerial: ((String) -> Unit)? = null
private var disSerial: String? = null
private var disHwRev: String? = null

Expand Down Expand Up @@ -4035,6 +4046,10 @@ class WhoopBleClient(
val prefix = disSerial?.trim()?.uppercase()?.take(3) ?: "?"
log("DIS: serialPrefix=$prefix hwRev=${disHwRev ?: "?"} -> variant=${variant.label}")
reconcileModelFromAttestation(variant)
// #1303: hand the strap's OWN serial up so the coordinator can re-point this pairing onto a stable
// `whoop-<serial>` id. Emitted, not acted on here, mirroring how the Oura source reports its serial:
// adoption re-points the ACTIVE device and tears down the very connection this callback runs inside.
disSerial?.let { onSerial?.invoke(it) }
}

/** The strap's own DIS attestation is ground truth (a WHOOP 4.0 never attests a 5AM/5AG serial). When
Expand Down Expand Up @@ -8269,6 +8284,13 @@ class WhoopBleClient(
}
}

/**
* #1303: let the identity owner write one line into the SAME strap log the connection uses, so an
* adoption is visible in the capture beside the DIS line that triggered it. Deliberately narrow —
* the general [log] stays private. Callers must pass a serial PREFIX, never a full serial.
*/
fun logIdentity(line: String) = log(line)

private fun log(s: String, domain: com.noop.testcentre.TestDomain? = null) {
// A diagnostic log line must NEVER be able to crash the app. log() runs on the GATT binder
// thread and from the background reconnect service, so an uncaught throw here takes the WHOLE
Expand Down
83 changes: 83 additions & 0 deletions android/app/src/main/java/com/noop/data/WhoopSerialIdentity.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package com.noop.data

/**
* The stable WHOOP device id derived from the strap's own serial (#1303). Byte-parity twin of Swift
* `WhoopSerialIdentity`.
*
* WHOOP strap identity is otherwise a TRANSIENT Bluetooth address/UUID: a re-pair or factory reset mints a
* fresh one, so the same physical strap forks into a second registry row and orphans its history (#1193).
* The ring path already solved this — `DeviceRegistry.adoptSerialIdentity` re-points a provisional id onto
* a serial id and migrates every device-scoped row — and this is the WHOOP half of the same idea.
*
* Pure and store-free so both platforms can pin the composition and, more importantly, the REFUSALS: a
* blank or implausible serial must yield null and leave the existing id alone. Adopting onto a junk id
* would be worse than not adopting at all, because the migration moves every row onto it.
*/
object WhoopSerialIdentity {

/**
* The one place the WHOOP id namespace is spelled. The add-device flow mints `whoop-<address>` and the
* registry classifies on the same prefix; a serial id joins the same namespace so every existing prefix
* check keeps working unchanged.
*/
const val ID_PREFIX = "whoop"

/**
* Shortest serial worth adopting. A 5.0/MG DIS serial is far longer; this only rejects a truncated or
* placeholder read, which a partial GATT response can produce.
*/
const val MIN_SERIAL_LENGTH = 6

private val ALLOWED = ('A'..'Z').toSet() + ('0'..'9').toSet() + '-'

/**
* The `whoop-<serial>` id for a strap serial, or null when the serial cannot be trusted to identify it.
*
* Refuses blank/whitespace, anything under [MIN_SERIAL_LENGTH], and any serial carrying a character
* outside `[A-Z0-9-]` after upper-casing — a DIS read that returns a descriptive string rather than a
* serial should never become a device id. Upper-cased so the same strap read twice, in either case,
* resolves to ONE id rather than two.
*/
fun adoptedId(serial: String?): String? {
val raw = serial?.trim().orEmpty()
if (raw.isEmpty()) return null
val up = raw.uppercase()
if (up.length < MIN_SERIAL_LENGTH) return null
if (!up.all { it in ALLOWED }) return null
return "$ID_PREFIX-$up"
}

/**
* Whether this pairing's id may be re-pointed onto a serial id at all.
*
* ONLY a provisional `whoop-<address>` id qualifies. The legacy `my-whoop` seed is deliberately
* EXCLUDED, and that exclusion is what makes this safe to ship before #1304: every existing
* single-WHOOP install is still on that seed, ~47 code paths still read the literal "my-whoop"
* directly, and [com.noop.ble.WhoopBleClient] documents that the single-WHOOP path never reassigns its
* deviceId. Adopting it would migrate the whole history onto `whoop-<serial>` while new samples kept
* being written under "my-whoop" - a split history that reads as data loss.
*
* The legacy seed joins this path as part of #1304, once the literals no longer assume it.
*/
fun mayAdopt(currentId: String): Boolean = currentId.startsWith("$ID_PREFIX-")

/**
* True when [id] is already the serial id for [serial] — the steady state on every reconnect after the
* first adoption, and the cheap early-out that keeps re-adoption from doing database work per connect.
*/
fun isAlreadyAdopted(id: String, serial: String?): Boolean {
val target = adoptedId(serial) ?: return false
return id == target
}

/**
* What may be written to a SHAREABLE strap log. The serial identifies the device, so only its leading
* characters are ever logged — the same rule `noteWhoop5VariantFromDis` already applies to the variant
* line. Never log [adoptedId]'s result directly.
*/
fun logSafe(serial: String?): String {
val raw = serial?.trim().orEmpty()
if (raw.isEmpty()) return "?"
return raw.uppercase().take(3) + "…"
}
}
Loading
Loading