@mentra/bluetooth-sdk 0.1.17 → 0.1.19

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 (57) hide show
  1. package/README.md +1 -1
  2. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +52 -4
  3. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +119 -16
  4. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +0 -6
  5. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +22 -21
  6. package/android/src/main/java/com/mentra/bluetoothsdk/OtaManifest.kt +1 -1
  7. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +488 -15
  8. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +457 -31
  9. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLiveL2capChannel.kt +274 -0
  10. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +361 -67
  11. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraosBle.java +7501 -3704
  12. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +134 -0
  13. package/android/src/main/java/com/mentra/bluetoothsdk/status/DeviceStatus.kt +0 -6
  14. package/android/src/main/java/com/mentra/bluetoothsdk/utils/BleJsonCompact.java +493 -0
  15. package/android/src/main/java/com/mentra/bluetoothsdk/utils/BlePhotoUploadService.java +4 -1
  16. package/android/src/main/java/com/mentra/bluetoothsdk/utils/BleWireProtocol.java +108 -0
  17. package/android/src/main/java/com/mentra/bluetoothsdk/utils/IncidentLogBleUploadService.java +4 -3
  18. package/android/src/main/java/com/mentra/bluetoothsdk/utils/K900LengthCodec.java +115 -0
  19. package/android/src/main/java/com/mentra/bluetoothsdk/utils/K900ProtocolUtils.java +103 -68
  20. package/android/src/main/java/com/mentra/bluetoothsdk/utils/MessageChunkReassembler.java +106 -0
  21. package/android/src/main/java/com/mentra/bluetoothsdk/utils/MessageChunker.java +93 -0
  22. package/android/src/main/java/com/mentra/bluetoothsdk/utils/NexSGCUtils.kt +111 -11
  23. package/android/src/test/java/com/mentra/bluetoothsdk/camera/PhotoRequestTest.kt +7 -9
  24. package/android/src/test/java/com/mentra/bluetoothsdk/utils/AvifExifStripperTest.java +17 -1
  25. package/android/src/test/java/com/mentra/bluetoothsdk/utils/BinaryMessageChunkerTest.java +84 -0
  26. package/android/src/test/java/com/mentra/bluetoothsdk/utils/BleJsonCompactTest.java +158 -0
  27. package/android/src/test/java/com/mentra/bluetoothsdk/utils/K900ProtocolUtilsEndiannessTest.java +172 -0
  28. package/android/src/test/java/com/mentra/bluetoothsdk/utils/K900ProtocolUtilsTest.java +24 -0
  29. package/build/BluetoothSdk.types.d.ts +12 -1
  30. package/build/BluetoothSdk.types.d.ts.map +1 -1
  31. package/build/BluetoothSdk.types.js.map +1 -1
  32. package/build/types/index.d.ts +3 -0
  33. package/build/types/index.d.ts.map +1 -0
  34. package/build/types/index.js +2 -0
  35. package/build/types/index.js.map +1 -0
  36. package/ios/LocalPhotoUploadServer.swift +78 -0
  37. package/ios/MentraPhotoReceiverModule.swift +10 -0
  38. package/ios/Source/BluetoothSdkDefaults.swift +1 -1
  39. package/ios/Source/Bridge.swift +63 -0
  40. package/ios/Source/DeviceManager.swift +134 -29
  41. package/ios/Source/DeviceStore.swift +0 -5
  42. package/ios/Source/MentraBluetoothSDK.swift +26 -23
  43. package/ios/Source/OtaManifest.swift +1 -1
  44. package/ios/Source/internal/BleTraceLogger.swift +192 -0
  45. package/ios/Source/sgcs/G2.swift +518 -11
  46. package/ios/Source/sgcs/MentraLive.swift +607 -39
  47. package/ios/Source/sgcs/MentraNex.swift +416 -32
  48. package/ios/Source/sgcs/SGCManager.swift +155 -0
  49. package/ios/Source/sgcs/mentraos_ble.pb.swift +726 -122
  50. package/ios/Source/status/DeviceStatus.swift +0 -8
  51. package/ios/Source/utils/BleJsonCompact.swift +395 -0
  52. package/ios/Source/utils/BleWireProtocol.swift +92 -0
  53. package/ios/Source/utils/MessageChunkReassembler.swift +82 -0
  54. package/ios/Source/utils/MessageChunker.swift +73 -0
  55. package/package.json +14 -7
  56. package/src/BluetoothSdk.types.ts +13 -1
  57. package/src/types/index.ts +7 -0
@@ -84,23 +84,230 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
84
84
  await sendTextWall("\(top)\n\(bottom)")
85
85
  }
86
86
 
