@mentra/bluetooth-sdk 3.2.0-dev.203 → 3.2.0-dev.206

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 (31) hide show
  1. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +0 -8
  2. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +5 -5
  3. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +13 -152
  4. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +14 -0
  5. package/android/src/main/java/com/mentra/bluetoothsdk/streaming/StreamControllerProbe.kt +14 -0
  6. package/android/src/main/java/com/mentra/bluetoothsdk/streaming/StreamModels.kt +16 -0
  7. package/android/src/main/java/com/mentra/bluetoothsdk/streaming/StreamSessionState.kt +32 -0
  8. package/android/src/test/java/com/mentra/bluetoothsdk/streaming/StreamControllerProbeTest.kt +24 -0
  9. package/android/src/test/java/com/mentra/bluetoothsdk/streaming/StreamSessionStateTest.kt +43 -0
  10. package/build/BluetoothSdk.types.d.ts +6 -1
  11. package/build/BluetoothSdk.types.d.ts.map +1 -1
  12. package/build/BluetoothSdk.types.js.map +1 -1
  13. package/build/_private/BluetoothSdkModule.d.ts +1 -3
  14. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  15. package/build/_private/BluetoothSdkModule.js.map +1 -1
  16. package/build/generated/releaseMetadata.js +5 -5
  17. package/build/generated/releaseMetadata.js.map +1 -1
  18. package/ios/BluetoothSdkModule.swift +0 -11
  19. package/ios/Source/BluetoothSdkDefaults.swift +1 -1
  20. package/ios/Source/GeneratedReleaseMetadata.swift +5 -5
  21. package/ios/Source/MentraBluetoothSDK.swift +10 -130
  22. package/ios/Source/sgcs/MentraLive.swift +46 -35
  23. package/ios/Source/streaming/StreamControllerProbe.swift +18 -0
  24. package/ios/Source/streaming/StreamModels.swift +12 -0
  25. package/ios/Source/streaming/StreamSessionState.swift +29 -0
  26. package/ios/Tests/StreamControllerProbeTests.swift +27 -0
  27. package/ios/Tests/StreamSessionStateTests.swift +38 -0
  28. package/package.json +1 -1
  29. package/src/BluetoothSdk.types.ts +6 -1
  30. package/src/_private/BluetoothSdkModule.ts +0 -3
  31. package/src/generated/releaseMetadata.ts +5 -5
@@ -807,16 +807,8 @@ class BluetoothSdkModule : Module() {
807
807
  requireSdk().startStream(StreamRequest.fromMap(params)).values
808
808
  }
809
809
 
810
- SdkCoroutineFunction("startExternallyManagedStream") { params: Map<String, Any> ->
811
- requireSdk().startExternallyManagedStream(StreamRequest.fromMap(params)).values
812
- }
813
-
814
810
  SdkCoroutineFunction("stopStream") { -> requireSdk().stopStream().values }
815
811
 
816
- SdkAsyncFunction("sendExternallyManagedStreamKeepAlive") { params: Map<String, Any> ->
817
- sdk?.sendExternallyManagedStreamKeepAlive(StreamKeepAliveRequest.fromMap(params))
818
- }
819
-
820
812
  // MARK: - Microphone Commands
821
813
 
