@mentra/bluetooth-sdk 3.2.1-beta.508 → 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.
@@ -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.508"
7
- const val RELEASE_SET_ID: String = "mentra-3.2.1-beta.508"
8
- const val SOURCE_COMMIT: String = "68789e3eb85a07a4297208c6322f92e9262d8e31"
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.508.json"
10
- const val OTA_MANIFEST_SHA256: String = "a1b8ffb1b635bc7e1e933fe18741eeb6133c6208c6c681bb2c3e966ef10e3eaa"
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
  }
@@ -2145,7 +2145,10 @@ class Nimo : SGCManager() {
2145
2145
  )
2146
2146
  }
2147
2147
  diagnostics?.connected((negotiatedMtu - 3).coerceIn(20, 512))
2148
- 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)
2149
2152
  }
2150
2153
 
2151
2154
  private fun handshakeFailed() {
@@ -2221,6 +2224,7 @@ class Nimo : SGCManager() {
2221
2224
  if (diagnostics?.onPacket(packet) == true) return
2222
2225
  if (packet.size >= 10 && packet[8].toInt() == 7 && (packet[9].toInt() and 255) in listOf(1, 3, 4)) {
2223
2226
  val response = NimoCanvasCodec.response(packet) ?: return
2227
+ if (response.first == 1) Bridge.log("NIMO: canvas Launch response status=${response.second.firstOrNull()?.toInt()}")
2224
2228
  canvas.response(response.first, response.second)
2225
2229
  return
2226
2230
  }
@@ -2252,13 +2256,13 @@ class Nimo : SGCManager() {
2252
2256
  if (appId != NimoCanvasCodec.APP_ID) {
2253
2257
  diagnostics?.cancelHeldCapture("Native app takeover preempted held capture", resumeCanvas = false)
2254
2258
  }
2255
- if (canvas.nativeApp(appId, true)) canvasEncoder.invalidate()
2259
+ canvas.nativeApp(appId, true)
2256
2260
  }
2257
2261
  NimoProtocol.STATE_EXIT -> {
2258
2262
  if (appId == NimoCanvasCodec.APP_ID) {
2259
2263
  diagnostics?.cancelHeldCapture("Canvas exit preempted held capture", resumeCanvas = false)
2260
2264
  }
2261
- if (canvas.nativeApp(appId, false)) canvasEncoder.invalidate()
2265
+ canvas.nativeApp(appId, false)
2262
2266
  }
2263
2267
  }
2264
2268
  }
@@ -2290,7 +2294,7 @@ class Nimo : SGCManager() {
2290
2294
  // Both predicates were sampled by this device report. TWS-only
2291
2295
  // reports and cached state cannot release a status-7 wait.
2292
2296
  if (handshakeState == HandshakeState.READY) {
2293
- canvas.confirmedReadiness(twsConnected && peerCompanionReady == true)
2297
+ canvas.confirmedReadiness(twsConnected && peerCompanionReady != false)
2294
2298
  }
2295
2299
  }
2296
2300
  }
@@ -2316,7 +2320,7 @@ class Nimo : SGCManager() {
2316
2320
  if (!connected && handshakeState == HandshakeState.READY) {
2317
2321
  Bridge.log("NIMO: TWS service dropped mid-session (arm removed/off?)")
2318
2322
  }
2319
- if (handshakeState == HandshakeState.READY) canvas.readiness(connected && peerCompanionReady == true)
2323
+ if (handshakeState == HandshakeState.READY) canvas.readiness(connected && peerCompanionReady != false)
2320
2324
  }
2321
2325
 
