@mentra/bluetooth-sdk 3.2.1-beta.455 → 3.2.1-beta.459
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/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +18 -8
- package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +5 -5
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Nimo.kt +4 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/DeviceManagerSceneHandoffTest.kt +95 -1
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoMicReconnectTest.kt +93 -0
- package/build/generated/releaseMetadata.js +5 -5
- package/build/generated/releaseMetadata.js.map +1 -1
- package/ios/Source/BluetoothSdkDefaults.swift +1 -1
- package/ios/Source/DeviceManager.swift +36 -26
- package/ios/Source/GeneratedReleaseMetadata.swift +5 -5
- package/ios/Source/audio/GlassesMicWatchdog.swift +33 -0
- package/ios/Source/sgcs/Ar99.swift +1 -2
- package/ios/Source/sgcs/Nimo.swift +1 -1
- package/ios/Tests/DeviceManagerReconnectTests.swift +337 -0
- package/package.json +2 -2
- package/src/generated/releaseMetadata.ts +5 -5
|
@@ -1378,7 +1378,8 @@ class DeviceManager internal constructor(initializeHardware: Boolean) {
|
|
|
1378
1378
|
Bridge.log("MAN: Cleaning up previous sgc type: ${sgc?.type}")
|
|
1379
1379
|
sgc?.cleanup()
|
|
1380
1380
|
sgc = null
|
|
1381
|
-
|
|
1381
|
+
DeviceStore.apply("glasses", "micEnabled", false)
|
|
1382
|
+
resetConnectionReadyState()
|
|
1382
1383
|
}
|
|
1383
1384
|
|
|
1384
1385
|
if (sgc != null) {
|
|
@@ -1574,10 +1575,11 @@ class DeviceManager internal constructor(initializeHardware: Boolean) {
|
|
|
1574
1575
|
handleMach1Ready() // Z100 uses same initialization as Mach1
|
|
1575
1576
|
}
|
|
1576
1577
|
|
|
1577
|
-
//
|
|
1578
|
-
//
|
|
1578
|
+
// Disconnect clears micEnabled but preserves the consumers' audio requests.
|
|
1579
|
+
// Recompute that derived flag before selecting a microphone; replaying an
|
|
1580
|
+
// unchanged should_send_* setting is deduplicated by DeviceStore.apply().
|
|
1579
1581
|
Bridge.log("MAN: Re-applying microphone settings after reconnection")
|
|
1580
|
-
|
|
1582
|
+
setMicState()
|
|
1581
1583
|
|
|
1582
1584
|
// send to the server our battery status:
|
|
1583
1585
|
Bridge.sendBatteryStatus(sgc?.batteryLevel ?: -1, false)
|
|
@@ -1617,7 +1619,11 @@ class DeviceManager internal constructor(initializeHardware: Boolean) {
|
|
|
1617
1619
|
mainHandler.postDelayed(sync, 3000)
|
|
1618
1620
|
}
|
|
1619
1621
|
|
|
1620
|
-
private fun
|
|
1622
|
+
private fun resetConnectionReadyState() {
|
|
1623
|
+
// Suppress duplicate readiness only within one connection, never across
|
|
1624
|
+
// a genuine disconnect/reconnect of the same glasses inside two seconds.
|
|
1625
|
+
lastReadyHandledKey = ""
|
|
1626
|
+
lastReadyHandledAtMs = 0L
|
|
1621
1627
|
pendingSystemTimeSync?.let { mainHandler.removeCallbacks(it) }
|
|
1622
1628
|
pendingSystemTimeSync = null
|
|
1623
1629
|
lastSystemTimeSyncConnectionKey = ""
|
|
@@ -1635,7 +1641,7 @@ class DeviceManager internal constructor(initializeHardware: Boolean) {
|
|
|
1635
1641
|
|
|
1636
1642
|
fun handleDeviceDisconnected() {
|
|
1637
1643
|
Bridge.log("MAN: Device disconnected")
|
|
1638
|
-
|
|
1644
|
+
resetConnectionReadyState()
|
|
1639
1645
|
resetMicHealth()
|
|
1640
1646
|
DeviceStore.apply("glasses", "headUp", false)
|
|
1641
1647
|
DeviceStore.apply(
|
|
@@ -2445,7 +2451,10 @@ class DeviceManager internal constructor(initializeHardware: Boolean) {
|
|
|
2445
2451
|
val device = sgc
|
|
2446
2452
|
if (device is Nimo) device.cleanup() else device?.disconnect()
|
|
2447
2453
|
sgc = null // Clear the SGC reference after disconnect
|
|
2448
|
-
|
|
2454
|
+
// This cache belongs to the discarded connection. Keep consumer demand,
|
|
2455
|
+
// but require a new mic-enable command when replacement glasses are ready.
|
|
2456
|
+
DeviceStore.apply("glasses", "micEnabled", false)
|
|
2457
|
+
resetConnectionReadyState()
|
|
2449
2458
|
resetMicHealth()
|
|
2450
2459
|
searching = false
|
|
2451
2460
|
micEnabled = false
|
|
@@ -2508,7 +2517,8 @@ class DeviceManager internal constructor(initializeHardware: Boolean) {
|
|
|
2508
2517
|
// session state without calling disconnect() again (that would hit a dead instance
|
|
2509
2518
|
// and leave a destroyed MentraLive retained for the next scan).
|
|
2510
2519
|
sgc = null
|
|
2511
|
-
|
|
2520
|
+
DeviceStore.apply("glasses", "micEnabled", false)
|
|
2521
|
+
resetConnectionReadyState()
|
|
2512
2522
|
searching = false
|
|
2513
2523
|
micEnabled = false
|
|
2514
2524
|
updateMicState()
|
|
@@ -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.1"
|
|
6
|
-
const val RELEASE_IDENTITY: String = "3.2.1-beta.
|
|
7
|
-
const val RELEASE_SET_ID: String = "mentra-3.2.1-beta.
|
|
8
|
-
const val SOURCE_COMMIT: String = "
|
|
9
|
-
const val OTA_MANIFEST_URL: String = "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.2.1/mentra-live-ota-3.2.1-beta.
|
|
10
|
-
const val OTA_MANIFEST_SHA256: String = "
|
|
6
|
+
const val RELEASE_IDENTITY: String = "3.2.1-beta.459"
|
|
7
|
+
const val RELEASE_SET_ID: String = "mentra-3.2.1-beta.459"
|
|
8
|
+
const val SOURCE_COMMIT: String = "4e485930e223459b6c6f4a4f30072be409611f3d"
|
|
9
|
+
const val OTA_MANIFEST_URL: String = "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.2.1/mentra-live-ota-3.2.1-beta.459.json"
|
|
10
|
+
const val OTA_MANIFEST_SHA256: String = "82457abc1aa4faa1aea0ff67b2e90ab5a5cb180a6ee1945e92465305124445e9"
|
|
11
11
|
}
|
|
@@ -2117,6 +2117,10 @@ class Nimo : SGCManager() {
|
|
|
2117
2117
|
canvasEncoder.invalidate()
|
|
2118
2118
|
canvas.disconnected()
|
|
2119
2119
|
audioClient.stop()
|
|
2120
|
+
// Nimo's decoder/stream belongs to this GATT session. Consumer demand
|
|
2121
|
+
// remains in bluetooth.should_send_*; readiness must start a fresh stream
|
|
2122
|
+
// even when reconnecting with this same adapter instance.
|
|
2123
|
+
DeviceStore.apply("glasses", "micEnabled", false)
|
|
2120
2124
|
receiveAssembler.reset()
|
|
2121
2125
|
writes.reset()
|
|
2122
2126
|
negotiatedMtu = 23
|
|
@@ -24,6 +24,96 @@ import java.util.concurrent.TimeUnit
|
|
|
24
24
|
class DeviceManagerSceneHandoffTest {
|
|
25
25
|
@Before fun setup() { Bridge.initialize(ApplicationProvider.getApplicationContext()) }
|
|
26
26
|
|
|
27
|
+
@Test @LooperMode(LooperMode.Mode.PAUSED)
|
|
28
|
+
fun reconnectRestoresUnchangedAudioRequests() {
|
|
29
|
+
for (request in listOf("should_send_lc3", "should_send_pcm", "should_send_transcript", "local_stt_fallback_active")) {
|
|
30
|
+
withReadyRecordingDevice { manager, original, _ ->
|
|
31
|
+
for (key in listOf("should_send_lc3", "should_send_pcm", "should_send_transcript", "local_stt_fallback_active")) {
|
|
32
|
+
DeviceStore.set("bluetooth", key, key == request)
|
|
33
|
+
}
|
|
34
|
+
DeviceStore.set("bluetooth", "micRanking", listOf("glasses"))
|
|
35
|
+
DeviceStore.set("glasses", "micEnabled", false)
|
|
36
|
+
original.hasMic = true
|
|
37
|
+
manager.setMicState()
|
|
38
|
+
assertEquals(true, DeviceStore.get("bluetooth", "micEnabled"))
|
|
39
|
+
assertEquals(true, DeviceStore.get("glasses", "micEnabled"))
|
|
40
|
+
|
|
41
|
+
repeat(3) {
|
|
42
|
+
manager.disconnect()
|
|
43
|
+
assertEquals(false, DeviceStore.get("bluetooth", "micEnabled"))
|
|
44
|
+
assertEquals(false, DeviceStore.get("glasses", "micEnabled"))
|
|
45
|
+
assertEquals(true, DeviceStore.get("bluetooth", request))
|
|
46
|
+
val replacement = NimoRecordingSGC().apply { hasMic = true }
|
|
47
|
+
manager.sgc = replacement
|
|
48
|
+
|
|
49
|
+
// No test reset of glasses.micEnabled: production teardown must
|
|
50
|
+
// clear it. Replaying the same consumer request is deduplicated.
|
|
51
|
+
DeviceStore.apply("bluetooth", request, true)
|
|
52
|
+
assertEquals(false, DeviceStore.get("bluetooth", "micEnabled"))
|
|
53
|
+
DeviceStore.apply("glasses", "fullyBooted", true)
|
|
54
|
+
|
|
55
|
+
assertEquals("Reconnect must restore $request", true, DeviceStore.get("bluetooth", "micEnabled"))
|
|
56
|
+
assertEquals(listOf(true), replacement.micChanges)
|
|
57
|
+
assertEquals("glasses", manager.activeMicSource())
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
@Test @LooperMode(LooperMode.Mode.PAUSED)
|
|
64
|
+
fun linkDisconnectRetainsIntentForTheSameCommunicator() {
|
|
65
|
+
withReadyRecordingDevice { manager, original, _ ->
|
|
66
|
+
original.hasMic = true
|
|
67
|
+
DeviceStore.set("bluetooth", "micRanking", listOf("glasses"))
|
|
68
|
+
DeviceStore.set("glasses", "micEnabled", false)
|
|
69
|
+
DeviceStore.set("bluetooth", "should_send_lc3", true)
|
|
70
|
+
manager.setMicState()
|
|
71
|
+
assertEquals(listOf(true), original.micChanges)
|
|
72
|
+
DeviceStore.set("glasses", "fullyBooted", true)
|
|
73
|
+
DeviceStore.apply("glasses", "fullyBooted", false)
|
|
74
|
+
assertSame(original, manager.sgc)
|
|
75
|
+
assertEquals(true, DeviceStore.get("bluetooth", "should_send_lc3"))
|
|
76
|
+
assertEquals(true, DeviceStore.get("glasses", "micEnabled"))
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
@Test @LooperMode(LooperMode.Mode.PAUSED)
|
|
81
|
+
fun discardingCommunicatorInvalidatesMicCache() {
|
|
82
|
+
withReadyRecordingDevice { manager, original, _ ->
|
|
83
|
+
original.hasMic = true
|
|
84
|
+
DeviceStore.set("bluetooth", "micRanking", listOf("glasses"))
|
|
85
|
+
DeviceStore.set("glasses", "micEnabled", false)
|
|
86
|
+
DeviceStore.set("bluetooth", "should_send_lc3", true)
|
|
87
|
+
manager.setMicState()
|
|
88
|
+
manager.initSGC(original.type)
|
|
89
|
+
assertEquals(true, DeviceStore.get("glasses", "micEnabled"))
|
|
90
|
+
|
|
91
|
+
// An unsupported model exercises disposal without starting real BLE.
|
|
92
|
+
manager.initSGC("Unavailable test glasses")
|
|
93
|
+
assertNull(manager.sgc)
|
|
94
|
+
assertEquals(false, DeviceStore.get("glasses", "micEnabled"))
|
|
95
|
+
assertEquals(true, DeviceStore.get("bluetooth", "should_send_lc3"))
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
@Test @LooperMode(LooperMode.Mode.PAUSED)
|
|
100
|
+
fun reconnectWithoutAudioRequestsLeavesMicrophoneOff() {
|
|
101
|
+
withReadyRecordingDevice { manager, _, _ ->
|
|
102
|
+
for (key in listOf("should_send_lc3", "should_send_pcm", "should_send_transcript", "local_stt_fallback_active")) {
|
|
103
|
+
DeviceStore.set("bluetooth", key, false)
|
|
104
|
+
}
|
|
105
|
+
DeviceStore.set("glasses", "micEnabled", true)
|
|
106
|
+
manager.disconnect()
|
|
107
|
+
assertEquals(false, DeviceStore.get("glasses", "micEnabled"))
|
|
108
|
+
val replacement = NimoRecordingSGC().apply { hasMic = true }
|
|
109
|
+
manager.sgc = replacement
|
|
110
|
+
DeviceStore.apply("glasses", "fullyBooted", true)
|
|
111
|
+
assertEquals(false, DeviceStore.get("bluetooth", "micEnabled"))
|
|
112
|
+
assertEquals("", manager.activeMicSource())
|
|
113
|
+
assertTrue(replacement.micChanges.isEmpty())
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
27
117
|
@Test @LooperMode(LooperMode.Mode.PAUSED)
|
|
28
118
|
fun nimoBrightnessChangesDoNotReplaceTheSelectedScene() {
|
|
29
119
|
for (headUp in listOf(false, true)) for ((key, value) in brightnessChanges()) {
|
|
@@ -499,6 +589,7 @@ class DeviceManagerSceneHandoffTest {
|
|
|
499
589
|
}
|
|
500
590
|
|
|
501
591
|
private open class RecordingSGC(override val sceneHandoffRequiresClear: Boolean) : SGCManager() {
|
|
592
|
+
val micChanges = mutableListOf<Boolean>()
|
|
502
593
|
val calls = CopyOnWriteArrayList<String>()
|
|
503
594
|
val brightnessCalls = mutableListOf<String>()
|
|
504
595
|
val textSent = CountDownLatch(1)
|
|
@@ -511,7 +602,10 @@ class DeviceManagerSceneHandoffTest {
|
|
|
511
602
|
override fun sendTextWall(text: String) { lastTextNanos = System.nanoTime(); calls += "text:$text"; textSent.countDown() }
|
|
512
603
|
override fun disconnect() { calls += "disconnect" }
|
|
513
604
|
override fun cleanup() { calls += "cleanup" }
|
|
514
|
-
override fun setMicEnabled(enabled: Boolean) {
|
|
605
|
+
override fun setMicEnabled(enabled: Boolean) {
|
|
606
|
+
micChanges += enabled
|
|
607
|
+
DeviceStore.set("glasses", "micEnabled", enabled)
|
|
608
|
+
}
|
|
515
609
|
override fun sortMicRanking(list: MutableList<String>) = list
|
|
516
610
|
override fun requestPhoto(request: PhotoRequest) {}
|
|
517
611
|
override fun startStream(message: MutableMap<String, Any>) {}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
package com.mentra.bluetoothsdk.sgcs
|
|
2
|
+
|
|
3
|
+
import android.bluetooth.BluetoothGattCharacteristic
|
|
4
|
+
import androidx.test.core.app.ApplicationProvider
|
|
5
|
+
import com.mentra.bluetoothsdk.Bridge
|
|
6
|
+
import com.mentra.bluetoothsdk.DeviceManager
|
|
7
|
+
import com.mentra.bluetoothsdk.DeviceStore
|
|
8
|
+
import org.junit.Assert.*
|
|
9
|
+
import org.junit.Test
|
|
10
|
+
import org.junit.runner.RunWith
|
|
11
|
+
import org.robolectric.RobolectricTestRunner
|
|
12
|
+
import org.robolectric.annotation.Config
|
|
13
|
+
import org.robolectric.annotation.LooperMode
|
|
14
|
+
import java.util.UUID
|
|
15
|
+
|
|
16
|
+
@RunWith(RobolectricTestRunner::class)
|
|
17
|
+
@Config(sdk = [33])
|
|
18
|
+
@LooperMode(LooperMode.Mode.PAUSED)
|
|
19
|
+
class NimoMicReconnectTest {
|
|
20
|
+
private val requests = listOf("should_send_lc3", "should_send_pcm", "should_send_transcript", "local_stt_fallback_active")
|
|
21
|
+
|
|
22
|
+
@Test fun sameAdapterReconnectRestartsTheStoppedAudioClientForRetainedConsumers() {
|
|
23
|
+
for (request in requests) withNimo { manager, device ->
|
|
24
|
+
DeviceStore.apply("bluetooth", request, true)
|
|
25
|
+
assertTrue(audioStarted(device))
|
|
26
|
+
repeat(3) {
|
|
27
|
+
resetLink(device)
|
|
28
|
+
assertFalse(audioStarted(device))
|
|
29
|
+
assertEquals(true, DeviceStore.get("bluetooth", request))
|
|
30
|
+
// This is the same adapter, not the replacement fake used by the manager tests.
|
|
31
|
+
DeviceStore.apply("glasses", "fullyBooted", true)
|
|
32
|
+
assertSame(device, manager.sgc)
|
|
33
|
+
assertTrue("Readiness must restart Nimo decoding without waiting for the watchdog", audioStarted(device))
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
@Test fun consumerStoppingDuringLinkLossDoesNotRestartAudio() = withNimo { _, device ->
|
|
39
|
+
DeviceStore.apply("bluetooth", "should_send_pcm", true)
|
|
40
|
+
assertTrue(audioStarted(device))
|
|
41
|
+
resetLink(device)
|
|
42
|
+
DeviceStore.apply("bluetooth", "should_send_pcm", false)
|
|
43
|
+
DeviceStore.apply("glasses", "fullyBooted", true)
|
|
44
|
+
assertFalse(audioStarted(device))
|
|
45
|
+
assertEquals(false, DeviceStore.get("glasses", "micEnabled"))
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
private fun resetLink(device: Nimo) {
|
|
49
|
+
// Exercise the production reset called by the GATT disconnect callback.
|
|
50
|
+
Nimo::class.java.getDeclaredMethod("resetSessionState").apply { isAccessible = true }.invoke(device)
|
|
51
|
+
DeviceStore.apply("glasses", "fullyBooted", false)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
private fun audioStarted(device: Nimo): Boolean {
|
|
55
|
+
val audio = Nimo::class.java.getDeclaredField("audioClient").apply { isAccessible = true }.get(device)
|
|
56
|
+
return audio.javaClass.getDeclaredField("started").apply { isAccessible = true }.getBoolean(audio)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
private fun withNimo(block: (DeviceManager, Nimo) -> Unit) {
|
|
60
|
+
Bridge.initialize(ApplicationProvider.getApplicationContext())
|
|
61
|
+
val singleton = DeviceManager::class.java.getDeclaredField("_instance").apply { isAccessible = true }
|
|
62
|
+
val previous = singleton.get(null)
|
|
63
|
+
val saved = listOf("bluetooth", "glasses").associateWith { DeviceStore.store.getCategory(it) }
|
|
64
|
+
val manager = DeviceManager(initializeHardware = false)
|
|
65
|
+
singleton.set(null, manager)
|
|
66
|
+
val device = Nimo()
|
|
67
|
+
manager.sgc = device
|
|
68
|
+
// No radio/firmware: supply the discovered mic characteristic and inspect the real
|
|
69
|
+
// audio-client lifecycle. Codec output and BLE delivery still require a device test.
|
|
70
|
+
Nimo::class.java.getDeclaredField("micChar").apply { isAccessible = true }.set(
|
|
71
|
+
device, BluetoothGattCharacteristic(UUID.randomUUID(), BluetoothGattCharacteristic.PROPERTY_WRITE, 0)
|
|
72
|
+
)
|
|
73
|
+
requests.forEach { DeviceStore.set("bluetooth", it, false) }
|
|
74
|
+
DeviceStore.set("bluetooth", "micEnabled", false)
|
|
75
|
+
DeviceStore.set("bluetooth", "micRanking", listOf("glasses"))
|
|
76
|
+
DeviceStore.set("glasses", "micEnabled", false)
|
|
77
|
+
DeviceStore.set("glasses", "fullyBooted", false)
|
|
78
|
+
try {
|
|
79
|
+
block(manager, device)
|
|
80
|
+
} finally {
|
|
81
|
+
device.cleanup()
|
|
82
|
+
manager.sgc = null
|
|
83
|
+
manager.cleanup()
|
|
84
|
+
singleton.set(null, previous)
|
|
85
|
+
saved.forEach { (category, values) ->
|
|
86
|
+
DeviceStore.store.getCategory(category).keys.filter { it !in values }.forEach {
|
|
87
|
+
DeviceStore.store.remove(category, it)
|
|
88
|
+
}
|
|
89
|
+
values.forEach { (key, value) -> DeviceStore.set(category, key, value) }
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
export const BLUETOOTH_SDK_RELEASE_METADATA = Object.freeze({
|
|
2
2
|
"schemaVersion": 1,
|
|
3
3
|
"familyBaseVersion": "3.2.1",
|
|
4
|
-
"releaseIdentity": "3.2.1-beta.
|
|
5
|
-
"releaseSetId": "mentra-3.2.1-beta.
|
|
6
|
-
"sourceCommit": "
|
|
7
|
-
"otaManifestUrl": "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.2.1/mentra-live-ota-3.2.1-beta.
|
|
8
|
-
"otaManifestSha256": "
|
|
4
|
+
"releaseIdentity": "3.2.1-beta.459",
|
|
5
|
+
"releaseSetId": "mentra-3.2.1-beta.459",
|
|
6
|
+
"sourceCommit": "4e485930e223459b6c6f4a4f30072be409611f3d",
|
|
7
|
+
"otaManifestUrl": "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.2.1/mentra-live-ota-3.2.1-beta.459.json",
|
|
8
|
+
"otaManifestSha256": "82457abc1aa4faa1aea0ff67b2e90ab5a5cb180a6ee1945e92465305124445e9"
|
|
9
9
|
});
|
|
10
10
|
//# sourceMappingURL=releaseMetadata.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"releaseMetadata.js","sourceRoot":"","sources":["../../src/generated/releaseMetadata.ts"],"names":[],"mappings":"AAWA,MAAM,CAAC,MAAM,8BAA8B,GAA0C,MAAM,CAAC,MAAM,CAAC;IACjG,eAAe,EAAE,CAAC;IAClB,mBAAmB,EAAE,OAAO;IAC5B,iBAAiB,EAAE,gBAAgB;IACnC,cAAc,EAAE,uBAAuB;IACvC,cAAc,EAAE,0CAA0C;IAC1D,gBAAgB,EAAE,kIAAkI;IACpJ,mBAAmB,EAAE,kEAAkE;CACxF,CAAC,CAAA","sourcesContent":["/** Generated by release CI. Do not edit in a release checkout. */\nexport interface BluetoothSdkReleaseMetadata {\n schemaVersion: 1\n familyBaseVersion: string | null\n releaseIdentity: string | null\n releaseSetId: string | null\n sourceCommit: string | null\n otaManifestUrl: string | null\n otaManifestSha256: string | null\n}\n\nexport const BLUETOOTH_SDK_RELEASE_METADATA: Readonly<BluetoothSdkReleaseMetadata> = Object.freeze({\n \"schemaVersion\": 1,\n \"familyBaseVersion\": \"3.2.1\",\n \"releaseIdentity\": \"3.2.1-beta.
|
|
1
|
+
{"version":3,"file":"releaseMetadata.js","sourceRoot":"","sources":["../../src/generated/releaseMetadata.ts"],"names":[],"mappings":"AAWA,MAAM,CAAC,MAAM,8BAA8B,GAA0C,MAAM,CAAC,MAAM,CAAC;IACjG,eAAe,EAAE,CAAC;IAClB,mBAAmB,EAAE,OAAO;IAC5B,iBAAiB,EAAE,gBAAgB;IACnC,cAAc,EAAE,uBAAuB;IACvC,cAAc,EAAE,0CAA0C;IAC1D,gBAAgB,EAAE,kIAAkI;IACpJ,mBAAmB,EAAE,kEAAkE;CACxF,CAAC,CAAA","sourcesContent":["/** Generated by release CI. Do not edit in a release checkout. */\nexport interface BluetoothSdkReleaseMetadata {\n schemaVersion: 1\n familyBaseVersion: string | null\n releaseIdentity: string | null\n releaseSetId: string | null\n sourceCommit: string | null\n otaManifestUrl: string | null\n otaManifestSha256: string | null\n}\n\nexport const BLUETOOTH_SDK_RELEASE_METADATA: Readonly<BluetoothSdkReleaseMetadata> = Object.freeze({\n \"schemaVersion\": 1,\n \"familyBaseVersion\": \"3.2.1\",\n \"releaseIdentity\": \"3.2.1-beta.459\",\n \"releaseSetId\": \"mentra-3.2.1-beta.459\",\n \"sourceCommit\": \"4e485930e223459b6c6f4a4f30072be409611f3d\",\n \"otaManifestUrl\": \"https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.2.1/mentra-live-ota-3.2.1-beta.459.json\",\n \"otaManifestSha256\": \"82457abc1aa4faa1aea0ff67b2e90ab5a5cb180a6ee1945e92465305124445e9\"\n})\n"]}
|
|
@@ -16,7 +16,7 @@ enum BluetoothSdkDefaults {
|
|
|
16
16
|
/// silently disable it on every pair the app touches.
|
|
17
17
|
static let autoPowerOffEnabled = false
|
|
18
18
|
private static let infoSdkVersionKey = "MentraBluetoothSdkVersion"
|
|
19
|
-
private static let swiftPackageSdkVersion = "3.2.1-beta.
|
|
19
|
+
private static let swiftPackageSdkVersion = "3.2.1-beta.459"
|
|
20
20
|
private static let swiftPackageSdkVersionPlaceholder = "__MENTRA" + "_BLUETOOTH_SDK_VERSION__"
|
|
21
21
|
|
|
22
22
|
private static func normalizedSdkVersion(_ value: String?) -> String? {
|
|
@@ -318,9 +318,9 @@ struct ViewState {
|
|
|
318
318
|
var lc3Converter: PcmConverter?
|
|
319
319
|
/// Audio output format - defaults to LC3 for bandwidth savings
|
|
320
320
|
private var audioOutputFormat: AudioOutputFormat = .lc3
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
321
|
+
private var micWatchdog = GlassesMicWatchdog()
|
|
322
|
+
// Injectable monotonic clock so recovery deadlines can be tested without sleeping.
|
|
323
|
+
var micWatchdogNow: () -> TimeInterval = { ProcessInfo.processInfo.systemUptime }
|
|
324
324
|
private var sequenceGapEvents: Int64 = 0
|
|
325
325
|
private var decodeFailures: Int64 = 0
|
|
326
326
|
private var lastLc3ReceivedAt: Int64?
|
|
@@ -472,10 +472,17 @@ struct ViewState {
|
|
|
472
472
|
}
|
|
473
473
|
|
|
474
474
|
func reportGlassesAudioActivity() {
|
|
475
|
-
|
|
475
|
+
micWatchdog.receivedAudio(at: micWatchdogNow())
|
|
476
476
|
lastPcmProducedAt = nowMs()
|
|
477
477
|
}
|
|
478
478
|
|
|
479
|
+
/// Decoded glasses audio must update recovery health; phone PCM must not.
|
|
480
|
+
func handleGlassesPcm(_ pcmData: Data) {
|
|
481
|
+
guard !pcmData.isEmpty else { return }
|
|
482
|
+
reportGlassesAudioActivity()
|
|
483
|
+
handlePcm(pcmData)
|
|
484
|
+
}
|
|
485
|
+
|
|
479
486
|
private func micHealth() -> MicHealth {
|
|
480
487
|
MicHealth(
|
|
481
488
|
sequenceGapEvents: sequenceGapEvents,
|
|
@@ -499,13 +506,13 @@ struct ViewState {
|
|
|
499
506
|
lastLc3ReceivedAt = nil
|
|
500
507
|
lastPcmProducedAt = nil
|
|
501
508
|
lastLc3Sequence = nil
|
|
502
|
-
|
|
509
|
+
micWatchdog = GlassesMicWatchdog()
|
|
503
510
|
}
|
|
504
511
|
|
|
505
512
|
private func recordLc3Packet(sequenceNumber: Int?) {
|
|
506
513
|
let receivedAt = nowMs()
|
|
507
514
|
lastLc3ReceivedAt = receivedAt
|
|
508
|
-
|
|
515
|
+
micWatchdog.receivedAudio(at: micWatchdogNow())
|
|
509
516
|
var hasGap = false
|
|
510
517
|
if let sequenceNumber {
|
|
511
518
|
let normalized = sequenceNumber & 0xFF
|
|
@@ -605,6 +612,7 @@ struct ViewState {
|
|
|
605
612
|
}
|
|
606
613
|
|
|
607
614
|
currentMic = micUsed
|
|
615
|
+
updateGlassesMicExpectation()
|
|
608
616
|
|
|
609
617
|
// log if no mic was found:
|
|
610
618
|
if micUsed == "" && micEnabled {
|
|
@@ -723,6 +731,7 @@ struct ViewState {
|
|
|
723
731
|
Bridge.log("MAN: Manager already initialized, cleaning up previous sgc")
|
|
724
732
|
sgc?.cleanup()
|
|
725
733
|
sgc = nil
|
|
734
|
+
DeviceStore.shared.apply("glasses", "micEnabled", false)
|
|
726
735
|
resetSystemTimeSync()
|
|
727
736
|
}
|
|
728
737
|
|
|
@@ -902,27 +911,20 @@ struct ViewState {
|
|
|
902
911
|
return result
|
|
903
912
|
}
|
|
904
913
|
|
|
905
|
-
private func
|
|
906
|
-
//
|
|
907
|
-
|
|
908
|
-
let
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
Bridge.log("MAN: Glasses mic intentionally suspended for phone audio; skipping mic recovery")
|
|
915
|
-
return
|
|
916
|
-
}
|
|
917
|
-
|
|
918
|
-
if PhoneAudioMonitor.getInstance().isOwnAppAudioPlaying() {
|
|
919
|
-
Bridge.log("MAN: Mentra audio is playing; skipping glasses mic recovery")
|
|
920
|
-
return
|
|
921
|
-
}
|
|
914
|
+
private func updateGlassesMicExpectation() {
|
|
915
|
+
// Demand/route and device readiness decide whether audio is expected. The
|
|
916
|
+
// shared micEnabled flag alone is not evidence of a running hardware stream.
|
|
917
|
+
let expected = micEnabled && currentMic == MicTypes.GLASSES_CUSTOM
|
|
918
|
+
&& sgc?.fullyBooted == true && sgc?.hasMic == true
|
|
919
|
+
&& sgc?.isMicSuspendedForAudio != true
|
|
920
|
+
&& !PhoneAudioMonitor.getInstance().isOwnAppAudioPlaying()
|
|
921
|
+
micWatchdog.expectAudio(expected, at: micWatchdogNow())
|
|
922
|
+
}
|
|
922
923
|
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
924
|
+
func checkAndReinitGlassesMic() {
|
|
925
|
+
updateGlassesMicExpectation()
|
|
926
|
+
if micWatchdog.shouldRetry(at: micWatchdogNow()) {
|
|
927
|
+
Bridge.log("MAN: Expected glasses audio missing for 5 seconds; retrying mic start")
|
|
926
928
|
sgc?.setMicEnabled(true)
|
|
927
929
|
}
|
|
928
930
|
}
|
|
@@ -1066,6 +1068,11 @@ struct ViewState {
|
|
|
1066
1068
|
#endif
|
|
1067
1069
|
checkCurrentAudioDevice()
|
|
1068
1070
|
|
|
1071
|
+
// Disconnect clears micEnabled but preserves the consumers' audio requests.
|
|
1072
|
+
// Recompute demand before selecting a microphone; unchanged requests are
|
|
1073
|
+
// deduplicated by DeviceStore.apply().
|
|
1074
|
+
setMicState()
|
|
1075
|
+
|
|
1069
1076
|
// save the default_wearable now that we're connected:
|
|
1070
1077
|
Bridge.saveSetting("default_wearable", defaultWearable)
|
|
1071
1078
|
Bridge.saveSetting("device_name", deviceName)
|
|
@@ -1960,6 +1967,9 @@ struct ViewState {
|
|
|
1960
1967
|
sgc?.clearDisplay() // clear the screen
|
|
1961
1968
|
sgc?.disconnect()
|
|
1962
1969
|
sgc = nil // Clear the SGC reference after disconnect
|
|
1970
|
+
// This cache belongs to the discarded connection. Keep consumer demand,
|
|
1971
|
+
// but require a new mic-enable command when replacement glasses are ready.
|
|
1972
|
+
DeviceStore.shared.apply("glasses", "micEnabled", false)
|
|
1963
1973
|
resetSystemTimeSync()
|
|
1964
1974
|
resetMicHealth()
|
|
1965
1975
|
searching = false
|
|
@@ -3,9 +3,9 @@ import Foundation
|
|
|
3
3
|
/// Generated by release CI. Do not edit in a release checkout.
|
|
4
4
|
enum GeneratedReleaseMetadata {
|
|
5
5
|
static let familyBaseVersion = "3.2.1"
|
|
6
|
-
static let releaseIdentity = "3.2.1-beta.
|
|
7
|
-
static let releaseSetId = "mentra-3.2.1-beta.
|
|
8
|
-
static let sourceCommit = "
|
|
9
|
-
static let otaManifestUrl = "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.2.1/mentra-live-ota-3.2.1-beta.
|
|
10
|
-
static let otaManifestSha256 = "
|
|
6
|
+
static let releaseIdentity = "3.2.1-beta.459"
|
|
7
|
+
static let releaseSetId = "mentra-3.2.1-beta.459"
|
|
8
|
+
static let sourceCommit = "4e485930e223459b6c6f4a4f30072be409611f3d"
|
|
9
|
+
static let otaManifestUrl = "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.2.1/mentra-live-ota-3.2.1-beta.459.json"
|
|
10
|
+
static let otaManifestSha256 = "82457abc1aa4faa1aea0ff67b2e90ab5a5cb180a6ee1945e92465305124445e9"
|
|
11
11
|
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// Tracks expected audio separately from observed audio. All times use a monotonic clock.
|
|
4
|
+
struct GlassesMicWatchdog {
|
|
5
|
+
private var expectedSince: TimeInterval?
|
|
6
|
+
private var lastActivity: TimeInterval?
|
|
7
|
+
private var lastRetry: TimeInterval?
|
|
8
|
+
private let timeout: TimeInterval = 5
|
|
9
|
+
|
|
10
|
+
mutating func expectAudio(_ expected: Bool, at now: TimeInterval) {
|
|
11
|
+
guard expected else {
|
|
12
|
+
self = GlassesMicWatchdog()
|
|
13
|
+
return
|
|
14
|
+
}
|
|
15
|
+
if expectedSince == nil {
|
|
16
|
+
expectedSince = now
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
mutating func receivedAudio(at now: TimeInterval) {
|
|
21
|
+
guard expectedSince != nil else { return }
|
|
22
|
+
lastActivity = now
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
mutating func shouldRetry(at now: TimeInterval) -> Bool {
|
|
26
|
+
guard let expectedSince else { return false }
|
|
27
|
+
let lastProgress = max(expectedSince, lastActivity ?? expectedSince, lastRetry ?? expectedSince)
|
|
28
|
+
guard now - lastProgress >= timeout else { return false }
|
|
29
|
+
// Rate-limit retries even when the device never produces its first packet.
|
|
30
|
+
lastRetry = now
|
|
31
|
+
return true
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -1850,8 +1850,7 @@ final class Ar99: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SGCM
|
|
|
1850
1850
|
private func ensureOpusDecoder() {
|
|
1851
1851
|
guard opusDecoder == nil else { return }
|
|
1852
1852
|
opusDecoder = Ar99OpusPcmDecoder { pcm in
|
|
1853
|
-
DeviceManager.shared.
|
|
1854
|
-
DeviceManager.shared.handlePcm(pcm)
|
|
1853
|
+
DeviceManager.shared.handleGlassesPcm(pcm)
|
|
1855
1854
|
}
|
|
1856
1855
|
if opusDecoder == nil {
|
|
1857
1856
|
Bridge.log("AR99: system Opus decoder unavailable")
|
|
@@ -1543,7 +1543,7 @@ class Nimo: NSObject, SGCManager {
|
|
|
1543
1543
|
guard let opusDecoder else { return }
|
|
1544
1544
|
for opusFrame in packet.opusFrames {
|
|
1545
1545
|
if let pcm = opusDecoder.decode(opusFrame), !pcm.isEmpty {
|
|
1546
|
-
DeviceManager.shared.
|
|
1546
|
+
DeviceManager.shared.handleGlassesPcm(pcm)
|
|
1547
1547
|
}
|
|
1548
1548
|
}
|
|
1549
1549
|
}
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
@testable import MentraBluetoothSDK
|
|
2
|
+
import XCTest
|
|
3
|
+
|
|
4
|
+
@MainActor
|
|
5
|
+
final class DeviceManagerReconnectTests: XCTestCase {
|
|
6
|
+
private let requests = ["should_send_lc3", "should_send_pcm", "should_send_transcript", "local_stt_fallback_active"]
|
|
7
|
+
|
|
8
|
+
private func withRecordingDevice(_ body: (DeviceManager, DeviceStore) -> Void) {
|
|
9
|
+
let manager = DeviceManager.shared
|
|
10
|
+
let store = DeviceStore.shared
|
|
11
|
+
let saved = ["bluetooth", "glasses"].map { ($0, store.store.getCategory($0)) }
|
|
12
|
+
let previousDevice = manager.sgc
|
|
13
|
+
let previousController = manager.controller
|
|
14
|
+
let previousClock = manager.micWatchdogNow
|
|
15
|
+
manager.micWatchdogNow = { 0 }
|
|
16
|
+
defer {
|
|
17
|
+
store.set("bluetooth", "micEnabled", false)
|
|
18
|
+
manager.updateMicState()
|
|
19
|
+
manager.micWatchdogNow = previousClock
|
|
20
|
+
manager.sgc = previousDevice
|
|
21
|
+
manager.controller = previousController
|
|
22
|
+
for (category, values) in saved {
|
|
23
|
+
for key in store.store.getCategory(category).keys where values[key] == nil {
|
|
24
|
+
store.store.remove(category, key)
|
|
25
|
+
}
|
|
26
|
+
for (key, value) in values {
|
|
27
|
+
store.set(category, key, value)
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
manager.controller = nil
|
|
32
|
+
manager.sgc = ReconnectRecordingDevice()
|
|
33
|
+
for key in requests {
|
|
34
|
+
store.set("bluetooth", key, false)
|
|
35
|
+
}
|
|
36
|
+
store.set("bluetooth", "micEnabled", false)
|
|
37
|
+
store.set("bluetooth", "micRanking", [MicTypes.GLASSES_CUSTOM])
|
|
38
|
+
store.set("glasses", "micEnabled", false)
|
|
39
|
+
store.set("glasses", "fullyBooted", false)
|
|
40
|
+
body(manager, store)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
func testReconnectRestoresUnchangedAudioRequests() {
|
|
44
|
+
for request in requests {
|
|
45
|
+
withRecordingDevice { manager, store in
|
|
46
|
+
store.apply("bluetooth", request, true)
|
|
47
|
+
XCTAssertEqual(store.get("bluetooth", "micEnabled") as? Bool, true)
|
|
48
|
+
XCTAssertEqual(store.get("glasses", "micEnabled") as? Bool, true)
|
|
49
|
+
for _ in 0 ..< 3 {
|
|
50
|
+
manager.disconnect()
|
|
51
|
+
XCTAssertEqual(store.get("bluetooth", request) as? Bool, true)
|
|
52
|
+
XCTAssertEqual(store.get("bluetooth", "micEnabled") as? Bool, false)
|
|
53
|
+
XCTAssertEqual(store.get("glasses", "micEnabled") as? Bool, false)
|
|
54
|
+
let reconnected = ReconnectRecordingDevice()
|
|
55
|
+
manager.sgc = reconnected
|
|
56
|
+
|
|
57
|
+
// Replaying the same consumer request is deduplicated. No test
|
|
58
|
+
// reset of glasses.micEnabled: production teardown must clear it.
|
|
59
|
+
store.apply("bluetooth", request, true)
|
|
60
|
+
XCTAssertEqual(store.get("bluetooth", "micEnabled") as? Bool, false)
|
|
61
|
+
store.apply("glasses", "fullyBooted", true)
|
|
62
|
+
|
|
63
|
+
XCTAssertEqual(store.get("bluetooth", "micEnabled") as? Bool, true, request)
|
|
64
|
+
XCTAssertEqual(reconnected.micChanges, [true], request)
|
|
65
|
+
XCTAssertEqual(store.get("bluetooth", "currentMic") as? String, MicTypes.GLASSES_CUSTOM, request)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
func testLinkDisconnectRetainsIntentForTheSameCommunicator() {
|
|
72
|
+
withRecordingDevice { manager, store in
|
|
73
|
+
store.apply("glasses", "fullyBooted", true)
|
|
74
|
+
store.apply("bluetooth", "should_send_lc3", true)
|
|
75
|
+
let original = manager.sgc as! ReconnectRecordingDevice
|
|
76
|
+
store.apply("glasses", "fullyBooted", false)
|
|
77
|
+
XCTAssertTrue((manager.sgc as? ReconnectRecordingDevice) === original)
|
|
78
|
+
XCTAssertEqual(store.get("bluetooth", "should_send_lc3") as? Bool, true)
|
|
79
|
+
XCTAssertEqual(store.get("glasses", "micEnabled") as? Bool, true)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
func testDiscardingCommunicatorInvalidatesMicCache() {
|
|
84
|
+
withRecordingDevice { manager, store in
|
|
85
|
+
store.apply("bluetooth", "should_send_lc3", true)
|
|
86
|
+
manager.initSGC(manager.sgc!.type)
|
|
87
|
+
XCTAssertEqual(store.get("glasses", "micEnabled") as? Bool, true)
|
|
88
|
+
|
|
89
|
+
// An unsupported model exercises disposal without starting real BLE.
|
|
90
|
+
manager.initSGC("Unavailable test glasses")
|
|
91
|
+
XCTAssertNil(manager.sgc)
|
|
92
|
+
XCTAssertEqual(store.get("glasses", "micEnabled") as? Bool, false)
|
|
93
|
+
XCTAssertEqual(store.get("bluetooth", "should_send_lc3") as? Bool, true)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
func testReconnectWithoutAudioRequestsLeavesMicOff() {
|
|
98
|
+
withRecordingDevice { manager, store in
|
|
99
|
+
store.set("glasses", "micEnabled", true)
|
|
100
|
+
manager.disconnect()
|
|
101
|
+
XCTAssertEqual(store.get("glasses", "micEnabled") as? Bool, false)
|
|
102
|
+
let reconnected = ReconnectRecordingDevice()
|
|
103
|
+
manager.sgc = reconnected
|
|
104
|
+
store.apply("glasses", "fullyBooted", true)
|
|
105
|
+
XCTAssertEqual(store.get("bluetooth", "micEnabled") as? Bool, false)
|
|
106
|
+
XCTAssertEqual(store.get("bluetooth", "currentMic") as? String, "")
|
|
107
|
+
XCTAssertTrue(reconnected.micChanges.isEmpty)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
func testWatchdogRetriesWhenFirstPacketNeverArrivesAndLimitsRetries() {
|
|
112
|
+
withRecordingDevice { manager, store in
|
|
113
|
+
var now: TimeInterval = 0
|
|
114
|
+
manager.micWatchdogNow = { now }
|
|
115
|
+
store.apply("glasses", "fullyBooted", true)
|
|
116
|
+
store.apply("bluetooth", "should_send_pcm", true)
|
|
117
|
+
let device = manager.sgc as! ReconnectRecordingDevice
|
|
118
|
+
now = 4
|
|
119
|
+
manager.checkAndReinitGlassesMic()
|
|
120
|
+
XCTAssertEqual(device.micChanges, [true])
|
|
121
|
+
now = 5
|
|
122
|
+
manager.checkAndReinitGlassesMic()
|
|
123
|
+
XCTAssertEqual(device.micChanges, [true, true])
|
|
124
|
+
now = 5.1
|
|
125
|
+
manager.checkAndReinitGlassesMic()
|
|
126
|
+
XCTAssertEqual(device.micChanges, [true, true])
|
|
127
|
+
now = 10
|
|
128
|
+
manager.checkAndReinitGlassesMic()
|
|
129
|
+
XCTAssertEqual(device.micChanges, [true, true, true])
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
func testDecodedGlassesAudioKeepsWatchdogHealthyAndMissingPacketsAfterwardRetry() {
|
|
134
|
+
withRecordingDevice { manager, store in
|
|
135
|
+
var now: TimeInterval = 0
|
|
136
|
+
manager.micWatchdogNow = { now }
|
|
137
|
+
store.apply("glasses", "fullyBooted", true)
|
|
138
|
+
store.apply("bluetooth", "should_send_pcm", true)
|
|
139
|
+
let device = manager.sgc as! ReconnectRecordingDevice
|
|
140
|
+
// This is the decoded-audio entry point used by Nimo and AR99.
|
|
141
|
+
for tick in 1 ... 10 {
|
|
142
|
+
now = TimeInterval(tick * 10)
|
|
143
|
+
manager.handleGlassesPcm(Data([0, 0]))
|
|
144
|
+
manager.checkAndReinitGlassesMic()
|
|
145
|
+
}
|
|
146
|
+
XCTAssertEqual(device.micChanges, [true])
|
|
147
|
+
now = 104
|
|
148
|
+
manager.checkAndReinitGlassesMic()
|
|
149
|
+
XCTAssertEqual(device.micChanges, [true])
|
|
150
|
+
now = 105
|
|
151
|
+
manager.checkAndReinitGlassesMic()
|
|
152
|
+
XCTAssertEqual(device.micChanges, [true, true])
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
func testLc3GlassesAudioKeepsWatchdogHealthy() {
|
|
157
|
+
withRecordingDevice { manager, store in
|
|
158
|
+
var now: TimeInterval = 0
|
|
159
|
+
manager.micWatchdogNow = { now }
|
|
160
|
+
store.apply("glasses", "fullyBooted", true)
|
|
161
|
+
store.apply("bluetooth", "should_send_pcm", true)
|
|
162
|
+
let device = manager.sgc as! ReconnectRecordingDevice
|
|
163
|
+
let lc3 = manager.lc3Converter!.encode(Data(repeating: 0, count: 320), frameSize: 40) as Data
|
|
164
|
+
XCTAssertEqual(lc3.count, 40)
|
|
165
|
+
// G2 and Live deliver LC3 through this entry point, not decoded PCM.
|
|
166
|
+
for tick in 1 ... 10 {
|
|
167
|
+
now = TimeInterval(tick * 10)
|
|
168
|
+
manager.handleGlassesMicData(lc3, 40)
|
|
169
|
+
manager.checkAndReinitGlassesMic()
|
|
170
|
+
}
|
|
171
|
+
XCTAssertEqual(device.micChanges, [true])
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
func testPhonePcmAndEmptyGlassesPcmCannotHideAMissingGlassesStream() {
|
|
176
|
+
withRecordingDevice { manager, store in
|
|
177
|
+
var now: TimeInterval = 0
|
|
178
|
+
manager.micWatchdogNow = { now }
|
|
179
|
+
store.apply("glasses", "fullyBooted", true)
|
|
180
|
+
store.apply("bluetooth", "should_send_pcm", true)
|
|
181
|
+
let device = manager.sgc as! ReconnectRecordingDevice
|
|
182
|
+
now = 5
|
|
183
|
+
manager.handlePcm(Data([0, 0]))
|
|
184
|
+
manager.handleGlassesPcm(Data())
|
|
185
|
+
manager.checkAndReinitGlassesMic()
|
|
186
|
+
XCTAssertEqual(device.micChanges, [true, true])
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
func testWatchdogRespectsSuspensionAndGivesResumeAFreshDeadline() {
|
|
191
|
+
withRecordingDevice { manager, store in
|
|
192
|
+
var now: TimeInterval = 0
|
|
193
|
+
manager.micWatchdogNow = { now }
|
|
194
|
+
store.apply("glasses", "fullyBooted", true)
|
|
195
|
+
store.apply("bluetooth", "should_send_pcm", true)
|
|
196
|
+
let device = manager.sgc as! ReconnectRecordingDevice
|
|
197
|
+
device.isMicSuspendedForAudio = true
|
|
198
|
+
now = 100
|
|
199
|
+
manager.checkAndReinitGlassesMic()
|
|
200
|
+
XCTAssertEqual(device.micChanges, [true])
|
|
201
|
+
device.isMicSuspendedForAudio = false
|
|
202
|
+
now = 200
|
|
203
|
+
manager.checkAndReinitGlassesMic()
|
|
204
|
+
XCTAssertEqual(device.micChanges, [true])
|
|
205
|
+
now = 205
|
|
206
|
+
manager.checkAndReinitGlassesMic()
|
|
207
|
+
XCTAssertEqual(device.micChanges, [true, true])
|
|
208
|
+
|
|
209
|
+
store.apply("bluetooth", "should_send_pcm", false)
|
|
210
|
+
// A late firmware ACK can leave the shared cache true after demand ends.
|
|
211
|
+
store.set("glasses", "micEnabled", true)
|
|
212
|
+
now = 300
|
|
213
|
+
manager.checkAndReinitGlassesMic()
|
|
214
|
+
XCTAssertEqual(device.micChanges, [true, true, false])
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
func testWatchdogRespectsOwnAppPlaybackAndPhoneRoute() {
|
|
219
|
+
withRecordingDevice { manager, store in
|
|
220
|
+
var now: TimeInterval = 0
|
|
221
|
+
manager.micWatchdogNow = { now }
|
|
222
|
+
let monitor = PhoneAudioMonitor.getInstance()
|
|
223
|
+
let wasPlaying = monitor.isOwnAppAudioPlaying()
|
|
224
|
+
defer { monitor.setOwnAppAudioPlaying(wasPlaying) }
|
|
225
|
+
monitor.setOwnAppAudioPlaying(false)
|
|
226
|
+
store.apply("glasses", "fullyBooted", true)
|
|
227
|
+
store.apply("bluetooth", "should_send_pcm", true)
|
|
228
|
+
let device = manager.sgc as! ReconnectRecordingDevice
|
|
229
|
+
monitor.setOwnAppAudioPlaying(true)
|
|
230
|
+
now = 100
|
|
231
|
+
manager.checkAndReinitGlassesMic()
|
|
232
|
+
XCTAssertEqual(device.micChanges, [true])
|
|
233
|
+
monitor.setOwnAppAudioPlaying(false)
|
|
234
|
+
store.set("bluetooth", "currentMic", MicTypes.PHONE_INTERNAL)
|
|
235
|
+
now = 200
|
|
236
|
+
manager.checkAndReinitGlassesMic()
|
|
237
|
+
XCTAssertEqual(device.micChanges, [true])
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
func testWatchdogDeadlineBelongsToTheCurrentConnection() {
|
|
242
|
+
withRecordingDevice { manager, store in
|
|
243
|
+
var now: TimeInterval = 0
|
|
244
|
+
manager.micWatchdogNow = { now }
|
|
245
|
+
store.apply("glasses", "fullyBooted", true)
|
|
246
|
+
store.apply("bluetooth", "should_send_pcm", true)
|
|
247
|
+
let device = manager.sgc as! ReconnectRecordingDevice
|
|
248
|
+
store.apply("glasses", "fullyBooted", false)
|
|
249
|
+
now = 100
|
|
250
|
+
manager.checkAndReinitGlassesMic()
|
|
251
|
+
XCTAssertEqual(device.micChanges, [true])
|
|
252
|
+
store.apply("glasses", "fullyBooted", true)
|
|
253
|
+
manager.checkAndReinitGlassesMic()
|
|
254
|
+
XCTAssertEqual(device.micChanges, [true])
|
|
255
|
+
now = 105
|
|
256
|
+
manager.checkAndReinitGlassesMic()
|
|
257
|
+
XCTAssertEqual(device.micChanges, [true, true])
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
@MainActor
|
|
263
|
+
private final class ReconnectRecordingDevice: SGCManager {
|
|
264
|
+
var type = "Reconnect test glasses"
|
|
265
|
+
let hasMic = true
|
|
266
|
+
let showConnectionConfirmation = false
|
|
267
|
+
var micChanges: [Bool] = []
|
|
268
|
+
var isMicSuspendedForAudio = false
|
|
269
|
+
|
|
270
|
+
func clearSceneElements(_: [String]) async {}
|
|
271
|
+
func sendTextWall(_: String) async {}
|
|
272
|
+
func applySceneFrame(_: SceneFrame) async {}
|
|
273
|
+
func clearDisplay() {}
|
|
274
|
+
|
|
275
|
+
func setMicEnabled(_ enabled: Bool) {
|
|
276
|
+
micChanges.append(enabled)
|
|
277
|
+
DeviceStore.shared.set("glasses", "micEnabled", enabled)
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
func sortMicRanking(list: [String]) -> [String] {
|
|
281
|
+
list
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
func sendJson(_: [String: Any], wakeUp _: Bool, requireAck _: Bool) {}
|
|
285
|
+
func requestPhoto(_: PhotoRequest) {}
|
|
286
|
+
func startStream(_: [String: Any]) {}
|
|
287
|
+
func stopStream() {}
|
|
288
|
+
func sendStreamKeepAlive(_: [String: Any]) {}
|
|
289
|
+
func startVideoRecording(requestId _: String, save _: Bool, sound _: Bool) {}
|
|
290
|
+
func stopVideoRecording(requestId _: String) {}
|
|
291
|
+
func sendButtonPhotoSettings() {}
|
|
292
|
+
func sendButtonVideoRecordingSettings() {}
|
|
293
|
+
func sendCameraFovSetting() {}
|
|
294
|
+
func sendButtonMaxRecordingTime() {}
|
|
295
|
+
func setBrightness(_: Int, autoMode _: Bool) {}
|
|
296
|
+
func sendText(_: String) async {}
|
|
297
|
+
func sendDoubleTextWall(_: String, _: String) async {}
|
|
298
|
+
func displayBitmap(base64ImageData _: String, x _: Int32?, y _: Int32?, width _: Int32?, height _: Int32?) async -> Bool {
|
|
299
|
+
false
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
func showDashboard() {}
|
|
303
|
+
func setDashboardPosition(_: Int, _: Int) {}
|
|
304
|
+
func setHeadUpAngle(_: Int) {}
|
|
305
|
+
func getBatteryStatus() {}
|
|
306
|
+
func setSilentMode(_: Bool) {}
|
|
307
|
+
func exit() {}
|
|
308
|
+
func sendShutdown() {}
|
|
309
|
+
func sendReboot() {}
|
|
310
|
+
func sendRgbLedControl(requestId _: String, packageName _: String?, action _: String, color _: String?, onDurationMs _: Int, offDurationMs _: Int, count _: Int) {}
|
|
311
|
+
func disconnect() {}
|
|
312
|
+
func forget() {}
|
|
313
|
+
func findCompatibleDevices() {}
|
|
314
|
+
func stopScan() {}
|
|
315
|
+
func connectById(_: String) {}
|
|
316
|
+
func getConnectedBluetoothName() -> String? {
|
|
317
|
+
nil
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
func cleanup() {}
|
|
321
|
+
func ping() {}
|
|
322
|
+
func dbg1() {}
|
|
323
|
+
func dbg2() {}
|
|
324
|
+
func connectController() {}
|
|
325
|
+
func disconnectController() {}
|
|
326
|
+
func requestWifiScan(scanId _: String?) {}
|
|
327
|
+
func sendWifiCredentials(_: String, _: String) {}
|
|
328
|
+
func forgetWifiNetwork(_: String) {}
|
|
329
|
+
func sendHotspotState(_: Bool) {}
|
|
330
|
+
func sendUserEmailToGlasses(_: String) {}
|
|
331
|
+
func sendOtaStart(otaVersionUrl _: String?) {}
|
|
332
|
+
func sendOtaQueryStatus() {}
|
|
333
|
+
func queryGalleryStatus() {}
|
|
334
|
+
func sendGalleryMode() {}
|
|
335
|
+
func requestVersionInfo() {}
|
|
336
|
+
func sendIncidentId(_: String, apiBaseUrl _: String?) {}
|
|
337
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mentra/bluetooth-sdk",
|
|
3
|
-
"version": "3.2.1-beta.
|
|
3
|
+
"version": "3.2.1-beta.459",
|
|
4
4
|
"description": "SDK for communicating with smart glasses",
|
|
5
5
|
"main": "build/index.js",
|
|
6
6
|
"react-native": "src/index.ts",
|
|
@@ -114,7 +114,7 @@
|
|
|
114
114
|
"registry": "https://registry.npmjs.org/"
|
|
115
115
|
},
|
|
116
116
|
"dependencies": {
|
|
117
|
-
"@mentra/cloud-protocol": "3.2.1-beta.
|
|
117
|
+
"@mentra/cloud-protocol": "3.2.1-beta.459"
|
|
118
118
|
},
|
|
119
119
|
"devDependencies": {
|
|
120
120
|
"@types/node": "^25.9.3",
|
|
@@ -12,9 +12,9 @@ export interface BluetoothSdkReleaseMetadata {
|
|
|
12
12
|
export const BLUETOOTH_SDK_RELEASE_METADATA: Readonly<BluetoothSdkReleaseMetadata> = Object.freeze({
|
|
13
13
|
"schemaVersion": 1,
|
|
14
14
|
"familyBaseVersion": "3.2.1",
|
|
15
|
-
"releaseIdentity": "3.2.1-beta.
|
|
16
|
-
"releaseSetId": "mentra-3.2.1-beta.
|
|
17
|
-
"sourceCommit": "
|
|
18
|
-
"otaManifestUrl": "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.2.1/mentra-live-ota-3.2.1-beta.
|
|
19
|
-
"otaManifestSha256": "
|
|
15
|
+
"releaseIdentity": "3.2.1-beta.459",
|
|
16
|
+
"releaseSetId": "mentra-3.2.1-beta.459",
|
|
17
|
+
"sourceCommit": "4e485930e223459b6c6f4a4f30072be409611f3d",
|
|
18
|
+
"otaManifestUrl": "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.2.1/mentra-live-ota-3.2.1-beta.459.json",
|
|
19
|
+
"otaManifestSha256": "82457abc1aa4faa1aea0ff67b2e90ab5a5cb180a6ee1945e92465305124445e9"
|
|
20
20
|
})
|