@mentra/bluetooth-sdk 3.2.1-beta.508 → 3.2.1-beta.518
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/GeneratedReleaseMetadata.kt +5 -5
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Nimo.kt +9 -5
- 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/sgcs/NimoCanvasCoordinatorTest.kt +44 -4
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasSessionTest.kt +82 -13
- package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoReadinessDriverTest.kt +15 -9
- package/build/generated/releaseMetadata.js +5 -5
- package/build/generated/releaseMetadata.js.map +1 -1
- package/ios/Source/BluetoothSdkDefaults.swift +1 -1
- package/ios/Source/GeneratedReleaseMetadata.swift +5 -5
- package/ios/Source/sgcs/Nimo.swift +9 -5
- package/ios/Source/sgcs/NimoCanvasCoordinator.swift +20 -6
- package/ios/Source/sgcs/NimoCanvasSession.swift +30 -14
- package/ios/Tests/NimoCanvasSessionTests.swift +74 -5
- package/ios/Tests/NimoCanvasTransportTests.swift +25 -0
- package/package.json +2 -2
- package/src/generated/releaseMetadata.ts +5 -5
|
@@ -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.518"
|
|
7
|
+
const val RELEASE_SET_ID: String = "mentra-3.2.1-beta.518"
|
|
8
|
+
const val SOURCE_COMMIT: String = "5a80fe37ebb97874d4518568d6ffa3945625c295"
|
|
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.518.json"
|
|
10
|
+
const val OTA_MANIFEST_SHA256: String = "26bbe68991dd51913325679f26b9536708bcb39785fcefc534c0e3367504014e"
|
|
11
11
|
}
|
|
@@ -2145,7 +2145,10 @@ class Nimo : SGCManager() {
|
|
|
2145
2145
|
)
|
|
2146
2146
|
}
|
|
2147
2147
|
diagnostics?.connected((negotiatedMtu - 3).coerceIn(20, 512))
|
|
2148
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
|
@@ -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() {
|
|
@@ -121,25 +121,31 @@ class NimoReadinessDriverTest {
|
|
|
121
121
|
assertTrue(failures.isEmpty())
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
-
@Test fun
|
|
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(
|
|
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 =
|
|
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
|
-
|
|
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.
|
|
5
|
-
"releaseSetId": "mentra-3.2.1-beta.
|
|
6
|
-
"sourceCommit": "
|
|
7
|
-
"otaManifestUrl": "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.2.1/mentra-live-ota-3.2.1-beta.
|
|
8
|
-
"otaManifestSha256": "
|
|
4
|
+
"releaseIdentity": "3.2.1-beta.518",
|
|
5
|
+
"releaseSetId": "mentra-3.2.1-beta.518",
|
|
6
|
+
"sourceCommit": "5a80fe37ebb97874d4518568d6ffa3945625c295",
|
|
7
|
+
"otaManifestUrl": "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.2.1/mentra-live-ota-3.2.1-beta.518.json",
|
|
8
|
+
"otaManifestSha256": "26bbe68991dd51913325679f26b9536708bcb39785fcefc534c0e3367504014e"
|
|
9
9
|
});
|
|
10
10
|
//# sourceMappingURL=releaseMetadata.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"releaseMetadata.js","sourceRoot":"","sources":["../../src/generated/releaseMetadata.ts"],"names":[],"mappings":"AAWA,MAAM,CAAC,MAAM,8BAA8B,GAA0C,MAAM,CAAC,MAAM,CAAC;IACjG,eAAe,EAAE,CAAC;IAClB,mBAAmB,EAAE,OAAO;IAC5B,iBAAiB,EAAE,gBAAgB;IACnC,cAAc,EAAE,uBAAuB;IACvC,cAAc,EAAE,0CAA0C;IAC1D,gBAAgB,EAAE,kIAAkI;IACpJ,mBAAmB,EAAE,kEAAkE;CACxF,CAAC,CAAA","sourcesContent":["/** Generated by release CI. Do not edit in a release checkout. */\nexport interface BluetoothSdkReleaseMetadata {\n schemaVersion: 1\n familyBaseVersion: string | null\n releaseIdentity: string | null\n releaseSetId: string | null\n sourceCommit: string | null\n otaManifestUrl: string | null\n otaManifestSha256: string | null\n}\n\nexport const BLUETOOTH_SDK_RELEASE_METADATA: Readonly<BluetoothSdkReleaseMetadata> = Object.freeze({\n \"schemaVersion\": 1,\n \"familyBaseVersion\": \"3.2.1\",\n \"releaseIdentity\": \"3.2.1-beta.
|
|
1
|
+
{"version":3,"file":"releaseMetadata.js","sourceRoot":"","sources":["../../src/generated/releaseMetadata.ts"],"names":[],"mappings":"AAWA,MAAM,CAAC,MAAM,8BAA8B,GAA0C,MAAM,CAAC,MAAM,CAAC;IACjG,eAAe,EAAE,CAAC;IAClB,mBAAmB,EAAE,OAAO;IAC5B,iBAAiB,EAAE,gBAAgB;IACnC,cAAc,EAAE,uBAAuB;IACvC,cAAc,EAAE,0CAA0C;IAC1D,gBAAgB,EAAE,kIAAkI;IACpJ,mBAAmB,EAAE,kEAAkE;CACxF,CAAC,CAAA","sourcesContent":["/** Generated by release CI. Do not edit in a release checkout. */\nexport interface BluetoothSdkReleaseMetadata {\n schemaVersion: 1\n familyBaseVersion: string | null\n releaseIdentity: string | null\n releaseSetId: string | null\n sourceCommit: string | null\n otaManifestUrl: string | null\n otaManifestSha256: string | null\n}\n\nexport const BLUETOOTH_SDK_RELEASE_METADATA: Readonly<BluetoothSdkReleaseMetadata> = Object.freeze({\n \"schemaVersion\": 1,\n \"familyBaseVersion\": \"3.2.1\",\n \"releaseIdentity\": \"3.2.1-beta.518\",\n \"releaseSetId\": \"mentra-3.2.1-beta.518\",\n \"sourceCommit\": \"5a80fe37ebb97874d4518568d6ffa3945625c295\",\n \"otaManifestUrl\": \"https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.2.1/mentra-live-ota-3.2.1-beta.518.json\",\n \"otaManifestSha256\": \"26bbe68991dd51913325679f26b9536708bcb39785fcefc534c0e3367504014e\"\n})\n"]}
|
|
@@ -16,7 +16,7 @@ enum BluetoothSdkDefaults {
|
|
|
16
16
|
/// silently disable it on every pair the app touches.
|
|
17
17
|
static let autoPowerOffEnabled = false
|
|
18
18
|
private static let infoSdkVersionKey = "MentraBluetoothSdkVersion"
|
|
19
|
-
private static let swiftPackageSdkVersion = "3.2.1-beta.
|
|
19
|
+
private static let swiftPackageSdkVersion = "3.2.1-beta.518"
|
|
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.
|
|
7
|
-
static let releaseSetId = "mentra-3.2.1-beta.
|
|
8
|
-
static let sourceCommit = "
|
|
9
|
-
static let otaManifestUrl = "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.2.1/mentra-live-ota-3.2.1-beta.
|
|
10
|
-
static let otaManifestSha256 = "
|
|
6
|
+
static let releaseIdentity = "3.2.1-beta.518"
|
|
7
|
+
static let releaseSetId = "mentra-3.2.1-beta.518"
|
|
8
|
+
static let sourceCommit = "5a80fe37ebb97874d4518568d6ffa3945625c295"
|
|
9
|
+
static let otaManifestUrl = "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.2.1/mentra-live-ota-3.2.1-beta.518.json"
|
|
10
|
+
static let otaManifestSha256 = "26bbe68991dd51913325679f26b9536708bcb39785fcefc534c0e3367504014e"
|
|
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
|
-
|
|
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
|
-
|
|
1434
|
+
canvas.nativeApp(appId, entered: true)
|
|
1431
1435
|
case NimoProtocol.STATE_EXIT:
|
|
1432
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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):
|
|
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
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
|
105
|
+
func testNativeTakeoverReplaysWithoutNewHostFrame() {
|
|
106
106
|
let session = NimoCanvasSession(); connected(session)
|
|
107
|
-
|
|
108
|
-
XCTAssertTrue(session.
|
|
109
|
-
|
|
110
|
-
XCTAssertEqual(
|
|
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.
|
|
3
|
+
"version": "3.2.1-beta.518",
|
|
4
4
|
"description": "SDK for communicating with smart glasses",
|
|
5
5
|
"main": "build/index.js",
|
|
6
6
|
"react-native": "src/index.ts",
|
|
@@ -114,7 +114,7 @@
|
|
|
114
114
|
"registry": "https://registry.npmjs.org/"
|
|
115
115
|
},
|
|
116
116
|
"dependencies": {
|
|
117
|
-
"@mentra/cloud-protocol": "3.2.1-beta.
|
|
117
|
+
"@mentra/cloud-protocol": "3.2.1-beta.518"
|
|
118
118
|
},
|
|
119
119
|
"devDependencies": {
|
|
120
120
|
"@types/node": "^25.9.3",
|
|
@@ -12,9 +12,9 @@ export interface BluetoothSdkReleaseMetadata {
|
|
|
12
12
|
export const BLUETOOTH_SDK_RELEASE_METADATA: Readonly<BluetoothSdkReleaseMetadata> = Object.freeze({
|
|
13
13
|
"schemaVersion": 1,
|
|
14
14
|
"familyBaseVersion": "3.2.1",
|
|
15
|
-
"releaseIdentity": "3.2.1-beta.
|
|
16
|
-
"releaseSetId": "mentra-3.2.1-beta.
|
|
17
|
-
"sourceCommit": "
|
|
18
|
-
"otaManifestUrl": "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.2.1/mentra-live-ota-3.2.1-beta.
|
|
19
|
-
"otaManifestSha256": "
|
|
15
|
+
"releaseIdentity": "3.2.1-beta.518",
|
|
16
|
+
"releaseSetId": "mentra-3.2.1-beta.518",
|
|
17
|
+
"sourceCommit": "5a80fe37ebb97874d4518568d6ffa3945625c295",
|
|
18
|
+
"otaManifestUrl": "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.2.1/mentra-live-ota-3.2.1-beta.518.json",
|
|
19
|
+
"otaManifestSha256": "26bbe68991dd51913325679f26b9536708bcb39785fcefc534c0e3367504014e"
|
|
20
20
|
})
|