822
814
  SdkAsyncFunction("setMicState") {
@@ -3,9 +3,9 @@ package com.mentra.bluetoothsdk
3
3
  /** Generated by release CI. Do not edit in a release checkout. */
4
4
  internal object GeneratedReleaseMetadata {
5
5
  const val FAMILY_BASE_VERSION: String = "3.2.0"
6
- const val RELEASE_IDENTITY: String = "3.2.0-dev.203"
7
- const val RELEASE_SET_ID: String = "mentra-3.2.0-dev.203"
8
- const val SOURCE_COMMIT: String = "7eb4deefff27d498c2b4562d628607f399aa1f40"
9
- const val OTA_MANIFEST_URL: String = "https://github.com/Mentra-Community/MentraOS/releases/download/mentra-builds-v3.2.0/mentra-live-ota-3.2.0-dev.203.json"
10
- const val OTA_MANIFEST_SHA256: String = "0ae026238c8e70970be4e181cc1ae73dd905418292edebddb610012f4f0a9b6f"
6
+ const val RELEASE_IDENTITY: String = "3.2.0-dev.206"
7
+ const val RELEASE_SET_ID: String = "mentra-3.2.0-dev.206"
8
+ const val SOURCE_COMMIT: String = "3c22b92a5a53ce608c087503417ca5b3d16cc926"
9
+ const val OTA_MANIFEST_URL: String = "https://github.com/Mentra-Community/MentraOS/releases/download/mentra-builds-v3.2.0/mentra-live-ota-3.2.0-dev.206.json"
10
+ const val OTA_MANIFEST_SHA256: String = "7bc5e09f2757b2c11da58c93459bb0a554f406dd0ae5b50b58db28b64b92c838"
11
11
  }
@@ -8,6 +8,7 @@ import android.content.Context
8
8
  import android.os.Bundle
9
9
  import android.os.Handler
10
10
  import android.os.Looper
11
+ import com.mentra.bluetoothsdk.streaming.StreamSessionState
11
12
  import com.mentra.bluetoothsdk.utils.ControllerTypes
12
13
  import com.mentra.bluetoothsdk.utils.PhoneAudioMonitor
13
14
  import java.util.Collections
@@ -52,8 +53,7 @@ class MentraBluetoothSdk private constructor(
52
53
  }
53
54
  private var activityLifecycleCallbacksRegistered = false
54
55
  private var suppressDefaultDeviceEvents = false
55
- private val streamKeepAliveLock = Any()
56
- private var activeStreamKeepAlive: ActiveStreamKeepAlive? = null
56
+ private val streamSession = StreamSessionState()
57
57
  private val pendingPhotoRequests = ConcurrentHashMap<String, PendingResponse<PhotoResponseEvent>>()
58
58
  private val pendingCameraStatusRequests = ConcurrentHashMap<String, PendingResponse<CameraStatusEvent>>()
59
59
  private val pendingVideoRecordingRequests =
@@ -110,7 +110,6 @@ class MentraBluetoothSdk private constructor(
110
110
  private const val OTA_BES_VERSION_WAIT_MS = 5_000L
111
111
  private const val OTA_MTK_VERSION_WAIT_MS = 2_000L
112
112
  private const val OTA_VERSION_POLL_MS = 100L
113
- private const val DEFAULT_STREAM_KEEP_ALIVE_INTERVAL_SECONDS = 5
114
113
  private const val MAX_MISSED_STREAM_KEEP_ALIVE_ACKS = 3
115
114
 
116
115
  // Stream states the glasses only reach after start_stream has run past
@@ -139,18 +138,6 @@ class MentraBluetoothSdk private constructor(
139
138
  ): MentraBluetoothSdk = MentraBluetoothSdk(context, config, listener)
140
139
  }
141
140
 
142
- private data class ActiveStreamKeepAlive(
143
- val streamId: String,
144
- val intervalMs: Long,
145
- var pendingAckId: String? = null,
146
- var missedAckCount: Int = 0,
147
- var nextTick: Runnable? = null,
148
- // Missed-ACK counting only begins once the stream is confirmed live/coming up, so a
149
- // slow startup (glasses can't ACK until they reach starting/streaming) can't trip a
150
- // false keep-alive timeout before the stream is ever up.
151
- var armed: Boolean = false,
152
- )
153
-
154
141
  // seq records send order (assigned and handed to the BLE queue under
155
142
  // streamStartOrderLock) so that an id-carrying status for a newer start can
156
143
  // identify which older in-flight starts it preempted.
@@ -1115,16 +1102,11 @@ class MentraBluetoothSdk private constructor(
1115
1102
  }
1116
1103
  }
1117
1104
 
