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
13 changes: 3 additions & 10 deletions Strand/BLE/GarminBroadcast.swift
Original file line number Diff line number Diff line change
@@ -1,25 +1,18 @@
import Foundation

/// EXPERIMENTAL Garmin support — recognition + the in-app "enable Broadcast Heart Rate" hint.
/// EXPERIMENTAL Garmin support — the in-app "enable Broadcast Heart Rate" hint.
///
/// HONEST, NON-PROPRIETARY BY DESIGN. Garmin watches do NOT expose a NOOP-readable proprietary live
/// stream. They DO broadcast the STANDARD Bluetooth Heart Rate profile (0x180D / 0x2A37) when the user
/// turns on "Broadcast Heart Rate" on the watch. So Garmin live HR is the EXISTING generic-HR path
/// (`StandardHRSource`) — there is nothing Garmin-proprietary to implement, and we don't pretend there is.
///
/// This type therefore carries only:
/// • recognition (delegated to `ExperimentalBrand`), and
/// • the user-facing hint to enable Broadcast HR (the one thing that makes the watch discoverable on
/// 0x180D).
/// This type carries the user-facing hint to enable Broadcast HR, which makes the watch discoverable
/// on 0x180D. Advertised-name recognition lives in `ExperimentalBrand`.
/// A Garmin device is registered with `sourceKind: .liveBLE` so the SourceCoordinator already runs it
/// through `StandardHRSource` — no new BLE driver is needed, and the WHOOP/standard paths are untouched.
public enum GarminBroadcast {

/// True when the advertised name reads as a Garmin watch.
public static func isGarmin(name: String) -> Bool {
ExperimentalBrand.recognise(name: name) == .garmin
}

/// Step-by-step guidance to put a Garmin watch into Broadcast Heart Rate mode so NOOP (and any other
/// standard-HR app) can read it. Human, US-neutral, no em-dashes. The exact menu path varies a little
/// by model, so we keep it general and accurate.
Expand Down
24 changes: 18 additions & 6 deletions StrandTests/ExperimentalDriversTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -55,9 +55,23 @@ final class ExperimentalDriversTests: XCTestCase {
}

func testRecogniseGarmin() {
XCTAssertEqual(ExperimentalBrand.recognise(name: "Garmin Forerunner 265"), .garmin)
XCTAssertEqual(ExperimentalBrand.recognise(name: "fenix 7"), .garmin)
XCTAssertEqual(ExperimentalBrand.recognise(name: "vívoactive 5"), .garmin)
// Exercise the catalog-to-enum bridge used by discovery, including model-only names and
// case/diacritic folding. Every recognised Garmin routes through standard broadcast HR.
for name in ["Garmin Instinct 2", "Garmin Forerunner 265", "FORERUNNER 265",
"fenix 7", "fēnix 7", "vívoactive 5", "Venu 3", "Instinct 2", "epix Pro", "vívosmart 5", "HRM-Pro"] {
let brand = ExperimentalBrand.recognise(name: name)
XCTAssertEqual(brand, .garmin, name)
XCTAssertEqual(brand?.sourceKind, .liveBLE, name)
XCTAssertEqual(brand?.idPrefix, "garmin", name)
XCTAssertEqual(brand?.canStreamLiveHR, true, name)
}
}

func testGarminRecognitionDoesNotCaptureOtherBrands() {
XCTAssertEqual(ExperimentalBrand.recognise(name: "Amazfit GTS"), .amazfit)
for name in ["Polar H10", "Wahoo TICKR", "Some Random Speaker", "", " "] {
XCTAssertNil(ExperimentalBrand.recognise(name: name), name)
}
}

func testRecogniseOura() {
Expand All @@ -83,9 +97,7 @@ final class ExperimentalDriversTests: XCTestCase {

// MARK: - Garmin is the standard path, not a proprietary one

func testGarminUsesStandardRecognitionHelper() {
XCTAssertTrue(GarminBroadcast.isGarmin(name: "Garmin Instinct 2"))
XCTAssertFalse(GarminBroadcast.isGarmin(name: "Amazfit GTS"))
func testGarminBroadcastGuidanceExists() {
// The hint exists and is non-empty so the prep step has guidance to show.
XCTAssertFalse(GarminBroadcast.broadcastHint.isEmpty)
}
Expand Down
5 changes: 1 addition & 4 deletions android/app/src/main/java/com/noop/ble/GarminBroadcast.kt
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package com.noop.ble

/**
* EXPERIMENTAL Garmin support — recognition + the in-app "enable Broadcast Heart Rate" hint.
* EXPERIMENTAL Garmin support — the in-app "enable Broadcast Heart Rate" hint.
*
* Faithful Kotlin twin of Strand/BLE/GarminBroadcast.swift.
*
Expand All @@ -15,9 +15,6 @@ package com.noop.ble
*/
object GarminBroadcast {

/** True when the advertised name reads as a Garmin watch. */
fun isGarmin(name: String): Boolean = ExperimentalBrand.recognise(name) == ExperimentalBrand.GARMIN

/**
* Step-by-step guidance to put a Garmin watch into Broadcast Heart Rate mode so NOOP (and any other
* standard-HR app) can read it. Human, US-neutral, no em-dashes. The exact menu path varies a little
Expand Down
26 changes: 18 additions & 8 deletions android/app/src/test/java/com/noop/ble/ExperimentalDriversTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -79,11 +79,23 @@ class ExperimentalDriversTest {

@Test
fun recogniseGarmin() {
assertEquals(ExperimentalBrand.GARMIN, ExperimentalBrand.recognise("Garmin Forerunner 265"))
assertEquals(ExperimentalBrand.GARMIN, ExperimentalBrand.recognise("fenix 7"))
// Accented branding (Garmin markets it as "vívoactive") must fold to the ASCII token. Parity
// with Swift ExperimentalDriversTests.testRecogniseGarmin.
assertEquals(ExperimentalBrand.GARMIN, ExperimentalBrand.recognise("vívoactive 5"))
// Same discovery inputs and routing assertions as ExperimentalDriversTests.swift.
for (name in listOf("Garmin Instinct 2", "Garmin Forerunner 265", "FORERUNNER 265",
"fenix 7", "fēnix 7", "vívoactive 5", "Venu 3", "Instinct 2", "epix Pro", "vívosmart 5", "HRM-Pro")) {
val brand = ExperimentalBrand.recognise(name)
assertEquals(name, ExperimentalBrand.GARMIN, brand)
assertEquals(name, SourceKind.liveBLE, brand?.sourceKind)
assertEquals(name, "garmin", brand?.idPrefix)
assertEquals(name, true, brand?.canStreamLiveHR)
}
}

@Test
fun garminRecognitionDoesNotCaptureOtherBrands() {
assertEquals(ExperimentalBrand.AMAZFIT, ExperimentalBrand.recognise("Amazfit GTS"))
for (name in listOf("Polar H10", "Wahoo TICKR", "Some Random Speaker", "", " ")) {
assertNull(name, ExperimentalBrand.recognise(name))
}
}

@Test
Expand All @@ -110,9 +122,7 @@ class ExperimentalDriversTest {
// MARK: - Garmin is the standard path, not a proprietary one

@Test
fun garminUsesStandardRecognitionHelper() {
assertTrue(GarminBroadcast.isGarmin("Garmin Instinct 2"))
assertFalse(GarminBroadcast.isGarmin("Amazfit GTS"))
fun garminBroadcastGuidanceExists() {
assertTrue(GarminBroadcast.broadcastHint.isNotEmpty())
}

Expand Down
Loading