87
- func displayBitmap(base64ImageData: String, x _: Int32? = nil, y _: Int32? = nil, width _: Int32? = nil, height _: Int32? = nil) async -> Bool {
87
+ func displayBitmap(base64ImageData: String, x _: Int32? = nil, y _: Int32? = nil, width: Int32? = nil, height: Int32? = nil) async -> Bool {
88
88
  guard let imageData = Data(base64Encoded: base64ImageData),
89
89
  let image = UIImage(data: imageData)
90
90
  else {
91
91
  Bridge.log("NEX: Failed to decode base64 image payload")
92
92
  return false
93
93
  }
94
- guard let bmpData = convertUIImageToBmpData(image) else {
95
- Bridge.log("NEX: Failed to convert UIImage to bitmap bytes")
94
+ // Same glasses-native pipeline as the canvas path: scale to the target
95
+ // size when given, invert + dither, encode a real 1-bit BMP (the old
96
+ // conversion emitted raw RGBA the firmware couldn't decode).
97
+ // Legacy callers can hand us zero/negative dims — clamp to the canvas
98
+ // so the scale/encode path can't divide by zero or allocate garbage.
99
+ let rawWidth = width ?? Int32(image.cgImage?.width ?? Int(image.size.width * image.scale))
100
+ let rawHeight = height ?? Int32(image.cgImage?.height ?? Int(image.size.height * image.scale))
101
+ let pixelWidth = min(max(rawWidth, 1), 500)
102
+ let pixelHeight = min(max(rawHeight, 1), 220)
103
+ let scaled = scaledImage(image, toWidth: pixelWidth, height: pixelHeight)
104
+ guard let bmpData = convertImageToNex1BitBmp(scaled) else {
105
+ Bridge.log("NEX: Failed to convert UIImage to 1-bit BMP")
96
106
  return false
97
107
  }
98
- let pixelWidth = image.cgImage?.width ?? Int(image.size.width * image.scale)
99
- let pixelHeight = image.cgImage?.height ?? Int(image.size.height * image.scale)
100
- displayBitmapData(bmpData, width: pixelWidth, height: pixelHeight)
108
+ displayBitmapData(bmpData, width: Int(pixelWidth), height: Int(pixelHeight))
101
109
  return true
102
110
  }
103
111
 
112
+ // MARK: - Canvas scene verbs (display.render() pipeline)
113
+
114
+ // Retained-mode canvas registry: elementId → firmware component. Mirrors
115
+ // MentraNex.kt. Text ids come from the firmware pool 1–6, bitmaps from
116
+ // 10–13 (mos_display_canvas_view.c); insertion order drives real oldest-out
117
+ // eviction when a pool is exhausted.
118
+ private struct CanvasElement {
119
+ let firmwareId: UInt32
120
+ let isBitmap: Bool
121
+ var rect: String
122
+ }
123
+
124
+ private var canvasElements: [(key: String, value: CanvasElement)] = []
125
+ private var currentLayoutId: String?
126
+ private let canvasTextIdPool: [UInt32] = [1, 2, 3, 4, 5, 6]
127
+ private let canvasBitmapIdPool: [UInt32] = [10, 11, 12, 13]
128
+
129
+ private func canvasElementIndex(_ key: String) -> Int? {
130
+ canvasElements.firstIndex(where: { $0.key == key })
131
+ }
132
+
133
+ private func sendCanvasCommand(_ configure: (inout Mentraos_Ble_PhoneToGlasses) -> Void) {
134
+ var msg = Mentraos_Ble_PhoneToGlasses()
135
+ configure(&msg)
136
+ guard let data = try? msg.serializedData() else { return }
137
+ queueDataWithOptimalChunking(data, packetType: PACKET_TYPE_PROTOBUF, waitTimeMs: 10)
138
+ }
139
+
140
+ /**
141
+ Handle a scene/layout change. DeviceManager sweeps the previous app's
142
+ elements before a cross-app frame arrives, so the registry is usually empty
143
+ here and switching costs nothing. Stale components are deleted
144
+ individually, NOT via CanvasClear — clear also exits the canvas VIEW (the
145
+ first create re-activates it), which reads as a full-screen flash.
146
+ */
147
+ private func ensureLayout(_ layoutId: String?) {
148
+ guard let layoutId, layoutId != currentLayoutId else { return }
149
+ if !canvasElements.isEmpty {
150
+ for (_, el) in canvasElements {
151
+ sendCanvasCommand { $0.canvasDeleteComponent = .with { $0.id = el.firmwareId } }
152
+ }
153
+ canvasElements.removeAll()
154
+ }
155
+ currentLayoutId = layoutId
156
+ }
157
+
158
+ /// Replay frames repaint from scratch: forget the registry so every element
159
+ /// takes the CREATE path (create-on-existing-id is replace in firmware;
160
+ /// updates to dead component ids are dropped silently).
161
+ func onSceneReplay(_ appId: String) async {
162
+ canvasElements.removeAll()
163
+ currentLayoutId = appId
164
+ }
165
+
166
+ /// Free firmware id from the type's pool, evicting the oldest same-type
167
+ /// element (delete on glasses + registry) when the pool is exhausted.
168
+ private func allocFirmwareId(isBitmap: Bool) -> UInt32? {
169
+ let pool = isBitmap ? canvasBitmapIdPool : canvasTextIdPool
170
+ let used = Set(canvasElements.filter { $0.value.isBitmap == isBitmap }.map(\.value.firmwareId))
171
+ if let free = pool.first(where: { !used.contains($0) }) {
172
+ return free
173
+ }
174
+ guard let oldestIdx = canvasElements.firstIndex(where: { $0.value.isBitmap == isBitmap }) else {
175
+ return nil
176
+ }
177
+ let oldest = canvasElements.remove(at: oldestIdx)
178
+ Bridge.log("NEX: pool full — evicting oldest \(isBitmap ? "bitmap" : "text") element '\(oldest.key)' (fw id \(oldest.value.firmwareId))")
179
+ sendCanvasCommand { $0.canvasDeleteComponent = .with { $0.id = oldest.value.firmwareId } }
180
+ return oldest.value.firmwareId
181
+ }
182
+
183
+ func drawLayoutText(
184
+ _ text: String, x: Int32, y: Int32, width: Int32, height: Int32,
185
+ borderWidth: Int32, borderRadius: Int32, elementId: String, layoutId: String?
186
+ ) async {
187
+ ensureLayout(layoutId)
188
+ let rect = "\(x),\(y),\(width)x\(height),\(borderWidth),\(borderRadius)"
189
+ if let i = canvasElementIndex(elementId), !canvasElements[i].value.isBitmap {
190
+ let fid = canvasElements[i].value.firmwareId
191
+ if canvasElements[i].value.rect != rect {
192
+ // Geometry moved/restyled — recreate the box at the SAME
193
+ // firmware id (create-on-existing-id = replace), then set text.
194
+ sendCanvasCommand {
195
+ $0.canvasCreateComponent = .with {
196
+ $0.id = fid
197
+ $0.type = .canvasTextbox
198
+ $0.x = UInt32(max(0, x)); $0.y = UInt32(max(0, y))
199
+ $0.width = UInt32(max(0, width)); $0.height = UInt32(max(0, height))
200
+ $0.borderWidth = UInt32(max(0, borderWidth))
201
+ $0.borderRadius = UInt32(max(0, borderRadius))
202
+ }
203
+ }
204
+ canvasElements[i].value.rect = rect
205
+ }
206
+ sendCanvasCommand { $0.canvasUpdateText = .with { $0.id = fid; $0.text = self.sanitizeDisplayText(text) } }
207
+ return
208
+ }
209
+ guard let fid = allocFirmwareId(isBitmap: false) else {
210
+ Bridge.log("NEX: text pool exhausted — dropping element '\(elementId)'")
211
+ return
212
+ }
213
+ canvasElements.append((key: elementId, value: CanvasElement(firmwareId: fid, isBitmap: false, rect: rect)))
214
+ sendCanvasCommand {
215
+ $0.canvasCreateComponent = .with {
216
+ $0.id = fid
217
+ $0.type = .canvasTextbox
218
+ $0.x = UInt32(max(0, x)); $0.y = UInt32(max(0, y))
219
+ $0.width = UInt32(max(0, width)); $0.height = UInt32(max(0, height))
220
+ $0.borderWidth = UInt32(max(0, borderWidth))
221
+ $0.borderRadius = UInt32(max(0, borderRadius))
222
+ }
223
+ }
224
+ sendCanvasCommand { $0.canvasUpdateText = .with { $0.id = fid; $0.text = self.sanitizeDisplayText(text) } }
225
+ }
226
+
227
+ func drawLayoutBitmap(
228
+ base64ImageData: String, x: Int32, y: Int32, width: Int32, height: Int32,
229
+ elementId: String, layoutId: String?
230
+ ) async -> Bool {
231
+ ensureLayout(layoutId)
232
+ guard let imageData = Data(base64Encoded: base64ImageData),
233
+ let image = UIImage(data: imageData)
234
+ else {
235
+ Bridge.log("NEX: drawLayoutBitmap failed to decode base64 image")
236
+ return false
237
+ }
238
+ // Scale phone-side to the component box (never on glasses), then run
239
+ // the glasses-native pipeline: invert → Floyd–Steinberg dither → real
240
+ // 1-bit BMP encode (Android parity; the old convertUIImageToBmpData
241
+ // emitted raw RGBA, which the firmware BMP decoder can't read).
242
+ let scaled = scaledImage(image, toWidth: width, height: height)
243
+ guard let bmpData = convertImageToNex1BitBmp(scaled) else {
244
+ Bridge.log("NEX: drawLayoutBitmap failed to convert image")
245
+ return false
246
+ }
247
+
248
+ let rect = "\(x),\(y),\(width)x\(height)"
249
+ let fid: UInt32
250
+ if let i = canvasElementIndex(elementId), canvasElements[i].value.isBitmap {
251
+ fid = canvasElements[i].value.firmwareId
252
+ if canvasElements[i].value.rect != rect {
253
+ sendCanvasCommand {
254
+ $0.canvasCreateComponent = .with {
255
+ $0.id = fid
256
+ $0.type = .canvasBitmap
257
+ $0.x = UInt32(max(0, x)); $0.y = UInt32(max(0, y))
258
+ $0.width = UInt32(max(0, width)); $0.height = UInt32(max(0, height))
259
+ }
260
+ }
261
+ canvasElements[i].value.rect = rect
262
+ }
263
+ } else {
264
+ guard let allocated = allocFirmwareId(isBitmap: true) else {
265
+ Bridge.log("NEX: bitmap pool exhausted — dropping element '\(elementId)'")
266
+ return false
267
+ }
268
+ fid = allocated
269
+ canvasElements.append((key: elementId, value: CanvasElement(firmwareId: fid, isBitmap: true, rect: rect)))
270
+ sendCanvasCommand {
271
+ $0.canvasCreateComponent = .with {
272
+ $0.id = fid
273
+ $0.type = .canvasBitmap
274
+ $0.x = UInt32(max(0, x)); $0.y = UInt32(max(0, y))
275
+ $0.width = UInt32(max(0, width)); $0.height = UInt32(max(0, height))
276
+ }
277
+ }
278
+ }
279
+
280
+ // Stream pixels into the component: CanvasUpdateImage header, then the
281
+ // same 0xB0 chunk transport the legacy DisplayImage path uses.
282
+ let streamId = String(format: "%04X", Int.random(in: 0 ... 0xFFFF))
283
+ let totalChunks = Int(ceil(Double(bmpData.count) / Double(bmpChunkSize)))
284
+ sendCanvasCommand {
285
+ $0.canvasUpdateImage = .with {
286
+ $0.id = fid
287
+ $0.streamID = streamId
288
+ $0.totalChunks = UInt32(totalChunks)
289
+ }
290
+ }
291
+ sendImageChunks(streamId: streamId, imageData: bmpData)
292
+ return true
293
+ }
294
+
295
+ func removeLayoutElement(_ elementId: String, layoutId _: String?) async {
296
+ guard let i = canvasElementIndex(elementId) else { return }
297
+ let el = canvasElements.remove(at: i)
298
+ sendCanvasCommand { $0.canvasDeleteComponent = .with { $0.id = el.value.firmwareId } }
299
+ }
300
+
301
+ private func scaledImage(_ image: UIImage, toWidth width: Int32, height: Int32) -> UIImage {
302
+ let size = CGSize(width: CGFloat(max(1, width)), height: CGFloat(max(1, height)))
303
+ if image.size == size { return image }
304
+ UIGraphicsBeginImageContextWithOptions(size, false, 1)
305
+ image.draw(in: CGRect(origin: .zero, size: size))
306
+ let out = UIGraphicsGetImageFromCurrentImageContext() ?? image
307
+ UIGraphicsEndImageContext()
308
+ return out
309
+ }
310
+
104
311
  func showDashboard() {
105
312
  exit()
106
313
  }
@@ -247,6 +454,11 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
247
454
  private var servicesReady = false
248
455
  private var serviceReadyWaiters: [CheckedContinuation<Void, Never>] = []
249
456
  private var pendingWriteContinuation: CheckedContinuation<Void, Never>?
457
+ // Separate from pendingWriteContinuation (which is flow-control for
458
+ // .withoutResponse): this one is resumed by didWriteValueFor, i.e. the ATT
459
+ // ack for a .withResponse write. Kept distinct so a peripheralIsReady
460
+ // flow-control callback can't spuriously resume an in-flight acked write.
461
+ private var pendingAckContinuation: CheckedContinuation<Void, Never>?
250
462
 
251
463
  // MARK: - Published Properties (G1-compatible)
252
464
 
@@ -360,11 +572,16 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
360
572
  let chunks: [[UInt8]]
361
573
  let waitTimeMs: Int
362
574
  let chunkDelayMs: Int
575
+ // true → fragments written .withResponse (firmware ATT-acks each, worker
576
+ // waits for the ack before the next). false → .withoutResponse (captions):
577
+ // fire-and-forget for throughput, newest caption supersedes the last.
578
+ let ack: Bool
363
579
 
364
- init(chunks: [[UInt8]], waitTimeMs: Int = 0, chunkDelayMs: Int = 8) {
580
+ init(chunks: [[UInt8]], waitTimeMs: Int = 0, chunkDelayMs: Int = 8, ack: Bool = true) {
365
581
  self.chunks = chunks
366
582
  self.waitTimeMs = waitTimeMs
367
583
  self.chunkDelayMs = chunkDelayMs
584
+ self.ack = ack
368
585
  }
369
586
  }
370
587
 
@@ -456,9 +673,9 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
456
673
  }
457
674
  }