1118
- suspend fun startStream(request: StreamRequest): StreamStatusEvent =
1119
- startStream(request, startSdkKeepAlive = true)
1120
-
1121
- internal suspend fun startExternallyManagedStream(request: StreamRequest): StreamStatusEvent =
1122
- startStream(request, startSdkKeepAlive = false)
1123
-
1124
- private suspend fun startStream(
1125
- request: StreamRequest,
1126
- startSdkKeepAlive: Boolean,
1127
- ): StreamStatusEvent {
1105
+ suspend fun startStream(request: StreamRequest): StreamStatusEvent {
1106
+ requireGlassesConnected("start stream")
1107
+ if (!streamSession.supported) {
1108
+ throw BluetoothSdkException("stream_control_unsupported", "Update the glasses software before starting a stream.")
1109
+ }
1128
1110
  val message = request.toMap().toMutableMap()
1129
1111
  val streamId = (message["streamId"] as? String)?.takeIf { it.isNotBlank() }
1130
1112
  ?: "sdk-${UUID.randomUUID()}"
@@ -1139,23 +1121,15 @@ class MentraBluetoothSdk private constructor(
1139
1121
  val registered = PendingStreamStart(streamStartSeq.incrementAndGet(), pending)
1140
1122
  pendingStreamStarts[streamId] = registered
1141
1123
  start = registered
1142
- stopStreamKeepAliveMonitor()
1143
1124
  deviceManager.startStream(message)
1144
1125
  }
1145
1126
  val event = pending.await(STREAM_START_TIMEOUT_MS)
1146
- if (startSdkKeepAlive) {
1147
- startStreamKeepAliveMonitor(streamId, DEFAULT_STREAM_KEEP_ALIVE_INTERVAL_SECONDS)
1148
- }
1149
1127
  return event
1150
1128
  } finally {
1151
1129
  start?.let { pendingStreamStarts.remove(streamId, it) }
1152
1130
  }
1153
1131
  }
1154
1132
 
