@mentra/bluetooth-sdk 0.1.13 → 0.1.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/README.md +18 -8
  2. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +48 -48
  3. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +5 -20
  4. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +360 -269
  5. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +3 -7
  6. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +152 -72
  7. package/android/src/main/java/com/mentra/bluetoothsdk/OtaManifest.kt +12 -12
  8. package/android/src/main/java/com/mentra/bluetoothsdk/camera/CameraModels.kt +25 -11
  9. package/android/src/main/java/com/mentra/bluetoothsdk/controllers/ControllerManager.kt +1 -2
  10. package/android/src/main/java/com/mentra/bluetoothsdk/controllers/R1.kt +1 -2
  11. package/android/src/main/java/com/mentra/bluetoothsdk/events/BluetoothEvents.kt +1 -22
  12. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.kt +51 -8
  13. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +1184 -800
  14. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Mach1.kt +1 -5
  15. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +423 -117
  16. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +4 -6
  17. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Nimo.kt +2532 -0
  18. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +17 -5
  19. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Simulated.kt +3 -7
  20. package/android/src/main/java/com/mentra/bluetoothsdk/status/DeviceStatus.kt +0 -6
  21. package/android/src/main/java/com/mentra/bluetoothsdk/streaming/StreamModels.kt +16 -33
  22. package/android/src/main/java/com/mentra/bluetoothsdk/types/DeviceModels.kt +5 -3
  23. package/android/src/main/java/com/mentra/bluetoothsdk/utils/Constants.kt +2 -1
  24. package/android/src/test/java/com/mentra/bluetoothsdk/camera/PhotoRequestTest.kt +60 -1
  25. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoProtocolTest.kt +333 -0
  26. package/build/BluetoothSdk.types.d.ts +50 -24
  27. package/build/BluetoothSdk.types.d.ts.map +1 -1
  28. package/build/BluetoothSdk.types.js +1 -0
  29. package/build/BluetoothSdk.types.js.map +1 -1
  30. package/build/_internal.d.ts +4 -4
  31. package/build/_internal.js +4 -4
  32. package/build/_internal.js.map +1 -1
  33. package/build/_private/BluetoothSdkModule.d.ts +14 -8
  34. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  35. package/build/_private/BluetoothSdkModule.js +4 -3
  36. package/build/_private/BluetoothSdkModule.js.map +1 -1
  37. package/build/_private/cameraRequestPayload.d.ts +4 -0
  38. package/build/_private/cameraRequestPayload.d.ts.map +1 -0
  39. package/build/_private/cameraRequestPayload.js +25 -0
  40. package/build/_private/cameraRequestPayload.js.map +1 -0
  41. package/build/_private/photoRequestPayload.d.ts.map +1 -1
  42. package/build/_private/photoRequestPayload.js +11 -3
  43. package/build/_private/photoRequestPayload.js.map +1 -1
  44. package/build/index.d.ts +1 -1
  45. package/build/index.d.ts.map +1 -1
  46. package/build/index.js +70 -61
  47. package/build/index.js.map +1 -1
  48. package/ios/BluetoothSdkModule.swift +51 -28
  49. package/ios/Source/Bridge.swift +4 -16
  50. package/ios/Source/DeviceManager.swift +76 -22
  51. package/ios/Source/DeviceStore.swift +0 -4
  52. package/ios/Source/MentraBluetoothSDK.swift +144 -78
  53. package/ios/Source/OtaManifest.swift +13 -13
  54. package/ios/Source/camera/CameraModels.swift +82 -20
  55. package/ios/Source/controllers/ControllerManager.swift +1 -2
  56. package/ios/Source/controllers/R1.swift +1 -2
  57. package/ios/Source/errors/{BluetoothError.swift → BluetoothSdkError.swift} +1 -1
  58. package/ios/Source/events/BluetoothEvents.swift +5 -27
  59. package/ios/Source/internal/BluetoothAvailability.swift +5 -5
  60. package/ios/Source/sgcs/Frame.swift +0 -2
  61. package/ios/Source/sgcs/G1.swift +52 -4
  62. package/ios/Source/sgcs/G2.swift +643 -204
  63. package/ios/Source/sgcs/Mach1.swift +1 -3
  64. package/ios/Source/sgcs/MentraLive.swift +64 -77
  65. package/ios/Source/sgcs/MentraNex.swift +1 -3
  66. package/ios/Source/sgcs/Nimo.swift +1928 -0
  67. package/ios/Source/sgcs/SGCManager.swift +16 -10
  68. package/ios/Source/sgcs/Simulated.swift +2 -6
  69. package/ios/Source/status/DeviceStatus.swift +0 -8
  70. package/ios/Source/streaming/StreamModels.swift +7 -32
  71. package/ios/Source/stt/STTTools.swift +1 -1
  72. package/ios/Source/types/DeviceModels.swift +5 -0
  73. package/ios/Source/utils/Constants.swift +2 -0
  74. package/package.json +6 -1
  75. package/src/BluetoothSdk.types.ts +54 -28
  76. package/src/_internal.ts +4 -4
  77. package/src/_private/BluetoothSdkModule.ts +20 -11
  78. package/src/_private/cameraRequestPayload.ts +29 -0
  79. package/src/_private/photoRequestPayload.ts +12 -3
  80. package/src/index.ts +77 -67