458
675
 
459
- private func queueChunks(_ chunks: [[UInt8]], waitTimeMs: Int = 0, chunkDelayMs: Int = 10) {
676
+ private func queueChunks(_ chunks: [[UInt8]], waitTimeMs: Int = 0, chunkDelayMs: Int = 10, ack: Bool = true) {
460
677
  let cmd = BufferedCommand(
461
- chunks: chunks, waitTimeMs: waitTimeMs, chunkDelayMs: chunkDelayMs
678
+ chunks: chunks, waitTimeMs: waitTimeMs, chunkDelayMs: chunkDelayMs, ack: ack
462
679
  )
463
680
  Task { [weak self] in
464
681
  await self?.commandQueue.enqueue(cmd)
@@ -500,6 +717,20 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
500
717
  pendingWriteContinuation = nil
501
718
  }
502
719
 
720
+ /// Suspends until the ATT ack for a .withResponse write arrives (resumed by
721
+ /// didWriteValueFor, or by the disconnect handlers so an in-flight acked
722
+ /// write can't wedge the queue across a drop).
723
+ private func waitUntilAcked() async {
724
+ await withCheckedContinuation { continuation in
725
+ pendingAckContinuation = continuation
726
+ }
727
+ }
728
+
729
+ private func resumePendingAck() {
730
+ pendingAckContinuation?.resume()
731
+ pendingAckContinuation = nil
732
+ }
733
+
503
734
  private func emitBleCommandSent(_ packetData: Data) {
504
735
  guard packetData.first == PACKET_TYPE_PROTOBUF else { return }
505
736
  guard packetData.count > 1 else { return }
@@ -530,7 +761,7 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
530
761
  /// reassemble messages larger than one MTU. Single-fragment messages set totalChunks = 1
531
762
  /// and are decoded directly by the firmware's fast path.
532
763
  private func queueDataWithOptimalChunking(
533
- _ data: Data, packetType: UInt8 = 0x02, waitTimeMs: Int = 0, emitTelemetry: Bool = true
764
+ _ data: Data, packetType: UInt8 = 0x02, waitTimeMs: Int = 0, emitTelemetry: Bool = true, ack: Bool = true
534
765
  ) {
535
766
  // Telemetry: report the full logical command (type byte + protobuf) before fragmenting.
536
767
  // Callers on high-rate paths (caption text walls, up to 10/sec) pass
@@ -576,7 +807,7 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
576
807
  // Bridge.log(
577
808
  // "NEX: 📦 Fragmented protobuf into \(chunks.count) chunk(s) (seq=\(seq), max payload \(effectiveChunkSize) bytes)"
578
809
  // )
579
- queueChunks(chunks, waitTimeMs: waitTimeMs)
810
+ queueChunks(chunks, waitTimeMs: waitTimeMs, ack: ack)
580
811
  }
581
812
 
582
813
  private func processCommand(_ command: BufferedCommand) async {
@@ -599,18 +830,22 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
599
830
  // "NEX: 📦 Sending chunk \(index) of \(command.chunks.count) to \(peripheral.name ?? "Unknown")"
600
831
  // )
601
832
  // Bridge.log("NEX: 📦 Chunk data: \(data.toHexString())")
602
- // // Write WITHOUT response: a write-with-response is one outstanding request at a
603
- // // time gated on a remote round trip, which puts the (screen-off-throttled) app
604
- // // thread in the path of every fragment and makes captions crawl in the background.
605
- // // .withoutResponse lets CoreBluetooth batch fragments into connection events.
606
- // // CoreBluetooth does NOT call didWriteValueFor for .withoutResponse, so we use its
607
- // // flow-control flag instead of an ack — gating prevents dropped fragments (which
608
- // // would make the firmware discard the whole reassembled caption). See
609
- // // docs/ble-disconnects-during-captions.md in the firmware repo.
610
- // if !peripheral.canSendWriteWithoutResponse {
611
- // await waitUntilReadyToWrite()
612
- // }
613
- peripheral.writeValue(data, for: writeCharacteristic, type: .withoutResponse)
833
+ if command.ack {
834
+ // Acked path (everything except captions): write WITH response so the
835
+ // firmware ATT-acks each fragment, then wait for that ack
836
+ // (didWriteValueFor) before sending the next reliable delivery, one
837
+ // outstanding write at a time.
838
+ peripheral.writeValue(data, for: writeCharacteristic, type: .withResponse)
839
+ await waitUntilAcked()
840
+ } else {
841
+ // Captions: write WITHOUT response. A write-with-response is one
842
+ // outstanding request gated on a remote round trip, which puts the
843
+ // (screen-off-throttled) app thread in the path of every fragment and
844
+ // makes captions crawl in the background. .withoutResponse lets
845
+ // CoreBluetooth batch fragments into connection events; the newest
846
+ // caption supersedes the last, so a dropped fragment self-heals.
847
+ peripheral.writeValue(data, for: writeCharacteristic, type: .withoutResponse)
848
+ }
614
849
 
615
850
  // // Delay between chunks except maybe after the last chunk if waitTime will handle it
616
851
  // if index < command.chunks.count - 1 {
@@ -991,10 +1226,23 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
991
1226
  }
992
1227
  }
993
1228
 
1229
+ // Characters the Nex font can render: letters, digits, whitespace, and a small
1230
+ // punctuation set. Everything else (CJK, emoji, smart quotes, …) is stripped when
1231
+ // Chinese captions are off. Matches UNSUPPORTED_GLYPH_REGEX in NexSGCUtils.kt.
1232
+ private static let unsupportedGlyphPattern = #"[^A-Za-z0-9 \r\n\.,!\?;:\-\[\]\(\)\{\}'"\+=/]"#
1233
+
1234
+ /// Sanitize text bound for the glasses. When Chinese captions are disabled (the
1235
+ /// default) the Nex font can't render CJK/emoji/etc., so em-dashes are normalised
1236
+ /// to hyphens and unsupported glyphs are dropped; when enabled, text passes through
1237
+ /// untouched. Every text path to the display funnels through here so captions and
1238
+ /// layout text filter identically. Mirrors sanitizeDisplayText in NexSGCUtils.kt.
994
1239
  private func sanitizeDisplayText(_ text: String) -> String {
1240
+ let chineseCaptionsEnabled = DeviceStore.shared.get("bluetooth", "nex_chinese_captions") as? Bool ?? false
1241
+ if chineseCaptionsEnabled { return text }
995
1242
  let normalized = text.replacingOccurrences(of: "—", with: "-")
996
- let pattern = #"[^A-Za-z0-9 \r\n\.,!\?;:\-\[\]\(\)\{\}'"\+=/]"#
997
- return normalized.replacingOccurrences(of: pattern, with: "", options: .regularExpression)
1243
+ return normalized.replacingOccurrences(
1244
+ of: MentraNexSGC.unsupportedGlyphPattern, with: "", options: .regularExpression
1245
+ )
998
1246
  }
999
1247
 
1000
1248
  func sendTextWall(_ text: String) async {
@@ -1003,10 +1251,9 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
1003
1251
  return
1004
1252
  }
1005
1253
 
1006
- // When Chinese captions are disabled (default), strip characters the Nex font
1007
- // can't render (CJK etc.). When enabled, pass the text through unmodified.
1008
- let chineseCaptionsEnabled = DeviceStore.shared.get("bluetooth", "nex_chinese_captions") as? Bool ?? false
1009
- let sanitizedText = chineseCaptionsEnabled ? text : sanitizeDisplayText(text)
1254
+ // sanitizeDisplayText applies the Chinese-captions gate internally: off (default)
1255
+ // strips glyphs the Nex font can't render, on passes the text through unmodified.
1256
+ let sanitizedText = sanitizeDisplayText(text)
1010
1257
  // No per-call log: this runs for every interim transcript (several/sec
1011
1258
  // during continuous speech) and each Bridge.log costs the JS thread.
1012
1259
  // Bridge.log("NEX: Displaying text wall: '\(sanitizedText)'")
@@ -1067,7 +1314,9 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
1067
1314
  }
1068
1315
  textWallLock.unlock()
1069
1316
  guard let toSend else { return }
1070
- queueDataWithOptimalChunking(toSend, packetType: PACKET_TYPE_PROTOBUF, emitTelemetry: false)
1317
+ // Captions go out unacked (.withoutResponse) for throughput; every other
1318
+ // command uses acked writes. Mirrors MentraNex.kt.
1319
+ queueDataWithOptimalChunking(toSend, packetType: PACKET_TYPE_PROTOBUF, emitTelemetry: false, ack: false)
1071
1320
  }
