@mentra/bluetooth-sdk 0.1.14 → 0.1.17

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 (67) hide show
  1. package/README.md +4 -6
  2. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +22 -9
  3. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +5 -20
  4. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +358 -261
  5. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +3 -3
  6. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +133 -52
  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 +22 -6
  9. package/android/src/main/java/com/mentra/bluetoothsdk/events/BluetoothEvents.kt +1 -22
  10. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.kt +50 -3
  11. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +1184 -795
  12. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +422 -78
  13. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +3 -4
  14. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Nimo.kt +2532 -0
  15. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +15 -1
  16. package/android/src/main/java/com/mentra/bluetoothsdk/types/DeviceModels.kt +5 -3
  17. package/android/src/main/java/com/mentra/bluetoothsdk/utils/Constants.kt +2 -1
  18. package/android/src/test/java/com/mentra/bluetoothsdk/camera/PhotoRequestTest.kt +60 -1
  19. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoProtocolTest.kt +333 -0
  20. package/build/BluetoothSdk.types.d.ts +39 -14
  21. package/build/BluetoothSdk.types.d.ts.map +1 -1
  22. package/build/BluetoothSdk.types.js +1 -0
  23. package/build/BluetoothSdk.types.js.map +1 -1
  24. package/build/_internal.d.ts +4 -4
  25. package/build/_internal.js +4 -4
  26. package/build/_internal.js.map +1 -1
  27. package/build/_private/BluetoothSdkModule.d.ts +11 -3
  28. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  29. package/build/_private/BluetoothSdkModule.js +5 -0
  30. package/build/_private/BluetoothSdkModule.js.map +1 -1
  31. package/build/_private/cameraRequestPayload.d.ts +4 -0
  32. package/build/_private/cameraRequestPayload.d.ts.map +1 -0
  33. package/build/_private/cameraRequestPayload.js +25 -0
  34. package/build/_private/cameraRequestPayload.js.map +1 -0
  35. package/build/_private/photoRequestPayload.d.ts.map +1 -1
  36. package/build/_private/photoRequestPayload.js +8 -2
  37. package/build/_private/photoRequestPayload.js.map +1 -1
  38. package/build/index.d.ts +1 -1
  39. package/build/index.d.ts.map +1 -1
  40. package/build/index.js +72 -54
  41. package/build/index.js.map +1 -1
  42. package/ios/BluetoothSdkModule.swift +40 -12
  43. package/ios/Source/BluetoothSdkDefaults.swift +2 -30
  44. package/ios/Source/Bridge.swift +4 -16
  45. package/ios/Source/DeviceManager.swift +72 -12
  46. package/ios/Source/MentraBluetoothSDK.swift +120 -50
  47. package/ios/Source/OtaManifest.swift +13 -13
  48. package/ios/Source/camera/CameraModels.swift +75 -9
  49. package/ios/Source/errors/{BluetoothError.swift → BluetoothSdkError.swift} +1 -1
  50. package/ios/Source/events/BluetoothEvents.swift +5 -27
  51. package/ios/Source/internal/BluetoothAvailability.swift +5 -5
  52. package/ios/Source/sgcs/G1.swift +51 -1
  53. package/ios/Source/sgcs/G2.swift +643 -203
  54. package/ios/Source/sgcs/MentraLive.swift +53 -39
  55. package/ios/Source/sgcs/Nimo.swift +1928 -0
  56. package/ios/Source/sgcs/SGCManager.swift +12 -5
  57. package/ios/Source/stt/STTTools.swift +1 -1
  58. package/ios/Source/types/DeviceModels.swift +5 -0
  59. package/ios/Source/utils/Constants.swift +2 -0
  60. package/package.json +9 -1
  61. package/scripts/inject-ios-sdk-version.mjs +65 -0
  62. package/src/BluetoothSdk.types.ts +42 -17
  63. package/src/_internal.ts +4 -4
  64. package/src/_private/BluetoothSdkModule.ts +18 -2
  65. package/src/_private/cameraRequestPayload.ts +29 -0
  66. package/src/_private/photoRequestPayload.ts +9 -2
  67. package/src/index.ts +76 -58