@@ -0,0 +1,333 @@
1
+ package com.mentra.bluetoothsdk.sgcs
2
+
3
+ import org.assertj.core.api.Assertions.assertThat
4
+ import org.junit.Test
5
+
6
+ class NimoCrc16Test {
7
+
8
+ @Test
9
+ fun `matches CRC-16-CCITT-FALSE check value`() {
10
+ // The standard check input "123456789" → 0x29B1 for CRC-16/CCITT-FALSE.
11
+ val data = "123456789".toByteArray(Charsets.US_ASCII)
12
+ assertThat(nimoCrc16(data)).isEqualTo(0x29B1)
13
+ }
14
+
15
+ @Test
16
+ fun `empty input yields init value`() {
17
+ assertThat(nimoCrc16(ByteArray(0))).isEqualTo(0xFFFF)
18
+ }
19
+ }
20
+
21
+ class NimoFrameCodecTest {
22
+
23
+ @Test
24
+ fun `transport header layout`() {
25
+ val payload = byteArrayOf(0x03, 0x01, 0x01, 0x00, 0x42)
26
+ val header = NimoFrameCodec.transportHeader(payload, index = 0x0201, needsAck = true)
27
+ assertThat(header).hasSize(8)
28
+ assertThat(header[0].toInt() and 0xFF).isEqualTo(0xBF)
29
+ assertThat(header[1].toInt() and 0xFF).isEqualTo(0x02) // ack bit
30
+ assertThat(header[2].toInt() and 0xFF).isEqualTo(payload.size) // len LE
31
+ assertThat(header[3].toInt() and 0xFF).isEqualTo(0)
32
+ val crc = nimoCrc16(payload)
33
+ assertThat(header[4].toInt() and 0xFF).isEqualTo(crc and 0xFF)
34
+ assertThat(header[5].toInt() and 0xFF).isEqualTo((crc shr 8) and 0xFF)
35
+ assertThat(header[6].toInt() and 0xFF).isEqualTo(0x01) // index LE
36
+ assertThat(header[7].toInt() and 0xFF).isEqualTo(0x02)
37
+ }
38
+
39
+ @Test
40
+ fun `request frame layout`() {
41
+ // setBrightness level 8: cmd=0x03 key=0x02 payload=[0x08]
42
+ val frame = NimoFrameCodec.encodeFrame(0x03, 0x02, byteArrayOf(0x08))
43
+ assertThat(frame).hasSize(8 + 4 + 1)
44
+ // app header after transport header
45
+ assertThat(frame[8].toInt()).isEqualTo(0x03)
46
+ assertThat(frame[9].toInt()).isEqualTo(0x02)
47
+ assertThat(frame[10].toInt()).isEqualTo(0x01) // payload len LE
48
+ assertThat(frame[11].toInt()).isEqualTo(0x00)
49
+ assertThat(frame[12].toInt()).isEqualTo(0x08)
50
+ }
51
+
52
+ @Test
53
+ fun `decode parses a response frame and validates crc`() {
54
+ // Response layout: [cmd][key][len(2)][status][data]
55
+ val appPayload = byteArrayOf(0x02, 0x06, 0x04, 0x00, 0x00, 90, 85, 0, 1)
56
+ val frame = NimoFrameCodec.transportHeader(appPayload, index = 0) + appPayload
57
+ val decoded = NimoFrameCodec.decode(frame)
58
+ assertThat(decoded).isNotNull
59
+ assertThat(decoded!!.cmd).isEqualTo(0x02)
60
+ assertThat(decoded.key).isEqualTo(0x06)
61
+ assertThat(decoded.statusCode).isEqualTo(0)
62
+ assertThat(decoded.data).containsExactly(90, 85, 0, 1)
63
+ }
64
+
65
+ @Test
66
+ fun `decode rejects corrupt crc`() {
67
+ val appPayload = byteArrayOf(0x02, 0x06, 0x04, 0x00, 0x00, 90, 85, 0, 1)
68
+ val frame = NimoFrameCodec.transportHeader(appPayload) + appPayload
69
+ frame[10] = (frame[10] + 1).toByte() // corrupt a payload byte after CRC was computed
70
+ assertThat(NimoFrameCodec.decode(frame)).isNull()
71
+ }
72
+
73
+ @Test
74
+ fun `decode rejects wrong magic and error status`() {
75
+ val appPayload = byteArrayOf(0x02, 0x06, 0x00, 0x00, 0x00)
76
+ val good = NimoFrameCodec.transportHeader(appPayload) + appPayload
77
+ val badMagic = good.copyOf().also { it[0] = 0x00 }
78
+ assertThat(NimoFrameCodec.decode(badMagic)).isNull()
79
+ val errStatus = good.copyOf().also { it[1] = 0x01 } // err bit set
80
+ assertThat(NimoFrameCodec.decode(errStatus)).isNull()
81
+ }
82
+
83
+ @Test
84
+ fun `single chunk content update has index zero`() {
85
+ val frames = NimoFrameCodec.updateContentFrames(0x06, 0, 0, 0x00, "hello".toByteArray())
86
+ assertThat(frames).hasSize(1)
87
+ val frame = frames[0]
88
+ // index bytes
89
+ assertThat(frame[6].toInt()).isEqualTo(0)
90
+ assertThat(frame[7].toInt()).isEqualTo(0)
91
+ // app header: cmd=0x07 key=0x04 len = 4 + 5
92
+ assertThat(frame[8].toInt()).isEqualTo(0x07)
93
+ assertThat(frame[9].toInt()).isEqualTo(0x04)
94
+ assertThat(frame[10].toInt()).isEqualTo(9)
95
+ // resource prefix [appId][layoutId][resId][resType]
96
+ assertThat(frame[12].toInt()).isEqualTo(0x06)
97
+ assertThat(frame[13].toInt()).isEqualTo(0x00)
98
+ assertThat(frame[14].toInt()).isEqualTo(0x00)
99
+ assertThat(frame[15].toInt()).isEqualTo(0x00)
100
+ assertThat(String(frame.copyOfRange(16, 21))).isEqualTo("hello")
101
+ }
102
+
103
+ @Test
104
+ fun `multi chunk content update follows the index rule`() {
105
+ // Full app data = 4 (header) + 4 (prefix) + content. Pick content so we get 3 chunks:
106
+ // total = 8 + 1200 = 1208 → chunks of 501, 501, 206.
107
+ val content = ByteArray(1200) { (it % 251).toByte() }
108
+ val frames = NimoFrameCodec.updateContentFrames(0x06, 0, 0, 0x00, content)
109
+ assertThat(frames).hasSize(3)
110
+ // indices: 1, 2, 0 (last)
111
+ assertThat(frames[0][6].toInt()).isEqualTo(1)
112
+ assertThat(frames[1][6].toInt()).isEqualTo(2)
113
+ assertThat(frames[2][6].toInt()).isEqualTo(0)
114
+ // chunk sizes via the transport length field
115
+ fun len(f: ByteArray) = (f[2].toInt() and 0xFF) or ((f[3].toInt() and 0xFF) shl 8)
116
+ assertThat(len(frames[0])).isEqualTo(501)
117
+ assertThat(len(frames[1])).isEqualTo(501)
118
+ assertThat(len(frames[2])).isEqualTo(206)
119
+ // every chunk's CRC covers that chunk only
120
+ for (f in frames) {
121
+ val payload = f.copyOfRange(8, 8 + len(f))
122
+ val crc = (f[4].toInt() and 0xFF) or ((f[5].toInt() and 0xFF) shl 8)
123
+ assertThat(nimoCrc16(payload)).isEqualTo(crc)
124
+ }
125
+ // chunks concatenate back to the full app data
126
+ val merged = frames.flatMap { f -> f.copyOfRange(8, 8 + len(f)).toList() }.toByteArray()
127
+ assertThat(merged).hasSize(1208)
128
+ assertThat(merged[0].toInt()).isEqualTo(0x07)
129
+ assertThat(merged.copyOfRange(8, 1208)).isEqualTo(content)
130
+ }
131
+
132
+ @Test
133
+ fun `image header layout`() {
134
+ val header =
135
+ NimoFrameCodec.imageHeader(
136
+ width = 304,
137
+ height = 180,
138
+ formatBpp = 0x02,
139
+ compression = 0x07,
140
+ originalSize = 13680,
141
+ compressedSize = 4096
142
+ )
143
+ assertThat(header).hasSize(15)
144
+ assertThat(header[0].toInt() and 0xFF).isEqualTo(0x16)
145
+ assertThat((header[1].toInt() and 0xFF) or ((header[2].toInt() and 0xFF) shl 8)).isEqualTo(304)
146
+ assertThat((header[3].toInt() and 0xFF) or ((header[4].toInt() and 0xFF) shl 8)).isEqualTo(180)
147
+ assertThat(header[5].toInt()).isEqualTo(0x02)
148
+ assertThat(header[6].toInt()).isEqualTo(0x07)
149
+ val originalSize =
150
+ (header[7].toInt() and 0xFF) or
151
+ ((header[8].toInt() and 0xFF) shl 8) or
152
+ ((header[9].toInt() and 0xFF) shl 16) or
153
+ ((header[10].toInt() and 0xFF) shl 24)
154
+ assertThat(originalSize).isEqualTo(13680)
155
+ }
156
+
157
+ @Test
158
+ fun `device time is nine bytes with sane fields`() {
159
+ // 2026-06-11 is a Thursday (week=4 with Sunday=0).
160
+ val cal = java.util.Calendar.getInstance()
161
+ cal.set(2026, java.util.Calendar.JUNE, 11, 15, 30, 45)
162
+ val b = NimoFrameCodec.encodeDeviceTime(cal.timeInMillis)
163
+ assertThat(b).hasSize(9)
164
+ assertThat((b[0].toInt() and 0xFF) or ((b[1].toInt() and 0xFF) shl 8)).isEqualTo(2026)
165
+ assertThat(b[2].toInt()).isEqualTo(6)
166
+ assertThat(b[3].toInt()).isEqualTo(11)
167
+ assertThat(b[4].toInt()).isEqualTo(15)
168
+ assertThat(b[5].toInt()).isEqualTo(30)
169
+ assertThat(b[6].toInt()).isEqualTo(45)
170
+ assertThat(b[7].toInt()).isEqualTo(4)
171
+ // zone is the local offset in 15-minute units, clamped to ±48
172
+ val zone = b[8].toInt().toByte().toInt()
173
+ assertThat(zone).isBetween(-48, 48)
174
+ }
175
+ }
176
+
177
+ class NimoReceiveAssemblerTest {
178
+
179
+ /** Builds one response fragment: transport header(index) + [cmd][key][len][status][data]. */
180
+ private fun fragment(cmd: Int, key: Int, status: Int, data: ByteArray, index: Int): ByteArray {
181
+ val appPayload =
182
+ byteArrayOf(
183
+ cmd.toByte(),
184
+ key.toByte(),
185
+ (data.size and 0xFF).toByte(),
186
+ ((data.size shr 8) and 0xFF).toByte(),
187
+ status.toByte()
188
+ ) + data
189
+ return NimoFrameCodec.transportHeader(appPayload, index = index) + appPayload
190
+ }
191
+
192
+ @Test
193
+ fun `single packet passes through unchanged`() {
194
+ val assembler = NimoReceiveAssembler()
195
+ val frame = fragment(0x02, 0x06, 0x00, byteArrayOf(90, 85, 0, 1), index = 0)
196
+ val out = assembler.ingest(frame)
197
+ assertThat(out).hasSize(1)
198
+ assertThat(out[0]).isEqualTo(frame)
199
+ }
200
+
201
+ @Test
202
+ fun `fragments merge in index order and reframe decodes`() {
203
+ val assembler = NimoReceiveAssembler()
204
+ assertThat(assembler.ingest(fragment(0x02, 0x0B, 0x00, "ver".toByteArray(), index = 1))).isEmpty()
205
+ assertThat(assembler.ingest(fragment(0x02, 0x0B, 0x00, "sion-".toByteArray(), index = 2))).isEmpty()
206
+ val out = assembler.ingest(fragment(0x02, 0x0B, 0x00, "1.2.3".toByteArray(), index = 0))
207
+ assertThat(out).hasSize(1)
208
+ val decoded = NimoFrameCodec.decode(out[0])
209
+ assertThat(decoded).isNotNull
210
+ assertThat(decoded!!.cmd).isEqualTo(0x02)
211
+ assertThat(decoded.key).isEqualTo(0x0B)
212
+ assertThat(decoded.statusCode).isEqualTo(0)
213
+ assertThat(String(decoded.data!!)).isEqualTo("version-1.2.3")
214
+ }
215
+
216
+ @Test
217
+ fun `out of order middle fragment is tolerated`() {
218
+ val assembler = NimoReceiveAssembler()
219
+ assertThat(assembler.ingest(fragment(0x02, 0x0B, 0x00, "B".toByteArray(), index = 2))).isEmpty()
220
+ assertThat(assembler.ingest(fragment(0x02, 0x0B, 0x00, "A".toByteArray(), index = 1))).isEmpty()
221
+ val out = assembler.ingest(fragment(0x02, 0x0B, 0x00, "C".toByteArray(), index = 0))
222
+ assertThat(out).hasSize(1)
223
+ val decoded = NimoFrameCodec.decode(out[0])
224
+ assertThat(String(decoded!!.data!!)).isEqualTo("ABC")
225
+ }
226
+
227
+ @Test
228
+ fun `stale first fragment is dropped when a new round starts`() {
229
+ val assembler = NimoReceiveAssembler()
230
+ // Round 1 loses its last packet.
231
+ assertThat(assembler.ingest(fragment(0x02, 0x0B, 0x00, "stale".toByteArray(), index = 1))).isEmpty()
232
+ // Round 2 starts fresh.
233
+ assertThat(assembler.ingest(fragment(0x02, 0x0B, 0x00, "fresh".toByteArray(), index = 1))).isEmpty()
234
+ val out = assembler.ingest(fragment(0x02, 0x0B, 0x00, "!".toByteArray(), index = 0))
235
+ val decoded = NimoFrameCodec.decode(out[0])
236
+ assertThat(String(decoded!!.data!!)).isEqualTo("fresh!")
237
+ }
238
+
239
+ @Test
240
+ fun `different cmd-key groups do not cross-merge`() {
241
+ val assembler = NimoReceiveAssembler()
242
+ assertThat(assembler.ingest(fragment(0x02, 0x0B, 0x00, "v".toByteArray(), index = 1))).isEmpty()
243
+ // A complete single frame from another (cmd,key) passes through while the group is open.
244
+ val battery = fragment(0x02, 0x06, 0x00, byteArrayOf(90, 85, 0, 1), index = 0)
245
+ assertThat(assembler.ingest(battery)).containsExactly(battery)
246
+ val out = assembler.ingest(fragment(0x02, 0x0B, 0x00, "1".toByteArray(), index = 0))
247
+ assertThat(String(NimoFrameCodec.decode(out[0])!!.data!!)).isEqualTo("v1")
248
+ }
249
+ }
250
+
251
+ class NimoAudioParserTest {
252
+
253
+ private fun audioPacket(type: Int, sn: Int, frames: List<ByteArray>): ByteArray {
254
+ val payload = ByteArrayPacker()
255
+ payload.add((sn and 0xFF).toByte(), ((sn shr 8) and 0xFF).toByte())
256
+ payload.add(frames.size.toByte(), 0x00)
257
+ for (opus in frames) {
258
+ val body = ByteArray(8 + opus.size)
259
+ body[3] = opus.size.toByte() // opusLen at body[3]
260
+ opus.copyInto(body, 8)
261
+ payload.add((body.size).toByte())
262
+ payload.addAll(body)
263
+ }
264
+ val p = payload.toByteArray()
265
+ return byteArrayOf(0x52, type.toByte(), (p.size and 0xFF).toByte(), ((p.size shr 8) and 0xFF).toByte()) + p
266
+ }
267
+
268
+ private class ByteArrayPacker {
269
+ private val out = java.io.ByteArrayOutputStream()
270
+ fun add(vararg bytes: Byte) = out.write(bytes)
271
+ fun addAll(bytes: ByteArray) = out.write(bytes)
272
+ fun toByteArray(): ByteArray = out.toByteArray()
273
+ }
274
+
275
+ @Test
276
+ fun `parses right-ear opus frames sliced by opusLen`() {
277
+ val opus1 = byteArrayOf(0xAA.toByte(), 0xBB.toByte(), 0xCC.toByte())
278
+ val opus2 = byteArrayOf(0x01, 0x02)
279
+ val packet = audioPacket(NimoAudioParser.TYPE_OPUS_RIGHT, 0x1234, listOf(opus1, opus2))
280
+ val parsed = NimoAudioParser.parse(packet)
281
+ assertThat(parsed).isNotNull
282
+ assertThat(parsed!!.type).isEqualTo(NimoAudioParser.TYPE_OPUS_RIGHT)
283
+ assertThat(parsed.sequence).isEqualTo(0x1234)
284
+ assertThat(parsed.opusFrames).hasSize(2)
285
+ assertThat(parsed.opusFrames[0]).isEqualTo(opus1)
286
+ assertThat(parsed.opusFrames[1]).isEqualTo(opus2)
287
+ }
288
+
289
+ @Test
290
+ fun `start and stop packets carry no frames`() {
291
+ assertThat(NimoAudioParser.parse(byteArrayOf(0x52, 0x01, 0x00, 0x00))!!.type)
292
+ .isEqualTo(NimoAudioParser.TYPE_START)
293
+ assertThat(NimoAudioParser.parse(byteArrayOf(0x52, 0x00, 0x00, 0x00))!!.type)
294
+ .isEqualTo(NimoAudioParser.TYPE_STOP)
295
+ }
296
+
297
+ @Test
298
+ fun `rejects wrong header and short input`() {
299
+ assertThat(NimoAudioParser.parse(byteArrayOf(0x51, 0x02, 0x00, 0x00))).isNull()
300
+ assertThat(NimoAudioParser.parse(byteArrayOf(0x52, 0x02))).isNull()
301
+ }
302
+
303
+ @Test
304
+ fun `skips frames with short header or opus overrun`() {
305
+ // frame 1: frameLen=4 (<8) → skipped; frame 2: opusLen overruns frameLen → skipped;
306
+ // frame 3: valid.
307
+ val valid = byteArrayOf(0x0F)
308
+ val payload = ByteArrayPacker()
309
+ payload.add(0x01, 0x00) // SN
310
+ payload.add(0x03, 0x00) // frameCnt=3
311
+ // frame 1: short header
312
+ payload.add(0x04)
313
+ payload.addAll(ByteArray(4))
314
+ // frame 2: opusLen 20 > frameLen-8
315
+ val bad = ByteArray(10)
316
+ bad[3] = 20
317
+ payload.add(0x0A)
318
+ payload.addAll(bad)
319
+ // frame 3: valid 1-byte opus
320
+ val body = ByteArray(9)
321
+ body[3] = 1
322
+ body[8] = valid[0]
323
+ payload.add(0x09)
324
+ payload.addAll(body)
325
+ val p = payload.toByteArray()
326
+ val packet =
327
+ byteArrayOf(0x52, 0x02, (p.size and 0xFF).toByte(), ((p.size shr 8) and 0xFF).toByte()) + p
328
+
329
+ val parsed = NimoAudioParser.parse(packet)
330
+ assertThat(parsed!!.opusFrames).hasSize(1)
331
+ assertThat(parsed.opusFrames[0]).isEqualTo(valid)
332
+ }
333
+ }
@@ -219,6 +219,14 @@ export type PhotoStatusEvent = {
219
219
  errorCode?: string;
220
220
  errorMessage?: string;
221
221
  };