1155
- internal fun sendExternallyManagedStreamKeepAlive(request: StreamKeepAliveRequest) {
1156
- deviceManager.keepStreamAlive(request.toMap().toMutableMap())
1157
- }
1158
-
1159
1133
  suspend fun rgbLedControl(request: RgbLedRequest): RgbLedControlResponseEvent {
1160
1134
  val pending = PendingResponse<RgbLedControlResponseEvent>("RGB LED command ${request.requestId}")
1161
1135
  pendingRgbLedRequests[request.requestId] = pending
@@ -1177,9 +1151,7 @@ class MentraBluetoothSdk private constructor(
1177
1151
 
1178
1152
  suspend fun stopStream(): StreamStatusEvent {
1179
1153
  val pending = PendingResponse<StreamStatusEvent>("stop stream")
1180
- val targetStreamId = synchronized(streamKeepAliveLock) {
1181
- activeStreamKeepAlive?.streamId
1182
- }
1154
+ val targetStreamId = streamSession.currentStreamId
1183
1155
  try {
1184
1156
  // The seq draw and the BLE hand-off share startStream's ordering
1185
1157
  // critical section: the stop takes its own slot in the send order,
@@ -1196,7 +1168,6 @@ class MentraBluetoothSdk private constructor(
1196
1168
  pendingStreamStop =
1197
1169
  PendingStreamStop(targetStreamId, streamStartSeq.incrementAndGet(), pending)
1198
1170
  }
1199
- stopStreamKeepAliveMonitor()
1200
1171
  deviceManager.stopStream()
1201
1172
  }
1202
1173
  return pending.await(STREAM_STOP_TIMEOUT_MS)
@@ -1554,7 +1525,6 @@ class MentraBluetoothSdk private constructor(
1554
1525
  }
1555
1526
 
1556
1527
  override fun close() {
1557
- stopStreamKeepAliveMonitor()
1558
1528
  resetWifiProtocolSession("", "sdk_closed")
1559
1529
  if (activityLifecycleCallbacksRegistered) {
1560
1530
  (appContext as? Application)?.unregisterActivityLifecycleCallbacks(
@@ -1871,17 +1841,18 @@ class MentraBluetoothSdk private constructor(
1871
1841
  handleRgbLedResponseForRequests(event)
1872
1842
  dispatchToListeners { it.onRgbLedControlResponse(event) }
1873
1843
  }
1844
+ "stream_control_ready" -> {
1845
+ streamSession.ready(data["sid"] as? String, (data["streamControlVersion"] as? Number)?.toInt())
1846
+ }
1874
1847
  "stream_status" -> {
1848
+ if (data.containsKey("revision") && !streamSession.accept(data)) return
1875
1849
  val event = StreamStatusEvent(data)
1876
1850
  handleStreamStatusForRequests(event)
1877
- handleStreamStatusForKeepAlive(event.status)
1878
1851
  dispatchToListeners { it.onStreamStatus(event) }
1879
1852
  }
1880
1853
  "keep_alive_ack" -> {
1881
1854
  val event = KeepAliveAckEvent(data)
1882
- if (!handleStreamKeepAliveAck(event)) {
1883
- dispatchToListeners { it.onKeepAliveAck(event) }
1884
- }
1855
+ dispatchToListeners { it.onKeepAliveAck(event) }
1885
1856
  }
1886
1857
  "ota_start_ack" -> {
1887
1858
  val event = OtaStartAckEvent.fromMap(data + mapOf("type" to "ota_start_ack"))
@@ -1974,29 +1945,6 @@ class MentraBluetoothSdk private constructor(
1974
1945
  }
1975
1946
  }
1976
1947
 
1977
- private fun startStreamKeepAliveMonitor(streamId: String, requestedIntervalSeconds: Int) {
1978
- val intervalSeconds =
1979
- requestedIntervalSeconds.takeIf { it > 0 } ?: DEFAULT_STREAM_KEEP_ALIVE_INTERVAL_SECONDS
1980
- val tracker = ActiveStreamKeepAlive(
1981
- streamId = streamId,
1982
- intervalMs = intervalSeconds * 1_000L,
1983
- )
1984
- synchronized(streamKeepAliveLock) {
1985
- activeStreamKeepAlive = tracker
1986
- }
1987
- sendNextStreamKeepAlive(tracker)
1988
- }
1989
-
1990
- private fun stopStreamKeepAliveMonitor() {
1991
- val tracker =
1992
- synchronized(streamKeepAliveLock) {
1993
- val current = activeStreamKeepAlive
1994
- activeStreamKeepAlive = null
1995
- current
1996
- }
1997
- tracker?.nextTick?.let { mainHandler.removeCallbacks(it) }
1998
- }
1999
-
2000
1948
  private fun handleStreamStatusForRequests(event: StreamStatusEvent) {
2001
1949
  val startMatch = matchingStreamStart(event)
2002
1950
  if (startMatch != null) {
@@ -2547,94 +2495,7 @@ class MentraBluetoothSdk private constructor(
2547
2495
  )
2548
2496
  }
2549
2497
 
2550
- private fun sendNextStreamKeepAlive(tracker: ActiveStreamKeepAlive) {
2551
- var timeoutEvent: StreamStatusEvent? = null
2552
- var request: StreamKeepAliveRequest? = null
2553
2498
 
2554
- synchronized(streamKeepAliveLock) {
2555
- if (activeStreamKeepAlive !== tracker) {
2556
- return
2557
- }
2558
-
2559
- if (tracker.armed && tracker.pendingAckId != null) {
2560
- tracker.missedAckCount += 1
2561
- if (tracker.missedAckCount >= MAX_MISSED_STREAM_KEEP_ALIVE_ACKS) {
2562
- activeStreamKeepAlive = null
2563
- tracker.nextTick?.let { mainHandler.removeCallbacks(it) }
2564
- timeoutEvent =
2565
- StreamStatusEvent(
2566
- StreamStatus.Error(
2567
- streamId = tracker.streamId,
2568
- errorDetails =
2569
- "Stream keep-alive timed out after ${tracker.missedAckCount} missed ACKs",
2570
- timestamp = System.currentTimeMillis(),
2571
- resolvedConfig = null,
2572
- )
2573
- )
2574
- return@synchronized
2575
- }
2576
- }
2577
-
2578
- val ackId = "ack-${System.currentTimeMillis()}"
2579
- tracker.pendingAckId = ackId
2580
- request = StreamKeepAliveRequest(streamId = tracker.streamId, ackId = ackId)
2581
- val nextTick = Runnable { sendNextStreamKeepAlive(tracker) }
2582
- tracker.nextTick = nextTick
2583
- mainHandler.postDelayed(nextTick, tracker.intervalMs)
2584
- }
2585
-
2586
- timeoutEvent?.let { event ->
2587
- dispatchToListeners { it.onStreamStatus(event) }
2588
- stopStreamKeepAliveMonitor()
2589
- deviceManager.stopStream()
2590
- return
2591
- }
2592
-
2593
- request?.let { keepAlive ->
2594
- deviceManager.keepStreamAlive(keepAlive.toMap().toMutableMap())
2595
- }
2596
- }
2597
-
2598
- private fun handleStreamKeepAliveAck(event: KeepAliveAckEvent): Boolean {
2599
- synchronized(streamKeepAliveLock) {
2600
- val tracker = activeStreamKeepAlive ?: return false
2601
- if (event.streamId != tracker.streamId || event.ackId != tracker.pendingAckId) {
2602
- return false
2603
- }
2604
- tracker.pendingAckId = null
2605
- tracker.missedAckCount = 0
2606
- return true
2607
- }
2608
- }
2609
-
2610
- private fun handleStreamStatusForKeepAlive(status: StreamStatus) {
2611
- val streamId = status.streamId
2612
- val activeStreamId = synchronized(streamKeepAliveLock) { activeStreamKeepAlive?.streamId }
2613
- if (streamId == null || activeStreamId != streamId) {
2614
- return
2615
- }
2616
- when (status.state) {
2617
- StreamState.STOPPED,
2618
- StreamState.STOPPING,
2619
- StreamState.ERROR,
2620
- StreamState.RECONNECT_FAILED -> stopStreamKeepAliveMonitor()
2621
- // A non-terminal status means the stream is live or coming up and the glasses can
2622
- // now ACK; arm the missed-ACK detector from here so a slow startup before the first
2623
- // ACK can't trip a false keep-alive timeout. On the arming transition, drop any
2624
- // pre-arm bookkeeping so a stale unacked id (sent before the glasses could ACK)
2625
- // can't immediately count as a miss.
2626
- else ->
2627
- synchronized(streamKeepAliveLock) {
2628
- activeStreamKeepAlive?.let {
2629
- if (it.streamId == streamId && !it.armed) {
2630
- it.armed = true
2631
- it.pendingAckId = null
2632
- it.missedAckCount = 0
2633
- }
2634
- }
2635
- }
2636
- }
2637
- }
2638
2499
  }
2639
2500
 
2640
2501
  /** OTA status messages are not request-correlated, so only known pre-ack failures settle a start. */
@@ -4086,6 +4086,14 @@ class MentraLive : SGCManager() {
4086
4086
  val percent = json.optInt("percent", batteryLevel)
4087
4087
  updateBatteryStatus(percent, isCharging)
4088
4088
  }
4089
+ "stream_controller_probe" -> {
4090
+ val values = mapOf("protocolVersion" to json.opt("protocolVersion"),
4091
+ "controllerId" to json.opt("controllerId"), "streamId" to json.opt("streamId"),
4092
+ "probeId" to json.opt("probeId"))
4093
+ com.mentra.bluetoothsdk.streaming.StreamControllerProbe.response(values)?.let {
4094
+ sendJson(JSONObject(it))
4095
+ }
4096
+ }
4089
4097
  "pong" ->
4090
4098
  // Process heartbeat pong response
4091
4099
  Bridge.log("LIVE: Received pong response - connection healthy")
@@ -4565,6 +4573,10 @@ class MentraLive : SGCManager() {
4565
4573
  mapOf("sid" to (glassesSessionId ?: "")),
4566
4574
  )
4567
4575
  readinessCompletedThisBleSession = true
4576
+ Bridge.sendTypedMessage("stream_control_ready", mapOf(
4577
+ "sid" to json.optString("sid", ""),
4578
+ "streamControlVersion" to json.optInt("streamControlVersion", 0),
4579
+ ))
4568
4580
 
4569
4581
  // Set the ready flag to stop any future readiness checks
4570
4582
  glassesReady = true
@@ -6802,6 +6814,8 @@ class MentraLive : SGCManager() {
6802
6814
  val json = JSONObject(message as Map<*, *>)
6803
6815
  // Remove timestamp as iOS does
6804
6816
  json.remove("timestamp")
6817
+ json.put("controllerProbeVersion", 1)
6818
+ json.put("controllerId", com.mentra.bluetoothsdk.streaming.StreamControllerProbe.controllerId)
6805
6819
  sendJson(json, true)
6806
6820
  } catch (e: Exception) {
6807
6821
  Log.e(TAG, "Error creating RTMP stream start JSON", e)
@@ -0,0 +1,14 @@
1
+ package com.mentra.bluetoothsdk.streaming
2
+
3
+ /** Process-scoped native ownership survives BLE reconnects, not app termination. */
4
+ internal object StreamControllerProbe {
5
+ val controllerId: String = java.util.UUID.randomUUID().toString()
6
+
7
+ fun response(values: Map<String, Any?>): Map<String, Any>? {
8
+ if (values["protocolVersion"] != 1 || values["controllerId"] != controllerId) return null
9
+ val streamId = (values["streamId"] as? String)?.takeIf { it.isNotEmpty() } ?: return null
10
+ val probeId = (values["probeId"] as? String)?.takeIf { it.isNotEmpty() } ?: return null
11
+ return mapOf("type" to "stream_controller_response", "protocolVersion" to 1,
12
+ "controllerId" to controllerId, "streamId" to streamId, "probeId" to probeId)
13
+ }
14
+ }
@@ -481,6 +481,14 @@ data class StreamStatusEvent(
481
481
  val status: StreamStatus,
482
482
  val stats: StreamLiveStats? = null,
483
483
  ) {
484
+ var processSessionId: String? = null
485
+ private set
486
+ var revision: Long? = null
487
+ private set
488
+ var terminal: Boolean? = null
489
+ private set
490
+ var errorDetails: String? = null
491
+ private set
484
492
  // True when the glasses will retry the failed publisher themselves
485
493
  // (emitting side lands in PR #3488); absent on older firmware and on
486
494
  // events not parsed from a glasses status map. Carried here instead of
@@ -493,6 +501,10 @@ data class StreamStatusEvent(
493
501
  stats = StreamLiveStats.fromMap(stringMapValue(values["stats"])),
494
502
  ) {
495
503
  willRetry = boolValue(values, "willRetry")
504
+ processSessionId = stringValue(values, "sid")
505
+ revision = longValue(values, "revision")
506
+ terminal = boolValue(values, "terminal")
507
+ errorDetails = stringValue(values, "errorDetails")
496
508
  }
497
509
 
498
510
  val state: StreamState get() = status.state
@@ -501,6 +513,10 @@ data class StreamStatusEvent(
501
513
  val values: Map<String, Any>
502
514
  get() = buildMap {
503
515
  putAll(status.toEventMap())
516
+ processSessionId?.let { put("sid", it) }
517
+ revision?.let { put("revision", it) }
518
+ terminal?.let { put("terminal", it) }
519
+ errorDetails?.let { put("errorDetails", it) }
504
520
  stats?.let { put("stats", it.toMap()) }
505
521
  willRetry?.let { put("willRetry", it) }
506
522
  }
@@ -0,0 +1,32 @@
1
+ package com.mentra.bluetoothsdk.streaming
2
+
3
+ /** Observes glasses-owned streaming; delivery gaps never imply publisher failure. */
4
+ internal class StreamSessionState {
5
+ @Volatile var processSessionId: String? = null
6
+ private set
7
+ @Volatile var currentStreamId: String? = null
8
+ private set
9
+ @Volatile var supported = false
10
+ private set
11
+ private var revision = -1L
12
+
13
+ @Synchronized
14
+ fun ready(sessionId: String?, controlVersion: Int?) {
15
+ supported = controlVersion == 1 && !sessionId.isNullOrEmpty()
16
+ if (processSessionId != sessionId) {
17
+ currentStreamId = null
18
+ revision = -1
19
+ }
20
+ processSessionId = sessionId
21
+ }
22
+
23
+ @Synchronized
24
+ fun accept(values: Map<String, Any?>): Boolean {
25
+ if (!supported || values["sid"] != processSessionId) return false
26
+ val incomingRevision = (values["revision"] as? Number)?.toLong() ?: return false
27
+ if (incomingRevision < revision) return false
28
+ revision = incomingRevision
29
+ currentStreamId = if (values["terminal"] == true) null else values["streamId"] as? String
30
+ return true
31
+ }
32
+ }
@@ -0,0 +1,24 @@
1
+ package com.mentra.bluetoothsdk.streaming
2
+
3
+ import org.junit.Assert.*
4
+ import org.junit.Test
5
+
6
+ class StreamControllerProbeTest {
7
+ private fun probe() = mapOf<String, Any>("protocolVersion" to 1,
8
+ "controllerId" to StreamControllerProbe.controllerId, "streamId" to "stream", "probeId" to "nonce")
9
+
10
+ @Test fun answersCurrentProcessChallengeWithoutJsOrTimer() {
11
+ val response = StreamControllerProbe.response(probe())!!
12
+ assertEquals("stream_controller_response", response["type"])
13
+ assertEquals("nonce", response["probeId"])
14
+ assertEquals("stream", response["streamId"])
15
+ }
16
+
17
+ @Test fun rejectsPreviousProcessAndMalformedChallenges() {
18
+ assertNull(StreamControllerProbe.response(probe() + ("controllerId" to "previous-process")))
19
+ assertNull(StreamControllerProbe.response(probe() + ("protocolVersion" to 2)))
20
+ assertNull(StreamControllerProbe.response(probe() + ("protocolVersion" to true)))
21
+ assertNull(StreamControllerProbe.response(probe() + ("probeId" to "")))
22
+ assertNull(StreamControllerProbe.response(probe() - "streamId"))
23
+ }
24
+ }
@@ -0,0 +1,43 @@
1
+ package com.mentra.bluetoothsdk.streaming
2
+
3
+ import org.junit.Assert.*
4
+ import org.junit.Test
5
+
6
+ class StreamSessionStateTest {
7
+ @Test fun readinessRequiresKnownProtocolAndProcessIdentity() {
8
+ val state = StreamSessionState()
9
+ state.ready("asg", null)
10
+ assertFalse(state.supported)
11
+ state.ready("asg", 2)
12
+ assertFalse(state.supported)
13
+ state.ready("", 1)
14
+ assertFalse(state.supported)
15
+ state.ready("asg", 1)
16
+ assertTrue(state.supported)
17
+ }
18
+
19
+ @Test fun reconnectReconcilesTerminalStateWithoutHeartbeatTimeouts() {
20
+ val state = StreamSessionState()
21
+ state.ready("asg", 1)
22
+ assertTrue(state.accept(event("asg", 1, false)))
23
+ state.ready("asg", 1)
24
+ assertEquals("stream", state.currentStreamId)
25
+ assertTrue(state.accept(event("asg", 3, true)))
26
+ assertNull(state.currentStreamId)
27
+ assertFalse(state.accept(event("asg", 2, false)))
28
+ assertNull(state.currentStreamId)
29
+ }
30
+
31
+ @Test fun restartRejectsOldStatusAndAcceptsNewStoppedSnapshot() {
32
+ val state = StreamSessionState()
33
+ state.ready("old", 1)
34
+ assertTrue(state.accept(event("old", 8, false)))
35
+ state.ready("new", 1)
36
+ assertFalse(state.accept(event("old", 9, false)))
37
+ assertTrue(state.accept(mapOf("sid" to "new", "revision" to 0, "terminal" to true)))
38
+ assertNull(state.currentStreamId)
39
+ }
40
+
41
+ private fun event(sid: String, revision: Int, terminal: Boolean) =
42
+ mapOf("sid" to sid, "revision" to revision, "streamId" to "stream", "terminal" to terminal)
43
+ }
@@ -719,6 +719,11 @@ export type StreamLiveStats = {
719
719
  type StreamStatusCommon = {
720
720
  type: "stream_status";
721
721
  streamId?: string;
722
+ /** ASG process identity and monotonic revision for reconnect reconciliation. */
723
+ sid?: string;
724
+ revision?: number;
725
+ terminal?: boolean;
726
+ errorDetails?: string;
722
727
  timestamp?: number;
723
728
  resolvedConfig?: StreamResolvedConfig;
724
729
  stats?: StreamLiveStats;
@@ -746,7 +751,7 @@ export type StreamStatusEvent = (StreamStatusCommon & {
746
751
  errorDetails: string;
747
752
  }) | (StreamStatusCommon & {
748
753
  kind: "snapshot";
749
- status: "streaming" | "reconnecting" | "stopped";
754
+ status: StreamStatusState;
750
755
  streaming: boolean;
751
756
  reconnecting: boolean;
752
757
  attempt?: number;