@mentra/bluetooth-sdk 3.3.0-dev.382 → 3.3.0-dev.391

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +7 -3
  2. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +5 -5
  3. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.kt +2 -2
  4. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +8 -7
  5. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +15 -10
  6. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +12 -8
  7. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SelectedDeviceAddress.kt +17 -0
  8. package/android/src/test/java/com/mentra/bluetoothsdk/DeviceIdentityPairingTest.kt +59 -0
  9. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G1PairingTest.kt +105 -0
  10. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G2PairingTargetTest.kt +90 -0
  11. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingTargetTest.kt +65 -0
  12. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraNexPairingTest.kt +151 -0
  13. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/SelectedDeviceAddressTest.kt +27 -0
  14. package/build/generated/releaseMetadata.js +5 -5
  15. package/build/generated/releaseMetadata.js.map +1 -1
  16. package/ios/Source/BluetoothSdkDefaults.swift +1 -1
  17. package/ios/Source/DeviceManager.swift +7 -3
  18. package/ios/Source/GeneratedReleaseMetadata.swift +5 -5
  19. package/ios/Source/sgcs/G1.swift +21 -6
  20. package/ios/Source/sgcs/G1ConnectionTarget.swift +13 -0
  21. package/ios/Source/sgcs/Mach1.swift +19 -7
  22. package/ios/Source/sgcs/MentraLive.swift +5 -2
  23. package/ios/Source/sgcs/MentraNex.swift +8 -31
  24. package/ios/Source/sgcs/NexConnectionTarget.swift +15 -0
  25. package/ios/Source/sgcs/VuzixConnectionTarget.swift +15 -0
  26. package/ios/Tests/DeviceIdentityPairingTests.swift +49 -0
  27. package/ios/Tests/SGCConnectionTargetTests.swift +48 -0
  28. package/ios/Tests/VersionInfoCommandTests.swift +53 -0
  29. package/package.json +2 -2
  30. package/src/generated/releaseMetadata.ts +5 -5
