@mentra/bluetooth-sdk 0.1.21-beta.0 → 0.1.21-beta.2

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 (89) hide show
  1. package/README.md +22 -11
  2. package/android/build.gradle +3 -0
  3. package/android/src/main/AndroidManifest.xml +3 -1
  4. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkAnalytics.kt +34 -0
  5. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkDefaults.kt +1 -0
  6. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +43 -3
  7. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +7 -2
  8. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +82 -6
  9. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +5 -1
  10. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +32 -3
  11. package/android/src/main/java/com/mentra/bluetoothsdk/OtaManifest.kt +43 -3
  12. package/android/src/main/java/com/mentra/bluetoothsdk/camera/CameraModels.kt +17 -4
  13. package/android/src/main/java/com/mentra/bluetoothsdk/debug/BleTraceLogger.kt +1 -1
  14. package/android/src/main/java/com/mentra/bluetoothsdk/services/Foreground.kt +53 -0
  15. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Ar99.java +2653 -0
  16. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +161 -14
  17. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +3 -0
  18. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ar99/ota/Ar99OtaManager.java +566 -0
  19. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ar99/ota/OtaByteUtils.java +18 -0
  20. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ar99/ota/OtaCommandConstants.java +35 -0
  21. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ar99/ota/OtaCrc32Util.java +13 -0
  22. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ar99/ota/OtaGattTransport.java +11 -0
  23. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ar99/ota/OtaProtocol.java +92 -0
  24. package/android/src/main/java/com/mentra/bluetoothsdk/streaming/StreamModels.kt +41 -1
  25. package/android/src/main/java/com/mentra/bluetoothsdk/types/DeviceModels.kt +7 -0
  26. package/android/src/main/java/com/mentra/bluetoothsdk/utils/BleJsonCompact.java +8 -0
  27. package/android/src/main/java/com/mentra/bluetoothsdk/utils/Constants.kt +2 -1
  28. package/android/src/main/java/com/mentra/bluetoothsdk/utils/PhoneAudioMonitor.kt +45 -0
  29. package/android/src/main/java/com/mentra/bluetoothsdk/utils/audio/Ar99OpusPcmDecoder.java +398 -0
  30. package/android/src/test/java/com/mentra/bluetoothsdk/OtaManifestDowngradeTest.kt +86 -0
  31. package/android/src/test/java/com/mentra/bluetoothsdk/StreamStatusEventTest.kt +29 -0
  32. package/android/src/test/java/com/mentra/bluetoothsdk/camera/PhotoRequestTest.kt +30 -0
  33. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/Ar99AdvertisementMatcherTest.java +22 -0
  34. package/android/src/test/java/com/mentra/bluetoothsdk/utils/BleJsonCompactTest.java +20 -0
  35. package/android/src/test/java/com/mentra/bluetoothsdk/utils/PhoneAudioMonitorTest.kt +45 -0
  36. package/build/BluetoothSdk.types.d.ts +58 -25
  37. package/build/BluetoothSdk.types.d.ts.map +1 -1
  38. package/build/BluetoothSdk.types.js +2 -1
  39. package/build/BluetoothSdk.types.js.map +1 -1
  40. package/build/_internal.d.ts +1 -0
  41. package/build/_internal.d.ts.map +1 -1
  42. package/build/_internal.js +1 -0
  43. package/build/_internal.js.map +1 -1
  44. package/build/_private/BluetoothSdkModule.d.ts +8 -2
  45. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  46. package/build/_private/BluetoothSdkModule.js +6 -3
  47. package/build/_private/BluetoothSdkModule.js.map +1 -1
  48. package/build/_private/cameraRequestPayload.d.ts +1 -1
  49. package/build/_private/cameraRequestPayload.d.ts.map +1 -1
  50. package/build/_private/cameraRequestPayload.js +6 -0
  51. package/build/_private/cameraRequestPayload.js.map +1 -1
  52. package/build/_private/photoRequestPayload.d.ts.map +1 -1
  53. package/build/_private/photoRequestPayload.js +10 -1
  54. package/build/_private/photoRequestPayload.js.map +1 -1
  55. package/build/_private/sdkOtaManifest.d.ts +20 -0
  56. package/build/_private/sdkOtaManifest.d.ts.map +1 -0
  57. package/build/_private/sdkOtaManifest.js +28 -0
  58. package/build/_private/sdkOtaManifest.js.map +1 -0
  59. package/build/index.d.ts +1 -1
  60. package/build/index.d.ts.map +1 -1
  61. package/build/index.js +7 -1
  62. package/build/index.js.map +1 -1
  63. package/ios/BluetoothSdkModule.swift +41 -2
  64. package/ios/Source/BluetoothSdkDefaults.swift +2 -1
  65. package/ios/Source/Bridge.swift +15 -3
  66. package/ios/Source/DeviceManager.swift +67 -17
  67. package/ios/Source/DeviceStore.swift +5 -1
  68. package/ios/Source/MentraBluetoothSDK.swift +37 -8
  69. package/ios/Source/OtaManifest.swift +47 -4
  70. package/ios/Source/camera/CameraModels.swift +20 -5
  71. package/ios/Source/internal/BleTraceLogger.swift +1 -1
  72. package/ios/Source/internal/BluetoothSdkAnalytics.swift +39 -0
  73. package/ios/Source/sgcs/Ar99.swift +2451 -0
  74. package/ios/Source/sgcs/MentraLive.swift +174 -38
  75. package/ios/Source/sgcs/SGCManager.swift +8 -0
  76. package/ios/Source/sgcs/ar99/ota/Ar99OtaManager.swift +637 -0
  77. package/ios/Source/streaming/StreamModels.swift +71 -7
  78. package/ios/Source/types/DeviceModels.swift +13 -0
  79. package/ios/Source/utils/BleJsonCompact.swift +8 -0
  80. package/ios/Source/utils/Constants.swift +2 -0
  81. package/ios/Source/utils/PhoneAudioMonitor.swift +4 -0
  82. package/package.json +1 -1
  83. package/src/BluetoothSdk.types.ts +61 -28
  84. package/src/_internal.ts +1 -0
  85. package/src/_private/BluetoothSdkModule.ts +20 -6
  86. package/src/_private/cameraRequestPayload.ts +8 -2
  87. package/src/_private/photoRequestPayload.ts +12 -2
  88. package/src/_private/sdkOtaManifest.ts +31 -0
  89. package/src/index.ts +9 -1
