@mentra/bluetooth-sdk 3.3.0-dev.516 → 3.3.0-dev.523

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
@@ -0,0 +1,101 @@
1
+ import Foundation
2
+
3
+ /// Readiness belongs to an authenticated pair and a completed initialization attempt, never to
4
+ /// a gesture from one arm. A generation retires asynchronous setup when either arm is lost.
5
+ struct G2ConnectionSession {
6
+ enum Side: String, CaseIterable {
7
+ case left = "L"
8
+ case right = "R"
9
+ }
10
+
11
+ private(set) var generation: UInt64 = 0
12
+ private var initialized: Set<Side> = []
13
+ private var authenticatedSides: Set<Side> = []
14
+ private var authStarted = false
15
+ private var setupComplete = false
16
+
17
+ var isReady: Bool {
18
+ initialized.count == 2 && authenticatedSides.count == 2 && setupComplete
19
+ }
20
+
21
+ func needsRecovery(attempt: UInt64) -> Bool {
22
+ generation == attempt && !isReady
23
+ }
24
+
25
+ mutating func initialize(_ side: Side) {
26
+ initialized.insert(side)
27
+ }
28
+
29
+ mutating func beginAuthentication() -> UInt64? {
30
+ guard initialized.count == 2, !authStarted else { return nil }
31
+ authStarted = true
32
+ return generation
33
+ }
34
+
35
+ mutating func authenticated(_ side: Side, success: Bool) {
36
+ guard authStarted else { return }
37
+ if success { authenticatedSides.insert(side) }
38
+ else { authenticatedSides.remove(side) }
39
+ }
40
+
41
+ mutating func completeSetup(generation: UInt64) {
42
+ guard self.generation == generation, authStarted else { return }
43
+ setupComplete = true
44
+ }
45
+
46
+ mutating func reset() {
47
+ generation &+= 1
48
+ initialized.removeAll()
49
+ authenticatedSides.removeAll()
50
+ authStarted = false
51
+ setupComplete = false
52
+ }
53
+ }
54
+
55
+ enum G2ConnectionTarget {
56
+ /// A serial-scoped cached UUID identifies an arm even without a new advertisement. Nearby
57
+ /// arms can share the G2_32 name fragment; only an advertisement's serial can select an
58
+ /// uncached arm. Never fall back to names alone or another pair's cached identifiers.
59
+ static func side(
60
+ identifier: UUID, name: String?, advertisedSerial: String?, searchID: String,
61
+ leftUUID: UUID?, rightUUID: UUID?
62
+ ) -> G2ConnectionSession.Side? {
63
+ guard !searchID.isEmpty, searchID != "NOT_SET" else { return nil }
64
+ if identifier == leftUUID, identifier == rightUUID { return nil }
65
+ if identifier == leftUUID { return .left }
66
+ if identifier == rightUUID { return .right }
67
+ guard let advertisedSerial, advertisedSerial.contains(searchID), let name else { return nil }
68
+ if name.contains("_L_") { return .left }
69
+ if name.contains("_R_") { return .right }
70
+ return nil
71
+ }
72
+ }
73
+
74
+ /// Normal pairing connects the arms sequentially. Surface a missing arm only after the
75
+ /// same partial link has persisted for three seconds, using monotonic time.
76
+ struct G2PartialConnection {
77
+ static let noticeDelay: TimeInterval = 3
78
+ private(set) var missingSide: G2ConnectionSession.Side?
79
+ private var partialSince: TimeInterval = 0
80
+
81
+ mutating func update(leftConnected: Bool, rightConnected: Bool, now: TimeInterval) {
82
+ let missing: G2ConnectionSession.Side? = leftConnected == rightConnected
83
+ ? nil : (leftConnected ? .right : .left)
84
+ if missing != missingSide {
85
+ missingSide = missing
86
+ partialSince = now
87
+ }
88
+ }
89
+
90
+ func visibleMissingSide(now: TimeInterval) -> G2ConnectionSession.Side? {
91
+ now - partialSince >= Self.noticeDelay ? missingSide : nil
92
+ }
93
+
94
+ var timeoutError: String {
95
+ switch missingSide {
96
+ case .left: "errors:g2LeftArmUnavailable"
97
+ case .right: "errors:g2RightArmUnavailable"
98
+ case nil: "errors:g2ConnectionTimedOut"
99
+ }
100
+ }
101
+ }
@@ -20,10 +20,6 @@ enum NimoBLE {
20
20
  static let CHAR_RX = CBUUID(string: "00002022-0000-1000-8000-00805F9B34FB")
21
21
  static let CHAR_MIC = CBUUID(string: "00002025-0000-1000-8000-00805F9B34FB")
22
22
 
23
- static let NAME_PREFIX = "nimo"
24
- /// iOS ANCS side-channel devices advertise "<name>_ble" — never the data channel.
25
- static let BLE_NAME_SUFFIX = "_ble"
26
-
27
23
  static let CHUNK_SIZE = 501
28
24
  static let INTER_FRAME_DELAY_MS = 5
29
25
  }