@@ -0,0 +1,151 @@
1
+ package com.mentra.bluetoothsdk.sgcs
2
+
3
+ import android.bluetooth.BluetoothAdapter
4
+ import android.bluetooth.BluetoothDevice
5
+ import android.bluetooth.BluetoothGatt
6
+ import android.bluetooth.BluetoothProfile
7
+ import android.bluetooth.le.ScanResult
8
+ import android.bluetooth.le.ScanSettings
9
+ import android.os.Looper
10
+ import androidx.test.core.app.ApplicationProvider
11
+ import com.mentra.bluetoothsdk.Bridge
12
+ import com.mentra.bluetoothsdk.DeviceManager
13
+ import com.mentra.bluetoothsdk.DeviceStore
14
+ import com.mentra.bluetoothsdk.utils.DeviceTypes
15
+ import org.junit.Assert.assertEquals
16
+ import org.junit.Assert.assertTrue
17
+ import org.junit.Before
18
+ import org.junit.Test
19
+ import org.junit.runner.RunWith
20
+ import org.robolectric.RobolectricTestRunner
21
+ import org.robolectric.Shadows.shadowOf
22
+ import org.robolectric.annotation.Config
23
+ import org.robolectric.annotation.Implementation
24
+ import org.robolectric.annotation.Implements
25
+ import org.robolectric.annotation.LooperMode
26
+ import org.robolectric.shadows.ShadowBluetoothGatt
27
+
28
+ @RunWith(RobolectricTestRunner::class)
29
+ @Config(sdk = [33], shadows = [G1PairingTest.ShadowLc3Cpp::class, MentraNexPairingTest.ShadowNexGatt::class], instrumentedPackages = ["com.mentra.lc3Lib"])
30
+ @LooperMode(LooperMode.Mode.PAUSED)
31
+ class MentraNexPairingTest {
32
+ // Robolectric implements connection callbacks but not the PHY binder request.
33
+ @Implements(BluetoothGatt::class)
34
+ class ShadowNexGatt : ShadowBluetoothGatt() {
35
+ @Implementation fun setPreferredPhy(txPhy: Int, rxPhy: Int, phyOptions: Int) {}
36
+ }
37
+
38
+ @Before fun setup() {
39
+ Bridge.initialize(ApplicationProvider.getApplicationContext())
40
+ shadowOf(BluetoothAdapter.getDefaultAdapter()).setState(BluetoothAdapter.STATE_ON)
41
+ DeviceStore.set("bluetooth", "pending_device_name", "Nex1-2")
42
+ DeviceStore.set("bluetooth", "pending_device_address", "")
43
+ DeviceStore.set("bluetooth", "device_address", "")
44
+ }
45
+
46
+ @Test fun `fresh pairing selects only the requested Nex`() {
47
+ assertScansSelectedTarget("", "")
48
+ }
49
+
50
+ @Test fun `simulated pairing selects only the requested Nex`() {
51
+ assertScansSelectedTarget("Simulated Glasses", "")
52
+ }
53
+
54
+ @Test fun `a different saved Nex name and address cannot redirect selection`() {
55
+ assertScansSelectedTarget("Nex1-1", "00:00:00:00:00:01")
56
+ }
57
+
58
+ @Test fun `pending address targets the new Nex instead of the saved pair`() {
59
+ assertDirectTarget("Nex1-1", "00:00:00:00:00:01", "00:00:00:00:00:02")
60
+ }
61
+
62
+ @Test fun `saved Nex reconnect retains its address`() {
63
+ assertDirectTarget("Nex1-2", "00:00:00:00:00:02", "")
64
+ }
65
+
66
+ @Test fun `name-only pairing readiness cannot associate the old MAC with the new Nex`() {
67
+ DeviceStore.set("bluetooth", "default_wearable", DeviceTypes.NEX)
68
+ DeviceStore.set("bluetooth", "pending_wearable", DeviceTypes.NEX)
69
+ DeviceStore.set("bluetooth", "device_name", "Nex1-1")
70
+ DeviceStore.set("bluetooth", "device_address", "00:00:00:00:00:01")
71
+ DeviceStore.set("bluetooth", "shouldSendBootingMessage", false)
72
+ DeviceStore.set("glasses", "fullyBooted", false)
73
+ val old = device("00:00:00:00:00:01", "Nex1-1")
74
+ val selected = device("00:00:00:00:00:02", "Nex1-2")
75
+ val singleton = DeviceManager::class.java.getDeclaredField("_instance").apply { isAccessible = true }
76
+ val previous = singleton.get(null)
77
+ val manager = DeviceManager(initializeHardware = false)
78
+ singleton.set(null, manager)
79
+ try {
80
+ var nex = MentraNex()
81
+ manager.sgc = nex
82
+ nex.connectById("Nex1-2")
83
+ val scanner = shadowOf(BluetoothAdapter.getDefaultAdapter().bluetoothLeScanner)
84
+ scanner.scanCallbacks.single().onScanResult(
85
+ ScanSettings.CALLBACK_TYPE_ALL_MATCHES, ScanResult(selected, null, -50, 0),
86
+ )
87
+ shadowOf(Looper.getMainLooper()).idle()
88
+ // Drive the actual ready callback and DeviceManager's identity promotion.
89
+ shadowOf(selected).simulateGattConnectionChange(BluetoothGatt.GATT_SUCCESS, BluetoothProfile.STATE_CONNECTED)
90
+ assertEquals("Nex1-2", DeviceStore.get("bluetooth", "device_name"))
91
+ assertEquals("", DeviceStore.get("bluetooth", "device_address"))
92
+ assertEquals("", DeviceStore.get("bluetooth", "pending_device_name"))
93
+ manager.disconnect()
94
+
95
+ nex = MentraNex()
96
+ manager.sgc = nex
97
+ nex.connectById(DeviceStore.get("bluetooth", "device_name") as String)
98
+ assertTrue(shadowOf(old).bluetoothGatts.isEmpty())
99
+ scanner.scanCallbacks.single().onScanResult(
100
+ ScanSettings.CALLBACK_TYPE_ALL_MATCHES, ScanResult(selected, null, -50, 0),
101
+ )
102
+ shadowOf(Looper.getMainLooper()).idle()
103
+ assertEquals(2, shadowOf(selected).bluetoothGatts.size)
104
+ } finally {
105
+ manager.disconnect()
106
+ manager.cleanup()
107
+ singleton.set(null, previous)
108
+ }
109
+ }
110
+
111
+ private fun assertScansSelectedTarget(savedName: String, savedAddress: String) {
112
+ DeviceStore.set("bluetooth", "device_name", savedName)
113
+ DeviceStore.set("bluetooth", "device_address", savedAddress)
114
+ val old = device("00:00:00:00:00:01", "Nex1-1")
115
+ val prefixCollision = device("00:00:00:00:00:03", "Nex1-23")
116
+ val selected = device("00:00:00:00:00:02", "Nex1-2")
117
+ val nex = MentraNex()
118
+ try {
119
+ nex.connectById("Nex1-2")
120
+ val scanner = shadowOf(BluetoothAdapter.getDefaultAdapter().bluetoothLeScanner)
121
+ val callback = scanner.scanCallbacks.single()
122
+ for (found in listOf(old, prefixCollision, selected)) {
123
+ callback.onScanResult(ScanSettings.CALLBACK_TYPE_ALL_MATCHES, ScanResult(found, null, -50, 0))
124
+ shadowOf(Looper.getMainLooper()).idle()
125
+ }
126
+ assertTrue(shadowOf(old).bluetoothGatts.isEmpty())
127
+ assertTrue(shadowOf(prefixCollision).bluetoothGatts.isEmpty())
128
+ assertEquals(1, shadowOf(selected).bluetoothGatts.size)
129
+ assertEquals(savedName, DeviceStore.get("bluetooth", "device_name"))
130
+ } finally { nex.disconnect() }
131
+ }
132
+
133
+ private fun assertDirectTarget(savedName: String, savedAddress: String, pendingAddress: String) {
134
+ DeviceStore.set("bluetooth", "device_name", savedName)
135
+ DeviceStore.set("bluetooth", "device_address", savedAddress)
136
+ DeviceStore.set("bluetooth", "pending_device_address", pendingAddress)
137
+ val old = device("00:00:00:00:00:01", "Nex1-1")
138
+ val selected = device("00:00:00:00:00:02", "Nex1-2")
139
+ val nex = MentraNex()
140
+ try {
141
+ nex.connectById("Nex1-2")
142
+ shadowOf(Looper.getMainLooper()).idle()
143
+ assertTrue(shadowOf(old).bluetoothGatts.isEmpty())
144
+ assertEquals(1, shadowOf(selected).bluetoothGatts.size)
145
+ assertEquals(savedName, DeviceStore.get("bluetooth", "device_name"))
146
+ } finally { nex.disconnect() }
147
+ }
148
+
149
+ private fun device(address: String, name: String): BluetoothDevice =
150
+ BluetoothAdapter.getDefaultAdapter().getRemoteDevice(address).also { shadowOf(it).setName(name) }
151
+ }
@@ -0,0 +1,27 @@
1
+ package com.mentra.bluetoothsdk.sgcs
2
+
3
+ import org.junit.Assert.assertEquals
4
+ import org.junit.Assert.assertNull
5
+ import org.junit.Test
6
+
7
+ class SelectedDeviceAddressTest {
8
+ @Test fun `selected pending address wins over saved address`() {
9
+ assertEquals("new", SelectedDeviceAddress.resolve("LIVE_2", "LIVE_2", "new", "LIVE_1", "old"))
10
+ }
11
+
12
+ @Test fun `a name-only selection cannot use another device's saved address`() {
13
+ assertNull(SelectedDeviceAddress.resolve("LIVE_2", "LIVE_2", "", "LIVE_1", "old"))
14
+ assertNull(SelectedDeviceAddress.resolve("Nex1-2", "Nex1-2", null, "Nex1-1", "old"))
15
+ assertNull(SelectedDeviceAddress.resolve("Nex1-2", null, null, "Simulated Glasses", ""))
16
+ }
17
+
18
+ @Test fun `saved reconnect keeps its address even with an unrelated pending selection`() {
19
+ assertEquals("old", SelectedDeviceAddress.resolve("LIVE_1", "LIVE_2", "new", "LIVE_1", "old"))
20
+ assertEquals("old", SelectedDeviceAddress.resolve("Nex1-1", null, null, "Nex1-1", "old"))
21
+ }
22
+
23
+ @Test fun `empty or mismatched identities never supply an address`() {
24
+ assertNull(SelectedDeviceAddress.resolve("", "", "new", "", "old"))
25
+ assertNull(SelectedDeviceAddress.resolve("LIVE_2", "LIVE_1", "new", "LIVE_1", "old"))
26
+ }
27
+ }
@@ -1,10 +1,10 @@
1
1
  export const BLUETOOTH_SDK_RELEASE_METADATA = Object.freeze({
2
2
  "schemaVersion": 1,
3
3
  "familyBaseVersion": "3.3.0",
4
- "releaseIdentity": "3.3.0-dev.382",
5
- "releaseSetId": "mentra-3.3.0-dev.382",
6
- "sourceCommit": "6a757abb267592562702b3320cdbb3281e90a59e",
7
- "otaManifestUrl": "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.3.0/mentra-live-ota-3.3.0-dev.382.json",
8
- "otaManifestSha256": "7d807fb6eea2ed3b6e0fe915decfe79524504752c1c7d8f48b24a3c1e5d56d31"
4
+ "releaseIdentity": "3.3.0-dev.391",
5
+ "releaseSetId": "mentra-3.3.0-dev.391",
6
+ "sourceCommit": "2763761a9cff7660e63412e9d006b4e20033f90b",
7
+ "otaManifestUrl": "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.3.0/mentra-live-ota-3.3.0-dev.391.json",
8
+ "otaManifestSha256": "c079797bc98050c666d513a4960fd41a6fb5dba5ea809f876954633ae08621fa"
9
9
  });
