@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.
- package/README.md +18 -8
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +48 -48
- package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +5 -20
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +360 -269
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +3 -7
- package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +152 -72
- package/android/src/main/java/com/mentra/bluetoothsdk/OtaManifest.kt +12 -12
- package/android/src/main/java/com/mentra/bluetoothsdk/camera/CameraModels.kt +25 -11
- package/android/src/main/java/com/mentra/bluetoothsdk/controllers/ControllerManager.kt +1 -2
- package/android/src/main/java/com/mentra/bluetoothsdk/controllers/R1.kt +1 -2
- package/android/src/main/java/com/mentra/bluetoothsdk/events/BluetoothEvents.kt +1 -22
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.kt +51 -8
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +1184 -800
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Mach1.kt +1 -5
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +423 -117
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +4 -6
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Nimo.kt +2532 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +17 -5
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Simulated.kt +3 -7
- package/android/src/main/java/com/mentra/bluetoothsdk/status/DeviceStatus.kt +0 -6
- package/android/src/main/java/com/mentra/bluetoothsdk/streaming/StreamModels.kt +16 -33
- package/android/src/main/java/com/mentra/bluetoothsdk/types/DeviceModels.kt +5 -3
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/Constants.kt +2 -1
- package/android/src/test/java/com/mentra/bluetoothsdk/camera/PhotoRequestTest.kt +60 -1
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoProtocolTest.kt +333 -0
- package/build/BluetoothSdk.types.d.ts +50 -24
- package/build/BluetoothSdk.types.d.ts.map +1 -1
- package/build/BluetoothSdk.types.js +1 -0
- package/build/BluetoothSdk.types.js.map +1 -1
- package/build/_internal.d.ts +4 -4
- package/build/_internal.js +4 -4
- package/build/_internal.js.map +1 -1
- package/build/_private/BluetoothSdkModule.d.ts +14 -8
- package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
- package/build/_private/BluetoothSdkModule.js +4 -3
- package/build/_private/BluetoothSdkModule.js.map +1 -1
- package/build/_private/cameraRequestPayload.d.ts +4 -0
- package/build/_private/cameraRequestPayload.d.ts.map +1 -0
- package/build/_private/cameraRequestPayload.js +25 -0
- package/build/_private/cameraRequestPayload.js.map +1 -0
- package/build/_private/photoRequestPayload.d.ts.map +1 -1
- package/build/_private/photoRequestPayload.js +11 -3
- package/build/_private/photoRequestPayload.js.map +1 -1
- package/build/index.d.ts +1 -1
- package/build/index.d.ts.map +1 -1
- package/build/index.js +70 -61
- package/build/index.js.map +1 -1
- package/ios/BluetoothSdkModule.swift +51 -28
- package/ios/Source/Bridge.swift +4 -16
- package/ios/Source/DeviceManager.swift +76 -22
- package/ios/Source/DeviceStore.swift +0 -4
- package/ios/Source/MentraBluetoothSDK.swift +144 -78
- package/ios/Source/OtaManifest.swift +13 -13
- package/ios/Source/camera/CameraModels.swift +82 -20
- package/ios/Source/controllers/ControllerManager.swift +1 -2
- package/ios/Source/controllers/R1.swift +1 -2
- package/ios/Source/errors/{BluetoothError.swift → BluetoothSdkError.swift} +1 -1
- package/ios/Source/events/BluetoothEvents.swift +5 -27
- package/ios/Source/internal/BluetoothAvailability.swift +5 -5
- package/ios/Source/sgcs/Frame.swift +0 -2
- package/ios/Source/sgcs/G1.swift +52 -4
- package/ios/Source/sgcs/G2.swift +643 -204
- package/ios/Source/sgcs/Mach1.swift +1 -3
- package/ios/Source/sgcs/MentraLive.swift +64 -77
- package/ios/Source/sgcs/MentraNex.swift +1 -3
- package/ios/Source/sgcs/Nimo.swift +1928 -0
- package/ios/Source/sgcs/SGCManager.swift +16 -10
- package/ios/Source/sgcs/Simulated.swift +2 -6
- package/ios/Source/status/DeviceStatus.swift +0 -8
- package/ios/Source/streaming/StreamModels.swift +7 -32
- package/ios/Source/stt/STTTools.swift +1 -1
- package/ios/Source/types/DeviceModels.swift +5 -0
- package/ios/Source/utils/Constants.swift +2 -0
- package/package.json +6 -1
- package/src/BluetoothSdk.types.ts +54 -28
- package/src/_internal.ts +4 -4
- package/src/_private/BluetoothSdkModule.ts +20 -11
- package/src/_private/cameraRequestPayload.ts +29 -0
- package/src/_private/photoRequestPayload.ts +12 -3
- 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" | "
|
|
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
|
|
341
|
-
size
|
|
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
|
|
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
|
|
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 =
|
|
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
|
-
|
|
777
|
-
|
|
778
|
-
|
|
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;
|