@@ -44,6 +40,8 @@ enum NimoProtocol {
44
40
  static let CMD_SET_PARAMETER = 0x03
45
41
  static let CMD_INSTRUCTION_REPORT = 0x06
46
42
  static let CMD_CONTROL_INSTRUCTION = 0x07
43
+ static let CMD_CONTROL_FACTORY = 0x08
44
+ static let FACTORY_RECOVER = 0x03
47
45
  static let CMD_CONTROL_NOTIFICATION = 0x09
48
46
 
49
47
  // get parameter keys
@@ -62,6 +60,9 @@ enum NimoProtocol {
62
60
  static let SET_DISPLAY_OFF = 0x0F
63
61
  static let SET_PHONE_TYPE = 0x14
64
62
  static let SET_HEIGHT_LEVEL = 0x17
63
+ // Dynamic-v1 firmware: 1-based UI locale; 1 selects English.
64
+ static let SET_SYSTEM_LANGUAGE = 0x24
65
+ static let LANGUAGE_ENGLISH: UInt8 = 0x01
65
66
 
66
67
  // control instruction keys
67
68
  static let CTRL_ENTER_APP = 0x01
@@ -591,6 +592,27 @@ class Nimo: NSObject, SGCManager {
591
592
  private var rxChar: CBCharacteristic?
592
593
  private var micChar: CBCharacteristic?
593
594
  private var isDisconnecting = false
595
+ private var scanRequested = false
596
+ private lazy var discovery = NimoDiscovery<CBPeripheral>(
597
+ register: { [weak self] in
598
+ #if os(iOS)
599
+ self?.centralManager?.registerForConnectionEvents(options: [.serviceUUIDs: [NimoBLE.SERVICE_UUID]])
600
+ #endif
601
+ }, connected: { [weak self] in
602
+ self?.centralManager?.retrieveConnectedPeripherals(withServices: [NimoBLE.SERVICE_UUID]) ?? []
603
+ }, scan: { [weak self] in
604
+ self?.centralManager?.scanForPeripherals(
605
+ withServices: nil, options: [CBCentralManagerScanOptionAllowDuplicatesKey: false]
606
+ )
607
+ }, stop: { [weak self] in
608
+ self?.centralManager?.stopScan()
609
+ #if os(iOS)
610
+ self?.centralManager?.registerForConnectionEvents(options: [:])
611
+ #endif
612
+ }, name: { $0.name }, found: { [weak self] peripheral, name, rssi in
613
+ self?.onDeviceFound(peripheral, name: name, rssi: rssi)
614
+ }
615
+ )
594
616
 
595
617
  /// Device search
596
618
  private var DEVICE_SEARCH_ID = "NOT_SET"
@@ -680,6 +702,8 @@ class Nimo: NSObject, SGCManager {
680
702
  func findCompatibleDevices() {
681
703
  Bridge.log("NIMO: findCompatibleDevices()")
682
704
  DEVICE_SEARCH_ID = "NOT_SET"
705
+ isDisconnecting = false
706
+ scanRequested = true
683
707
  DeviceStore.shared.apply("glasses", "connectionState", ConnTypes.SCANNING)
684
708
  startScan()
685
709
  }
@@ -687,6 +711,7 @@ class Nimo: NSObject, SGCManager {
687
711
  func connectById(_ id: String) {
688
712
  Bridge.log("NIMO: connectById(\(id))")
689
713
  DEVICE_SEARCH_ID = id
714
+ scanRequested = true
690
715
  DeviceStore.shared.apply("glasses", "connectionState", ConnTypes.CONNECTING)
691
716
  isDisconnecting = false
692
717
  startPairingTimeout()
@@ -694,7 +719,8 @@ class Nimo: NSObject, SGCManager {
694
719
  }
695
720
 
696
721
  func stopScan() {
697
- centralManager?.stopScan()
722
+ scanRequested = false
723
+ discovery.cancel()
698
724
  }
699
725
 
700
726
  func disconnect() {
@@ -721,6 +747,27 @@ class Nimo: NSObject, SGCManager {
721
747
  DeviceStore.shared.apply("glasses", "connectionState", ConnTypes.DISCONNECTED)
722
748
  }
723
749
 
750
+ /// Explicit Unpair only; passive forget/cleanup must never reset the glasses.
751
+ func resetForUnpair(onResult: @escaping (Bool) -> Void) {
752
+ guard peripheral != nil, txChar != nil, handshakeState == .ready else {
753
+ Bridge.log("NIMO: unpair while offline; remote reset unavailable")
754
+ onResult(false)
755
+ return
756
+ }
757
+ isDisconnecting = true
758
+ stopTimers()
759
+ Task { await reconnectionManager.stop() }
760
+ canvasEncoder.invalidate()
761
+ canvas.disconnected()
762
+ Bridge.log("NIMO: sending factory reset for explicit Unpair")
763
+ sendAwaitingAck(cmd: NimoProtocol.CMD_CONTROL_FACTORY, key: NimoProtocol.FACTORY_RECOVER,
764
+ payload: Data())
765
+ { success in
766
+ Bridge.log("NIMO: factory reset acknowledged=\(success)")
767
+ onResult(success)
768
+ }
769
+ }
770
+
724
771
  func forget() {
725
772
  Bridge.log("NIMO: forget()")
726
773
  disconnect()
@@ -1061,13 +1108,9 @@ class Nimo: NSObject, SGCManager {
1061
1108
 
1062
1109
  // MARK: - BLE Scanning
1063
1110
 
1064
- private func isNimoMainDevice(_ name: String) -> Bool {
1065
- let lower = name.lowercased()
1066
- return lower.hasPrefix(NimoBLE.NAME_PREFIX) && !lower.hasSuffix(NimoBLE.BLE_NAME_SUFFIX)
1067
- }
1068
-
1069
1111
  @discardableResult
1070
1112
  private func startScan() -> Bool {
1113
+ guard scanRequested, !isDisconnecting else { return false }
1071
1114
  Bridge.log("NIMO: startScan()")
1072
1115
  if centralManager == nil {
1073
1116
  centralManager = CBCentralManager(
@@ -1083,17 +1126,32 @@ class Nimo: NSObject, SGCManager {
1083
1126
 
1084
1127
  // Fast path: reconnect to the cached peripheral UUID.
1085
1128
  if connectByUUID() {
1129
+ stopScan()
1086
1130
  return true
1087
1131
  }
1088
1132
 
1089
- centralManager!.scanForPeripherals(
1090
- withServices: nil,
1091
- options: [CBCentralManagerScanOptionAllowDuplicatesKey: false]
1092
- )
1133
+ // NIMO's Companion service is GATT over BR/EDR. The main device must
1134
+ // first be paired in Settings; it does not appear in a BLE-only scan.
1135
+ discovery.start()
1093
1136
  return true
1094
1137
  }
1095
1138
 
1139
+ private func onDeviceFound(_ peripheral: CBPeripheral, name: String, rssi: Int?) {
1140
+ Bridge.log("NIMO: found Companion device: \(name)")
1141
+ Bridge.sendDiscoveredDevice(DeviceTypes.NIMO, name, rssi: rssi)
1142
+ guard DEVICE_SEARCH_ID != "NOT_SET", name == DEVICE_SEARCH_ID,
1143
+ self.peripheral == nil else { return }
1144
+ Bridge.log("NIMO: Connecting to \(name)")
1145
+ stopScan()
1146
+ lastDeviceName = name
1147
+ lastDeviceUUID = peripheral.identifier.uuidString
1148
+ self.peripheral = peripheral
1149
+ peripheral.delegate = self
1150
+ centralManager?.connect(peripheral, options: nil)
1151
+ }
1152
+
1096
1153
  private func connectByUUID() -> Bool {
1154
+ guard peripheral == nil else { return false }
1097
1155
  guard DEVICE_SEARCH_ID != "NOT_SET", !DEVICE_SEARCH_ID.isEmpty else { return false }
1098
1156
  guard lastDeviceName == DEVICE_SEARCH_ID,
1099
1157
  let uuidString = lastDeviceUUID,
@@ -1255,6 +1313,9 @@ class Nimo: NSObject, SGCManager {
1255
1313
  needsAck: false
1256
1314
  )
1257
1315
  )
1316
+ // Optional setting: older firmware may reject it without preventing connection.
1317
+ sendFrame(NimoFrameCodec.encodeFrame(cmd: NimoProtocol.CMD_SET_PARAMETER,
1318
+ key: NimoProtocol.SET_SYSTEM_LANGUAGE, payload: Data([NimoProtocol.LANGUAGE_ENGLISH])))
1258
1319
  getBatteryStatus()
1259
1320
  requestVersionInfo()
1260
1321
 
@@ -1264,7 +1325,10 @@ class Nimo: NSObject, SGCManager {
1264
1325
  DeviceStore.shared.apply("glasses", "fullyBooted", true)
1265
1326
  DeviceStore.shared.apply("glasses", "connectionState", ConnTypes.CONNECTED)
1266
1327
  startTimers()
1267
- canvas.readiness(twsConnected && peerCompanionReady == true)
1328
+ // The Launch ACK validates peer readiness without waiting for the first
1329
+ // unsolicited heartbeat (which can arrive a minute after connection).
1330
+ canvas.activate()
1331
+ canvas.readiness(twsConnected && peerCompanionReady != false)
1268
1332
  }
1269
1333
 
1270
1334
  private func handshakeFailed() {
@@ -1299,7 +1363,8 @@ class Nimo: NSObject, SGCManager {
1299
1363
  }
1300
1364
  Bridge.log("NIMO: Attempting reconnection...")
1301
1365
  await MainActor.run {
1302
- self.isDisconnecting = false
1366
+ guard !self.isDisconnecting else { return }
1367
+ self.scanRequested = true
1303
1368
  self.startScan()
1304
1369
  }
1305
1370
  return false
@@ -1332,6 +1397,7 @@ class Nimo: NSObject, SGCManager {
1332
1397
  private func handleRxPacket(_ packet: Data) {
1333
1398
  if packet.count >= 10, packet[8] == 7, [1, 3, 4].contains(packet[9]) {
1334
1399
  if let response = NimoCanvasCodec.response(packet) {
1400
+ if response.key == 1 { Bridge.log("NIMO: canvas Launch response status=\(response.payload.first.map(Int.init) ?? -1)") }
1335
1401
  canvas.response(key: response.key, payload: response.payload)
1336
1402
  }
1337
1403
  return
@@ -1365,9 +1431,9 @@ class Nimo: NSObject, SGCManager {
1365
1431
  Bridge.log("NIMO: app state report appId=\(appId) phase=\(phase)")
1366
1432
  switch phase {
1367
1433
  case NimoProtocol.STATE_ENTER:
1368
- if canvas.nativeApp(appId, entered: true) { canvasEncoder.invalidate() }
1434
+ canvas.nativeApp(appId, entered: true)
1369
1435
  case NimoProtocol.STATE_EXIT:
1370
- if canvas.nativeApp(appId, entered: false) { canvasEncoder.invalidate() }
1436
+ canvas.nativeApp(appId, entered: false)
1371
1437
  default:
1372
1438
  break
1373
1439
  }
@@ -1397,7 +1463,7 @@ class Nimo: NSObject, SGCManager {
1397
1463
  peerCompanionReady = v[9] != 0
1398
1464
  onTwsState(Int(v[8]) >= 1)
1399
1465
  if handshakeState == .ready {
1400
- canvas.readiness(twsConnected && peerCompanionReady == true, confirmed: true)
1466
+ canvas.readiness(twsConnected && peerCompanionReady != false, confirmed: true)
1401
1467
  }
1402
1468
  }
1403
1469
  case NimoProtocol.BUSINESS_BATTERY:
@@ -1420,7 +1486,7 @@ class Nimo: NSObject, SGCManager {
1420
1486
  if !connected, handshakeState == .ready {
1421
1487
  Bridge.log("NIMO: TWS service dropped mid-session (arm removed/off?)")
1422
1488
  }
1423
- if handshakeState == .ready { canvas.readiness(connected && peerCompanionReady == true) }
1489
+ if handshakeState == .ready { canvas.readiness(connected && peerCompanionReady != false) }
1424
1490
  }
1425
1491
 
1426
1492
  private func handleInputEvent(_ code: Int) {
@@ -1456,6 +1522,9 @@ class Nimo: NSObject, SGCManager {
1456
1522
  }
1457
1523
 
1458
1524
  private func handleResponse(cmd: Int, key: Int, statusCode: Int, data: Data) {
1525
+ if cmd == NimoProtocol.CMD_SET_PARAMETER, key == NimoProtocol.SET_SYSTEM_LANGUAGE {
1526
+ Bridge.log("NIMO: English menu setting acknowledged=\(statusCode == 0) status=\(statusCode)")
1527
+ }
1459
1528
  resolvePendingAck(cmd: cmd, key: key, success: statusCode == 0)
1460
1529
 
1461
1530
  guard cmd == NimoProtocol.CMD_GET_PARAMETER, statusCode == 0 else { return }
@@ -1543,7 +1612,7 @@ class Nimo: NSObject, SGCManager {
1543
1612
  guard let opusDecoder else { return }
1544
1613
  for opusFrame in packet.opusFrames {
1545
1614
  if let pcm = opusDecoder.decode(opusFrame), !pcm.isEmpty {
1546
- DeviceManager.shared.handlePcm(pcm)
1615
+ DeviceManager.shared.handleGlassesPcm(pcm)
1547
1616
  }
1548
1617
  }
1549
1618
  }
@@ -1564,37 +1633,29 @@ extension Nimo: CBCentralManagerDelegate {
1564
1633
  }
1565
1634
 
1566
1635
  nonisolated func centralManager(
1567
- _ central: CBCentralManager,
1636
+ _: CBCentralManager,
1568
1637
  didDiscover peripheral: CBPeripheral,
1569
1638
  advertisementData: [String: Any],
1570
1639
  rssi: NSNumber
1571
1640
  ) {
1572
- guard
1573
- let name = peripheral.name
1574
- ?? advertisementData[CBAdvertisementDataLocalNameKey] as? String
1575
- else { return }
1641
+ let advertisedName = advertisementData[CBAdvertisementDataLocalNameKey] as? String
1576
1642
  let rssiValue = rssi.intValue
1577
-
1578
1643
  DispatchQueue.main.async { [weak self] in
1579
- guard let self else { return }
1580
- guard self.isNimoMainDevice(name) else { return }
1581
-
1582
- Bridge.sendDiscoveredDevice(DeviceTypes.NIMO, name, rssi: rssiValue)
1583
-
1584
- guard self.DEVICE_SEARCH_ID != "NOT_SET" else { return }
1585
- guard name == self.DEVICE_SEARCH_ID else { return }
1586
- guard self.peripheral == nil else { return }
1587
-
1588
- Bridge.log("NIMO: Connecting to \(name)")
1589
- self.stopScan()
1590
- self.lastDeviceName = name
1591
- self.lastDeviceUUID = peripheral.identifier.uuidString
1592
- self.peripheral = peripheral
1593
- peripheral.delegate = self
1594
- central.connect(peripheral, options: nil)
1644
+ self?.discovery.receive(peripheral, advertisedName: advertisedName, rssi: rssiValue)
1595
1645
  }
1596
1646
  }
1597
1647
 
1648
+ #if os(iOS)
1649
+ nonisolated func centralManager(
1650
+ _: CBCentralManager, connectionEventDidOccur event: CBConnectionEvent, for peripheral: CBPeripheral
1651
+ ) {
1652
+ guard event == .peerConnected else { return }
1653
+ DispatchQueue.main.async { [weak self] in
1654
+ self?.discovery.receive(peripheral)
1655
+ }
1656
+ }
1657
+ #endif
1658
+
1598
1659
  nonisolated func centralManager(_: CBCentralManager, didConnect peripheral: CBPeripheral) {
1599
1660
  DispatchQueue.main.async { [weak self] in
1600
1661
  guard let self else { return }
@@ -21,6 +21,7 @@ final class NimoCanvasCoordinator {
21
21
  private let rejected: (Int) -> Void
22
22
  private let session = NimoCanvasSession()
23
23
  private var flight: Flight?
24
+ private var cancelRetry: (() -> Void)?
24
25
  private var cancelDeadline: (() -> Void)?
25
26
 
26
27
  init(schedule: @escaping NimoScheduler, writeCapacity: @escaping () -> Int,
@@ -35,23 +36,26 @@ final class NimoCanvasCoordinator {
35
36
  run(session.offer(bytes, scope: scope, force: force))
36
37
  }
37
38
 
39
+ func activate() {
40
+ run(session.activate())
41
+ }
42
+
38
43
  func readiness(_ value: Bool, confirmed: Bool = false) {
44
+ if !value { cancelRetry?(); cancelRetry = nil }
39
45
  run(session.readiness(value, confirmed: confirmed))
40
46
  }
41
47
 
42
48
  func exit() {
49
+ cancelRetry?(); cancelRetry = nil
43
50
  run(session.exit())
44
51
  }
45
52
 
46
- /// An expected Exit report must not discard a newer host encode queued behind Exit.
47
- func nativeApp(_ appId: Int, entered: Bool) -> Bool {
48
- let takeover = (appId == NimoCanvasCodec.appId && !entered) || (appId != NimoCanvasCodec.appId && entered)
49
- let expectedExit = appId == NimoCanvasCodec.appId && !entered && flight?.key == 3
53
+ func nativeApp(_ appId: Int, entered: Bool) {
50
54
  run(session.nativeApp(appId, entered: entered))
51
- return takeover && !expectedExit
52
55
  }
53
56
 
54
57
  func disconnected() {
58
+ cancelRetry?(); cancelRetry = nil
55
59
  cancelDeadline?(); cancelDeadline = nil
56
60
  flight = nil
57
61
  session.disconnected()
@@ -72,6 +76,7 @@ final class NimoCanvasCoordinator {
72
76
  for action in actions {
73
77
  switch action {
74
78
  case let .send(key, frame, ticket):
79
+ cancelRetry?(); cancelRetry = nil
75
80
  cancelDeadline?()
76
81
  let current = Flight(key: key)
77
82
  flight = current
@@ -93,7 +98,16 @@ final class NimoCanvasCoordinator {
93
98
  disconnected(); reconnect("Canvas framing failed: \(error)")
94
99
  }
95
100
  case let .reconnect(reason): disconnected(); reconnect(reason)
96
- case let .rejected(status): rejected(status)
101
+ case let .rejected(status):
102
+ rejected(status)
103
+ if status == 7 {
104
+ cancelRetry?()
105
+ cancelRetry = schedule(1) { [weak self] in
106
+ guard let self else { return }
107
+ self.cancelRetry = nil
108
+ self.run(self.session.retryNotReady())
109
+ }
110
+ }
97
111
  }
98
112
  }
99
113
  }
@@ -15,12 +15,14 @@ final class NimoCanvasSession {
15
15
  }
16
16
 
17
17
  private var ready = false
18
+ private var transportReady = false
18
19
  private var active = false
19
20
  private var locked = false
20
21
  private var launchBlocked = false
21
22
  private var waitingReadiness = false
22
23
  private var observedNotReady = false
23
24
  private var readinessRetries = 0
25
+ private var probeRetries = 0
24
26
  private var exitRequested = false
25
27
  private var desired: Data?
26
28
  private var accepted: Data?
@@ -39,9 +41,27 @@ final class NimoCanvasSession {
39
41
  return pump()
40
42
  }
41
43
 
44
+ /// Enter a blank canvas unless a newer host scene survived the connection change.
45
+ func activate() -> [Action] {
46
+ if desired == nil { desired = Data([0, 0, 1]) }
47
+ exitRequested = false
48
+ return pump()
49
+ }
50
+
51
+ /// The firmware validates peer readiness on Launch. Only an explicit NOT_READY
52
+ /// permits a bounded retry; an ambiguous ACK still requires a new transport.
53
+ func retryNotReady() -> [Action] {
54
+ guard transportReady, waitingReadiness, desired != nil, probeRetries < 3 else { return [] }
55
+ probeRetries += 1
56
+ waitingReadiness = false
57
+ ready = true
58
+ return pump()
59
+ }
60
+
42
61
  /// Only a fresh heartbeat samples both TWS and peer Companion readiness.
43
62
  func readiness(_ value: Bool, confirmed: Bool = false) -> [Action] {
44
- if !value { readinessRetries = 0 }
63
+ transportReady = value
64
+ if !value { readinessRetries = 0; probeRetries = 0 }
45
65
  if !value, flight != nil { return resetLink("Readiness lost during canvas command") }
46
66
  if waitingReadiness {
47
67
  if !value { observedNotReady = true }
@@ -58,8 +78,8 @@ final class NimoCanvasSession {
58
78
 
59
79
  /// Keep only the latest desired scene across a new transport generation.
60
80
  func disconnected() {
61
- ready = false; active = false; locked = false; launchBlocked = false
62
- waitingReadiness = false; observedNotReady = false; readinessRetries = 0
81
+ ready = false; transportReady = false; active = false; locked = false; launchBlocked = false
82
+ waitingReadiness = false; observedNotReady = false; readinessRetries = 0; probeRetries = 0
63
83
  accepted = nil; rejected = nil; flight = nil
64
84
  ticket &+= 1; forceRevision &+= 1
65
85
  }
@@ -69,16 +89,12 @@ final class NimoCanvasSession {
69
89
  return pump()
70
90
  }
71
91
 
92
+ /// Stock UI reports do not relinquish host ownership. Replay after the current ACK.
72
93
  func nativeApp(_ appId: Int, entered: Bool) -> [Action] {
73
94
  if (appId == NimoCanvasCodec.appId && !entered) || (appId != NimoCanvasCodec.appId && entered) {
74
- if flight?.key == 3 {
75
- active = false; accepted = nil
76
- if appId != NimoCanvasCodec.appId, entered { desired = nil; rejected = nil; scope = nil }
77
- return []
78
- }
79
- let ambiguous = flight != nil
80
- desired = nil; accepted = nil; rejected = nil; active = false; exitRequested = false; scope = nil
81
- if ambiguous { return resetLink("Native app transition interrupted canvas command") }
95
+ active = false
96
+ accepted = nil
97
+ return pump()
82
98
  }
83
99
  return []
84
100
  }
@@ -112,8 +128,8 @@ final class NimoCanvasSession {
112
128
  return [.rejected(status)] + pump()
113
129
  }
114
130
  switch key {
115
- case 1: active = true
116
- case 4: accepted = current.frame; rejected = nil; readinessRetries = 0
131
+ case 1: active = true; accepted = nil
132
+ case 4: accepted = current.frame; rejected = nil; readinessRetries = 0; probeRetries = 0
117
133
  case 3: active = false; accepted = nil; exitRequested = false
118
134
  default: break
119
135
  }
@@ -126,7 +142,7 @@ final class NimoCanvasSession {
126
142
  }
127
143
 
128
144
  private func resetLink(_ reason: String) -> [Action] {
129
- ready = false; active = false; accepted = nil; flight = nil
145
+ ready = false; transportReady = false; active = false; accepted = nil; flight = nil
130
146
  ticket &+= 1; forceRevision &+= 1
131
147
  return [.reconnect(reason)]
132
148
  }
@@ -0,0 +1,49 @@
1
+ import Foundation
2
+
3
+ /// Combines system-connected classic devices with incoming CoreBluetooth discoveries.
4
+ /// Kept independent of the radio so discovery can be exercised without hardware.
5
+ @MainActor
6
+ final class NimoDiscovery<Peripheral> {
7
+ private(set) var active = false
8
+ private let register: () -> Void
9
+ private let connected: () -> [Peripheral]
10
+ private let scan: () -> Void
11
+ private let stop: () -> Void
12
+ private let name: (Peripheral) -> String?
13
+ private let found: (Peripheral, String, Int?) -> Void
14
+
15
+ init(register: @escaping () -> Void, connected: @escaping () -> [Peripheral],
16
+ scan: @escaping () -> Void, stop: @escaping () -> Void,
17
+ name: @escaping (Peripheral) -> String?, found: @escaping (Peripheral, String, Int?) -> Void)
18
+ {
19
+ self.register = register
20
+ self.connected = connected
21
+ self.scan = scan
22
+ self.stop = stop
23
+ self.name = name
24
+ self.found = found
25
+ }
26
+
27
+ func start() {
28
+ active = true
29
+ // Register first so pairing in Settings cannot race the connected snapshot.
30
+ register()
31
+ for peripheral in connected() {
32
+ receive(peripheral)
33
+ }
34
+ if active { scan() }
35
+ }
36
+
37
+ func cancel() {
38
+ active = false
39
+ stop()
40
+ }
41
+
42
+ func receive(_ peripheral: Peripheral, advertisedName: String? = nil, rssi: Int? = nil) {
43
+ guard active, let name = advertisedName ?? name(peripheral) else { return }
44
+ let lower = name.lowercased()
45
+ // The separate ANCS peripheral does not host the Companion data service.
46
+ guard lower.hasPrefix("nimo"), !lower.hasSuffix("_ble") else { return }
47
+ found(peripheral, name, rssi)
48
+ }
49
+ }
@@ -123,6 +123,7 @@ struct GlassesStatus: CustomStringConvertible {
123
123
  "wifiLocalIp": "",
124
124
  "signalStrength": -1,
125
125
  "signalStrengthUpdatedAt": 0,
126
+ "g2MissingArm": NSNull(),
126
127
  ]))
127
128
  }
128
129