@mentra/bluetooth-sdk 3.2.1-beta.319 → 3.2.1-beta.355

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 (83) hide show
  1. package/README.md +1 -1
  2. package/android/src/debug/java/com/mentra/bluetoothsdk/sgcs/NimoDiagnostics.kt +368 -0
  3. package/android/src/debug/java/com/mentra/bluetoothsdk/sgcs/NimoFramebufferProtocol.kt +156 -0
  4. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkDefaults.kt +3 -0
  5. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +4 -0
  6. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +2 -1
  7. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +67 -12
  8. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +37 -13
  9. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedChangelogCatalog.kt +1 -1
  10. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +5 -5
  11. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +4 -0
  12. package/android/src/main/java/com/mentra/bluetoothsdk/VersionInfoResponseAccumulator.kt +13 -2
  13. package/android/src/main/java/com/mentra/bluetoothsdk/services/Foreground.kt +33 -0
  14. package/android/src/main/java/com/mentra/bluetoothsdk/services/G2ConnectionRecovery.kt +47 -0
  15. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +64 -14
  16. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +73 -3
  17. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Nimo.kt +257 -395
  18. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCodec.kt +245 -0
  19. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCoordinator.kt +225 -0
  20. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasEncoder.kt +58 -0
  21. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasSession.kt +168 -0
  22. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +11 -0
  23. package/android/src/main/java/com/mentra/bluetoothsdk/utils/G2Text.kt +7 -0
  24. package/android/src/release/java/com/mentra/bluetoothsdk/sgcs/NimoDiagnostics.kt +20 -0
  25. package/android/src/test/java/com/mentra/bluetoothsdk/DeviceManagerSceneHandoffTest.kt +557 -0
  26. package/android/src/test/java/com/mentra/bluetoothsdk/G2TextTest.kt +13 -0
  27. package/android/src/test/java/com/mentra/bluetoothsdk/VersionInfoResponseAccumulatorTest.kt +71 -0
  28. package/android/src/test/java/com/mentra/bluetoothsdk/services/G2ConnectionRecoveryTest.kt +51 -0
  29. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G2DisplayThreadTest.kt +120 -0
  30. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoBitmapAcceptanceTest.kt +105 -0
  31. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCodecTest.kt +220 -0
  32. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCoordinatorTest.kt +308 -0
  33. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasSessionTest.kt +270 -0
  34. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoDiagnosticsTest.kt +287 -0
  35. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoFramebufferProtocolTest.kt +127 -0
  36. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoLifecycleTest.kt +194 -0
  37. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoReadinessDriverTest.kt +199 -0
  38. package/build/BluetoothSdk.types.d.ts +11 -0
  39. package/build/BluetoothSdk.types.d.ts.map +1 -1
  40. package/build/BluetoothSdk.types.js.map +1 -1
  41. package/build/_private/BluetoothSdkModule.d.ts +2 -0
  42. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  43. package/build/_private/BluetoothSdkModule.js +3 -0
  44. package/build/_private/BluetoothSdkModule.js.map +1 -1
  45. package/build/generated/changelogCatalog.d.ts +1 -1
  46. package/build/generated/changelogCatalog.js +1 -1
  47. package/build/generated/changelogCatalog.js.map +1 -1
  48. package/build/generated/releaseMetadata.js +5 -5
  49. package/build/generated/releaseMetadata.js.map +1 -1
  50. package/build/index.d.ts.map +1 -1
  51. package/build/index.js +1 -0
  52. package/build/index.js.map +1 -1
  53. package/ios/BluetoothSdkModule.swift +5 -0
  54. package/ios/MentraBluetoothSDK.podspec +3 -2
  55. package/ios/Source/BluetoothSdkDefaults.swift +6 -1
  56. package/ios/Source/Bridge.swift +15 -2
  57. package/ios/Source/DeviceManager.swift +23 -10
  58. package/ios/Source/DeviceStore.swift +26 -7
  59. package/ios/Source/GeneratedChangelogCatalog.swift +1 -1
  60. package/ios/Source/GeneratedReleaseMetadata.swift +5 -5
  61. package/ios/Source/MentraBluetoothSDK.swift +4 -0
  62. package/ios/Source/requests/VersionInfoResponseAccumulator.swift +11 -1
  63. package/ios/Source/sgcs/G2.swift +2 -2
  64. package/ios/Source/sgcs/MentraLive.swift +66 -3
  65. package/ios/Source/sgcs/Nimo.swift +182 -371
  66. package/ios/Source/sgcs/NimoCanvasCodec.swift +269 -0
  67. package/ios/Source/sgcs/NimoCanvasCoordinator.swift +219 -0
  68. package/ios/Source/sgcs/NimoCanvasImage.swift +53 -0
  69. package/ios/Source/sgcs/NimoCanvasSession.swift +150 -0
  70. package/ios/Source/sgcs/SGCManager.swift +24 -0
  71. package/ios/Source/utils/G2Text.swift +11 -0
  72. package/ios/Tests/DashboardRenderingTests.swift +31 -0
  73. package/ios/Tests/G2TextTests.swift +10 -0
  74. package/ios/Tests/NimoCanvasCodecTests.swift +204 -0
  75. package/ios/Tests/NimoCanvasSessionTests.swift +112 -0
  76. package/ios/Tests/NimoCanvasTransportTests.swift +161 -0
  77. package/ios/Tests/VersionInfoResponseAccumulatorTests.swift +65 -0
  78. package/package.json +2 -2
  79. package/src/BluetoothSdk.types.ts +11 -0
  80. package/src/_private/BluetoothSdkModule.ts +6 -0
  81. package/src/generated/changelogCatalog.ts +1 -1
  82. package/src/generated/releaseMetadata.ts +5 -5
  83. package/src/index.ts +1 -0
