@mentra/bluetooth-sdk 0.1.18 → 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.
- package/README.md +1 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +119 -16
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +0 -6
- package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +22 -21
- package/android/src/main/java/com/mentra/bluetoothsdk/OtaManifest.kt +1 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +488 -15
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +457 -31
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLiveL2capChannel.kt +274 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +361 -67
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraosBle.java +7501 -3704
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +134 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/status/DeviceStatus.kt +0 -6
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/BleJsonCompact.java +493 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/BlePhotoUploadService.java +4 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/BleWireProtocol.java +108 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/IncidentLogBleUploadService.java +4 -3
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/K900LengthCodec.java +115 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/K900ProtocolUtils.java +103 -68
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/MessageChunkReassembler.java +106 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/MessageChunker.java +93 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/NexSGCUtils.kt +111 -11
- package/android/src/test/java/com/mentra/bluetoothsdk/camera/PhotoRequestTest.kt +7 -9
- package/android/src/test/java/com/mentra/bluetoothsdk/utils/AvifExifStripperTest.java +17 -1
- package/android/src/test/java/com/mentra/bluetoothsdk/utils/BinaryMessageChunkerTest.java +84 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/utils/BleJsonCompactTest.java +158 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/utils/K900ProtocolUtilsEndiannessTest.java +172 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/utils/K900ProtocolUtilsTest.java +24 -0
- package/build/BluetoothSdk.types.d.ts +12 -1
- package/build/BluetoothSdk.types.d.ts.map +1 -1
- package/build/BluetoothSdk.types.js.map +1 -1
- package/build/types/index.d.ts +3 -0
- package/build/types/index.d.ts.map +1 -0
- package/build/types/index.js +2 -0
- package/build/types/index.js.map +1 -0
- package/ios/Source/BluetoothSdkDefaults.swift +1 -1
- package/ios/Source/DeviceManager.swift +134 -29
- package/ios/Source/DeviceStore.swift +0 -5
- package/ios/Source/MentraBluetoothSDK.swift +26 -23
- package/ios/Source/OtaManifest.swift +1 -1
- package/ios/Source/sgcs/G2.swift +518 -11
- package/ios/Source/sgcs/MentraLive.swift +308 -30
- package/ios/Source/sgcs/MentraNex.swift +416 -32
- package/ios/Source/sgcs/SGCManager.swift +155 -0
- package/ios/Source/sgcs/mentraos_ble.pb.swift +726 -122
- package/ios/Source/status/DeviceStatus.swift +0 -8
- package/ios/Source/utils/BleJsonCompact.swift +395 -0
- package/ios/Source/utils/BleWireProtocol.swift +92 -0
- package/ios/Source/utils/MessageChunkReassembler.swift +82 -0
- package/ios/Source/utils/MessageChunker.swift +73 -0
- package/package.json +14 -7
- package/src/BluetoothSdk.types.ts +13 -1
- 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
|
|
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
|
-
|
|
95
|
-
|
|
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
|
-
|
|
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
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
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
|
-
|
|
997
|
-
|
|
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
|
-
//
|
|
1007
|
-
// can't render
|
|
1008
|
-
let
|
|
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
|
-
|
|
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
|
-
|
|
3184
|
+
resumePendingAck()
|
|
2801
3185
|
return
|
|
2802
3186
|
}
|
|
2803
|
-
|
|
3187
|
+
resumePendingAck()
|
|
2804
3188
|
}
|
|
2805
3189
|
|
|
2806
3190
|
func peripheralIsReady(toSendWriteWithoutResponse _: CBPeripheral) {
|