@mentra/bluetooth-sdk 0.1.13 → 0.1.16

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 (80) hide show
  1. package/README.md +18 -8
  2. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +48 -48
  3. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +5 -20
  4. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +360 -269
  5. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +3 -7
  6. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +152 -72
  7. package/android/src/main/java/com/mentra/bluetoothsdk/OtaManifest.kt +12 -12
  8. package/android/src/main/java/com/mentra/bluetoothsdk/camera/CameraModels.kt +25 -11
  9. package/android/src/main/java/com/mentra/bluetoothsdk/controllers/ControllerManager.kt +1 -2
  10. package/android/src/main/java/com/mentra/bluetoothsdk/controllers/R1.kt +1 -2
  11. package/android/src/main/java/com/mentra/bluetoothsdk/events/BluetoothEvents.kt +1 -22
  12. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.kt +51 -8
  13. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +1184 -800
  14. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Mach1.kt +1 -5
  15. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +423 -117
  16. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +4 -6
  17. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Nimo.kt +2532 -0
  18. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +17 -5
  19. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Simulated.kt +3 -7
  20. package/android/src/main/java/com/mentra/bluetoothsdk/status/DeviceStatus.kt +0 -6
  21. package/android/src/main/java/com/mentra/bluetoothsdk/streaming/StreamModels.kt +16 -33
  22. package/android/src/main/java/com/mentra/bluetoothsdk/types/DeviceModels.kt +5 -3
  23. package/android/src/main/java/com/mentra/bluetoothsdk/utils/Constants.kt +2 -1
  24. package/android/src/test/java/com/mentra/bluetoothsdk/camera/PhotoRequestTest.kt +60 -1
  25. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoProtocolTest.kt +333 -0
  26. package/build/BluetoothSdk.types.d.ts +50 -24
  27. package/build/BluetoothSdk.types.d.ts.map +1 -1
  28. package/build/BluetoothSdk.types.js +1 -0
  29. package/build/BluetoothSdk.types.js.map +1 -1
  30. package/build/_internal.d.ts +4 -4
  31. package/build/_internal.js +4 -4
  32. package/build/_internal.js.map +1 -1
  33. package/build/_private/BluetoothSdkModule.d.ts +14 -8
  34. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  35. package/build/_private/BluetoothSdkModule.js +4 -3
  36. package/build/_private/BluetoothSdkModule.js.map +1 -1
  37. package/build/_private/cameraRequestPayload.d.ts +4 -0
  38. package/build/_private/cameraRequestPayload.d.ts.map +1 -0
  39. package/build/_private/cameraRequestPayload.js +25 -0
  40. package/build/_private/cameraRequestPayload.js.map +1 -0
  41. package/build/_private/photoRequestPayload.d.ts.map +1 -1
  42. package/build/_private/photoRequestPayload.js +11 -3
  43. package/build/_private/photoRequestPayload.js.map +1 -1
  44. package/build/index.d.ts +1 -1
  45. package/build/index.d.ts.map +1 -1
  46. package/build/index.js +70 -61
  47. package/build/index.js.map +1 -1
  48. package/ios/BluetoothSdkModule.swift +51 -28
  49. package/ios/Source/Bridge.swift +4 -16
  50. package/ios/Source/DeviceManager.swift +76 -22
  51. package/ios/Source/DeviceStore.swift +0 -4
  52. package/ios/Source/MentraBluetoothSDK.swift +144 -78
  53. package/ios/Source/OtaManifest.swift +13 -13
  54. package/ios/Source/camera/CameraModels.swift +82 -20
  55. package/ios/Source/controllers/ControllerManager.swift +1 -2
  56. package/ios/Source/controllers/R1.swift +1 -2
  57. package/ios/Source/errors/{BluetoothError.swift → BluetoothSdkError.swift} +1 -1
  58. package/ios/Source/events/BluetoothEvents.swift +5 -27
  59. package/ios/Source/internal/BluetoothAvailability.swift +5 -5
  60. package/ios/Source/sgcs/Frame.swift +0 -2
  61. package/ios/Source/sgcs/G1.swift +52 -4
  62. package/ios/Source/sgcs/G2.swift +643 -204
  63. package/ios/Source/sgcs/Mach1.swift +1 -3
  64. package/ios/Source/sgcs/MentraLive.swift +64 -77
  65. package/ios/Source/sgcs/MentraNex.swift +1 -3
  66. package/ios/Source/sgcs/Nimo.swift +1928 -0
  67. package/ios/Source/sgcs/SGCManager.swift +16 -10
  68. package/ios/Source/sgcs/Simulated.swift +2 -6
  69. package/ios/Source/status/DeviceStatus.swift +0 -8
  70. package/ios/Source/streaming/StreamModels.swift +7 -32
  71. package/ios/Source/stt/STTTools.swift +1 -1
  72. package/ios/Source/types/DeviceModels.swift +5 -0
  73. package/ios/Source/utils/Constants.swift +2 -0
  74. package/package.json +6 -1
  75. package/src/BluetoothSdk.types.ts +54 -28
  76. package/src/_internal.ts +4 -4
  77. package/src/_private/BluetoothSdkModule.ts +20 -11
  78. package/src/_private/cameraRequestPayload.ts +29 -0
  79. package/src/_private/photoRequestPayload.ts +12 -3
  80. package/src/index.ts +77 -67