@@ -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
 
@@ -23,7 +21,7 @@ enum NimoBLE {
23
21
  static let CHAR_MIC = CBUUID(string: "00002025-0000-1000-8000-00805F9B34FB")
24
22
 
25
23
  static let NAME_PREFIX = "nimo"
26
- // iOS ANCS side-channel devices advertise "<name>_ble" — never the data channel.
24
+ /// iOS ANCS side-channel devices advertise "<name>_ble" — never the data channel.
27
25
  static let BLE_NAME_SUFFIX = "_ble"
28
26
 
29
27
  static let CHUNK_SIZE = 501
@@ -31,6 +29,7 @@ enum NimoBLE {
31
29
  }
32
30
 
33
31
  // MARK: - Nimo Protocol Constants
32
+
34
33
  // Byte values follow the glasses firmware protocol and must not be changed.
35
34
 
36
35
  enum NimoProtocol {
@@ -69,7 +68,7 @@ enum NimoProtocol {
69
68
  static let CTRL_QUIT_APP = 0x03
70
69
  static let CTRL_UPDATE_CONTENT = 0x04
71
70
 
72
- // notification keys
71
+ /// notification keys
73
72
  static let NOTIFICATION_SEND = 0x01
74
73
 
75
74
  // report keys (cmd 0x06)
@@ -108,7 +107,7 @@ enum NimoProtocol {
108
107
  static let APP_ID_PROMPTER = 0x06
109
108
  static let APP_ID_AI_TALK = 0x07
110
109
 
111
- // enterApp modes
110
+ /// enterApp modes
112
111
  static let APP_MODE_STANDALONE = 0x00
113
112
 
114
113
  // widget resTypes
@@ -126,7 +125,7 @@ enum NimoProtocol {
126
125
  static let NAV_LARGE_MAP_WIDTH = 452
127
126
  static let NAV_LARGE_MAP_HEIGHT = 170
128
127
 
129
- // phone types
128
+ /// phone types
130
129
  static let PHONE_TYPE_IOS = 0x01
131
130
 
132
131
  // image header
@@ -570,6 +569,9 @@ actor NimoReconnectionManager {
570
569
  class Nimo: NSObject, SGCManager {
571
570
  var type: String = DeviceTypes.NIMO
572
571
  let hasMic = true
572
+ let showBrightnessConfirmation = false
573
+ let showConnectionConfirmation = false
574
+ let sceneHandoffRequiresClear = false
573
575
 
574
576
  private static let _bluetoothQueue = DispatchQueue(label: "BluetoothNimo", qos: .userInitiated)
575
577
 
@@ -578,19 +580,10 @@ class Nimo: NSObject, SGCManager {
578
580
  static let ackTimeoutSeconds: TimeInterval = 5
579
581
  static let pairingTimeoutSeconds: TimeInterval = 15
580
582
  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.
583
+ /// Fall back to the other ear if the preferred one goes quiet for this long.
584
584
  static let micSideFallbackSeconds: TimeInterval = 2
585
585
  }
586
586
 
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
587
  // BLE
595
588
  private var centralManager: CBCentralManager?
596
589
  private var peripheral: CBPeripheral?
@@ -599,7 +592,7 @@ class Nimo: NSObject, SGCManager {
599
592
  private var micChar: CBCharacteristic?
600
593
  private var isDisconnecting = false
601
594
 
602
- // Device search
595
+ /// Device search
603
596
  private var DEVICE_SEARCH_ID = "NOT_SET"
604
597
 
605
598
  private var lastDeviceName: String? {
@@ -615,7 +608,7 @@ class Nimo: NSObject, SGCManager {
615
608
  private let reconnectionManager = NimoReconnectionManager()
616
609
  private let receiveAssembler = NimoReceiveAssembler()
617
610
 
618
- // Handshake
611
+ /// Handshake
619
612
  private enum HandshakeState {
620
613
  case idle
621
614
  case awaitingTws
@@ -625,10 +618,12 @@ class Nimo: NSObject, SGCManager {
625
618
 
626
619
  private var handshakeState = HandshakeState.idle
627
620
  private var twsConnected = false
621
+ private var peerCompanionReady: Bool?
622
+ private var setTimeAttempts = 0
628
623
  private var twsTimeoutItem: DispatchWorkItem?
629
624
  private var pairingTimeoutItem: DispatchWorkItem?
630
625
 
631
- // Pending acks keyed by (cmd << 8) | key
626
+ /// Pending acks keyed by (cmd << 8) | key
632
627
  private struct PendingAck {
633
628
  let onResult: (Bool) -> Void
634
629
  let timeoutItem: DispatchWorkItem
@@ -636,26 +631,36 @@ class Nimo: NSObject, SGCManager {
636
631
 
637
632
  private var pendingAcks: [Int: PendingAck] = [:]
638
633
 
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
634
+ private static func scheduler(_ queue: DispatchQueue) -> NimoScheduler {
635
+ { delay, task in
636
+ let item = DispatchWorkItem(block: task)
637
+ queue.asyncAfter(deadline: .now() + delay, execute: item)
638
+ return { item.cancel() }
639
+ }
644
640
  }
645
641
 
646
- private var writeQueue: [QueuedWrite] = []
647
- private var writeInFlight = false
648
- private var writeWatchdogItem: DispatchWorkItem?
649
-
650
- // Timers
642
+ private let encoderQueue = DispatchQueue(label: "NimoCanvasEncoder", qos: .userInitiated)
643
+ private lazy var canvasEncoder = NimoCanvasEncoder(
644
+ main: Self.scheduler(.main), worker: Self.scheduler(encoderQueue),
645
+ failure: { Bridge.log("NIMO: canvas encoding failed; retaining last valid scene: \($0)") }
646
+ )
647
+ private lazy var writes = NimoWriteQueue<CBCharacteristic>(
648
+ schedule: Self.scheduler(.main), write: { [weak self] characteristic, bytes in
649
+ guard let self, let peripheral = self.peripheral, peripheral.state == .connected,
650
+ bytes.count <= peripheral.maximumWriteValueLength(for: .withResponse) else { return false }
651
+ peripheral.writeValue(bytes, for: characteristic, type: .withResponse)
652
+ return true
653
+ }, failure: { [weak self] in self?.abortTransport($0) }
654
+ )
655
+ private lazy var canvas = NimoCanvasCoordinator(
656
+ schedule: Self.scheduler(.main),
657
+ writeCapacity: { [weak self] in min(512, self?.peripheral?.maximumWriteValueLength(for: .withResponse) ?? 20) },
658
+ enqueue: { [weak self] frames, started, completed in
659
+ self?.enqueueFrames(frames, finalStarted: started, completed: completed) ?? false
660
+ }, reconnect: { [weak self] in self?.abortTransport($0) },
661
+ rejected: { Bridge.log("NIMO: canvas rejected status=\($0); not a render ACK") }
662
+ )
651
663
  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
664
 
660
665
  // Battery
661
666
  private var lastBatteryLevel = -1
@@ -804,8 +809,7 @@ class Nimo: NSObject, SGCManager {
804
809
  }
805
810
 
806
811
  func clearDisplay() {
807
- // Keep the text app/page alive — a blank update avoids re-entering the app next time.
808
- pendingText = " "
812
+ encodeCanvas(scope: "legacy") { try NimoCanvasCodec.replace([]) }
809
813
  }
810
814
 
811
815
  func sendText(_ text: String) async {
@@ -813,92 +817,61 @@ class Nimo: NSObject, SGCManager {
813
817
  }
814
818
 
815
819
  func sendTextWall(_ text: String) async {
816
- // Coalesced: only the most recent pending text survives until the next 100ms drain.
817
- pendingText = text
820
+ encodeCanvas(scope: "legacy") {
821
+ try NimoCanvasCodec.replace(NimoCanvasCodec.text(text, 0, 0, 500, 220, border: 0, radius: 0))
822
+ }
818
823
  }
819
824
 
820
825
  func sendDoubleTextWall(_ top: String, _ bottom: String) async {
821
826
  await sendTextWall(top + "\n\n" + bottom)
822
827
  }
823
828
 
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")
829
+ func sendPositionedText(_ text: String, x: Int32, y: Int32, width: Int32, height: Int32,
830
+ borderWidth: Int32, borderRadius: Int32) async
831
+ {
832
+ encodeCanvas(scope: "legacy") {
833
+ try NimoCanvasCodec.replace(NimoCanvasCodec.text(text, Int(x), Int(y), Int(width), Int(height),
834
+ border: Int(borderWidth), radius: Int(borderRadius)))
835
+ }
836
+ }
837
+
838
+ func displayBitmap(base64ImageData: String, x: Int32?, y: Int32?, width: Int32?, height: Int32?) async -> Bool {
839
+ do {
840
+ let left = Int(x ?? 0), top = Int(y ?? 0)
841
+ let w = width.map(Int.init) ?? (NimoCanvasCodec.width - left)
842
+ let h = height.map(Int.init) ?? (NimoCanvasCodec.height - top)
843
+ try NimoCanvasCodec.region(left, top, w, h)
844
+ // True acknowledges bounded local validation, never visible rendering.
845
+ let source = try NimoCanvasImage.source(base64ImageData)
846
+ encodeCanvas(scope: "legacy") {
847
+ try NimoCanvasCodec.replace([NimoCanvasCodec.bitmap(left, top, w, h,
848
+ gray: NimoCanvasImage.grayscale(source, width: w, height: h))])
849
+ }
850
+ return true
851
+ } catch {
852
+ Bridge.log("NIMO: displayBitmap failed: \(error)")
852
853
  return false
853
854
  }
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
855
+ }
856
+
857
+ func applySceneFrame(_ frame: SceneFrame) async {
858
+ encodeCanvas(scope: "\(frame.appId):\(frame.epoch)", force: frame.replay) {
859
+ try NimoCanvasCodec.scene(frame.elements) { try NimoCanvasImage.decode($0, width: $1, height: $2) }
860
+ }
861
+ }
862
+
863
+ func clearSceneElements(_: [String]) async {
864
+ clearDisplay()
865
+ }
866
+
867
+ private func encodeCanvas(scope: String, force: Bool = false, encode: @escaping () throws -> Data) {
868
+ canvasEncoder.submit(encode: encode) { [weak self] bytes in
869
+ self?.canvas.offer(bytes, scope: scope, force: force)
868
870
  }
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
887
871
  }
888
872
 
889
873
  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
- )
874
+ exit()
902
875
  }
903
876
 
904
877
  func setDashboardPosition(_ height: Int, _ depth: Int) {
@@ -963,17 +936,8 @@ class Nimo: NSObject, SGCManager {
963
936
  }
964
937
 
965
938
  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
- )
939
+ canvasEncoder.invalidate()
940
+ canvas.exit()
977
941
  }
978
942
 
979
943
  func sendShutdown() {
@@ -1020,7 +984,9 @@ class Nimo: NSObject, SGCManager {
1020
984
  if bytes.count > maxLen {
1021
985
  // Truncate on a UTF-8 boundary
1022
986
  var end = maxLen
1023
- while end > 0, bytes[end] & 0xC0 == 0x80 { end -= 1 }
987
+ while end > 0, bytes[end] & 0xC0 == 0x80 {
988
+ end -= 1
989
+ }
1024
990
  bytes = bytes.subdata(in: 0 ..< end)
1025
991
  }
1026
992
  dest.replaceSubrange(offset ..< offset + bytes.count, with: bytes)
@@ -1070,7 +1036,7 @@ class Nimo: NSObject, SGCManager {
1070
1036
  func sendWifiCredentials(_: String, _: String) {}
1071
1037
  func forgetWifiNetwork(_: String) {}
1072
1038
  func sendHotspotState(_: Bool) {}
1073
- func sendOtaStart(otaVersionUrl: String?) {}
1039
+ func sendOtaStart(otaVersionUrl _: String?) {}
1074
1040
  func sendOtaQueryStatus() {}
1075
1041
 
1076
1042
  // MARK: - SGCManager: User Context / Gallery / Version
@@ -1163,44 +1129,9 @@ class Nimo: NSObject, SGCManager {
1163
1129
 
1164
1130
  // MARK: - Write Queue
1165
1131
 
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()
1132
+ private func enqueueWrite(_ characteristic: CBCharacteristic, _ bytes: Data) {
1133
+ guard let peripheral else { return }
1134
+ writes.enqueue(peripheral, characteristic: characteristic, frames: [bytes])
1204
1135
  }
1205
1136
 
1206
1137
  private func sendFrame(_ frame: Data) {
@@ -1208,12 +1139,18 @@ class Nimo: NSObject, SGCManager {
1208
1139
  enqueueWrite(txChar, frame)
1209
1140
  }
1210
1141
 
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()
1142
+ private func enqueueFrames(_ frames: [Data], finalStarted: @escaping () -> Void,
1143
+ completed: @escaping () -> Void) -> Bool
1144
+ {
1145
+ guard let peripheral, let txChar else { return false }
1146
+ return writes.enqueue(peripheral, characteristic: txChar, frames: frames,
1147
+ finalStarted: finalStarted, completed: completed)
1148
+ }
1149
+
1150
+ private func abortTransport(_ reason: String) {
1151
+ Bridge.log("NIMO: retiring transport: \(reason)")
1152
+ resetSessionState()
1153
+ if let peripheral { centralManager?.cancelPeripheralConnection(peripheral) }
1217
1154
  }
1218
1155
 
1219
1156
  // MARK: - Pending ACKs
@@ -1286,18 +1223,24 @@ class Nimo: NSObject, SGCManager {
1286
1223
  cancelTwsTimeout()
1287
1224
  handshakeState = .awaitingTimeAck
1288
1225
  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
- }
1226
+ setTimeAttempts = 0
1227
+ attemptSetTime()
1228
+ }
1229
+
1230
+ private func attemptSetTime() {
1231
+ guard handshakeState == .awaitingTimeAck, let expected = peripheral else { return }
1232
+ setTimeAttempts += 1
1233
+ sendAwaitingAck(cmd: NimoProtocol.CMD_SET_PARAMETER, key: NimoProtocol.SET_TIME,
1234
+ payload: NimoFrameCodec.encodeDeviceTime())
1235
+ { [weak self] ok in
1236
+ guard let self, self.peripheral === expected, self.handshakeState == .awaitingTimeAck else { return }
1237
+ if ok { self.finishHandshake() }
1238
+ else if self.setTimeAttempts < 3 {
1239
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
1240
+ guard let self, self.peripheral === expected else { return }
1241
+ self.attemptSetTime()
1242
+ }
1243
+ } else { self.handshakeFailed() }
1301
1244
  }
1302
1245
  }
1303
1246
 
@@ -1321,6 +1264,7 @@ class Nimo: NSObject, SGCManager {
1321
1264
  DeviceStore.shared.apply("glasses", "fullyBooted", true)
1322
1265
  DeviceStore.shared.apply("glasses", "connectionState", ConnTypes.CONNECTED)
1323
1266
  startTimers()
1267
+ canvas.readiness(twsConnected && peerCompanionReady == true)
1324
1268
  }
1325
1269
 
1326
1270
  private func handshakeFailed() {
@@ -1333,16 +1277,13 @@ class Nimo: NSObject, SGCManager {
1333
1277
 
1334
1278
  private func resetSessionState() {
1335
1279
  handshakeState = .idle
1280
+ cancelTwsTimeout()
1336
1281
  twsConnected = false
1337
- textAppEntered = false
1338
- navAppEntered = false
1339
- currentGlassesAppId = -1
1340
- pendingText = nil
1282
+ peerCompanionReady = nil
1283
+ canvasEncoder.invalidate()
1284
+ canvas.disconnected()
1341
1285
  receiveAssembler.reset()
1342
- writeQueue.removeAll()
1343
- writeInFlight = false
1344
- writeWatchdogItem?.cancel()
1345
- writeWatchdogItem = nil
1286
+ writes.reset()
1346
1287
  failAllPendingAcks()
1347
1288
  stopTimers()
1348
1289
  }
@@ -1366,7 +1307,7 @@ class Nimo: NSObject, SGCManager {
1366
1307
  }
1367
1308
  }
1368
1309
 
1369
- // MARK: - Timers (battery poll keepalive + text queue)
1310
+ // MARK: - Timers (battery poll keepalive)
1370
1311
 
1371
1312
  private func startTimers() {
1372
1313
  stopTimers()
@@ -1379,62 +1320,29 @@ class Nimo: NSObject, SGCManager {
1379
1320
  self?.getBatteryStatus()
1380
1321
  }
1381
1322
  }
1382
- textQueueTimer = Timer.scheduledTimer(
1383
- withTimeInterval: Const.textQueueTickSeconds, repeats: true
1384
- ) { [weak self] _ in
1385
- Task { @MainActor in
1386
- self?.drainTextQueue()
1387
- }
1388
- }
1389
1323
  }
1390
1324
 
1391
1325
  private func stopTimers() {
1392
1326
  batteryPollTimer?.invalidate()
1393
1327
  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
1328
  }
1427
1329
 
1428
1330
  // MARK: - Incoming Data
1429
1331
 
1430
1332
  private func handleRxPacket(_ packet: Data) {
1333
+ if packet.count >= 10, packet[8] == 7, [1, 3, 4].contains(packet[9]) {
1334
+ if let response = NimoCanvasCodec.response(packet) {
1335
+ canvas.response(key: response.key, payload: response.payload)
1336
+ }
1337
+ return
1338
+ }
1431
1339
  receiveAssembler.cleanup()
1432
1340
  for frame in receiveAssembler.ingest(packet) {
1433
1341
  guard let decoded = NimoFrameCodec.decode(frame),
1434
1342
  let cmd = decoded.cmd, let key = decoded.key
1435
1343
  else { continue }
1436
1344
  if cmd == NimoProtocol.CMD_INSTRUCTION_REPORT {
1437
- handleReport(key: key, data: decoded.data ?? Data())
1345
+ if decoded.statusCode == 0 { handleReport(key: key, data: decoded.data ?? Data()) }
1438
1346
  } else {
1439
1347
  handleResponse(
1440
1348
  cmd: cmd, key: key, statusCode: decoded.statusCode ?? 1,
@@ -1457,13 +1365,9 @@ class Nimo: NSObject, SGCManager {
1457
1365
  Bridge.log("NIMO: app state report appId=\(appId) phase=\(phase)")
1458
1366
  switch phase {
1459
1367
  case NimoProtocol.STATE_ENTER:
1460
- currentGlassesAppId = appId
1461
- if appId != textAppId { textAppEntered = false }
1462
- if appId != NimoProtocol.APP_ID_NAV { navAppEntered = false }
1368
+ if canvas.nativeApp(appId, entered: true) { canvasEncoder.invalidate() }
1463
1369
  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 }
1370
+ if canvas.nativeApp(appId, entered: false) { canvasEncoder.invalidate() }
1467
1371
  default:
1468
1372
  break
1469
1373
  }
@@ -1490,7 +1394,11 @@ class Nimo: NSObject, SGCManager {
1490
1394
  case NimoProtocol.BUSINESS_HEARTBEAT:
1491
1395
  // [leftMv(2)][rightMv(2)][btSysStatus(4)][twsStatus(1)][slaveGatt(1)]
1492
1396
  if v.count >= 10 {
1397
+ peerCompanionReady = v[9] != 0
1493
1398
  onTwsState(Int(v[8]) >= 1)
1399
+ if handshakeState == .ready {
1400
+ canvas.readiness(twsConnected && peerCompanionReady == true, confirmed: true)
1401
+ }
1494
1402
  }
1495
1403
  case NimoProtocol.BUSINESS_BATTERY:
1496
1404
  if v.count >= 4 {
@@ -1512,6 +1420,7 @@ class Nimo: NSObject, SGCManager {
1512
1420
  if !connected, handshakeState == .ready {
1513
1421
  Bridge.log("NIMO: TWS service dropped mid-session (arm removed/off?)")
1514
1422
  }
1423
+ if handshakeState == .ready { canvas.readiness(connected && peerCompanionReady == true) }
1515
1424
  }
1516
1425
 
1517
1426
  private func handleInputEvent(_ code: Int) {
@@ -1573,7 +1482,9 @@ class Nimo: NSObject, SGCManager {
1573
1482
  }
1574
1483
  case NimoProtocol.GET_VERSION_DETAIL:
1575
1484
  var end = bytes.count
1576
- while end > 0, bytes[end - 1] == 0 { end -= 1 }
1485
+ while end > 0, bytes[end - 1] == 0 {
1486
+ end -= 1
1487
+ }
1577
1488
  firmwareVersionDetail =
1578
1489
  String(bytes: bytes[0 ..< end], encoding: .utf8)?
1579
1490
  .trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
@@ -1636,121 +1547,6 @@ class Nimo: NSObject, SGCManager {
1636
1547
  }
1637
1548
  }
1638
1549
  }
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])
1680
- }
1681
- }
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
- }
1754
1550
  }
1755
1551
 
1756
1552
  // MARK: - CBCentralManagerDelegate
@@ -1775,7 +1571,7 @@ extension Nimo: CBCentralManagerDelegate {
1775
1571
  ) {
1776
1572
  guard
1777
1573
  let name = peripheral.name
1778
- ?? advertisementData[CBAdvertisementDataLocalNameKey] as? String
1574
+ ?? advertisementData[CBAdvertisementDataLocalNameKey] as? String
1779
1575
  else { return }
1780
1576
  let rssiValue = rssi.intValue
1781
1577
 
@@ -1803,6 +1599,8 @@ extension Nimo: CBCentralManagerDelegate {
1803
1599
  DispatchQueue.main.async { [weak self] in
1804
1600
  guard let self else { return }
1805
1601
  Bridge.log("NIMO: Connected to \(peripheral.name ?? "unknown")")
1602
+ guard self.peripheral === peripheral else { return }
1603
+ self.writes.connected(peripheral)
1806
1604
  self.lastDeviceUUID = peripheral.identifier.uuidString
1807
1605
  peripheral.discoverServices([NimoBLE.SERVICE_UUID])
1808
1606
  }
@@ -1816,18 +1614,19 @@ extension Nimo: CBCentralManagerDelegate {
1816
1614
  Bridge.log(
1817
1615
  "NIMO: Failed to connect \(peripheral.name ?? "unknown"): \(error?.localizedDescription ?? "unknown")"
1818
1616
  )
1617
+ guard self.peripheral === peripheral else { return }
1819
1618
  self.peripheral = nil
1820
1619
  self.startReconnectionTimer()
1821
1620
  }
1822
1621
  }
1823
1622
 
1824
1623
  nonisolated func centralManager(
1825
- _: CBCentralManager, didDisconnectPeripheral _: CBPeripheral, error: Error?
1624
+ _: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?
1826
1625
  ) {
1827
1626
  DispatchQueue.main.async { [weak self] in
1828
1627
  guard let self else { return }
1829
1628
  Bridge.log("NIMO: Disconnected: \(error?.localizedDescription ?? "clean")")
1830
- if self.isDisconnecting { return }
1629
+ if self.isDisconnecting || self.peripheral !== peripheral { return }
1831
1630
 
1832
1631
  self.peripheral = nil
1833
1632
  self.txChar = nil
@@ -1846,15 +1645,19 @@ extension Nimo: CBCentralManagerDelegate {
1846
1645
  // MARK: - CBPeripheralDelegate
1847
1646
 
1848
1647
  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 {
1648
+ nonisolated func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
1649
+ DispatchQueue.main.async { [weak self] in
1650
+ guard let self, self.peripheral === peripheral else { return }
1651
+ guard error == nil, let service = peripheral.services?.first(where: { $0.uuid == NimoBLE.SERVICE_UUID }) else {
1652
+ self.abortTransport("NIMO service discovery failed")
1653
+ return
1654
+ }
1852
1655
  peripheral.discoverCharacteristics(nil, for: service)
1853
1656
  }
1854
1657
  }
1855
1658
 
1856
1659
  nonisolated func peripheral(
1857
- _ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error _: Error?
1660
+ _ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?
1858
1661
  ) {
1859
1662
  guard service.uuid == NimoBLE.SERVICE_UUID,
1860
1663
  let characteristics = service.characteristics
@@ -1862,6 +1665,8 @@ extension Nimo: CBPeripheralDelegate {
1862
1665
 
1863
1666
  DispatchQueue.main.async { [weak self] in
1864
1667
  guard let self else { return }
1668
+ guard self.peripheral === peripheral else { return }
1669
+ guard error == nil else { self.abortTransport("NIMO characteristic discovery failed"); return }
1865
1670
  for char in characteristics {
1866
1671
  switch char.uuid {
1867
1672
  case NimoBLE.CHAR_TX:
@@ -1876,6 +1681,10 @@ extension Nimo: CBPeripheralDelegate {
1876
1681
  break
1877
1682
  }
1878
1683
  }
1684
+ guard self.txChar?.properties.contains(.write) == true, self.rxChar != nil, self.micChar != nil else {
1685
+ self.abortTransport("Required NIMO characteristics unavailable")
1686
+ return
1687
+ }
1879
1688
  Bridge.log(
1880
1689
  "NIMO: chars tx=\(self.txChar != nil) rx=\(self.rxChar != nil) mic=\(self.micChar != nil)"
1881
1690
  )
@@ -1883,46 +1692,48 @@ extension Nimo: CBPeripheralDelegate {
1883
1692
  }
1884
1693
 
1885
1694
  nonisolated func peripheral(
1886
- _: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic,
1695
+ _ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic,
1887
1696
  error: Error?
1888
1697
  ) {
1889
1698
  DispatchQueue.main.async { [weak self] in
1890
1699
  guard let self else { return }
1700
+ guard self.peripheral === peripheral else { return }
1891
1701
  if let error {
1702
+ self.abortTransport("Notification subscription failed")
1892
1703
  Bridge.log("NIMO: notify enable failed: \(error.localizedDescription)")
1893
1704
  return
1894
1705
  }
1895
- // RX notifications live → the data channel is usable; run the handshake.
1896
- if characteristic.uuid == NimoBLE.CHAR_RX, self.handshakeState == .idle {
1706
+ guard characteristic === self.rxChar || characteristic === self.micChar else { return }
1707
+ guard characteristic.isNotifying else { self.abortTransport("NIMO notifications disabled"); return }
1708
+ if self.rxChar?.isNotifying == true, self.micChar?.isNotifying == true,
1709
+ self.txChar?.properties.contains(.write) == true, self.handshakeState == .idle
1710
+ {
1897
1711
  self.startHandshake()
1898
1712
  }
1899
1713
  }
1900
1714
  }
1901
1715
 
1902
1716
  nonisolated func peripheral(
1903
- _: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?
1717
+ _ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?
1904
1718
  ) {
1905
1719
  guard let data = characteristic.value, error == nil else { return }
1906
1720
  let uuid = characteristic.uuid
1907
1721
  DispatchQueue.main.async { [weak self] in
1908
1722
  guard let self else { return }
1909
- if uuid == NimoBLE.CHAR_RX {
1723
+ guard self.peripheral === peripheral else { return }
1724
+ if uuid == NimoBLE.CHAR_RX, characteristic === self.rxChar {
1910
1725
  self.handleRxPacket(data)
1911
- } else if uuid == NimoBLE.CHAR_MIC {
1726
+ } else if uuid == NimoBLE.CHAR_MIC, characteristic === self.micChar {
1912
1727
  self.handleMicPacket(data)
1913
1728
  }
1914
1729
  }
1915
1730
  }
1916
1731
 
1917
1732
  nonisolated func peripheral(
1918
- _: CBPeripheral, didWriteValueFor _: CBCharacteristic, error: Error?
1733
+ _ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?
1919
1734
  ) {
1920
1735
  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()
1736
+ self?.writes.written(peripheral, characteristic: characteristic, success: error == nil)
1926
1737
  }
1927
1738
  }
1928
1739
  }