@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.
- package/README.md +4 -6
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +22 -9
- package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +5 -20
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +358 -261
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +3 -3
- package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +133 -52
- package/android/src/main/java/com/mentra/bluetoothsdk/OtaManifest.kt +12 -12
- package/android/src/main/java/com/mentra/bluetoothsdk/camera/CameraModels.kt +22 -6
- package/android/src/main/java/com/mentra/bluetoothsdk/events/BluetoothEvents.kt +1 -22
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.kt +50 -3
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +1184 -795
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +422 -78
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +3 -4
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Nimo.kt +2532 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +15 -1
- 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 +39 -14
- 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 +11 -3
- package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
- package/build/_private/BluetoothSdkModule.js +5 -0
- 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 +8 -2
- 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 +72 -54
- package/build/index.js.map +1 -1
- package/ios/BluetoothSdkModule.swift +40 -12
- package/ios/Source/BluetoothSdkDefaults.swift +2 -30
- package/ios/Source/Bridge.swift +4 -16
- package/ios/Source/DeviceManager.swift +72 -12
- package/ios/Source/MentraBluetoothSDK.swift +120 -50
- package/ios/Source/OtaManifest.swift +13 -13
- package/ios/Source/camera/CameraModels.swift +75 -9
- 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/G1.swift +51 -1
- package/ios/Source/sgcs/G2.swift +643 -203
- package/ios/Source/sgcs/MentraLive.swift +53 -39
- package/ios/Source/sgcs/Nimo.swift +1928 -0
- package/ios/Source/sgcs/SGCManager.swift +12 -5
- 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 +9 -1
- package/scripts/inject-ios-sdk-version.mjs +65 -0
- package/src/BluetoothSdk.types.ts +42 -17
- package/src/_internal.ts +4 -4
- package/src/_private/BluetoothSdkModule.ts +18 -2
- package/src/_private/cameraRequestPayload.ts +29 -0
- package/src/_private/photoRequestPayload.ts +9 -2
- 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
|
|
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
|
|
12
|
-
|
|
12
|
+
class BluetoothSdkException(
|
|
13
|
+
code: String,
|
|
13
14
|
message: String,
|
|
14
15
|
cause: Throwable? = null,
|
|
15
|
-
) :
|
|
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
|
|
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
|
|
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 =
|
|
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;
|