@@ -0,0 +1,92 @@
1
+ package com.mentra.bluetoothsdk.sgcs.ar99.ota;
2
+
3
+ import java.io.ByteArrayOutputStream;
4
+
5
+ public final class OtaProtocol {
6
+ private OtaProtocol() {}
7
+
8
+ public static byte[] buildFrame(byte cmd, byte paramType, byte[] payload) {
9
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
10
+ out.write(OtaCommandConstants.OTA.SVC_ID & 0xFF);
11
+ out.write(cmd & 0xFF);
12
+ out.write(paramType & 0xFF);
13
+ int len = payload != null ? payload.length : 0;
14
+ out.write(len & 0xFF);
15
+ out.write((len >> 8) & 0xFF);
16
+ if (payload != null && payload.length > 0) {
17
+ try {
18
+ out.write(payload);
19
+ } catch (Exception ignored) {
20
+ }
21
+ }
22
+ return out.toByteArray();
23
+ }
24
+
25
+ public static byte[] buildRequestUpgrade(int packageSize, int blockSize, long crc32) {
26
+ // Wire layout matches the vendor bring-up frame: type marker, then TLV
27
+ // 0x0A/len4 = package size (LE). CRC is validated per-block in SEND_IMAGE_DATA;
28
+ // blockSize is negotiated separately in CONNECT_NEGOTIATION.
29
+ ByteArrayOutputStream payload = new ByteArrayOutputStream();
30
+ payload.write(0x09);
31
+ payload.write(0x01);
32
+ payload.write(0x00);
33
+ payload.write(0x01);
34
+ payload.write(0x0A);
35
+ payload.write(0x04);
36
+ payload.write(packageSize & 0xFF);
37
+ payload.write((packageSize >> 8) & 0xFF);
38
+ payload.write((packageSize >> 16) & 0xFF);
39
+ payload.write((packageSize >> 24) & 0xFF);
40
+ payload.write(0x00);
41
+ return buildFrame(OtaCommandConstants.OTA.REQUEST_UPGRADE, (byte) 0x80, payload.toByteArray());
42
+ }
43
+
44
+ public static byte[] buildConnectNegotiation(int blockSize) {
45
+ return buildFrame(OtaCommandConstants.OTA.CONNECT_NEGOTIATION, (byte) 0x80, null);
46
+ }
47
+
48
+ public static byte[] buildNegotiationResult() {
49
+ ByteArrayOutputStream payload = new ByteArrayOutputStream();
50
+ payload.write(0x01);
51
+ payload.write(0x01);
52
+ payload.write(0x00);
53
+ payload.write(0x01);
54
+ return buildFrame(OtaCommandConstants.OTA.NEGOTIATION_RESULT, (byte) 0x80, payload.toByteArray());
55
+ }
56
+
57
+ public static byte[] buildSendImageData(int psn, byte[] data) {
58
+ ByteArrayOutputStream payload = new ByteArrayOutputStream();
59
+ payload.write(psn & 0xFF);
60
+ long crc32 = OtaCrc32Util.calculate(data);
61
+ payload.write((byte) (crc32 & 0xFF));
62
+ payload.write((byte) ((crc32 >> 8) & 0xFF));
63
+ payload.write((byte) ((crc32 >> 16) & 0xFF));
64
+ payload.write((byte) ((crc32 >> 24) & 0xFF));
65
+ if (data != null && data.length > 0) {
66
+ try {
67
+ payload.write(data);
68
+ } catch (Exception ignored) {
69
+ }
70
+ }
71
+ return buildFrame(OtaCommandConstants.OTA.SEND_IMAGE_DATA, (byte) 0x80, payload.toByteArray());
72
+ }
73
+
74
+ public static FrameHeader parseFrameHeader(byte[] frame) {
75
+ if (frame == null || frame.length < 5) return null;
76
+ FrameHeader header = new FrameHeader();
77
+ header.svcId = frame[0];
78
+ header.cmd = frame[1];
79
+ header.paramType = frame[2];
80
+ header.paramLen = OtaByteUtils.readShortLE(frame, 3) & 0xFFFF;
81
+ header.payloadOffset = 5;
82
+ return header;
83
+ }
84
+
85
+ public static class FrameHeader {
86
+ public byte svcId;
87
+ public byte cmd;
88
+ public byte paramType;
89
+ public int paramLen;
90
+ public int payloadOffset;
91
+ }
92
+ }
@@ -168,12 +168,45 @@ data class StreamResolvedConfig @JvmOverloads constructor(
168
168
  }
