@mentra/bluetooth-sdk 3.2.1-dev.314 → 3.3.0-beta.942

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 (191) hide show
  1. package/README.md +72 -10
  2. package/android/build.gradle +15 -0
  3. package/android/lc3Lib/build.gradle +6 -1
  4. package/android/src/debug/java/com/mentra/bluetoothsdk/sgcs/NimoDiagnostics.kt +368 -0
  5. package/android/src/debug/java/com/mentra/bluetoothsdk/sgcs/NimoFramebufferProtocol.kt +156 -0
  6. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkAnalytics.kt +63 -14
  7. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkAnalyticsTracker.kt +55 -1
  8. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkDefaults.kt +4 -1
  9. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkInstallId.kt +23 -0
  10. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +8 -2
  11. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkUpdateCheck.kt +184 -0
  12. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkUsageTracker.kt +388 -0
  13. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +2 -1
  14. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +133 -26
  15. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +37 -13
  16. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedChangelogCatalog.kt +3 -2
  17. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +6 -6
  18. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +227 -36
  19. package/android/src/main/java/com/mentra/bluetoothsdk/MentraLocalNetworkModule.kt +24 -0
  20. package/android/src/main/java/com/mentra/bluetoothsdk/NetworkDiagnostics.kt +25 -0
  21. package/android/src/main/java/com/mentra/bluetoothsdk/VersionInfoResponseAccumulator.kt +13 -2
  22. package/android/src/main/java/com/mentra/bluetoothsdk/debug/BleTraceLogger.kt +3 -0
  23. package/android/src/main/java/com/mentra/bluetoothsdk/requests/DisplayRequests.kt +1 -1
  24. package/android/src/main/java/com/mentra/bluetoothsdk/services/Foreground.kt +33 -0
  25. package/android/src/main/java/com/mentra/bluetoothsdk/services/G2ConnectionRecovery.kt +47 -0
  26. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ClassicAudioConnectionTracker.kt +4 -0
  27. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ClassicAudioServiceDiscovery.kt +70 -0
  28. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.kt +2 -2
  29. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1TextSanitizer.kt +11 -32
  30. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +72 -21
  31. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/LatinTextSanitizer.kt +39 -0
  32. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +194 -40
  33. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +12 -8
  34. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Nimo.kt +314 -395
  35. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCodec.kt +247 -0
  36. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCoordinator.kt +238 -0
  37. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasEncoder.kt +58 -0
  38. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasSession.kt +180 -0
  39. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +11 -0
  40. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SelectedDeviceAddress.kt +17 -0
  41. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SerializedGattCallback.kt +5 -0
  42. package/android/src/main/java/com/mentra/bluetoothsdk/utils/G2Text.kt +7 -0
  43. package/android/src/release/java/com/mentra/bluetoothsdk/sgcs/NimoDiagnostics.kt +20 -0
  44. package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkAnalyticsTrackerTest.kt +58 -3
  45. package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkUpdateCheckTest.kt +124 -0
  46. package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkUsageTrackerTest.kt +263 -0
  47. package/android/src/test/java/com/mentra/bluetoothsdk/DashboardContentFormatterTest.kt +2 -2
  48. package/android/src/test/java/com/mentra/bluetoothsdk/DeviceIdentityPairingTest.kt +59 -0
  49. package/android/src/test/java/com/mentra/bluetoothsdk/DeviceManagerSceneHandoffTest.kt +666 -0
  50. package/android/src/test/java/com/mentra/bluetoothsdk/G2TextTest.kt +13 -0
  51. package/android/src/test/java/com/mentra/bluetoothsdk/NetworkDiagnosticsTest.kt +55 -0
  52. package/android/src/test/java/com/mentra/bluetoothsdk/ScanCompletionTest.kt +5 -1
  53. package/android/src/test/java/com/mentra/bluetoothsdk/VersionInfoResponseAccumulatorTest.kt +71 -0
  54. package/android/src/test/java/com/mentra/bluetoothsdk/debug/BleTraceLoggerTest.kt +69 -0
  55. package/android/src/test/java/com/mentra/bluetoothsdk/services/G2ConnectionRecoveryTest.kt +51 -0
  56. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/ClassicAudioServiceDiscoveryTest.kt +124 -0
  57. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G1PairingTest.kt +105 -0
  58. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G1TextSanitizerTest.kt +29 -4
  59. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G2DisplayThreadTest.kt +120 -0
  60. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G2PairingTargetTest.kt +90 -0
  61. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveClassicDisconnectTest.kt +133 -0
  62. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveFileTransferSessionTest.kt +55 -0
  63. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveGattCallbackTest.kt +33 -0
  64. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveIncidentDispatchTest.kt +126 -0
  65. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingTargetTest.kt +65 -0
  66. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveWifiCredentialLogTest.kt +55 -0
  67. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraNexPairingTest.kt +151 -0
  68. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoBitmapAcceptanceTest.kt +105 -0
  69. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCodecTest.kt +233 -0
  70. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCoordinatorTest.kt +348 -0
  71. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasSessionTest.kt +339 -0
  72. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoMicReconnectTest.kt +93 -0
  73. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoProtocolTest.kt +11 -0
  74. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/SelectedDeviceAddressTest.kt +27 -0
  75. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoDiagnosticsTest.kt +287 -0
  76. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoFramebufferProtocolTest.kt +127 -0
  77. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoLifecycleTest.kt +288 -0
  78. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoReadinessDriverTest.kt +205 -0
  79. package/build/BluetoothSdk.types.d.ts +16 -0
  80. package/build/BluetoothSdk.types.d.ts.map +1 -1
  81. package/build/BluetoothSdk.types.js +1 -0
  82. package/build/BluetoothSdk.types.js.map +1 -1
  83. package/build/_private/BluetoothSdkModule.d.ts +4 -0
  84. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  85. package/build/_private/BluetoothSdkModule.js +5 -1
  86. package/build/_private/BluetoothSdkModule.js.map +1 -1
  87. package/build/_private/MentraLocalNetworkModule.d.ts +23 -0
  88. package/build/_private/MentraLocalNetworkModule.d.ts.map +1 -1
  89. package/build/_private/MentraLocalNetworkModule.js.map +1 -1
  90. package/build/_private/nativeLogConsole.d.ts +19 -0
  91. package/build/_private/nativeLogConsole.d.ts.map +1 -1
  92. package/build/_private/nativeLogConsole.js +12 -0
  93. package/build/_private/nativeLogConsole.js.map +1 -1
  94. package/build/generated/changelogCatalog.d.ts +5 -2
  95. package/build/generated/changelogCatalog.d.ts.map +1 -1
  96. package/build/generated/changelogCatalog.js +6 -2
  97. package/build/generated/changelogCatalog.js.map +1 -1
  98. package/build/generated/releaseMetadata.js +6 -6
  99. package/build/generated/releaseMetadata.js.map +1 -1
  100. package/build/index.d.ts.map +1 -1
  101. package/build/index.js +1 -0
  102. package/build/index.js.map +1 -1
  103. package/build/ota-transport/index.d.ts +4 -0
  104. package/build/ota-transport/index.d.ts.map +1 -1
  105. package/build/ota-transport/index.js +2 -0
  106. package/build/ota-transport/index.js.map +1 -1
  107. package/ios/BluetoothSdkModule.swift +17 -4
  108. package/ios/MentraBluetoothSDK.podspec +3 -2
  109. package/ios/Source/BluetoothBackgroundMode.swift +32 -0
  110. package/ios/Source/BluetoothSdkDefaults.swift +7 -2
  111. package/ios/Source/Bridge.swift +15 -2
  112. package/ios/Source/DeviceManager.swift +102 -66
  113. package/ios/Source/DeviceStore.swift +26 -7
  114. package/ios/Source/GeneratedChangelogCatalog.swift +3 -2
  115. package/ios/Source/GeneratedReleaseMetadata.swift +6 -6
  116. package/ios/Source/MentraBluetoothSDK.swift +313 -54
  117. package/ios/Source/audio/GlassesMicWatchdog.swift +33 -0
  118. package/ios/Source/internal/BluetoothAvailability.swift +6 -0
  119. package/ios/Source/internal/BluetoothSdkAnalytics.swift +87 -13
  120. package/ios/Source/internal/BluetoothSdkAnalyticsTracker.swift +54 -1
  121. package/ios/Source/internal/BluetoothSdkInstallId.swift +21 -0
  122. package/ios/Source/internal/BluetoothSdkUpdateCheck.swift +223 -0
  123. package/ios/Source/internal/BluetoothSdkUsageTracker.swift +428 -0
  124. package/ios/Source/requests/DisplayRequests.swift +1 -1
  125. package/ios/Source/requests/VersionInfoResponseAccumulator.swift +11 -1
  126. package/ios/Source/sgcs/Ar99.swift +1 -2
  127. package/ios/Source/sgcs/G1.swift +68 -38
  128. package/ios/Source/sgcs/G1ConnectionTarget.swift +13 -0
  129. package/ios/Source/sgcs/G2.swift +261 -247
  130. package/ios/Source/sgcs/G2ConnectionSession.swift +101 -0
  131. package/ios/Source/sgcs/Mach1.swift +19 -7
  132. package/ios/Source/sgcs/MentraLive.swift +272 -197
  133. package/ios/Source/sgcs/MentraLiveFileTransferSession.swift +137 -0
  134. package/ios/Source/sgcs/MentraLivePairingAdvertisement.swift +47 -0
  135. package/ios/Source/sgcs/MentraNex.swift +8 -31
  136. package/ios/Source/sgcs/NexConnectionTarget.swift +15 -0
  137. package/ios/Source/sgcs/Nimo.swift +280 -408
  138. package/ios/Source/sgcs/NimoCanvasCodec.swift +272 -0
  139. package/ios/Source/sgcs/NimoCanvasCoordinator.swift +233 -0
  140. package/ios/Source/sgcs/NimoCanvasImage.swift +53 -0
  141. package/ios/Source/sgcs/NimoCanvasSession.swift +166 -0
  142. package/ios/Source/sgcs/NimoDiscovery.swift +49 -0
  143. package/ios/Source/sgcs/SGCManager.swift +25 -1
  144. package/ios/Source/sgcs/VuzixConnectionTarget.swift +15 -0
  145. package/ios/Source/status/DeviceStatus.swift +1 -0
  146. package/ios/Source/stt/SherpaOnnxTranscriber.swift +49 -189
  147. package/ios/Source/utils/AudioSessionMonitor.swift +44 -8
  148. package/ios/Source/utils/DemandDrivenAudioWorker.swift +150 -0
  149. package/ios/Source/utils/G1Text.swift +2 -24
  150. package/ios/Source/utils/G1TextSanitizer.swift +27 -0
  151. package/ios/Source/utils/G2Text.swift +11 -0
  152. package/ios/Source/utils/LatinTextSanitizer.swift +48 -0
  153. package/ios/Tests/BluetoothAudioRouteTests.swift +26 -0
  154. package/ios/Tests/BluetoothBackgroundModeTests.swift +32 -0
  155. package/ios/Tests/BluetoothSdkAnalyticsTrackerTests.swift +54 -3
  156. package/ios/Tests/BluetoothSdkUpdateCheckTests.swift +153 -0
  157. package/ios/Tests/BluetoothSdkUsageTrackerTests.swift +296 -0
  158. package/ios/Tests/DashboardContentFormatterTests.swift +2 -2
  159. package/ios/Tests/DashboardRenderingTests.swift +31 -0
  160. package/ios/Tests/DemandDrivenAudioWorkerTests.swift +157 -0
  161. package/ios/Tests/DeviceIdentityPairingTests.swift +49 -0
  162. package/ios/Tests/DeviceManagerReconnectTests.swift +362 -0
  163. package/ios/Tests/G1PairingTests.swift +162 -0
  164. package/ios/Tests/G1TextSanitizerTests.swift +32 -0
  165. package/ios/Tests/G2ConnectionSessionTests.swift +172 -0
  166. package/ios/Tests/G2TextTests.swift +10 -0
  167. package/ios/Tests/IncidentLogDispatchTests.swift +111 -0
  168. package/ios/Tests/MentraLiveFileTransferSessionTests.swift +47 -0
  169. package/ios/Tests/MentraLiveReconnectPolicyTests.swift +58 -0
  170. package/ios/Tests/MentraLiveSessionReadinessTests.swift +163 -0
  171. package/ios/Tests/NimoCanvasCodecTests.swift +217 -0
  172. package/ios/Tests/NimoCanvasSessionTests.swift +181 -0
  173. package/ios/Tests/NimoCanvasTransportTests.swift +186 -0
  174. package/ios/Tests/NimoDeviceCommandsTests.swift +16 -0
  175. package/ios/Tests/NimoDiscoveryTests.swift +79 -0
  176. package/ios/Tests/SGCConnectionTargetTests.swift +48 -0
  177. package/ios/Tests/VersionInfoCommandTests.swift +194 -0
  178. package/ios/Tests/VersionInfoResponseAccumulatorTests.swift +65 -0
  179. package/ios/Tests/VideoRecordingCommandTests.swift +81 -0
  180. package/ios/Tests/WifiCredentialLoggingTests.swift +42 -0
  181. package/package.json +2 -2
  182. package/plugin/build/withIos.d.ts +2 -0
  183. package/plugin/build/withIos.js +12 -1
  184. package/src/BluetoothSdk.types.ts +15 -0
  185. package/src/_private/BluetoothSdkModule.ts +10 -1
  186. package/src/_private/MentraLocalNetworkModule.ts +28 -0
  187. package/src/_private/nativeLogConsole.ts +19 -0
  188. package/src/generated/changelogCatalog.ts +6 -2
  189. package/src/generated/releaseMetadata.ts +6 -6
  190. package/src/index.ts +1 -0
  191. package/src/ota-transport/index.ts +10 -1
