@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.
Files changed (31) hide show
  1. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +2 -0
  2. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +37 -2
  3. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +5 -5
  4. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Nimo.kt +59 -6
  5. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCoordinator.kt +26 -13
  6. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasSession.kt +31 -19
  7. package/android/src/test/java/com/mentra/bluetoothsdk/DeviceManagerSceneHandoffTest.kt +16 -1
  8. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCoordinatorTest.kt +44 -4
  9. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasSessionTest.kt +82 -13
  10. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoProtocolTest.kt +11 -0
  11. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoLifecycleTest.kt +94 -0
  12. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoReadinessDriverTest.kt +15 -9
  13. package/build/_private/BluetoothSdkModule.d.ts +2 -0
  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 +4 -0
  19. package/ios/Source/BluetoothSdkDefaults.swift +1 -1
  20. package/ios/Source/DeviceManager.swift +23 -1
  21. package/ios/Source/GeneratedReleaseMetadata.swift +5 -5
  22. package/ios/Source/sgcs/Nimo.swift +41 -5
  23. package/ios/Source/sgcs/NimoCanvasCoordinator.swift +20 -6
  24. package/ios/Source/sgcs/NimoCanvasSession.swift +30 -14
  25. package/ios/Tests/DeviceManagerReconnectTests.swift +26 -1
  26. package/ios/Tests/NimoCanvasSessionTests.swift +74 -5
  27. package/ios/Tests/NimoCanvasTransportTests.swift +25 -0
  28. package/ios/Tests/NimoDeviceCommandsTests.swift +16 -0
  29. package/package.json +2 -2
  30. package/src/_private/BluetoothSdkModule.ts +2 -0
  31. 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
- sgc?.setDashboardPosition(h, d)
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.506"
7
- const val RELEASE_SET_ID: String = "mentra-3.2.1-beta.506"
8
- const val SOURCE_COMMIT: String = "cb87ecd9de2a0d253e54728493f1e8002c128207"
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.506.json"
10
- const val OTA_MANIFEST_SHA256: String = "af76d286c921c9c8f22b938a92b0b13aa983f1983c377006df51a1ad7646eed7"
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
- if (pendingAcks.remove(ackKey) != null) abortTransport("Handshake ACK timeout cmd=$cmd key=$key")
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
- canvas.readiness(twsConnected && peerCompanionReady == true)
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
- if (canvas.nativeApp(appId, true)) canvasEncoder.invalidate()
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
- if (canvas.nativeApp(appId, false)) canvasEncoder.invalidate()
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 == true)
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 == true)
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 readiness(ready: Boolean) = run(session.readiness(ready))
144
- fun confirmedReadiness(ready: Boolean) = run(session.confirmedReadiness(ready))
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() = run(session.exit())
159
- /** Whether this report also invalidates an unfinished host encode. Exit's report does not
160
- * invalidate a NEW scene submitted while the explicit Exit waits for its business ACK. */
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 -> rejected(action.status)
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
- if (!value) readinessRetries = 0
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
- /** A local gesture/native app takeover suppresses replay until the host submits another scene. */
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
- // The expected FD Exit report preserves newer host content. A different
85
- // native app entering is a real takeover, even while our Exit waits for ACK.
86
- if (flight?.key == 3) {
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
- assertFalse(f.canvas.nativeApp(0xFD, false))
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 actualNativeTakeoverDuringExitInvalidatesEncodingAndDoesNotRelaunch() {
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
- assertTrue(f.canvas.nativeApp(0, true))
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 actualNativeTakeoverDuringExitDiscardsNewerScene() {
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
- assertTrue(success(session, 3).isEmpty())
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.readiness(true).isEmpty())
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 nativeTakeoverSuppressesReplayUntilNewHostFrame() {
225
- val session = NimoCanvasSession()
226
- launched(session); success(session, 4)
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.readiness(true).isEmpty())
229
- assertEquals(1, send(session.offer(frameA, "one:1")).key)
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 nativeTakeoverOrReadinessLossDuringCommandResets() {
233
- for (takeover in listOf(true, false)) {
234
- val session = NimoCanvasSession(); launched(session)
235
- val actions = if (takeover) session.nativeApp(0, true) else session.readiness(false)
236
- assertTrue(actions.single() is NimoCanvasSession.Action.Reconnect)
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() {