@mentra/bluetooth-sdk 3.3.0-dev.517 → 3.3.0-dev.525

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 (50) hide show
  1. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +2 -0
  2. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +55 -10
  3. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +5 -5
  4. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Nimo.kt +63 -6
  5. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCoordinator.kt +26 -13
  6. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasSession.kt +31 -19
  7. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SerializedGattCallback.kt +5 -0
  8. package/android/src/test/java/com/mentra/bluetoothsdk/DeviceManagerSceneHandoffTest.kt +111 -2
  9. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveGattCallbackTest.kt +33 -0
  10. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCoordinatorTest.kt +44 -4
  11. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasSessionTest.kt +82 -13
  12. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoMicReconnectTest.kt +93 -0
  13. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoProtocolTest.kt +11 -0
  14. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoLifecycleTest.kt +94 -0
  15. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoReadinessDriverTest.kt +15 -9
  16. package/build/BluetoothSdk.types.d.ts +2 -0
  17. package/build/BluetoothSdk.types.d.ts.map +1 -1
  18. package/build/BluetoothSdk.types.js +1 -0
  19. package/build/BluetoothSdk.types.js.map +1 -1
  20. package/build/_private/BluetoothSdkModule.d.ts +2 -0
  21. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  22. package/build/_private/BluetoothSdkModule.js.map +1 -1
  23. package/build/generated/releaseMetadata.js +5 -5
  24. package/build/generated/releaseMetadata.js.map +1 -1
  25. package/ios/BluetoothSdkModule.swift +4 -0
  26. package/ios/Source/BluetoothSdkDefaults.swift +1 -1
  27. package/ios/Source/DeviceManager.swift +66 -50
  28. package/ios/Source/GeneratedReleaseMetadata.swift +5 -5
  29. package/ios/Source/audio/GlassesMicWatchdog.swift +33 -0
  30. package/ios/Source/sgcs/Ar99.swift +1 -2
  31. package/ios/Source/sgcs/G2.swift +259 -245
  32. package/ios/Source/sgcs/G2ConnectionSession.swift +101 -0
  33. package/ios/Source/sgcs/Nimo.swift +104 -43
  34. package/ios/Source/sgcs/NimoCanvasCoordinator.swift +20 -6
  35. package/ios/Source/sgcs/NimoCanvasSession.swift +30 -14
  36. package/ios/Source/sgcs/NimoDiscovery.swift +49 -0
  37. package/ios/Source/status/DeviceStatus.swift +1 -0
  38. package/ios/Source/stt/SherpaOnnxTranscriber.swift +49 -189
  39. package/ios/Source/utils/DemandDrivenAudioWorker.swift +150 -0
  40. package/ios/Tests/DemandDrivenAudioWorkerTests.swift +157 -0
  41. package/ios/Tests/DeviceManagerReconnectTests.swift +362 -0
  42. package/ios/Tests/G2ConnectionSessionTests.swift +172 -0
  43. package/ios/Tests/NimoCanvasSessionTests.swift +74 -5
  44. package/ios/Tests/NimoCanvasTransportTests.swift +25 -0
  45. package/ios/Tests/NimoDeviceCommandsTests.swift +16 -0
  46. package/ios/Tests/NimoDiscoveryTests.swift +79 -0
  47. package/package.json +2 -2
  48. package/src/BluetoothSdk.types.ts +3 -0
  49. package/src/_private/BluetoothSdkModule.ts +2 -0
  50. package/src/generated/releaseMetadata.ts +5 -5