1072
1321
 
1073
1322
  @objc func displayTextLine(_ text: String) {
@@ -1246,6 +1495,112 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
1246
1495
  return pixelData
1247
1496
  }
1248
1497
 
1498
+ // MARK: - Glasses-native 1-bit BMP pipeline (parity with Android's decodeBitmapForNex)
1499
+
1500
+ /**
1501
+ Decode → invert → Floyd–Steinberg dither → 1-bit BMP encode, mirroring
1502
+ Android's `decodeBitmapForNex` + `BitmapJavaUtils.convertBitmapTo1BitBmpBytes`.
1503
+
1504
+ The panel is 1-bpp and renders white-on-black; the firmware BMP decoder
1505
+ normalises palette polarity, so the only way to flip the on-glass result is
1506
+ to invert the actual pixel content. Dithering preserves gradients as dot
1507
+ patterns instead of a hard 50% threshold. Output byte layout matches the
1508
+ Android encoder exactly: 14-byte file header + 40-byte BITMAPINFOHEADER +
1509
+ 8-byte palette (index 0 = white, 1 = black), rows padded to 4 bytes,
1510
+ bottom-to-top, bit set when the (post-invert, post-dither) pixel is dark.
1511
+ */
1512
+ private func convertImageToNex1BitBmp(_ image: UIImage) -> Data? {
1513
+ guard let cgImage = image.cgImage else { return nil }
1514
+ let width = cgImage.width
1515
+ let height = cgImage.height
1516
+ guard width > 0, height > 0 else { return nil }
1517
+
1518
+ // RGBA readback.
1519
+ var rgba = [UInt8](repeating: 0, count: width * height * 4)
1520
+ guard
1521
+ let context = CGContext(
1522
+ data: &rgba,
1523
+ width: width,
1524
+ height: height,
1525
+ bitsPerComponent: 8,
1526
+ bytesPerRow: width * 4,
1527
+ space: CGColorSpaceCreateDeviceRGB(),
1528
+ bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
1529
+ )
1530
+ else { return nil }
1531
+ context.draw(cgImage, in: CGRect(x: 0, y: 0, width: width, height: height))
1532
+
1533
+ // Luminance (Rec. 601), INVERTED — same order as Android (invert, then dither).
1534
+ var lum = [Float](repeating: 0, count: width * height)
1535
+ for i in 0 ..< (width * height) {
1536
+ let r = Float(rgba[i * 4])
1537
+ let g = Float(rgba[i * 4 + 1])
1538
+ let b = Float(rgba[i * 4 + 2])
1539
+ lum[i] = 255.0 - (0.299 * r + 0.587 * g + 0.114 * b)
1540
+ }
1541
+
1542
+ // Floyd–Steinberg error diffusion (7/3/5/1 over 16, right/below neighbours).
1543
+ var dark = [Bool](repeating: false, count: width * height)
1544
+ for y in 0 ..< height {
1545
+ for x in 0 ..< width {
1546
+ let idx = y * width + x
1547
+ let old = lum[idx]
1548
+ let newVal: Float = old < 128 ? 0 : 255
1549
+ let err = old - newVal
1550
+ dark[idx] = newVal < 128
1551
+ if x + 1 < width { lum[idx + 1] += err * 7 / 16 }
1552
+ if y + 1 < height {
1553
+ if x >= 1 { lum[idx + width - 1] += err * 3 / 16 }
1554
+ lum[idx + width] += err * 5 / 16
1555
+ if x + 1 < width { lum[idx + width + 1] += err * 1 / 16 }
1556
+ }
1557
+ }
1558
+ }
1559
+
1560
+ // 1-bpp BMP encode (Android layout, invert=false palette).
1561
+ let rowSizeBytes = ((width + 31) / 32) * 4
1562
+ let imageSize = rowSizeBytes * height
1563
+ let dataOffset = 62
1564
+ var bmp = Data(capacity: dataOffset + imageSize)
1565
+
1566
+ func putU16(_ v: UInt16) {
1567
+ bmp.append(UInt8(v & 0xFF)); bmp.append(UInt8(v >> 8))
1568
+ }
1569
+ func putU32(_ v: UInt32) {
1570
+ bmp.append(UInt8(v & 0xFF)); bmp.append(UInt8((v >> 8) & 0xFF))
1571
+ bmp.append(UInt8((v >> 16) & 0xFF)); bmp.append(UInt8((v >> 24) & 0xFF))
1572
+ }
1573
+
1574
+ bmp.append(UInt8(ascii: "B")); bmp.append(UInt8(ascii: "M"))
1575
+ putU32(UInt32(dataOffset + imageSize)) // file size
1576
+ putU16(0); putU16(0) // reserved
1577
+ putU32(UInt32(dataOffset)) // pixel data offset
1578
+ putU32(40) // DIB header size
1579
+ putU32(UInt32(width))
1580
+ putU32(UInt32(height)) // positive => bottom-to-top
1581
+ putU16(1) // planes
1582
+ putU16(1) // bits per pixel
1583
+ putU32(0) // BI_RGB
1584
+ putU32(UInt32(imageSize))
1585
+ putU32(2835); putU32(2835) // 72 DPI
1586
+ putU32(2) // palette colors
1587
+ putU32(0) // important colors
1588
+ // Palette: index 0 = white, index 1 = black (Android invert=false).
1589
+ bmp.append(contentsOf: [0xFF, 0xFF, 0xFF, 0x00])
1590
+ bmp.append(contentsOf: [0x00, 0x00, 0x00, 0x00])
1591
+
1592
+ var row = [UInt8](repeating: 0, count: rowSizeBytes)
1593
+ for y in 0 ..< height {
1594
+ let py = height - 1 - y // BMP rows are bottom-to-top
1595
+ for i in 0 ..< rowSizeBytes { row[i] = 0 }
1596
+ for x in 0 ..< width where dark[py * width + x] {
1597
+ row[x / 8] |= UInt8(0x80 >> (x % 8))
1598
+ }
1599
+ bmp.append(contentsOf: row)
1600
+ }
1601
+ return bmp
1602
+ }
1603
+
1249
1604
  // MARK: - Display Control Commands