169
169
  }
170
170
 
171
+ /** Live encoder and device telemetry reported by the glasses while streaming. */
172
+ data class StreamLiveStats @JvmOverloads constructor(
173
+ val bitrate: Long? = null,
174
+ val fps: Double? = null,
175
+ val droppedFrames: Long? = null,
176
+ val duration: Long? = null,
177
+ val temperatureC: Double? = null,
178
+ ) {
179
+ fun toMap(): Map<String, Any> =
180
+ buildMap {
181
+ bitrate?.let { put("bitrate", it) }
182
+ fps?.let { put("fps", it) }
183
+ droppedFrames?.let { put("droppedFrames", it) }
184
+ duration?.let { put("duration", it) }
185
+ temperatureC?.let { put("temperatureC", it) }
186
+ }
187
+
188
+ companion object {
189
+ @JvmStatic
190
+ fun fromMap(values: Map<String, Any>?): StreamLiveStats? {
191
+ values ?: return null
192
+ return StreamLiveStats(
193
+ bitrate = longValue(values, "bitrate"),
194
+ fps = doubleValue(values, "fps"),
195
+ droppedFrames = longValue(values, "droppedFrames"),
196
+ duration = longValue(values, "duration"),
197
+ temperatureC = doubleValue(values, "temperatureC"),
198
+ )
199
+ }
200
+ }
201
+ }
202
+
171
203
  data class StreamRequest @JvmOverloads constructor(
172
204
  val streamUrl: String,
173
205
  val streamId: String = "",
174
206
  val sound: Boolean = true,
175
207
  val video: StreamVideoConfig? = null,
176
208
  val audio: StreamAudioConfig? = null,
209
+ val authToken: String? = null,
177
210
  ) {
178
211
  fun toMap(): Map<String, Any> =
179
212
  buildMap {
@@ -183,6 +216,7 @@ data class StreamRequest @JvmOverloads constructor(
183
216
  put("sound", sound)
184
217
  video?.toMap()?.takeIf { it.isNotEmpty() }?.let { put("video", it) }
185
218
  audio?.toMap()?.takeIf { it.isNotEmpty() }?.let { put("audio", it) }
219
+ authToken?.takeIf { it.isNotEmpty() }?.let { put("authToken", it) }
186
220
  }
187
221
 
188
222
  companion object {
@@ -196,6 +230,7 @@ data class StreamRequest @JvmOverloads constructor(
196
230
  sound = values["sound"] as? Boolean ?: true,
197
231
  video = StreamVideoConfig.fromMap(stringMapValue(values["video"])),
198
232
  audio = StreamAudioConfig.fromMap(stringMapValue(values["audio"])),
233
+ authToken = values["authToken"] as? String ?: values["auth_token"] as? String,
199
234
  )
200
235
  }
201
236
  }
@@ -435,6 +470,7 @@ sealed interface StreamStatus {
435
470
 
436
471
  data class StreamStatusEvent(
437
472
  val status: StreamStatus,
473
+ val stats: StreamLiveStats? = null,
438
474
  ) {
439
475
  // True when the glasses will retry the failed publisher themselves
440
476
  // (emitting side lands in PR #3488); absent on older firmware and on
@@ -443,7 +479,10 @@ data class StreamStatusEvent(
443
479
  var willRetry: Boolean? = null
444
480
  private set
445
481
 
446
- constructor(values: Map<String, Any>) : this(StreamStatus.fromMap(values)) {
482
+ constructor(values: Map<String, Any>) : this(
483
+ status = StreamStatus.fromMap(values),
484
+ stats = StreamLiveStats.fromMap(stringMapValue(values["stats"])),
485
+ ) {
447
486
  willRetry = boolValue(values, "willRetry")
448
487
  }
449
488
 
@@ -453,6 +492,7 @@ data class StreamStatusEvent(
453
492
  val values: Map<String, Any>
454
493
  get() = buildMap {
455
494
  putAll(status.toEventMap())
495
+ stats?.let { put("stats", it.toMap()) }
456
496
  willRetry?.let { put("willRetry", it) }
457
497
  }
458
498
  }
@@ -23,6 +23,7 @@ enum class DeviceModel(val deviceType: String) {
23
23
  Z100(DeviceTypes.Z100),
24
24
  FRAME(DeviceTypes.FRAME),
25
25
  NIMO(DeviceTypes.NIMO),
26
+ AR99(DeviceTypes.AR99),
26
27
  SIMULATED(DeviceTypes.SIMULATED),
27
28
  R1(ControllerTypes.R1);
28
29
 
@@ -38,6 +39,7 @@ data class Device(
38
39
  val name: String,
39
40
  /** Android Bluetooth address when available. */
40
41
  val address: String? = null,
42
+ val projectName: String? = null,
41
43
  val rssi: Int? = null,
42
44
  /** Stable app-facing scan-result key. Do not parse; use typed fields instead. */
43
45
  val id: String = address?.takeIf { it.isNotBlank() } ?: "${model.deviceType}:$name",
@@ -50,6 +52,9 @@ data class Device(
50
52
  address?.takeIf { it.isNotBlank() }?.let {
51
53
  put("address", it)
52
54
  }
55
+ projectName?.takeIf { it.isNotBlank() }?.let {
56
+ put("projectName", it)
57
+ }
53
58
  rssi?.let { put("rssi", it) }
54
59
  }
55
60
 
@@ -58,12 +63,14 @@ data class Device(
58
63
  val model = stringValue(values, "model") ?: return null
59
64
  val name = stringValue(values, "name") ?: return null
60
65
  val address = stringValue(values, "address")?.takeIf { it.isNotBlank() }
66
+ val projectName = stringValue(values, "projectName")?.takeIf { it.isNotBlank() }
61
67
  val rssi = numberValue(values, "rssi")
62
68
  val id = stringValue(values, "id")?.takeIf { it.isNotBlank() } ?: address ?: "${model}:$name"
63
69
  return Device(
64
70
  model = DeviceModel.fromDeviceType(model),
65
71
  name = name,
66
72
  address = address,
73
+ projectName = projectName,
67
74
  rssi = rssi,
68
75
  id = id,
69
76
  )
@@ -66,6 +66,14 @@ public final class BleJsonCompact {
66
66
  putKey("transferMethod", "tm");
67
67
  putKey("manual", "m");
68
68
  putKey("reconnecting", "rc");
69
+ putKey("stats", "st");
70
+ putKey("width", "w");
71
+ putKey("height", "h");
72
+ putKey("bitrate", "br");
73
+ putKey("fps", "f");
74
+ putKey("droppedFrames", "df");
75
+ putKey("duration", "du");
76
+ putKey("temperatureC", "tc");
69
77
 
70
78
  putEnum(PHOTO_STATUS_TO_INT, PHOTO_STATUS_FROM_INT, "accepted", 0);
71
79
  putEnum(PHOTO_STATUS_TO_INT, PHOTO_STATUS_FROM_INT, "queued", 1);
@@ -10,7 +10,8 @@ object DeviceTypes {
10
10
  const val FRAME = "Brilliant Frame"
11
11
  const val G2 = "Even Realities G2"
12
12
  const val NIMO = "NIMO"
13
- val ALL = arrayOf(SIMULATED, G1, G2, MACH1, LIVE, Z100, FRAME, NEX, NIMO)
13
+ const val AR99 = "AR99"
14
+ val ALL = arrayOf(SIMULATED, G1, G2, MACH1, LIVE, Z100, FRAME, NEX, NIMO, AR99)
14
15
  }
15
16
 
16
17
  object ControllerTypes {
@@ -26,6 +26,14 @@ class PhoneAudioMonitor private constructor(private val context: Context) {
26
26
  @Volatile
27
27
  private var instance: PhoneAudioMonitor? = null
28
28
 
29
+ internal fun shouldKeepOwnAppAudioSignal(
30
+ signalledPlaying: Boolean,
31
+ systemAudioPlaying: Boolean,
32
+ nowMs: Long,
33
+ holdoffUntilMs: Long,
34
+ ): Boolean =
35
+ signalledPlaying && (systemAudioPlaying || nowMs < holdoffUntilMs)
36
+
29
37
  @JvmStatic
30
38
  fun getInstance(context: Context): PhoneAudioMonitor {
31
39
  return instance ?: synchronized(this) {
@@ -48,6 +56,10 @@ class PhoneAudioMonitor private constructor(private val context: Context) {
48
56
  private var listener: Listener? = null
49
57
  private var isMonitoring = false
50
58
  private var lastKnownState = false
59
+ // Set explicitly by AudioPlaybackService. Unlike isPlaying(), this is
60
+ // limited to Mentra's own playback and is safe for mic-watchdog gating.
61
+ @Volatile
62
+ private var ownAppAudioPlaying = false
51
63
 
52
64
  // Rate limiting: max 1 state change notification per 500ms
53
65
  // If state changes rapidly (true/false/true), we wait and send the final state
@@ -78,6 +90,37 @@ class PhoneAudioMonitor private constructor(private val context: Context) {
78
90
  return audioManager.isMusicActive
79
91
  }
80
92
 
93
+ /**
94
+ * Return the explicit React Native playback hint, reconciling it with
95
+ * Android's actual media state.
96
+ *
97
+ * Expo can miss a completion callback while its Activity is backgrounded,
98
+ * leaving the explicit hint stuck at PLAYING. The hint only protects the
99
+ * short A2DP codec transition; after that hold-off, Android's
100
+ * isMusicActive is authoritative. Clearing a stale hint lets the glasses
101
+ * mic watchdog recover instead of suppressing recovery forever.
102
+ */
103
+ @Synchronized
104
+ fun isOwnAppAudioPlaying(): Boolean {
105
+ val systemAudioPlaying = isPlaying()
106
+ if (
107
+ shouldKeepOwnAppAudioSignal(
108
+ ownAppAudioPlaying,
109
+ systemAudioPlaying,
110
+ System.currentTimeMillis(),
111
+ audioStartHoldoffUntil,
112
+ )
113
+ ) {
114
+ return true
115
+ }
116
+
117
+ if (ownAppAudioPlaying) {
118
+ ownAppAudioPlaying = false
119
+ Bridge.log("$TAG: Cleared stale own-app audio flag (system audio is inactive)")
120
+ }
121
+ return false
122
+ }
123
+
81
124
  /**
82
125
  * Notify the monitor that our own app started/stopped playing audio
83
126
  * Called from RN AudioPlaybackService via Bridge
@@ -87,7 +130,9 @@ class PhoneAudioMonitor private constructor(private val context: Context) {
87
130
  * there's a brief gap where the system reports 0 active configs (codec is switching).
88
131
  * The hold-off prevents us from reporting STOPPED during this transition.
89
132
  */
133
+ @Synchronized
90
134
  fun setOwnAppAudioPlaying(playing: Boolean) {
135
+ ownAppAudioPlaying = playing
91
136
  Bridge.log("$TAG: Own app audio -> ${if (playing) "PLAYING" else "STOPPED"}")
92
137
 
93
138
  if (playing) {