@@ -1386,8 +1386,9 @@ private final class ImgAckBox {
1386
1386
 
1387
1387
  // MARK: - G2 Class (SGCManager implementation)
1388
1388
 
1389
- /// Actor for reconnection logic (matches G1 pattern)
1390
- actor G2ReconnectionManager {
1389
+ /// Reconnection attempts share the driver's main-actor lifecycle.
1390
+ @MainActor
1391
+ final class G2ReconnectionManager {
1391
1392
  private var task: Task<Void, Never>?
1392
1393
  private let intervalSeconds: TimeInterval
1393
1394
  private var attempts = 0
@@ -1459,10 +1460,10 @@ class G2: NSObject, SGCManager {
1459
1460
  private var rightNotifyChar: CBCharacteristic?
1460
1461
  private var rightAudioChar: CBCharacteristic?
1461
1462
  private var leftAudioChar: CBCharacteristic?
1462
- private var leftInitialized: Bool = false
1463
- private var rightInitialized: Bool = false
1464
- private var leftAuthenticated: Bool = false
1465
- private var rightAuthenticated: Bool = false
1463
+ private var connectionSession = G2ConnectionSession()
1464
+ private var partialConnection = G2PartialConnection()
1465
+ private var partialConnectionTimer: DispatchWorkItem?
1466
+ private var authTask: Task<Void, Never>?
1466
1467
  private var isDisconnecting = false
1467
1468
  private var pairingTimeoutTimer: DispatchWorkItem?
1468
1469
  private var useEvenDashboard = true
@@ -1528,7 +1529,7 @@ class G2: NSObject, SGCManager {
1528
1529
 
1529
1530
  // Protocol state
1530
1531
  private let sendManager = G2SendManager()
1531
- private let receiveManager = G2ReceiveManager()
1532
+ private var receiveManager = G2ReceiveManager()
1532
1533
  private var foregroundObserver: NSObjectProtocol?
1533
1534
  private var startupPageCreated: Bool = false // createStartUpPageContainer can only be called once
1534
1535
  private var pageCreated: Bool = false
@@ -1564,7 +1565,6 @@ class G2: NSObject, SGCManager {
1564
1565
  /// How many redundant resends each text update gets (text has no ACK). The reconcile loop sets a
1565
1566
  /// container's `pendingSends` to `1 + EVEN_HUB_RESEND_COUNT` on change.
1566
1567
  private let EVEN_HUB_RESEND_COUNT: Int = 1
1567
- private var authStarted: Bool = false
1568
1568
 
1569
1569
  /// Dashboard menu: appId → packageName mapping for selection reverse lookup
1570
1570
  private var menuAppIdToPackageName: [Int32: String] = [:]
@@ -1754,16 +1754,17 @@ class G2: NSObject, SGCManager {
1754
1754
  if leftDraining { return }
1755
1755
  leftDraining = true
1756
1756
  }
1757
+ let generation = connectionSession.generation
1757
1758
  Task { @MainActor [weak self] in
1758
- await self?.drainLoop(right: right)
1759
+ await self?.drainLoop(right: right, generation: generation)
1759
1760
  }
1760
1761
  }
1761
1762
 
1762
1763
  /// Drain one side's queue: write each packet directly (`.withoutResponse`), paced by a small
1763
1764
  /// sleep. No `canSend` gate (see note above). Runs until the queue is empty, then clears the
1764
1765
  /// per-side flag so the next enqueue restarts it.
1765
- private func drainLoop(right: Bool) async {
1766
- while true {
1766
+ private func drainLoop(right: Bool, generation: UInt64) async {
1767
+ while connectionSession.generation == generation && !Task.isCancelled {
1767
1768
  guard let peripheral = right ? rightPeripheral : leftPeripheral,
1768
1769
  let char = right ? rightWriteChar : leftWriteChar
1769
1770
  else {
@@ -1891,20 +1892,19 @@ class G2: NSObject, SGCManager {
1891
1892
 
1892
1893
  // MARK: - Authentication Sequence
1893
1894
 
1894
- private func authLeft() {
1895
- // Auth to left side
1896
- if leftPeripheral != nil && leftWriteChar != nil {
1897
- let authL = DevSettingsProto.authCmd(magicRandom: sendManager.nextMagicRandom())
1898
- sendDevSettingsCommand(authL, left: true, right: false)
1899
- }
1895
+ private func startAuthenticationIfReady() {
1896
+ guard let generation = connectionSession.beginAuthentication() else { return }
1897
+ Bridge.log("G2: Both sides initialized, starting auth sequence")
1898
+ authTask = Task { [weak self] in await self?.runAuthSequence(generation: generation) }
1900
1899
  }
1901
1900
 
1902
- private func authRight() {
1903
- let authR = DevSettingsProto.authCmd(magicRandom: sendManager.nextMagicRandom())
1904
- sendDevSettingsCommand(authR, left: false, right: true)
1901
+ private func waitForAuthStep(generation: UInt64) async -> Bool {
1902
+ do { try await Task.sleep(nanoseconds: 200_000_000) } catch { return false }
1903
+ return !Task.isCancelled && connectionSession.generation == generation && !isDisconnecting
1905
1904
  }
1906
1905
 
1907
- private func runAuthSequence() async {
1906
+ private func runAuthSequence(generation: UInt64) async {
1907
+ guard !Task.isCancelled, connectionSession.generation == generation, !isDisconnecting else { return }
1908
1908
  Bridge.log("G2: Running auth sequence")
1909
1909
 
1910
1910
  // Auth to left side
@@ -1914,19 +1914,19 @@ class G2: NSObject, SGCManager {
1914
1914
  }
1915
1915
 
1916
1916
  // Small delay then auth right + pipe role change + time sync
1917
- try? await Task.sleep(nanoseconds: 200_000_000)
1917
+ guard await waitForAuthStep(generation: generation) else { return }
1918
1918
 
1919
1919
  let authR = DevSettingsProto.authCmd(magicRandom: sendManager.nextMagicRandom())
1920
1920
  sendDevSettingsCommand(authR, left: false, right: true)
1921
1921
 
1922
- try? await Task.sleep(nanoseconds: 200_000_000)
1922
+ guard await waitForAuthStep(generation: generation) else { return }
1923
1923
 
1924
1924
  let roleChange = DevSettingsProto.pipeRoleChange(
1925
1925
  magicRandom: sendManager.nextMagicRandom()
1926
1926
  )
1927
1927
  sendDevSettingsCommand(roleChange, left: false, right: true)
1928
1928
 
1929
- try? await Task.sleep(nanoseconds: 200_000_000)
1929
+ guard await waitForAuthStep(generation: generation) else { return }
1930
1930
 
1931
1931
  let timeSync = DevSettingsProto.timeSync(
1932
1932
  magicRandom: sendManager.nextMagicRandom()
@@ -1934,7 +1934,7 @@ class G2: NSObject, SGCManager {
1934
1934
  sendDevSettingsCommand(timeSync, left: true, right: true)
1935
1935
 
1936
1936
  // Skip onboarding on connect
1937
- try? await Task.sleep(nanoseconds: 200_000_000)
1937
+ guard await waitForAuthStep(generation: generation) else { return }
1938
1938
  let onboarding = OnboardingProto.skipOnboarding(
1939
1939
  magicRandom: sendManager.nextMagicRandom()
1940
1940
  )
@@ -2018,11 +2018,21 @@ class G2: NSObject, SGCManager {
2018
2018
 
2019
2019
  Bridge.log("G2: Sent full Even-compatible init sequence")
2020
2020
 
2021
- // Start heartbeats after auth
2022
- startHeartbeats()
2021
+ connectionSession.completeSetup(generation: generation)
2022
+ finishConnectionIfReady()
2023
+ }
2024
+
2025
+ private func finishConnectionIfReady() {
2026
+ guard connectionSession.isReady, !isDisconnecting,
2027
+ leftPeripheral?.state == .connected, rightPeripheral?.state == .connected,
2028
+ DeviceStore.shared.get("glasses", "fullyBooted") as? Bool != true
2029
+ else { return }
2023
2030
 
2024
- Task { await self.reconnectionManager.stop() }
2025
- Bridge.log("G2: Auth sequence complete, glasses ready")
2031
+ cancelPairingTimeout()
2032
+ updatePartialConnection()
2033
+ startHeartbeats()
2034
+ reconnectionManager.stop()
2035
+ Bridge.log("G2: Both arms authenticated and initialization complete, glasses ready")
2026
2036
 
2027
2037
  // Set device_name so DeviceManager can save it for reconnection
2028
2038
  let peripheralName = rightPeripheral?.name ?? leftPeripheral?.name
@@ -2048,6 +2058,8 @@ class G2: NSObject, SGCManager {
2048
2058
  DeviceStore.shared.apply("glasses", "deviceModel", DeviceTypes.G2)
2049
2059
 
2050
2060
  setFullyConnected()
2061
+ sendEvenHubHeartbeat()
2062
+ sendDevSettingsHeartbeat()
2051
2063
 
2052
2064
  // connnect a controller if we have one:
2053
2065
  connectController()
@@ -2973,11 +2985,14 @@ class G2: NSObject, SGCManager {
2973
2985
 
2974
2986
  /// re-creates the containers and re-sends all images to the glasses:
2975
2987
  private func rebuildState() async {
2988
+ guard connectionSession.isReady, !isDisconnecting else { return }
2989
+ let generation = connectionSession.generation
2976
2990
  Bridge.log("G2: rebuildState()")
2977
2991
  // recreate the containers (sets pageCreated = true; embeds text content directly):
2978
2992
  createPageWithContainers()
2979
2993
 
2980
- try? await Task.sleep(nanoseconds: 300_000_000) // 300ms to settle
2994
+ do { try await Task.sleep(nanoseconds: 300_000_000) } catch { return }
2995
+ guard connectionSession.generation == generation, connectionSession.isReady else { return }
2981
2996
  // Mark every image container dirty and let the reconcile loop re-send them, one at a time.
2982
2997
  // Doing the sends here directly is what used to race a concurrent displayBitmap and clobber
2983
2998
  // imgAckBox; routing through the dirty flag keeps a single sender (see displayReconcileTask).
@@ -2990,7 +3005,8 @@ class G2: NSObject, SGCManager {
2990
3005
  }
2991
3006
  signalDisplayDirty()
2992
3007
 
2993
- try? await Task.sleep(nanoseconds: 300_000_000) // 300ms to settle
3008
+ do { try await Task.sleep(nanoseconds: 300_000_000) } catch { return }
3009
+ guard connectionSession.generation == generation, connectionSession.isReady else { return }
2994
3010
  syncImuReportingWithIntent()
2995
3011
  confirmImuReporting(
2996
3012
  forPageGeneration: pageGeneration,
@@ -3005,6 +3021,7 @@ class G2: NSObject, SGCManager {
3005
3021
  /// guard each one triggered a rebuild that was torn down again → rebuild→exit→rebuild
3006
3022
  /// storm. At most one rebuild in flight, and at most one per RECOVERY_DEBOUNCE_MS.
3007
3023
  private func recoverPageAndMic(reason: String) {
3024
+ guard connectionSession.isReady, !isDisconnecting else { return }
3008
3025
  let now = Int64(Date().timeIntervalSince1970 * 1000)
3009
3026
  // If the page is already alive and the mic matches intent, there's nothing to recover —
3010
3027
  // this is a spurious/phantom firmware event (it spams close/exit even when our page is
@@ -3023,11 +3040,13 @@ class G2: NSObject, SGCManager {
3023
3040
  return
3024
3041
  }
3025
3042
  recoveryInFlight = true
3043
+ let generation = connectionSession.generation
3026
3044
  lastRecoveryRebuildMs = now
3027
3045
  Bridge.log("G2: recover(\(reason)) — rebuilding EvenHub page")
3028
3046
  Task { [weak self] in
3029
3047
  guard let self = self else { return }
3030
3048
  await self.rebuildState()
3049
+ guard self.connectionSession.generation == generation, self.connectionSession.isReady else { return }
3031
3050
  // Reconcile against DeviceManager's authoritative current view so the glasses
3032
3051
  // match the phone, not just the last-cached G2 containers.
3033
3052
  DeviceManager.shared.sendCurrentState()
@@ -3649,28 +3668,67 @@ class G2: NSObject, SGCManager {
3649
3668
 
3650
3669
  func findCompatibleDevices() {
3651
3670
  Bridge.log("G2: findCompatibleDevices()")
3671
+ disconnect()
3672
+ isDisconnecting = false
3652
3673
  DEVICE_SEARCH_ID = "NOT_SET"
3653
3674
  startScan()
3654
3675
  }
3655
3676
 
3656
3677
  func connectById(_ id: String) {
3657
3678
  Bridge.log("G2: connectById(\(id))")
3679
+ disconnect()
3658
3680
  DEVICE_SEARCH_ID = id
3681
+ isDisconnecting = false
3659
3682
  startScan()
3660
- startPairingTimeout()
3661
3683
  }
3662
3684
 
3663
3685
  private func startPairingTimeout() {
3664
- pairingTimeoutTimer?.cancel()
3686
+ guard pairingTimeoutTimer == nil, !connectionSession.isReady,
3687
+ DEVICE_SEARCH_ID != "NOT_SET", !DEVICE_SEARCH_ID.isEmpty else { return }
3688
+ let generation = connectionSession.generation
3665
3689
  let work = DispatchWorkItem { [weak self] in
3666
- guard let self = self else { return }
3667
- if self.leftPeripheral != nil && self.rightPeripheral == nil {
3668
- Bridge.log("G2: pairing timeout — found LEFT but not RIGHT")
3669
- Bridge.sendPairFailureEvent("errors:pairNeedDisconnect")
3670
- }
3690
+ guard let self, !self.isDisconnecting,
3691
+ self.connectionSession.needsRecovery(attempt: generation) else { return }
3692
+ Bridge.log("G2: Pair initialization timed out; retrying both arms")
3693
+ self.updatePartialConnection()
3694
+ let error = self.partialConnection.timeoutError
3695
+ self.recoverConnection()
3696
+ Bridge.sendPairFailureEvent(error)
3671
3697
  }
3672
3698
  pairingTimeoutTimer = work
3673
- DispatchQueue.main.asyncAfter(deadline: .now() + 10, execute: work)
3699
+ DispatchQueue.main.asyncAfter(deadline: .now() + 15, execute: work)
3700
+ }
3701
+
3702
+ /// Publish a durable status field so Home and pairing agree, including after a remount.
3703
+ /// Do not flash a warning for the normal interval between the two BLE connections.
3704
+ private func updatePartialConnection() {
3705
+ partialConnectionTimer?.cancel()
3706
+ partialConnectionTimer = nil
3707
+ partialConnection.update(
3708
+ leftConnected: leftPeripheral?.state == .connected,
3709
+ rightConnected: rightPeripheral?.state == .connected,
3710
+ now: ProcessInfo.processInfo.systemUptime
3711
+ )
3712
+ publishPartialConnection()
3713
+ guard partialConnection.missingSide != nil else { return }
3714
+ let generation = connectionSession.generation
3715
+ let work = DispatchWorkItem { [weak self] in
3716
+ guard let self, !self.isDisconnecting, self.connectionSession.generation == generation else { return }
3717
+ self.partialConnection.update(
3718
+ leftConnected: self.leftPeripheral?.state == .connected,
3719
+ rightConnected: self.rightPeripheral?.state == .connected,
3720
+ now: ProcessInfo.processInfo.systemUptime
3721
+ )
3722
+ self.publishPartialConnection()
3723
+ }
3724
+ partialConnectionTimer = work
3725
+ DispatchQueue.main.asyncAfter(deadline: .now() + G2PartialConnection.noticeDelay, execute: work)
3726
+ }
3727
+
3728
+ private func publishPartialConnection() {
3729
+ let side = partialConnection.visibleMissingSide(now: ProcessInfo.processInfo.systemUptime)
3730
+ let value: Any = side.map { $0 == .left ? "left" : "right" } as Any? ?? NSNull()
3731
+ DeviceStore.shared.apply("glasses", "g2MissingArm", value)
3674
3732
  }
3675
3733
 
3676
3734
  private func cancelPairingTimeout() {
@@ -3678,49 +3736,67 @@ class G2: NSObject, SGCManager {
3678
3736
  pairingTimeoutTimer = nil
3679
3737
  }
3680
3738
 
3681
- func disconnect() {
3682
- Bridge.log("G2: disconnect()")
3683
- isDisconnecting = true
3684
- notificationEpoch += 1
3685
- notificationControlMagic = nil
3686
- clearDisplay()
3739
+ /// Retire the entire attempt before cancelling BLE, so late callbacks and suspended work
3740
+ /// cannot revive it. Only release peripherals belonging to this selected pair.
3741
+ private func resetConnection() {
3742
+ connectionSession.reset()
3743
+ partialConnectionTimer?.cancel()
3744
+ partialConnectionTimer = nil
3745
+ partialConnection = G2PartialConnection()
3746
+ publishPartialConnection()
3747
+ authTask?.cancel()
3748
+ authTask = nil
3687
3749
  cancelPairingTimeout()
3688
3750
  stopHeartbeats()
3689
- Task { await reconnectionManager.stop() }
3690
-
3691
- // Disconnect known peripherals
3692
- if let left = leftPeripheral {
3693
- centralManager?.cancelPeripheralConnection(left)
3694
- }
3695
- if let right = rightPeripheral {
3696
- centralManager?.cancelPeripheralConnection(right)
3697
- }
3698
-
3699
- // Also disconnect any other G2 peripherals the system still has connected
3700
- let connected = getConnectedDevices()
3701
- for peripheral in connected {
3702
- centralManager?.cancelPeripheralConnection(peripheral)
3703
- }
3704
-
3751
+ centralManager?.stopScan()
3752
+ notificationEpoch += 1
3753
+ notificationControlMagic = nil
3754
+ let peripherals = [leftPeripheral, rightPeripheral].compactMap { $0 }
3755
+ leftPeripheral = nil
3756
+ rightPeripheral = nil
3757
+ leftWriteChar = nil
3758
+ rightWriteChar = nil
3759
+ leftNotifyChar = nil
3760
+ rightNotifyChar = nil
3761
+ leftAudioChar = nil
3762
+ rightAudioChar = nil
3705
3763
  leftWriteQueue.removeAll()
3706
3764
  rightWriteQueue.removeAll()
3707
- leftInitialized = false
3708
- rightInitialized = false
3709
- authStarted = false
3710
- leftAuthenticated = false
3711
- rightAuthenticated = false
3765
+ leftDraining = false
3766
+ rightDraining = false
3767
+ receiveManager = G2ReceiveManager()
3712
3768
  startupPageCreated = false
3713
3769
  pageCreated = false
3770
+ pageGeneration &+= 1
3771
+ evenHubMicActive = false
3772
+ recoveryInFlight = false
3773
+ lastRecoveryRebuildMs = 0
3714
3774
  dashboardShowing = 0
3715
3775
  dashboardOpening = false
3716
3776
  heartbeatCounter = 0
3717
3777
  DeviceStore.shared.apply("glasses", "connected", false)
3718
3778
  DeviceStore.shared.apply("glasses", "fullyBooted", false)
3779
+ for peripheral in peripherals {
3780
+ centralManager?.cancelPeripheralConnection(peripheral)
3781
+ }
3782
+ publishNotificationStatus()
3783
+ }
3784
+
3785
+ private func recoverConnection() {
3786
+ resetConnection()
3787
+ startReconnectionTimer()
3788
+ }
3789
+
3790
+ func disconnect() {
3791
+ Bridge.log("G2: disconnect()")
3792
+ isDisconnecting = true
3793
+ resetConnection()
3794
+ reconnectionManager.stop()
3719
3795
  }
3720
3796
 
3721
3797
  func forget() {
3722
3798
  stopHeartbeats()
3723
- Task { await reconnectionManager.stop() }
3799
+ reconnectionManager.stop()
3724
3800
  disconnect()
3725
3801
  // Note: leftGlassUUIDMap / rightGlassUUIDMap intentionally preserved so a future
3726
3802
  // pair to the same serial number can reuse the cached peripheral UUID.
@@ -3960,7 +4036,7 @@ class G2: NSObject, SGCManager {
3960
4036
  // } else {
3961
4037
  // stopCompass()
3962
4038
  // }
3963
- Task { await runAuthSequence() }
4039
+ startAuthenticationIfReady()
3964
4040
  }
3965
4041
 
3966
4042
  /// Start a navigation session so the glasses stream compass heading via
@@ -4182,6 +4258,7 @@ class G2: NSObject, SGCManager {
4182
4258
 
4183
4259
  @discardableResult
4184
4260
  private func startScan() -> Bool {
4261
+ guard !isDisconnecting else { return false }
4185
4262
  Bridge.log("G2: startScan()")
4186
4263
  if centralManager == nil {
4187
4264
  centralManager = CBCentralManager(
@@ -4190,44 +4267,16 @@ class G2: NSObject, SGCManager {
4190
4267
  )
4191
4268
  }
4192
4269
 
4193
- isDisconnecting = false
4194
4270
  guard centralManager!.state == .poweredOn else {
4195
4271
  Bridge.log("G2: Bluetooth not powered on")
4196
4272
  return false
4197
4273
  }
4198
4274
 
4199
- let devices = getConnectedDevices()
4200
- Bridge.log("G2: connnectedDevices.count: (\(devices.count))")
4201
- for device in devices {
4202
- if let name = device.name, let serialNumber = deviceNameToSerialNumber[name] {
4203
- Bridge.log("G2: Connected to device: \(name)")
4204
-
4205
- if name.contains("_L_") && serialNumber.contains(DEVICE_SEARCH_ID) {
4206
- leftPeripheral = device
4207
- device.delegate = self
4208
- device.discoverServices([G2BLE.SERVICE_UUID])
4209
- centralManager!.connect(
4210
- leftPeripheral!,
4211
- options: notificationConnectionOptions
4212
- )
4213
- } else if name.contains("_R_") && serialNumber.contains(DEVICE_SEARCH_ID) {
4214
- rightPeripheral = device
4215
- device.delegate = self
4216
- device.discoverServices([G2BLE.SERVICE_UUID])
4217
- centralManager!.connect(
4218
- rightPeripheral!,
4219
- options: notificationConnectionOptions
4220
- )
4221
- }
4222
- // we can't emit the serial number here unfortunately:
4223
- emitDiscoveredDevice(serialNumber)
4224
- }
4225
- }
4226
-
4227
- // Try UUID-based reconnection first
4228
- if connectByUUID() {
4229
- return true
4230
- }
4275
+ startPairingTimeout()
4276
+ // UUIDs are scoped to the selected serial. Reclaim each saved arm independently,
4277
+ // including one still connected to iOS after a process restart, then scan for its peer.
4278
+ connectKnownPeripherals()
4279
+ if leftPeripheral != nil && rightPeripheral != nil { return true }
4231
4280
 
4232
4281
  centralManager!.scanForPeripherals(
4233
4282
  withServices: nil,
@@ -4242,47 +4291,34 @@ class G2: NSObject, SGCManager {
4242
4291
  centralManager?.stopScan()
4243
4292
  }
4244
4293
 
4245
- private func connectByUUID() -> Bool {
4246
- // don't do this if we don't have a search id set:
4247
- if DEVICE_SEARCH_ID == "NOT_SET" || DEVICE_SEARCH_ID.isEmpty {
4248
- Bridge.log("G2: 🔵 No DEVICE_SEARCH_ID set, skipping connect by UUID")
4249
- return false
4294
+ private func connectKnownPeripherals() {
4295
+ guard DEVICE_SEARCH_ID != "NOT_SET", !DEVICE_SEARCH_ID.isEmpty else { return }
4296
+ let leftUUID = leftGlassUUID(forSN: DEVICE_SEARCH_ID)
4297
+ let rightUUID = rightGlassUUID(forSN: DEVICE_SEARCH_ID)
4298
+ let cachedIDs = [leftUUID, rightUUID].compactMap { $0 }
4299
+ let cached = centralManager?.retrievePeripherals(withIdentifiers: cachedIDs) ?? []
4300
+ for peripheral in cached + getConnectedDevices() {
4301
+ let serial = peripheral.name.flatMap { deviceNameToSerialNumber[$0] }
4302
+ guard let side = G2ConnectionTarget.side(
4303
+ identifier: peripheral.identifier, name: peripheral.name,
4304
+ advertisedSerial: serial, searchID: DEVICE_SEARCH_ID,
4305
+ leftUUID: leftUUID, rightUUID: rightUUID
4306
+ ) else { continue }
4307
+ connectPeripheral(peripheral, side: side)
4308
+ }
4309
+ }
4310
+
4311
+ private func connectPeripheral(_ peripheral: CBPeripheral, side: G2ConnectionSession.Side) {
4312
+ guard !isDisconnecting, peripheral.state != .disconnecting else { return }
4313
+ if side == .left {
4314
+ guard leftPeripheral == nil else { return }
4315
+ leftPeripheral = peripheral
4316
+ } else {
4317
+ guard rightPeripheral == nil else { return }
4318
+ rightPeripheral = peripheral
4250
4319
  }
4251
-
4252
- guard let leftUUID = leftGlassUUID(forSN: DEVICE_SEARCH_ID),
4253
- let rightUUID = rightGlassUUID(forSN: DEVICE_SEARCH_ID)
4254
- else { return false }
4255
-
4256
- let knownLeft = centralManager?.retrievePeripherals(withIdentifiers: [leftUUID])
4257
- let knownRight = centralManager?.retrievePeripherals(withIdentifiers: [rightUUID])
4258
-
4259
- guard let left = knownLeft?.first, let right = knownRight?.first else { return false }
4260
-
4261
- // Validate the cached peripherals match the device the user selected
4262
- let leftName = left.name ?? ""
4263
- let rightName = right.name ?? ""
4264
- // if !leftName.isEmpty && !leftName.contains(DEVICE_SEARCH_ID) {
4265
- // Bridge.log(
4266
- // "G2: connectByUUID - cached left '\(leftName)' doesn't match search ID '\(DEVICE_SEARCH_ID)', skipping"
4267
- // )
4268
- // return false
4269
- // }
4270
- // if !rightName.isEmpty && !rightName.contains(DEVICE_SEARCH_ID) {
4271
- // Bridge.log(
4272
- // "G2: connectByUUID - cached right '\(rightName)' doesn't match search ID '\(DEVICE_SEARCH_ID)', skipping"
4273
- // )
4274
- // return false
4275
- // }
4276
-
4277
- Bridge.log("G2: connectByUUID - left: \(leftName), right: \(rightName)")
4278
-
4279
- leftPeripheral = left
4280
- rightPeripheral = right
4281
- left.delegate = self
4282
- right.delegate = self
4283
- centralManager?.connect(left, options: notificationConnectionOptions)
4284
- centralManager?.connect(right, options: notificationConnectionOptions)
4285
- return true
4320
+ peripheral.delegate = self
4321
+ centralManager?.connect(peripheral, options: notificationConnectionOptions)
4286
4322
  }
4287
4323
 
4288
4324
  private func getConnectedDevices() -> [CBPeripheral] {
@@ -4628,6 +4664,7 @@ class G2: NSObject, SGCManager {
4628
4664
  }
4629
4665
 
4630
4666
  private func setFullyConnected() {
4667
+ guard connectionSession.isReady, !isDisconnecting else { return }
4631
4668
  let isFullyConnected = DeviceStore.shared.get("glasses", "connected") as? Bool ?? false
4632
4669
  let isFullyBooted = DeviceStore.shared.get("glasses", "fullyBooted") as? Bool ?? false
4633
4670
  if !isFullyConnected {
@@ -4683,7 +4720,10 @@ class G2: NSObject, SGCManager {
4683
4720
  return (x, y, z)
4684
4721
  }
4685
4722
 
4686
- private func handleTouchEvent(_ devEventData: Data) {
4723
+ func handleTouchEvent(_ devEventData: Data) {
4724
+ // A single arm can emit system_exit and gestures before the pair has authenticated.
4725
+ // Those events must neither establish readiness nor drive page recovery.
4726
+ guard connectionSession.isReady, !isDisconnecting else { return }
4687
4727
  // Parse SendDeviceEvent: field 1=ListEvent, field 2=TextEvent, field 3=SysEvent
4688
4728
  var reader = ProtobufReader(devEventData)
4689
4729
  let fields = reader.parseFields()
@@ -4719,9 +4759,6 @@ class G2: NSObject, SGCManager {
4719
4759
  }
4720
4760
  lastClickTimestamp = timestamp
4721
4761
 
4722
- // if we are receiving touch events we are fully booted:
4723
- setFullyConnected()
4724
-
4725
4762
  // Bridge.log("G2: handleTouchEvent: \(fields)")
4726
4763
  // Bridge.log(
4727
4764
  // "G2: handleTouchEvent: \(devEventData.map { String(format: "%02X", $0) }.joined())")
@@ -4970,16 +5007,13 @@ class G2: NSObject, SGCManager {
4970
5007
  }
4971
5008
  let secAuthStr = secAuth.map { $0 ? "true" : "false" } ?? "?"
4972
5009
  Bridge.log("G2: Authentication response: \(sourceKey) secAuth=\(secAuthStr)")
4973
- if secAuth == true {
4974
- if sourceKey == "L" {
4975
- leftAuthenticated = true
4976
- } else if sourceKey == "R" {
4977
- rightAuthenticated = true
4978
- }
4979
- if leftAuthenticated && rightAuthenticated {
4980
- Bridge.log("G2: Both sides authenticated, setting fully booted and connected")
4981
- setFullyConnected()
4982
- }
5010
+ if secAuth == false {
5011
+ recoverConnection()
5012
+ return
5013
+ }
5014
+ if let side = G2ConnectionSession.Side(rawValue: sourceKey) {
5015
+ connectionSession.authenticated(side, success: secAuth == true)
5016
+ finishConnectionIfReady()
4983
5017
  }
4984
5018
  }
4985
5019
  }
@@ -5205,14 +5239,18 @@ extension G2: CBCentralManagerDelegate {
5205
5239
  DispatchQueue.main.async { [weak self] in
5206
5240
  guard let self = self else { return }
5207
5241
  Bridge.log("G2: Bluetooth state: \(state.rawValue)")
5242
+ guard !self.isDisconnecting else { return }
5208
5243
  if state == .poweredOn {
5209
5244
  _ = self.startScan()
5245
+ } else {
5246
+ self.reconnectionManager.stop()
5247
+ self.resetConnection()
5210
5248
  }
5211
5249
  }
5212
5250
  }
5213
5251
 
5214
5252
  nonisolated func centralManager(
5215
- _ central: CBCentralManager,
5253
+ _: CBCentralManager,
5216
5254
  didDiscover peripheral: CBPeripheral,
5217
5255
  advertisementData: [String: Any],
5218
5256
  rssi _: NSNumber
@@ -5235,6 +5273,7 @@ extension G2: CBCentralManagerDelegate {
5235
5273
  Bridge.log("G2: Could not extract SN from manufacturer data")
5236
5274
  return
5237
5275
  }
5276
+ guard !self.isDisconnecting else { return }
5238
5277
  // sn = "S200LACA040040"
5239
5278
  let mfgHex = mfgData.map { String(format: "%02X", $0) }.joined(separator: " ")
5240
5279
  Bridge.log(
@@ -5257,33 +5296,17 @@ extension G2: CBCentralManagerDelegate {
5257
5296
  self.emitDiscoveredDevice(serialNumber)
5258
5297
 
5259
5298
  // If scan-only mode (no search ID set), don't auto-connect
5260
- guard self.DEVICE_SEARCH_ID != "NOT_SET" else { return }
5261
-
5262
- // Bridge.log("G2: SN: \(serialNumber), DEVICE_SEARCH_ID: \(self.DEVICE_SEARCH_ID) name: \(name)")
5263
-
5264
- // Only connect to devices matching our search ID
5265
- guard serialNumber.contains(self.DEVICE_SEARCH_ID) else { return }
5266
-
5267
- if name.contains("_L_") {
5268
- if self.leftPeripheral == nil {
5269
- self.leftPeripheral = peripheral
5270
- peripheral.delegate = self
5271
- central.connect(peripheral, options: self.notificationConnectionOptions)
5272
- // Bridge.log("G2: Connecting to LEFT: \(name)")
5273
- }
5274
- } else if name.contains("_R_") {
5275
- if self.rightPeripheral == nil {
5276
- self.rightPeripheral = peripheral
5277
- peripheral.delegate = self
5278
- central.connect(peripheral, options: self.notificationConnectionOptions)
5279
- // Bridge.log("G2: Connecting to RIGHT: \(name)")
5280
- }
5281
- }
5282
-
5283
- // Stop scanning once we have both
5299
+ guard let side = G2ConnectionTarget.side(
5300
+ identifier: peripheral.identifier, name: name, advertisedSerial: serialNumber,
5301
+ searchID: self.DEVICE_SEARCH_ID,
5302
+ leftUUID: self.leftGlassUUID(forSN: self.DEVICE_SEARCH_ID),
5303
+ rightUUID: self.rightGlassUUID(forSN: self.DEVICE_SEARCH_ID)
5304
+ ) else { return }
5305
+ self.connectPeripheral(peripheral, side: side)
5306
+
5307
+ // Keep the attempt deadline until both arms are authenticated, not merely discovered.
5284
5308
  if self.leftPeripheral != nil && self.rightPeripheral != nil {
5285
5309
  self.stopScan()
5286
- self.cancelPairingTimeout()
5287
5310
  }
5288
5311
  }
5289
5312
  }
@@ -5300,7 +5323,10 @@ extension G2: CBCentralManagerDelegate {
5300
5323
  nonisolated func centralManager(_: CBCentralManager, didConnect peripheral: CBPeripheral) {
5301
5324
  DispatchQueue.main.async { [weak self] in
5302
5325
  guard let self = self else { return }
5326
+ guard peripheral === self.leftPeripheral || peripheral === self.rightPeripheral,
5327
+ !self.isDisconnecting else { return }
5303
5328
  Bridge.log("G2: Connected to \(peripheral.name ?? "unknown")")
5329
+ self.updatePartialConnection()
5304
5330
 
5305
5331
  // Store UUID for reconnection, keyed by serial number.
5306
5332
  let sn = peripheral.name.flatMap { self.deviceNameToSerialNumber[$0] }
@@ -5330,59 +5356,29 @@ extension G2: CBCentralManagerDelegate {
5330
5356
  Bridge.log("G2: Disconnected \(side): \(error?.localizedDescription ?? "clean")")
5331
5357
 
5332
5358
  guard peripheral === self.leftPeripheral || peripheral === self.rightPeripheral else { return }
5333
- self.notificationEpoch += 1
5334
- self.notificationControlMagic = nil
5335
- // Only reconnect if not intentionally disconnecting
5336
5359
  if self.isDisconnecting { return }
5360
+ self.recoverConnection()
5361
+ }
5362
+ }
5337
5363
 
5338
- // Clear both sides to force re-discovery (like G1)
5339
- self.leftPeripheral = nil
5340
- self.rightPeripheral = nil
5341
- self.leftWriteQueue.removeAll()
5342
- self.rightWriteQueue.removeAll()
5343
- self.leftInitialized = false
5344
- self.rightInitialized = false
5345
- self.leftWriteChar = nil
5346
- self.rightWriteChar = nil
5347
- self.leftNotifyChar = nil
5348
- self.rightNotifyChar = nil
5349
- self.leftAudioChar = nil
5350
- self.rightAudioChar = nil
5351
- self.authStarted = false
5352
-
5353
- self.startupPageCreated = false
5354
- self.pageCreated = false
5355
- self.dashboardShowing = 0
5356
- self.dashboardOpening = false
5357
- DeviceStore.shared.apply("glasses", "connected", false)
5358
- DeviceStore.shared.apply("glasses", "fullyBooted", false)
5359
- self.publishNotificationStatus()
5360
-
5361
- // Start persistent reconnection loop (every 30s, unlimited attempts)
5362
- self.startReconnectionTimer()
5364
+ nonisolated func centralManager(
5365
+ _: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?
5366
+ ) {
5367
+ Task { @MainActor [weak self] in
5368
+ guard let self, !self.isDisconnecting,
5369
+ peripheral === self.leftPeripheral || peripheral === self.rightPeripheral else { return }
5370
+ Bridge.log("G2: Connection failed: \(error?.localizedDescription ?? "unknown")")
5371
+ // The attempt deadline bounds failures without an immediate reconnect loop.
5363
5372
  }
5364
5373
  }
5365
5374
 
5366
5375
  private func startReconnectionTimer() {
5367
- Task {
5368
- await reconnectionManager.start { [weak self] in
5369
- guard let self else { return false }
5370
-
5371
- // Check if already connected
5372
- if await MainActor.run(body: {
5373
- DeviceStore.shared.get("glasses", "fullyBooted") as? Bool ?? false
5374
- }) {
5375
- Bridge.log("G2: Already connected, stopping reconnection")
5376
- return true
5377
- }
5378
-
5376
+ reconnectionManager.start { [weak self] in
5377
+ await MainActor.run {
5378
+ guard let self, !self.isDisconnecting else { return true }
5379
+ if self.connectionSession.isReady { return true }
5379
5380
  Bridge.log("G2: Attempting reconnection...")
5380
-
5381
- await MainActor.run {
5382
- self.startScan()
5383
- }
5384
-
5385
- // Return false to keep trying
5381
+ self.startScan()
5386
5382
  return false
5387
5383
  }
5388
5384
  }
@@ -5393,9 +5389,13 @@ extension G2: CBCentralManagerDelegate {
5393
5389
 
5394
5390
  extension G2: CBPeripheralDelegate {
5395
5391
  nonisolated func peripheral(_ peripheral: CBPeripheral, didDiscoverServices _: Error?) {
5396
- guard let services = peripheral.services else { return }
5397
- for service in services {
5398
- peripheral.discoverCharacteristics(nil, for: service)
5392
+ Task { @MainActor [weak self] in
5393
+ guard let self, !self.isDisconnecting,
5394
+ peripheral === self.leftPeripheral || peripheral === self.rightPeripheral,
5395
+ let services = peripheral.services else { return }
5396
+ for service in services {
5397
+ peripheral.discoverCharacteristics(nil, for: service)
5398
+ }
5399
5399
  }
5400
5400
  }
5401
5401
 
@@ -5407,6 +5407,8 @@ extension G2: CBPeripheralDelegate {
5407
5407
 
5408
5408
  DispatchQueue.main.async { [weak self] in
5409
5409
  guard let self = self else { return }
5410
+ guard !self.isDisconnecting,
5411
+ peripheral === self.leftPeripheral || peripheral === self.rightPeripheral else { return }
5410
5412
  let side = peripheral === self.leftPeripheral ? "LEFT" : "RIGHT"
5411
5413
 
5412
5414
  for char in characteristics {
@@ -5448,23 +5450,32 @@ extension G2: CBPeripheralDelegate {
5448
5450
  }
5449
5451
  }
5450
5452
 
5451
- // Check if this side is fully initialized
5452
- if peripheral === self.leftPeripheral && self.leftWriteChar != nil {
5453
- self.leftInitialized = true
5454
- Bridge.log("G2: LEFT initialized")
5455
- } else if peripheral === self.rightPeripheral && self.rightWriteChar != nil
5456
- && self.rightNotifyChar != nil
5457
- {
5458
- self.rightInitialized = true
5459
- Bridge.log("G2: RIGHT initialized")
5460
- }
5453
+ self.updateInitialization(for: peripheral)
5454
+ }
5455
+ }
5461
5456
 
5462
- // Both sides ready -> run auth (once)
5463
- if self.leftInitialized && self.rightInitialized && !self.authStarted {
5464
- self.authStarted = true
5465
- Bridge.log("G2: Both sides initialized, starting auth sequence")
5466
- Task { await self.runAuthSequence() }
5457
+ private func updateInitialization(for peripheral: CBPeripheral) {
5458
+ guard !isDisconnecting, peripheral.state == .connected else { return }
5459
+ if peripheral === leftPeripheral, leftWriteChar != nil, leftNotifyChar?.isNotifying == true {
5460
+ connectionSession.initialize(.left)
5461
+ } else if peripheral === rightPeripheral, rightWriteChar != nil, rightNotifyChar?.isNotifying == true {
5462
+ connectionSession.initialize(.right)
5463
+ }
5464
+ startAuthenticationIfReady()
5465
+ }
5466
+
5467
+ nonisolated func peripheral(
5468
+ _ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic, error: Error?
5469
+ ) {
5470
+ guard characteristic.uuid == G2BLE.CHAR_NOTIFY else { return }
5471
+ Task { @MainActor [weak self] in
5472
+ guard let self, !self.isDisconnecting,
5473
+ peripheral === self.leftPeripheral || peripheral === self.rightPeripheral else { return }
5474
+ guard error == nil, characteristic.isNotifying else {
5475
+ self.recoverConnection()
5476
+ return
5467
5477
  }
5478
+ self.updateInitialization(for: peripheral)
5468
5479
  }
5469
5480
  }
5470
5481
 
@@ -5485,7 +5496,10 @@ extension G2: CBPeripheralDelegate {
5485
5496
  Task { @MainActor [weak self] in
5486
5497
  guard let self = self else { return }
5487
5498
 
5499
+ guard !self.isDisconnecting,
5500
+ peripheral === self.leftPeripheral || peripheral === self.rightPeripheral else { return }
5488
5501
  if characteristic.uuid == G2BLE.AUDIO_NOTIFY {
5502
+ guard self.connectionSession.isReady else { return }
5489
5503
  // Audio data - forward to mic system
5490
5504
  await self.handleAudioData(data)
5491
5505
  } else if characteristic.uuid == G2BLE.CHAR_NOTIFY {