10
10
  //# sourceMappingURL=releaseMetadata.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"releaseMetadata.js","sourceRoot":"","sources":["../../src/generated/releaseMetadata.ts"],"names":[],"mappings":"AAWA,MAAM,CAAC,MAAM,8BAA8B,GAA0C,MAAM,CAAC,MAAM,CAAC;IACjG,eAAe,EAAE,CAAC;IAClB,mBAAmB,EAAE,OAAO;IAC5B,iBAAiB,EAAE,eAAe;IAClC,cAAc,EAAE,sBAAsB;IACtC,cAAc,EAAE,0CAA0C;IAC1D,gBAAgB,EAAE,iIAAiI;IACnJ,mBAAmB,EAAE,kEAAkE;CACxF,CAAC,CAAA","sourcesContent":["/** Generated by release CI. Do not edit in a release checkout. */\nexport interface BluetoothSdkReleaseMetadata {\n schemaVersion: 1\n familyBaseVersion: string | null\n releaseIdentity: string | null\n releaseSetId: string | null\n sourceCommit: string | null\n otaManifestUrl: string | null\n otaManifestSha256: string | null\n}\n\nexport const BLUETOOTH_SDK_RELEASE_METADATA: Readonly<BluetoothSdkReleaseMetadata> = Object.freeze({\n \"schemaVersion\": 1,\n \"familyBaseVersion\": \"3.3.0\",\n \"releaseIdentity\": \"3.3.0-dev.382\",\n \"releaseSetId\": \"mentra-3.3.0-dev.382\",\n \"sourceCommit\": \"6a757abb267592562702b3320cdbb3281e90a59e\",\n \"otaManifestUrl\": \"https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.3.0/mentra-live-ota-3.3.0-dev.382.json\",\n \"otaManifestSha256\": \"7d807fb6eea2ed3b6e0fe915decfe79524504752c1c7d8f48b24a3c1e5d56d31\"\n})\n"]}