@@ -7,10 +7,8 @@
7
7
  //
8
8
 
9
9
  import AVFoundation
10
- import Compression
11
10
  import CoreBluetooth
12
11
  import Foundation
13
- import CoreGraphics
14
12
 
15
13
  // MARK: - Nimo BLE Constants
16
14
 
@@ -22,15 +20,12 @@ enum NimoBLE {
22
20
  static let CHAR_RX = CBUUID(string: "00002022-0000-1000-8000-00805F9B34FB")
23
21
  static let CHAR_MIC = CBUUID(string: "00002025-0000-1000-8000-00805F9B34FB")
24
22
 
25
- static let NAME_PREFIX = "nimo"
26
- // iOS ANCS side-channel devices advertise "<name>_ble" — never the data channel.
27
- static let BLE_NAME_SUFFIX = "_ble"
28
-
29
23
  static let CHUNK_SIZE = 501
30
24
  static let INTER_FRAME_DELAY_MS = 5
31
25
  }
32
26
 
33
27
  // MARK: - Nimo Protocol Constants
28
+
34
29
  // Byte values follow the glasses firmware protocol and must not be changed.
35
30
 
36
31
  enum NimoProtocol {
@@ -45,6 +40,8 @@ enum NimoProtocol {
45
40
  static let CMD_SET_PARAMETER = 0x03
46
41
  static let CMD_INSTRUCTION_REPORT = 0x06
47
42
  static let CMD_CONTROL_INSTRUCTION = 0x07
43
+ static let CMD_CONTROL_FACTORY = 0x08
44
+ static let FACTORY_RECOVER = 0x03
48
45
  static let CMD_CONTROL_NOTIFICATION = 0x09
49
46
 
50
47
  // get parameter keys
@@ -63,13 +60,16 @@ enum NimoProtocol {
63
60
  static let SET_DISPLAY_OFF = 0x0F
64
61
  static let SET_PHONE_TYPE = 0x14
65
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
66
66
 
67
67
  // control instruction keys
68
68
  static let CTRL_ENTER_APP = 0x01
69
69
  static let CTRL_QUIT_APP = 0x03
70
70
  static let CTRL_UPDATE_CONTENT = 0x04
71
71
 
72
- // notification keys
72
+ /// notification keys
73
73
  static let NOTIFICATION_SEND = 0x01
74
74
 
75
75
  // report keys (cmd 0x06)
@@ -108,7 +108,7 @@ enum NimoProtocol {
108
108
  static let APP_ID_PROMPTER = 0x06
109
109
  static let APP_ID_AI_TALK = 0x07
110
110
 
111
- // enterApp modes
111
+ /// enterApp modes
112
112
  static let APP_MODE_STANDALONE = 0x00
113
113
 
114
114
  // widget resTypes
@@ -126,7 +126,7 @@ enum NimoProtocol {
126
126
  static let NAV_LARGE_MAP_WIDTH = 452
127
127
  static let NAV_LARGE_MAP_HEIGHT = 170
128
128
 
129
- // phone types
129
+ /// phone types
130
130
  static let PHONE_TYPE_IOS = 0x01
131
131
 
132
132
  // image header
@@ -570,6 +570,9 @@ actor NimoReconnectionManager {
570
570
  class Nimo: NSObject, SGCManager {
571
571
  var type: String = DeviceTypes.NIMO
572
572
  let hasMic = true
573
+ let showBrightnessConfirmation = false
574
+ let showConnectionConfirmation = false
575
+ let sceneHandoffRequiresClear = false
573
576
 
574
577
  private static let _bluetoothQueue = DispatchQueue(label: "BluetoothNimo", qos: .userInitiated)
575
578
 
@@ -578,19 +581,10 @@ class Nimo: NSObject, SGCManager {
578
581
  static let ackTimeoutSeconds: TimeInterval = 5
579
582
  static let pairingTimeoutSeconds: TimeInterval = 15
580
583
  static let batteryPollSeconds: TimeInterval = 30
581
- static let textQueueTickSeconds: TimeInterval = 0.1
582
- static let writeWatchdogSeconds: TimeInterval = 1
583
- // Fall back to the other ear if the preferred one goes quiet for this long.
584
+ /// Fall back to the other ear if the preferred one goes quiet for this long.
584
585
  static let micSideFallbackSeconds: TimeInterval = 2
585
586
  }
586
587
 
587
- // The text surface every sendTextWall renders into. The ASR note view (appId 0x04,
588
- // note text resId 0x00) is a plain text page that renders pushed text directly;
589
- // Prompter (0x06) was tried first but did not display pushed text on hardware.
590
- private let textAppId = NimoProtocol.APP_ID_ASR_NOTE
591
- private let textLayoutId = 0
592
- private let textResId = 0
593
-
594
588
  // BLE
595
589
  private var centralManager: CBCentralManager?
596
590
  private var peripheral: CBPeripheral?
@@ -598,8 +592,29 @@ class Nimo: NSObject, SGCManager {
598
592
  private var rxChar: CBCharacteristic?
599
593
  private var micChar: CBCharacteristic?
600
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
+ )
601
616
 
602
- // Device search
617
+ /// Device search
603
618
  private var DEVICE_SEARCH_ID = "NOT_SET"
604
619
 
605
620
  private var lastDeviceName: String? {
@@ -615,7 +630,7 @@ class Nimo: NSObject, SGCManager {
615
630
  private let reconnectionManager = NimoReconnectionManager()
616
631
  private let receiveAssembler = NimoReceiveAssembler()
617
632
 
618
- // Handshake
633
+ /// Handshake
619
634
  private enum HandshakeState {
620
635
  case idle
621
636
  case awaitingTws
@@ -625,10 +640,12 @@ class Nimo: NSObject, SGCManager {
625
640
 
626
641
  private var handshakeState = HandshakeState.idle
627
642
  private var twsConnected = false
643
+ private var peerCompanionReady: Bool?
644
+ private var setTimeAttempts = 0
628
645
  private var twsTimeoutItem: DispatchWorkItem?
629
646
  private var pairingTimeoutItem: DispatchWorkItem?
630
647
 
631
- // Pending acks keyed by (cmd << 8) | key
648
+ /// Pending acks keyed by (cmd << 8) | key
632
649
  private struct PendingAck {
633
650
  let onResult: (Bool) -> Void
634
651
  let timeoutItem: DispatchWorkItem
@@ -636,26 +653,36 @@ class Nimo: NSObject, SGCManager {
636
653
 
637
654
  private var pendingAcks: [Int: PendingAck] = [:]
638
655
 
639
- // Serialized write queue: writes use .withResponse, the next write goes out after
640
- // didWriteValueFor (plus a 5 ms inter-frame delay), with a watchdog to unstick the queue.
641
- private struct QueuedWrite {
642
- let char: CBCharacteristic
643
- let bytes: Data
656
+ private static func scheduler(_ queue: DispatchQueue) -> NimoScheduler {
657
+ { delay, task in
658
+ let item = DispatchWorkItem(block: task)
659
+ queue.asyncAfter(deadline: .now() + delay, execute: item)
660
+ return { item.cancel() }
661
+ }
644
662
  }
645
663
 
646
- private var writeQueue: [QueuedWrite] = []
647
- private var writeInFlight = false
648
- private var writeWatchdogItem: DispatchWorkItem?
649
-
650
- // Timers
664
+ private let encoderQueue = DispatchQueue(label: "NimoCanvasEncoder", qos: .userInitiated)
665
+ private lazy var canvasEncoder = NimoCanvasEncoder(
666
+ main: Self.scheduler(.main), worker: Self.scheduler(encoderQueue),
667
+ failure: { Bridge.log("NIMO: canvas encoding failed; retaining last valid scene: \($0)") }
668
+ )
669
+ private lazy var writes = NimoWriteQueue<CBCharacteristic>(
670
+ schedule: Self.scheduler(.main), write: { [weak self] characteristic, bytes in
671
+ guard let self, let peripheral = self.peripheral, peripheral.state == .connected,
672
+ bytes.count <= peripheral.maximumWriteValueLength(for: .withResponse) else { return false }
673
+ peripheral.writeValue(bytes, for: characteristic, type: .withResponse)
674
+ return true
675
+ }, failure: { [weak self] in self?.abortTransport($0) }
676
+ )
677
+ private lazy var canvas = NimoCanvasCoordinator(
678
+ schedule: Self.scheduler(.main),
679
+ writeCapacity: { [weak self] in min(512, self?.peripheral?.maximumWriteValueLength(for: .withResponse) ?? 20) },
680
+ enqueue: { [weak self] frames, started, completed in
681
+ self?.enqueueFrames(frames, finalStarted: started, completed: completed) ?? false
682
+ }, reconnect: { [weak self] in self?.abortTransport($0) },
683
+ rejected: { Bridge.log("NIMO: canvas rejected status=\($0); not a render ACK") }
684
+ )
651
685
  private var batteryPollTimer: Timer?
652
- private var textQueueTimer: Timer?
653
-
654
- // Text rendering
655
- private var pendingText: String?
656
- private var textAppEntered = false
657
- private var navAppEntered = false
658
- private var currentGlassesAppId = -1
659
686
 
660
687
  // Battery
661
688
  private var lastBatteryLevel = -1
@@ -675,6 +702,8 @@ class Nimo: NSObject, SGCManager {
675
702
  func findCompatibleDevices() {
676
703
  Bridge.log("NIMO: findCompatibleDevices()")
677
704
  DEVICE_SEARCH_ID = "NOT_SET"
705
+ isDisconnecting = false
706
+ scanRequested = true
678
707
  DeviceStore.shared.apply("glasses", "connectionState", ConnTypes.SCANNING)
679
708
  startScan()
680
709
  }
@@ -682,6 +711,7 @@ class Nimo: NSObject, SGCManager {
682
711
  func connectById(_ id: String) {
683
712
  Bridge.log("NIMO: connectById(\(id))")
684
713
  DEVICE_SEARCH_ID = id
714
+ scanRequested = true
685
715
  DeviceStore.shared.apply("glasses", "connectionState", ConnTypes.CONNECTING)
686
716
  isDisconnecting = false
687
717
  startPairingTimeout()
@@ -689,7 +719,8 @@ class Nimo: NSObject, SGCManager {
689
719
  }
690
720
 
691
721
  func stopScan() {
692
- centralManager?.stopScan()
722
+ scanRequested = false
723
+ discovery.cancel()
693
724
  }
694
725
 
695
726
  func disconnect() {
@@ -716,6 +747,27 @@ class Nimo: NSObject, SGCManager {
716
747
  DeviceStore.shared.apply("glasses", "connectionState", ConnTypes.DISCONNECTED)
717
748
  }
718
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
+
719
771
  func forget() {
720
772
  Bridge.log("NIMO: forget()")
721
773
  disconnect()
@@ -804,8 +856,7 @@ class Nimo: NSObject, SGCManager {
804
856
  }
805
857
 
806
858
  func clearDisplay() {
807
- // Keep the text app/page alive — a blank update avoids re-entering the app next time.
808
- pendingText = " "
859
+ encodeCanvas(scope: "legacy") { try NimoCanvasCodec.replace([]) }
809
860
  }
810
861
 
811
862
  func sendText(_ text: String) async {
@@ -813,92 +864,61 @@ class Nimo: NSObject, SGCManager {
813
864
  }
814
865
 
815
866
  func sendTextWall(_ text: String) async {
816
- // Coalesced: only the most recent pending text survives until the next 100ms drain.
817
- pendingText = text
867
+ encodeCanvas(scope: "legacy") {
868
+ try NimoCanvasCodec.replace(NimoCanvasCodec.text(text, 0, 0, 500, 220, border: 0, radius: 0))
869
+ }
818
870
  }
819
871
 
820
872
  func sendDoubleTextWall(_ top: String, _ bottom: String) async {
821
873
  await sendTextWall(top + "\n\n" + bottom)
822
874
  }
823
875
 
824
- func sendPositionedText(
825
- _ text: String, x _: Int32, y _: Int32, width _: Int32, height _: Int32,
826
- borderWidth _: Int32, borderRadius _: Int32
827
- ) async {
828
- // Navigation pushes turn text via positioned_text. Nimo widgets have fixed geometry, so
829
- // position/border are ignored — funnel the text through the same coalesced path as
830
- // sendTextWall so it renders on the ASR note page. Without this override the base no-op
831
- // silently dropped all navigation text.
832
- // Bridge.log("NIMO: sendPositionedText(text=\(text))")
833
- // pendingText = text
834
- }
835
-
836
- func displayBitmap(
837
- base64ImageData: String, x _: Int32?, y _: Int32?, width _: Int32?, height _: Int32?
838
- ) async -> Bool {
839
- // Nimo widgets have fixed geometry; x/y are not honored. Renders into the navigation
840
- // large-map widget (appId 0x01, resId 0x05, 452x170 2bpp) — the full-width nav map,
841
- // which shows far more than the small 160x160 mini-map (resId 0x00). bitmapToGrayscale
842
- // aspect-fits onto black, so a non-matching source aspect letterboxes rather than
843
- // distorts. The nav app must be foregrounded before pushing content (mirrors the text
844
- // path). TODO: hardware-verify the large-map widget renders and the nav app-mode.
845
- let targetWidth = NimoProtocol.NAV_LARGE_MAP_WIDTH
846
- let targetHeight = NimoProtocol.NAV_LARGE_MAP_HEIGHT
847
- Bridge.log("NIMO: displayBitmap → nav large map (navAppEntered=\(navAppEntered))")
848
- guard let imageData = Data(base64Encoded: base64ImageData),
849
- let image = SdkImage(data: imageData)
850
- else {
851
- Bridge.log("NIMO: displayBitmap — could not decode image")
876
+ func sendPositionedText(_ text: String, x: Int32, y: Int32, width: Int32, height: Int32,
877
+ borderWidth: Int32, borderRadius: Int32) async
878
+ {
879
+ encodeCanvas(scope: "legacy") {
880
+ try NimoCanvasCodec.replace(NimoCanvasCodec.text(text, Int(x), Int(y), Int(width), Int(height),
881
+ border: Int(borderWidth), radius: Int(borderRadius)))
882
+ }
883
+ }
884
+
885
+ func displayBitmap(base64ImageData: String, x: Int32?, y: Int32?, width: Int32?, height: Int32?) async -> Bool {
886
+ do {
887
+ let left = Int(x ?? 0), top = Int(y ?? 0)
888
+ let w = width.map(Int.init) ?? (NimoCanvasCodec.width - left)
889
+ let h = height.map(Int.init) ?? (NimoCanvasCodec.height - top)
890
+ try NimoCanvasCodec.region(left, top, w, h)
891
+ // True acknowledges bounded local validation, never visible rendering.
892
+ let source = try NimoCanvasImage.source(base64ImageData)
893
+ encodeCanvas(scope: "legacy") {
894
+ try NimoCanvasCodec.replace([NimoCanvasCodec.bitmap(left, top, w, h,
895
+ gray: NimoCanvasImage.grayscale(source, width: w, height: h))])
896
+ }
897
+ return true
898
+ } catch {
899
+ Bridge.log("NIMO: displayBitmap failed: \(error)")
852
900
  return false
853
901
  }
854
- guard let grayscale = bitmapToGrayscale(image, width: targetWidth, height: targetHeight)
855
- else { return false }
856
- let packed = packL8To2bpp(grayscale)
857
- let (payload, compression) = compressAdaptive(packed)
858
- if !navAppEntered {
859
- sendFrame(
860
- NimoFrameCodec.encodeFrame(
861
- cmd: NimoProtocol.CMD_CONTROL_INSTRUCTION,
862
- key: NimoProtocol.CTRL_ENTER_APP,
863
- payload: Data([UInt8(NimoProtocol.APP_ID_NAV), UInt8(NimoProtocol.APP_MODE_STANDALONE)])
864
- )
865
- )
866
- // Optimistic; corrected by app-state reports if the glasses refuse/exit.
867
- navAppEntered = true
868
- }
869
- let content =
870
- NimoFrameCodec.imageHeader(
871
- width: targetWidth,
872
- height: targetHeight,
873
- formatBpp: NimoProtocol.FORMAT_2BPP,
874
- compression: compression,
875
- originalSize: packed.count,
876
- compressedSize: compression == NimoProtocol.COMPRESSION_NONE ? 0 : payload.count
877
- ) + payload
878
- let frames = NimoFrameCodec.updateContentFrames(
879
- appId: NimoProtocol.APP_ID_NAV,
880
- layoutId: 0,
881
- resId: NimoProtocol.NAV_RES_LARGE_MAP,
882
- resType: NimoProtocol.WIDGET_PICTURE,
883
- content: content
884
- )
885
- enqueueFrames(frames)
886
- return true
902
+ }
903
+
904
+ func applySceneFrame(_ frame: SceneFrame) async {
905
+ encodeCanvas(scope: "\(frame.appId):\(frame.epoch)", force: frame.replay) {
906
+ try NimoCanvasCodec.scene(frame.elements) { try NimoCanvasImage.decode($0, width: $1, height: $2) }
907
+ }
908
+ }
909
+
910
+ func clearSceneElements(_: [String]) async {
911
+ clearDisplay()
912
+ }
913
+
914
+ private func encodeCanvas(scope: String, force: Bool = false, encode: @escaping () throws -> Data) {
915
+ canvasEncoder.submit(encode: encode) { [weak self] bytes in
916
+ self?.canvas.offer(bytes, scope: scope, force: force)
917
+ }
887
918
  }
888
919
 
889
920
  func showDashboard() {
890
- Bridge.log("NIMO: showDashboard()")
891
- textAppEntered = false
892
- navAppEntered = false
893
- sendFrame(
894
- NimoFrameCodec.encodeFrame(
895
- cmd: NimoProtocol.CMD_CONTROL_INSTRUCTION,
896
- key: NimoProtocol.CTRL_ENTER_APP,
897
- payload: Data([
898
- UInt8(NimoProtocol.APP_ID_DASHBOARD), UInt8(NimoProtocol.APP_MODE_STANDALONE),
899
- ])
900
- )
901
- )
921
+ exit()
902
922
  }
903
923
 
904
924
  func setDashboardPosition(_ height: Int, _ depth: Int) {
@@ -963,17 +983,8 @@ class Nimo: NSObject, SGCManager {
963
983
  }
964
984
 
965
985
  func exit() {
966
- Bridge.log("NIMO: exit()")
967
- let appId = currentGlassesAppId >= 0 ? currentGlassesAppId : textAppId
968
- textAppEntered = false
969
- navAppEntered = false
970
- sendFrame(
971
- NimoFrameCodec.encodeFrame(
972
- cmd: NimoProtocol.CMD_CONTROL_INSTRUCTION,
973
- key: NimoProtocol.CTRL_QUIT_APP,
974
- payload: Data([UInt8(appId & 0xFF)])
975
- )
976
- )
986
+ canvasEncoder.invalidate()
987
+ canvas.exit()
977
988
  }
978
989
 
979
990
  func sendShutdown() {
@@ -1020,7 +1031,9 @@ class Nimo: NSObject, SGCManager {
1020
1031
  if bytes.count > maxLen {
1021
1032
  // Truncate on a UTF-8 boundary
1022
1033
  var end = maxLen
1023
- while end > 0, bytes[end] & 0xC0 == 0x80 { end -= 1 }
1034
+ while end > 0, bytes[end] & 0xC0 == 0x80 {
1035
+ end -= 1
1036
+ }
1024
1037
  bytes = bytes.subdata(in: 0 ..< end)
1025
1038
  }
1026
1039
  dest.replaceSubrange(offset ..< offset + bytes.count, with: bytes)
@@ -1070,7 +1083,7 @@ class Nimo: NSObject, SGCManager {
1070
1083
  func sendWifiCredentials(_: String, _: String) {}
1071
1084
  func forgetWifiNetwork(_: String) {}
1072
1085
  func sendHotspotState(_: Bool) {}
1073
- func sendOtaStart(otaVersionUrl: String?) {}
1086
+ func sendOtaStart(otaVersionUrl _: String?) {}
1074
1087
  func sendOtaQueryStatus() {}
1075
1088
 
1076
1089
  // MARK: - SGCManager: User Context / Gallery / Version
@@ -1095,13 +1108,9 @@ class Nimo: NSObject, SGCManager {
1095
1108
 
1096
1109
  // MARK: - BLE Scanning
1097
1110
 
1098
- private func isNimoMainDevice(_ name: String) -> Bool {
1099
- let lower = name.lowercased()
1100
- return lower.hasPrefix(NimoBLE.NAME_PREFIX) && !lower.hasSuffix(NimoBLE.BLE_NAME_SUFFIX)
1101
- }
1102
-
1103
1111
  @discardableResult
1104
1112
  private func startScan() -> Bool {
1113
+ guard scanRequested, !isDisconnecting else { return false }
1105
1114
  Bridge.log("NIMO: startScan()")
1106
1115
  if centralManager == nil {
1107
1116
  centralManager = CBCentralManager(
@@ -1117,17 +1126,32 @@ class Nimo: NSObject, SGCManager {
1117
1126
 
1118
1127
  // Fast path: reconnect to the cached peripheral UUID.
1119
1128
  if connectByUUID() {
1129
+ stopScan()
1120
1130
  return true
1121
1131
  }
1122
1132
 
1123
- centralManager!.scanForPeripherals(
1124
- withServices: nil,
1125
- options: [CBCentralManagerScanOptionAllowDuplicatesKey: false]
1126
- )
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()
1127
1136
  return true
1128
1137
  }
1129
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
+
1130
1153
  private func connectByUUID() -> Bool {
1154
+ guard peripheral == nil else { return false }
1131
1155
  guard DEVICE_SEARCH_ID != "NOT_SET", !DEVICE_SEARCH_ID.isEmpty else { return false }
1132
1156
  guard lastDeviceName == DEVICE_SEARCH_ID,
1133
1157
  let uuidString = lastDeviceUUID,
@@ -1163,44 +1187,9 @@ class Nimo: NSObject, SGCManager {
1163
1187
 
1164
1188
  // MARK: - Write Queue
1165
1189
 
1166
- private func drainWriteQueue() {
1167
- if writeInFlight { return }
1168
- guard !writeQueue.isEmpty else { return }
1169
- guard let peripheral else {
1170
- writeQueue.removeAll()
1171
- return
1172
- }
1173
- let item = writeQueue.removeFirst()
1174
- writeInFlight = true
1175
- peripheral.writeValue(item.bytes, for: item.char, type: .withResponse)
1176
-
1177
- // Watchdog: if the write callback never arrives, unblock the queue.
1178
- writeWatchdogItem?.cancel()
1179
- let watchdog = DispatchWorkItem { [weak self] in
1180
- guard let self else { return }
1181
- if self.writeInFlight {
1182
- Bridge.log("NIMO: write watchdog fired — forcing queue drain")
1183
- self.writeInFlight = false
1184
- self.drainWriteQueue()
1185
- }
1186
- }
1187
- writeWatchdogItem = watchdog
1188
- DispatchQueue.main.asyncAfter(deadline: .now() + Const.writeWatchdogSeconds, execute: watchdog)
1189
- }
1190
-
1191
- private func onWriteCompleted() {
1192
- DispatchQueue.main.asyncAfter(
1193
- deadline: .now() + .milliseconds(NimoBLE.INTER_FRAME_DELAY_MS)
1194
- ) { [weak self] in
1195
- guard let self else { return }
1196
- self.writeInFlight = false
1197
- self.drainWriteQueue()
1198
- }
1199
- }
1200
-
1201
- private func enqueueWrite(_ char: CBCharacteristic, _ bytes: Data) {
1202
- writeQueue.append(QueuedWrite(char: char, bytes: bytes))
1203
- drainWriteQueue()
1190
+ private func enqueueWrite(_ characteristic: CBCharacteristic, _ bytes: Data) {
1191
+ guard let peripheral else { return }
1192
+ writes.enqueue(peripheral, characteristic: characteristic, frames: [bytes])
1204
1193
  }
1205
1194
 
1206
1195
  private func sendFrame(_ frame: Data) {
@@ -1208,12 +1197,18 @@ class Nimo: NSObject, SGCManager {
1208
1197
  enqueueWrite(txChar, frame)
1209
1198
  }
1210
1199
 
1211
- private func enqueueFrames(_ frames: [Data]) {
1212
- guard let txChar else { return }
1213
- for frame in frames {
1214
- writeQueue.append(QueuedWrite(char: txChar, bytes: frame))
1215
- }
1216
- drainWriteQueue()
1200
+ private func enqueueFrames(_ frames: [Data], finalStarted: @escaping () -> Void,
1201
+ completed: @escaping () -> Void) -> Bool
1202
+ {
1203
+ guard let peripheral, let txChar else { return false }
1204
+ return writes.enqueue(peripheral, characteristic: txChar, frames: frames,
1205
+ finalStarted: finalStarted, completed: completed)
1206
+ }
1207
+
1208
+ private func abortTransport(_ reason: String) {
1209
+ Bridge.log("NIMO: retiring transport: \(reason)")
1210
+ resetSessionState()
1211
+ if let peripheral { centralManager?.cancelPeripheralConnection(peripheral) }
1217
1212
  }
1218
1213
 
1219
1214
  // MARK: - Pending ACKs
@@ -1286,18 +1281,24 @@ class Nimo: NSObject, SGCManager {
1286
1281
  cancelTwsTimeout()
1287
1282
  handshakeState = .awaitingTimeAck
1288
1283
  Bridge.log("NIMO: TWS OK — sending setTime (awaiting ACK)")
1289
- sendAwaitingAck(
1290
- cmd: NimoProtocol.CMD_SET_PARAMETER,
1291
- key: NimoProtocol.SET_TIME,
1292
- payload: NimoFrameCodec.encodeDeviceTime()
1293
- ) { [weak self] ok in
1294
- guard let self else { return }
1295
- if !ok {
1296
- Bridge.log("NIMO: setTime ACK failed/timed out — handshake failed")
1297
- self.handshakeFailed()
1298
- } else {
1299
- self.finishHandshake()
1300
- }
1284
+ setTimeAttempts = 0
1285
+ attemptSetTime()
1286
+ }
1287
+
1288
+ private func attemptSetTime() {
1289
+ guard handshakeState == .awaitingTimeAck, let expected = peripheral else { return }
1290
+ setTimeAttempts += 1
1291
+ sendAwaitingAck(cmd: NimoProtocol.CMD_SET_PARAMETER, key: NimoProtocol.SET_TIME,
1292
+ payload: NimoFrameCodec.encodeDeviceTime())
1293
+ { [weak self] ok in
1294
+ guard let self, self.peripheral === expected, self.handshakeState == .awaitingTimeAck else { return }
1295
+ if ok { self.finishHandshake() }
1296
+ else if self.setTimeAttempts < 3 {
1297
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
1298
+ guard let self, self.peripheral === expected else { return }
1299
+ self.attemptSetTime()
1300
+ }
1301
+ } else { self.handshakeFailed() }
1301
1302
  }
1302
1303
  }
1303
1304
 
@@ -1312,6 +1313,9 @@ class Nimo: NSObject, SGCManager {
1312
1313
  needsAck: false
1313
1314
  )
1314
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])))
1315
1319
  getBatteryStatus()
1316
1320
  requestVersionInfo()
1317
1321
 
@@ -1321,6 +1325,10 @@ class Nimo: NSObject, SGCManager {
1321
1325
  DeviceStore.shared.apply("glasses", "fullyBooted", true)
1322
1326
  DeviceStore.shared.apply("glasses", "connectionState", ConnTypes.CONNECTED)
1323
1327
  startTimers()
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)
1324
1332
  }
1325
1333
 
1326
1334
  private func handshakeFailed() {
@@ -1333,16 +1341,13 @@ class Nimo: NSObject, SGCManager {
1333
1341
 
1334
1342
  private func resetSessionState() {
1335
1343
  handshakeState = .idle
1344
+ cancelTwsTimeout()
1336
1345
  twsConnected = false
1337
- textAppEntered = false
1338
- navAppEntered = false
1339
- currentGlassesAppId = -1
1340
- pendingText = nil
1346
+ peerCompanionReady = nil
1347
+ canvasEncoder.invalidate()
1348
+ canvas.disconnected()
1341
1349
  receiveAssembler.reset()
1342
- writeQueue.removeAll()
1343
- writeInFlight = false
1344
- writeWatchdogItem?.cancel()
1345
- writeWatchdogItem = nil
1350
+ writes.reset()
1346
1351
  failAllPendingAcks()
1347
1352
  stopTimers()
1348
1353
  }
@@ -1358,7 +1363,8 @@ class Nimo: NSObject, SGCManager {
1358
1363
  }
1359
1364
  Bridge.log("NIMO: Attempting reconnection...")
1360
1365
  await MainActor.run {
1361
- self.isDisconnecting = false
1366
+ guard !self.isDisconnecting else { return }
1367
+ self.scanRequested = true
1362
1368
  self.startScan()
1363
1369
  }
1364
1370
  return false
@@ -1366,7 +1372,7 @@ class Nimo: NSObject, SGCManager {
1366
1372
  }
1367
1373
  }
1368
1374
 
1369
- // MARK: - Timers (battery poll keepalive + text queue)
1375
+ // MARK: - Timers (battery poll keepalive)
1370
1376
 
1371
1377
  private func startTimers() {
1372
1378
  stopTimers()
@@ -1379,62 +1385,30 @@ class Nimo: NSObject, SGCManager {
1379
1385
  self?.getBatteryStatus()
1380
1386
  }
1381
1387
  }
1382
- textQueueTimer = Timer.scheduledTimer(
1383
- withTimeInterval: Const.textQueueTickSeconds, repeats: true
1384
- ) { [weak self] _ in
1385
- Task { @MainActor in
1386
- self?.drainTextQueue()
1387
- }
1388
- }
1389
1388
  }
1390
1389
 
1391
1390
  private func stopTimers() {
1392
1391
  batteryPollTimer?.invalidate()
1393
1392
  batteryPollTimer = nil
1394
- textQueueTimer?.invalidate()
1395
- textQueueTimer = nil
1396
- }
1397
-
1398
- // MARK: - Text Rendering
1399
-
1400
- private func drainTextQueue() {
1401
- guard let text = pendingText, handshakeState == .ready else { return }
1402
- pendingText = nil
1403
-
1404
- // All text (text_wall / double_text_wall / reference_card / positioned_text) renders on
1405
- // the ASR note page — the only text surface confirmed to render on hardware.
1406
- if !textAppEntered {
1407
- sendFrame(
1408
- NimoFrameCodec.encodeFrame(
1409
- cmd: NimoProtocol.CMD_CONTROL_INSTRUCTION,
1410
- key: NimoProtocol.CTRL_ENTER_APP,
1411
- payload: Data([UInt8(textAppId), UInt8(NimoProtocol.APP_MODE_STANDALONE)])
1412
- )
1413
- )
1414
- // Optimistic; corrected by app-state reports if the glasses refuse/exit.
1415
- textAppEntered = true
1416
- }
1417
-
1418
- let frames = NimoFrameCodec.updateContentFrames(
1419
- appId: textAppId,
1420
- layoutId: textLayoutId,
1421
- resId: textResId,
1422
- resType: NimoProtocol.WIDGET_TEXT_NEW,
1423
- content: Data(text.utf8)
1424
- )
1425
- enqueueFrames(frames)
1426
1393
  }
1427
1394
 
1428
1395
  // MARK: - Incoming Data
1429
1396
 
1430
1397
  private func handleRxPacket(_ packet: Data) {
1398
+ if packet.count >= 10, packet[8] == 7, [1, 3, 4].contains(packet[9]) {
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)") }
1401
+ canvas.response(key: response.key, payload: response.payload)
1402
+ }
1403
+ return
1404
+ }
1431
1405
  receiveAssembler.cleanup()
1432
1406
  for frame in receiveAssembler.ingest(packet) {
1433
1407
  guard let decoded = NimoFrameCodec.decode(frame),
1434
1408
  let cmd = decoded.cmd, let key = decoded.key
1435
1409
  else { continue }
1436
1410
  if cmd == NimoProtocol.CMD_INSTRUCTION_REPORT {
1437
- handleReport(key: key, data: decoded.data ?? Data())
1411
+ if decoded.statusCode == 0 { handleReport(key: key, data: decoded.data ?? Data()) }
1438
1412
  } else {
1439
1413
  handleResponse(
1440
1414
  cmd: cmd, key: key, statusCode: decoded.statusCode ?? 1,
@@ -1457,13 +1431,9 @@ class Nimo: NSObject, SGCManager {
1457
1431
  Bridge.log("NIMO: app state report appId=\(appId) phase=\(phase)")
1458
1432
  switch phase {
1459
1433
  case NimoProtocol.STATE_ENTER:
1460
- currentGlassesAppId = appId
1461
- if appId != textAppId { textAppEntered = false }
1462
- if appId != NimoProtocol.APP_ID_NAV { navAppEntered = false }
1434
+ canvas.nativeApp(appId, entered: true)
1463
1435
  case NimoProtocol.STATE_EXIT:
1464
- if appId == currentGlassesAppId { currentGlassesAppId = -1 }
1465
- if appId == textAppId { textAppEntered = false }
1466
- if appId == NimoProtocol.APP_ID_NAV { navAppEntered = false }
1436
+ canvas.nativeApp(appId, entered: false)
1467
1437
  default:
1468
1438
  break
1469
1439
  }
@@ -1490,7 +1460,11 @@ class Nimo: NSObject, SGCManager {
1490
1460
  case NimoProtocol.BUSINESS_HEARTBEAT:
1491
1461
  // [leftMv(2)][rightMv(2)][btSysStatus(4)][twsStatus(1)][slaveGatt(1)]
1492
1462
  if v.count >= 10 {
1463
+ peerCompanionReady = v[9] != 0
1493
1464
  onTwsState(Int(v[8]) >= 1)
1465
+ if handshakeState == .ready {
1466
+ canvas.readiness(twsConnected && peerCompanionReady != false, confirmed: true)
1467
+ }
1494
1468
  }
1495
1469
  case NimoProtocol.BUSINESS_BATTERY:
1496
1470
  if v.count >= 4 {
@@ -1512,6 +1486,7 @@ class Nimo: NSObject, SGCManager {
1512
1486
  if !connected, handshakeState == .ready {
1513
1487
  Bridge.log("NIMO: TWS service dropped mid-session (arm removed/off?)")
1514
1488
  }
1489
+ if handshakeState == .ready { canvas.readiness(connected && peerCompanionReady != false) }
1515
1490
  }
1516
1491
 
1517
1492
  private func handleInputEvent(_ code: Int) {
@@ -1547,6 +1522,9 @@ class Nimo: NSObject, SGCManager {
1547
1522
  }
1548
1523
 
1549
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
+ }
1550
1528
  resolvePendingAck(cmd: cmd, key: key, success: statusCode == 0)
1551
1529
 
1552
1530
  guard cmd == NimoProtocol.CMD_GET_PARAMETER, statusCode == 0 else { return }
@@ -1573,7 +1551,9 @@ class Nimo: NSObject, SGCManager {
1573
1551
  }
1574
1552
  case NimoProtocol.GET_VERSION_DETAIL:
1575
1553
  var end = bytes.count
1576
- while end > 0, bytes[end - 1] == 0 { end -= 1 }
1554
+ while end > 0, bytes[end - 1] == 0 {
1555
+ end -= 1
1556
+ }
1577
1557
  firmwareVersionDetail =
1578
1558
  String(bytes: bytes[0 ..< end], encoding: .utf8)?
1579
1559
  .trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
@@ -1632,124 +1612,9 @@ class Nimo: NSObject, SGCManager {
1632
1612
  guard let opusDecoder else { return }
1633
1613
  for opusFrame in packet.opusFrames {
1634
1614
  if let pcm = opusDecoder.decode(opusFrame), !pcm.isEmpty {
1635
- DeviceManager.shared.handlePcm(pcm)
1636
- }
1637
- }
1638
- }
1639
-
1640
- // MARK: - Bitmap Helpers
1641
-
1642
- /// Aspect-fit the image into `width`x`height` on black, then convert to L8 grayscale.
1643
- private func bitmapToGrayscale(_ image: SdkImage, width: Int, height: Int) -> Data? {
1644
- guard let cgImage = image.cgImage else { return nil }
1645
- let colorSpace = CGColorSpaceCreateDeviceGray()
1646
- guard let context = CGContext(
1647
- data: nil,
1648
- width: width,
1649
- height: height,
1650
- bitsPerComponent: 8,
1651
- bytesPerRow: width,
1652
- space: colorSpace,
1653
- bitmapInfo: CGImageAlphaInfo.none.rawValue
1654
- ) else { return nil }
1655
-
1656
- context.setFillColor(gray: 0, alpha: 1)
1657
- context.fill(CGRect(x: 0, y: 0, width: width, height: height))
1658
- let scale = min(CGFloat(width) / CGFloat(cgImage.width), CGFloat(height) / CGFloat(cgImage.height))
1659
- let dw = CGFloat(cgImage.width) * scale
1660
- let dh = CGFloat(cgImage.height) * scale
1661
- let rect = CGRect(
1662
- x: (CGFloat(width) - dw) / 2, y: (CGFloat(height) - dh) / 2, width: dw, height: dh
1663
- )
1664
- context.interpolationQuality = .medium
1665
- context.draw(cgImage, in: rect)
1666
-
1667
- guard let buffer = context.data else { return nil }
1668
- // CGContext rows are top-down here; copy row by row in case bytesPerRow != width.
1669
- // Invert luminance (255 - value): MentraOS app bitmaps follow the platform convention
1670
- // of dark content on a light background, but Nimo's additive display lights up high
1671
- // 2bpp values (3 = white). Without inverting, the background lights the whole lens and
1672
- // the content stays dark — the inverted look. Flipping renders content lit on a dark lens.
1673
- var gray = Data(capacity: width * height)
1674
- let bytesPerRow = context.bytesPerRow
1675
- let base = buffer.assumingMemoryBound(to: UInt8.self)
1676
- for row in 0 ..< height {
1677
- let rowOffset = row * bytesPerRow
1678
- for col in 0 ..< width {
1679
- gray.append(255 - base[rowOffset + col])
1615
+ DeviceManager.shared.handleGlassesPcm(pcm)
1680
1616
  }
1681
1617
  }
1682
- return gray
1683
- }
1684
-
1685
- /// L8 → 2bpp: thresholds 0x40/0x80/0xC0, 4 pixels per byte, MSB first.
1686
- private func packL8To2bpp(_ l8: Data) -> Data {
1687
- let bytes = [UInt8](l8)
1688
- let outBytes = (bytes.count + 3) >> 2
1689
- var dst = Data(count: outBytes)
1690
- for i in 0 ..< outBytes {
1691
- var packed: UInt8 = 0
1692
- for j in 0 ..< 4 {
1693
- let idx = i * 4 + j
1694
- var value: UInt8 = 0
1695
- if idx < bytes.count {
1696
- let px = bytes[idx]
1697
- if px < 0x40 {
1698
- value = 0
1699
- } else if px < 0x80 {
1700
- value = 1
1701
- } else if px < 0xC0 {
1702
- value = 2
1703
- } else {
1704
- value = 3
1705
- }
1706
- }
1707
- packed |= (value & 0x03) << UInt8(6 - 2 * j)
1708
- }
1709
- dst[i] = packed
1710
- }
1711
- return dst
1712
- }
1713
-
1714
- /// zlib-compress (RFC1950); falls back to uncompressed when not smaller.
1715
- /// Apple's Compression framework emits raw deflate, so the 2-byte zlib header
1716
- /// and adler32 trailer the firmware expects are added manually.
1717
- private func compressAdaptive(_ data: Data) -> (Data, Int) {
1718
- guard !data.isEmpty, let compressed = zlibCompress(data), compressed.count < data.count
1719
- else { return (data, NimoProtocol.COMPRESSION_NONE) }
1720
- return (compressed, NimoProtocol.COMPRESSION_ZLIB)
1721
- }
1722
-
1723
- private func zlibCompress(_ data: Data) -> Data? {
1724
- let dstCapacity = data.count + data.count / 16 + 256
1725
- var deflated = Data(count: dstCapacity)
1726
- let written = deflated.withUnsafeMutableBytes { (dst: UnsafeMutableRawBufferPointer) -> Int in
1727
- data.withUnsafeBytes { (src: UnsafeRawBufferPointer) -> Int in
1728
- compression_encode_buffer(
1729
- dst.bindMemory(to: UInt8.self).baseAddress!, dstCapacity,
1730
- src.bindMemory(to: UInt8.self).baseAddress!, data.count,
1731
- nil, COMPRESSION_ZLIB
1732
- )
1733
- }
1734
- }
1735
- guard written > 0 else { return nil }
1736
- deflated.removeSubrange(written ..< deflated.count)
1737
-
1738
- var out = Data([0x78, 0x9C]) // zlib header, default compression
1739
- out.append(deflated)
1740
- // adler32 over the uncompressed data, big-endian
1741
- var a: UInt32 = 1
1742
- var b: UInt32 = 0
1743
- for byte in data {
1744
- a = (a + UInt32(byte)) % 65521
1745
- b = (b + a) % 65521
1746
- }
1747
- let adler = (b << 16) | a
1748
- out.append(contentsOf: [
1749
- UInt8((adler >> 24) & 0xFF), UInt8((adler >> 16) & 0xFF),
1750
- UInt8((adler >> 8) & 0xFF), UInt8(adler & 0xFF),
1751
- ])
1752
- return out
1753
1618
  }
1754
1619
  }
1755
1620
 
@@ -1768,41 +1633,35 @@ extension Nimo: CBCentralManagerDelegate {
1768
1633
  }
1769
1634
 
1770
1635
  nonisolated func centralManager(
1771
- _ central: CBCentralManager,
1636
+ _: CBCentralManager,
1772
1637
  didDiscover peripheral: CBPeripheral,
1773
1638
  advertisementData: [String: Any],
1774
1639
  rssi: NSNumber
1775
1640
  ) {
1776
- guard
1777
- let name = peripheral.name
1778
- ?? advertisementData[CBAdvertisementDataLocalNameKey] as? String
1779
- else { return }
1641
+ let advertisedName = advertisementData[CBAdvertisementDataLocalNameKey] as? String
1780
1642
  let rssiValue = rssi.intValue
1781
-
1782
1643
  DispatchQueue.main.async { [weak self] in
1783
- guard let self else { return }
1784
- guard self.isNimoMainDevice(name) else { return }
1785
-
1786
- Bridge.sendDiscoveredDevice(DeviceTypes.NIMO, name, rssi: rssiValue)
1787
-
1788
- guard self.DEVICE_SEARCH_ID != "NOT_SET" else { return }
1789
- guard name == self.DEVICE_SEARCH_ID else { return }
1790
- guard self.peripheral == nil else { return }
1791
-
1792
- Bridge.log("NIMO: Connecting to \(name)")
1793
- self.stopScan()
1794
- self.lastDeviceName = name
1795
- self.lastDeviceUUID = peripheral.identifier.uuidString
1796
- self.peripheral = peripheral
1797
- peripheral.delegate = self
1798
- central.connect(peripheral, options: nil)
1644
+ self?.discovery.receive(peripheral, advertisedName: advertisedName, rssi: rssiValue)
1799
1645
  }
1800
1646
  }
1801
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
+
1802
1659
  nonisolated func centralManager(_: CBCentralManager, didConnect peripheral: CBPeripheral) {
1803
1660
  DispatchQueue.main.async { [weak self] in
1804
1661
  guard let self else { return }
1805
1662
  Bridge.log("NIMO: Connected to \(peripheral.name ?? "unknown")")
1663
+ guard self.peripheral === peripheral else { return }
1664
+ self.writes.connected(peripheral)
1806
1665
  self.lastDeviceUUID = peripheral.identifier.uuidString
1807
1666
  peripheral.discoverServices([NimoBLE.SERVICE_UUID])
1808
1667
  }
@@ -1816,18 +1675,19 @@ extension Nimo: CBCentralManagerDelegate {
1816
1675
  Bridge.log(
1817
1676
  "NIMO: Failed to connect \(peripheral.name ?? "unknown"): \(error?.localizedDescription ?? "unknown")"
1818
1677
  )
1678
+ guard self.peripheral === peripheral else { return }
1819
1679
  self.peripheral = nil
1820
1680
  self.startReconnectionTimer()
1821
1681
  }
1822
1682
  }
1823
1683
 
1824
1684
  nonisolated func centralManager(
1825
- _: CBCentralManager, didDisconnectPeripheral _: CBPeripheral, error: Error?
1685
+ _: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?
1826
1686
  ) {
1827
1687
  DispatchQueue.main.async { [weak self] in
1828
1688
  guard let self else { return }
1829
1689
  Bridge.log("NIMO: Disconnected: \(error?.localizedDescription ?? "clean")")
1830
- if self.isDisconnecting { return }
1690
+ if self.isDisconnecting || self.peripheral !== peripheral { return }
1831
1691
 
1832
1692
  self.peripheral = nil
1833
1693
  self.txChar = nil
@@ -1846,15 +1706,19 @@ extension Nimo: CBCentralManagerDelegate {
1846
1706
  // MARK: - CBPeripheralDelegate
1847
1707
 
1848
1708
  extension Nimo: CBPeripheralDelegate {
1849
- nonisolated func peripheral(_ peripheral: CBPeripheral, didDiscoverServices _: Error?) {
1850
- guard let services = peripheral.services else { return }
1851
- for service in services where service.uuid == NimoBLE.SERVICE_UUID {
1709
+ nonisolated func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
1710
+ DispatchQueue.main.async { [weak self] in
1711
+ guard let self, self.peripheral === peripheral else { return }
1712
+ guard error == nil, let service = peripheral.services?.first(where: { $0.uuid == NimoBLE.SERVICE_UUID }) else {
1713
+ self.abortTransport("NIMO service discovery failed")
1714
+ return
1715
+ }
1852
1716
  peripheral.discoverCharacteristics(nil, for: service)
1853
1717
  }
1854
1718
  }
1855
1719
 
1856
1720
  nonisolated func peripheral(
1857
- _ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error _: Error?
1721
+ _ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?
1858
1722
  ) {
1859
1723
  guard service.uuid == NimoBLE.SERVICE_UUID,
1860
1724
  let characteristics = service.characteristics
@@ -1862,6 +1726,8 @@ extension Nimo: CBPeripheralDelegate {
1862
1726
 
1863
1727
  DispatchQueue.main.async { [weak self] in
1864
1728
  guard let self else { return }
1729
+ guard self.peripheral === peripheral else { return }
1730
+ guard error == nil else { self.abortTransport("NIMO characteristic discovery failed"); return }
1865
1731
  for char in characteristics {
1866
1732
  switch char.uuid {
1867
1733
  case NimoBLE.CHAR_TX:
@@ -1876,6 +1742,10 @@ extension Nimo: CBPeripheralDelegate {
1876
1742
  break
1877
1743
  }
1878
1744
  }
1745
+ guard self.txChar?.properties.contains(.write) == true, self.rxChar != nil, self.micChar != nil else {
1746
+ self.abortTransport("Required NIMO characteristics unavailable")
1747
+ return
1748
+ }
1879
1749
  Bridge.log(
1880
1750
  "NIMO: chars tx=\(self.txChar != nil) rx=\(self.rxChar != nil) mic=\(self.micChar != nil)"
1881
1751
  )
@@ -1883,46 +1753,48 @@ extension Nimo: CBPeripheralDelegate {
1883
1753
  }
1884
1754
 
1885
1755
  nonisolated func peripheral(
1886
- _: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic,
1756
+ _ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic,
1887
1757
  error: Error?
1888
1758
  ) {
1889
1759
  DispatchQueue.main.async { [weak self] in
1890
1760
  guard let self else { return }
1761
+ guard self.peripheral === peripheral else { return }
1891
1762
  if let error {
1763
+ self.abortTransport("Notification subscription failed")
1892
1764
  Bridge.log("NIMO: notify enable failed: \(error.localizedDescription)")
1893
1765
  return
1894
1766
  }
1895
- // RX notifications live → the data channel is usable; run the handshake.
1896
- if characteristic.uuid == NimoBLE.CHAR_RX, self.handshakeState == .idle {
1767
+ guard characteristic === self.rxChar || characteristic === self.micChar else { return }
1768
+ guard characteristic.isNotifying else { self.abortTransport("NIMO notifications disabled"); return }
1769
+ if self.rxChar?.isNotifying == true, self.micChar?.isNotifying == true,
1770
+ self.txChar?.properties.contains(.write) == true, self.handshakeState == .idle
1771
+ {
1897
1772
  self.startHandshake()
1898
1773
  }
1899
1774
  }
1900
1775
  }
1901
1776
 
1902
1777
  nonisolated func peripheral(
1903
- _: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?
1778
+ _ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?
1904
1779
  ) {
1905
1780
  guard let data = characteristic.value, error == nil else { return }
1906
1781
  let uuid = characteristic.uuid
1907
1782
  DispatchQueue.main.async { [weak self] in
1908
1783
  guard let self else { return }
1909
- if uuid == NimoBLE.CHAR_RX {
1784
+ guard self.peripheral === peripheral else { return }
1785
+ if uuid == NimoBLE.CHAR_RX, characteristic === self.rxChar {
1910
1786
  self.handleRxPacket(data)
1911
- } else if uuid == NimoBLE.CHAR_MIC {
1787
+ } else if uuid == NimoBLE.CHAR_MIC, characteristic === self.micChar {
1912
1788
  self.handleMicPacket(data)
1913
1789
  }
1914
1790
  }
1915
1791
  }
1916
1792
 
1917
1793
  nonisolated func peripheral(
1918
- _: CBPeripheral, didWriteValueFor _: CBCharacteristic, error: Error?
1794
+ _ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?
1919
1795
  ) {
1920
1796
  DispatchQueue.main.async { [weak self] in
1921
- guard let self else { return }
1922
- if let error {
1923
- Bridge.log("NIMO: Write error: \(error.localizedDescription)")
1924
- }
1925
- self.onWriteCompleted()
1797
+ self?.writes.written(peripheral, characteristic: characteristic, success: error == nil)
1926
1798
  }
1927
1799
  }
1928
1800
  }