1250
1605
 
1251
1606
  @objc func clearDisplay() {
@@ -1256,6 +1611,14 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
1256
1611
 
1257
1612
  Bridge.log("NEX: Clearing display")
1258
1613
 
1614
+ // Tear down any canvas components and forget the registry — clear_view
1615
+ // wipes the whole screen, canvas included.
1616
+ if !canvasElements.isEmpty || currentLayoutId != nil {
1617
+ canvasElements.removeAll()
1618
+ currentLayoutId = nil
1619
+ sendCanvasCommand { $0.canvasClear = Mentraos_Ble_CanvasClear() }
1620
+ }
1621
+
1259
1622
  // Drop any pending/resendable text wall so a stale caption can't
1260
1623
  // repaint the display after this clear.
1261
1624
  textWallLock.lock()
@@ -1625,6 +1988,16 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
1625
1988
  emitBleCommandReceived(fullPacket, payloadDescription: String(describing: glassesToPhone.payload))
1626
1989
 
1627
1990
  switch glassesToPhone.payload {
1991
+ case let .canvasResult(canvasResult):
1992
+ // Ack for CanvasCreateComponent / CanvasClear (updates are
1993
+ // unacked). Non-OK (INVALID / OVERSIZE / OOM) invalidates the
1994
+ // registry entry so the next frame recreates the component —
1995
+ // and a silent blank screen becomes a diagnosable log line.
1996
+ if canvasResult.code != .ok {
1997
+ Bridge.log("NEX: CANVAS_RESULT id=\(canvasResult.id) code=\(canvasResult.code) — dropping registry entry for recreate")
1998
+ canvasElements.removeAll { $0.value.firmwareId == canvasResult.id }
1999
+ }
2000
+
1628
2001
  case let .batteryStatus(batteryStatus):
1629
2002
  handleBatteryStatusProtobuf(batteryStatus)
1630
2003
 
@@ -2142,6 +2515,7 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
2142
2515
  releaseServiceWaiters()
2143
2516
  Task { await commandQueue.clear() }
2144
2517
  resumePendingWrite()
2518
+ resumePendingAck()
2145
2519
  stopReconnectionTimer()
2146
2520
  stopTextWallDrain()
2147
2521
  }
@@ -2192,6 +2566,7 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
2192
2566
  releaseServiceWaiters()
2193
2567
  Task { await commandQueue.clear() }
2194
2568
  resumePendingWrite()
2569
+ resumePendingAck()
2195
2570
 
2196
2571
  Bridge.log("NEX: ✅ MentraNexSGC destroyed successfully")
2197
2572
  // Reset initialization flags
@@ -2459,6 +2834,7 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
2459
2834
  releaseServiceWaiters()
2460
2835
  Task { await commandQueue.clear() }
2461
2836
  resumePendingWrite()
2837
+ resumePendingAck()
2462
2838
  self.peripheral = nil // Reset peripheral on failure to allow reconnection
2463
2839
  // Optionally, start reconnection attempts here
2464
2840
  if !isDisconnecting, !isKilled {
@@ -2478,6 +2854,13 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
2478
2854
  Bridge.log("NEX-CONN: ⚠️ Disconnect error: \(error.localizedDescription)")
2479
2855
  }
2480
2856
 
2857
+ // The glasses lose their canvas on disconnect — forget the component
2858
+ // registry so post-reconnect frames take the CREATE path (firmware
2859
+ // silently drops updates to dead component ids). The host replays the
2860
+ // current scene after reconnect.
2861
+ canvasElements.removeAll()
2862
+ currentLayoutId = nil
2863
+
2481
2864
  // Reset connection state
2482
2865
  // Save microphone state before disconnection (like Java implementation)
2483
2866
  saveMicrophoneStateBeforeDisconnection()
@@ -2485,6 +2868,7 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
2485
2868
  releaseServiceWaiters()
2486
2869
  Task { await commandQueue.clear() }
2487
2870
  resumePendingWrite()
2871
+ resumePendingAck()
2488
2872
 
2489
2873
  // Reset protobuf version posted flag for next connection (like Java implementation)
2490
2874
  protobufVersionPosted = false
@@ -2797,10 +3181,10 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
2797
3181
  Bridge.log(
2798
3182
  "NEX-CONN: ❌ Error writing value to \(characteristic.uuid): \(error.localizedDescription)"
2799
3183
  )
2800
- resumePendingWrite()
3184
+ resumePendingAck()
2801
3185
  return
2802
3186
  }
2803
- resumePendingWrite()
3187
+ resumePendingAck()
2804
3188
  }
2805
3189
 
2806
3190
  func peripheralIsReady(toSendWriteWithoutResponse _: CBPeripheral) {