1
+ {"version":3,"file":"releaseMetadata.js","sourceRoot":"","sources":["../../src/generated/releaseMetadata.ts"],"names":[],"mappings":"AAWA,MAAM,CAAC,MAAM,8BAA8B,GAA0C,MAAM,CAAC,MAAM,CAAC;IACjG,eAAe,EAAE,CAAC;IAClB,mBAAmB,EAAE,OAAO;IAC5B,iBAAiB,EAAE,eAAe;IAClC,cAAc,EAAE,sBAAsB;IACtC,cAAc,EAAE,0CAA0C;IAC1D,gBAAgB,EAAE,iIAAiI;IACnJ,mBAAmB,EAAE,kEAAkE;CACxF,CAAC,CAAA","sourcesContent":["/** Generated by release CI. Do not edit in a release checkout. */\nexport interface BluetoothSdkReleaseMetadata {\n schemaVersion: 1\n familyBaseVersion: string | null\n releaseIdentity: string | null\n releaseSetId: string | null\n sourceCommit: string | null\n otaManifestUrl: string | null\n otaManifestSha256: string | null\n}\n\nexport const BLUETOOTH_SDK_RELEASE_METADATA: Readonly<BluetoothSdkReleaseMetadata> = Object.freeze({\n \"schemaVersion\": 1,\n \"familyBaseVersion\": \"3.3.0\",\n \"releaseIdentity\": \"3.3.0-dev.391\",\n \"releaseSetId\": \"mentra-3.3.0-dev.391\",\n \"sourceCommit\": \"2763761a9cff7660e63412e9d006b4e20033f90b\",\n \"otaManifestUrl\": \"https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.3.0/mentra-live-ota-3.3.0-dev.391.json\",\n \"otaManifestSha256\": \"c079797bc98050c666d513a4960fd41a6fb5dba5ea809f876954633ae08621fa\"\n})\n"]}
@@ -16,7 +16,7 @@ enum BluetoothSdkDefaults {
16
16
  /// silently disable it on every pair the app touches.
17
17
  static let autoPowerOffEnabled = false
18
18
  private static let infoSdkVersionKey = "MentraBluetoothSdkVersion"
19
- private static let swiftPackageSdkVersion = "3.3.0-dev.382"
19
+ private static let swiftPackageSdkVersion = "3.3.0-dev.391"
20
20
  private static let swiftPackageSdkVersionPlaceholder = "__MENTRA" + "_BLUETOOTH_SDK_VERSION__"
21
21
 
22
22
  private static func normalizedSdkVersion(_ value: String?) -> String? {
@@ -998,12 +998,16 @@ struct ViewState {
998
998
  Bridge.log("MAN: handleDeviceReady(): \(sgc.type)")
999
999
  resetMicHealth()
1000
1000
 
1001
+ // A new identity must never inherit the previous glasses' address when
1002
+ // pairing supplied only a name. Same-device reconnects retain their cache.
1003
+ let identityChanged = !pendingDeviceName.isEmpty &&
1004
+ (pendingDeviceName != deviceName || sgc.type != defaultWearable)
1005
+ if identityChanged || !pendingDeviceAddress.isEmpty {
1006
+ deviceAddress = pendingDeviceAddress
1007
+ }
1001
1008
  if !pendingDeviceName.isEmpty {
1002
1009
  deviceName = pendingDeviceName
1003
1010
  }
1004
- if !pendingDeviceAddress.isEmpty {
1005
- deviceAddress = pendingDeviceAddress
1006
- }
1007
1011
  clearPendingConnection()
1008
1012
  defaultWearable = sgc.type
1009
1013
  searching = false
@@ -3,9 +3,9 @@ import Foundation
3
3
  /// Generated by release CI. Do not edit in a release checkout.
4
4
  enum GeneratedReleaseMetadata {
5
5
  static let familyBaseVersion = "3.3.0"
6
- static let releaseIdentity = "3.3.0-dev.382"
7
- static let releaseSetId = "mentra-3.3.0-dev.382"
8
- static let sourceCommit = "6a757abb267592562702b3320cdbb3281e90a59e"
9
- static let otaManifestUrl = "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.3.0/mentra-live-ota-3.3.0-dev.382.json"
10
- static let otaManifestSha256 = "7d807fb6eea2ed3b6e0fe915decfe79524504752c1c7d8f48b24a3c1e5d56d31"
6
+ static let releaseIdentity = "3.3.0-dev.391"
7
+ static let releaseSetId = "mentra-3.3.0-dev.391"
8
+ static let sourceCommit = "2763761a9cff7660e63412e9d006b4e20033f90b"
9
+ static let otaManifestUrl = "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.3.0/mentra-live-ota-3.3.0-dev.391.json"
10
+ static let otaManifestSha256 = "c079797bc98050c666d513a4960fd41a6fb5dba5ea809f876954633ae08621fa"
11
11
  }
@@ -468,8 +468,10 @@ class G1: NSObject, SGCManager {
468
468
  set {
469
469
  if let newValue = newValue {
470
470
  UserDefaults.standard.set(newValue.uuidString, forKey: "leftGlassUUID")
471
+ UserDefaults.standard.set(DEVICE_SEARCH_ID, forKey: "leftGlassSearchID")
471
472
  } else {
472
473
  UserDefaults.standard.removeObject(forKey: "leftGlassUUID")
474
+ UserDefaults.standard.removeObject(forKey: "leftGlassSearchID")
473
475
  }
474
476
  }
475
477
  }
@@ -484,8 +486,10 @@ class G1: NSObject, SGCManager {
484
486
  set {
485
487
  if let newValue = newValue {
486
488
  UserDefaults.standard.set(newValue.uuidString, forKey: "rightGlassUUID")
489
+ UserDefaults.standard.set(DEVICE_SEARCH_ID, forKey: "rightGlassSearchID")
487
490
  } else {
488
491
  UserDefaults.standard.removeObject(forKey: "rightGlassUUID")
492
+ UserDefaults.standard.removeObject(forKey: "rightGlassSearchID")
489
493
  }
490
494
  }
491
495
  }
@@ -2401,17 +2405,22 @@ extension G1: CBCentralManagerDelegate, CBPeripheralDelegate {
2401
2405
  }
2402
2406
 
2403
2407
  Bridge.log("G1: 🔵 Attempting to connect by UUID")
2404
- var foundAny = false
2408
+ var foundSides = 0
2405
2409
 
2406
2410
  if let leftUUID = leftGlassUUID {
2407
2411
  Bridge.log("G1: 🔵 Found stored left glass UUID: \(leftUUID.uuidString)")
2408
2412
  let leftDevices = centralManager!.retrievePeripherals(withIdentifiers: [leftUUID])
2409
2413
 
2410
- if let leftDevice = leftDevices.first {
2414
+ if let leftDevice = leftDevices.first,
2415
+ G1ConnectionTarget.matchesCachedPeripheral(
2416
+ name: leftDevice.name, searchID: DEVICE_SEARCH_ID,
2417
+ cachedSearchID: UserDefaults.standard.string(forKey: "leftGlassSearchID")
2418
+ )
2419
+ {
2411
2420
  Bridge.log(
2412
2421
  "G1: 🔵 Successfully retrieved left glass: \(leftDevice.name ?? "Unknown")"
2413
2422
  )
2414
- foundAny = true
2423
+ foundSides += 1
2415
2424
  leftPeripheral = leftDevice
2416
2425
  leftDevice.delegate = self
2417
2426
  centralManager!.connect(
@@ -2428,11 +2437,16 @@ extension G1: CBCentralManagerDelegate, CBPeripheralDelegate {
2428
2437
  Bridge.log("G1: 🔵 Found stored right glass UUID: \(rightUUID.uuidString)")
2429
2438
  let rightDevices = centralManager!.retrievePeripherals(withIdentifiers: [rightUUID])
2430
2439
 
2431
- if let rightDevice = rightDevices.first {
2440
+ if let rightDevice = rightDevices.first,
2441
+ G1ConnectionTarget.matchesCachedPeripheral(
2442
+ name: rightDevice.name, searchID: DEVICE_SEARCH_ID,
2443
+ cachedSearchID: UserDefaults.standard.string(forKey: "rightGlassSearchID")
2444
+ )
2445
+ {
2432
2446
  Bridge.log(
2433
2447
  "G1: 🔵 Successfully retrieved right glass: \(rightDevice.name ?? "Unknown")"
2434
2448
  )
2435
- foundAny = true
2449
+ foundSides += 1
2436
2450
  rightPeripheral = rightDevice
2437
2451
  rightDevice.delegate = self
2438
2452
  centralManager!.connect(
@@ -2445,7 +2459,8 @@ extension G1: CBCentralManagerDelegate, CBPeripheralDelegate {
2445
2459
  }
2446
2460
  }
2447
2461
 
2448
- return foundAny
2462
+ // Continue scanning if only one matching cached half was available.
2463
+ return foundSides == 2
2449
2464
  }
2450
2465
 
2451
2466
  func peripheral(_ peripheral: CBPeripheral, didDiscoverServices _: Error?) {
@@ -0,0 +1,13 @@
1
+ import Foundation
2
+
3
+ enum G1ConnectionTarget {
4
+ /// A legacy UUID needs a matching peripheral name; newly recorded UUIDs can
5
+ /// also reconnect without a name because their search ID was saved with them.
6
+ static func matchesCachedPeripheral(name: String?, searchID: String, cachedSearchID: String?) -> Bool {
7
+ guard searchID != "NOT_SET", searchID.count > 2 else { return false }
8
+ if let name, !name.isEmpty {
9
+ return name.contains("G1" + searchID)
10
+ }
11
+ return cachedSearchID == searchID
12
+ }
13
+ }
@@ -133,6 +133,15 @@ class Mach1: UltraliteBaseViewController, SGCManager {
133
133
 
134
134
  /// Store discovered peripherals by their identifier
135
135
  private var discoveredPeripherals: [String: CBPeripheral] = [:]
136
+ private var selectedPeripheralID: String?
137
+
138
+ private var currentDeviceMatchesSelection: Bool {
139
+ guard let selectedPeripheralID else { return true }
140
+ let currentDevice = UltraliteManager.shared.currentDevice
141
+ return VuzixConnectionTarget.matches(
142
+ name: currentDevice?.peripheral?.name ?? currentDevice?.getName(), target: selectedPeripheralID
143
+ )
144
+ }
136
145
 
137
146
  private var textHandle: Int?
138
147
  private var tapTextHandle: Int?
@@ -146,6 +155,7 @@ class Mach1: UltraliteBaseViewController, SGCManager {
146
155
  if setupDone { return }
147
156
  isConnectedListener = BondListener(listener: { [weak self] value in
148
157
  guard let self else { return }
158
+ guard !value || self.currentDeviceMatchesSelection else { return }
149
159
  Bridge.log("MACH1: isConnectedListener: \(value)")
150
160
 
151
161
  if value {
@@ -171,6 +181,7 @@ class Mach1: UltraliteBaseViewController, SGCManager {
171
181
 
172
182
  batteryLevelListener = BondListener(listener: { [weak self] value in
173
183
  guard let self else { return }
184
+ guard self.currentDeviceMatchesSelection else { return }
174
185
  Bridge.log("MACH1: batteryLevelListener: \(value)")
175
186
  batteryLevel = value
176
187
  DeviceStore.shared.apply("glasses", "batteryLevel", value)
@@ -238,23 +249,22 @@ class Mach1: UltraliteBaseViewController, SGCManager {
238
249
 
239
250
  // Extract the ID from the device name if it contains brackets
240
251
  // e.g., "Vuzix Z100 [f1b87c]" -> "f1b87c"
241
- var peripheralId = id
242
- if let deviceId = id.split(separator: "[").last?.split(separator: "]").first {
243
- peripheralId = String(deviceId)
244
- }
252
+ let peripheralId = VuzixConnectionTarget.identifier(id)
253
+ selectedPeripheralID = peripheralId
245
254
 
246
255
  let isLinked = UltraliteManager.shared.isLinked.value
247
256
  let currentDevice = UltraliteManager.shared.currentDevice
248
257
  let isConnected =
249
258
  isLinked && currentDevice != nil && currentDevice!.isPaired
250
259
  && currentDevice!.isConnected.value
251
- let peripheral = discoveredPeripherals[peripheralId] ?? currentDevice?.peripheral
260
+ let matchesSelection = currentDeviceMatchesSelection
261
+ let peripheral = discoveredPeripherals[peripheralId] ?? (matchesSelection ? currentDevice?.peripheral : nil)
252
262
 
253
263
  // Bind listeners to get notified when device connects
254
264
  UltraliteManager.shared.currentDevice?.isConnected.bind(listener: isConnectedListener!)
255
265
  UltraliteManager.shared.currentDevice?.batteryLevel.bind(listener: batteryLevelListener!)
256
266
 
257
- if isConnected {
267
+ if isConnected && matchesSelection {
258
268
  // Already connected, request control now
259
269
  let gotControl = currentDevice?.requestControl(
260
270
  layout: UltraliteSDK.Ultralite.Layout.textBottomLeftAlign, timeout: 0,
@@ -266,7 +276,7 @@ class Mach1: UltraliteBaseViewController, SGCManager {
266
276
  return
267
277
  }
268
278
 
269
- if !isLinked {
279
+ if !isLinked || !matchesSelection {
270
280
  if peripheral == nil {
271
281
  Bridge.log("Mach1Manager: No peripheral found or stored with ID: \(peripheralId)")
272
282
  CONNECTING_DEVICE = peripheralId
@@ -279,6 +289,8 @@ class Mach1: UltraliteBaseViewController, SGCManager {
279
289
  UltraliteManager.shared.stopScan()
280
290
  CONNECTING_DEVICE = ""
281
291
 
292
+ // Replace the SDK link only after finding the explicitly selected device.
293
+ if isLinked { UltraliteManager.shared.unlink() }
282
294
  UltraliteManager.shared.link(device: peripheral!, callback: linked)
283
295
  UltraliteManager.shared.currentDevice?.isConnected.bind(listener: isConnectedListener!)
284
296
  UltraliteManager.shared.currentDevice?.batteryLevel.bind(
@@ -3016,7 +3016,9 @@ class MentraLive: NSObject, SGCManager {
3016
3016
 
3017
3017
  case "stream_controller_probe":
3018
3018
  if let response = StreamControllerProbe.response(json) {
3019
- sendJson(response)
3019
+ // BES buffers non-waking commands when MTK enters standby, even while
3020
+ // its streaming CPU lease is held. The current probe needs a live reply.
3021
+ sendJson(response, wakeUp: true)
3020
3022
  }
3021
3023
 
3022
3024
  case "pong":
@@ -5284,7 +5286,8 @@ class MentraLive: NSObject, SGCManager {
5284
5286
  if let requestId {
5285
5287
  json["request_id"] = requestId
5286
5288
  }
5287
- sendJson(json)
5289
+ // Wake ASG so the version request and its response can finish after idle.
5290
+ sendJson(json, wakeUp: true)
5288
5291
  }
5289
5292
 
5290
5293
  private func sendCoreTokenToAsgClient() {
@@ -994,7 +994,7 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
994
994
  // global "last" one. peripheralToConnectName == nil means an auto-reconnect with no
995
995
  // specific target, so the cached device is exactly what we want — keep using it.)
996
996
  if let targetName = peripheralToConnectName,
997
- !(peripheralToConnect.name?.contains(targetName) ?? false)
997
+ peripheralToConnect.name != targetName
998
998
  {
999
999
  Bridge.log(
1000
1000
  "NEX-CONN: 🔵 Stored UUID is '\(peripheralToConnect.name ?? "unnamed")' but target is '\(targetName)'. Skipping UUID fast-path; will scan for the target."
@@ -1088,7 +1088,7 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
1088
1088
  ])
1089
1089
  if let targetName = peripheralToConnectName,
1090
1090
  let existingPeripheral = connectedPeripherals.first(where: {
1091
- $0.name?.contains(targetName) == true
1091
+ $0.name == targetName
1092
1092
  })
1093
1093
  {
1094
1094
  Bridge.log(
@@ -2724,35 +2724,12 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
2724
2724
  // Always emit the discovered device for the UI list
2725
2725
  emitDiscoveredDevice(deviceName)
2726
2726
 
2727
- // Auto-connect logic based on target or saved device (from Java MentraNexSGC)
2728
- var shouldConnect = false
2729
- var connectionReason = ""
2730
-
2731
- // Check if this matches our target device name for connection
2732
- if let targetName = peripheralToConnectName, deviceName.contains(targetName) {
2733
- shouldConnect = true
2734
- connectionReason = "Target device name match: \(targetName)"
2735
- }
2736
- // During a user-initiated discovery scan, only list devices — never auto-connect a
2737
- // saved/preferred device, so the user can pick a different one.
2738
- else if isDiscoveryScan {
2739
- shouldConnect = false
2740
- }
2741
- // Check if this matches our saved device for reconnection
2742
- else if let savedName = savedDeviceName, deviceName == savedName {
2743
- shouldConnect = true
2744
- connectionReason = "Saved device reconnection: \(savedName)"
2745
- }
2746
- // Check if this matches preferred device ID
2747
- else if let preferredId = preferredDeviceId {
2748
- if let extractedId = extractDeviceId(from: deviceName), extractedId == preferredId {
2749
- shouldConnect = true
2750
- connectionReason = "Preferred device ID match: \(preferredId)"
2751
- }
2752
- }
2753
-
2754
- if shouldConnect {
2755
- connectToFoundDevice(peripheral, reason: connectionReason)
2727
+ if NexConnectionTarget.shouldConnect(
2728
+ name: deviceName, target: peripheralToConnectName, discoveryOnly: isDiscoveryScan,
2729
+ savedName: savedDeviceName, preferredID: preferredDeviceId,
2730
+ discoveredID: extractDeviceId(from: deviceName)
2731
+ ) {
2732
+ connectToFoundDevice(peripheral, reason: "Selected target or saved reconnect identity")
2756
2733
  }
2757
2734
  }
2758
2735
 
@@ -0,0 +1,15 @@
1
+ import Foundation
2
+
3
+ enum NexConnectionTarget {
4
+ static func shouldConnect(
5
+ name: String, target: String?, discoveryOnly: Bool,
6
+ savedName: String?, preferredID: String?, discoveredID: String?
7
+ ) -> Bool {
8
+ guard !discoveryOnly else { return false }
9
+ // An explicit selection is authoritative even when it does not match.
10
+ // Saved-device fallbacks are only for reconnecting without a selection.
11
+ if let target { return !target.isEmpty && name == target }
12
+ if let savedName, !savedName.isEmpty, name == savedName { return true }
13
+ return preferredID != nil && preferredID == discoveredID
14
+ }
15
+ }
@@ -0,0 +1,15 @@
1
+ import Foundation
2
+
3
+ enum VuzixConnectionTarget {
4
+ static func identifier(_ name: String) -> String {
5
+ guard let start = name.lastIndex(of: "["),
6
+ let end = name[start...].firstIndex(of: "]")
7
+ else { return name }
8
+ return String(name[name.index(after: start) ..< end])
9
+ }
10
+
11
+ static func matches(name: String?, target: String) -> Bool {
12
+ guard let name, !target.isEmpty else { return false }
13
+ return identifier(name) == identifier(target)
14
+ }
15
+ }
@@ -0,0 +1,49 @@
1
+ @testable import MentraBluetoothSDK
2
+ import XCTest
3
+
4
+ @MainActor
5
+ final class DeviceIdentityPairingTests: XCTestCase {
6
+ func testReadinessPromotesCoherentIdentityAndPreservesSameDeviceReconnects() {
7
+ let store = DeviceStore.shared.store
8
+ let saved = ["bluetooth", "glasses"].map { ($0, store.getCategory($0)) }
9
+ defer {
10
+ for (category, values) in saved {
11
+ for key in store.getCategory(category).keys where values[key] == nil {
12
+ store.remove(category, key)
13
+ }
14
+ for (key, value) in values {
15
+ store.set(category, key, value)
16
+ }
17
+ }
18
+ }
19
+ let cases = [
20
+ // pending name, pending address, saved model, expected address
21
+ ("new", "", "test", ""),
22
+ ("new", "new-address", "test", "new-address"),
23
+ ("old", "", "test", "old-address"),
24
+ ("", "", "test", "old-address"),
25
+ ("old", "", "other-model", ""),
26
+ ]
27
+ for (pendingName, pendingAddress, savedModel, expectedAddress) in cases {
28
+ let manager = DeviceManager()
29
+ let device = Simulated()
30
+ device.type = "test"
31
+ manager.sgc = device
32
+ defer {
33
+ manager.disconnect()
34
+ manager.cleanup()
35
+ }
36
+ store.set("bluetooth", "default_wearable", savedModel)
37
+ store.set("bluetooth", "device_name", "old")
38
+ store.set("bluetooth", "device_address", "old-address")
39
+ store.set("bluetooth", "pending_device_name", pendingName)
40
+ store.set("bluetooth", "pending_device_address", pendingAddress)
41
+ store.set("bluetooth", "shouldSendBootingMessage", false)
42
+ manager.handleDeviceReady()
43
+ XCTAssertEqual(DeviceStore.shared.get("bluetooth", "device_name") as? String, pendingName.isEmpty ? "old" : pendingName)
44
+ XCTAssertEqual(DeviceStore.shared.get("bluetooth", "device_address") as? String, expectedAddress)
45
+ XCTAssertEqual(DeviceStore.shared.get("bluetooth", "pending_device_name") as? String, "")
46
+ XCTAssertEqual(DeviceStore.shared.get("bluetooth", "pending_device_address") as? String, "")
47
+ }
48
+ }
49
+ }
@@ -0,0 +1,48 @@
1
+ @testable import MentraBluetoothSDK
2
+ import XCTest
3
+
4
+ final class SGCConnectionTargetTests: XCTestCase {
5
+ func testG1RejectsAnotherPairsLegacyUUID() {
6
+ XCTAssertFalse(G1ConnectionTarget.matchesCachedPeripheral(name: "Even G1_12_L_123456", searchID: "_47_", cachedSearchID: nil))
7
+ XCTAssertTrue(G1ConnectionTarget.matchesCachedPeripheral(name: "Even G1_47_L_123456", searchID: "_47_", cachedSearchID: nil))
8
+ XCTAssertFalse(G1ConnectionTarget.matchesCachedPeripheral(name: "Even G1_470_R_123456", searchID: "_47_", cachedSearchID: nil))
9
+ }
10
+
11
+ func testG1NamelessBackgroundReconnectRequiresRecordedTarget() {
12
+ XCTAssertTrue(G1ConnectionTarget.matchesCachedPeripheral(name: nil, searchID: "_47_", cachedSearchID: "_47_"))
13
+ XCTAssertFalse(G1ConnectionTarget.matchesCachedPeripheral(name: nil, searchID: "_47_", cachedSearchID: "_12_"))
14
+ XCTAssertFalse(G1ConnectionTarget.matchesCachedPeripheral(name: nil, searchID: "_47_", cachedSearchID: nil))
15
+ XCTAssertFalse(G1ConnectionTarget.matchesCachedPeripheral(name: "Even G1_12_L_123456", searchID: "_47_", cachedSearchID: "_47_"))
16
+ XCTAssertFalse(G1ConnectionTarget.matchesCachedPeripheral(name: nil, searchID: "NOT_SET", cachedSearchID: "NOT_SET"))
17
+ }
18
+
19
+ func testNexExplicitSelectionExcludesSavedAndPreferredFallbacks() {
20
+ XCTAssertFalse(nex(name: "Nex1-old", target: "Nex1-new"))
21
+ XCTAssertFalse(nex(name: "Nex1-new-extra", target: "Nex1-new"))
22
+ XCTAssertTrue(nex(name: "Nex1-new", target: "Nex1-new"))
23
+ XCTAssertFalse(nex(name: "Nex1-old", target: ""))
24
+ }
25
+
26
+ func testNexDiscoveryNeverConnectsAndSavedReconnectStillWorks() {
27
+ XCTAssertFalse(nex(name: "Nex1-old", target: nil, discovery: true))
28
+ XCTAssertFalse(nex(name: "Nex1-new", target: "Nex1-new", discovery: true))
29
+ XCTAssertTrue(nex(name: "Nex1-old", target: nil))
30
+ XCTAssertTrue(nex(name: "Nex1-renamed", target: nil))
31
+ }
32
+
33
+ func testVuzixCurrentLinkMustMatchTheSelectedBracketedID() {
34
+ XCTAssertTrue(VuzixConnectionTarget.matches(name: "Vuzix Z100 [abc123]", target: "abc123"))
35
+ XCTAssertTrue(VuzixConnectionTarget.matches(name: "Vuzix Z100 [abc123]", target: "Vuzix Z100 [abc123]"))
36
+ XCTAssertFalse(VuzixConnectionTarget.matches(name: "Vuzix Z100 [old123]", target: "abc123"))
37
+ XCTAssertFalse(VuzixConnectionTarget.matches(name: nil, target: "abc123"))
38
+ XCTAssertFalse(VuzixConnectionTarget.matches(name: "", target: ""))
39
+ XCTAssertEqual(VuzixConnectionTarget.identifier("abc123"), "abc123")
40
+ }
41
+
42
+ private func nex(name: String, target: String?, discovery: Bool = false) -> Bool {
43
+ NexConnectionTarget.shouldConnect(
44
+ name: name, target: target, discoveryOnly: discovery,
45
+ savedName: "Nex1-old", preferredID: "old", discoveredID: "old"
46
+ )
47
+ }
48
+ }