@@ -0,0 +1,1928 @@
1
+ //
2
+ // Nimo.swift
3
+ //
4
+ // SGC for Nimo glasses. Native implementation of the vendor's BLE protocol:
5
+ // a 0xBF-framed UART channel (service 7033) for commands/content, plus a
6
+ // separate H-T-L-V Opus microphone channel (char 2025).
7
+ //
8
+
9
+ import AVFoundation
10
+ import Compression
11
+ import CoreBluetooth
12
+ import Foundation
13
+ import UIKit
14
+
15
+ // MARK: - Nimo BLE Constants
16
+
17
+ enum NimoBLE {
18
+ // UART-style service: TX = phone→glasses commands, RX = glasses→phone notifications.
19
+ // Short UUIDs from the vendor SDK expanded onto the Bluetooth base UUID.
20
+ static let SERVICE_UUID = CBUUID(string: "00007033-0000-1000-8000-00805F9B34FB")
21
+ static let CHAR_TX = CBUUID(string: "00002021-0000-1000-8000-00805F9B34FB")
22
+ static let CHAR_RX = CBUUID(string: "00002022-0000-1000-8000-00805F9B34FB")
23
+ static let CHAR_MIC = CBUUID(string: "00002025-0000-1000-8000-00805F9B34FB")
24
+
25
+ static let NAME_PREFIX = "nimo"
26
+ // iOS ANCS side-channel devices advertise "<name>_ble" — never the data channel.
27
+ static let BLE_NAME_SUFFIX = "_ble"
28
+
29
+ static let CHUNK_SIZE = 501
30
+ static let INTER_FRAME_DELAY_MS = 5
31
+ }
32
+
33
+ // MARK: - Nimo Protocol Constants
34
+ // Byte values follow the glasses firmware protocol and must not be changed.
35
+
36
+ enum NimoProtocol {
37
+ static let FRAME_MAGIC: UInt8 = 0xBF
38
+
39
+ // status bits
40
+ static let STATUS_ERR: UInt8 = 0x01
41
+ static let STATUS_ACK: UInt8 = 0x02
42
+
43
+ // command categories
44
+ static let CMD_GET_PARAMETER = 0x02
45
+ static let CMD_SET_PARAMETER = 0x03
46
+ static let CMD_INSTRUCTION_REPORT = 0x06
47
+ static let CMD_CONTROL_INSTRUCTION = 0x07
48
+ static let CMD_CONTROL_NOTIFICATION = 0x09
49
+
50
+ // get parameter keys
51
+ static let GET_BATTERY = 0x06
52
+ static let GET_VERSION = 0x0A
53
+ static let GET_VERSION_DETAIL = 0x0B
54
+ static let GET_TWS_STATUS = 0x16
55
+
56
+ // set parameter keys
57
+ static let SET_TIME = 0x01
58
+ static let SET_BRIGHTNESS = 0x02
59
+ static let SET_DISTANCE = 0x03
60
+ static let SET_ANGLE = 0x04
61
+ static let SET_HEADUP_DISPLAY = 0x0D
62
+ static let SET_AUTO_BRIGHTNESS = 0x0E
63
+ static let SET_DISPLAY_OFF = 0x0F
64
+ static let SET_PHONE_TYPE = 0x14
65
+ static let SET_HEIGHT_LEVEL = 0x17
66
+
67
+ // control instruction keys
68
+ static let CTRL_ENTER_APP = 0x01
69
+ static let CTRL_QUIT_APP = 0x03
70
+ static let CTRL_UPDATE_CONTENT = 0x04
71
+
72
+ // notification keys
73
+ static let NOTIFICATION_SEND = 0x01
74
+
75
+ // report keys (cmd 0x06)
76
+ static let REPORT_INPUT = 0x01
77
+ static let REPORT_APP = 0x02
78
+ static let REPORT_TWS = 0x03
79
+ static let REPORT_BUSINESS = 0x04
80
+ static let REPORT_GATT_STATE = 0x05
81
+
82
+ // business report ids
83
+ static let BUSINESS_HEARTBEAT = 0x03
84
+ static let BUSINESS_BATTERY = 0x05
85
+
86
+ // input event codes
87
+ static let INPUT_HEAD_UP = 0x01
88
+ static let INPUT_HEAD_DOWN = 0x02
89
+ static let INPUT_CLICK_RIGHT = 0x03
90
+ static let INPUT_DOUBLE_CLICK_RIGHT = 0x04
91
+ static let INPUT_LONG_PRESS_RIGHT = 0x05
92
+ static let INPUT_TOUCH_PRESS_RIGHT = 0x06
93
+ static let INPUT_TOUCH_RELEASE_RIGHT = 0x07
94
+ static let INPUT_CLICK_LEFT = 0x13
95
+ static let INPUT_DOUBLE_CLICK_LEFT = 0x14
96
+ static let INPUT_LONG_PRESS_LEFT = 0x15
97
+ static let INPUT_TOUCH_PRESS_LEFT = 0x16
98
+ static let INPUT_TOUCH_RELEASE_LEFT = 0x17
99
+
100
+ // app state report phases
101
+ static let STATE_ENTER = 0x01
102
+ static let STATE_EXIT = 0x03
103
+
104
+ // app ids
105
+ static let APP_ID_DASHBOARD = 0x00
106
+ static let APP_ID_NAV = 0x01
107
+ static let APP_ID_ASR_NOTE = 0x04
108
+ static let APP_ID_PROMPTER = 0x06
109
+ static let APP_ID_AI_TALK = 0x07
110
+
111
+ // enterApp modes
112
+ static let APP_MODE_STANDALONE = 0x00
113
+
114
+ // widget resTypes
115
+ static let WIDGET_TEXT_NEW = 0x00
116
+ static let WIDGET_PICTURE = 0x80
117
+
118
+ // navigation widget resIds (appId 0x01): mini map 0x00, arrow 0x01, turn text 0x02,
119
+ // status bar 0x03 (raw), tip 0x04, large map 0x05.
120
+ static let NAV_RES_MINI_MAP = 0x00
121
+ static let NAV_RES_TURN_TEXT = 0x02
122
+ static let NAV_RES_LARGE_MAP = 0x05
123
+
124
+ // navigation image widget sizes (hard firmware requirements; see IMAGE_PROTOCOL).
125
+ static let NAV_MINI_MAP_SIZE = 160
126
+ static let NAV_LARGE_MAP_WIDTH = 452
127
+ static let NAV_LARGE_MAP_HEIGHT = 170
128
+
129
+ // phone types
130
+ static let PHONE_TYPE_IOS = 0x01
131
+
132
+ // image header
133
+ static let COM_IMAGE_HEADER: UInt8 = 0x16
134
+ static let FORMAT_2BPP = 0x02
135
+ static let COMPRESSION_NONE = 0x00
136
+ static let COMPRESSION_ZLIB = 0x07
137
+
138
+ static let MAX_BRIGHTNESS_LEVEL = 16
139
+ }
140
+
141
+ // MARK: - CRC16-CCITT
142
+
143
+ /// CRC-16/CCITT-FALSE: init 0xFFFF, poly 0x1021, no reflection, no final XOR.
144
+ /// Computed over the application payload (app header + data), NOT the transport header.
145
+ func nimoCrc16(_ data: Data) -> UInt16 {
146
+ var crc: UInt16 = 0xFFFF
147
+ for byte in data {
148
+ crc ^= UInt16(byte) << 8
149
+ for _ in 0 ..< 8 {
150
+ if crc & 0x8000 != 0 {
151
+ crc = (crc << 1) ^ 0x1021
152
+ } else {
153
+ crc = crc << 1
154
+ }
155
+ }
156
+ }
157
+ return crc
158
+ }
159
+
160
+ // MARK: - Frame Codec
161
+
162
+ /// Pure bytes-in/bytes-out frame codec for the Nimo 0xBF transport.
163
+ ///
164
+ /// Frame layout (little-endian):
165
+ /// - 8-byte transport header: [magic][status][len(2)][crc16(2)][index(2)]
166
+ /// - 4-byte application header (requests): [cmd][key][len(2)]
167
+ /// - responses/reports carry an extra status byte: [cmd][key][len(2)][status][data...]
168
+ enum NimoFrameCodec {
169
+ static func transportHeader(_ payload: Data, index: Int = 0, needsAck: Bool = true) -> Data {
170
+ var header = Data(count: 8)
171
+ header[0] = NimoProtocol.FRAME_MAGIC
172
+ header[1] = needsAck ? NimoProtocol.STATUS_ACK : 0
173
+ header[2] = UInt8(payload.count & 0xFF)
174
+ header[3] = UInt8((payload.count >> 8) & 0xFF)
175
+ let crc = nimoCrc16(payload)
176
+ header[4] = UInt8(crc & 0xFF)
177
+ header[5] = UInt8((crc >> 8) & 0xFF)
178
+ header[6] = UInt8(index & 0xFF)
179
+ header[7] = UInt8((index >> 8) & 0xFF)
180
+ return header
181
+ }
182
+
183
+ static func applicationHeader(cmd: Int, key: Int, payloadSize: Int) -> Data {
184
+ return Data([
185
+ UInt8(cmd & 0xFF),
186
+ UInt8(key & 0xFF),
187
+ UInt8(payloadSize & 0xFF),
188
+ UInt8((payloadSize >> 8) & 0xFF),
189
+ ])
190
+ }
191
+
192
+ /// One complete single frame: transport header + app header + payload.
193
+ static func encodeFrame(
194
+ cmd: Int, key: Int, payload: Data = Data(), index: Int = 0, needsAck: Bool = true
195
+ ) -> Data {
196
+ let transportPayload = applicationHeader(cmd: cmd, key: key, payloadSize: payload.count) + payload
197
+ return transportHeader(transportPayload, index: index, needsAck: needsAck) + transportPayload
198
+ }
199
+
200
+ /// Content update (cmd=0x07 key=0x04) blind-sliced into 501-byte chunks, each wrapped in its
201
+ /// own transport header. Index rule: last chunk = 0, others = i+1 (first = 1); single chunk = 0.
202
+ static func updateContentFrames(
203
+ appId: Int, layoutId: Int, resId: Int, resType: Int, content: Data,
204
+ chunkSize: Int = NimoBLE.CHUNK_SIZE
205
+ ) -> [Data] {
206
+ // App header payloadSize includes the 4-byte [appId][layoutId][resId][resType] prefix.
207
+ let appHeader = applicationHeader(
208
+ cmd: NimoProtocol.CMD_CONTROL_INSTRUCTION,
209
+ key: NimoProtocol.CTRL_UPDATE_CONTENT,
210
+ payloadSize: 4 + content.count
211
+ )
212
+ var full = appHeader
213
+ full.append(contentsOf: [
214
+ UInt8(appId & 0xFF), UInt8(layoutId & 0xFF), UInt8(resId & 0xFF), UInt8(resType & 0xFF),
215
+ ])
216
+ full.append(content)
217
+
218
+ let chunkCount = (full.count + chunkSize - 1) / chunkSize
219
+ var frames: [Data] = []
220
+ for i in 0 ..< chunkCount {
221
+ let start = i * chunkSize
222
+ let end = min(start + chunkSize, full.count)
223
+ let chunk = full.subdata(in: start ..< end)
224
+ let isLast = i == chunkCount - 1
225
+ let index = isLast ? 0 : i + 1
226
+ frames.append(transportHeader(chunk, index: index) + chunk)
227
+ }
228
+ return frames
229
+ }
230
+
231
+ /// 15-byte image header. `originalSize` MUST be the uncompressed pixel byte count
232
+ /// (the glasses use it to allocate the decompression buffer).
233
+ static func imageHeader(
234
+ width: Int, height: Int, formatBpp: Int, compression: Int, originalSize: Int,
235
+ compressedSize: Int
236
+ ) -> Data {
237
+ var p = Data(count: 15)
238
+ p[0] = NimoProtocol.COM_IMAGE_HEADER
239
+ p[1] = UInt8(width & 0xFF)
240
+ p[2] = UInt8((width >> 8) & 0xFF)
241
+ p[3] = UInt8(height & 0xFF)
242
+ p[4] = UInt8((height >> 8) & 0xFF)
243
+ p[5] = UInt8(formatBpp & 0xFF)
244
+ p[6] = UInt8(compression & 0xFF)
245
+ p[7] = UInt8(originalSize & 0xFF)
246
+ p[8] = UInt8((originalSize >> 8) & 0xFF)
247
+ p[9] = UInt8((originalSize >> 16) & 0xFF)
248
+ p[10] = UInt8((originalSize >> 24) & 0xFF)
249
+ p[11] = UInt8(compressedSize & 0xFF)
250
+ p[12] = UInt8((compressedSize >> 8) & 0xFF)
251
+ p[13] = UInt8((compressedSize >> 16) & 0xFF)
252
+ p[14] = UInt8((compressedSize >> 24) & 0xFF)
253
+ return p
254
+ }
255
+
256
+ /// 9-byte device time: [year(2 LE)][month][day][hour][min][sec][week][zone]
257
+ /// week: Sunday=0..Saturday=6; zone: signed, 15-minute units, clamped to ±48.
258
+ static func encodeDeviceTime(_ date: Date = Date()) -> Data {
259
+ let cal = Calendar.current
260
+ let c = cal.dateComponents(
261
+ [.year, .month, .day, .hour, .minute, .second, .weekday], from: date
262
+ )
263
+ var b = Data(count: 9)
264
+ let year = c.year ?? 2000
265
+ b[0] = UInt8(year & 0xFF)
266
+ b[1] = UInt8((year >> 8) & 0xFF)
267
+ b[2] = UInt8((c.month ?? 1) & 0xFF)
268
+ b[3] = UInt8((c.day ?? 1) & 0xFF)
269
+ b[4] = UInt8((c.hour ?? 0) & 0xFF)
270
+ b[5] = UInt8((c.minute ?? 0) & 0xFF)
271
+ b[6] = UInt8((c.second ?? 0) & 0xFF)
272
+ // Calendar weekday: Sunday=1..Saturday=7 → protocol Sunday=0..Saturday=6
273
+ b[7] = UInt8(((c.weekday ?? 1) - 1) & 0xFF)
274
+ let offsetMinutes = TimeZone.current.secondsFromGMT(for: date) / 60
275
+ let zone = max(-48, min(48, offsetMinutes / 15))
276
+ b[8] = UInt8(bitPattern: Int8(zone))
277
+ return b
278
+ }
279
+
280
+ /// A decoded frame. `cmd`/`key`/`statusCode`/`data` are nil when the payload is too short.
281
+ struct DecodedFrame {
282
+ let transportStatus: UInt8
283
+ let index: Int
284
+ let cmd: Int?
285
+ let key: Int?
286
+ let statusCode: Int?
287
+ let data: Data?
288
+ }
289
+
290
+ /// Decodes one complete frame (responses and reports both use the 5-byte
291
+ /// [cmd][key][len(2)][status] application header). Returns nil on transport
292
+ /// error, bad CRC, or truncation.
293
+ static func decode(_ frame: Data) -> DecodedFrame? {
294
+ guard frame.count >= 8, frame[0] == NimoProtocol.FRAME_MAGIC else { return nil }
295
+ let transportStatus = frame[1]
296
+ let payloadLen = Int(frame[2]) | (Int(frame[3]) << 8)
297
+ let crcValue = UInt16(frame[4]) | (UInt16(frame[5]) << 8)
298
+ let index = Int(frame[6]) | (Int(frame[7]) << 8)
299
+ guard frame.count >= 8 + payloadLen else { return nil }
300
+ guard transportStatus & NimoProtocol.STATUS_ERR == 0 else { return nil }
301
+
302
+ let payload = frame.subdata(in: 8 ..< 8 + payloadLen)
303
+ guard nimoCrc16(payload) == crcValue else { return nil }
304
+
305
+ guard payload.count >= 5 else {
306
+ return DecodedFrame(
307
+ transportStatus: transportStatus, index: index, cmd: nil, key: nil,
308
+ statusCode: nil, data: nil
309
+ )
310
+ }
311
+ let data = payload.count > 5 ? payload.subdata(in: 5 ..< payload.count) : Data()
312
+ return DecodedFrame(
313
+ transportStatus: transportStatus,
314
+ index: index,
315
+ cmd: Int(payload[0]),
316
+ key: Int(payload[1]),
317
+ statusCode: Int(payload[4]),
318
+ data: data
319
+ )
320
+ }
321
+ }
322
+
323
+ // MARK: - Receive Assembler
324
+
325
+ /// Reassembles multi-packet responses. Fragments are grouped by (cmd,key); each fragment carries
326
+ /// the full 5-byte response app header. The first fragment (index==1) keeps its header, the
327
+ /// continuation fragments (index>1) and the last fragment (index==0) contribute only their data
328
+ /// sections, concatenated in ascending index order with the last (0) treated as largest. The
329
+ /// merged message is re-framed (lengths and CRC recomputed) so `NimoFrameCodec.decode` can parse
330
+ /// it. Single packets (index==0, no cached group) pass through unchanged.
331
+ class NimoReceiveAssembler {
332
+ private class Pending {
333
+ var firstAppPayload: Data?
334
+ var dataByIndex: [Int: Data] = [:]
335
+ let startTime = Date()
336
+ }
337
+
338
+ private var pending: [Int: Pending] = [:]
339
+
340
+ func ingest(_ packet: Data) -> [Data] {
341
+ guard packet.count >= 8 else { return [] }
342
+ let payloadLen = Int(packet[2]) | (Int(packet[3]) << 8)
343
+ let index = Int(packet[6]) | (Int(packet[7]) << 8)
344
+ guard packet.count >= 8 + payloadLen else { return [] }
345
+ let appPayload = packet.subdata(in: 8 ..< 8 + payloadLen)
346
+
347
+ guard appPayload.count >= 2 else {
348
+ return index == 0 ? [packet] : []
349
+ }
350
+
351
+ let cmd = Int(appPayload[0])
352
+ let key = Int(appPayload[1])
353
+ let groupKey = (cmd << 8) | key
354
+
355
+ if index == 0 {
356
+ guard let p = pending.removeValue(forKey: groupKey) else { return [packet] }
357
+ p.dataByIndex[Int.max] = dataSection(appPayload)
358
+ return [reframe(p)]
359
+ }
360
+
361
+ if index == 1 {
362
+ // A new first fragment while an old first fragment is cached means the previous
363
+ // round lost its last packet — drop the stale group and start over.
364
+ if let existing = pending[groupKey], existing.firstAppPayload != nil {
365
+ pending.removeValue(forKey: groupKey)
366
+ }
367
+ }
368
+
369
+ let p = pending[groupKey] ?? Pending()
370
+ pending[groupKey] = p
371
+ if index == 1 {
372
+ p.firstAppPayload = appPayload
373
+ }
374
+ p.dataByIndex[index] = dataSection(appPayload)
375
+ return []
376
+ }
377
+
378
+ func cleanup(timeoutSeconds: TimeInterval = 10) {
379
+ let now = Date()
380
+ pending = pending.filter { now.timeIntervalSince($0.value.startTime) < timeoutSeconds }
381
+ }
382
+
383
+ func reset() {
384
+ pending.removeAll()
385
+ }
386
+
387
+ private func dataSection(_ appPayload: Data) -> Data {
388
+ return appPayload.count <= 5 ? Data() : appPayload.subdata(in: 5 ..< appPayload.count)
389
+ }
390
+
391
+ private func reframe(_ p: Pending) -> Data {
392
+ var merged = Data()
393
+ for k in p.dataByIndex.keys.sorted() {
394
+ merged.append(p.dataByIndex[k]!)
395
+ }
396
+ let first = p.firstAppPayload
397
+ let cmd: UInt8 = (first?.count ?? 0) >= 1 ? first![0] : 0
398
+ let key: UInt8 = (first?.count ?? 0) >= 2 ? first![1] : 0
399
+ let status: UInt8 = (first?.count ?? 0) >= 5 ? first![4] : 0
400
+
401
+ var appPayload = Data([
402
+ cmd, key, UInt8(merged.count & 0xFF), UInt8((merged.count >> 8) & 0xFF), status,
403
+ ])
404
+ appPayload.append(merged)
405
+ return NimoFrameCodec.transportHeader(appPayload, index: 0) + appPayload
406
+ }
407
+ }
408
+
409
+ // MARK: - Mic Audio Parser
410
+
411
+ /// H-T-L-V parser for the mic channel (char 2025). Packets: [0x52][type][len(2 LE)][payload].
412
+ /// For Opus packets the payload is [SN(2 LE)][frameCnt][reserved] then frameCnt frames of
413
+ /// [frameLen(1)][body] where opusLen = body[3] and the Opus bytes are body[8 .. 8+opusLen].
414
+ /// Slicing by opusLen (not frameLen-8) is mandatory — the tail holds CRC/padding bytes.
415
+ enum NimoAudioParser {
416
+ static let HEADER: UInt8 = 0x52
417
+ static let TYPE_STOP = 0x00
418
+ static let TYPE_START = 0x01
419
+ static let TYPE_OPUS_LEFT = 0x02
420
+ static let TYPE_OPUS_RIGHT = 0x03
421
+
422
+ struct Packet {
423
+ let type: Int
424
+ let sequence: Int
425
+ let opusFrames: [Data]
426
+ }
427
+
428
+ static func parse(_ data: Data) -> Packet? {
429
+ guard data.count >= 4, data[0] == HEADER else { return nil }
430
+ let type = Int(data[1])
431
+ let len = Int(data[2]) | (Int(data[3]) << 8)
432
+ guard data.count >= 4 + len else { return Packet(type: type, sequence: 0, opusFrames: []) }
433
+ guard type == TYPE_OPUS_LEFT || type == TYPE_OPUS_RIGHT else {
434
+ return Packet(type: type, sequence: 0, opusFrames: [])
435
+ }
436
+
437
+ let payload = data.subdata(in: 4 ..< 4 + len)
438
+ guard payload.count >= 4 else { return Packet(type: type, sequence: 0, opusFrames: []) }
439
+ let sn = Int(payload[0]) | (Int(payload[1]) << 8)
440
+ let frameCnt = Int(payload[2])
441
+ var offset = 4
442
+ var frames: [Data] = []
443
+ var i = 0
444
+ while i < frameCnt, offset < payload.count {
445
+ let frameLen = Int(payload[offset])
446
+ offset += 1
447
+ guard offset + frameLen <= payload.count else { break }
448
+ let body = payload.subdata(in: offset ..< offset + frameLen)
449
+ offset += frameLen
450
+ i += 1
451
+ if frameLen < 8 { continue }
452
+ let opusLen = Int(body[3])
453
+ if 8 + opusLen > frameLen { continue }
454
+ frames.append(body.subdata(in: 8 ..< 8 + opusLen))
455
+ }
456
+ return Packet(type: type, sequence: sn, opusFrames: frames)
457
+ }
458
+ }
459
+
460
+ // MARK: - Opus Decoder (AVAudioConverter)
461
+
462
+ /// Decodes the glasses' 16 kHz mono Opus frames to 16-bit PCM via the system
463
+ /// Opus decoder, so no native Opus library has to be vendored.
464
+ private class NimoOpusDecoder {
465
+ private let opusFormat: AVAudioFormat
466
+ private let pcmFormat: AVAudioFormat
467
+ private var converter: AVAudioConverter?
468
+
469
+ init?() {
470
+ var desc = AudioStreamBasicDescription(
471
+ mSampleRate: 16000,
472
+ mFormatID: kAudioFormatOpus,
473
+ mFormatFlags: 0,
474
+ mBytesPerPacket: 0,
475
+ mFramesPerPacket: 320, // 20 ms at 16 kHz
476
+ mBytesPerFrame: 0,
477
+ mChannelsPerFrame: 1,
478
+ mBitsPerChannel: 0,
479
+ mReserved: 0
480
+ )
481
+ guard let inFormat = AVAudioFormat(streamDescription: &desc),
482
+ let outFormat = AVAudioFormat(
483
+ commonFormat: .pcmFormatInt16, sampleRate: 16000, channels: 1, interleaved: true
484
+ )
485
+ else { return nil }
486
+ opusFormat = inFormat
487
+ pcmFormat = outFormat
488
+ converter = AVAudioConverter(from: inFormat, to: outFormat)
489
+ if converter == nil {
490
+ return nil
491
+ }
492
+ }
493
+
494
+ func decode(_ opusFrame: Data) -> Data? {
495
+ guard let converter, !opusFrame.isEmpty else { return nil }
496
+
497
+ let compressed = AVAudioCompressedBuffer(
498
+ format: opusFormat, packetCapacity: 1, maximumPacketSize: opusFrame.count
499
+ )
500
+ opusFrame.withUnsafeBytes { (raw: UnsafeRawBufferPointer) in
501
+ compressed.data.copyMemory(from: raw.baseAddress!, byteCount: opusFrame.count)
502
+ }
503
+ compressed.byteLength = UInt32(opusFrame.count)
504
+ compressed.packetCount = 1
505
+ compressed.packetDescriptions?.pointee = AudioStreamPacketDescription(
506
+ mStartOffset: 0, mVariableFramesInPacket: 0, mDataByteSize: UInt32(opusFrame.count)
507
+ )
508
+
509
+ // Up to 40 ms of output per frame, to be safe.
510
+ guard let pcmBuffer = AVAudioPCMBuffer(pcmFormat: pcmFormat, frameCapacity: 640) else {
511
+ return nil
512
+ }
513
+ var fed = false
514
+ var error: NSError?
515
+ let status = converter.convert(to: pcmBuffer, error: &error) { _, outStatus in
516
+ if fed {
517
+ outStatus.pointee = .noDataNow
518
+ return nil
519
+ }
520
+ fed = true
521
+ outStatus.pointee = .haveData
522
+ return compressed
523
+ }
524
+ if status == .error {
525
+ Bridge.log("NIMO: Opus decode error: \(error?.localizedDescription ?? "unknown")")
526
+ return nil
527
+ }
528
+ guard pcmBuffer.frameLength > 0, let channel = pcmBuffer.int16ChannelData else {
529
+ return nil
530
+ }
531
+ return Data(bytes: channel[0], count: Int(pcmBuffer.frameLength) * 2)
532
+ }
533
+ }
534
+
535
+ // MARK: - Reconnection Manager
536
+
537
+ actor NimoReconnectionManager {
538
+ private var task: Task<Void, Never>?
539
+ private let intervalSeconds: UInt64
540
+
541
+ init(intervalSeconds: UInt64 = 30) {
542
+ self.intervalSeconds = intervalSeconds
543
+ }
544
+
545
+ func start(_ onAttempt: @escaping () async -> Bool) {
546
+ stop()
547
+ task = Task {
548
+ var attempts = 0
549
+ while !Task.isCancelled {
550
+ try? await Task.sleep(nanoseconds: intervalSeconds * 1_000_000_000)
551
+ if Task.isCancelled { return }
552
+ attempts += 1
553
+ Bridge.log("NIMO: Reconnection attempt \(attempts)")
554
+ if await onAttempt() {
555
+ Bridge.log("NIMO: Reconnection successful, stopping")
556
+ return
557
+ }
558
+ }
559
+ }
560
+ }
561
+
562
+ func stop() {
563
+ task?.cancel()
564
+ task = nil
565
+ }
566
+ }
567
+
568
+ // MARK: - Nimo Class
569
+
570
+ class Nimo: NSObject, SGCManager {
571
+ var type: String = DeviceTypes.NIMO
572
+ let hasMic = true
573
+
574
+ private static let _bluetoothQueue = DispatchQueue(label: "BluetoothNimo", qos: .userInitiated)
575
+
576
+ private enum Const {
577
+ static let twsTimeoutSeconds: TimeInterval = 10
578
+ static let ackTimeoutSeconds: TimeInterval = 5
579
+ static let pairingTimeoutSeconds: TimeInterval = 15
580
+ static let batteryPollSeconds: TimeInterval = 30
581
+ static let textQueueTickSeconds: TimeInterval = 0.1
582
+ static let writeWatchdogSeconds: TimeInterval = 1
583
+ // Fall back to the other ear if the preferred one goes quiet for this long.
584
+ static let micSideFallbackSeconds: TimeInterval = 2
585
+ }
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
+ // BLE
595
+ private var centralManager: CBCentralManager?
596
+ private var peripheral: CBPeripheral?
597
+ private var txChar: CBCharacteristic?
598
+ private var rxChar: CBCharacteristic?
599
+ private var micChar: CBCharacteristic?
600
+ private var isDisconnecting = false
601
+
602
+ // Device search
603
+ private var DEVICE_SEARCH_ID = "NOT_SET"
604
+
605
+ private var lastDeviceName: String? {
606
+ get { UserDefaults.standard.string(forKey: "nimo_lastDeviceName") }
607
+ set { UserDefaults.standard.set(newValue, forKey: "nimo_lastDeviceName") }
608
+ }
609
+
610
+ private var lastDeviceUUID: String? {
611
+ get { UserDefaults.standard.string(forKey: "nimo_lastDeviceUUID") }
612
+ set { UserDefaults.standard.set(newValue, forKey: "nimo_lastDeviceUUID") }
613
+ }
614
+
615
+ private let reconnectionManager = NimoReconnectionManager()
616
+ private let receiveAssembler = NimoReceiveAssembler()
617
+
618
+ // Handshake
619
+ private enum HandshakeState {
620
+ case idle
621
+ case awaitingTws
622
+ case awaitingTimeAck
623
+ case ready
624
+ }
625
+
626
+ private var handshakeState = HandshakeState.idle
627
+ private var twsConnected = false
628
+ private var twsTimeoutItem: DispatchWorkItem?
629
+ private var pairingTimeoutItem: DispatchWorkItem?
630
+
631
+ // Pending acks keyed by (cmd << 8) | key
632
+ private struct PendingAck {
633
+ let onResult: (Bool) -> Void
634
+ let timeoutItem: DispatchWorkItem
635
+ }
636
+
637
+ private var pendingAcks: [Int: PendingAck] = [:]
638
+
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
644
+ }
645
+
646
+ private var writeQueue: [QueuedWrite] = []
647
+ private var writeInFlight = false
648
+ private var writeWatchdogItem: DispatchWorkItem?
649
+
650
+ // Timers
651
+ 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
+
660
+ // Battery
661
+ private var lastBatteryLevel = -1
662
+ private var lastCharging = false
663
+
664
+ // Version info
665
+ private var firmwareVersionPacked = ""
666
+ private var firmwareVersionDetail = ""
667
+
668
+ // Mic audio
669
+ private var opusDecoder: NimoOpusDecoder?
670
+ private var lastRightPacketTime = Date.distantPast
671
+ private var preferRightMic = true
672
+
673
+ // MARK: - SGCManager: Connection Management
674
+
675
+ func findCompatibleDevices() {
676
+ Bridge.log("NIMO: findCompatibleDevices()")
677
+ DEVICE_SEARCH_ID = "NOT_SET"
678
+ DeviceStore.shared.apply("glasses", "connectionState", ConnTypes.SCANNING)
679
+ startScan()
680
+ }
681
+
682
+ func connectById(_ id: String) {
683
+ Bridge.log("NIMO: connectById(\(id))")
684
+ DEVICE_SEARCH_ID = id
685
+ DeviceStore.shared.apply("glasses", "connectionState", ConnTypes.CONNECTING)
686
+ isDisconnecting = false
687
+ startPairingTimeout()
688
+ startScan()
689
+ }
690
+
691
+ func stopScan() {
692
+ centralManager?.stopScan()
693
+ }
694
+
695
+ func disconnect() {
696
+ Bridge.log("NIMO: disconnect()")
697
+ isDisconnecting = true
698
+ cancelPairingTimeout()
699
+ cancelTwsTimeout()
700
+ stopScan()
701
+ stopTimers()
702
+ Task { await reconnectionManager.stop() }
703
+ failAllPendingAcks()
704
+
705
+ if let peripheral {
706
+ centralManager?.cancelPeripheralConnection(peripheral)
707
+ }
708
+ peripheral = nil
709
+ txChar = nil
710
+ rxChar = nil
711
+ micChar = nil
712
+ resetSessionState()
713
+
714
+ DeviceStore.shared.apply("glasses", "connected", false)
715
+ DeviceStore.shared.apply("glasses", "fullyBooted", false)
716
+ DeviceStore.shared.apply("glasses", "connectionState", ConnTypes.DISCONNECTED)
717
+ }
718
+
719
+ func forget() {
720
+ Bridge.log("NIMO: forget()")
721
+ disconnect()
722
+ lastDeviceName = nil
723
+ lastDeviceUUID = nil
724
+ DEVICE_SEARCH_ID = "NOT_SET"
725
+ }
726
+
727
+ func cleanup() {
728
+ disconnect()
729
+ opusDecoder = nil
730
+ }
731
+
732
+ func getConnectedBluetoothName() -> String? {
733
+ return peripheral?.name
734
+ }
735
+
736
+ func ping() {
737
+ sendFrame(
738
+ NimoFrameCodec.encodeFrame(
739
+ cmd: NimoProtocol.CMD_GET_PARAMETER, key: NimoProtocol.GET_TWS_STATUS
740
+ )
741
+ )
742
+ }
743
+
744
+ func dbg1() {}
745
+ func dbg2() {}
746
+ func connectController() {}
747
+ func disconnectController() {}
748
+
749
+ // MARK: - SGCManager: Audio Control
750
+
751
+ func setMicEnabled(_ enabled: Bool) {
752
+ Bridge.log("NIMO: setMicEnabled(\(enabled))")
753
+ guard let micChar else {
754
+ Bridge.log("NIMO: mic characteristic not available")
755
+ return
756
+ }
757
+ DeviceStore.shared.apply("glasses", "micEnabled", enabled)
758
+ if enabled {
759
+ if opusDecoder == nil {
760
+ opusDecoder = NimoOpusDecoder()
761
+ if opusDecoder == nil {
762
+ Bridge.log("NIMO: system Opus decoder unavailable — glasses mic disabled")
763
+ }
764
+ }
765
+ enqueueWrite(micChar, Data([0x52, 0x01, 0x00, 0x00]))
766
+ } else {
767
+ enqueueWrite(micChar, Data([0x52, 0x00, 0x00, 0x00]))
768
+ }
769
+ }
770
+
771
+ func sortMicRanking(list: [String]) -> [String] {
772
+ return list
773
+ }
774
+
775
+ // MARK: - SGCManager: Messaging
776
+
777
+ func sendJson(_: [String: Any], wakeUp _: Bool, requireAck _: Bool) {
778
+ Bridge.log("NIMO: sendJson - not supported")
779
+ }
780
+
781
+ // MARK: - SGCManager: Display Control
782
+
783
+ func setBrightness(_ level: Int, autoMode: Bool) {
784
+ Bridge.log("NIMO: setBrightness(\(level), auto=\(autoMode))")
785
+ sendFrame(
786
+ NimoFrameCodec.encodeFrame(
787
+ cmd: NimoProtocol.CMD_SET_PARAMETER,
788
+ key: NimoProtocol.SET_AUTO_BRIGHTNESS,
789
+ payload: Data([autoMode ? 1 : 0])
790
+ )
791
+ )
792
+ if !autoMode {
793
+ // The firmware takes a 0–16 level, not a 0–100 percent.
794
+ let clamped = max(0, min(100, level))
795
+ let lvl = Int((Double(clamped) / 100.0 * Double(NimoProtocol.MAX_BRIGHTNESS_LEVEL)).rounded())
796
+ sendFrame(
797
+ NimoFrameCodec.encodeFrame(
798
+ cmd: NimoProtocol.CMD_SET_PARAMETER,
799
+ key: NimoProtocol.SET_BRIGHTNESS,
800
+ payload: Data([UInt8(lvl)])
801
+ )
802
+ )
803
+ }
804
+ }
805
+
806
+ func clearDisplay() {
807
+ // Keep the text app/page alive — a blank update avoids re-entering the app next time.
808
+ pendingText = " "
809
+ }
810
+
811
+ func sendText(_ text: String) async {
812
+ await sendTextWall(text)
813
+ }
814
+
815
+ func sendTextWall(_ text: String) async {
816
+ // Coalesced: only the most recent pending text survives until the next 100ms drain.
817
+ pendingText = text
818
+ }
819
+
820
+ func sendDoubleTextWall(_ top: String, _ bottom: String) async {
821
+ await sendTextWall(top + "\n\n" + bottom)
822
+ }
823
+
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 = UIImage(data: imageData)
850
+ else {
851
+ Bridge.log("NIMO: displayBitmap — could not decode image")
852
+ return false
853
+ }
854
+ guard let grayscale = bitmapToGrayscale(image, width: targetWidth, height: targetHeight)
855
+ else { return false }
856
+ let packed = packL8To2bpp(grayscale)
857
+ let (payload, compression) = compressAdaptive(packed)
858
+ if !navAppEntered {
859
+ sendFrame(
860
+ NimoFrameCodec.encodeFrame(
861
+ cmd: NimoProtocol.CMD_CONTROL_INSTRUCTION,
862
+ key: NimoProtocol.CTRL_ENTER_APP,
863
+ payload: Data([UInt8(NimoProtocol.APP_ID_NAV), UInt8(NimoProtocol.APP_MODE_STANDALONE)])
864
+ )
865
+ )
866
+ // Optimistic; corrected by app-state reports if the glasses refuse/exit.
867
+ navAppEntered = true
868
+ }
869
+ let content =
870
+ NimoFrameCodec.imageHeader(
871
+ width: targetWidth,
872
+ height: targetHeight,
873
+ formatBpp: NimoProtocol.FORMAT_2BPP,
874
+ compression: compression,
875
+ originalSize: packed.count,
876
+ compressedSize: compression == NimoProtocol.COMPRESSION_NONE ? 0 : payload.count
877
+ ) + payload
878
+ let frames = NimoFrameCodec.updateContentFrames(
879
+ appId: NimoProtocol.APP_ID_NAV,
880
+ layoutId: 0,
881
+ resId: NimoProtocol.NAV_RES_LARGE_MAP,
882
+ resType: NimoProtocol.WIDGET_PICTURE,
883
+ content: content
884
+ )
885
+ enqueueFrames(frames)
886
+ return true
887
+ }
888
+
889
+ 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
+ )
902
+ }
903
+
904
+ func setDashboardPosition(_ height: Int, _ depth: Int) {
905
+ Bridge.log("NIMO: setDashboardPosition(\(height), \(depth))")
906
+ sendFrame(
907
+ NimoFrameCodec.encodeFrame(
908
+ cmd: NimoProtocol.CMD_SET_PARAMETER,
909
+ key: NimoProtocol.SET_HEIGHT_LEVEL,
910
+ payload: Data([UInt8(max(0, min(10, height)))])
911
+ )
912
+ )
913
+ sendFrame(
914
+ NimoFrameCodec.encodeFrame(
915
+ cmd: NimoProtocol.CMD_SET_PARAMETER,
916
+ key: NimoProtocol.SET_DISTANCE,
917
+ payload: Data([UInt8(max(0, min(10, depth)))])
918
+ )
919
+ )
920
+ }
921
+
922
+ // MARK: - SGCManager: Device Control
923
+
924
+ func setHeadUpAngle(_ angle: Int) {
925
+ let clamped = max(0, min(90, angle))
926
+ Bridge.log("NIMO: setHeadUpAngle(\(clamped))")
927
+ // Enable the head-up display gesture, then set the wake angle.
928
+ sendFrame(
929
+ NimoFrameCodec.encodeFrame(
930
+ cmd: NimoProtocol.CMD_SET_PARAMETER,
931
+ key: NimoProtocol.SET_HEADUP_DISPLAY,
932
+ payload: Data([1])
933
+ )
934
+ )
935
+ // setAngle payload is [optType, deg]. TODO: hardware-verify optType semantics.
936
+ sendFrame(
937
+ NimoFrameCodec.encodeFrame(
938
+ cmd: NimoProtocol.CMD_SET_PARAMETER,
939
+ key: NimoProtocol.SET_ANGLE,
940
+ payload: Data([0x01, UInt8(clamped)])
941
+ )
942
+ )
943
+ }
944
+
945
+ func getBatteryStatus() {
946
+ sendFrame(
947
+ NimoFrameCodec.encodeFrame(
948
+ cmd: NimoProtocol.CMD_GET_PARAMETER, key: NimoProtocol.GET_BATTERY
949
+ )
950
+ )
951
+ }
952
+
953
+ func setSilentMode(_ enabled: Bool) {
954
+ Bridge.log("NIMO: setSilentMode(\(enabled))")
955
+ // TODO: hardware-verify that display-off matches MentraOS silent-mode semantics.
956
+ sendFrame(
957
+ NimoFrameCodec.encodeFrame(
958
+ cmd: NimoProtocol.CMD_SET_PARAMETER,
959
+ key: NimoProtocol.SET_DISPLAY_OFF,
960
+ payload: Data([enabled ? 1 : 0])
961
+ )
962
+ )
963
+ }
964
+
965
+ 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
+ )
977
+ }
978
+
979
+ func sendShutdown() {
980
+ Bridge.log("NIMO: sendShutdown - not supported")
981
+ }
982
+
983
+ func sendReboot() {
984
+ Bridge.log("NIMO: sendReboot - not supported")
985
+ }
986
+
987
+ func sendRgbLedControl(
988
+ requestId: String, packageName _: String?, action _: String, color _: String?,
989
+ onDurationMs _: Int, offDurationMs _: Int, count _: Int
990
+ ) {
991
+ Bridge.sendRgbLedControlResponse(requestId: requestId, success: false, error: "device_not_supported")
992
+ }
993
+
994
+ // MARK: - Notifications
995
+
996
+ /// Pushes a notification (cmd=0x09 key=0x01, fixed 319-byte struct:
997
+ /// appId(1) + title(51, UTF-8 zero-padded) + time(9) + contentLen(2 LE) + content(256)).
998
+ func sendNotification(notificationAppId: Int, title: String, content: String) {
999
+ var payload = Data(count: 319)
1000
+ payload[0] = UInt8(max(0, min(255, notificationAppId)))
1001
+ writeUtf8Padded(&payload, offset: 1, text: title, maxLen: 51)
1002
+ let time = NimoFrameCodec.encodeDeviceTime()
1003
+ payload.replaceSubrange(52 ..< 61, with: time)
1004
+ let contentBytes = Data(content.utf8)
1005
+ let clen = min(contentBytes.count, 256)
1006
+ payload[61] = UInt8(clen & 0xFF)
1007
+ payload[62] = UInt8((clen >> 8) & 0xFF)
1008
+ writeUtf8Padded(&payload, offset: 63, text: content, maxLen: 256)
1009
+ sendFrame(
1010
+ NimoFrameCodec.encodeFrame(
1011
+ cmd: NimoProtocol.CMD_CONTROL_NOTIFICATION,
1012
+ key: NimoProtocol.NOTIFICATION_SEND,
1013
+ payload: payload
1014
+ )
1015
+ )
1016
+ }
1017
+
1018
+ private func writeUtf8Padded(_ dest: inout Data, offset: Int, text: String, maxLen: Int) {
1019
+ var bytes = Data(text.utf8)
1020
+ if bytes.count > maxLen {
1021
+ // Truncate on a UTF-8 boundary
1022
+ var end = maxLen
1023
+ while end > 0, bytes[end] & 0xC0 == 0x80 { end -= 1 }
1024
+ bytes = bytes.subdata(in: 0 ..< end)
1025
+ }
1026
+ dest.replaceSubrange(offset ..< offset + bytes.count, with: bytes)
1027
+ }
1028
+
1029
+ // MARK: - SGCManager: Camera & Media (no camera)
1030
+
1031
+ func requestPhoto(
1032
+ _: String, appId _: String, size _: String?, webhookUrl _: String?, authToken _: String?,
1033
+ compress _: String?, flash _: Bool, save _: Bool, sound _: Bool, exposureTimeNs _: Double?,
1034
+ iso _: Int?
1035
+ ) {
1036
+ Bridge.log("NIMO: requestPhoto - not supported (no camera)")
1037
+ }
1038
+
1039
+ func requestPhoto(_: PhotoRequest) {
1040
+ Bridge.log("NIMO: requestPhoto(PhotoRequest) - not supported (no camera)")
1041
+ }
1042
+
1043
+ func startStream(_: [String: Any]) {
1044
+ Bridge.log("NIMO: startStream - not supported")
1045
+ }
1046
+
1047
+ func stopStream() {
1048
+ Bridge.log("NIMO: stopStream - not supported")
1049
+ }
1050
+
1051
+ func sendStreamKeepAlive(_: [String: Any]) {}
1052
+
1053
+ func startVideoRecording(requestId _: String, save _: Bool, sound _: Bool) {
1054
+ Bridge.log("NIMO: startVideoRecording - not supported")
1055
+ }
1056
+
1057
+ func stopVideoRecording(requestId _: String) {
1058
+ Bridge.log("NIMO: stopVideoRecording - not supported")
1059
+ }
1060
+
1061
+ func sendButtonPhotoSettings() {}
1062
+ func sendButtonVideoRecordingSettings() {}
1063
+ func sendButtonMaxRecordingTime() {}
1064
+ func sendButtonCameraLedSetting() {}
1065
+ func sendCameraFovSetting() {}
1066
+
1067
+ // MARK: - SGCManager: Network (no WiFi)
1068
+
1069
+ func requestWifiScan() {}
1070
+ func sendWifiCredentials(_: String, _: String) {}
1071
+ func forgetWifiNetwork(_: String) {}
1072
+ func sendHotspotState(_: Bool) {}
1073
+ func sendOtaStart(otaVersionUrl: String?) {}
1074
+ func sendOtaQueryStatus() {}
1075
+
1076
+ // MARK: - SGCManager: User Context / Gallery / Version
1077
+
1078
+ func sendUserEmailToGlasses(_: String) {}
1079
+ func sendIncidentId(_: String, apiBaseUrl _: String?) {}
1080
+ func queryGalleryStatus() {}
1081
+ func sendGalleryMode() {}
1082
+
1083
+ func requestVersionInfo() {
1084
+ sendFrame(
1085
+ NimoFrameCodec.encodeFrame(
1086
+ cmd: NimoProtocol.CMD_GET_PARAMETER, key: NimoProtocol.GET_VERSION
1087
+ )
1088
+ )
1089
+ sendFrame(
1090
+ NimoFrameCodec.encodeFrame(
1091
+ cmd: NimoProtocol.CMD_GET_PARAMETER, key: NimoProtocol.GET_VERSION_DETAIL
1092
+ )
1093
+ )
1094
+ }
1095
+
1096
+ // MARK: - BLE Scanning
1097
+
1098
+ private func isNimoMainDevice(_ name: String) -> Bool {
1099
+ let lower = name.lowercased()
1100
+ return lower.hasPrefix(NimoBLE.NAME_PREFIX) && !lower.hasSuffix(NimoBLE.BLE_NAME_SUFFIX)
1101
+ }
1102
+
1103
+ @discardableResult
1104
+ private func startScan() -> Bool {
1105
+ Bridge.log("NIMO: startScan()")
1106
+ if centralManager == nil {
1107
+ centralManager = CBCentralManager(
1108
+ delegate: self, queue: Nimo._bluetoothQueue,
1109
+ options: [CBCentralManagerOptionShowPowerAlertKey: 0]
1110
+ )
1111
+ }
1112
+ guard centralManager!.state == .poweredOn else {
1113
+ // centralManagerDidUpdateState retries the scan once the radio is on.
1114
+ Bridge.log("NIMO: Bluetooth not powered on yet")
1115
+ return false
1116
+ }
1117
+
1118
+ // Fast path: reconnect to the cached peripheral UUID.
1119
+ if connectByUUID() {
1120
+ return true
1121
+ }
1122
+
1123
+ centralManager!.scanForPeripherals(
1124
+ withServices: nil,
1125
+ options: [CBCentralManagerScanOptionAllowDuplicatesKey: false]
1126
+ )
1127
+ return true
1128
+ }
1129
+
1130
+ private func connectByUUID() -> Bool {
1131
+ guard DEVICE_SEARCH_ID != "NOT_SET", !DEVICE_SEARCH_ID.isEmpty else { return false }
1132
+ guard lastDeviceName == DEVICE_SEARCH_ID,
1133
+ let uuidString = lastDeviceUUID,
1134
+ let uuid = UUID(uuidString: uuidString)
1135
+ else { return false }
1136
+ guard let known = centralManager?.retrievePeripherals(withIdentifiers: [uuid]).first
1137
+ else { return false }
1138
+
1139
+ Bridge.log("NIMO: connectByUUID - \(known.name ?? uuidString)")
1140
+ peripheral = known
1141
+ known.delegate = self
1142
+ centralManager?.connect(known, options: nil)
1143
+ return true
1144
+ }
1145
+
1146
+ private func startPairingTimeout() {
1147
+ cancelPairingTimeout()
1148
+ let item = DispatchWorkItem { [weak self] in
1149
+ guard let self else { return }
1150
+ if !self.fullyBooted {
1151
+ Bridge.log("NIMO: pairing timeout — handshake never completed")
1152
+ Bridge.sendPairFailureEvent("errors:pairNeedDisconnect")
1153
+ }
1154
+ }
1155
+ pairingTimeoutItem = item
1156
+ DispatchQueue.main.asyncAfter(deadline: .now() + Const.pairingTimeoutSeconds, execute: item)
1157
+ }
1158
+
1159
+ private func cancelPairingTimeout() {
1160
+ pairingTimeoutItem?.cancel()
1161
+ pairingTimeoutItem = nil
1162
+ }
1163
+
1164
+ // MARK: - Write Queue
1165
+
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()
1204
+ }
1205
+
1206
+ private func sendFrame(_ frame: Data) {
1207
+ guard let txChar else { return }
1208
+ enqueueWrite(txChar, frame)
1209
+ }
1210
+
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()
1217
+ }
1218
+
1219
+ // MARK: - Pending ACKs
1220
+
1221
+ private func sendAwaitingAck(
1222
+ cmd: Int, key: Int, payload: Data, onResult: @escaping (Bool) -> Void
1223
+ ) {
1224
+ let ackKey = (cmd << 8) | key
1225
+ // Only one in-flight ack per (cmd,key); fail any previous waiter.
1226
+ if let previous = pendingAcks.removeValue(forKey: ackKey) {
1227
+ previous.timeoutItem.cancel()
1228
+ previous.onResult(false)
1229
+ }
1230
+ let timeoutItem = DispatchWorkItem { [weak self] in
1231
+ guard let self else { return }
1232
+ self.pendingAcks.removeValue(forKey: ackKey)?.onResult(false)
1233
+ }
1234
+ pendingAcks[ackKey] = PendingAck(onResult: onResult, timeoutItem: timeoutItem)
1235
+ DispatchQueue.main.asyncAfter(deadline: .now() + Const.ackTimeoutSeconds, execute: timeoutItem)
1236
+ sendFrame(NimoFrameCodec.encodeFrame(cmd: cmd, key: key, payload: payload, needsAck: true))
1237
+ }
1238
+
1239
+ private func resolvePendingAck(cmd: Int, key: Int, success: Bool) {
1240
+ let ackKey = (cmd << 8) | key
1241
+ if let ack = pendingAcks.removeValue(forKey: ackKey) {
1242
+ ack.timeoutItem.cancel()
1243
+ ack.onResult(success)
1244
+ }
1245
+ }
1246
+
1247
+ private func failAllPendingAcks() {
1248
+ let acks = pendingAcks.values
1249
+ pendingAcks.removeAll()
1250
+ for ack in acks {
1251
+ ack.timeoutItem.cancel()
1252
+ ack.onResult(false)
1253
+ }
1254
+ }
1255
+
1256
+ // MARK: - Handshake
1257
+
1258
+ private func startHandshake() {
1259
+ Bridge.log("NIMO: starting handshake (awaiting TWS service-connection state)")
1260
+ handshakeState = .awaitingTws
1261
+ if twsConnected {
1262
+ proceedToTimeSync()
1263
+ return
1264
+ }
1265
+ cancelTwsTimeout()
1266
+ let item = DispatchWorkItem { [weak self] in
1267
+ guard let self else { return }
1268
+ if self.handshakeState == .awaitingTws {
1269
+ Bridge.log("NIMO: TWS state timeout — handshake failed, will reconnect")
1270
+ self.handshakeFailed()
1271
+ }
1272
+ }
1273
+ twsTimeoutItem = item
1274
+ DispatchQueue.main.asyncAfter(deadline: .now() + Const.twsTimeoutSeconds, execute: item)
1275
+ // Also actively query in case the glasses don't push the report unprompted.
1276
+ ping()
1277
+ }
1278
+
1279
+ private func cancelTwsTimeout() {
1280
+ twsTimeoutItem?.cancel()
1281
+ twsTimeoutItem = nil
1282
+ }
1283
+
1284
+ private func proceedToTimeSync() {
1285
+ guard handshakeState == .awaitingTws else { return }
1286
+ cancelTwsTimeout()
1287
+ handshakeState = .awaitingTimeAck
1288
+ 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
+ }
1301
+ }
1302
+ }
1303
+
1304
+ private func finishHandshake() {
1305
+ Bridge.log("NIMO: handshake complete — fully connected")
1306
+ handshakeState = .ready
1307
+ sendFrame(
1308
+ NimoFrameCodec.encodeFrame(
1309
+ cmd: NimoProtocol.CMD_SET_PARAMETER,
1310
+ key: NimoProtocol.SET_PHONE_TYPE,
1311
+ payload: Data([UInt8(NimoProtocol.PHONE_TYPE_IOS)]),
1312
+ needsAck: false
1313
+ )
1314
+ )
1315
+ getBatteryStatus()
1316
+ requestVersionInfo()
1317
+
1318
+ cancelPairingTimeout()
1319
+ Task { await reconnectionManager.stop() }
1320
+ DeviceStore.shared.apply("glasses", "connected", true)
1321
+ DeviceStore.shared.apply("glasses", "fullyBooted", true)
1322
+ DeviceStore.shared.apply("glasses", "connectionState", ConnTypes.CONNECTED)
1323
+ startTimers()
1324
+ }
1325
+
1326
+ private func handshakeFailed() {
1327
+ handshakeState = .idle
1328
+ if let peripheral {
1329
+ centralManager?.cancelPeripheralConnection(peripheral)
1330
+ }
1331
+ // The didDisconnectPeripheral path handles cleanup + reconnection.
1332
+ }
1333
+
1334
+ private func resetSessionState() {
1335
+ handshakeState = .idle
1336
+ twsConnected = false
1337
+ textAppEntered = false
1338
+ navAppEntered = false
1339
+ currentGlassesAppId = -1
1340
+ pendingText = nil
1341
+ receiveAssembler.reset()
1342
+ writeQueue.removeAll()
1343
+ writeInFlight = false
1344
+ writeWatchdogItem?.cancel()
1345
+ writeWatchdogItem = nil
1346
+ failAllPendingAcks()
1347
+ stopTimers()
1348
+ }
1349
+
1350
+ private func startReconnectionTimer() {
1351
+ Task {
1352
+ await reconnectionManager.start { [weak self] in
1353
+ guard let self else { return false }
1354
+ if await MainActor.run(body: {
1355
+ DeviceStore.shared.get("glasses", "fullyBooted") as? Bool ?? false
1356
+ }) {
1357
+ return true
1358
+ }
1359
+ Bridge.log("NIMO: Attempting reconnection...")
1360
+ await MainActor.run {
1361
+ self.isDisconnecting = false
1362
+ self.startScan()
1363
+ }
1364
+ return false
1365
+ }
1366
+ }
1367
+ }
1368
+
1369
+ // MARK: - Timers (battery poll keepalive + text queue)
1370
+
1371
+ private func startTimers() {
1372
+ stopTimers()
1373
+ // The battery poll doubles as a keepalive so iOS bluetoothd never sees the link
1374
+ // as unused and reclaims it.
1375
+ batteryPollTimer = Timer.scheduledTimer(
1376
+ withTimeInterval: Const.batteryPollSeconds, repeats: true
1377
+ ) { [weak self] _ in
1378
+ Task { @MainActor in
1379
+ self?.getBatteryStatus()
1380
+ }
1381
+ }
1382
+ textQueueTimer = Timer.scheduledTimer(
1383
+ withTimeInterval: Const.textQueueTickSeconds, repeats: true
1384
+ ) { [weak self] _ in
1385
+ Task { @MainActor in
1386
+ self?.drainTextQueue()
1387
+ }
1388
+ }
1389
+ }
1390
+
1391
+ private func stopTimers() {
1392
+ batteryPollTimer?.invalidate()
1393
+ 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
+ }
1427
+
1428
+ // MARK: - Incoming Data
1429
+
1430
+ private func handleRxPacket(_ packet: Data) {
1431
+ receiveAssembler.cleanup()
1432
+ for frame in receiveAssembler.ingest(packet) {
1433
+ guard let decoded = NimoFrameCodec.decode(frame),
1434
+ let cmd = decoded.cmd, let key = decoded.key
1435
+ else { continue }
1436
+ if cmd == NimoProtocol.CMD_INSTRUCTION_REPORT {
1437
+ handleReport(key: key, data: decoded.data ?? Data())
1438
+ } else {
1439
+ handleResponse(
1440
+ cmd: cmd, key: key, statusCode: decoded.statusCode ?? 1,
1441
+ data: decoded.data ?? Data()
1442
+ )
1443
+ }
1444
+ }
1445
+ }
1446
+
1447
+ private func handleReport(key: Int, data: Data) {
1448
+ switch key {
1449
+ case NimoProtocol.REPORT_INPUT:
1450
+ if !data.isEmpty {
1451
+ handleInputEvent(Int(data[data.startIndex]))
1452
+ }
1453
+ case NimoProtocol.REPORT_APP:
1454
+ if data.count >= 2 {
1455
+ let appId = Int(data[data.startIndex])
1456
+ let phase = Int(data[data.startIndex + 1])
1457
+ Bridge.log("NIMO: app state report appId=\(appId) phase=\(phase)")
1458
+ switch phase {
1459
+ case NimoProtocol.STATE_ENTER:
1460
+ currentGlassesAppId = appId
1461
+ if appId != textAppId { textAppEntered = false }
1462
+ if appId != NimoProtocol.APP_ID_NAV { navAppEntered = false }
1463
+ 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 }
1467
+ default:
1468
+ break
1469
+ }
1470
+ }
1471
+ case NimoProtocol.REPORT_TWS:
1472
+ if !data.isEmpty {
1473
+ onTwsState(Int(data[data.startIndex]) >= 1)
1474
+ }
1475
+ case NimoProtocol.REPORT_BUSINESS:
1476
+ handleBusinessReport(data)
1477
+ case NimoProtocol.REPORT_GATT_STATE:
1478
+ break
1479
+ default:
1480
+ Bridge.log("NIMO: unknown report key=\(key)")
1481
+ }
1482
+ }
1483
+
1484
+ private func handleBusinessReport(_ data: Data) {
1485
+ guard !data.isEmpty else { return }
1486
+ let bytes = [UInt8](data)
1487
+ let id = Int(bytes[0])
1488
+ let v = Array(bytes.dropFirst())
1489
+ switch id {
1490
+ case NimoProtocol.BUSINESS_HEARTBEAT:
1491
+ // [leftMv(2)][rightMv(2)][btSysStatus(4)][twsStatus(1)][slaveGatt(1)]
1492
+ if v.count >= 10 {
1493
+ onTwsState(Int(v[8]) >= 1)
1494
+ }
1495
+ case NimoProtocol.BUSINESS_BATTERY:
1496
+ if v.count >= 4 {
1497
+ applyBattery(
1498
+ left: Int(v[0]), right: Int(v[1]),
1499
+ leftCharging: v[2] == 1, rightCharging: v[3] == 1
1500
+ )
1501
+ }
1502
+ default:
1503
+ break
1504
+ }
1505
+ }
1506
+
1507
+ private func onTwsState(_ connected: Bool) {
1508
+ twsConnected = connected
1509
+ if connected, handshakeState == .awaitingTws {
1510
+ proceedToTimeSync()
1511
+ }
1512
+ if !connected, handshakeState == .ready {
1513
+ Bridge.log("NIMO: TWS service dropped mid-session (arm removed/off?)")
1514
+ }
1515
+ }
1516
+
1517
+ private func handleInputEvent(_ code: Int) {
1518
+ let timestamp = Int64(Date().timeIntervalSince1970 * 1000)
1519
+ switch code {
1520
+ case NimoProtocol.INPUT_HEAD_UP:
1521
+ DeviceStore.shared.apply("glasses", "headUp", true)
1522
+ Bridge.sendHeadUp(true)
1523
+ case NimoProtocol.INPUT_HEAD_DOWN:
1524
+ DeviceStore.shared.apply("glasses", "headUp", false)
1525
+ Bridge.sendHeadUp(false)
1526
+ case NimoProtocol.INPUT_CLICK_RIGHT, NimoProtocol.INPUT_CLICK_LEFT:
1527
+ Bridge.sendTouchEvent(
1528
+ deviceModel: DeviceTypes.NIMO, gestureName: "single_tap", timestamp: timestamp
1529
+ )
1530
+ case NimoProtocol.INPUT_DOUBLE_CLICK_RIGHT, NimoProtocol.INPUT_DOUBLE_CLICK_LEFT:
1531
+ Bridge.sendTouchEvent(
1532
+ deviceModel: DeviceTypes.NIMO, gestureName: "double_tap", timestamp: timestamp
1533
+ )
1534
+ case NimoProtocol.INPUT_LONG_PRESS_RIGHT, NimoProtocol.INPUT_LONG_PRESS_LEFT:
1535
+ Bridge.sendTouchEvent(
1536
+ deviceModel: DeviceTypes.NIMO, gestureName: "long_press", timestamp: timestamp
1537
+ )
1538
+ case NimoProtocol.INPUT_TOUCH_PRESS_RIGHT,
1539
+ NimoProtocol.INPUT_TOUCH_RELEASE_RIGHT,
1540
+ NimoProtocol.INPUT_TOUCH_PRESS_LEFT,
1541
+ NimoProtocol.INPUT_TOUCH_RELEASE_LEFT:
1542
+ // Raw press/release transitions are too chatty to forward; taps cover the UX.
1543
+ break
1544
+ default:
1545
+ Bridge.log("NIMO: unknown input event code=\(code)")
1546
+ }
1547
+ }
1548
+
1549
+ private func handleResponse(cmd: Int, key: Int, statusCode: Int, data: Data) {
1550
+ resolvePendingAck(cmd: cmd, key: key, success: statusCode == 0)
1551
+
1552
+ guard cmd == NimoProtocol.CMD_GET_PARAMETER, statusCode == 0 else { return }
1553
+ let bytes = [UInt8](data)
1554
+ switch key {
1555
+ case NimoProtocol.GET_BATTERY:
1556
+ if bytes.count >= 4 {
1557
+ applyBattery(
1558
+ left: Int(bytes[0]), right: Int(bytes[1]),
1559
+ leftCharging: bytes[2] == 1, rightCharging: bytes[3] == 1
1560
+ )
1561
+ }
1562
+ case NimoProtocol.GET_VERSION:
1563
+ if bytes.count >= 4 {
1564
+ let v =
1565
+ UInt32(bytes[0]) | (UInt32(bytes[1]) << 8) | (UInt32(bytes[2]) << 16)
1566
+ | (UInt32(bytes[3]) << 24)
1567
+ let major = (v >> 28) & 0xF
1568
+ let minor = (v >> 21) & 0x7F
1569
+ let micro = (v >> 12) & 0x1FF
1570
+ let build = v & 0xFFF
1571
+ firmwareVersionPacked = "\(major).\(minor).\(micro).\(build)"
1572
+ emitVersionInfo()
1573
+ }
1574
+ case NimoProtocol.GET_VERSION_DETAIL:
1575
+ var end = bytes.count
1576
+ while end > 0, bytes[end - 1] == 0 { end -= 1 }
1577
+ firmwareVersionDetail =
1578
+ String(bytes: bytes[0 ..< end], encoding: .utf8)?
1579
+ .trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
1580
+ emitVersionInfo()
1581
+ case NimoProtocol.GET_TWS_STATUS:
1582
+ if !bytes.isEmpty {
1583
+ onTwsState(Int(bytes[0]) >= 1)
1584
+ }
1585
+ default:
1586
+ break
1587
+ }
1588
+ }
1589
+
1590
+ private func applyBattery(left: Int, right: Int, leftCharging: Bool, rightCharging: Bool) {
1591
+ // Two independent arms → report the conservative (lower) level.
1592
+ let level = min(left, right)
1593
+ let charging = leftCharging || rightCharging
1594
+ if level != lastBatteryLevel || charging != lastCharging {
1595
+ lastBatteryLevel = level
1596
+ lastCharging = charging
1597
+ DeviceStore.shared.apply("glasses", "batteryLevel", level)
1598
+ DeviceStore.shared.apply("glasses", "charging", charging)
1599
+ Bridge.sendBatteryStatus(level: level, charging: charging)
1600
+ }
1601
+ }
1602
+
1603
+ private func emitVersionInfo() {
1604
+ let version = firmwareVersionDetail.isEmpty ? firmwareVersionPacked : firmwareVersionDetail
1605
+ guard !version.isEmpty else { return }
1606
+ DeviceStore.shared.apply("glasses", "firmwareVersion", version)
1607
+ Bridge.sendVersionInfo(["firmwareVersion": version])
1608
+ }
1609
+
1610
+ // MARK: - Mic Audio
1611
+
1612
+ private func handleMicPacket(_ data: Data) {
1613
+ guard let packet = NimoAudioParser.parse(data) else { return }
1614
+ let now = Date()
1615
+ switch packet.type {
1616
+ case NimoAudioParser.TYPE_OPUS_RIGHT:
1617
+ lastRightPacketTime = now
1618
+ preferRightMic = true
1619
+ case NimoAudioParser.TYPE_OPUS_LEFT:
1620
+ // Only fall back to the left ear when the right has gone quiet —
1621
+ // forwarding both would duplicate the audio.
1622
+ if preferRightMic,
1623
+ now.timeIntervalSince(lastRightPacketTime) < Const.micSideFallbackSeconds
1624
+ {
1625
+ return
1626
+ }
1627
+ preferRightMic = false
1628
+ default:
1629
+ return
1630
+ }
1631
+
1632
+ guard let opusDecoder else { return }
1633
+ for opusFrame in packet.opusFrames {
1634
+ if let pcm = opusDecoder.decode(opusFrame), !pcm.isEmpty {
1635
+ DeviceManager.shared.handlePcm(pcm)
1636
+ }
1637
+ }
1638
+ }
1639
+
1640
+ // MARK: - Bitmap Helpers
1641
+
1642
+ /// Aspect-fit the image into `width`x`height` on black, then convert to L8 grayscale.
1643
+ private func bitmapToGrayscale(_ image: UIImage, 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
+ }
1755
+
1756
+ // MARK: - CBCentralManagerDelegate
1757
+
1758
+ extension Nimo: CBCentralManagerDelegate {
1759
+ nonisolated func centralManagerDidUpdateState(_ central: CBCentralManager) {
1760
+ let state = central.state
1761
+ DispatchQueue.main.async { [weak self] in
1762
+ guard let self else { return }
1763
+ Bridge.log("NIMO: Bluetooth state: \(state.rawValue)")
1764
+ if state == .poweredOn {
1765
+ self.startScan()
1766
+ }
1767
+ }
1768
+ }
1769
+
1770
+ nonisolated func centralManager(
1771
+ _ central: CBCentralManager,
1772
+ didDiscover peripheral: CBPeripheral,
1773
+ advertisementData: [String: Any],
1774
+ rssi: NSNumber
1775
+ ) {
1776
+ guard
1777
+ let name = peripheral.name
1778
+ ?? advertisementData[CBAdvertisementDataLocalNameKey] as? String
1779
+ else { return }
1780
+ let rssiValue = rssi.intValue
1781
+
1782
+ DispatchQueue.main.async { [weak self] in
1783
+ guard let self else { return }
1784
+ guard self.isNimoMainDevice(name) else { return }
1785
+
1786
+ Bridge.sendDiscoveredDevice(DeviceTypes.NIMO, name, rssi: rssiValue)
1787
+
1788
+ guard self.DEVICE_SEARCH_ID != "NOT_SET" else { return }
1789
+ guard name == self.DEVICE_SEARCH_ID else { return }
1790
+ guard self.peripheral == nil else { return }
1791
+
1792
+ Bridge.log("NIMO: Connecting to \(name)")
1793
+ self.stopScan()
1794
+ self.lastDeviceName = name
1795
+ self.lastDeviceUUID = peripheral.identifier.uuidString
1796
+ self.peripheral = peripheral
1797
+ peripheral.delegate = self
1798
+ central.connect(peripheral, options: nil)
1799
+ }
1800
+ }
1801
+
1802
+ nonisolated func centralManager(_: CBCentralManager, didConnect peripheral: CBPeripheral) {
1803
+ DispatchQueue.main.async { [weak self] in
1804
+ guard let self else { return }
1805
+ Bridge.log("NIMO: Connected to \(peripheral.name ?? "unknown")")
1806
+ self.lastDeviceUUID = peripheral.identifier.uuidString
1807
+ peripheral.discoverServices([NimoBLE.SERVICE_UUID])
1808
+ }
1809
+ }
1810
+
1811
+ nonisolated func centralManager(
1812
+ _: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?
1813
+ ) {
1814
+ DispatchQueue.main.async { [weak self] in
1815
+ guard let self else { return }
1816
+ Bridge.log(
1817
+ "NIMO: Failed to connect \(peripheral.name ?? "unknown"): \(error?.localizedDescription ?? "unknown")"
1818
+ )
1819
+ self.peripheral = nil
1820
+ self.startReconnectionTimer()
1821
+ }
1822
+ }
1823
+
1824
+ nonisolated func centralManager(
1825
+ _: CBCentralManager, didDisconnectPeripheral _: CBPeripheral, error: Error?
1826
+ ) {
1827
+ DispatchQueue.main.async { [weak self] in
1828
+ guard let self else { return }
1829
+ Bridge.log("NIMO: Disconnected: \(error?.localizedDescription ?? "clean")")
1830
+ if self.isDisconnecting { return }
1831
+
1832
+ self.peripheral = nil
1833
+ self.txChar = nil
1834
+ self.rxChar = nil
1835
+ self.micChar = nil
1836
+ self.resetSessionState()
1837
+
1838
+ DeviceStore.shared.apply("glasses", "connected", false)
1839
+ DeviceStore.shared.apply("glasses", "fullyBooted", false)
1840
+ DeviceStore.shared.apply("glasses", "connectionState", ConnTypes.DISCONNECTED)
1841
+ self.startReconnectionTimer()
1842
+ }
1843
+ }
1844
+ }
1845
+
1846
+ // MARK: - CBPeripheralDelegate
1847
+
1848
+ 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 {
1852
+ peripheral.discoverCharacteristics(nil, for: service)
1853
+ }
1854
+ }
1855
+
1856
+ nonisolated func peripheral(
1857
+ _ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error _: Error?
1858
+ ) {
1859
+ guard service.uuid == NimoBLE.SERVICE_UUID,
1860
+ let characteristics = service.characteristics
1861
+ else { return }
1862
+
1863
+ DispatchQueue.main.async { [weak self] in
1864
+ guard let self else { return }
1865
+ for char in characteristics {
1866
+ switch char.uuid {
1867
+ case NimoBLE.CHAR_TX:
1868
+ self.txChar = char
1869
+ case NimoBLE.CHAR_RX:
1870
+ self.rxChar = char
1871
+ peripheral.setNotifyValue(true, for: char)
1872
+ case NimoBLE.CHAR_MIC:
1873
+ self.micChar = char
1874
+ peripheral.setNotifyValue(true, for: char)
1875
+ default:
1876
+ break
1877
+ }
1878
+ }
1879
+ Bridge.log(
1880
+ "NIMO: chars tx=\(self.txChar != nil) rx=\(self.rxChar != nil) mic=\(self.micChar != nil)"
1881
+ )
1882
+ }
1883
+ }
1884
+
1885
+ nonisolated func peripheral(
1886
+ _: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic,
1887
+ error: Error?
1888
+ ) {
1889
+ DispatchQueue.main.async { [weak self] in
1890
+ guard let self else { return }
1891
+ if let error {
1892
+ Bridge.log("NIMO: notify enable failed: \(error.localizedDescription)")
1893
+ return
1894
+ }
1895
+ // RX notifications live → the data channel is usable; run the handshake.
1896
+ if characteristic.uuid == NimoBLE.CHAR_RX, self.handshakeState == .idle {
1897
+ self.startHandshake()
1898
+ }
1899
+ }
1900
+ }
1901
+
1902
+ nonisolated func peripheral(
1903
+ _: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?
1904
+ ) {
1905
+ guard let data = characteristic.value, error == nil else { return }
1906
+ let uuid = characteristic.uuid
1907
+ DispatchQueue.main.async { [weak self] in
1908
+ guard let self else { return }
1909
+ if uuid == NimoBLE.CHAR_RX {
1910
+ self.handleRxPacket(data)
1911
+ } else if uuid == NimoBLE.CHAR_MIC {
1912
+ self.handleMicPacket(data)
1913
+ }
1914
+ }
1915
+ }
1916
+
1917
+ nonisolated func peripheral(
1918
+ _: CBPeripheral, didWriteValueFor _: CBCharacteristic, error: Error?
1919
+ ) {
1920
+ 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()
1926
+ }
1927
+ }
1928
+ }