@@ -0,0 +1,2532 @@
1
+ package com.mentra.bluetoothsdk.sgcs
2
+
3
+ import android.bluetooth.BluetoothAdapter
4
+ import android.bluetooth.BluetoothGatt
5
+ import android.bluetooth.BluetoothGattCallback
6
+ import android.bluetooth.BluetoothGattCharacteristic
7
+ import android.bluetooth.BluetoothGattDescriptor
8
+ import android.bluetooth.BluetoothProfile
9
+ import android.content.Context
10
+ import android.graphics.Bitmap
11
+ import android.graphics.BitmapFactory
12
+ import android.graphics.Canvas
13
+ import android.graphics.Color
14
+ import android.graphics.Paint
15
+ import android.graphics.Rect
16
+ import android.media.MediaCodec
17
+ import android.media.MediaFormat
18
+ import android.os.Handler
19
+ import android.os.HandlerThread
20
+ import android.os.Looper
21
+ import android.util.Base64
22
+ import com.mentra.bluetoothsdk.Bridge
23
+ import com.mentra.bluetoothsdk.DeviceManager
24
+ import com.mentra.bluetoothsdk.DeviceStore
25
+ import com.mentra.bluetoothsdk.PhotoRequest
26
+ import com.mentra.bluetoothsdk.utils.ConnTypes
27
+ import com.mentra.bluetoothsdk.utils.DeviceTypes
28
+ import java.io.ByteArrayOutputStream
29
+ import java.nio.ByteBuffer
30
+ import java.nio.ByteOrder
31
+ import java.util.Calendar
32
+ import java.util.TimeZone
33
+ import java.util.UUID
34
+ import java.util.zip.Deflater
35
+
36
+ // ---------- Nimo BLE Constants ----------
37
+
38
+ internal object NimoBLE {
39
+ // UART-style service: TX = phone→glasses commands, RX = glasses→phone notifications.
40
+ // Short UUIDs from the vendor SDK expanded onto the Bluetooth base UUID.
41
+ val SERVICE_UUID: UUID = UUID.fromString("00007033-0000-1000-8000-00805F9B34FB")
42
+ val CHAR_TX: UUID = UUID.fromString("00002021-0000-1000-8000-00805F9B34FB")
43
+ val CHAR_RX: UUID = UUID.fromString("00002022-0000-1000-8000-00805F9B34FB")
44
+ val CHAR_MIC: UUID = UUID.fromString("00002025-0000-1000-8000-00805F9B34FB")
45
+ val CLIENT_CHARACTERISTIC_CONFIG: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
46
+
47
+ const val NAME_PREFIX = "nimo"
48
+ // iOS ANCS side-channel devices advertise "<name>_ble" — never the data channel.
49
+ const val BLE_NAME_SUFFIX = "_ble"
50
+
51
+ const val CHUNK_SIZE = 501
52
+ const val INTER_FRAME_DELAY_MS = 5L
53
+ }
54
+
55
+ // ---------- Nimo Protocol Constants ----------
56
+ // Byte values follow the glasses firmware protocol and must not be changed.
57
+
58
+ internal object NimoProtocol {
59
+ const val FRAME_MAGIC = 0xBF
60
+
61
+ // status bits
62
+ const val STATUS_ERR = 0x01
63
+ const val STATUS_ACK = 0x02
64
+
65
+ // command categories
66
+ const val CMD_GET_PARAMETER = 0x02
67
+ const val CMD_SET_PARAMETER = 0x03
68
+ const val CMD_INSTRUCTION_REPORT = 0x06
69
+ const val CMD_CONTROL_INSTRUCTION = 0x07
70
+ const val CMD_CONTROL_FACTORY = 0x08
71
+ const val CMD_CONTROL_NOTIFICATION = 0x09
72
+
73
+ // get parameter keys
74
+ const val GET_BRIGHTNESS = 0x02
75
+ const val GET_BATTERY = 0x06
76
+ const val GET_SCREEN_INFO = 0x07
77
+ const val GET_VERSION = 0x0A
78
+ const val GET_VERSION_DETAIL = 0x0B
79
+ const val GET_TWS_STATUS = 0x16
80
+
81
+ // set parameter keys
82
+ const val SET_TIME = 0x01
83
+ const val SET_BRIGHTNESS = 0x02
84
+ const val SET_DISTANCE = 0x03
85
+ const val SET_ANGLE = 0x04
86
+ const val SET_HEADUP_DISPLAY = 0x0D
87
+ const val SET_AUTO_BRIGHTNESS = 0x0E
88
+ const val SET_DISPLAY_OFF = 0x0F
89
+ const val SET_PHONE_TYPE = 0x14
90
+ const val SET_HEIGHT_LEVEL = 0x17
91
+
92
+ // control instruction keys
93
+ const val CTRL_ENTER_APP = 0x01
94
+ const val CTRL_QUIT_APP = 0x03
95
+ const val CTRL_UPDATE_CONTENT = 0x04
96
+
97
+ // notification keys
98
+ const val NOTIFICATION_SEND = 0x01
99
+
100
+ // report keys (cmd 0x06)
101
+ const val REPORT_INPUT = 0x01
102
+ const val REPORT_APP = 0x02
103
+ const val REPORT_TWS = 0x03
104
+ const val REPORT_BUSINESS = 0x04
105
+ const val REPORT_GATT_STATE = 0x05
106
+
107
+ // business report ids
108
+ const val BUSINESS_MESSAGE_PUSH = 0x01
109
+ const val BUSINESS_HEARTBEAT = 0x03
110
+ const val BUSINESS_BATTERY = 0x05
111
+
112
+ // input event codes
113
+ const val INPUT_HEAD_UP = 0x01
114
+ const val INPUT_HEAD_DOWN = 0x02
115
+ const val INPUT_CLICK_RIGHT = 0x03
116
+ const val INPUT_DOUBLE_CLICK_RIGHT = 0x04
117
+ const val INPUT_LONG_PRESS_RIGHT = 0x05
118
+ const val INPUT_TOUCH_PRESS_RIGHT = 0x06
119
+ const val INPUT_TOUCH_RELEASE_RIGHT = 0x07
120
+ const val INPUT_CLICK_LEFT = 0x13
121
+ const val INPUT_DOUBLE_CLICK_LEFT = 0x14
122
+ const val INPUT_LONG_PRESS_LEFT = 0x15
123
+ const val INPUT_TOUCH_PRESS_LEFT = 0x16
124
+ const val INPUT_TOUCH_RELEASE_LEFT = 0x17
125
+
126
+ // app state report phases
127
+ const val STATE_ENTER = 0x01
128
+ const val STATE_EXIT = 0x03
129
+
130
+ // app ids
131
+ const val APP_ID_DASHBOARD = 0x00
132
+ const val APP_ID_NAV = 0x01
133
+ const val APP_ID_ASR_NOTE = 0x04
134
+ const val APP_ID_PROMPTER = 0x06
135
+ const val APP_ID_AI_TALK = 0x07
136
+
137
+ // enterApp modes
138
+ const val APP_MODE_STANDALONE = 0x00
139
+ // Undocumented: defined in the vendor SDK constants (appModePopup) but never used there.
140
+ // Hardware-tested June 2026: does NOT remove the ASR view's time/battery status bar.
141
+ const val APP_MODE_POPUP = 0x01
142
+
143
+ // widget resTypes
144
+ const val WIDGET_TEXT_NEW = 0x00
145
+ const val WIDGET_TEXT_APPEND = 0x01
146
+ const val WIDGET_PICTURE = 0x80
147
+
148
+ // navigation widget resIds (appId 0x01): mini map 0x00, arrow 0x01, turn text 0x02,
149
+ // status bar 0x03 (raw), tip 0x04, large map 0x05.
150
+ const val NAV_RES_MINI_MAP = 0x00
151
+ const val NAV_RES_TURN_TEXT = 0x02
152
+ const val NAV_RES_LARGE_MAP = 0x05
153
+
154
+ // navigation image widget sizes (hard firmware requirements; see IMAGE_PROTOCOL).
155
+ const val NAV_MINI_MAP_SIZE = 160
156
+ const val NAV_LARGE_MAP_WIDTH = 452
157
+ const val NAV_LARGE_MAP_HEIGHT = 170
158
+
159
+ // phone types
160
+ const val PHONE_TYPE_OTHER = 0x02
161
+
162
+ // image header
163
+ const val COM_IMAGE_HEADER = 0x16
164
+ const val FORMAT_2BPP = 0x02
165
+ const val COMPRESSION_NONE = 0x00
166
+ const val COMPRESSION_ZLIB = 0x07
167
+
168
+ const val MAX_BRIGHTNESS_LEVEL = 16
169
+ }
170
+
171
+ // ---------- CRC16-CCITT ----------
172
+
173
+ /**
174
+ * CRC-16/CCITT-FALSE: init 0xFFFF, poly 0x1021, no reflection, no final XOR.
175
+ * Computed over the application payload (app header + data), NOT the transport header.
176
+ */
177
+ internal fun nimoCrc16(data: ByteArray): Int {
178
+ var crc = 0xFFFF
179
+ for (byte in data) {
180
+ crc = crc xor ((byte.toInt() and 0xFF) shl 8)
181
+ for (i in 0 until 8) {
182
+ crc =
183
+ if (crc and 0x8000 != 0) {
184
+ ((crc shl 1) xor 0x1021) and 0xFFFF
185
+ } else {
186
+ (crc shl 1) and 0xFFFF
187
+ }
188
+ }
189
+ }
190
+ return crc
191
+ }
192
+
193
+ // ---------- Frame Codec ----------
194
+
195
+ /**
196
+ * Pure bytes-in/bytes-out frame codec for the Nimo 0xBF transport.
197
+ *
198
+ * Frame layout (little-endian):
199
+ * - 8-byte transport header: [magic][status][len(2)][crc16(2)][index(2)]
200
+ * - 4-byte application header (requests): [cmd][key][len(2)]
201
+ * - responses/reports carry an extra status byte: [cmd][key][len(2)][status][data...]
202
+ */
203
+ internal object NimoFrameCodec {
204
+
205
+ fun transportHeader(payload: ByteArray, index: Int = 0, needsAck: Boolean = true): ByteArray {
206
+ val header = ByteArray(8)
207
+ header[0] = NimoProtocol.FRAME_MAGIC.toByte()
208
+ header[1] = (if (needsAck) NimoProtocol.STATUS_ACK else 0).toByte()
209
+ header[2] = (payload.size and 0xFF).toByte()
210
+ header[3] = ((payload.size shr 8) and 0xFF).toByte()
211
+ val crc = nimoCrc16(payload)
212
+ header[4] = (crc and 0xFF).toByte()
213
+ header[5] = ((crc shr 8) and 0xFF).toByte()
214
+ header[6] = (index and 0xFF).toByte()
215
+ header[7] = ((index shr 8) and 0xFF).toByte()
216
+ return header
217
+ }
218
+
219
+ fun applicationHeader(cmd: Int, key: Int, payloadSize: Int): ByteArray {
220
+ return byteArrayOf(
221
+ cmd.toByte(),
222
+ key.toByte(),
223
+ (payloadSize and 0xFF).toByte(),
224
+ ((payloadSize shr 8) and 0xFF).toByte()
225
+ )
226
+ }
227
+
228
+ /** One complete single frame: transport header + app header + payload. */
229
+ fun encodeFrame(
230
+ cmd: Int,
231
+ key: Int,
232
+ payload: ByteArray = ByteArray(0),
233
+ index: Int = 0,
234
+ needsAck: Boolean = true
235
+ ): ByteArray {
236
+ val appHeader = applicationHeader(cmd, key, payload.size)
237
+ val transportPayload = appHeader + payload
238
+ return transportHeader(transportPayload, index, needsAck) + transportPayload
239
+ }
240
+
241
+ /**
242
+ * Content update (cmd=0x07 key=0x04) blind-sliced into 501-byte chunks, each wrapped in its
243
+ * own transport header. Index rule: last chunk = 0, others = i+1 (first = 1); single chunk = 0.
244
+ */
245
+ fun updateContentFrames(
246
+ appId: Int,
247
+ layoutId: Int,
248
+ resId: Int,
249
+ resType: Int,
250
+ content: ByteArray,
251
+ chunkSize: Int = NimoBLE.CHUNK_SIZE
252
+ ): List<ByteArray> {
253
+ // App header payloadSize includes the 4-byte [appId][layoutId][resId][resType] prefix.
254
+ val appHeader =
255
+ applicationHeader(
256
+ NimoProtocol.CMD_CONTROL_INSTRUCTION,
257
+ NimoProtocol.CTRL_UPDATE_CONTENT,
258
+ 4 + content.size
259
+ )
260
+ val full =
261
+ appHeader +
262
+ byteArrayOf(
263
+ appId.toByte(),
264
+ layoutId.toByte(),
265
+ resId.toByte(),
266
+ resType.toByte()
267
+ ) +
268
+ content
269
+
270
+ val chunkCount = (full.size + chunkSize - 1) / chunkSize
271
+ val frames = mutableListOf<ByteArray>()
272
+ for (i in 0 until chunkCount) {
273
+ val start = i * chunkSize
274
+ val end = minOf(start + chunkSize, full.size)
275
+ val chunk = full.copyOfRange(start, end)
276
+ val isLast = i == chunkCount - 1
277
+ val index = if (isLast) 0 else i + 1
278
+ frames.add(transportHeader(chunk, index) + chunk)
279
+ }
280
+ return frames
281
+ }
282
+
283
+ /**
284
+ * 15-byte image header. [originalSize] MUST be the uncompressed pixel byte count
285
+ * (the glasses use it to allocate the decompression buffer).
286
+ */
287
+ fun imageHeader(
288
+ width: Int,
289
+ height: Int,
290
+ formatBpp: Int,
291
+ compression: Int,
292
+ originalSize: Int,
293
+ compressedSize: Int
294
+ ): ByteArray {
295
+ val p = ByteArray(15)
296
+ p[0] = NimoProtocol.COM_IMAGE_HEADER.toByte()
297
+ p[1] = (width and 0xFF).toByte()
298
+ p[2] = ((width shr 8) and 0xFF).toByte()
299
+ p[3] = (height and 0xFF).toByte()
300
+ p[4] = ((height shr 8) and 0xFF).toByte()
301
+ p[5] = formatBpp.toByte()
302
+ p[6] = compression.toByte()
303
+ p[7] = (originalSize and 0xFF).toByte()
304
+ p[8] = ((originalSize shr 8) and 0xFF).toByte()
305
+ p[9] = ((originalSize shr 16) and 0xFF).toByte()
306
+ p[10] = ((originalSize shr 24) and 0xFF).toByte()
307
+ p[11] = (compressedSize and 0xFF).toByte()
308
+ p[12] = ((compressedSize shr 8) and 0xFF).toByte()
309
+ p[13] = ((compressedSize shr 16) and 0xFF).toByte()
310
+ p[14] = ((compressedSize shr 24) and 0xFF).toByte()
311
+ return p
312
+ }
313
+
314
+ /**
315
+ * 9-byte device time: [year(2 LE)][month][day][hour][min][sec][week][zone]
316
+ * week: Sunday=0..Saturday=6; zone: signed, 15-minute units, clamped to ±48.
317
+ */
318
+ fun encodeDeviceTime(timeMillis: Long = System.currentTimeMillis()): ByteArray {
319
+ val cal = Calendar.getInstance()
320
+ cal.timeInMillis = timeMillis
321
+ val b = ByteArray(9)
322
+ val year = cal.get(Calendar.YEAR)
323
+ b[0] = (year and 0xFF).toByte()
324
+ b[1] = ((year shr 8) and 0xFF).toByte()
325
+ b[2] = (cal.get(Calendar.MONTH) + 1).toByte()
326
+ b[3] = cal.get(Calendar.DAY_OF_MONTH).toByte()
327
+ b[4] = cal.get(Calendar.HOUR_OF_DAY).toByte()
328
+ b[5] = cal.get(Calendar.MINUTE).toByte()
329
+ b[6] = cal.get(Calendar.SECOND).toByte()
330
+ // Calendar.DAY_OF_WEEK: Sunday=1..Saturday=7 → protocol Sunday=0..Saturday=6
331
+ b[7] = (cal.get(Calendar.DAY_OF_WEEK) - 1).toByte()
332
+ val offsetMinutes = TimeZone.getDefault().getOffset(timeMillis) / 60_000
333
+ val zone = (offsetMinutes / 15.0).toInt().coerceIn(-48, 48)
334
+ b[8] = (zone and 0xFF).toByte()
335
+ return b
336
+ }
337
+
338
+ /** A decoded frame. [cmd]/[key]/[statusCode]/[data] are null when the payload is too short. */
339
+ data class DecodedFrame(
340
+ val transportStatus: Int,
341
+ val index: Int,
342
+ val cmd: Int?,
343
+ val key: Int?,
344
+ val statusCode: Int?,
345
+ val data: ByteArray?
346
+ )
347
+
348
+ /**
349
+ * Decodes one complete frame (responses and reports both use the 5-byte
350
+ * [cmd][key][len(2)][status] application header). Returns null on transport
351
+ * error, bad CRC, or truncation.
352
+ */
353
+ fun decode(frame: ByteArray): DecodedFrame? {
354
+ if (frame.size < 8) return null
355
+ if ((frame[0].toInt() and 0xFF) != NimoProtocol.FRAME_MAGIC) return null
356
+ val transportStatus = frame[1].toInt() and 0xFF
357
+ val payloadLen = (frame[2].toInt() and 0xFF) or ((frame[3].toInt() and 0xFF) shl 8)
358
+ val crcValue = (frame[4].toInt() and 0xFF) or ((frame[5].toInt() and 0xFF) shl 8)
359
+ val index = (frame[6].toInt() and 0xFF) or ((frame[7].toInt() and 0xFF) shl 8)
360
+ if (frame.size < 8 + payloadLen) return null
361
+ if (transportStatus and NimoProtocol.STATUS_ERR != 0) return null
362
+
363
+ val payload = frame.copyOfRange(8, 8 + payloadLen)
364
+ if (nimoCrc16(payload) != crcValue) return null
365
+
366
+ if (payload.size < 5) {
367
+ return DecodedFrame(transportStatus, index, null, null, null, null)
368
+ }
369
+ val cmd = payload[0].toInt() and 0xFF
370
+ val key = payload[1].toInt() and 0xFF
371
+ val statusCode = payload[4].toInt() and 0xFF
372
+ val data = if (payload.size > 5) payload.copyOfRange(5, payload.size) else ByteArray(0)
373
+ return DecodedFrame(transportStatus, index, cmd, key, statusCode, data)
374
+ }
375
+ }
376
+
377
+ // ---------- Receive Assembler ----------
378
+
379
+ /**
380
+ * Reassembles multi-packet responses. Fragments are grouped by (cmd,key); each fragment carries
381
+ * the full 5-byte response app header. The first fragment (index==1) keeps its header, the
382
+ * continuation fragments (index>1) and the last fragment (index==0) contribute only their data
383
+ * sections, concatenated in ascending index order with the last (0) treated as largest. The
384
+ * merged message is re-framed (lengths and CRC recomputed) so [NimoFrameCodec.decode] can parse
385
+ * it. Single packets (index==0, no cached group) pass through unchanged.
386
+ */
387
+ internal class NimoReceiveAssembler {
388
+ private class Pending {
389
+ var firstAppPayload: ByteArray? = null
390
+ val dataByIndex = mutableMapOf<Int, ByteArray>()
391
+ val startTime = System.currentTimeMillis()
392
+ }
393
+
394
+ private val pending = mutableMapOf<Int, Pending>()
395
+
396
+ fun ingest(packet: ByteArray): List<ByteArray> {
397
+ if (packet.size < 8) return emptyList()
398
+ val payloadLen = (packet[2].toInt() and 0xFF) or ((packet[3].toInt() and 0xFF) shl 8)
399
+ val index = (packet[6].toInt() and 0xFF) or ((packet[7].toInt() and 0xFF) shl 8)
400
+ if (packet.size < 8 + payloadLen) return emptyList()
401
+ val appPayload = packet.copyOfRange(8, 8 + payloadLen)
402
+
403
+ if (appPayload.size < 2) {
404
+ return if (index == 0) listOf(packet) else emptyList()
405
+ }
406
+
407
+ val cmd = appPayload[0].toInt() and 0xFF
408
+ val key = appPayload[1].toInt() and 0xFF
409
+ val groupKey = (cmd shl 8) or key
410
+
411
+ if (index == 0) {
412
+ val p = pending.remove(groupKey) ?: return listOf(packet)
413
+ p.dataByIndex[Int.MAX_VALUE] = dataSection(appPayload)
414
+ return listOf(reframe(p))
415
+ }
416
+
417
+ if (index == 1) {
418
+ // A new first fragment while an old first fragment is cached means the previous
419
+ // round lost its last packet — drop the stale group and start over.
420
+ val existing = pending[groupKey]
421
+ if (existing?.firstAppPayload != null) {
422
+ pending.remove(groupKey)
423
+ }
424
+ }
425
+
426
+ val p = pending.getOrPut(groupKey) { Pending() }
427
+ if (index == 1) {
428
+ p.firstAppPayload = appPayload
429
+ }
430
+ p.dataByIndex[index] = dataSection(appPayload)
431
+ return emptyList()
432
+ }
433
+
434
+ fun cleanup(timeoutMs: Long = 10_000) {
435
+ val now = System.currentTimeMillis()
436
+ pending.entries.removeAll { now - it.value.startTime >= timeoutMs }
437
+ }
438
+
439
+ fun reset() = pending.clear()
440
+
441
+ private fun dataSection(appPayload: ByteArray): ByteArray {
442
+ return if (appPayload.size <= 5) ByteArray(0) else appPayload.copyOfRange(5, appPayload.size)
443
+ }
444
+
445
+ private fun reframe(p: Pending): ByteArray {
446
+ val merged = ByteArrayOutputStream()
447
+ for (k in p.dataByIndex.keys.sorted()) {
448
+ merged.write(p.dataByIndex[k]!!)
449
+ }
450
+ val mergedData = merged.toByteArray()
451
+ val first = p.firstAppPayload
452
+ val cmd = if (first != null && first.isNotEmpty()) first[0] else 0
453
+ val key = if (first != null && first.size >= 2) first[1] else 0
454
+ val status = if (first != null && first.size >= 5) first[4] else 0
455
+
456
+ val appPayload = ByteArray(5 + mergedData.size)
457
+ appPayload[0] = cmd
458
+ appPayload[1] = key
459
+ appPayload[2] = (mergedData.size and 0xFF).toByte()
460
+ appPayload[3] = ((mergedData.size shr 8) and 0xFF).toByte()
461
+ appPayload[4] = status
462
+ mergedData.copyInto(appPayload, 5)
463
+
464
+ return NimoFrameCodec.transportHeader(appPayload, index = 0) + appPayload
465
+ }
466
+ }
467
+
468
+ // ---------- Mic Audio Parser ----------
469
+
470
+ /**
471
+ * H-T-L-V parser for the mic channel (char 2025). Packets: [0x52][type][len(2 LE)][payload].
472
+ * For Opus packets the payload is [SN(2 LE)][frameCnt][reserved] then frameCnt frames of
473
+ * [frameLen(1)][body] where opusLen = body[3] and the Opus bytes are body[8 .. 8+opusLen].
474
+ * Slicing by opusLen (not frameLen-8) is mandatory — the tail holds CRC/padding bytes.
475
+ */
476
+ internal object NimoAudioParser {
477
+ const val HEADER = 0x52
478
+ const val TYPE_STOP = 0x00
479
+ const val TYPE_START = 0x01
480
+ const val TYPE_OPUS_LEFT = 0x02
481
+ const val TYPE_OPUS_RIGHT = 0x03
482
+
483
+ data class Packet(val type: Int, val sequence: Int, val opusFrames: List<ByteArray>)
484
+
485
+ fun parse(data: ByteArray): Packet? {
486
+ if (data.size < 4 || (data[0].toInt() and 0xFF) != HEADER) return null
487
+ val type = data[1].toInt() and 0xFF
488
+ val len = (data[2].toInt() and 0xFF) or ((data[3].toInt() and 0xFF) shl 8)
489
+ if (data.size < 4 + len) return Packet(type, 0, emptyList())
490
+ if (type != TYPE_OPUS_LEFT && type != TYPE_OPUS_RIGHT) return Packet(type, 0, emptyList())
491
+
492
+ val payload = data.copyOfRange(4, 4 + len)
493
+ if (payload.size < 4) return Packet(type, 0, emptyList())
494
+ val sn = (payload[0].toInt() and 0xFF) or ((payload[1].toInt() and 0xFF) shl 8)
495
+ val frameCnt = payload[2].toInt() and 0xFF
496
+ var offset = 4
497
+ val frames = mutableListOf<ByteArray>()
498
+ var i = 0
499
+ while (i < frameCnt && offset < payload.size) {
500
+ val frameLen = payload[offset].toInt() and 0xFF
501
+ offset += 1
502
+ if (offset + frameLen > payload.size) break
503
+ val body = payload.copyOfRange(offset, offset + frameLen)
504
+ offset += frameLen
505
+ i++
506
+ if (frameLen < 8) continue
507
+ val opusLen = body[3].toInt() and 0xFF
508
+ if (8 + opusLen > frameLen) continue
509
+ frames.add(body.copyOfRange(8, 8 + opusLen))
510
+ }
511
+ return Packet(type, sn, frames)
512
+ }
513
+ }
514
+
515
+ // ---------- Opus Decoder (MediaCodec) ----------
516
+
517
+ /**
518
+ * Decodes the glasses' 16 kHz mono Opus frames to 16-bit PCM via the platform
519
+ * MediaCodec "audio/opus" decoder, so no native Opus library has to be vendored.
520
+ */
521
+ private class NimoOpusDecoder(
522
+ private val sampleRate: Int = 16_000,
523
+ private val channels: Int = 1
524
+ ) {
525
+ private var codec: MediaCodec? = null
526
+ private var ptsUs: Long = 0
527
+
528
+ fun start(): Boolean {
529
+ if (codec != null) return true
530
+ return try {
531
+ val format =
532
+ MediaFormat.createAudioFormat(MediaFormat.MIMETYPE_AUDIO_OPUS, sampleRate, channels)
533
+ // csd-0: OpusHead identification header
534
+ val opusHead = ByteBuffer.allocate(19).order(ByteOrder.LITTLE_ENDIAN)
535
+ opusHead.put("OpusHead".toByteArray(Charsets.US_ASCII))
536
+ opusHead.put(1) // version
537
+ opusHead.put(channels.toByte())
538
+ opusHead.putShort(0) // pre-skip
539
+ opusHead.putInt(sampleRate)
540
+ opusHead.putShort(0) // output gain
541
+ opusHead.put(0) // mapping family
542
+ opusHead.flip()
543
+ format.setByteBuffer("csd-0", opusHead)
544
+ // csd-1/csd-2: 64-bit pre-skip and seek pre-roll in nanoseconds
545
+ format.setByteBuffer(
546
+ "csd-1",
547
+ ByteBuffer.allocate(8).order(ByteOrder.LITTLE_ENDIAN).putLong(0).apply { flip() }
548
+ )
549
+ format.setByteBuffer(
550
+ "csd-2",
551
+ ByteBuffer.allocate(8).order(ByteOrder.LITTLE_ENDIAN).putLong(0).apply { flip() }
552
+ )
553
+ val c = MediaCodec.createDecoderByType(MediaFormat.MIMETYPE_AUDIO_OPUS)
554
+ c.configure(format, null, null, 0)
555
+ c.start()
556
+ codec = c
557
+ ptsUs = 0
558
+ true
559
+ } catch (e: Exception) {
560
+ Bridge.log("NIMO: failed to start Opus decoder: ${e.message}")
561
+ codec = null
562
+ false
563
+ }
564
+ }
565
+
566
+ /** Decodes one Opus frame. May return empty while the codec is still buffering. */
567
+ fun decode(opusFrame: ByteArray): ByteArray {
568
+ val c = codec ?: return ByteArray(0)
569
+ val out = ByteArrayOutputStream()
570
+ try {
571
+ val inIdx = c.dequeueInputBuffer(10_000)
572
+ if (inIdx >= 0) {
573
+ val buf = c.getInputBuffer(inIdx) ?: return ByteArray(0)
574
+ buf.clear()
575
+ buf.put(opusFrame)
576
+ c.queueInputBuffer(inIdx, 0, opusFrame.size, ptsUs, 0)
577
+ ptsUs += 20_000 // 20 ms frames
578
+ }
579
+ val info = MediaCodec.BufferInfo()
580
+ var outIdx = c.dequeueOutputBuffer(info, 10_000)
581
+ while (outIdx >= 0) {
582
+ val buf = c.getOutputBuffer(outIdx)
583
+ if (buf != null && info.size > 0) {
584
+ val pcm = ByteArray(info.size)
585
+ buf.position(info.offset)
586
+ buf.get(pcm)
587
+ out.write(pcm)
588
+ }
589
+ c.releaseOutputBuffer(outIdx, false)
590
+ outIdx = c.dequeueOutputBuffer(info, 0)
591
+ }
592
+ } catch (e: Exception) {
593
+ Bridge.log("NIMO: Opus decode error: ${e.message}")
594
+ release()
595
+ }
596
+ return out.toByteArray()
597
+ }
598
+
599
+ fun release() {
600
+ try {
601
+ codec?.stop()
602
+ codec?.release()
603
+ } catch (_: Exception) {}
604
+ codec = null
605
+ }
606
+ }
607
+
608
+ // ---------- Audio Client ----------
609
+
610
+ /**
611
+ * Mic audio client, mirroring the vendor's GlassesAudioClient:
612
+ * - bounded FIFO of raw uplink packets (listener only enqueues; a sequential drain
613
+ * on a dedicated thread decodes one packet at a time); on overflow the oldest is
614
+ * dropped with a periodic warning (backpressure guard);
615
+ * - codec is initialized BEFORE the start command is sent (the glasses begin
616
+ * uplinking immediately) and on stop, processing halts and the queue is cleared
617
+ * BEFORE the codec is released (tail frames arrive after the stop command);
618
+ * - first-event diagnostics: one log each for first packet / first non-opus /
619
+ * first decode skip / first emitted frame, to locate where "frames=0" stalls;
620
+ * - frame duration varies (10 ms / 20 ms) — PCM length is never assumed.
621
+ */
622
+ private class NimoAudioClient(
623
+ private val sendCommand: (ByteArray) -> Unit,
624
+ private val onPcm: (ByteArray) -> Unit,
625
+ private val onActivity: () -> Unit,
626
+ ) {
627
+ companion object {
628
+ // Same cap as the vendor's maxPendingPackets.
629
+ private const val MAX_PENDING_PACKETS = 64
630
+ // Fall back to the other ear if the preferred one goes quiet for this long.
631
+ private const val SIDE_FALLBACK_MS = 2_000L
632
+ }
633
+
634
+ private var thread: HandlerThread? = null
635
+ private var handler: Handler? = null
636
+ private var decoder: NimoOpusDecoder? = null
637
+ @Volatile private var started = false
638
+ private val pendingCount = java.util.concurrent.atomic.AtomicInteger(0)
639
+ private var droppedCount = 0
640
+ private var lastRightPacketMs = 0L
641
+ private var preferRight = true
642
+
643
+ private var logFirstPacket = true
644
+ private var logFirstNonOpus = true
645
+ private var logFirstSkip = true
646
+ private var logFirstEmit = true
647
+
648
+ fun start() {
649
+ if (started) {
650
+ // Already running: just re-send the start command (idempotent on the
651
+ // firmware) so a watchdog-triggered re-enable can recover a real outage.
652
+ sendCommand(byteArrayOf(0x52, 0x01, 0x00, 0x00))
653
+ return
654
+ }
655
+ val t = HandlerThread("NimoAudio")
656
+ t.start()
657
+ thread = t
658
+ handler = Handler(t.looper)
659
+ decoder =
660
+ NimoOpusDecoder().also {
661
+ if (!it.start()) Bridge.log("NIMO: audio: Opus decoder failed to start")
662
+ }
663
+ logFirstPacket = true
664
+ logFirstNonOpus = true
665
+ logFirstSkip = true
666
+ logFirstEmit = true
667
+ droppedCount = 0
668
+ // started=true BEFORE the start command: the glasses begin uplinking the
669
+ // moment they receive it; otherwise the first frames would be discarded.
670
+ started = true
671
+ sendCommand(byteArrayOf(0x52, 0x01, 0x00, 0x00))
672
+ }
673
+
674
+ fun stop() {
675
+ if (!started) return
676
+ // Halt processing and drop in-flight frames BEFORE releasing the decoder —
677
+ // the glasses keep sending a few frames after the stop command, and decoding
678
+ // on a released codec would throw.
679
+ started = false
680
+ handler?.removeCallbacksAndMessages(null)
681
+ pendingCount.set(0)
682
+ sendCommand(byteArrayOf(0x52, 0x00, 0x00, 0x00))
683
+ val h = handler
684
+ val t = thread
685
+ val d = decoder
686
+ handler = null
687
+ thread = null
688
+ decoder = null
689
+ if (h != null) {
690
+ h.post {
691
+ d?.release()
692
+ t?.quitSafely()
693
+ }
694
+ } else {
695
+ d?.release()
696
+ t?.quitSafely()
697
+ }
698
+ }
699
+
700
+ /** Called from the GATT notify callback (binder thread): enqueue only. */
701
+ fun enqueue(bytes: ByteArray) {
702
+ if (!started) return
703
+ val h = handler ?: return
704
+ if (pendingCount.get() >= MAX_PENDING_PACKETS) {
705
+ droppedCount++
706
+ if (droppedCount == 1 || droppedCount % 50 == 0) {
707
+ Bridge.log(
708
+ "NIMO: audio backpressure: queue full ($MAX_PENDING_PACKETS), dropped packet (total=$droppedCount) — decode lagging capture"
709
+ )
710
+ }
711
+ return
712
+ }
713
+ pendingCount.incrementAndGet()
714
+ h.post {
715
+ pendingCount.decrementAndGet()
716
+ process(bytes)
717
+ }
718
+ }
719
+
720
+ // Flow stats: one summary line every 5s while audio streams, including the
721
+ // DeviceManager forwarding flags so "is audio reaching the cloud" is visible.
722
+ private var statsPackets = 0
723
+ private var statsFrames = 0
724
+ private var statsPcmBytes = 0
725
+ private var statsLastReportMs = 0L
726
+
727
+ private fun reportStats(now: Long) {
728
+ if (now - statsLastReportMs < 5_000) return
729
+ if (statsLastReportMs != 0L) {
730
+ val sendLc3 = DeviceStore.get("bluetooth", "should_send_lc3") as? Boolean ?: false
731
+ val sendPcm = DeviceStore.get("bluetooth", "should_send_pcm") as? Boolean ?: false
732
+ val sendTranscript =
733
+ DeviceStore.get("bluetooth", "should_send_transcript") as? Boolean ?: false
734
+ Bridge.log(
735
+ "NIMO: audio: flowing — $statsPackets pkts, $statsFrames frames, $statsPcmBytes PCM bytes in 5s (cloud: lc3=$sendLc3 pcm=$sendPcm transcript=$sendTranscript)"
736
+ )
737
+ }
738
+ statsPackets = 0
739
+ statsFrames = 0
740
+ statsPcmBytes = 0
741
+ statsLastReportMs = now
742
+ }
743
+
744
+ /** Runs on the audio thread, one packet at a time (sequential drain). */
745
+ private fun process(bytes: ByteArray) {
746
+ if (!started) return
747
+ if (logFirstPacket) {
748
+ logFirstPacket = false
749
+ Bridge.log("NIMO: audio: first uplink packet (${bytes.size} bytes)")
750
+ }
751
+ statsPackets++
752
+ reportStats(System.currentTimeMillis())
753
+ val packet = NimoAudioParser.parse(bytes)
754
+ if (packet == null ||
755
+ (packet.type != NimoAudioParser.TYPE_OPUS_LEFT &&
756
+ packet.type != NimoAudioParser.TYPE_OPUS_RIGHT)
757
+ ) {
758
+ if (logFirstNonOpus) {
759
+ logFirstNonOpus = false
760
+ Bridge.log(
761
+ "NIMO: audio: first packet not an opus frame (parsed=${packet != null}, type=${packet?.type})"
762
+ )
763
+ }
764
+ return
765
+ }
766
+ // Any parsed opus packet counts as glasses-audio activity for the watchdog.
767
+ onActivity()
768
+
769
+ val now = System.currentTimeMillis()
770
+ when (packet.type) {
771
+ NimoAudioParser.TYPE_OPUS_RIGHT -> {
772
+ lastRightPacketMs = now
773
+ preferRight = true
774
+ }
775
+ NimoAudioParser.TYPE_OPUS_LEFT -> {
776
+ // Only fall back to the left ear when the right has gone quiet —
777
+ // forwarding both would duplicate the audio.
778
+ if (preferRight && now - lastRightPacketMs < SIDE_FALLBACK_MS) return
779
+ preferRight = false
780
+ }
781
+ }
782
+
783
+ val codec = decoder ?: return
784
+ for (opusFrame in packet.opusFrames) {
785
+ // Frame duration varies (10 ms = 160 samples, 20 ms = 320 samples at
786
+ // 16 kHz) — accept whatever the decoder returns, never assume a length.
787
+ val pcm48 = codec.decode(opusFrame)
788
+ if (pcm48.isEmpty()) {
789
+ if (logFirstSkip) {
790
+ logFirstSkip = false
791
+ Bridge.log("NIMO: audio: first frame skipped at decode (opusLen=${opusFrame.size})")
792
+ }
793
+ continue
794
+ }
795
+ // MediaCodec's Opus decoder always outputs 48 kHz regardless of the
796
+ // OpusHead rate; the MentraOS pipeline expects 16 kHz mono → decimate by 3.
797
+ val pcm16 = downsample48kTo16k(pcm48)
798
+ if (pcm16.isEmpty()) continue
799
+ if (logFirstEmit) {
800
+ logFirstEmit = false
801
+ Bridge.log("NIMO: audio: first decoded PCM frame emitted (${pcm16.size} bytes @16kHz)")
802
+ }
803
+ statsFrames++
804
+ statsPcmBytes += pcm16.size
805
+ onPcm(pcm16)
806
+ }
807
+ }
808
+
809
+ /** 48 kHz → 16 kHz: average each group of 3 samples (cheap low-pass + decimate). */
810
+ private fun downsample48kTo16k(pcm48: ByteArray): ByteArray {
811
+ val inSamples = pcm48.size / 2
812
+ val outSamples = inSamples / 3
813
+ if (outSamples == 0) return ByteArray(0)
814
+ val out = ByteArray(outSamples * 2)
815
+ var i = 0
816
+ for (o in 0 until outSamples) {
817
+ var sum = 0
818
+ for (k in 0 until 3) {
819
+ val lo = pcm48[i].toInt() and 0xFF
820
+ val hi = pcm48[i + 1].toInt() // sign-extended high byte
821
+ sum += (hi shl 8) or lo
822
+ i += 2
823
+ }
824
+ val sample = (sum / 3).coerceIn(-32768, 32767)
825
+ out[o * 2] = (sample and 0xFF).toByte()
826
+ out[o * 2 + 1] = ((sample shr 8) and 0xFF).toByte()
827
+ }
828
+ return out
829
+ }
830
+ }
831
+
832
+ // ---------- Reconnection Manager ----------
833
+
834
+ private class NimoReconnectionManager(
835
+ private val intervalMs: Long = 30_000L,
836
+ // First attempt comes quickly so a transient failure during pairing
837
+ // self-heals well inside the pairing timeout.
838
+ private val firstDelayMs: Long = 5_000L,
839
+ private val maxAttempts: Int = -1 // -1 for unlimited
840
+ ) {
841
+ private val handler = Handler(Looper.getMainLooper())
842
+ private var runnable: Runnable? = null
843
+ private var attempts = 0
844
+
845
+ fun start(onAttempt: () -> Boolean) {
846
+ stop()
847
+ attempts = 0
848
+ val r =
849
+ object : Runnable {
850
+ override fun run() {
851
+ if (maxAttempts > 0 && attempts >= maxAttempts) {
852
+ Bridge.log("NIMO: Max reconnection attempts ($maxAttempts) reached")
853
+ stop()
854
+ return
855
+ }
856
+ attempts++
857
+ Bridge.log("NIMO: Reconnection attempt $attempts")
858
+ if (onAttempt()) {
859
+ Bridge.log("NIMO: Reconnection successful, stopping")
860
+ stop()
861
+ return
862
+ }
863
+ handler.postDelayed(this, intervalMs)
864
+ }
865
+ }
866
+ runnable = r
867
+ handler.postDelayed(r, firstDelayMs)
868
+ }
869
+
870
+ fun stop() {
871
+ runnable?.let { handler.removeCallbacks(it) }
872
+ runnable = null
873
+ attempts = 0
874
+ }
875
+ }
876
+
877
+ // ---------- Nimo Class ----------
878
+
879
+ class Nimo : SGCManager() {
880
+
881
+ companion object {
882
+ private const val PREFS_NAME = "NimoPrefs"
883
+ private const val KEY_LAST_ADDRESS = "nimo_lastDeviceAddress"
884
+ private const val KEY_LAST_NAME = "nimo_lastDeviceName"
885
+
886
+ private const val TWS_TIMEOUT_MS = 10_000L
887
+ private const val ACK_TIMEOUT_MS = 5_000L
888
+ // Generous: the glasses' classic bond can take 30-45s PER attempt and may need
889
+ // a retry, so this must cover several bond attempts + discovery + the 10s TWS gate.
890
+ private const val PAIRING_TIMEOUT_MS = 120_000L
891
+ // Settle time after BOND_BONDED before the first GATT connect — connecting
892
+ // immediately races the BR/EDR teardown + CTKD finalization (status 133).
893
+ // Measured: 1.5s was too short (the first connect-after-bond still hit 133 and
894
+ // only the 5s reconnection timer recovered it); 5s lets the first connect land.
895
+ private const val POST_BOND_SETTLE_MS = 5_000L
896
+ // The glasses' BR/EDR bond is slow and flaky — it can report BOND_NONE mid-pairing
897
+ // yet succeed on a retry, so don't treat the first failure as terminal.
898
+ private const val MAX_BOND_ATTEMPTS = 3
899
+ private const val BOND_RETRY_DELAY_MS = 2_000L
900
+ private const val BATTERY_POLL_MS = 30_000L
901
+ private const val TEXT_QUEUE_TICK_MS = 100L
902
+ private const val WRITE_WATCHDOG_MS = 1_000L
903
+ private const val SET_TIME_MAX_ATTEMPTS = 3
904
+ private const val SET_TIME_RETRY_DELAY_MS = 500L
905
+ }
906
+
907
+ init {
908
+ type = DeviceTypes.NIMO
909
+ hasMic = true
910
+ }
911
+
912
+ // The text surface every sendTextWall renders into. The ASR note view (appId 0x04,
913
+ // note text resId 0x00) is a plain text page that renders pushed text directly;
914
+ // Prompter (0x06) was tried first but did not display pushed text on hardware.
915
+ private val textAppId = NimoProtocol.APP_ID_ASR_NOTE
916
+ private val textLayoutId = 0
917
+ private val textResId = 0
918
+
919
+ // BLE
920
+ private val context: Context
921
+ get() = Bridge.getContext()
922
+ private val bluetoothAdapter: BluetoothAdapter? = BluetoothAdapter.getDefaultAdapter()
923
+ private var gatt: BluetoothGatt? = null
924
+ private var txChar: BluetoothGattCharacteristic? = null
925
+ private var rxChar: BluetoothGattCharacteristic? = null
926
+ private var micChar: BluetoothGattCharacteristic? = null
927
+ private var isDisconnecting = false
928
+ private var negotiatedMtu = 23
929
+
930
+ // Device search
931
+ private var DEVICE_SEARCH_ID = "NOT_SET"
932
+
933
+ private var lastDeviceAddress: String?
934
+ get() =
935
+ context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
936
+ .getString(KEY_LAST_ADDRESS, null)
937
+ set(value) {
938
+ context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE).edit().apply {
939
+ if (value != null) putString(KEY_LAST_ADDRESS, value) else remove(KEY_LAST_ADDRESS)
940
+ apply()
941
+ }
942
+ }
943
+
944
+ private var lastDeviceName: String?
945
+ get() =
946
+ context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
947
+ .getString(KEY_LAST_NAME, null)
948
+ set(value) {
949
+ context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE).edit().apply {
950
+ if (value != null) putString(KEY_LAST_NAME, value) else remove(KEY_LAST_NAME)
951
+ apply()
952
+ }
953
+ }
954
+
955
+ private val reconnectionManager = NimoReconnectionManager()
956
+ private val receiveAssembler = NimoReceiveAssembler()
957
+ private val mainHandler = Handler(Looper.getMainLooper())
958
+
959
+ // Handshake
960
+ private enum class HandshakeState {
961
+ IDLE,
962
+ AWAITING_TWS,
963
+ AWAITING_TIME_ACK,
964
+ READY
965
+ }
966
+ private var handshakeState = HandshakeState.IDLE
967
+ private var twsConnected = false
968
+ private var twsTimeoutRunnable: Runnable? = null
969
+ private var pairingTimeoutRunnable: Runnable? = null
970
+
971
+ // Pending acks keyed by (cmd shl 8) or key
972
+ private class PendingAck(val onResult: (Boolean) -> Unit, val timeout: Runnable)
973
+ private val pendingAcks = mutableMapOf<Int, PendingAck>()
974
+
975
+ // Serialized write queue: Android allows one in-flight GATT op; WRITE_TYPE_DEFAULT also
976
+ // gives us long-writes when a 509-byte frame exceeds the negotiated MTU.
977
+ private class QueuedWrite(val char: BluetoothGattCharacteristic, val bytes: ByteArray)
978
+ private val writeQueue = ArrayDeque<QueuedWrite>()
979
+ private var writeInFlight = false
980
+ private var writeWatchdog: Runnable? = null
981
+
982
+ // Heartbeat / text queue
983
+ private var batteryPollRunnable: Runnable? = null
984
+ private var textQueueRunnable: Runnable? = null
985
+ private var pendingText: String? = null
986
+ private var textAppEntered = false
987
+ private var navAppEntered = false
988
+ private var currentGlassesAppId = -1
989
+
990
+ // Battery
991
+ private var lastBatteryLevel = -1
992
+ private var lastCharging = false
993
+
994
+ // Version info
995
+ private var firmwareVersionPacked: String = ""
996
+ private var firmwareVersionDetail: String = ""
997
+
998
+ // Mic audio (vendor GlassesAudioClient structure)
999
+ private val audioClient =
1000
+ NimoAudioClient(
1001
+ sendCommand = { bytes -> micChar?.let { enqueueWrite(it, bytes) } },
1002
+ onPcm = { pcm -> DeviceManager.getInstance().handlePcm(pcm) },
1003
+ onActivity = { DeviceManager.getInstance().reportGlassesAudioActivity() }
1004
+ )
1005
+
1006
+ // ---------- SGCManager: Connection Management ----------
1007
+
1008
+ override fun findCompatibleDevices() {
1009
+ Bridge.log("NIMO: findCompatibleDevices()")
1010
+ DEVICE_SEARCH_ID = "NOT_SET"
1011
+ DeviceStore.apply("glasses", "connectionState", ConnTypes.SCANNING)
1012
+ startScan()
1013
+ }
1014
+
1015
+ override fun connectById(id: String) {
1016
+ Bridge.log("NIMO: connectById($id)")
1017
+ DEVICE_SEARCH_ID = id
1018
+ DeviceStore.apply("glasses", "connectionState", ConnTypes.CONNECTING)
1019
+ isDisconnecting = false
1020
+ bondAttempts = 0
1021
+ startPairingTimeout()
1022
+ // Bonded target → connect directly, WITHOUT starting classic discovery.
1023
+ // Inquiry monopolizes the BR/EDR radio and kills an in-flight GATT page
1024
+ // (status 133), so discovery must only run when the device is unknown.
1025
+ if (connectToBondedTarget()) return
1026
+ if (connectByAddress()) return
1027
+ startScan()
1028
+ }
1029
+
1030
+ private fun connectToBondedTarget(): Boolean {
1031
+ val adapter = bluetoothAdapter ?: return false
1032
+ val target =
1033
+ try {
1034
+ adapter.bondedDevices?.firstOrNull { it.name == DEVICE_SEARCH_ID }
1035
+ } catch (e: SecurityException) {
1036
+ null
1037
+ }
1038
+ ?: return false
1039
+ Bridge.log("NIMO: target is bonded — connecting directly (no discovery)")
1040
+ lastDeviceName = target.name
1041
+ lastDeviceAddress = target.address
1042
+ mainHandler.post { connectGattLe(target) }
1043
+ return true
1044
+ }
1045
+
1046
+ override fun stopScan() {
1047
+ try {
1048
+ bluetoothAdapter?.cancelDiscovery()
1049
+ } catch (_: Exception) {}
1050
+ discoveryReceiver?.let {
1051
+ try {
1052
+ context.unregisterReceiver(it)
1053
+ } catch (_: Exception) {}
1054
+ }
1055
+ discoveryReceiver = null
1056
+ }
1057
+
1058
+ override fun disconnect() {
1059
+ Bridge.log("NIMO: disconnect()")
1060
+ isDisconnecting = true
1061
+ cancelPairingTimeout()
1062
+ cancelTwsTimeout()
1063
+ stopScan()
1064
+ unregisterBondReceiver()
1065
+ stopTimers()
1066
+ reconnectionManager.stop()
1067
+ failAllPendingAcks()
1068
+
1069
+ gatt?.disconnect()
1070
+ gatt?.close()
1071
+ gatt = null
1072
+ txChar = null
1073
+ rxChar = null
1074
+ micChar = null
1075
+ resetSessionState()
1076
+
1077
+ DeviceStore.apply("glasses", "connected", false)
1078
+ DeviceStore.apply("glasses", "fullyBooted", false)
1079
+ DeviceStore.apply("glasses", "connectionState", ConnTypes.DISCONNECTED)
1080
+ }
1081
+
1082
+ override fun forget() {
1083
+ Bridge.log("NIMO: forget()")
1084
+ disconnect()
1085
+ lastDeviceAddress = null
1086
+ lastDeviceName = null
1087
+ DEVICE_SEARCH_ID = "NOT_SET"
1088
+ }
1089
+
1090
+ override fun cleanup() {
1091
+ disconnect()
1092
+ audioClient.stop()
1093
+ }
1094
+
1095
+ override fun getConnectedBluetoothName(): String {
1096
+ return gatt?.device?.name ?: ""
1097
+ }
1098
+
1099
+ override fun ping() {
1100
+ sendFrame(NimoFrameCodec.encodeFrame(NimoProtocol.CMD_GET_PARAMETER, NimoProtocol.GET_TWS_STATUS))
1101
+ }
1102
+
1103
+ override fun dbg1() {}
1104
+ override fun dbg2() {}
1105
+
1106
+ // ---------- SGCManager: Audio Control ----------
1107
+
1108
+ override fun setMicEnabled(enabled: Boolean) {
1109
+ Bridge.log("NIMO: setMicEnabled($enabled)")
1110
+ if (micChar == null) {
1111
+ Bridge.log("NIMO: mic characteristic not available")
1112
+ return
1113
+ }
1114
+ DeviceStore.apply("glasses", "micEnabled", enabled)
1115
+ if (enabled) {
1116
+ audioClient.start()
1117
+ } else {
1118
+ audioClient.stop()
1119
+ }
1120
+ }
1121
+
1122
+ override fun sortMicRanking(list: MutableList<String>): MutableList<String> {
1123
+ return list
1124
+ }
1125
+
1126
+ // ---------- SGCManager: Display Control ----------
1127
+
1128
+ override fun setBrightness(level: Int, autoMode: Boolean) {
1129
+ Bridge.log("NIMO: setBrightness($level, auto=$autoMode)")
1130
+ sendFrame(
1131
+ NimoFrameCodec.encodeFrame(
1132
+ NimoProtocol.CMD_SET_PARAMETER,
1133
+ NimoProtocol.SET_AUTO_BRIGHTNESS,
1134
+ byteArrayOf(if (autoMode) 1 else 0)
1135
+ )
1136
+ )
1137
+ if (!autoMode) {
1138
+ // The firmware takes a 0–16 level, not a 0–100 percent.
1139
+ val lvl =
1140
+ Math.round(level.coerceIn(0, 100) / 100.0 * NimoProtocol.MAX_BRIGHTNESS_LEVEL)
1141
+ .toInt()
1142
+ sendFrame(
1143
+ NimoFrameCodec.encodeFrame(
1144
+ NimoProtocol.CMD_SET_PARAMETER,
1145
+ NimoProtocol.SET_BRIGHTNESS,
1146
+ byteArrayOf(lvl.toByte())
1147
+ )
1148
+ )
1149
+ }
1150
+ }
1151
+
1152
+ override fun clearDisplay() {
1153
+ // Keep the text app/page alive — a blank update avoids re-entering the app next time.
1154
+ sendTextWall(" ")
1155
+ }
1156
+
1157
+ override fun sendText(text: String) {
1158
+ // Coalesced: only the most recent pending text survives until the next 100ms drain.
1159
+ Bridge.log("NIMO: sendText(text=$text)")
1160
+ pendingText = text
1161
+ }
1162
+
1163
+ override fun sendTextWall(text: String) {
1164
+ // Coalesced: only the most recent pending text survives until the next 100ms drain.
1165
+ Bridge.log("NIMO: sendTextWall(text=$text)")
1166
+ pendingText = text
1167
+ }
1168
+
1169
+ override fun sendDoubleTextWall(top: String, bottom: String) {
1170
+ sendTextWall(top + "\n\n" + bottom)
1171
+ }
1172
+
1173
+ override fun sendPositionedText(
1174
+ text: String,
1175
+ x: Int,
1176
+ y: Int,
1177
+ width: Int,
1178
+ height: Int,
1179
+ borderWidth: Int,
1180
+ borderRadius: Int
1181
+ ) {
1182
+ // Navigation pushes turn text via positioned_text. Nimo widgets have fixed geometry, so
1183
+ // position/border are ignored — funnel the text through the same coalesced path as
1184
+ // sendTextWall so it renders on the ASR note page. Without this override the base no-op
1185
+ // silently dropped all navigation text.
1186
+ // Bridge.log("NIMO: sendPositionedText(text=$text)")
1187
+ // pendingText = text
1188
+ }
1189
+
1190
+ override fun displayBitmap(
1191
+ base64ImageData: String,
1192
+ x: Int?,
1193
+ y: Int?,
1194
+ width: Int?,
1195
+ height: Int?
1196
+ ): Boolean {
1197
+ // Nimo widgets have fixed geometry; x/y are not honored. Renders into the navigation
1198
+ // large-map widget (appId 0x01, resId 0x05, 452x170 2bpp) — the full-width nav map,
1199
+ // which shows far more than the small 160x160 mini-map (resId 0x00). bitmapToGrayscale
1200
+ // aspect-fits onto black, so a non-matching source aspect letterboxes rather than
1201
+ // distorts. The nav app must be foregrounded before pushing content (mirrors the text
1202
+ // path). TODO: hardware-verify the large-map widget renders and the nav app-mode.
1203
+ val targetWidth = NimoProtocol.NAV_LARGE_MAP_WIDTH
1204
+ val targetHeight = NimoProtocol.NAV_LARGE_MAP_HEIGHT
1205
+ Bridge.log("NIMO: displayBitmap → nav large map (navAppEntered=$navAppEntered)")
1206
+ return try {
1207
+ val imageBytes = Base64.decode(base64ImageData, Base64.DEFAULT)
1208
+ val bitmap =
1209
+ BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size) ?: return false
1210
+ val grayscale = bitmapToGrayscale(bitmap, targetWidth, targetHeight)
1211
+ val packed = packL8To2bpp(grayscale)
1212
+ val (payload, compression) = compressAdaptive(packed)
1213
+ if (!navAppEntered) {
1214
+ sendFrame(
1215
+ NimoFrameCodec.encodeFrame(
1216
+ NimoProtocol.CMD_CONTROL_INSTRUCTION,
1217
+ NimoProtocol.CTRL_ENTER_APP,
1218
+ byteArrayOf(
1219
+ NimoProtocol.APP_ID_NAV.toByte(),
1220
+ NimoProtocol.APP_MODE_STANDALONE.toByte()
1221
+ )
1222
+ )
1223
+ )
1224
+ // Optimistic; corrected by app-state reports if the glasses refuse/exit.
1225
+ navAppEntered = true
1226
+ }
1227
+ val content =
1228
+ NimoFrameCodec.imageHeader(
1229
+ width = targetWidth,
1230
+ height = targetHeight,
1231
+ formatBpp = NimoProtocol.FORMAT_2BPP,
1232
+ compression = compression,
1233
+ originalSize = packed.size,
1234
+ compressedSize =
1235
+ if (compression == NimoProtocol.COMPRESSION_NONE) 0
1236
+ else payload.size
1237
+ ) + payload
1238
+ val frames =
1239
+ NimoFrameCodec.updateContentFrames(
1240
+ appId = NimoProtocol.APP_ID_NAV,
1241
+ layoutId = 0,
1242
+ resId = NimoProtocol.NAV_RES_LARGE_MAP,
1243
+ resType = NimoProtocol.WIDGET_PICTURE,
1244
+ content = content
1245
+ )
1246
+ enqueueFrames(frames)
1247
+ true
1248
+ } catch (e: Exception) {
1249
+ Bridge.log("NIMO: displayBitmap failed: ${e.message}")
1250
+ false
1251
+ }
1252
+ }
1253
+
1254
+ override fun showDashboard() {
1255
+ Bridge.log("NIMO: showDashboard()")
1256
+ textAppEntered = false
1257
+ navAppEntered = false
1258
+ sendFrame(
1259
+ NimoFrameCodec.encodeFrame(
1260
+ NimoProtocol.CMD_CONTROL_INSTRUCTION,
1261
+ NimoProtocol.CTRL_ENTER_APP,
1262
+ byteArrayOf(
1263
+ NimoProtocol.APP_ID_DASHBOARD.toByte(),
1264
+ NimoProtocol.APP_MODE_STANDALONE.toByte()
1265
+ )
1266
+ )
1267
+ )
1268
+ }
1269
+
1270
+ override fun setDashboardPosition(height: Int, depth: Int) {
1271
+ Bridge.log("NIMO: setDashboardPosition($height, $depth)")
1272
+ sendFrame(
1273
+ NimoFrameCodec.encodeFrame(
1274
+ NimoProtocol.CMD_SET_PARAMETER,
1275
+ NimoProtocol.SET_HEIGHT_LEVEL,
1276
+ byteArrayOf(height.coerceIn(0, 10).toByte())
1277
+ )
1278
+ )
1279
+ sendFrame(
1280
+ NimoFrameCodec.encodeFrame(
1281
+ NimoProtocol.CMD_SET_PARAMETER,
1282
+ NimoProtocol.SET_DISTANCE,
1283
+ byteArrayOf(depth.coerceIn(0, 10).toByte())
1284
+ )
1285
+ )
1286
+ }
1287
+
1288
+ // ---------- SGCManager: Device Control ----------
1289
+
1290
+ override fun setHeadUpAngle(angle: Int) {
1291
+ val clamped = angle.coerceIn(0, 90)
1292
+ Bridge.log("NIMO: setHeadUpAngle($clamped)")
1293
+ // Enable the head-up display gesture, then set the wake angle.
1294
+ sendFrame(
1295
+ NimoFrameCodec.encodeFrame(
1296
+ NimoProtocol.CMD_SET_PARAMETER,
1297
+ NimoProtocol.SET_HEADUP_DISPLAY,
1298
+ byteArrayOf(1)
1299
+ )
1300
+ )
1301
+ // setAngle payload is [optType, deg]. TODO: hardware-verify optType semantics.
1302
+ sendFrame(
1303
+ NimoFrameCodec.encodeFrame(
1304
+ NimoProtocol.CMD_SET_PARAMETER,
1305
+ NimoProtocol.SET_ANGLE,
1306
+ byteArrayOf(0x01, clamped.toByte())
1307
+ )
1308
+ )
1309
+ }
1310
+
1311
+ override fun getBatteryStatus() {
1312
+ sendFrame(NimoFrameCodec.encodeFrame(NimoProtocol.CMD_GET_PARAMETER, NimoProtocol.GET_BATTERY))
1313
+ }
1314
+
1315
+ override fun setSilentMode(enabled: Boolean) {
1316
+ Bridge.log("NIMO: setSilentMode($enabled)")
1317
+ // TODO: hardware-verify that display-off matches MentraOS silent-mode semantics.
1318
+ sendFrame(
1319
+ NimoFrameCodec.encodeFrame(
1320
+ NimoProtocol.CMD_SET_PARAMETER,
1321
+ NimoProtocol.SET_DISPLAY_OFF,
1322
+ byteArrayOf(if (enabled) 1 else 0)
1323
+ )
1324
+ )
1325
+ }
1326
+
1327
+ override fun exit() {
1328
+ Bridge.log("NIMO: exit()")
1329
+ val appId = if (currentGlassesAppId >= 0) currentGlassesAppId else textAppId
1330
+ textAppEntered = false
1331
+ navAppEntered = false
1332
+ sendFrame(
1333
+ NimoFrameCodec.encodeFrame(
1334
+ NimoProtocol.CMD_CONTROL_INSTRUCTION,
1335
+ NimoProtocol.CTRL_QUIT_APP,
1336
+ byteArrayOf(appId.toByte())
1337
+ )
1338
+ )
1339
+ }
1340
+
1341
+ override fun sendShutdown() {
1342
+ Bridge.log("NIMO: sendShutdown - not supported")
1343
+ }
1344
+
1345
+ override fun sendReboot() {
1346
+ Bridge.log("NIMO: sendReboot - not supported")
1347
+ }
1348
+
1349
+ override fun sendRgbLedControl(
1350
+ requestId: String,
1351
+ packageName: String?,
1352
+ action: String,
1353
+ color: String?,
1354
+ onDurationMs: Int,
1355
+ offDurationMs: Int,
1356
+ count: Int
1357
+ ) {
1358
+ Bridge.sendRgbLedControlResponse(requestId, false, "device_not_supported")
1359
+ }
1360
+
1361
+ // ---------- Notifications ----------
1362
+
1363
+ /**
1364
+ * Pushes a notification (cmd=0x09 key=0x01, fixed 319-byte struct:
1365
+ * appId(1) + title(51, UTF-8 zero-padded) + time(9) + contentLen(2 LE) + content(256)).
1366
+ */
1367
+ fun sendNotification(notificationAppId: Int, title: String, content: String) {
1368
+ val payload = ByteArray(319)
1369
+ payload[0] = notificationAppId.coerceIn(0, 255).toByte()
1370
+ writeUtf8Padded(payload, 1, title, 51)
1371
+ NimoFrameCodec.encodeDeviceTime().copyInto(payload, 52)
1372
+ val contentBytes = content.toByteArray(Charsets.UTF_8)
1373
+ val clen = minOf(contentBytes.size, 256)
1374
+ payload[61] = (clen and 0xFF).toByte()
1375
+ payload[62] = ((clen shr 8) and 0xFF).toByte()
1376
+ writeUtf8Padded(payload, 63, content, 256)
1377
+ sendFrame(
1378
+ NimoFrameCodec.encodeFrame(
1379
+ NimoProtocol.CMD_CONTROL_NOTIFICATION,
1380
+ NimoProtocol.NOTIFICATION_SEND,
1381
+ payload
1382
+ )
1383
+ )
1384
+ }
1385
+
1386
+ private fun writeUtf8Padded(dest: ByteArray, offset: Int, text: String, maxLen: Int) {
1387
+ var bytes = text.toByteArray(Charsets.UTF_8)
1388
+ if (bytes.size > maxLen) {
1389
+ // Truncate on a UTF-8 boundary
1390
+ var end = maxLen
1391
+ while (end > 0 && (bytes[end].toInt() and 0xC0) == 0x80) end--
1392
+ bytes = bytes.copyOfRange(0, end)
1393
+ }
1394
+ bytes.copyInto(dest, offset)
1395
+ }
1396
+
1397
+ // ---------- SGCManager: Camera & Media (no camera) ----------
1398
+
1399
+ override fun requestPhoto(request: PhotoRequest) {
1400
+ Bridge.log("NIMO: requestPhoto - not supported (no camera)")
1401
+ }
1402
+
1403
+ override fun startStream(message: MutableMap<String, Any>) {
1404
+ Bridge.log("NIMO: startStream - not supported")
1405
+ }
1406
+
1407
+ override fun stopStream() {
1408
+ Bridge.log("NIMO: stopStream - not supported")
1409
+ }
1410
+
1411
+ override fun sendStreamKeepAlive(message: MutableMap<String, Any>) {
1412
+ Bridge.log("NIMO: sendStreamKeepAlive - not supported")
1413
+ }
1414
+
1415
+ override fun startVideoRecording(requestId: String, save: Boolean, sound: Boolean) {
1416
+ Bridge.log("NIMO: startVideoRecording - not supported")
1417
+ }
1418
+
1419
+ override fun stopVideoRecording(requestId: String) {
1420
+ Bridge.log("NIMO: stopVideoRecording - not supported")
1421
+ }
1422
+
1423
+ override fun sendButtonPhotoSettings() {}
1424
+ override fun sendButtonVideoRecordingSettings() {}
1425
+ override fun sendButtonMaxRecordingTime() {}
1426
+ override fun sendCameraFovSetting() {}
1427
+
1428
+ // ---------- SGCManager: Network (no WiFi) ----------
1429
+
1430
+ override fun requestWifiScan() {}
1431
+ override fun sendWifiCredentials(ssid: String, password: String) {}
1432
+ override fun forgetWifiNetwork(ssid: String) {}
1433
+ override fun sendHotspotState(enabled: Boolean) {}
1434
+
1435
+ // ---------- SGCManager: User Context / Gallery / Version ----------
1436
+
1437
+ override fun sendUserEmailToGlasses(email: String) {}
1438
+ override fun sendIncidentId(incidentId: String, apiBaseUrl: String?) {}
1439
+ override fun queryGalleryStatus() {}
1440
+ override fun sendGalleryMode() {}
1441
+
1442
+ override fun requestVersionInfo() {
1443
+ sendFrame(NimoFrameCodec.encodeFrame(NimoProtocol.CMD_GET_PARAMETER, NimoProtocol.GET_VERSION))
1444
+ sendFrame(
1445
+ NimoFrameCodec.encodeFrame(
1446
+ NimoProtocol.CMD_GET_PARAMETER,
1447
+ NimoProtocol.GET_VERSION_DETAIL
1448
+ )
1449
+ )
1450
+ }
1451
+
1452
+ // ---------- Device Discovery (classic Bluetooth — mirrors the vendor transport) ----------
1453
+ //
1454
+ // The MAIN Nimo device does not BLE-advertise under its name; only the "<name>_BLE"
1455
+ // ANCS side channel advertises, and that device has no data service. The vendor's
1456
+ // Android transport therefore discovers the main device over CLASSIC Bluetooth
1457
+ // (bonded list + discovery) and then connects BLE GATT to the same address
1458
+ // (dual-mode chip, shared MAC).
1459
+
1460
+ private fun isNimoMainDevice(name: String): Boolean {
1461
+ val lower = name.lowercase()
1462
+ return lower.startsWith(NimoBLE.NAME_PREFIX) && !lower.endsWith(NimoBLE.BLE_NAME_SUFFIX)
1463
+ }
1464
+
1465
+ private var discoveryReceiver: android.content.BroadcastReceiver? = null
1466
+
1467
+ private fun startScan(): Boolean {
1468
+ Bridge.log("NIMO: startScan() (classic discovery)")
1469
+ stopScan()
1470
+
1471
+ val adapter =
1472
+ bluetoothAdapter
1473
+ ?: run {
1474
+ Bridge.log("NIMO: BluetoothAdapter not available")
1475
+ return false
1476
+ }
1477
+ if (!adapter.isEnabled) {
1478
+ Bridge.log("NIMO: Bluetooth not enabled")
1479
+ return false
1480
+ }
1481
+
1482
+ // 1) Bonded devices first: Android does not re-discover already-paired devices.
1483
+ try {
1484
+ for (device in adapter.bondedDevices ?: emptySet()) {
1485
+ val name = device.name ?: continue
1486
+ if (!isNimoMainDevice(name)) continue
1487
+ Bridge.log("NIMO: bonded Nimo device: $name (${device.address})")
1488
+ mainHandler.post { onDeviceFound(device, name) }
1489
+ }
1490
+ } catch (e: SecurityException) {
1491
+ Bridge.log("NIMO: bondedDevices SecurityException: ${e.message}")
1492
+ }
1493
+
1494
+ // 2) Classic discovery for unbonded glasses.
1495
+ val receiver =
1496
+ object : android.content.BroadcastReceiver() {
1497
+ @Suppress("deprecation")
1498
+ override fun onReceive(ctx: Context?, intent: android.content.Intent?) {
1499
+ if (intent?.action != android.bluetooth.BluetoothDevice.ACTION_FOUND) return
1500
+ val device: android.bluetooth.BluetoothDevice =
1501
+ intent.getParcelableExtra(
1502
+ android.bluetooth.BluetoothDevice.EXTRA_DEVICE
1503
+ )
1504
+ ?: return
1505
+ val name =
1506
+ try {
1507
+ device.name
1508
+ } catch (e: SecurityException) {
1509
+ null
1510
+ }
1511
+ ?: intent.getStringExtra(
1512
+ android.bluetooth.BluetoothDevice.EXTRA_NAME
1513
+ )
1514
+ ?: return
1515
+ if (!isNimoMainDevice(name)) return
1516
+ mainHandler.post { onDeviceFound(device, name) }
1517
+ }
1518
+ }
1519
+ discoveryReceiver = receiver
1520
+ context.registerReceiver(
1521
+ receiver,
1522
+ android.content.IntentFilter(android.bluetooth.BluetoothDevice.ACTION_FOUND)
1523
+ )
1524
+ return try {
1525
+ val started = adapter.startDiscovery()
1526
+ Bridge.log("NIMO: classic discovery started: $started")
1527
+ true
1528
+ } catch (e: SecurityException) {
1529
+ Bridge.log("NIMO: startDiscovery SecurityException — bluetooth permission missing: ${e.message}")
1530
+ false
1531
+ } catch (e: Exception) {
1532
+ Bridge.log("NIMO: startDiscovery failed: ${e.message}")
1533
+ false
1534
+ }
1535
+ }
1536
+
1537
+ private fun onDeviceFound(device: android.bluetooth.BluetoothDevice, name: String) {
1538
+ Bridge.sendDiscoveredDevice(DeviceTypes.NIMO, name, device.address ?: "")
1539
+
1540
+ if (DEVICE_SEARCH_ID == "NOT_SET") return
1541
+ if (name != DEVICE_SEARCH_ID) return
1542
+ if (gatt != null) return
1543
+
1544
+ Bridge.log("NIMO: Connecting to $name (${device.address})")
1545
+ stopScan()
1546
+ lastDeviceName = name
1547
+ lastDeviceAddress = device.address
1548
+ connectGattLe(device)
1549
+ }
1550
+
1551
+ /**
1552
+ * Vendor connection model (from bitfantasy_glasses_sdk_transport_ble + their
1553
+ * flutter_blue_plus fork notes, "Android CTKD transport=BREDR"):
1554
+ *
1555
+ * 1. The main device is discovered over CLASSIC Bluetooth (it never advertises its
1556
+ * name over LE; the "<name>_BLE" peripheral is a separate ANCS-only device).
1557
+ * 2. Pairing is a CLASSIC (BR/EDR) bond. CTKD (cross-transport key derivation)
1558
+ * derives the LE keys — critically the IRK — during that bond.
1559
+ * 3. The DATA channel is GATT over LE (service 7033). The glasses advertise with a
1560
+ * resolvable private address, so connecting by their public MAC only works once
1561
+ * the phone holds the IRK from step 2. (Same reason iOS requires Settings-app
1562
+ * pairing first — iOS performs the classic bond + CTKD itself.)
1563
+ */
1564
+ private fun connectGattLe(device: android.bluetooth.BluetoothDevice) {
1565
+ // Classic discovery degrades/aborts connections — always cancel first.
1566
+ try {
1567
+ bluetoothAdapter?.cancelDiscovery()
1568
+ } catch (_: Exception) {}
1569
+
1570
+ val bondState =
1571
+ try {
1572
+ device.bondState
1573
+ } catch (e: SecurityException) {
1574
+ android.bluetooth.BluetoothDevice.BOND_NONE
1575
+ }
1576
+ if (bondState != android.bluetooth.BluetoothDevice.BOND_BONDED) {
1577
+ // Bond over classic first (CTKD gives us the LE keys); connect on completion.
1578
+ startBond(device)
1579
+ return
1580
+ }
1581
+ connectLeTransport(device)
1582
+ }
1583
+
1584
+ /**
1585
+ * Kicks off (or retries) the classic bond. The glasses' BR/EDR bond is slow (30-45s)
1586
+ * and flaky — it can report BOND_NONE mid-pairing yet succeed on a retry — so this is
1587
+ * called both on the initial attempt and from the BOND_NONE handler.
1588
+ */
1589
+ private fun startBond(device: android.bluetooth.BluetoothDevice) {
1590
+ val bondState =
1591
+ try {
1592
+ device.bondState
1593
+ } catch (e: SecurityException) {
1594
+ android.bluetooth.BluetoothDevice.BOND_NONE
1595
+ }
1596
+ // A previous (apparently failed) attempt may have bonded in the background.
1597
+ if (bondState == android.bluetooth.BluetoothDevice.BOND_BONDED) {
1598
+ Bridge.log("NIMO: already bonded with ${device.address} — connecting")
1599
+ unregisterBondReceiver()
1600
+ connectLeTransport(device)
1601
+ return
1602
+ }
1603
+ // Don't fire a second createBond on top of one already in flight (duplicate
1604
+ // connectById calls do this, and it poisons the bond).
1605
+ if (bondState == android.bluetooth.BluetoothDevice.BOND_BONDING ||
1606
+ (bondReceiver != null && bondingAddress == device.address)
1607
+ ) {
1608
+ Bridge.log("NIMO: bond already in progress for ${device.address} — waiting")
1609
+ registerBondReceiver(device)
1610
+ bondingAddress = device.address
1611
+ return
1612
+ }
1613
+ bondAttempts++
1614
+ Bridge.log(
1615
+ "NIMO: creating classic bond with ${device.address} (attempt $bondAttempts/$MAX_BOND_ATTEMPTS)"
1616
+ )
1617
+ DeviceStore.apply("glasses", "connectionState", ConnTypes.BONDING)
1618
+ registerBondReceiver(device)
1619
+ bondingAddress = device.address
1620
+ val started =
1621
+ try {
1622
+ device.createBond()
1623
+ } catch (e: SecurityException) {
1624
+ Bridge.log("NIMO: createBond SecurityException: ${e.message}")
1625
+ false
1626
+ }
1627
+ if (!started) {
1628
+ Bridge.log("NIMO: createBond failed to start — trying LE connect anyway")
1629
+ unregisterBondReceiver()
1630
+ connectLeTransport(device)
1631
+ }
1632
+ }
1633
+
1634
+ private fun connectLeTransport(device: android.bluetooth.BluetoothDevice) {
1635
+ // GATT over BR/EDR: the glasses pair with a legacy (non-SC) link key, so no LE
1636
+ // keys exist (le_linkkey_known:F) and the controller records the device as
1637
+ // BR_EDR-only — the 7033 UART service is served over the classic link. This is
1638
+ // what the vendor's flutter_blue_plus fork patches in ("transport=BREDR"), and
1639
+ // why iOS needs Settings pairing (CoreBluetooth bridges GATT over BR/EDR only
1640
+ // for system-paired dual-mode accessories).
1641
+ Bridge.log("NIMO: connecting GATT over BR/EDR to ${device.address}")
1642
+ DeviceStore.apply("glasses", "connectionState", ConnTypes.CONNECTING)
1643
+ gatt =
1644
+ device.connectGatt(
1645
+ context,
1646
+ false,
1647
+ gattCallback,
1648
+ android.bluetooth.BluetoothDevice.TRANSPORT_BREDR
1649
+ )
1650
+ }
1651
+
1652
+ private var bondReceiver: android.content.BroadcastReceiver? = null
1653
+ private var bondAttempts = 0
1654
+ private var bondingAddress: String? = null
1655
+
1656
+ private fun registerBondReceiver(target: android.bluetooth.BluetoothDevice) {
1657
+ unregisterBondReceiver()
1658
+ val receiver =
1659
+ object : android.content.BroadcastReceiver() {
1660
+ @Suppress("deprecation")
1661
+ override fun onReceive(ctx: Context?, intent: android.content.Intent?) {
1662
+ if (intent?.action !=
1663
+ android.bluetooth.BluetoothDevice.ACTION_BOND_STATE_CHANGED
1664
+ )
1665
+ return
1666
+ val device: android.bluetooth.BluetoothDevice =
1667
+ intent.getParcelableExtra(
1668
+ android.bluetooth.BluetoothDevice.EXTRA_DEVICE
1669
+ )
1670
+ ?: return
1671
+ val state =
1672
+ intent.getIntExtra(
1673
+ android.bluetooth.BluetoothDevice.EXTRA_BOND_STATE,
1674
+ android.bluetooth.BluetoothDevice.BOND_NONE
1675
+ )
1676
+ Bridge.log("NIMO: bond state changed: ${device.address} state=$state")
1677
+ if (device.address != target.address) return
1678
+ mainHandler.post {
1679
+ when (state) {
1680
+ android.bluetooth.BluetoothDevice.BOND_BONDED -> {
1681
+ Bridge.log(
1682
+ "NIMO: bonded with ${device.address} — settling before GATT connect"
1683
+ )
1684
+ unregisterBondReceiver()
1685
+ // Connecting GATT the instant BOND_BONDED fires races the
1686
+ // BR/EDR link teardown + CTKD finalization and comes back as
1687
+ // status 133. Let the stack settle so the first-ever connect
1688
+ // (right after pairing) succeeds instead of failing until the
1689
+ // user manually retries into the already-bonded fast path.
1690
+ mainHandler.postDelayed(
1691
+ { connectLeTransport(device) },
1692
+ POST_BOND_SETTLE_MS
1693
+ )
1694
+ }
1695
+ android.bluetooth.BluetoothDevice.BOND_NONE -> {
1696
+ unregisterBondReceiver()
1697
+ // The bond can report NONE mid-pairing yet succeed on a
1698
+ // retry — only surface failure once retries are exhausted.
1699
+ if (bondAttempts < MAX_BOND_ATTEMPTS && !isDisconnecting) {
1700
+ Bridge.log(
1701
+ "NIMO: bond attempt $bondAttempts failed for ${device.address} — retrying in ${BOND_RETRY_DELAY_MS}ms"
1702
+ )
1703
+ mainHandler.postDelayed(
1704
+ { if (!isDisconnecting) startBond(device) },
1705
+ BOND_RETRY_DELAY_MS
1706
+ )
1707
+ } else {
1708
+ Bridge.log(
1709
+ "NIMO: bonding failed with ${device.address} after $bondAttempts attempts"
1710
+ )
1711
+ Bridge.sendPairFailureEvent("errors:pairNeedDisconnect")
1712
+ }
1713
+ }
1714
+ // BOND_BONDING: in progress — wait.
1715
+ }
1716
+ }
1717
+ }
1718
+ }
1719
+ bondReceiver = receiver
1720
+ context.registerReceiver(
1721
+ receiver,
1722
+ android.content.IntentFilter(
1723
+ android.bluetooth.BluetoothDevice.ACTION_BOND_STATE_CHANGED
1724
+ )
1725
+ )
1726
+ }
1727
+
1728
+ private fun unregisterBondReceiver() {
1729
+ bondReceiver?.let {
1730
+ try {
1731
+ context.unregisterReceiver(it)
1732
+ } catch (_: Exception) {}
1733
+ }
1734
+ bondReceiver = null
1735
+ bondingAddress = null
1736
+ }
1737
+
1738
+ private fun connectByAddress(): Boolean {
1739
+ if (DEVICE_SEARCH_ID == "NOT_SET" || DEVICE_SEARCH_ID.isEmpty()) return false
1740
+ if (lastDeviceName != DEVICE_SEARCH_ID) return false
1741
+ val address = lastDeviceAddress ?: return false
1742
+ val adapter = bluetoothAdapter ?: return false
1743
+ return try {
1744
+ val device = adapter.getRemoteDevice(address)
1745
+ Bridge.log("NIMO: connectByAddress - ${device.name ?: address}")
1746
+ connectGattLe(device)
1747
+ true
1748
+ } catch (e: Exception) {
1749
+ Bridge.log("NIMO: connectByAddress failed: ${e.message}")
1750
+ false
1751
+ }
1752
+ }
1753
+
1754
+ private fun startPairingTimeout() {
1755
+ cancelPairingTimeout()
1756
+ val work = Runnable {
1757
+ if (!fullyBooted) {
1758
+ Bridge.log("NIMO: pairing timeout — handshake never completed")
1759
+ Bridge.sendPairFailureEvent("errors:pairNeedDisconnect")
1760
+ }
1761
+ }
1762
+ pairingTimeoutRunnable = work
1763
+ mainHandler.postDelayed(work, PAIRING_TIMEOUT_MS)
1764
+ }
1765
+
1766
+ private fun cancelPairingTimeout() {
1767
+ pairingTimeoutRunnable?.let { mainHandler.removeCallbacks(it) }
1768
+ pairingTimeoutRunnable = null
1769
+ }
1770
+
1771
+ // ---------- GATT Callback ----------
1772
+
1773
+ private val gattCallback =
1774
+ object : BluetoothGattCallback() {
1775
+ override fun onConnectionStateChange(g: BluetoothGatt, status: Int, newState: Int) {
1776
+ mainHandler.post {
1777
+ Bridge.log("NIMO: onConnectionStateChange status=$status newState=$newState")
1778
+ if (newState == BluetoothProfile.STATE_CONNECTED) {
1779
+ Bridge.log("NIMO: Connected to ${g.device?.name ?: "unknown"}")
1780
+ lastDeviceAddress = g.device?.address
1781
+ // Do NOT request a large MTU here: the vendor transport warns that
1782
+ // requesting one during connect makes some Nimo firmwares throw a
1783
+ // GATT error and loop reconnecting. The system negotiates the MTU;
1784
+ // frames larger than it go out as ATT long writes (WRITE_TYPE_DEFAULT).
1785
+ try {
1786
+ g.discoverServices()
1787
+ } catch (e: SecurityException) {
1788
+ Bridge.log("NIMO: discoverServices SecurityException: ${e.message}")
1789
+ }
1790
+ } else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
1791
+ Bridge.log("NIMO: Disconnected")
1792
+ if (isDisconnecting) return@post
1793
+
1794
+ gatt?.close()
1795
+ gatt = null
1796
+ txChar = null
1797
+ rxChar = null
1798
+ micChar = null
1799
+ resetSessionState()
1800
+
1801
+ DeviceStore.apply("glasses", "connected", false)
1802
+ DeviceStore.apply("glasses", "fullyBooted", false)
1803
+ DeviceStore.apply("glasses", "connectionState", ConnTypes.DISCONNECTED)
1804
+ startReconnectionTimer()
1805
+ }
1806
+ }
1807
+ }
1808
+
1809
+ override fun onMtuChanged(g: BluetoothGatt, mtu: Int, status: Int) {
1810
+ Bridge.log("NIMO: onMtuChanged mtu=$mtu status=$status")
1811
+ mainHandler.post {
1812
+ negotiatedMtu = mtu
1813
+ try {
1814
+ g.discoverServices()
1815
+ } catch (e: SecurityException) {
1816
+ Bridge.log("NIMO: discoverServices SecurityException: ${e.message}")
1817
+ }
1818
+ }
1819
+ }
1820
+
1821
+ override fun onServicesDiscovered(g: BluetoothGatt, status: Int) {
1822
+ if (status != BluetoothGatt.GATT_SUCCESS) {
1823
+ Bridge.log("NIMO: onServicesDiscovered failed status=$status")
1824
+ return
1825
+ }
1826
+ mainHandler.post {
1827
+ val service = g.getService(NimoBLE.SERVICE_UUID)
1828
+ if (service == null) {
1829
+ // Dump everything so a UUID mismatch is diagnosable from logs.
1830
+ Bridge.log("NIMO: UART service not found; discovered services:")
1831
+ for (s in g.services ?: emptyList()) {
1832
+ val chars = s.characteristics.joinToString(", ") { it.uuid.toString() }
1833
+ Bridge.log("NIMO: service ${s.uuid} chars=[$chars]")
1834
+ }
1835
+ // Vendor treats a missing service as a connection FAILURE (it throws
1836
+ // and the upper layer reconnects). Disconnect so our reconnection /
1837
+ // transport-alternation logic engages instead of idling here.
1838
+ g.disconnect()
1839
+ return@post
1840
+ }
1841
+ txChar = service.getCharacteristic(NimoBLE.CHAR_TX)
1842
+ rxChar = service.getCharacteristic(NimoBLE.CHAR_RX)
1843
+ micChar = service.getCharacteristic(NimoBLE.CHAR_MIC)
1844
+ Bridge.log(
1845
+ "NIMO: chars tx=${txChar != null} rx=${rxChar != null} mic=${micChar != null}"
1846
+ )
1847
+ if (txChar == null || rxChar == null) {
1848
+ Bridge.log("NIMO: required characteristics missing")
1849
+ g.disconnect()
1850
+ return@post
1851
+ }
1852
+ enableNotifications(g, rxChar!!) {
1853
+ micChar?.let { mic -> enableNotifications(g, mic) {} }
1854
+ startHandshake()
1855
+ }
1856
+ }
1857
+ }
1858
+
1859
+ @Deprecated("Deprecated in API level 33")
1860
+ override fun onCharacteristicChanged(
1861
+ g: BluetoothGatt,
1862
+ characteristic: BluetoothGattCharacteristic
1863
+ ) {
1864
+ val data = characteristic.value ?: return
1865
+ when (characteristic.uuid) {
1866
+ NimoBLE.CHAR_RX -> mainHandler.post { handleRxPacket(data) }
1867
+ NimoBLE.CHAR_MIC -> audioClient.enqueue(data)
1868
+ }
1869
+ }
1870
+
1871
+ override fun onCharacteristicWrite(
1872
+ g: BluetoothGatt,
1873
+ characteristic: BluetoothGattCharacteristic,
1874
+ status: Int
1875
+ ) {
1876
+ mainHandler.postDelayed(
1877
+ {
1878
+ writeInFlight = false
1879
+ drainWriteQueue()
1880
+ },
1881
+ NimoBLE.INTER_FRAME_DELAY_MS
1882
+ )
1883
+ }
1884
+
1885
+ override fun onDescriptorWrite(
1886
+ g: BluetoothGatt,
1887
+ descriptor: BluetoothGattDescriptor,
1888
+ status: Int
1889
+ ) {
1890
+ mainHandler.post { descriptorWriteCompletion?.let { it() } }
1891
+ }
1892
+ }
1893
+
1894
+ private var descriptorWriteCompletion: (() -> Unit)? = null
1895
+
1896
+ @Suppress("deprecation")
1897
+ private fun enableNotifications(
1898
+ g: BluetoothGatt,
1899
+ characteristic: BluetoothGattCharacteristic,
1900
+ onComplete: () -> Unit
1901
+ ) {
1902
+ g.setCharacteristicNotification(characteristic, true)
1903
+ val descriptor = characteristic.getDescriptor(NimoBLE.CLIENT_CHARACTERISTIC_CONFIG)
1904
+ if (descriptor != null) {
1905
+ descriptorWriteCompletion = {
1906
+ descriptorWriteCompletion = null
1907
+ onComplete()
1908
+ }
1909
+ descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
1910
+ g.writeDescriptor(descriptor)
1911
+ } else {
1912
+ onComplete()
1913
+ }
1914
+ }
1915
+
1916
+ // ---------- Write Queue ----------
1917
+
1918
+ @Suppress("deprecation")
1919
+ private fun drainWriteQueue() {
1920
+ if (writeInFlight) return
1921
+ val item = writeQueue.removeFirstOrNull() ?: return
1922
+ val g = gatt
1923
+ if (g == null) {
1924
+ writeQueue.clear()
1925
+ return
1926
+ }
1927
+ writeInFlight = true
1928
+ item.char.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
1929
+ item.char.value = item.bytes
1930
+ val ok =
1931
+ try {
1932
+ g.writeCharacteristic(item.char)
1933
+ } catch (e: Exception) {
1934
+ Bridge.log("NIMO: writeCharacteristic failed: ${e.message}")
1935
+ false
1936
+ }
1937
+ if (!ok) {
1938
+ // Another GATT op (e.g. a descriptor write) is in flight — requeue and retry
1939
+ // shortly instead of dropping the frame.
1940
+ writeInFlight = false
1941
+ writeQueue.addFirst(item)
1942
+ mainHandler.postDelayed({ drainWriteQueue() }, 100)
1943
+ return
1944
+ }
1945
+ // Watchdog: if the write callback never arrives, unblock the queue.
1946
+ writeWatchdog?.let { mainHandler.removeCallbacks(it) }
1947
+ val watchdog = Runnable {
1948
+ if (writeInFlight) {
1949
+ Bridge.log("NIMO: write watchdog fired — forcing queue drain")
1950
+ writeInFlight = false
1951
+ drainWriteQueue()
1952
+ }
1953
+ }
1954
+ writeWatchdog = watchdog
1955
+ mainHandler.postDelayed(watchdog, WRITE_WATCHDOG_MS)
1956
+ }
1957
+
1958
+ private fun enqueueWrite(char: BluetoothGattCharacteristic, bytes: ByteArray) {
1959
+ mainHandler.post {
1960
+ writeQueue.addLast(QueuedWrite(char, bytes))
1961
+ drainWriteQueue()
1962
+ }
1963
+ }
1964
+
1965
+ private fun sendFrame(frame: ByteArray) {
1966
+ val tx = txChar ?: return
1967
+ enqueueWrite(tx, frame)
1968
+ }
1969
+
1970
+ private fun enqueueFrames(frames: List<ByteArray>) {
1971
+ val tx = txChar ?: return
1972
+ mainHandler.post {
1973
+ for (frame in frames) {
1974
+ writeQueue.addLast(QueuedWrite(tx, frame))
1975
+ }
1976
+ drainWriteQueue()
1977
+ }
1978
+ }
1979
+
1980
+ // ---------- Pending ACKs ----------
1981
+
1982
+ private fun sendAwaitingAck(
1983
+ cmd: Int,
1984
+ key: Int,
1985
+ payload: ByteArray,
1986
+ timeoutMs: Long = ACK_TIMEOUT_MS,
1987
+ onResult: (Boolean) -> Unit
1988
+ ) {
1989
+ val ackKey = (cmd shl 8) or key
1990
+ // Only one in-flight ack per (cmd,key); fail any previous waiter.
1991
+ pendingAcks.remove(ackKey)?.let {
1992
+ mainHandler.removeCallbacks(it.timeout)
1993
+ it.onResult(false)
1994
+ }
1995
+ val timeout = Runnable {
1996
+ pendingAcks.remove(ackKey)?.onResult(false)
1997
+ }
1998
+ pendingAcks[ackKey] = PendingAck(onResult, timeout)
1999
+ mainHandler.postDelayed(timeout, timeoutMs)
2000
+ sendFrame(NimoFrameCodec.encodeFrame(cmd, key, payload, needsAck = true))
2001
+ }
2002
+
2003
+ private fun resolvePendingAck(cmd: Int, key: Int, success: Boolean) {
2004
+ val ackKey = (cmd shl 8) or key
2005
+ pendingAcks.remove(ackKey)?.let {
2006
+ mainHandler.removeCallbacks(it.timeout)
2007
+ it.onResult(success)
2008
+ }
2009
+ }
2010
+
2011
+ private fun failAllPendingAcks() {
2012
+ val acks = pendingAcks.values.toList()
2013
+ pendingAcks.clear()
2014
+ for (ack in acks) {
2015
+ mainHandler.removeCallbacks(ack.timeout)
2016
+ ack.onResult(false)
2017
+ }
2018
+ }
2019
+
2020
+ // ---------- Handshake ----------
2021
+
2022
+ private fun startHandshake() {
2023
+ Bridge.log("NIMO: starting handshake (awaiting TWS service-connection state)")
2024
+ handshakeState = HandshakeState.AWAITING_TWS
2025
+ if (twsConnected) {
2026
+ proceedToTimeSync()
2027
+ return
2028
+ }
2029
+ cancelTwsTimeout()
2030
+ val timeout = Runnable {
2031
+ if (handshakeState == HandshakeState.AWAITING_TWS) {
2032
+ Bridge.log("NIMO: TWS state timeout — handshake failed, will reconnect")
2033
+ handshakeFailed()
2034
+ }
2035
+ }
2036
+ twsTimeoutRunnable = timeout
2037
+ mainHandler.postDelayed(timeout, TWS_TIMEOUT_MS)
2038
+ // Also actively query in case the glasses don't push the report unprompted.
2039
+ ping()
2040
+ }
2041
+
2042
+ private fun cancelTwsTimeout() {
2043
+ twsTimeoutRunnable?.let { mainHandler.removeCallbacks(it) }
2044
+ twsTimeoutRunnable = null
2045
+ }
2046
+
2047
+ private var setTimeAttempts = 0
2048
+
2049
+ private fun proceedToTimeSync() {
2050
+ if (handshakeState != HandshakeState.AWAITING_TWS) return
2051
+ cancelTwsTimeout()
2052
+ handshakeState = HandshakeState.AWAITING_TIME_ACK
2053
+ Bridge.log("NIMO: TWS OK — sending setTime (awaiting ACK)")
2054
+ setTimeAttempts = 0
2055
+ attemptSetTime()
2056
+ }
2057
+
2058
+ private fun attemptSetTime() {
2059
+ if (handshakeState != HandshakeState.AWAITING_TIME_ACK || gatt == null) return
2060
+ setTimeAttempts++
2061
+ sendAwaitingAck(
2062
+ NimoProtocol.CMD_SET_PARAMETER,
2063
+ NimoProtocol.SET_TIME,
2064
+ NimoFrameCodec.encodeDeviceTime()
2065
+ ) { ok ->
2066
+ if (ok) {
2067
+ finishHandshake()
2068
+ } else if (setTimeAttempts < SET_TIME_MAX_ATTEMPTS &&
2069
+ handshakeState == HandshakeState.AWAITING_TIME_ACK &&
2070
+ gatt != null
2071
+ ) {
2072
+ // The firmware can answer "busy" right after the link comes up —
2073
+ // give it a moment and retry instead of dropping the connection.
2074
+ Bridge.log("NIMO: setTime attempt $setTimeAttempts failed — retrying")
2075
+ mainHandler.postDelayed({ attemptSetTime() }, SET_TIME_RETRY_DELAY_MS)
2076
+ } else {
2077
+ Bridge.log("NIMO: setTime failed after $setTimeAttempts attempts — handshake failed")
2078
+ handshakeFailed()
2079
+ }
2080
+ }
2081
+ }
2082
+
2083
+ private fun finishHandshake() {
2084
+ Bridge.log("NIMO: handshake complete — fully connected")
2085
+ handshakeState = HandshakeState.READY
2086
+ sendFrame(
2087
+ NimoFrameCodec.encodeFrame(
2088
+ NimoProtocol.CMD_SET_PARAMETER,
2089
+ NimoProtocol.SET_PHONE_TYPE,
2090
+ byteArrayOf(NimoProtocol.PHONE_TYPE_OTHER.toByte()),
2091
+ needsAck = false
2092
+ )
2093
+ )
2094
+ getBatteryStatus()
2095
+ requestVersionInfo()
2096
+
2097
+ cancelPairingTimeout()
2098
+ reconnectionManager.stop()
2099
+ DeviceStore.apply("glasses", "connected", true)
2100
+ DeviceStore.apply("glasses", "fullyBooted", true)
2101
+ DeviceStore.apply("glasses", "connectionState", ConnTypes.CONNECTED)
2102
+ startTimers()
2103
+ }
2104
+
2105
+ private fun handshakeFailed() {
2106
+ handshakeState = HandshakeState.IDLE
2107
+ gatt?.disconnect()
2108
+ // The DISCONNECTED callback path handles cleanup + reconnection.
2109
+ }
2110
+
2111
+ private fun resetSessionState() {
2112
+ handshakeState = HandshakeState.IDLE
2113
+ twsConnected = false
2114
+ textAppEntered = false
2115
+ navAppEntered = false
2116
+ currentGlassesAppId = -1
2117
+ pendingText = null
2118
+ audioClient.stop()
2119
+ receiveAssembler.reset()
2120
+ writeQueue.clear()
2121
+ writeInFlight = false
2122
+ writeWatchdog?.let { mainHandler.removeCallbacks(it) }
2123
+ writeWatchdog = null
2124
+ failAllPendingAcks()
2125
+ stopTimers()
2126
+ }
2127
+
2128
+ private fun startReconnectionTimer() {
2129
+ reconnectionManager.start {
2130
+ if (fullyBooted) {
2131
+ return@start true
2132
+ }
2133
+ Bridge.log("NIMO: Attempting reconnection...")
2134
+ isDisconnecting = false
2135
+ // Each reconnection cycle is a fresh bond budget — the glasses drop their
2136
+ // bond on some disconnects (status 19), so this path may need to re-bond.
2137
+ bondAttempts = 0
2138
+ if (!connectByAddress()) {
2139
+ startScan()
2140
+ }
2141
+ return@start false
2142
+ }
2143
+ }
2144
+
2145
+ // ---------- Timers (battery poll keepalive + text queue) ----------
2146
+
2147
+ private fun startTimers() {
2148
+ stopTimers()
2149
+ val battery =
2150
+ object : Runnable {
2151
+ override fun run() {
2152
+ // Doubles as a keepalive so the link never goes idle long enough for
2153
+ // the stack to reclaim it.
2154
+ getBatteryStatus()
2155
+ mainHandler.postDelayed(this, BATTERY_POLL_MS)
2156
+ }
2157
+ }
2158
+ batteryPollRunnable = battery
2159
+ mainHandler.postDelayed(battery, BATTERY_POLL_MS)
2160
+
2161
+ val textQueue =
2162
+ object : Runnable {
2163
+ override fun run() {
2164
+ drainTextQueue()
2165
+ mainHandler.postDelayed(this, TEXT_QUEUE_TICK_MS)
2166
+ }
2167
+ }
2168
+ textQueueRunnable = textQueue
2169
+ mainHandler.postDelayed(textQueue, TEXT_QUEUE_TICK_MS)
2170
+ }
2171
+
2172
+ private fun stopTimers() {
2173
+ batteryPollRunnable?.let { mainHandler.removeCallbacks(it) }
2174
+ batteryPollRunnable = null
2175
+ textQueueRunnable?.let { mainHandler.removeCallbacks(it) }
2176
+ textQueueRunnable = null
2177
+ }
2178
+
2179
+ // ---------- Text Rendering ----------
2180
+
2181
+ /**
2182
+ * TEMP HW TEST: build a 160x160 grayscale test pattern (four vertical bands
2183
+ * at the 4 quantization levels: black | dark | light | white) and push it
2184
+ * through the exact same nav-minimap pipeline as displayBitmap. If the bands
2185
+ * render left→right on the glasses, the SDK bitmap path works end-to-end.
2186
+ */
2187
+ private fun displaySampleBitmap() {
2188
+ val w = 160
2189
+ val h = 160
2190
+ val gray = ByteArray(w * h)
2191
+ for (y in 0 until h) {
2192
+ for (x in 0 until w) {
2193
+ val band = (x * 4 / w).coerceIn(0, 3) // 0..3 across the width
2194
+ gray[y * w + x] = (band * 85).toByte() // 0, 85, 170, 255
2195
+ }
2196
+ }
2197
+ val packed = packL8To2bpp(gray)
2198
+ val (payload, compression) = compressAdaptive(packed)
2199
+ if (!navAppEntered) {
2200
+ sendFrame(
2201
+ NimoFrameCodec.encodeFrame(
2202
+ NimoProtocol.CMD_CONTROL_INSTRUCTION,
2203
+ NimoProtocol.CTRL_ENTER_APP,
2204
+ byteArrayOf(
2205
+ NimoProtocol.APP_ID_NAV.toByte(),
2206
+ NimoProtocol.APP_MODE_STANDALONE.toByte()
2207
+ )
2208
+ )
2209
+ )
2210
+ navAppEntered = true
2211
+ }
2212
+ val content =
2213
+ NimoFrameCodec.imageHeader(
2214
+ width = w,
2215
+ height = h,
2216
+ formatBpp = NimoProtocol.FORMAT_2BPP,
2217
+ compression = compression,
2218
+ originalSize = packed.size,
2219
+ compressedSize =
2220
+ if (compression == NimoProtocol.COMPRESSION_NONE) 0
2221
+ else payload.size
2222
+ ) + payload
2223
+ val frames =
2224
+ NimoFrameCodec.updateContentFrames(
2225
+ appId = NimoProtocol.APP_ID_NAV,
2226
+ layoutId = 0,
2227
+ resId = 0,
2228
+ resType = NimoProtocol.WIDGET_PICTURE,
2229
+ content = content
2230
+ )
2231
+ enqueueFrames(frames)
2232
+ Bridge.log(
2233
+ "NIMO: displaySampleBitmap → ${frames.size} frames, ${payload.size}B comp=$compression"
2234
+ )
2235
+ }
2236
+
2237
+ private fun drainTextQueue() {
2238
+ val text = pendingText ?: return
2239
+ if (handshakeState != HandshakeState.READY) return
2240
+ pendingText = null
2241
+
2242
+ // All text (text_wall / double_text_wall / reference_card / positioned_text) renders on
2243
+ // the ASR note page — the only text surface confirmed to render on hardware.
2244
+ Bridge.log("NIMO: text → glasses (enterApp=${!textAppEntered}): \"${text.take(60)}\"")
2245
+ if (!textAppEntered) {
2246
+ sendFrame(
2247
+ NimoFrameCodec.encodeFrame(
2248
+ NimoProtocol.CMD_CONTROL_INSTRUCTION,
2249
+ NimoProtocol.CTRL_ENTER_APP,
2250
+ byteArrayOf(textAppId.toByte(), NimoProtocol.APP_MODE_STANDALONE.toByte())
2251
+ )
2252
+ )
2253
+ // Optimistic; corrected by app-state reports if the glasses refuse/exit.
2254
+ textAppEntered = true
2255
+ }
2256
+
2257
+ val frames =
2258
+ NimoFrameCodec.updateContentFrames(
2259
+ appId = textAppId,
2260
+ layoutId = textLayoutId,
2261
+ resId = textResId,
2262
+ resType = NimoProtocol.WIDGET_TEXT_NEW,
2263
+ content = text.toByteArray(Charsets.UTF_8)
2264
+ )
2265
+ enqueueFrames(frames)
2266
+ }
2267
+
2268
+ // ---------- Incoming Data ----------
2269
+
2270
+ private fun handleRxPacket(packet: ByteArray) {
2271
+ receiveAssembler.cleanup()
2272
+ for (frame in receiveAssembler.ingest(packet)) {
2273
+ val decoded = NimoFrameCodec.decode(frame) ?: continue
2274
+ val cmd = decoded.cmd ?: continue
2275
+ val key = decoded.key ?: continue
2276
+ if (cmd == NimoProtocol.CMD_INSTRUCTION_REPORT) {
2277
+ handleReport(key, decoded.data ?: ByteArray(0))
2278
+ } else {
2279
+ handleResponse(cmd, key, decoded.statusCode ?: 1, decoded.data ?: ByteArray(0))
2280
+ }
2281
+ }
2282
+ }
2283
+
2284
+ private fun handleReport(key: Int, data: ByteArray) {
2285
+ when (key) {
2286
+ NimoProtocol.REPORT_INPUT -> {
2287
+ if (data.isNotEmpty()) handleInputEvent(data[0].toInt() and 0xFF)
2288
+ }
2289
+ NimoProtocol.REPORT_APP -> {
2290
+ if (data.size >= 2) {
2291
+ val appId = data[0].toInt() and 0xFF
2292
+ val phase = data[1].toInt() and 0xFF
2293
+ Bridge.log("NIMO: app state report appId=$appId phase=$phase")
2294
+ when (phase) {
2295
+ NimoProtocol.STATE_ENTER -> {
2296
+ currentGlassesAppId = appId
2297
+ if (appId != textAppId) textAppEntered = false
2298
+ if (appId != NimoProtocol.APP_ID_NAV) navAppEntered = false
2299
+ }
2300
+ NimoProtocol.STATE_EXIT -> {
2301
+ if (appId == currentGlassesAppId) currentGlassesAppId = -1
2302
+ if (appId == textAppId) textAppEntered = false
2303
+ if (appId == NimoProtocol.APP_ID_NAV) navAppEntered = false
2304
+ }
2305
+ }
2306
+ }
2307
+ }
2308
+ NimoProtocol.REPORT_TWS -> {
2309
+ if (data.isNotEmpty()) {
2310
+ onTwsState((data[0].toInt() and 0xFF) >= 1)
2311
+ }
2312
+ }
2313
+ NimoProtocol.REPORT_BUSINESS -> handleBusinessReport(data)
2314
+ NimoProtocol.REPORT_GATT_STATE -> {}
2315
+ else -> Bridge.log("NIMO: unknown report key=$key")
2316
+ }
2317
+ }
2318
+
2319
+ private fun handleBusinessReport(data: ByteArray) {
2320
+ if (data.isEmpty()) return
2321
+ val id = data[0].toInt() and 0xFF
2322
+ val v = data.copyOfRange(1, data.size)
2323
+ when (id) {
2324
+ NimoProtocol.BUSINESS_HEARTBEAT -> {
2325
+ // [leftMv(2)][rightMv(2)][btSysStatus(4)][twsStatus(1)][slaveGatt(1)]
2326
+ if (v.size >= 10) {
2327
+ onTwsState((v[8].toInt() and 0xFF) >= 1)
2328
+ }
2329
+ }
2330
+ NimoProtocol.BUSINESS_BATTERY -> {
2331
+ if (v.size >= 4) {
2332
+ applyBattery(
2333
+ left = v[0].toInt() and 0xFF,
2334
+ right = v[1].toInt() and 0xFF,
2335
+ leftCharging = v[2].toInt() == 1,
2336
+ rightCharging = v[3].toInt() == 1
2337
+ )
2338
+ }
2339
+ }
2340
+ else -> {}
2341
+ }
2342
+ }
2343
+
2344
+ private fun onTwsState(connected: Boolean) {
2345
+ twsConnected = connected
2346
+ if (connected && handshakeState == HandshakeState.AWAITING_TWS) {
2347
+ proceedToTimeSync()
2348
+ }
2349
+ if (!connected && handshakeState == HandshakeState.READY) {
2350
+ Bridge.log("NIMO: TWS service dropped mid-session (arm removed/off?)")
2351
+ }
2352
+ }
2353
+
2354
+ private fun handleInputEvent(code: Int) {
2355
+ val timestamp = System.currentTimeMillis()
2356
+ when (code) {
2357
+ NimoProtocol.INPUT_HEAD_UP -> {
2358
+ DeviceStore.apply("glasses", "headUp", true)
2359
+ Bridge.sendHeadUp(true)
2360
+ }
2361
+ NimoProtocol.INPUT_HEAD_DOWN -> {
2362
+ DeviceStore.apply("glasses", "headUp", false)
2363
+ Bridge.sendHeadUp(false)
2364
+ }
2365
+ NimoProtocol.INPUT_CLICK_RIGHT, NimoProtocol.INPUT_CLICK_LEFT ->
2366
+ Bridge.sendTouchEvent(DeviceTypes.NIMO, "single_tap", timestamp)
2367
+ NimoProtocol.INPUT_DOUBLE_CLICK_RIGHT, NimoProtocol.INPUT_DOUBLE_CLICK_LEFT ->
2368
+ Bridge.sendTouchEvent(DeviceTypes.NIMO, "double_tap", timestamp)
2369
+ NimoProtocol.INPUT_LONG_PRESS_RIGHT, NimoProtocol.INPUT_LONG_PRESS_LEFT ->
2370
+ Bridge.sendTouchEvent(DeviceTypes.NIMO, "long_press", timestamp)
2371
+ NimoProtocol.INPUT_TOUCH_PRESS_RIGHT,
2372
+ NimoProtocol.INPUT_TOUCH_RELEASE_RIGHT,
2373
+ NimoProtocol.INPUT_TOUCH_PRESS_LEFT,
2374
+ NimoProtocol.INPUT_TOUCH_RELEASE_LEFT -> {
2375
+ // Raw press/release transitions are too chatty to forward; taps cover the UX.
2376
+ }
2377
+ else -> Bridge.log("NIMO: unknown input event code=$code")
2378
+ }
2379
+ }
2380
+
2381
+ private fun handleResponse(cmd: Int, key: Int, statusCode: Int, data: ByteArray) {
2382
+ if (statusCode != 0) {
2383
+ // Protocol error codes: 1 fail, 2 timeout, 3 invalid format, 4 no memory,
2384
+ // 5 not supported, 6 bad parameter, 7 device busy.
2385
+ Bridge.log("NIMO: response cmd=$cmd key=$key error status=$statusCode")
2386
+ }
2387
+ resolvePendingAck(cmd, key, statusCode == 0)
2388
+
2389
+ if (cmd != NimoProtocol.CMD_GET_PARAMETER || statusCode != 0) return
2390
+ when (key) {
2391
+ NimoProtocol.GET_BATTERY -> {
2392
+ if (data.size >= 4) {
2393
+ applyBattery(
2394
+ left = data[0].toInt() and 0xFF,
2395
+ right = data[1].toInt() and 0xFF,
2396
+ leftCharging = data[2].toInt() == 1,
2397
+ rightCharging = data[3].toInt() == 1
2398
+ )
2399
+ }
2400
+ }
2401
+ NimoProtocol.GET_VERSION -> {
2402
+ if (data.size >= 4) {
2403
+ val v =
2404
+ (data[0].toInt() and 0xFF) or
2405
+ ((data[1].toInt() and 0xFF) shl 8) or
2406
+ ((data[2].toInt() and 0xFF) shl 16) or
2407
+ ((data[3].toInt() and 0xFF) shl 24)
2408
+ val major = (v ushr 28) and 0xF
2409
+ val minor = (v ushr 21) and 0x7F
2410
+ val micro = (v ushr 12) and 0x1FF
2411
+ val build = v and 0xFFF
2412
+ firmwareVersionPacked = "$major.$minor.$micro.$build"
2413
+ emitVersionInfo()
2414
+ }
2415
+ }
2416
+ NimoProtocol.GET_VERSION_DETAIL -> {
2417
+ var end = data.size
2418
+ while (end > 0 && data[end - 1].toInt() == 0) end--
2419
+ firmwareVersionDetail = String(data, 0, end, Charsets.UTF_8).trim()
2420
+ emitVersionInfo()
2421
+ }
2422
+ NimoProtocol.GET_TWS_STATUS -> {
2423
+ if (data.isNotEmpty()) onTwsState((data[0].toInt() and 0xFF) >= 1)
2424
+ }
2425
+ else -> {}
2426
+ }
2427
+ }
2428
+
2429
+ private fun applyBattery(left: Int, right: Int, leftCharging: Boolean, rightCharging: Boolean) {
2430
+ // Two independent arms → report the conservative (lower) level.
2431
+ val level = minOf(left, right)
2432
+ val charging = leftCharging || rightCharging
2433
+ if (level != lastBatteryLevel || charging != lastCharging) {
2434
+ lastBatteryLevel = level
2435
+ lastCharging = charging
2436
+ DeviceStore.apply("glasses", "batteryLevel", level)
2437
+ DeviceStore.apply("glasses", "charging", charging)
2438
+ Bridge.sendBatteryStatus(level, charging)
2439
+ }
2440
+ }
2441
+
2442
+ private fun emitVersionInfo() {
2443
+ val version = firmwareVersionDetail.ifEmpty { firmwareVersionPacked }
2444
+ if (version.isEmpty()) return
2445
+ DeviceStore.apply("glasses", "firmwareVersion", version)
2446
+ Bridge.sendVersionInfo(mapOf("firmwareVersion" to version))
2447
+ }
2448
+
2449
+ // ---------- Bitmap Helpers ----------
2450
+
2451
+ /** Aspect-fit the bitmap into [width]x[height] on black, then convert to L8 grayscale. */
2452
+ private fun bitmapToGrayscale(source: Bitmap, width: Int, height: Int): ByteArray {
2453
+ val scaled = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
2454
+ val canvas = Canvas(scaled)
2455
+ canvas.drawColor(Color.BLACK)
2456
+ val scale = minOf(width.toFloat() / source.width, height.toFloat() / source.height)
2457
+ val dw = (source.width * scale).toInt()
2458
+ val dh = (source.height * scale).toInt()
2459
+ val left = (width - dw) / 2
2460
+ val top = (height - dh) / 2
2461
+ canvas.drawBitmap(
2462
+ source,
2463
+ null,
2464
+ Rect(left, top, left + dw, top + dh),
2465
+ Paint(Paint.FILTER_BITMAP_FLAG)
2466
+ )
2467
+
2468
+ val pixels = IntArray(width * height)
2469
+ scaled.getPixels(pixels, 0, width, 0, 0, width, height)
2470
+ val gray = ByteArray(width * height)
2471
+ for (i in pixels.indices) {
2472
+ val p = pixels[i]
2473
+ val luminance =
2474
+ (0.299 * Color.red(p) + 0.587 * Color.green(p) + 0.114 * Color.blue(p)).toInt()
2475
+ // Invert luminance. MentraOS app bitmaps follow the platform convention of dark
2476
+ // content on a light background (see BitmapJavaUtils: gray<128 -> "black"). Nimo's
2477
+ // additive display lights up high 2bpp values (3 = white), so sending luma as-is
2478
+ // lights the whole lens for the background and leaves the content dark — the
2479
+ // inverted look. 255 - luma flips it so content renders lit on a dark lens.
2480
+ gray[i] = (255 - luminance.coerceIn(0, 255)).toByte()
2481
+ }
2482
+ scaled.recycle()
2483
+ return gray
2484
+ }
2485
+
2486
+ /** L8 → 2bpp: thresholds 0x40/0x80/0xC0, 4 pixels per byte, MSB first. */
2487
+ private fun packL8To2bpp(l8: ByteArray): ByteArray {
2488
+ val outBytes = (l8.size + 3) shr 2
2489
+ val dst = ByteArray(outBytes)
2490
+ for (i in 0 until outBytes) {
2491
+ var packed = 0
2492
+ for (j in 0 until 4) {
2493
+ val idx = i * 4 + j
2494
+ var value = 0
2495
+ if (idx < l8.size) {
2496
+ val px = l8[idx].toInt() and 0xFF
2497
+ value =
2498
+ when {
2499
+ px < 0x40 -> 0
2500
+ px < 0x80 -> 1
2501
+ px < 0xC0 -> 2
2502
+ else -> 3
2503
+ }
2504
+ }
2505
+ packed = packed or ((value and 0x03) shl (6 - 2 * j))
2506
+ }
2507
+ dst[i] = packed.toByte()
2508
+ }
2509
+ return dst
2510
+ }
2511
+
2512
+ /** zlib-compress; falls back to uncompressed when that isn't smaller. */
2513
+ private fun compressAdaptive(data: ByteArray): Pair<ByteArray, Int> {
2514
+ if (data.isEmpty()) return Pair(data, NimoProtocol.COMPRESSION_NONE)
2515
+ val deflater = Deflater()
2516
+ deflater.setInput(data)
2517
+ deflater.finish()
2518
+ val out = ByteArrayOutputStream(data.size)
2519
+ val buffer = ByteArray(4096)
2520
+ while (!deflater.finished()) {
2521
+ val n = deflater.deflate(buffer)
2522
+ out.write(buffer, 0, n)
2523
+ }
2524
+ deflater.end()
2525
+ val compressed = out.toByteArray()
2526
+ return if (compressed.size < data.size) {
2527
+ Pair(compressed, NimoProtocol.COMPRESSION_ZLIB)
2528
+ } else {
2529
+ Pair(data, NimoProtocol.COMPRESSION_NONE)
2530
+ }
2531
+ }
2532
+ }