@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
@@ -77,6 +77,20 @@ abstract class SGCManager {
77
77
  width: Int? = null,
78
78
  height: Int? = null
79
79
  ): Boolean
80
+
81
+ /**
82
+ * Show text in a positioned container with an optional rounded border.
83
+ * G2-only capability; default no-op so other glasses ignore it.
84
+ */
85
+ open fun sendPositionedText(
86
+ text: String,
87
+ x: Int,
88
+ y: Int,
89
+ width: Int,
90
+ height: Int,
91
+ borderWidth: Int = 0,
92
+ borderRadius: Int = 0
93
+ ) {}
80
94
  abstract fun showDashboard()
81
95
  abstract fun setDashboardPosition(height: Int, depth: Int)
82
96
 
@@ -171,7 +185,7 @@ abstract class SGCManager {
171
185
  // Voice Activity Detection
172
186
  open fun sendVoiceActivityDetectionSetting() {}
173
187
 
174
- // Start/stop LC3 audio playback from glasses based on the nex_audio_playback flag.
188
+ // Start/stop LC3 audio playback from glasses based on the nex_lc3_audio_playback flag.
175
189
  open fun applyNexAudioPlaybackSetting() {}
176
190
 
177
191
  // Version info
@@ -2,17 +2,18 @@ package com.mentra.bluetoothsdk
2
2
 
3
3
  import com.mentra.bluetoothsdk.utils.ControllerTypes
4
4
  import com.mentra.bluetoothsdk.utils.DeviceTypes
5
+ import expo.modules.kotlin.exception.CodedException
5
6
 
6
7
  data class MentraBluetoothSdkConfig @JvmOverloads constructor(
7
8
  val deliverCallbacksOnMainThread: Boolean = true,
8
9
  val analytics: BluetoothSdkAnalyticsConfig = BluetoothSdkAnalyticsConfig(),
9
10
  )
10
11
 
11
- class BluetoothException(
12
- val code: String,
12
+ class BluetoothSdkException(
13
+ code: String,
13
14
  message: String,
14
15
  cause: Throwable? = null,
15
- ) : IllegalStateException(message, cause)
16
+ ) : CodedException(code, message, cause)
16
17
 
17
18
  enum class DeviceModel(val deviceType: String) {
18
19
  G1(DeviceTypes.G1),
@@ -22,6 +23,7 @@ enum class DeviceModel(val deviceType: String) {
22
23
  MACH1(DeviceTypes.MACH1),
23
24
  Z100(DeviceTypes.Z100),
24
25
  FRAME(DeviceTypes.FRAME),
26
+ NIMO(DeviceTypes.NIMO),
25
27
  SIMULATED(DeviceTypes.SIMULATED),
26
28
  R1(ControllerTypes.R1);
27
29
 
@@ -9,7 +9,8 @@ object DeviceTypes {
9
9
  const val Z100 = "Vuzix Z100"
10
10
  const val FRAME = "Brilliant Frame"
11
11
  const val G2 = "Even Realities G2"
12
- val ALL = arrayOf(SIMULATED, G1, G2, MACH1, LIVE, Z100, FRAME, NEX)
12
+ const val NIMO = "NIMO"
13
+ val ALL = arrayOf(SIMULATED, G1, G2, MACH1, LIVE, Z100, FRAME, NEX, NIMO)
13
14
  }
14
15
 
15
16
  object ControllerTypes {
@@ -1,16 +1,33 @@
1
1
  package com.mentra.bluetoothsdk.camera
2
2
 
3
+ import com.mentra.bluetoothsdk.PhotoCompression
4
+ import com.mentra.bluetoothsdk.PhotoRequest
5
+ import com.mentra.bluetoothsdk.PhotoSize
3
6
  import kotlin.test.Test
7
+ import kotlin.test.assertEquals
4
8
  import kotlin.test.assertNull
9
+ import kotlin.test.assertTrue
5
10
 
6
11
  class PhotoRequestTest {
12
+ @Test
13
+ fun `constructor generates requestId when omitted`() {
14
+ val request =
15
+ PhotoRequest(
16
+ size = PhotoSize.MEDIUM,
17
+ webhookUrl = "https://example.com/upload",
18
+ compress = PhotoCompression.NONE,
19
+ sound = true,
20
+ )
21
+
22
+ assertTrue(request.requestId.startsWith("photo-"))
23
+ }
24
+
7
25
  @Test
8
26
  fun `fromMap defaults exposureTimeNs null`() {
9
27
  val request =
10
28
  PhotoRequest.fromMap(
11
29
  mapOf(
12
30
  "requestId" to "photo-1",
13
- "appId" to "com.test.app",
14
31
  "size" to "medium",
15
32
  "webhookUrl" to "https://example.com/upload",
16
33
  "compress" to "none",
@@ -20,4 +37,46 @@ class PhotoRequestTest {
20
37
 
21
38
  assertNull(request.exposureTimeNs)
22
39
  }
40
+
41
+ @Test
42
+ fun `fromMap generates requestId when omitted or blank`() {
43
+ val withoutRequestId =
44
+ PhotoRequest.fromMap(
45
+ mapOf(
46
+ "size" to "medium",
47
+ "webhookUrl" to "https://example.com/upload",
48
+ "compress" to "none",
49
+ "sound" to true,
50
+ )
51
+ )
52
+ val blankRequestId =
53
+ PhotoRequest.fromMap(
54
+ mapOf(
55
+ "requestId" to " ",
56
+ "size" to "medium",
57
+ "webhookUrl" to "https://example.com/upload",
58
+ "compress" to "none",
59
+ "sound" to true,
60
+ )
61
+ )
62
+
63
+ assertTrue(withoutRequestId.requestId.startsWith("photo-"))
64
+ assertTrue(blankRequestId.requestId.startsWith("photo-"))
65
+ }
66
+
67
+ @Test
68
+ fun `fromMap preserves explicit requestId`() {
69
+ val request =
70
+ PhotoRequest.fromMap(
71
+ mapOf(
72
+ "requestId" to "photo-1",
73
+ "size" to "medium",
74
+ "webhookUrl" to "https://example.com/upload",
75
+ "compress" to "none",
76
+ "sound" to true,
77
+ )
78
+ )
79
+
80
+ assertEquals("photo-1", request.requestId)
81
+ }
23
82
  }
@@ -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_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
  };
@@ -383,6 +395,7 @@ export declare const DeviceModels: {
383
395
  readonly Mach1: "Mentra Mach1";
384
396
  readonly Z100: "Vuzix Z100";
385
397
  readonly Frame: "Brilliant Frame";
398
+ readonly Nimo: "NIMO";
386
399
  readonly R1: "Even Realities R1";
387
400
  };
388
401
  export type DeviceModel = (typeof DeviceModels)[keyof typeof DeviceModels];
@@ -421,8 +434,7 @@ type NativeCameraFovSetting = {
421
434
  export type MicPreference = "auto" | "phone" | "glasses" | "bluetooth";
422
435
  export type MicMode = "phone" | "glasses" | "bluetoothClassic" | "bluetooth";
423
436
  export type PhotoRequestParams = {
424
- requestId: string;
425
- appId: string;
437
+ requestId?: string;
426
438
  size: PhotoSize;
427
439
  webhookUrl: string | null;
428
440
  authToken: string | null;
@@ -444,6 +456,12 @@ export type PhotoRequestParams = {
444
456
  ispDigitalGain?: number;
445
457
  ispAnalogGain?: string;
446
458
  };
459
+ export type WarmUpCameraParams = {
460
+ requestId?: string;
461
+ size: PhotoSize;
462
+ exposureTimeNs?: number | null;
463
+ durationMs?: number;
464
+ };
447
465
  export type StreamVideoConfig = {
448
466
  width?: number;
449
467
  height?: number;
@@ -592,14 +610,6 @@ export type MtkUpdateCompleteEvent = {
592
610
  message: string;
593
611
  timestamp: number;
594
612
  };
595
- export type OtaUpdateAvailableEvent = {
596
- type: "ota_update_available";
597
- version_code?: number;
598
- version_name?: string;
599
- updates?: string[];
600
- total_size?: number;
601
- cache_ready?: boolean;
602
- };
603
613
  /** @deprecated Glasses no longer emit ota_progress; use {@link OtaStatusEvent} and status-store mapping. */
604
614
  export type OtaProgressEvent = {
605
615
  type: "ota_progress";
@@ -627,7 +637,7 @@ export type OtaStatusEvent = {
627
637
  status: "in_progress" | "step_complete" | "complete" | "failed" | "idle";
628
638
  error_message?: string;
629
639
  };
630
- export type OtaQueryResult = OtaUpdateAvailableEvent | OtaStatusEvent;
640
+ export type OtaQueryResult = OtaStatusEvent;
631
641
  /** Nex BLE protobuf trace (NexEventUtils); payload matches native Map keys. */
632
642
  export type BleCommandTraceEvent = {
633
643
  command: string;
@@ -662,6 +672,7 @@ export type BluetoothSdkModuleEvents = {
662
672
  hotspot_error: (event: HotspotErrorEvent) => void;
663
673
  photo_response: (event: PhotoResponseEvent) => void;
664
674
  photo_status: (event: PhotoStatusEvent) => void;
675
+ camera_status: (event: CameraStatusEvent) => void;
665
676
  video_recording_status: (event: VideoRecordingStatusEvent) => void;
666
677
  media_success: (event: MediaUploadSuccessEvent) => void;
667
678
  media_error: (event: MediaUploadErrorEvent) => void;
@@ -685,7 +696,6 @@ export type BluetoothSdkModuleEvents = {
685
696
  stream_status: (event: StreamStatusEvent) => void;
686
697
  keep_alive_ack: (event: KeepAliveAckEvent) => void;
687
698
  mtk_update_complete: (event: MtkUpdateCompleteEvent) => void;
688
- ota_update_available: (event: OtaUpdateAvailableEvent) => void;
689
699
  ota_start_ack: (event: OtaStartAckEvent) => void;
690
700
  ota_status: (event: OtaStatusEvent) => void;
691
701
  version_info: (event: VersionInfoEvent) => void;
@@ -722,6 +732,7 @@ export type BluetoothSdkEventMap = {
722
732
  hotspot_error: HotspotErrorEvent;
723
733
  photo_response: PhotoResponseEvent;
724
734
  photo_status: PhotoStatusEvent;
735
+ camera_status: CameraStatusEvent;
725
736
  video_recording_status: VideoRecordingStatusEvent;
726
737
  media_success: MediaUploadSuccessEvent;
727
738
  media_error: MediaUploadErrorEvent;
@@ -780,8 +791,23 @@ export interface BluetoothSdkPublicModule {
780
791
  setVideoRecordingDefaults(settings: VideoRecordingDefaults): Promise<SettingsAckSuccessEvent>;
781
792
  setMaxVideoRecordingDuration(minutes: number): Promise<SettingsAckSuccessEvent>;
782
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>;
783
808
  queryGalleryStatus(): Promise<GalleryStatusEvent>;
784
809
  requestPhoto(params: PhotoRequestParams): Promise<PhotoSuccessResponseEvent>;
810
+ warmUpCamera(params: WarmUpCameraParams): Promise<CameraStatusEvent>;
785
811
  startVideoRecording(requestId: string, save: boolean, sound: boolean, settings?: VideoRecordingSettings): Promise<VideoRecordingStartedStatusEvent>;
786
812
  /**
787
813
  * Stop the active recording. When {@link webhookUrl} is provided, the glasses
@@ -836,7 +862,6 @@ export interface OtaUpdateInfo {
836
862
  versionName: string;
837
863
  updates: string[];
838
864
  totalSize: number;
839
- cacheReady?: boolean;
840
865
  }
841
866
  export interface OtaProgress {
842
867
  stage: OtaStage;