2322
2326
  private fun handleInputEvent(code: Int) {
@@ -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
 
@@ -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() {
@@ -121,25 +121,31 @@ class NimoReadinessDriverTest {
121
121
  assertTrue(failures.isEmpty())
122
122
  }
123
123
 
124
- @Test fun unknownPeerCannotLaunchFromHandshakeOrTwsOnlyReports() {
125
- canvas.offer(byteArrayOf(0, 0, 1), "initial")
124
+ @Test fun freshHandshakeProbesCanvasWithoutWaitingForHeartbeat() {
126
125
  finishHandshake(peer = null)
127
- assertKeys()
128
- emit(packet(6, 3, byteArrayOf(0, 1)))
129
126
  assertNull(field("peerCompanionReady").get(nimo))
127
+ assertKeys(1) // Firmware validates peer readiness, not a synthetic heartbeat.
128
+ emit(packet(6, 2, byteArrayOf(0, 0, 1))) // Stock dashboard entering at startup.
129
+ assertKeys(1)
130
+ emit(packet(7, 1, byteArrayOf(0, 0xFD.toByte())))
131
+ assertKeys(1, 4)
132
+ assertArrayEquals(NimoCanvasCodec.frames(4, byteArrayOf(0, 0, 1), 512).single(), frames.last())
133
+ assertTrue(failures.isEmpty())
134
+ }
135
+
136
+ @Test fun knownDisconnectedPeerStillBlocksUntilReadinessArrives() {
137
+ finishHandshake(peer = false)
130
138
  assertKeys()
131
- emit(heartbeat(peer = 0))
132
- assertEquals(false, field("peerCompanionReady").get(nimo))
139
+ emit(packet(6, 3, byteArrayOf(0, 1)))
133
140
  assertKeys()
134
141
  emit(heartbeat())
135
- assertEquals(true, field("peerCompanionReady").get(nimo))
136
142
  assertKeys(1)
137
143
  assertTrue(failures.isEmpty())
138
144
  }
139
145
 
140
146
  @Test fun queuedOldGattAndWrongServiceCannotEstablishReadiness() {
141
147
  canvas.offer(byteArrayOf(0, 0, 1), "initial")
142
- finishHandshake(peer = null)
148
+ finishHandshake(peer = false)
143
149
  val oldGatt = currentGatt
144
150
  queue(heartbeat(), oldGatt)
145
151
  currentGatt = newGatt()
@@ -147,7 +153,7 @@ class NimoReadinessDriverTest {
147
153
  idle() // The generation check must occur when the queued callback executes.
148
154
  emit(heartbeat(), oldGatt)
149
155
  emit(heartbeat(), currentGatt, characteristic(UUID.fromString("00001234-0000-1000-8000-00805f9b34fb")))
150
- assertNull(field("peerCompanionReady").get(nimo))
156
+ assertEquals(false, field("peerCompanionReady").get(nimo))
151
157
  assertKeys()
152
158
 
153
159
  emit(heartbeat()) // Positive control through the actual current RX callback.
@@ -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.508",
5
- "releaseSetId": "mentra-3.2.1-beta.508",
6
- "sourceCommit": "68789e3eb85a07a4297208c6322f92e9262d8e31",
7
- "otaManifestUrl": "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.2.1/mentra-live-ota-3.2.1-beta.508.json",
8
- "otaManifestSha256": "a1b8ffb1b635bc7e1e933fe18741eeb6133c6208c6c681bb2c3e966ef10e3eaa"
4
+ "releaseIdentity": "3.2.1-beta.512",
5
+ "releaseSetId": "mentra-3.2.1-beta.512",
6
+ "sourceCommit": "e09a07ee7b71972e75a5051003ec561c12e6cb9b",
7
+ "otaManifestUrl": "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.2.1/mentra-live-ota-3.2.1-beta.512.json",
8
+ "otaManifestSha256": "bd24cbd8279319912d643699eeb435df3ca88582194953583491a3625799ec01"
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.508\",\n \"releaseSetId\": \"mentra-3.2.1-beta.508\",\n \"sourceCommit\": \"68789e3eb85a07a4297208c6322f92e9262d8e31\",\n \"otaManifestUrl\": \"https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.2.1/mentra-live-ota-3.2.1-beta.508.json\",\n \"otaManifestSha256\": \"a1b8ffb1b635bc7e1e933fe18741eeb6133c6208c6c681bb2c3e966ef10e3eaa\"\n})\n"]}
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.512\",\n \"releaseSetId\": \"mentra-3.2.1-beta.512\",\n \"sourceCommit\": \"e09a07ee7b71972e75a5051003ec561c12e6cb9b\",\n \"otaManifestUrl\": \"https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.2.1/mentra-live-ota-3.2.1-beta.512.json\",\n \"otaManifestSha256\": \"bd24cbd8279319912d643699eeb435df3ca88582194953583491a3625799ec01\"\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.508"
19
+ private static let swiftPackageSdkVersion = "3.2.1-beta.512"
20
20
  private static let swiftPackageSdkVersionPlaceholder = "__MENTRA" + "_BLUETOOTH_SDK_VERSION__"
21
21
 
22
22
  private static func normalizedSdkVersion(_ value: String?) -> String? {
@@ -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.508"
7
- static let releaseSetId = "mentra-3.2.1-beta.508"
8
- static let sourceCommit = "68789e3eb85a07a4297208c6322f92e9262d8e31"
9
- static let otaManifestUrl = "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.2.1/mentra-live-ota-3.2.1-beta.508.json"
10
- static let otaManifestSha256 = "a1b8ffb1b635bc7e1e933fe18741eeb6133c6208c6c681bb2c3e966ef10e3eaa"
6
+ static let releaseIdentity = "3.2.1-beta.512"
7
+ static let releaseSetId = "mentra-3.2.1-beta.512"
8
+ static let sourceCommit = "e09a07ee7b71972e75a5051003ec561c12e6cb9b"
9
+ static let otaManifestUrl = "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.2.1/mentra-live-ota-3.2.1-beta.512.json"
10
+ static let otaManifestSha256 = "bd24cbd8279319912d643699eeb435df3ca88582194953583491a3625799ec01"
11
11
  }
@@ -1325,7 +1325,10 @@ class Nimo: NSObject, SGCManager {
1325
1325
  DeviceStore.shared.apply("glasses", "fullyBooted", true)
1326
1326
  DeviceStore.shared.apply("glasses", "connectionState", ConnTypes.CONNECTED)
1327
1327
  startTimers()
1328
- canvas.readiness(twsConnected && peerCompanionReady == true)
1328
+ // The Launch ACK validates peer readiness without waiting for the first
1329
+ // unsolicited heartbeat (which can arrive a minute after connection).
1330
+ canvas.activate()
1331
+ canvas.readiness(twsConnected && peerCompanionReady != false)
1329
1332
  }
1330
1333
 
1331
1334
  private func handshakeFailed() {
@@ -1394,6 +1397,7 @@ class Nimo: NSObject, SGCManager {
1394
1397
  private func handleRxPacket(_ packet: Data) {
1395
1398
  if packet.count >= 10, packet[8] == 7, [1, 3, 4].contains(packet[9]) {
1396
1399
  if let response = NimoCanvasCodec.response(packet) {
1400
+ if response.key == 1 { Bridge.log("NIMO: canvas Launch response status=\(response.payload.first.map(Int.init) ?? -1)") }
1397
1401
  canvas.response(key: response.key, payload: response.payload)
1398
1402
  }
1399
1403
  return
@@ -1427,9 +1431,9 @@ class Nimo: NSObject, SGCManager {
1427
1431
  Bridge.log("NIMO: app state report appId=\(appId) phase=\(phase)")
1428
1432
  switch phase {
1429
1433
  case NimoProtocol.STATE_ENTER:
1430
- if canvas.nativeApp(appId, entered: true) { canvasEncoder.invalidate() }
1434
+ canvas.nativeApp(appId, entered: true)
1431
1435
  case NimoProtocol.STATE_EXIT:
1432
- if canvas.nativeApp(appId, entered: false) { canvasEncoder.invalidate() }
1436
+ canvas.nativeApp(appId, entered: false)
1433
1437
  default:
1434
1438
  break
1435
1439
  }
@@ -1459,7 +1463,7 @@ class Nimo: NSObject, SGCManager {
1459
1463
  peerCompanionReady = v[9] != 0
1460
1464
  onTwsState(Int(v[8]) >= 1)
1461
1465
  if handshakeState == .ready {
1462
- canvas.readiness(twsConnected && peerCompanionReady == true, confirmed: true)
1466
+ canvas.readiness(twsConnected && peerCompanionReady != false, confirmed: true)
1463
1467
  }
1464
1468
  }
1465
1469
  case NimoProtocol.BUSINESS_BATTERY:
@@ -1482,7 +1486,7 @@ class Nimo: NSObject, SGCManager {
1482
1486
  if !connected, handshakeState == .ready {
1483
1487
  Bridge.log("NIMO: TWS service dropped mid-session (arm removed/off?)")
1484
1488
  }
1485
- if handshakeState == .ready { canvas.readiness(connected && peerCompanionReady == true) }
1489
+ if handshakeState == .ready { canvas.readiness(connected && peerCompanionReady != false) }
1486
1490
  }
1487
1491
 
1488
1492
  private func handleInputEvent(_ code: Int) {
@@ -21,6 +21,7 @@ final class NimoCanvasCoordinator {
21
21
  private let rejected: (Int) -> Void
22
22
  private let session = NimoCanvasSession()
23
23
  private var flight: Flight?
24
+ private var cancelRetry: (() -> Void)?
24
25
  private var cancelDeadline: (() -> Void)?
25
26
 
26
27
  init(schedule: @escaping NimoScheduler, writeCapacity: @escaping () -> Int,
@@ -35,23 +36,26 @@ final class NimoCanvasCoordinator {
35
36
  run(session.offer(bytes, scope: scope, force: force))
36
37
  }
37
38
 
39
+ func activate() {
40
+ run(session.activate())
41
+ }
42
+
38
43
  func readiness(_ value: Bool, confirmed: Bool = false) {
44
+ if !value { cancelRetry?(); cancelRetry = nil }
39
45
  run(session.readiness(value, confirmed: confirmed))
40
46
  }
41
47
 
42
48
  func exit() {
49
+ cancelRetry?(); cancelRetry = nil
43
50
  run(session.exit())
44
51
  }
45
52
 
46
- /// An expected Exit report must not discard a newer host encode queued behind Exit.
47
- func nativeApp(_ appId: Int, entered: Bool) -> Bool {
48
- let takeover = (appId == NimoCanvasCodec.appId && !entered) || (appId != NimoCanvasCodec.appId && entered)
49
- let expectedExit = appId == NimoCanvasCodec.appId && !entered && flight?.key == 3
53
+ func nativeApp(_ appId: Int, entered: Bool) {
50
54
  run(session.nativeApp(appId, entered: entered))
51
- return takeover && !expectedExit
52
55
  }
53
56
 
54
57
  func disconnected() {
58
+ cancelRetry?(); cancelRetry = nil
55
59
  cancelDeadline?(); cancelDeadline = nil
56
60
  flight = nil
57
61
  session.disconnected()
@@ -72,6 +76,7 @@ final class NimoCanvasCoordinator {
72
76
  for action in actions {
73
77
  switch action {
74
78
  case let .send(key, frame, ticket):
79
+ cancelRetry?(); cancelRetry = nil
75
80
  cancelDeadline?()
76
81
  let current = Flight(key: key)
77
82
  flight = current
@@ -93,7 +98,16 @@ final class NimoCanvasCoordinator {
93
98
  disconnected(); reconnect("Canvas framing failed: \(error)")
94
99
  }
95
100
  case let .reconnect(reason): disconnected(); reconnect(reason)
96
- case let .rejected(status): rejected(status)
101
+ case let .rejected(status):
102
+ rejected(status)
103
+ if status == 7 {
104
+ cancelRetry?()
105
+ cancelRetry = schedule(1) { [weak self] in
106
+ guard let self else { return }
107
+ self.cancelRetry = nil
108
+ self.run(self.session.retryNotReady())
109
+ }
110
+ }
97
111
  }
98
112
  }
99
113
  }
@@ -15,12 +15,14 @@ final class NimoCanvasSession {
15
15
  }
16
16
 
17
17
  private var ready = false
18
+ private var transportReady = false
18
19
  private var active = false
19
20
  private var locked = false
20
21
  private var launchBlocked = false
21
22
  private var waitingReadiness = false
22
23
  private var observedNotReady = false
23
24
  private var readinessRetries = 0
25
+ private var probeRetries = 0
24
26
  private var exitRequested = false
25
27
  private var desired: Data?
26
28
  private var accepted: Data?
@@ -39,9 +41,27 @@ final class NimoCanvasSession {
39
41
  return pump()
40
42
  }
41
43
 
44
+ /// Enter a blank canvas unless a newer host scene survived the connection change.
45
+ func activate() -> [Action] {
46
+ if desired == nil { desired = Data([0, 0, 1]) }
47
+ exitRequested = false
48
+ return pump()
49
+ }
50
+
51
+ /// The firmware validates peer readiness on Launch. Only an explicit NOT_READY
52
+ /// permits a bounded retry; an ambiguous ACK still requires a new transport.
53
+ func retryNotReady() -> [Action] {
54
+ guard transportReady, waitingReadiness, desired != nil, probeRetries < 3 else { return [] }
55
+ probeRetries += 1
56
+ waitingReadiness = false
57
+ ready = true
58
+ return pump()
59
+ }
60
+
42
61
  /// Only a fresh heartbeat samples both TWS and peer Companion readiness.
43
62
  func readiness(_ value: Bool, confirmed: Bool = false) -> [Action] {
44
- if !value { readinessRetries = 0 }
63
+ transportReady = value
64
+ if !value { readinessRetries = 0; probeRetries = 0 }
45
65
  if !value, flight != nil { return resetLink("Readiness lost during canvas command") }
46
66
  if waitingReadiness {
47
67
  if !value { observedNotReady = true }
@@ -58,8 +78,8 @@ final class NimoCanvasSession {
58
78
 
59
79
  /// Keep only the latest desired scene across a new transport generation.
60
80
  func disconnected() {
61
- ready = false; active = false; locked = false; launchBlocked = false
62
- waitingReadiness = false; observedNotReady = false; readinessRetries = 0
81
+ ready = false; transportReady = false; active = false; locked = false; launchBlocked = false
82
+ waitingReadiness = false; observedNotReady = false; readinessRetries = 0; probeRetries = 0
63
83
  accepted = nil; rejected = nil; flight = nil
64
84
  ticket &+= 1; forceRevision &+= 1
65
85
  }
@@ -69,16 +89,12 @@ final class NimoCanvasSession {
69
89
  return pump()
70
90
  }
71
91
 
92
+ /// Stock UI reports do not relinquish host ownership. Replay after the current ACK.
72
93
  func nativeApp(_ appId: Int, entered: Bool) -> [Action] {
73
94
  if (appId == NimoCanvasCodec.appId && !entered) || (appId != NimoCanvasCodec.appId && entered) {
74
- if flight?.key == 3 {
75
- active = false; accepted = nil
76
- if appId != NimoCanvasCodec.appId, entered { desired = nil; rejected = nil; scope = nil }
77
- return []
78
- }
79
- let ambiguous = flight != nil
80
- desired = nil; accepted = nil; rejected = nil; active = false; exitRequested = false; scope = nil
81
- if ambiguous { return resetLink("Native app transition interrupted canvas command") }
95
+ active = false
96
+ accepted = nil
97
+ return pump()
82
98
  }
83
99
  return []
84
100
  }
@@ -112,8 +128,8 @@ final class NimoCanvasSession {
112
128
  return [.rejected(status)] + pump()
113
129
  }
114
130
  switch key {
115
- case 1: active = true
116
- case 4: accepted = current.frame; rejected = nil; readinessRetries = 0
131
+ case 1: active = true; accepted = nil
132
+ case 4: accepted = current.frame; rejected = nil; readinessRetries = 0; probeRetries = 0
117
133
  case 3: active = false; accepted = nil; exitRequested = false
118
134
  default: break
119
135
  }
@@ -126,7 +142,7 @@ final class NimoCanvasSession {
126
142
  }
127
143
 
128
144
  private func resetLink(_ reason: String) -> [Action] {
129
- ready = false; active = false; accepted = nil; flight = nil
145
+ ready = false; transportReady = false; active = false; accepted = nil; flight = nil
130
146
  ticket &+= 1; forceRevision &+= 1
131
147
  return [.reconnect(reason)]
132
148
  }
@@ -102,11 +102,80 @@ final class NimoCanvasSessionTests: XCTestCase {
102
102
  XCTAssertEqual(frame, b)
103
103
  }
104
104
 
105
- func testNativeTakeoverSuppressesReplayUntilHostOffersAgain() {
105
+ func testNativeTakeoverReplaysWithoutNewHostFrame() {
106
106
  let session = NimoCanvasSession(); connected(session)
107
- XCTAssertTrue(session.nativeApp(1, entered: true).isEmpty)
108
- XCTAssertTrue(session.readiness(true, confirmed: true).isEmpty)
109
- XCTAssertEqual(sentKey(session.offer(b, scope: "new")), 1)
110
- XCTAssertEqual(session.nativeApp(1, entered: true), [.reconnect("Native app transition interrupted canvas command")])
107
+ XCTAssertEqual(sentKey(session.nativeApp(0, entered: true)), 1)
108
+ XCTAssertTrue(session.nativeApp(0, entered: true).isEmpty)
109
+ guard case let .send(_, frame, _) = ack(session, 1).first else { return XCTFail() }
110
+ XCTAssertEqual(frame, a)
111
+ XCTAssertTrue(session.nativeApp(0xFD, entered: false).isEmpty)
112
+ XCTAssertEqual(sentKey(ack(session, 4)), 1)
113
+ }
114
+
115
+ func testTakeoverDuringUpdateReplaysIdenticalFrameAfterRelaunch() {
116
+ let session = NimoCanvasSession()
117
+ _ = session.offer(a, scope: "app:1"); _ = session.readiness(true)
118
+ XCTAssertEqual(sentKey(ack(session, 1)), 4)
119
+ XCTAssertTrue(session.nativeApp(0, entered: true).isEmpty)
120
+ XCTAssertEqual(sentKey(ack(session, 4)), 1)
121
+ guard case let .send(_, frame, _) = ack(session, 1).first else { return XCTFail("Missing replay Update") }
122
+ XCTAssertEqual(frame, a)
123
+ XCTAssertTrue(ack(session, 4).isEmpty)
124
+ XCTAssertTrue(session.offer(a, scope: "app:1").isEmpty)
125
+ }
126
+
127
+ func testStartupStockUiPreservesReconnectScene() {
128
+ let session = NimoCanvasSession(); connected(session)
129
+ session.disconnected()
130
+ XCTAssertTrue(session.nativeApp(0, entered: true).isEmpty)
131
+ XCTAssertTrue(session.activate().isEmpty)
132
+ XCTAssertEqual(sentKey(session.readiness(true)), 1)
133
+ guard case let .send(_, frame, _) = ack(session, 1).first else { return XCTFail() }
134
+ XCTAssertEqual(frame, a)
135
+ }
136
+
137
+ func testActivationEntersBlankCanvasBeforeAnyMiniapp() {
138
+ let session = NimoCanvasSession()
139
+ XCTAssertTrue(session.activate().isEmpty)
140
+ XCTAssertEqual(sentKey(session.readiness(true)), 1)
141
+ guard case let .send(_, frame, _) = ack(session, 1).first else { return XCTFail() }
142
+ XCTAssertEqual(frame, Data([0, 0, 1]))
143
+ }
144
+
145
+ func testExplicitExitStopsRestorationUntilAnotherConnection() {
146
+ let session = NimoCanvasSession(); connected(session)
147
+ XCTAssertEqual(sentKey(session.exit()), 3)
148
+ XCTAssertTrue(session.nativeApp(0, entered: true).isEmpty)
149
+ XCTAssertTrue(ack(session, 3).isEmpty)
150
+ XCTAssertTrue(session.nativeApp(0xFD, entered: false).isEmpty)
151
+ session.disconnected()
152
+ _ = session.activate()
153
+ XCTAssertEqual(sentKey(session.readiness(true)), 1)
154
+ }
155
+
156
+ func testSuccessfulUpdateRestoresProbeBudgetForNextRecovery() {
157
+ let session = NimoCanvasSession()
158
+ _ = session.activate(); _ = session.readiness(true)
159
+ for episode in 0 ..< 2 {
160
+ if episode > 0 { XCTAssertEqual(sentKey(session.nativeApp(0, entered: true)), 1) }
161
+ for _ in 0 ..< 3 {
162
+ _ = ack(session, 1, status: 7)
163
+ XCTAssertEqual(sentKey(session.retryNotReady()), 1)
164
+ }
165
+ XCTAssertEqual(sentKey(ack(session, 1)), 4)
166
+ XCTAssertTrue(ack(session, 4).isEmpty)
167
+ }
168
+ }
169
+
170
+ func testProbesAreBoundedAndHeartbeatCanStillRecover() {
171
+ let session = NimoCanvasSession()
172
+ _ = session.activate(); _ = session.readiness(true)
173
+ for _ in 0 ..< 3 {
174
+ _ = ack(session, 1, status: 7)
175
+ XCTAssertEqual(sentKey(session.retryNotReady()), 1)
176
+ }
177
+ _ = ack(session, 1, status: 7)
178
+ XCTAssertTrue(session.retryNotReady().isEmpty)
179
+ XCTAssertEqual(sentKey(session.readiness(true, confirmed: true)), 1)
111
180
  }
112
181
  }
@@ -33,6 +33,31 @@ private final class NimoTestClock {
33
33
  final class NimoCanvasTransportTests: XCTestCase {
34
34
  private let frame = Data([0, 0, 1])
35
35
 
36
+ func testNotReadyProbesAreTimedBoundedAndCancelled() {
37
+ for stop in ["budget", "disconnect", "exit", "notReady"] {
38
+ let clock = NimoTestClock()
39
+ var keys: [Int] = []
40
+ let canvas = NimoCanvasCoordinator(schedule: clock.schedule, writeCapacity: { 512 }, enqueue: { frames, start, complete in
41
+ keys.append(Int(frames[0][9])); start(); complete(); return true
42
+ }, reconnect: { XCTFail($0) }, rejected: { _ in })
43
+ canvas.activate(); canvas.readiness(true)
44
+ for attempt in 0 ..< 4 {
45
+ canvas.response(key: 1, payload: Data([7]))
46
+ if stop == "disconnect" { canvas.disconnected() }
47
+ if stop == "exit" { canvas.exit() }
48
+ if stop == "notReady" { canvas.readiness(false) }
49
+ let before = keys.count
50
+ clock.advance(0.999)
51
+ XCTAssertEqual(keys.count, before)
52
+ clock.advance(0.001)
53
+ XCTAssertEqual(keys.count, before + (stop == "budget" && attempt < 3 ? 1 : 0))
54
+ if stop != "budget" { break }
55
+ }
56
+ clock.advance(60)
57
+ XCTAssertEqual(keys.count, stop == "budget" ? 4 : 1)
58
+ }
59
+ }
60
+
36
61
  func testEarlyAckOnlyAdvancesAfterFinalWriteCompletes() {
37
62
  let clock = NimoTestClock()
38
63
  var chains: [[Data]] = [], starts: [() -> Void] = [], completes: [() -> Void] = []
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mentra/bluetooth-sdk",
3
- "version": "3.2.1-beta.508",
3
+ "version": "3.2.1-beta.512",
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.508"
117
+ "@mentra/cloud-protocol": "3.2.1-beta.512"
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.508",
16
- "releaseSetId": "mentra-3.2.1-beta.508",
17
- "sourceCommit": "68789e3eb85a07a4297208c6322f92e9262d8e31",
18
- "otaManifestUrl": "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.2.1/mentra-live-ota-3.2.1-beta.508.json",
19
- "otaManifestSha256": "a1b8ffb1b635bc7e1e933fe18741eeb6133c6208c6c681bb2c3e966ef10e3eaa"
15
+ "releaseIdentity": "3.2.1-beta.512",
16
+ "releaseSetId": "mentra-3.2.1-beta.512",
17
+ "sourceCommit": "e09a07ee7b71972e75a5051003ec561c12e6cb9b",
18
+ "otaManifestUrl": "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.2.1/mentra-live-ota-3.2.1-beta.512.json",
19
+ "otaManifestSha256": "bd24cbd8279319912d643699eeb435df3ca88582194953583491a3625799ec01"
20
20
  })