222
+ export type CameraStatusEvent = {
223
+ type: "camera_status";
224
+ requestId: string;
225
+ state: "warming" | "ready" | "stopped" | "error" | string;
226
+ timestamp: number;
227
+ errorCode?: string;
228
+ errorMessage?: string;
229
+ };
222
230
  export type VideoRecordingStatusEvent = {
223
231
  type: "video_recording_status";
224
232
  requestId?: string;
@@ -309,7 +317,7 @@ export type RgbLedControlSuccessResponseEvent = Extract<RgbLedControlResponseEve
309
317
  state: "success";
310
318
  }>;
311
319
  export type SettingsAckStatus = "applied" | "ready" | "error" | "failed" | "failure" | "rejected";
312
- export type SettingsAckSetting = "gallery_mode" | "button_photo" | "button_video_recording" | "button_camera_led" | "button_max_recording_time" | "camera_fov";
320
+ export type SettingsAckSetting = "gallery_mode" | "button_photo" | "button_video_recording" | "button_max_recording_time" | "camera_fov" | "camera_tuning";
313
321
  export type SettingsAckEvent = {
314
322
  type: "settings_ack";
315
323
  requestId: string;
@@ -326,6 +334,10 @@ export type SettingsAckEvent = {
326
334
  fps?: number;
327
335
  enabled?: boolean;
328
336
  minutes?: number;
337
+ /** ANR enabled flag; present when setting === "camera_tuning" */
338
+ anr?: boolean;
339
+ /** Stock-gain flag; present when setting === "camera_tuning" */
340
+ gain?: boolean;
329
341
  errorCode?: string;
330
342
  errorMessage?: string;
331
343
  };
@@ -337,8 +349,8 @@ export type RgbLedAction = "on" | "off";
337
349
  export type RgbLedColor = "red" | "green" | "blue" | "orange" | "white";
338
350
  export type PhotoSize = "low" | "medium" | "high" | "max";
339
351
  export type ButtonPhotoSize = "low" | "medium" | "high" | "max";
340
- export type ButtonPhotoSettings = {
341
- size: ButtonPhotoSize;
352
+ export type PhotoCaptureDefaults = {
353
+ size?: PhotoSize;
342
354
  mfnr?: boolean;
343
355
  zsl?: boolean;
344
356
  noiseReduction?: boolean;
@@ -353,9 +365,14 @@ export type ButtonPhotoSettings = {
353
365
  resetCaptureTuning?: boolean;
354
366
  };
355
367
  export type PhotoCompression = "none" | "medium" | "heavy";
368
+ export type VideoRecordingDefaults = {
369
+ width: number;
370
+ height: number;
371
+ fps: number;
372
+ };
356
373
  /**
357
374
  * Optional per-recording video settings for {@link startVideoRecording}. When
358
- * omitted, the glasses fall back to their saved button-video settings. Any
375
+ * omitted, the glasses fall back to their saved video recording defaults. Any
359
376
  * field left undefined is omitted from the BLE command (glasses default applies).
360
377
  */
361
378
  export interface VideoRecordingSettings {
@@ -378,6 +395,7 @@ export declare const DeviceModels: {
378
395
  readonly Mach1: "Mentra Mach1";
379
396
  readonly Z100: "Vuzix Z100";
380
397
  readonly Frame: "Brilliant Frame";
398
+ readonly Nimo: "NIMO";
381
399
  readonly R1: "Even Realities R1";
382
400
  };
383
401
  export type DeviceModel = (typeof DeviceModels)[keyof typeof DeviceModels];
@@ -416,8 +434,7 @@ type NativeCameraFovSetting = {
416
434
  export type MicPreference = "auto" | "phone" | "glasses" | "bluetooth";
417
435
  export type MicMode = "phone" | "glasses" | "bluetoothClassic" | "bluetooth";
418
436
  export type PhotoRequestParams = {
419
- requestId: string;
420
- appId: string;
437
+ requestId?: string;
421
438
  size: PhotoSize;
422
439
  webhookUrl: string | null;
423
440
  authToken: string | null;
@@ -439,6 +456,12 @@ export type PhotoRequestParams = {
439
456
  ispDigitalGain?: number;
440
457
  ispAnalogGain?: string;
441
458
  };
459
+ export type WarmUpCameraParams = {
460
+ requestId?: string;
461
+ size: PhotoSize;
462
+ exposureTimeNs?: number | null;
463
+ durationMs?: number;
464
+ };
442
465
  export type StreamVideoConfig = {
443
466
  width?: number;
444
467
  height?: number;
@@ -455,8 +478,6 @@ export type StreamStartRequest = {
455
478
  type?: "start_stream";
456
479
  streamUrl: string;
457
480
  streamId?: string;
458
- keepAlive?: boolean;
459
- keepAliveIntervalSeconds?: number;
460
481
  sound?: boolean;
461
482
  video?: StreamVideoConfig;
462
483
  audio?: StreamAudioConfig;
@@ -589,14 +610,6 @@ export type MtkUpdateCompleteEvent = {
589
610
  message: string;
590
611
  timestamp: number;
591
612
  };
592
- export type OtaUpdateAvailableEvent = {
593
- type: "ota_update_available";
594
- version_code?: number;
595
- version_name?: string;
596
- updates?: string[];
597
- total_size?: number;
598
- cache_ready?: boolean;
599
- };
600
613
  /** @deprecated Glasses no longer emit ota_progress; use {@link OtaStatusEvent} and status-store mapping. */
601
614
  export type OtaProgressEvent = {
602
615
  type: "ota_progress";
@@ -624,7 +637,7 @@ export type OtaStatusEvent = {
624
637
  status: "in_progress" | "step_complete" | "complete" | "failed" | "idle";
625
638
  error_message?: string;
626
639
  };
627
- export type OtaQueryResult = OtaUpdateAvailableEvent | OtaStatusEvent;
640
+ export type OtaQueryResult = OtaStatusEvent;
628
641
  /** Nex BLE protobuf trace (NexEventUtils); payload matches native Map keys. */
629
642
  export type BleCommandTraceEvent = {
630
643
  command: string;
@@ -659,6 +672,7 @@ export type BluetoothSdkModuleEvents = {
659
672
  hotspot_error: (event: HotspotErrorEvent) => void;
660
673
  photo_response: (event: PhotoResponseEvent) => void;
661
674
  photo_status: (event: PhotoStatusEvent) => void;
675
+ camera_status: (event: CameraStatusEvent) => void;
662
676
  video_recording_status: (event: VideoRecordingStatusEvent) => void;
663
677
  media_success: (event: MediaUploadSuccessEvent) => void;
664
678
  media_error: (event: MediaUploadErrorEvent) => void;
@@ -682,7 +696,6 @@ export type BluetoothSdkModuleEvents = {
682
696
  stream_status: (event: StreamStatusEvent) => void;
683
697
  keep_alive_ack: (event: KeepAliveAckEvent) => void;
684
698
  mtk_update_complete: (event: MtkUpdateCompleteEvent) => void;
685
- ota_update_available: (event: OtaUpdateAvailableEvent) => void;
686
699
  ota_start_ack: (event: OtaStartAckEvent) => void;
687
700
  ota_status: (event: OtaStatusEvent) => void;
688
701
  version_info: (event: VersionInfoEvent) => void;
@@ -719,6 +732,7 @@ export type BluetoothSdkEventMap = {
719
732
  hotspot_error: HotspotErrorEvent;
720
733
  photo_response: PhotoResponseEvent;
721
734
  photo_status: PhotoStatusEvent;
735
+ camera_status: CameraStatusEvent;
722
736
  video_recording_status: VideoRecordingStatusEvent;
723
737
  media_success: MediaUploadSuccessEvent;
724
738
  media_error: MediaUploadErrorEvent;
@@ -773,13 +787,27 @@ export interface BluetoothSdkPublicModule {
773
787
  setHotspotState(enabled: boolean): Promise<HotspotStatusChangeEvent>;
774
788
  setGalleryModeEnabled(enabled: boolean): Promise<SettingsAckSuccessEvent>;
775
789
  setVoiceActivityDetectionEnabled(enabled: boolean): Promise<void>;
776
- setButtonPhotoSettings(settings: ButtonPhotoSettings): Promise<SettingsAckSuccessEvent>;
777
- setButtonVideoRecordingSettings(width: number, height: number, fps: number): Promise<SettingsAckSuccessEvent>;
778
- setButtonCameraLed(enabled: boolean): Promise<SettingsAckSuccessEvent>;
779
- setButtonMaxRecordingTime(minutes: number): Promise<SettingsAckSuccessEvent>;
790
+ setPhotoCaptureDefaults(settings: PhotoCaptureDefaults): Promise<SettingsAckSuccessEvent>;
791
+ setVideoRecordingDefaults(settings: VideoRecordingDefaults): Promise<SettingsAckSuccessEvent>;
792
+ setMaxVideoRecordingDuration(minutes: number): Promise<SettingsAckSuccessEvent>;
780
793
  setCameraFov(request: CameraFovRequest): Promise<CameraFovResult>;
794
+ /**
795
+ * Configure camera HAL tuning (ANR / gain) on Mentra Live glasses.
796
+ *
797
+ * The phone sends a {@code camera_tuning_config} BLE command; the glasses relay it as a
798
+ * {@code camconfig} broadcast to the camera HAL so parameters take effect without a reboot.
799
+ *
800
+ * **Scan-mode convention**: call with `(false, false)` when activating scan mode to disable ANR
801
+ * and pixsmart gain for sharper text/barcode captures. Call with `(true, true)` to restore
802
+ * defaults when exiting scan mode.
803
+ *
804
+ * @param anrOn `true` = ANR enabled (default), `false` = ANR disabled
805
+ * @param gainOn `true` = stock gain params (default), `false` = pixsmart gain-off params
806
+ */
807
+ setCameraTuningConfig(anrOn: boolean, gainOn: boolean): Promise<SettingsAckSuccessEvent>;
781
808
  queryGalleryStatus(): Promise<GalleryStatusEvent>;
782
809
  requestPhoto(params: PhotoRequestParams): Promise<PhotoSuccessResponseEvent>;
810
+ warmUpCamera(params: WarmUpCameraParams): Promise<CameraStatusEvent>;
783
811
  startVideoRecording(requestId: string, save: boolean, sound: boolean, settings?: VideoRecordingSettings): Promise<VideoRecordingStartedStatusEvent>;
784
812
  /**
785
813
  * Stop the active recording. When {@link webhookUrl} is provided, the glasses
@@ -834,7 +862,6 @@ export interface OtaUpdateInfo {
834
862
  versionName: string;
835
863
  updates: string[];
836
864
  totalSize: number;
837
- cacheReady?: boolean;
838
865
  }
839
866
  export interface OtaProgress {
840
867
  stage: OtaStage;
@@ -996,7 +1023,6 @@ export type BluetoothSettingsUpdate = Partial<{
996
1023
  button_video_width: number;
997
1024
  button_video_height: number;
998
1025
  button_video_fps: number;
999
- button_camera_led: boolean;
1000
1026
  button_max_recording_time: number;
1001
1027
  camera_fov: NativeCameraFovSetting;
1002
1028
  should_send_pcm: boolean;