@mentra/bluetooth-sdk 3.2.1-beta.506 → 3.2.1-beta.512
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/BluetoothSdkModule.kt +2 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +37 -2
- package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +5 -5
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Nimo.kt +59 -6
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCoordinator.kt +26 -13
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasSession.kt +31 -19
- package/android/src/test/java/com/mentra/bluetoothsdk/DeviceManagerSceneHandoffTest.kt +16 -1
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCoordinatorTest.kt +44 -4
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasSessionTest.kt +82 -13
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoProtocolTest.kt +11 -0
- package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoLifecycleTest.kt +94 -0
- package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoReadinessDriverTest.kt +15 -9
- package/build/_private/BluetoothSdkModule.d.ts +2 -0
- package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
- package/build/_private/BluetoothSdkModule.js.map +1 -1
- package/build/generated/releaseMetadata.js +5 -5
- package/build/generated/releaseMetadata.js.map +1 -1
- package/ios/BluetoothSdkModule.swift +4 -0
- package/ios/Source/BluetoothSdkDefaults.swift +1 -1
- package/ios/Source/DeviceManager.swift +23 -1
- package/ios/Source/GeneratedReleaseMetadata.swift +5 -5
- package/ios/Source/sgcs/Nimo.swift +41 -5
- package/ios/Source/sgcs/NimoCanvasCoordinator.swift +20 -6
- package/ios/Source/sgcs/NimoCanvasSession.swift +30 -14
- package/ios/Tests/DeviceManagerReconnectTests.swift +26 -1
- package/ios/Tests/NimoCanvasSessionTests.swift +74 -5
- package/ios/Tests/NimoCanvasTransportTests.swift +25 -0
- package/ios/Tests/NimoDeviceCommandsTests.swift +16 -0
- package/package.json +2 -2
- package/src/_private/BluetoothSdkModule.ts +2 -0
- package/src/generated/releaseMetadata.ts +5 -5
|
@@ -550,6 +550,8 @@ class BluetoothSdkModule : Module() {
|
|
|
550
550
|
|
|
551
551
|
SdkAsyncFunction("forget") { -> sdk?.forget() }
|
|
552
552
|
|
|
553
|
+
SdkCoroutineFunction("unpair") { -> deviceManager?.unpair() }
|
|
554
|
+
|
|
553
555
|
AsyncFunction("connectDefaultController") { deviceManager?.connectDefaultController() }
|
|
554
556
|
|
|
555
557
|
AsyncFunction("disconnectController") { deviceManager?.disconnectController() }
|
|
@@ -35,7 +35,10 @@ import com.mentra.bluetoothsdk.utils.MicTypes
|
|
|
35
35
|
import com.mentra.bluetoothsdk.utils.PhoneAudioMonitor
|
|
36
36
|
import com.mentra.lc3Lib.Lc3Cpp
|
|
37
37
|
import com.mentra.bluetoothsdk.stt.SherpaOnnxTranscriber
|
|
38
|
+
import kotlin.coroutines.resume
|
|
39
|
+
import kotlinx.coroutines.suspendCancellableCoroutine
|
|
38
40
|
import kotlinx.coroutines.CoroutineScope
|
|
41
|
+
import kotlinx.coroutines.withContext
|
|
39
42
|
import kotlinx.coroutines.Dispatchers
|
|
40
43
|
import kotlinx.coroutines.launch
|
|
41
44
|
import java.text.SimpleDateFormat
|
|
@@ -1537,6 +1540,7 @@ class DeviceManager internal constructor(initializeHardware: Boolean) {
|
|
|
1537
1540
|
// re-apply display height/depth after reconnection
|
|
1538
1541
|
mainHandler.postDelayed(
|
|
1539
1542
|
{
|
|
1543
|
+
val device = sgc ?: return@postDelayed
|
|
1540
1544
|
val h =
|
|
1541
1545
|
(DeviceStore.store.get("bluetooth", "dashboard_height") as? Number)
|
|
1542
1546
|
?.toInt()
|
|
@@ -1545,8 +1549,8 @@ class DeviceManager internal constructor(initializeHardware: Boolean) {
|
|
|
1545
1549
|
(DeviceStore.store.get("bluetooth", "dashboard_depth") as? Number)
|
|
1546
1550
|
?.toInt()
|
|
1547
1551
|
?: dashboardDepth // canonical default (2), not 1
|
|
1548
|
-
val d = rawDepth.coerceIn(1, 4)
|
|
1549
|
-
|
|
1552
|
+
val d = if (device.type == DeviceTypes.NIMO) rawDepth.coerceIn(0, 10) else rawDepth.coerceIn(1, 4)
|
|
1553
|
+
device.setDashboardPosition(h, d)
|
|
1550
1554
|
},
|
|
1551
1555
|
2000
|
|
1552
1556
|
)
|
|
@@ -2501,6 +2505,37 @@ class DeviceManager internal constructor(initializeHardware: Boolean) {
|
|
|
2501
2505
|
controller = null
|
|
2502
2506
|
}
|
|
2503
2507
|
|
|
2508
|
+
private var unpairInProgress = false
|
|
2509
|
+
|
|
2510
|
+
/** Explicit user Unpair, separate from passive pairing cleanup and logout. */
|
|
2511
|
+
suspend fun unpair() = withContext(Dispatchers.Main) {
|
|
2512
|
+
check(!unpairInProgress) { "Unpair already in progress" }
|
|
2513
|
+
unpairInProgress = true
|
|
2514
|
+
try {
|
|
2515
|
+
val target = sgc
|
|
2516
|
+
val nimo = target as? Nimo
|
|
2517
|
+
val savedNimoAddress = when {
|
|
2518
|
+
defaultWearable == DeviceTypes.NIMO -> deviceAddress
|
|
2519
|
+
pendingWearable == DeviceTypes.NIMO -> pendingDeviceAddress
|
|
2520
|
+
else -> null
|
|
2521
|
+
}
|
|
2522
|
+
if (nimo != null) {
|
|
2523
|
+
suspendCancellableCoroutine<Boolean> { continuation ->
|
|
2524
|
+
nimo.resetForUnpair { if (continuation.isActive) continuation.resume(it) }
|
|
2525
|
+
}
|
|
2526
|
+
check(sgc === target) { "Glasses changed during Unpair" }
|
|
2527
|
+
// Drop the encrypted link before removing the phone's bond.
|
|
2528
|
+
nimo.disconnect()
|
|
2529
|
+
nimo.removeBluetoothBond()
|
|
2530
|
+
} else if (target == null && savedNimoAddress != null) {
|
|
2531
|
+
Nimo.removeBluetoothBond(savedNimoAddress)
|
|
2532
|
+
}
|
|
2533
|
+
forget()
|
|
2534
|
+
} finally {
|
|
2535
|
+
unpairInProgress = false
|
|
2536
|
+
}
|
|
2537
|
+
}
|
|
2538
|
+
|
|
2504
2539
|
fun forget() {
|
|
2505
2540
|
synchronized(recoveryLock) {
|
|
2506
2541
|
recoverG2Connection = false
|
|
@@ -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.512"
|
|
7
|
+
const val RELEASE_SET_ID: String = "mentra-3.2.1-beta.512"
|
|
8
|
+
const val SOURCE_COMMIT: String = "e09a07ee7b71972e75a5051003ec561c12e6cb9b"
|
|
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.512.json"
|
|
10
|
+
const val OTA_MANIFEST_SHA256: String = "bd24cbd8279319912d643699eeb435df3ca88582194953583491a3625799ec01"
|
|
11
11
|
}
|
|
@@ -69,6 +69,7 @@ internal object NimoProtocol {
|
|
|
69
69
|
const val CMD_INSTRUCTION_REPORT = 0x06
|
|
70
70
|
const val CMD_CONTROL_INSTRUCTION = 0x07
|
|
71
71
|
const val CMD_CONTROL_FACTORY = 0x08
|
|
72
|
+
const val FACTORY_RECOVER = 0x03
|
|
72
73
|
const val CMD_CONTROL_NOTIFICATION = 0x09
|
|
73
74
|
|
|
74
75
|
// get parameter keys
|
|
@@ -89,6 +90,9 @@ internal object NimoProtocol {
|
|
|
89
90
|
const val SET_DISPLAY_OFF = 0x0F
|
|
90
91
|
const val SET_PHONE_TYPE = 0x14
|
|
91
92
|
const val SET_HEIGHT_LEVEL = 0x17
|
|
93
|
+
// Dynamic-v1 firmware: 1-based UI locale; 1 selects English.
|
|
94
|
+
const val SET_SYSTEM_LANGUAGE = 0x24
|
|
95
|
+
const val LANGUAGE_ENGLISH = 0x01
|
|
92
96
|
|
|
93
97
|
// control instruction keys
|
|
94
98
|
const val CTRL_ENTER_APP = 0x01
|
|
@@ -889,6 +893,17 @@ class Nimo : SGCManager() {
|
|
|
889
893
|
private const val KEY_LAST_ADDRESS = "nimo_lastDeviceAddress"
|
|
890
894
|
private const val KEY_LAST_NAME = "nimo_lastDeviceName"
|
|
891
895
|
|
|
896
|
+
internal fun removeBluetoothBond(address: String?) {
|
|
897
|
+
if (address.isNullOrBlank()) return
|
|
898
|
+
try {
|
|
899
|
+
val device = BluetoothAdapter.getDefaultAdapter()?.getRemoteDevice(address) ?: return
|
|
900
|
+
val removed = device.javaClass.getMethod("removeBond").invoke(device) as? Boolean
|
|
901
|
+
Bridge.log("NIMO: Bluetooth unbond requested=$removed")
|
|
902
|
+
} catch (error: Exception) {
|
|
903
|
+
Bridge.log("NIMO: Bluetooth unbond failed: ${error.message}")
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
|
|
892
907
|
private const val TWS_TIMEOUT_MS = 10_000L
|
|
893
908
|
private const val ACK_TIMEOUT_MS = 5_000L
|
|
894
909
|
// Generous: the glasses' classic bond can take 30-45s PER attempt and may need
|
|
@@ -1097,6 +1112,31 @@ class Nimo : SGCManager() {
|
|
|
1097
1112
|
DeviceStore.apply("glasses", "connectionState", ConnTypes.DISCONNECTED)
|
|
1098
1113
|
}
|
|
1099
1114
|
|
|
1115
|
+
/** Explicit Unpair only. Ordinary forget/cleanup must never reset the glasses. */
|
|
1116
|
+
internal fun resetForUnpair(onResult: (Boolean) -> Unit) {
|
|
1117
|
+
check(Looper.myLooper() == mainHandler.looper)
|
|
1118
|
+
if (gatt == null || txChar == null || handshakeState != HandshakeState.READY) {
|
|
1119
|
+
Bridge.log("NIMO: unpair while offline; remote reset unavailable")
|
|
1120
|
+
onResult(false)
|
|
1121
|
+
return
|
|
1122
|
+
}
|
|
1123
|
+
isDisconnecting = true
|
|
1124
|
+
stopTimers()
|
|
1125
|
+
reconnectionManager.stop()
|
|
1126
|
+
canvasEncoder.invalidate()
|
|
1127
|
+
canvas.disconnected()
|
|
1128
|
+
Bridge.log("NIMO: sending factory reset for explicit Unpair")
|
|
1129
|
+
sendAwaitingAck(NimoProtocol.CMD_CONTROL_FACTORY, NimoProtocol.FACTORY_RECOVER, byteArrayOf()) {
|
|
1130
|
+
Bridge.log("NIMO: factory reset acknowledged=$it")
|
|
1131
|
+
onResult(it)
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
internal fun removeBluetoothBond() {
|
|
1136
|
+
val address = gatt?.device?.address ?: lastDeviceAddress ?: return
|
|
1137
|
+
removeBluetoothBond(address)
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1100
1140
|
override fun forget() {
|
|
1101
1141
|
if (Looper.myLooper() != mainHandler.looper) {
|
|
1102
1142
|
runBlocking { withContext(Dispatchers.Main) { forget() } }
|
|
@@ -1980,7 +2020,10 @@ class Nimo : SGCManager() {
|
|
|
1980
2020
|
}
|
|
1981
2021
|
val timeout = Runnable {
|
|
1982
2022
|
// Do not retry a timed-out key on the same transport: a late response is uncorrelated.
|
|
1983
|
-
|
|
2023
|
+
pendingAcks.remove(ackKey)?.let { expired ->
|
|
2024
|
+
abortTransport("ACK timeout cmd=$cmd key=$key")
|
|
2025
|
+
expired.onResult(false)
|
|
2026
|
+
}
|
|
1984
2027
|
}
|
|
1985
2028
|
pendingAcks[ackKey] = PendingAck(onResult, timeout)
|
|
1986
2029
|
mainHandler.postDelayed(timeout, timeoutMs)
|
|
@@ -2080,6 +2123,9 @@ class Nimo : SGCManager() {
|
|
|
2080
2123
|
needsAck = false
|
|
2081
2124
|
)
|
|
2082
2125
|
)
|
|
2126
|
+
// Optional setting: older firmware may reject it without preventing connection.
|
|
2127
|
+
sendFrame(NimoFrameCodec.encodeFrame(NimoProtocol.CMD_SET_PARAMETER,
|
|
2128
|
+
NimoProtocol.SET_SYSTEM_LANGUAGE, byteArrayOf(NimoProtocol.LANGUAGE_ENGLISH.toByte())))
|
|
2083
2129
|
getBatteryStatus()
|
|
2084
2130
|
requestVersionInfo()
|
|
2085
2131
|
|
|
@@ -2099,7 +2145,10 @@ class Nimo : SGCManager() {
|
|
|
2099
2145
|
)
|
|
2100
2146
|
}
|
|
2101
2147
|
diagnostics?.connected((negotiatedMtu - 3).coerceIn(20, 512))
|
|
2102
|
-
|
|
2148
|
+
// Launch checks peer readiness in firmware; do not wait up to a minute for
|
|
2149
|
+
// the first unsolicited heartbeat just because peer status is still unknown.
|
|
2150
|
+
canvas.activate()
|
|
2151
|
+
canvas.readiness(twsConnected && peerCompanionReady != false)
|
|
2103
2152
|
}
|
|
2104
2153
|
|
|
2105
2154
|
private fun handshakeFailed() {
|
|
@@ -2175,6 +2224,7 @@ class Nimo : SGCManager() {
|
|
|
2175
2224
|
if (diagnostics?.onPacket(packet) == true) return
|
|
2176
2225
|
if (packet.size >= 10 && packet[8].toInt() == 7 && (packet[9].toInt() and 255) in listOf(1, 3, 4)) {
|
|
2177
2226
|
val response = NimoCanvasCodec.response(packet) ?: return
|
|
2227
|
+
if (response.first == 1) Bridge.log("NIMO: canvas Launch response status=${response.second.firstOrNull()?.toInt()}")
|
|
2178
2228
|
canvas.response(response.first, response.second)
|
|
2179
2229
|
return
|
|
2180
2230
|
}
|
|
@@ -2206,13 +2256,13 @@ class Nimo : SGCManager() {
|
|
|
2206
2256
|
if (appId != NimoCanvasCodec.APP_ID) {
|
|
2207
2257
|
diagnostics?.cancelHeldCapture("Native app takeover preempted held capture", resumeCanvas = false)
|
|
2208
2258
|
}
|
|
2209
|
-
|
|
2259
|
+
canvas.nativeApp(appId, true)
|
|
2210
2260
|
}
|
|
2211
2261
|
NimoProtocol.STATE_EXIT -> {
|
|
2212
2262
|
if (appId == NimoCanvasCodec.APP_ID) {
|
|
2213
2263
|
diagnostics?.cancelHeldCapture("Canvas exit preempted held capture", resumeCanvas = false)
|
|
2214
2264
|
}
|
|
2215
|
-
|
|
2265
|
+
canvas.nativeApp(appId, false)
|
|
2216
2266
|
}
|
|
2217
2267
|
}
|
|
2218
2268
|
}
|
|
@@ -2244,7 +2294,7 @@ class Nimo : SGCManager() {
|
|
|
2244
2294
|
// Both predicates were sampled by this device report. TWS-only
|
|
2245
2295
|
// reports and cached state cannot release a status-7 wait.
|
|
2246
2296
|
if (handshakeState == HandshakeState.READY) {
|
|
2247
|
-
canvas.confirmedReadiness(twsConnected && peerCompanionReady
|
|
2297
|
+
canvas.confirmedReadiness(twsConnected && peerCompanionReady != false)
|
|
2248
2298
|
}
|
|
2249
2299
|
}
|
|
2250
2300
|
}
|
|
@@ -2270,7 +2320,7 @@ class Nimo : SGCManager() {
|
|
|
2270
2320
|
if (!connected && handshakeState == HandshakeState.READY) {
|
|
2271
2321
|
Bridge.log("NIMO: TWS service dropped mid-session (arm removed/off?)")
|
|
2272
2322
|
}
|
|
2273
|
-
if (handshakeState == HandshakeState.READY) canvas.readiness(connected && peerCompanionReady
|
|
2323
|
+
if (handshakeState == HandshakeState.READY) canvas.readiness(connected && peerCompanionReady != false)
|
|
2274
2324
|
}
|
|
2275
2325
|
|
|
2276
2326
|
private fun handleInputEvent(code: Int) {
|
|
@@ -2306,6 +2356,9 @@ class Nimo : SGCManager() {
|
|
|
2306
2356
|
// 5 not supported, 6 bad parameter, 7 device busy.
|
|
2307
2357
|
Bridge.log("NIMO: response cmd=$cmd key=$key error status=$statusCode")
|
|
2308
2358
|
}
|
|
2359
|
+
if (cmd == NimoProtocol.CMD_SET_PARAMETER && key == NimoProtocol.SET_SYSTEM_LANGUAGE) {
|
|
2360
|
+
Bridge.log("NIMO: English menu setting acknowledged=${statusCode == 0} status=$statusCode")
|
|
2361
|
+
}
|
|
2309
2362
|
resolvePendingAck(cmd, key, statusCode == 0)
|
|
2310
2363
|
|
|
2311
2364
|
if (cmd != NimoProtocol.CMD_GET_PARAMETER || statusCode != 0) return
|
|
@@ -132,6 +132,7 @@ internal class NimoCanvasCoordinator(
|
|
|
132
132
|
private var holding = false
|
|
133
133
|
private var heldScope: String? = null
|
|
134
134
|
private var cancelDeadline: (() -> Unit)? = null
|
|
135
|
+
private var cancelRetry: (() -> Unit)? = null
|
|
135
136
|
|
|
136
137
|
fun offer(bytes: ByteArray, scope: String, force: Boolean = false) {
|
|
137
138
|
if (holding && scope != heldScope) {
|
|
@@ -140,8 +141,15 @@ internal class NimoCanvasCoordinator(
|
|
|
140
141
|
}
|
|
141
142
|
run(session.offer(bytes, scope, force))
|
|
142
143
|
}
|
|
143
|
-
fun
|
|
144
|
-
fun
|
|
144
|
+
fun activate() = run(session.activate())
|
|
145
|
+
fun readiness(ready: Boolean) {
|
|
146
|
+
if (!ready) { cancelRetry?.invoke(); cancelRetry = null }
|
|
147
|
+
run(session.readiness(ready))
|
|
148
|
+
}
|
|
149
|
+
fun confirmedReadiness(ready: Boolean) {
|
|
150
|
+
if (!ready) { cancelRetry?.invoke(); cancelRetry = null }
|
|
151
|
+
run(session.confirmedReadiness(ready))
|
|
152
|
+
}
|
|
145
153
|
fun hold(onReady: () -> Unit) {
|
|
146
154
|
session.hold(true)
|
|
147
155
|
holding = true
|
|
@@ -155,19 +163,14 @@ internal class NimoCanvasCoordinator(
|
|
|
155
163
|
heldScope = null
|
|
156
164
|
run(session.hold(false, resume))
|
|
157
165
|
}
|
|
158
|
-
fun exit()
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
fun nativeApp(appId: Int, entered: Boolean): Boolean {
|
|
162
|
-
val takeover = (appId == NimoCanvasCodec.APP_ID && !entered) ||
|
|
163
|
-
(appId != NimoCanvasCodec.APP_ID && entered)
|
|
164
|
-
val expectedExitReport = appId == NimoCanvasCodec.APP_ID && !entered && flight?.action?.key == 3
|
|
165
|
-
val invalidateEncoding = takeover && !expectedExitReport
|
|
166
|
-
run(session.nativeApp(appId, entered))
|
|
167
|
-
return invalidateEncoding
|
|
166
|
+
fun exit() {
|
|
167
|
+
cancelRetry?.invoke(); cancelRetry = null
|
|
168
|
+
run(session.exit())
|
|
168
169
|
}
|
|
170
|
+
fun nativeApp(appId: Int, entered: Boolean) = run(session.nativeApp(appId, entered))
|
|
169
171
|
|
|
170
172
|
fun disconnected() {
|
|
173
|
+
cancelRetry?.invoke(); cancelRetry = null
|
|
171
174
|
cancelDeadline?.invoke(); cancelDeadline = null
|
|
172
175
|
flight = null
|
|
173
176
|
holdReady = null
|
|
@@ -199,6 +202,7 @@ internal class NimoCanvasCoordinator(
|
|
|
199
202
|
private fun run(actions: List<NimoCanvasSession.Action>) {
|
|
200
203
|
for (action in actions) when (action) {
|
|
201
204
|
is NimoCanvasSession.Action.Send -> {
|
|
205
|
+
cancelRetry?.invoke(); cancelRetry = null
|
|
202
206
|
cancelDeadline?.invoke()
|
|
203
207
|
val current = Flight(action)
|
|
204
208
|
flight = current
|
|
@@ -219,7 +223,16 @@ internal class NimoCanvasCoordinator(
|
|
|
219
223
|
}
|
|
220
224
|
}
|
|
221
225
|
is NimoCanvasSession.Action.Reconnect -> { disconnected(); reconnect(action.reason) }
|
|
222
|
-
is NimoCanvasSession.Action.Rejected ->
|
|
226
|
+
is NimoCanvasSession.Action.Rejected -> {
|
|
227
|
+
rejected(action.status)
|
|
228
|
+
if (action.status == 7) {
|
|
229
|
+
cancelRetry?.invoke()
|
|
230
|
+
cancelRetry = scheduler.post(1_000) {
|
|
231
|
+
cancelRetry = null
|
|
232
|
+
run(session.retryNotReady())
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
223
236
|
}
|
|
224
237
|
}
|
|
225
238
|
}
|
|
@@ -9,6 +9,7 @@ internal class NimoCanvasSession {
|
|
|
9
9
|
}
|
|
10
10
|
private data class Flight(val key: Int, val frame: ByteArray, val ticket: Long)
|
|
11
11
|
private var ready = false
|
|
12
|
+
private var transportReady = false
|
|
12
13
|
private var active = false
|
|
13
14
|
private var held = false
|
|
14
15
|
private var locked = false
|
|
@@ -16,6 +17,7 @@ internal class NimoCanvasSession {
|
|
|
16
17
|
private var waitingReadiness = false
|
|
17
18
|
private var observedNotReady = false
|
|
18
19
|
private var readinessRetries = 0
|
|
20
|
+
private var probeRetries = 0
|
|
19
21
|
private var exitRequested = false
|
|
20
22
|
private var desired: ByteArray? = null
|
|
21
23
|
private var accepted: ByteArray? = null
|
|
@@ -35,6 +37,23 @@ internal class NimoCanvasSession {
|
|
|
35
37
|
return pump()
|
|
36
38
|
}
|
|
37
39
|
|
|
40
|
+
/** Enter a blank canvas on a fresh connection unless a newer host scene is retained. */
|
|
41
|
+
fun activate(): List<Action> {
|
|
42
|
+
if (desired == null) desired = byteArrayOf(0, 0, 1)
|
|
43
|
+
exitRequested = false
|
|
44
|
+
return pump()
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Launch itself checks peer readiness in firmware. Retry only explicit NOT_READY,
|
|
48
|
+
* with a small budget; never retry an ambiguous/missing business ACK on this link. */
|
|
49
|
+
fun retryNotReady(): List<Action> {
|
|
50
|
+
if (!transportReady || !waitingReadiness || desired == null || probeRetries >= 3) return emptyList()
|
|
51
|
+
probeRetries++
|
|
52
|
+
waitingReadiness = false
|
|
53
|
+
ready = true
|
|
54
|
+
return pump()
|
|
55
|
+
}
|
|
56
|
+
|
|
38
57
|
/** Debug capture seam; production behavior is unchanged unless explicitly held. */
|
|
39
58
|
fun hold(value: Boolean, resume: Boolean = true): List<Action> {
|
|
40
59
|
held = value
|
|
@@ -50,7 +69,8 @@ internal class NimoCanvasSession {
|
|
|
50
69
|
fun confirmedReadiness(value: Boolean): List<Action> = updateReadiness(value, confirmed = true)
|
|
51
70
|
|
|
52
71
|
private fun updateReadiness(value: Boolean, confirmed: Boolean): List<Action> {
|
|
53
|
-
|
|
72
|
+
transportReady = value
|
|
73
|
+
if (!value) { readinessRetries = 0; probeRetries = 0 }
|
|
54
74
|
if (!value && flight != null) return resetLink("Readiness lost during canvas command")
|
|
55
75
|
if (waitingReadiness) {
|
|
56
76
|
if (!value) observedNotReady = true
|
|
@@ -67,9 +87,9 @@ internal class NimoCanvasSession {
|
|
|
67
87
|
|
|
68
88
|
/** A new transport generation has no accepted frame. Retain only the latest desired scene. */
|
|
69
89
|
fun disconnected() {
|
|
70
|
-
ready = false; active = false; held = false; locked = false; launchBlocked = false
|
|
90
|
+
ready = false; transportReady = false; active = false; held = false; locked = false; launchBlocked = false
|
|
71
91
|
waitingReadiness = false; observedNotReady = false
|
|
72
|
-
readinessRetries = 0
|
|
92
|
+
readinessRetries = 0; probeRetries = 0
|
|
73
93
|
accepted = null; rejected = null; flight = null; ticket++; forceRevision++
|
|
74
94
|
}
|
|
75
95
|
|
|
@@ -78,21 +98,13 @@ internal class NimoCanvasSession {
|
|
|
78
98
|
return pump()
|
|
79
99
|
}
|
|
80
100
|
|
|
81
|
-
/**
|
|
101
|
+
/** Firmware can restore its stock UI after reconnect. Keep host ownership and
|
|
102
|
+
* replay the newest scene, serializing behind any outstanding command ACK. */
|
|
82
103
|
fun nativeApp(appId: Int, entered: Boolean): List<Action> {
|
|
83
104
|
if ((appId == NimoCanvasCodec.APP_ID && !entered) || (appId != NimoCanvasCodec.APP_ID && entered)) {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
active = false; accepted = null
|
|
88
|
-
if (appId != NimoCanvasCodec.APP_ID && entered) {
|
|
89
|
-
desired = null; rejected = null; scope = null
|
|
90
|
-
}
|
|
91
|
-
return emptyList()
|
|
92
|
-
}
|
|
93
|
-
val ambiguous = flight != null
|
|
94
|
-
desired = null; accepted = null; rejected = null; active = false; exitRequested = false; scope = null
|
|
95
|
-
if (ambiguous) return resetLink("Native app transition interrupted canvas command")
|
|
105
|
+
active = false
|
|
106
|
+
accepted = null
|
|
107
|
+
return pump()
|
|
96
108
|
}
|
|
97
109
|
return emptyList()
|
|
98
110
|
}
|
|
@@ -130,8 +142,8 @@ internal class NimoCanvasSession {
|
|
|
130
142
|
return listOf(Action.Rejected(status)) + pump()
|
|
131
143
|
}
|
|
132
144
|
when (key) {
|
|
133
|
-
1 -> active = true
|
|
134
|
-
4 -> { accepted = current.frame; rejected = null; readinessRetries = 0 }
|
|
145
|
+
1 -> { active = true; accepted = null }
|
|
146
|
+
4 -> { accepted = current.frame; rejected = null; readinessRetries = 0; probeRetries = 0 }
|
|
135
147
|
3 -> { active = false; accepted = null; exitRequested = false }
|
|
136
148
|
}
|
|
137
149
|
return pump()
|
|
@@ -143,7 +155,7 @@ internal class NimoCanvasSession {
|
|
|
143
155
|
}
|
|
144
156
|
|
|
145
157
|
private fun resetLink(reason: String): List<Action> {
|
|
146
|
-
ready = false; active = false; accepted = null; flight = null; ticket++; forceRevision++
|
|
158
|
+
ready = false; transportReady = false; active = false; accepted = null; flight = null; ticket++; forceRevision++
|
|
147
159
|
return listOf(Action.Reconnect(reason))
|
|
148
160
|
}
|
|
149
161
|
|
|
@@ -24,6 +24,20 @@ 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 delayedReconnectReplayPreservesNimoDepthEndpoints() {
|
|
29
|
+
for (depth in listOf(0, 10)) {
|
|
30
|
+
withReadyRecordingDevice { manager, device, _ ->
|
|
31
|
+
DeviceStore.set("bluetooth", "dashboard_height", 7)
|
|
32
|
+
DeviceStore.set("bluetooth", "dashboard_depth", depth)
|
|
33
|
+
manager.handleDeviceReady()
|
|
34
|
+
assertTrue(device.positions.isEmpty())
|
|
35
|
+
Shadows.shadowOf(Looper.getMainLooper()).idleFor(2, TimeUnit.SECONDS)
|
|
36
|
+
assertEquals(listOf(7 to depth), device.positions)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
27
41
|
@Test @LooperMode(LooperMode.Mode.PAUSED)
|
|
28
42
|
fun reconnectRestoresUnchangedAudioRequests() {
|
|
29
43
|
for (request in listOf("should_send_lc3", "should_send_pcm", "should_send_transcript", "local_stt_fallback_active")) {
|
|
@@ -592,6 +606,7 @@ class DeviceManagerSceneHandoffTest {
|
|
|
592
606
|
val micChanges = mutableListOf<Boolean>()
|
|
593
607
|
val calls = CopyOnWriteArrayList<String>()
|
|
594
608
|
val brightnessCalls = mutableListOf<String>()
|
|
609
|
+
val positions = mutableListOf<Pair<Int, Int>>()
|
|
595
610
|
val textSent = CountDownLatch(1)
|
|
596
611
|
var lastTextNanos = 0L
|
|
597
612
|
var lastClearNanos = 0L
|
|
@@ -622,7 +637,7 @@ class DeviceManagerSceneHandoffTest {
|
|
|
622
637
|
override fun sendDoubleTextWall(top: String, bottom: String) {}
|
|
623
638
|
override fun displayBitmap(base64ImageData: String, x: Int?, y: Int?, width: Int?, height: Int?) = true
|
|
624
639
|
override fun showDashboard() {}
|
|
625
|
-
override fun setDashboardPosition(height: Int, depth: Int) {}
|
|
640
|
+
override fun setDashboardPosition(height: Int, depth: Int) { positions += height to depth }
|
|
626
641
|
override fun setHeadUpAngle(angle: Int) {}
|
|
627
642
|
override fun getBatteryStatus() {}
|
|
628
643
|
override fun setSilentMode(enabled: Boolean) {}
|
|
@@ -51,6 +51,46 @@ class NimoCanvasCoordinatorTest {
|
|
|
51
51
|
fun finishUpdate(frame: ByteArray) { repeat(NimoCanvasCodec.frames(4, frame, 20).size) { written() }; ack(4) }
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
@Test fun notReadyRetriesLaunchPromptlyButStopsAfterThreeProbes() {
|
|
55
|
+
val f = Fixture(); f.canvas.activate(); f.canvas.readiness(true)
|
|
56
|
+
repeat(4) { attempt ->
|
|
57
|
+
f.written(); f.ack(1, 7)
|
|
58
|
+
val before = f.writes.size
|
|
59
|
+
f.clock.advance(999)
|
|
60
|
+
assertEquals(before, f.writes.size)
|
|
61
|
+
f.clock.advance(1)
|
|
62
|
+
assertEquals(before + if (attempt < 3) 1 else 0, f.writes.size)
|
|
63
|
+
}
|
|
64
|
+
f.clock.advance(60_000)
|
|
65
|
+
assertEquals(4, f.writes.size)
|
|
66
|
+
assertTrue(f.failures.isEmpty())
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
@Test fun pendingReadinessRetryIsCancelledOnDisconnectExitAndKnownReadinessLoss() {
|
|
70
|
+
for (stop in listOf("disconnect", "exit", "notReady")) {
|
|
71
|
+
val f = Fixture(); f.canvas.activate(); f.canvas.readiness(true)
|
|
72
|
+
f.written(); f.ack(1, 7)
|
|
73
|
+
when (stop) {
|
|
74
|
+
"disconnect" -> f.canvas.disconnected()
|
|
75
|
+
"exit" -> f.canvas.exit()
|
|
76
|
+
else -> f.canvas.readiness(false)
|
|
77
|
+
}
|
|
78
|
+
f.clock.advance(2_000)
|
|
79
|
+
assertEquals(1, f.writes.size)
|
|
80
|
+
assertTrue(f.failures.isEmpty())
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
@Test fun readinessHeartbeatCancelsPendingProbeAndDoesNotDuplicateLaunch() {
|
|
85
|
+
val f = Fixture(); f.canvas.activate(); f.canvas.readiness(true)
|
|
86
|
+
f.written(); f.ack(1, 7)
|
|
87
|
+
f.canvas.confirmedReadiness(true)
|
|
88
|
+
f.clock.advance(1_000)
|
|
89
|
+
assertEquals(2, f.writes.size)
|
|
90
|
+
f.written(); f.ack(1); f.finishUpdate(byteArrayOf(0, 0, 1))
|
|
91
|
+
assertTrue(f.failures.isEmpty())
|
|
92
|
+
}
|
|
93
|
+
|
|
54
94
|
@Test fun bothNotificationDescriptorsGateCharacteristicWrites() {
|
|
55
95
|
val f = Fixture()
|
|
56
96
|
val rx = Any(); val mic = Any()
|
|
@@ -184,7 +224,7 @@ class NimoCanvasCoordinatorTest {
|
|
|
184
224
|
val f = Fixture(); f.launch(); f.finishUpdate(byteArrayOf(0, 0, 1))
|
|
185
225
|
f.canvas.exit()
|
|
186
226
|
f.canvas.offer(byteArrayOf(1, 0, 1), "two:1")
|
|
187
|
-
|
|
227
|
+
f.canvas.nativeApp(0xFD, false)
|
|
188
228
|
f.written()
|
|
189
229
|
val before = f.writes.size
|
|
190
230
|
f.ack(3)
|
|
@@ -192,15 +232,15 @@ class NimoCanvasCoordinatorTest {
|
|
|
192
232
|
assertArrayEquals(NimoCanvasCodec.frames(1, writeCapacity = 20).single(), f.writes.last())
|
|
193
233
|
}
|
|
194
234
|
|
|
195
|
-
@Test fun
|
|
235
|
+
@Test fun nativeTakeoverDuringExitRetainsNewerSceneUntilExitAck() {
|
|
196
236
|
val f = Fixture(); f.launch(); f.finishUpdate(byteArrayOf(0, 0, 1))
|
|
197
237
|
f.canvas.exit()
|
|
198
238
|
f.canvas.offer(byteArrayOf(1, 0, 1), "two:1")
|
|
199
|
-
|
|
239
|
+
f.canvas.nativeApp(0, true)
|
|
200
240
|
f.written()
|
|
201
241
|
val before = f.writes.size
|
|
202
242
|
f.ack(3)
|
|
203
|
-
assertEquals(before, f.writes.size)
|
|
243
|
+
assertEquals(before + 1, f.writes.size)
|
|
204
244
|
assertTrue(f.failures.isEmpty())
|
|
205
245
|
}
|
|
206
246
|
|
|
@@ -210,31 +210,100 @@ class NimoCanvasSessionTest {
|
|
|
210
210
|
assertArrayEquals(frameB, send(success(session, 1)).frame)
|
|
211
211
|
}
|
|
212
212
|
|
|
213
|
-
@Test fun
|
|
213
|
+
@Test fun nativeReportDuringExitPreservesNewerHostScene() {
|
|
214
214
|
val session = NimoCanvasSession()
|
|
215
215
|
launched(session); success(session, 4)
|
|
216
216
|
assertEquals(3, send(session.exit()).key)
|
|
217
217
|
session.offer(frameB, "two:1")
|
|
218
218
|
assertTrue(session.nativeApp(0, true).isEmpty())
|
|
219
|
-
|
|
219
|
+
assertEquals(1, send(success(session, 3)).key)
|
|
220
|
+
assertArrayEquals(frameB, send(success(session, 1)).frame)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
@Test fun nativeTakeoverRestoresLatestSceneWithoutAnotherHostOffer() {
|
|
224
|
+
for (app in listOf(0, 0xFD)) {
|
|
225
|
+
val session = NimoCanvasSession()
|
|
226
|
+
launched(session); success(session, 4)
|
|
227
|
+
assertEquals(1, send(session.nativeApp(app, app != 0xFD)).key)
|
|
228
|
+
assertArrayEquals(frameA, send(success(session, 1)).frame)
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
@Test fun nativeTakeoverWaitsForCurrentAckInsteadOfOverlappingCommands() {
|
|
233
|
+
val session = NimoCanvasSession(); launched(session)
|
|
234
|
+
assertTrue(session.nativeApp(0, true).isEmpty())
|
|
235
|
+
session.offer(frameB, "one:1")
|
|
236
|
+
assertEquals(1, send(success(session, 4)).key)
|
|
237
|
+
assertTrue(session.nativeApp(0, true).isEmpty()) // Startup report during Launch.
|
|
238
|
+
assertArrayEquals(frameB, send(success(session, 1)).frame)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
@Test fun takeoverDuringUpdateReplaysIdenticalFrameAfterRelaunch() {
|
|
242
|
+
val session = NimoCanvasSession(); launched(session)
|
|
243
|
+
assertTrue(session.nativeApp(0, true).isEmpty())
|
|
244
|
+
// The old Update ACK must not deduplicate the same scene on a new canvas.
|
|
245
|
+
assertEquals(1, send(success(session, 4)).key)
|
|
246
|
+
assertArrayEquals(frameA, send(success(session, 1)).frame)
|
|
247
|
+
assertTrue(success(session, 4).isEmpty())
|
|
248
|
+
assertTrue(session.offer(frameA, "one:1").isEmpty())
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
@Test fun startupStockUiDoesNotDiscardRetainedSceneBeforeReadiness() {
|
|
252
|
+
val session = NimoCanvasSession(); launched(session); success(session, 4)
|
|
220
253
|
session.disconnected()
|
|
221
|
-
assertTrue(session.
|
|
254
|
+
assertTrue(session.nativeApp(0, true).isEmpty())
|
|
255
|
+
session.activate()
|
|
256
|
+
assertEquals(1, send(session.readiness(true)).key)
|
|
257
|
+
assertArrayEquals(frameA, send(success(session, 1)).frame)
|
|
222
258
|
}
|
|
223
259
|
|
|
224
|
-
@Test fun
|
|
225
|
-
val session = NimoCanvasSession()
|
|
226
|
-
|
|
260
|
+
@Test fun explicitExitStillReturnsControlToStockUiUntilNextConnection() {
|
|
261
|
+
val session = NimoCanvasSession(); launched(session); success(session, 4)
|
|
262
|
+
assertEquals(3, send(session.exit()).key)
|
|
227
263
|
assertTrue(session.nativeApp(0, true).isEmpty())
|
|
228
|
-
assertTrue(session
|
|
229
|
-
|
|
264
|
+
assertTrue(success(session, 3).isEmpty())
|
|
265
|
+
assertTrue(session.nativeApp(0, true).isEmpty())
|
|
266
|
+
session.disconnected(); session.activate()
|
|
267
|
+
assertEquals(1, send(session.readiness(true)).key)
|
|
268
|
+
assertArrayEquals(byteArrayOf(0, 0, 1), send(success(session, 1)).frame)
|
|
230
269
|
}
|
|
231
270
|
|
|
232
|
-
@Test fun
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
271
|
+
@Test fun activationLaunchesBlankCanvasWithoutAnyMiniapp() {
|
|
272
|
+
val session = NimoCanvasSession()
|
|
273
|
+
assertTrue(session.activate().isEmpty())
|
|
274
|
+
assertEquals(1, send(session.readiness(true)).key)
|
|
275
|
+
assertArrayEquals(byteArrayOf(0, 0, 1), send(success(session, 1)).frame)
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
@Test fun successfulUpdateRestoresProbeBudgetForNextRecovery() {
|
|
279
|
+
val session = NimoCanvasSession(); session.activate(); session.readiness(true)
|
|
280
|
+
repeat(2) { episode ->
|
|
281
|
+
if (episode > 0) assertEquals(1, send(session.nativeApp(0, true)).key)
|
|
282
|
+
repeat(3) {
|
|
283
|
+
session.response(1, byteArrayOf(7))
|
|
284
|
+
assertEquals(1, send(session.retryNotReady()).key)
|
|
285
|
+
}
|
|
286
|
+
assertEquals(4, send(success(session, 1)).key)
|
|
287
|
+
assertTrue(success(session, 4).isEmpty())
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
@Test fun notReadyProbesAreBoundedAndFreshHeartbeatStillRecovers() {
|
|
292
|
+
val session = NimoCanvasSession(); session.activate(); session.readiness(true)
|
|
293
|
+
repeat(3) {
|
|
294
|
+
session.response(1, byteArrayOf(7))
|
|
295
|
+
assertEquals(1, send(session.retryNotReady()).key)
|
|
237
296
|
}
|
|
297
|
+
session.response(1, byteArrayOf(7))
|
|
298
|
+
repeat(5) { assertTrue(session.retryNotReady().isEmpty()) }
|
|
299
|
+
assertEquals(1, send(session.confirmedReadiness(true)).key)
|
|
300
|
+
assertEquals(4, send(success(session, 1)).key)
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
@Test fun lostReadinessDuringCommandStillResets() {
|
|
304
|
+
val session = NimoCanvasSession(); launched(session)
|
|
305
|
+
assertTrue(session.readiness(false).single() is NimoCanvasSession.Action.Reconnect)
|
|
306
|
+
assertTrue(session.retryNotReady().isEmpty())
|
|
238
307
|
}
|
|
239
308
|
|
|
240
309
|
@Test fun offersCopyMutableInputAndRejectOversizedFrame() {
|