@mentra/bluetooth-sdk 3.4.0-dev.978 → 3.4.0-dev.980

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.
@@ -0,0 +1,476 @@
1
+ import Foundation
2
+ import CoreBluetooth
3
+ @testable import MentraBluetoothSDK
4
+ import XCTest
5
+
6
+ final class NimoResourceSessionTests: XCTestCase {
7
+ func testConnectedDiscoverySelectionRequiresExactUniquePeerAndCannotChangeAfterSettlement() {
8
+ let model = NimoResourceDiscovery(name: "NIMO exact", startedElapsedMs: 10)
9
+ model.observe(uuid: uuid, name: "NIMO exact_BLE")
10
+ XCTAssertTrue(model.candidates.isEmpty)
11
+ model.observe(uuid: uuid, name: "NIMO exact")
12
+ model.observe(uuid: uuid, name: "NIMO exact")
13
+ model.finish(at: 20, callbacksPending: 1)
14
+ XCTAssertNil(model.settledElapsedMs)
15
+ XCTAssertNil(model.selectedPeripheralUuid)
16
+ model.finish(at: 21, callbacksPending: 0)
17
+ XCTAssertEqual(model.selectedPeripheralUuid, uuid)
18
+ model.observe(uuid: "00000000-0000-4000-8000-000000000002", name: "NIMO exact")
19
+ model.cancel(); model.finish(at: 30, callbacksPending: 0)
20
+ XCTAssertEqual(model.selectedPeripheralUuid, uuid)
21
+ XCTAssertEqual(model.settledElapsedMs, 21)
22
+ XCTAssertEqual(model.candidates.count, 1)
23
+ }
24
+ func testConnectedDiscoveryAmbiguityCancellationAndMissingPeerNeverSelect() {
25
+ for scenario in 0..<3 {
26
+ let model = NimoResourceDiscovery(name: "NIMO exact", startedElapsedMs: 10)
27
+ if scenario != 2 { model.observe(uuid: uuid, name: "NIMO exact") }
28
+ if scenario == 0 { model.observe(uuid: "00000000-0000-4000-8000-000000000002", name: "NIMO exact") }
29
+ if scenario == 1 { model.cancel() }
30
+ model.finish(at: 20, callbacksPending: 0)
31
+ XCTAssertNil(model.selectedPeripheralUuid)
32
+ XCTAssertEqual(model.settledElapsedMs, 20)
33
+ if scenario != 1 { XCTAssertFalse(model.errors.isEmpty) }
34
+ }
35
+ }
36
+ func testConnectedDiscoveryDoesNotSelectPeerThatDisappearedBeforeFinalObservation() {
37
+ let model = NimoResourceDiscovery(name: "NIMO exact", startedElapsedMs: 10)
38
+ model.observeConnectedSnapshot([(uuid, "NIMO exact")])
39
+ model.observeConnectedSnapshot([])
40
+ model.finish(at: 20, callbacksPending: 0)
41
+ XCTAssertNil(model.selectedPeripheralUuid)
42
+ XCTAssertEqual(model.candidates.count, 1)
43
+ XCTAssertFalse(model.errors.isEmpty)
44
+ }
45
+ private func discoveryRequest(operation: String, capture: String, nonce: String, extraPair: [String: Any] = [:]) throws -> Data {
46
+ var pair: [String: Any] = ["name": "NIMO exact", "peripheralSelection": "connected-service-exact-name-v1"]
47
+ for (key, value) in extraPair { pair[key] = value }
48
+ return try JSONSerialization.data(withJSONObject: ["version": 1, "operation": operation, "requestNonce": nonce, "captureNonce": capture,
49
+ "planSha256": String(repeating: "a", count: 64), "grantSha256": String(repeating: "b", count: 64),
50
+ "expectedProcess": ["pid": 123, "instanceId": uuid], "expectedPair": pair, "original": NSNull()])
51
+ }
52
+ func testDiscoveryWireRejectsMixedOrUnknownSelectionRatherThanPlaceholderUuid() throws {
53
+ let data = try discoveryRequest(operation: "discover", capture: uuid, nonce: uuid)
54
+ XCTAssertEqual(try NimoResourceRequest.decode(data).expectedPair.peripheralSelection, NimoResourceSelection.connectedService)
55
+ XCTAssertThrowsError(try NimoResourceRequest.decode(discoveryRequest(operation: "discover", capture: uuid, nonce: uuid, extraPair: ["peripheralUuid": uuid])))
56
+ XCTAssertThrowsError(try NimoResourceRequest.decode(discoveryRequest(operation: "discover", capture: uuid, nonce: uuid, extraPair: ["peripheralSelection": "name-only"])))
57
+ XCTAssertThrowsError(try NimoResourceRequest.decode(discoveryRequest(operation: "discover", capture: uuid, nonce: uuid, extraPair: ["peripheralSelection": NSNull()])))
58
+ }
59
+ @MainActor
60
+ func testActualFixtureDiscoveryCannotAdmitManagerUntilSeparateArmAndPersistedDiscoveryReceipt() async throws {
61
+ let discoveryId = "00000000-0000-4000-8000-000000000002"
62
+ for persisted in [false, true] {
63
+ let fixture = NimoResourceFixture()
64
+ fixture.packageSeedDiscovery(name: "NIMO exact", capture: uuid, discoveryOperation: discoveryId, candidates: [(uuid, "NIMO exact")], persisted: persisted)
65
+ XCTAssertNil(fixture.session)
66
+ XCTAssertFalse(fixture.allowsManager("NIMO", name: "NIMO exact"))
67
+ let status = try await fixture.packagePerform(discoveryRequest(operation: "status", capture: uuid, nonce: discoveryId))
68
+ XCTAssertTrue(status["session"] is NSNull)
69
+ let anchor = (status["capture"] as! [String: Any])["armedElapsedMs"] as! UInt64
70
+ do {
71
+ let armed = try await fixture.packagePerform(discoveryRequest(operation: "arm", capture: uuid, nonce: "00000000-0000-4000-8000-000000000003"))
72
+ XCTAssertTrue(persisted)
73
+ XCTAssertEqual(fixture.session?.pair.peripheralUuid, uuid)
74
+ XCTAssertEqual((armed["capture"] as! [String: Any])["armedElapsedMs"] as? UInt64, anchor)
75
+ XCTAssertTrue(fixture.allowsManager("NIMO", name: "NIMO exact"))
76
+ } catch { XCTAssertFalse(persisted) }
77
+ }
78
+ }
79
+ @MainActor
80
+ func testActualFixtureDiscoveryOnlyCancelClosesObserverWithoutSessionOrFakeDisconnectAndForbidsLaterArm() async throws {
81
+ let fixture = NimoResourceFixture(), discoveryId = "00000000-0000-4000-8000-000000000002"
82
+ fixture.packageSeedDiscovery(name: "NIMO exact", capture: uuid, discoveryOperation: discoveryId, candidates: [(uuid, "NIMO exact")], persisted: true)
83
+ let result = try await fixture.packagePerform(discoveryRequest(operation: "cancel", capture: uuid, nonce: discoveryId))
84
+ XCTAssertNil(fixture.session)
85
+ let release = result["release"] as! [String: Any]
86
+ XCTAssertEqual(release["noTransport"] as? Bool, true)
87
+ XCTAssertEqual(release["settled"] as? Bool, true)
88
+ XCTAssertTrue(release["disconnectCallbackElapsedMs"] is NSNull)
89
+ XCTAssertTrue(fixture.allowsManager("G2", name: "G2"))
90
+ do { _ = try await fixture.packagePerform(discoveryRequest(operation: "arm", capture: uuid, nonce: "00000000-0000-4000-8000-000000000003")); XCTFail("cancelled discovery admitted arm") }
91
+ catch { XCTAssertNil(fixture.session) }
92
+ }
93
+ @MainActor
94
+ func testActualObserverLoopRequiresFreshFinalRetrievalAndRejectsRadioStateLossWithoutRadio() async {
95
+ for finalState in [CBManagerState.poweredOn, .poweredOff, .resetting] {
96
+ for finalPeerPresent in [false, true] {
97
+ var clock: UInt64 = 100, samples = 0, pauses = 0
98
+ let observer = NimoResourceConnectedDiscovery(name: "NIMO exact", startedElapsedMs: clock)
99
+ observer.packageConfigureObserver(now: { clock }, pause: {
100
+ pauses += 1; clock += 5_000
101
+ }, sample: {
102
+ samples += 1
103
+ if samples == 1 { return (.poweredOn, [(self.uuid, "NIMO exact")]) }
104
+ return (finalState, finalPeerPresent ? [(self.uuid, "NIMO exact")] : [])
105
+ })
106
+ await observer.discoverConnected()
107
+ XCTAssertEqual(samples, 2)
108
+ XCTAssertEqual(pauses, 1)
109
+ XCTAssertEqual(observer.callbacksPending, 0)
110
+ XCTAssertEqual(observer.ledger.settledElapsedMs, 5_100)
111
+ XCTAssertEqual(observer.ledger.selectedPeripheralUuid, finalState == .poweredOn && finalPeerPresent ? uuid : nil)
112
+ XCTAssertEqual(observer.ledger.candidates.count, 1)
113
+ }
114
+ }
115
+ }
116
+ @MainActor
117
+ func testActualObserverInvalidatesIntermediateRadioLossAndCancellationCannotReusePreviousSample() async {
118
+ var clock: UInt64 = 100, samples = 0
119
+ let observer = NimoResourceConnectedDiscovery(name: "NIMO exact", startedElapsedMs: clock)
120
+ observer.packageConfigureObserver(now: { clock }, pause: {
121
+ clock += 2_500
122
+ if clock == 5_100 { throw CancellationError() }
123
+ }, sample: {
124
+ samples += 1
125
+ return samples == 1 ? (.poweredOn, [(self.uuid, "NIMO exact")]) : (.resetting, [])
126
+ })
127
+ await observer.discoverConnected()
128
+ XCTAssertEqual(samples, 2)
129
+ XCTAssertNil(observer.ledger.selectedPeripheralUuid)
130
+ XCTAssertEqual(observer.ledger.settledElapsedMs, 5_100)
131
+ XCTAssertTrue(observer.ledger.cancelled)
132
+ XCTAssertTrue(observer.ledger.errors.contains("discovery-task-cancelled"))
133
+ }
134
+ private let uuid = "00000000-0000-4000-8000-000000000001"
135
+ private final class Clock { var value: UInt64 = 100; func next() -> UInt64 { value += 1; return value } }
136
+ private func fixture() -> (NimoResourceSession, NSObject, NSObject, Clock) {
137
+ let clock = Clock(), owner = NSObject(), peer = NSObject()
138
+ let model = NimoResourceSession(captureNonce: uuid, pair: NimoResourcePair(name: "NIMO test", peripheralUuid: uuid), now: clock.next)
139
+ XCTAssertTrue(model.admit(owner, instanceId: uuid, name: "NIMO test"))
140
+ return (model, owner, peer, clock)
141
+ }
142
+ @MainActor
143
+ func testActualNimoReleaseRetiresPendingTimeAckWithoutRadioOrFalseHandshakeFailure() async {
144
+ for cancel in [false, true] {
145
+ for timeoutBeforeEntry in [false, true] {
146
+ let result = await Nimo().packageExercisePendingTimeAck(cancel: cancel, timeoutBeforeEntry: timeoutBeforeEntry)
147
+ XCTAssertTrue(result.errors.isEmpty)
148
+ XCTAssertEqual(result.pendingAcks, 0)
149
+ XCTAssertTrue(result.idle)
150
+ XCTAssertFalse(result.ready)
151
+ XCTAssertTrue(result.sameWriteEvidence)
152
+ XCTAssertTrue(result.ackStillAbsent)
153
+ XCTAssertTrue(result.noRadio)
154
+ XCTAssertFalse(result.physicalClosure)
155
+ }
156
+ }
157
+ }
158
+ @MainActor
159
+ func testActualNimoActiveTimeAckTimeoutRemainsFailure() async {
160
+ let result = await Nimo().packageExercisePendingTimeAck(cancel: true, timeoutBeforeEntry: false, activeTimeout: true)
161
+ XCTAssertTrue(result.errors.contains("handshake-failed"))
162
+ XCTAssertFalse(result.ready)
163
+ XCTAssertTrue(result.noRadio)
164
+ }
165
+ func testReleaseFenceDoesNotTurnHandshakeAbandonmentIntoCommandOrWriterProof() {
166
+ let (model, owner, peer, _) = fixture()
167
+ XCTAssertTrue(model.beginTransport(owner, peripheral: peer, uuid: uuid))
168
+ model.transportConnected(owner, peer); model.observeTws(owner, connected: true)
169
+ let command = model.beginTimeCommand(owner)!
170
+ model.timeWriteStarted(owner, command: command)
171
+ let started = model.timeWriteStartedElapsedMs
172
+ XCTAssertTrue(model.beginRelease(nonce: uuid, cancel: true, mic: true, canvas: true))
173
+ XCTAssertFalse(model.recordHandshakeFailure(owner))
174
+ model.timeWriteCompleted(owner, command: command); model.timeAck(owner, command: command, status: 0)
175
+ XCTAssertEqual(model.timeWriteStartedElapsedMs, started)
176
+ XCTAssertNil(model.timeWriteCompletedElapsedMs)
177
+ XCTAssertNil(model.timeAckElapsedMs)
178
+ XCTAssertFalse(model.ready)
179
+ XCTAssertFalse(model.commandsSettled)
180
+ var producers = NimoResourceProducers(); producers.writerQueued = 1
181
+ XCTAssertFalse(model.settled(producers: producers))
182
+ XCTAssertTrue(model.errors.isEmpty)
183
+ }
184
+ func testForeignManagerCannotReplaceCaptureUntilOriginalClosure() {
185
+ XCTAssertTrue(NimoResourceManagerState.allowsReplacement(fixtureEnabled: false, originalClosed: false))
186
+ XCTAssertFalse(NimoResourceManagerState.allowsReplacement(fixtureEnabled: true, originalClosed: false))
187
+ XCTAssertTrue(NimoResourceManagerState.allowsReplacement(fixtureEnabled: true, originalClosed: true))
188
+ }
189
+ func testFailedOriginalConnectionCanReleaseWithoutInventingDisconnect() {
190
+ let (model, owner, peer, clock) = fixture()
191
+ XCTAssertTrue(model.beginTransport(owner, peripheral: peer, uuid: uuid))
192
+ model.connectionFailed(owner, NSObject(), receivedElapsedMs: clock.next())
193
+ model.connectionFailed(owner, peer, receivedElapsedMs: model.startedElapsedMs! - 1)
194
+ XCTAssertNil(model.connectionFailedElapsedMs)
195
+ model.connectionFailed(owner, peer, receivedElapsedMs: clock.next())
196
+ XCTAssertNotNil(model.connectionFailedElapsedMs)
197
+ XCTAssertFalse(model.ready)
198
+ XCTAssertFalse(model.settled(producers: NimoResourceProducers()))
199
+ XCTAssertTrue(model.beginRelease(nonce: uuid, cancel: true, mic: false, canvas: false))
200
+ XCTAssertTrue(model.settled(producers: NimoResourceProducers()))
201
+ XCTAssertNil(model.disconnectCallbackElapsedMs)
202
+ XCTAssertFalse(model.noTransport)
203
+ XCTAssertNotNil(model.original)
204
+ var producers = NimoResourceProducers(); producers.postedCallbacks = 1
205
+ XCTAssertFalse(model.settled(producers: producers))
206
+ model.transportConnected(owner, peer) // A contradictory late callback cannot preserve closure.
207
+ XCTAssertFalse(model.settled(producers: NimoResourceProducers()))
208
+ XCTAssertTrue(model.errors.contains("connect-after-failed-attempt"))
209
+ let (connected, other, otherPeer, otherClock) = fixture()
210
+ XCTAssertTrue(connected.beginTransport(other, peripheral: otherPeer, uuid: uuid))
211
+ connected.transportConnected(other, otherPeer)
212
+ connected.connectionFailed(other, otherPeer, receivedElapsedMs: otherClock.next())
213
+ XCTAssertNil(connected.connectionFailedElapsedMs)
214
+ XCTAssertTrue(connected.beginRelease(nonce: uuid, cancel: true, mic: false, canvas: false))
215
+ XCTAssertFalse(connected.settled(producers: NimoResourceProducers()))
216
+ let (fenced, fencedOwner, fencedPeer, fencedClock) = fixture()
217
+ XCTAssertTrue(fenced.beginTransport(fencedOwner, peripheral: fencedPeer, uuid: uuid))
218
+ XCTAssertTrue(fenced.beginRelease(nonce: uuid, cancel: true, mic: false, canvas: false))
219
+ fenced.transportConnected(fencedOwner, fencedPeer)
220
+ XCTAssertTrue(fenced.connected)
221
+ fenced.connectionFailed(fencedOwner, fencedPeer, receivedElapsedMs: fencedClock.next())
222
+ XCTAssertNil(fenced.connectionFailedElapsedMs)
223
+ XCTAssertFalse(fenced.settled(producers: NimoResourceProducers()))
224
+ }
225
+ func testOrdinaryManagerCleanupPreservesVerifiedOriginalClosureButNotActiveCapture() {
226
+ let (model, owner, _, _) = fixture()
227
+ XCTAssertTrue(model.beginRelease(nonce: uuid, cancel: true, mic: false, canvas: false))
228
+ model.proveNoTransport()
229
+ let accepted = model.settled(producers: NimoResourceProducers())
230
+ XCTAssertTrue(accepted)
231
+ XCTAssertFalse(model.shouldRunOrdinaryDisconnect(owner, originalClosed: accepted))
232
+ XCTAssertFalse(model.shouldRunOrdinaryDisconnect(owner, originalClosed: accepted))
233
+ XCTAssertTrue(model.errors.isEmpty)
234
+ XCTAssertTrue(model.settled(producers: NimoResourceProducers())) // Fresh status, not old bytes.
235
+ XCTAssertTrue(NimoResourceManagerState.allowsReplacement(fixtureEnabled: true, originalClosed: model.settled(producers: NimoResourceProducers())))
236
+ let (active, other, _, _) = fixture()
237
+ XCTAssertTrue(active.shouldRunOrdinaryDisconnect(other, originalClosed: false))
238
+ XCTAssertTrue(active.errors.contains("ordinary-disconnect-during-capture"))
239
+ XCTAssertFalse(active.settled(producers: NimoResourceProducers()))
240
+ let (failed, third, peer, clock) = fixture()
241
+ XCTAssertTrue(failed.beginTransport(third, peripheral: peer, uuid: uuid))
242
+ failed.connectionFailed(third, peer, receivedElapsedMs: clock.next())
243
+ XCTAssertTrue(failed.beginRelease(nonce: uuid, cancel: true, mic: false, canvas: false))
244
+ XCTAssertTrue(failed.settled(producers: NimoResourceProducers()))
245
+ failed.transportConnected(third, peer) // A late contradictory callback still invalidates closure.
246
+ XCTAssertTrue(failed.shouldRunOrdinaryDisconnect(third, originalClosed: true))
247
+ XCTAssertFalse(failed.settled(producers: NimoResourceProducers()))
248
+ }
249
+ func testManagerEntryRosterScopesOriginalNimoWithoutIgnoringItsCallbacks() {
250
+ let entries = NimoResourceManagerEntries(), foreign = NimoResourceManagerEntries()
251
+ let (model, owner, _, _) = fixture()
252
+ XCTAssertTrue(model.beginRelease(nonce: uuid, cancel: true, mic: false, canvas: false)); model.proveNoTransport()
253
+ let nimo = entries.begin("NIMO")
254
+ var producers = NimoResourceProducers(); producers.postedCallbacks = entries.originalNimo
255
+ XCTAssertFalse(model.settled(producers: producers))
256
+ let g2 = entries.begin("G2")
257
+ XCTAssertEqual(entries.all, 2); XCTAssertEqual(entries.originalNimo, 1)
258
+ foreign.end(nimo); XCTAssertEqual(entries.originalNimo, 1)
259
+ entries.end(nimo); entries.end(nimo) // Exact one-shot ticket, never an early/duplicate decrement.
260
+ producers.postedCallbacks = entries.originalNimo
261
+ XCTAssertEqual(entries.all, 1); XCTAssertTrue(model.settled(producers: producers))
262
+ XCTAssertFalse(model.shouldRunOrdinaryDisconnect(owner, originalClosed: true))
263
+ entries.end(g2); XCTAssertEqual(entries.all, 0)
264
+ producers.postedCallbacks = 1 // A late actual original callback still holds closure.
265
+ XCTAssertFalse(model.settled(producers: producers))
266
+ }
267
+ func testOriginalClosureCannotClearReplacementManagerSharedState() {
268
+ let (model, owner, _, _) = fixture()
269
+ XCTAssertTrue(model.beginRelease(nonce: uuid, cancel: false, mic: false, canvas: false))
270
+ model.proveNoTransport()
271
+ XCTAssertTrue(model.settled(producers: NimoResourceProducers()))
272
+ let g2 = NSObject()
273
+ var connected = true
274
+ NimoResourceManagerState.publish(owner: owner, current: g2) { connected = false }
275
+ XCTAssertTrue(connected)
276
+ NimoResourceManagerState.publish(owner: owner, current: nil) { connected = false }
277
+ XCTAssertTrue(connected)
278
+ NimoResourceManagerState.publish(owner: owner, current: owner) { connected = false }
279
+ XCTAssertFalse(connected)
280
+ }
281
+ func testQueuedOldNotificationsKeepReceiveTimeAndCannotSatisfyNewCommands() {
282
+ let (model, owner, peer, clock) = fixture()
283
+ XCTAssertTrue(model.beginTransport(owner, peripheral: peer, uuid: uuid))
284
+ model.transportConnected(owner, peer)
285
+ model.observeTws(owner, connected: true)
286
+ let command = model.beginTimeCommand(owner)!
287
+ let staleAck = clock.value
288
+ model.timeWriteStarted(owner, command: command)
289
+ model.timeAck(owner, command: command, status: 0, receivedElapsedMs: staleAck)
290
+ XCTAssertNil(model.timeAckElapsedMs)
291
+ let actualAck = clock.next()
292
+ model.timeAck(owner, command: command, status: 0, receivedElapsedMs: actualAck)
293
+ XCTAssertFalse(model.ready) // Real ACK may precede didWrite callback.
294
+ model.timeWriteCompleted(owner, command: command)
295
+ XCTAssertTrue(model.ready)
296
+ XCTAssertEqual(model.timeAckElapsedMs, actualAck)
297
+ let staleStop = clock.value
298
+ XCTAssertTrue(model.beginRelease(nonce: uuid, cancel: false, mic: true, canvas: true))
299
+ model.micStopStarted(owner)
300
+ model.micStopMarker(owner, receivedElapsedMs: staleStop)
301
+ model.exitAcknowledged(owner, status: 0, receivedElapsedMs: staleStop)
302
+ XCTAssertNil(model.micStopMarkerElapsedMs)
303
+ XCTAssertNil(model.exitAckElapsedMs)
304
+ let actualStop = clock.next()
305
+ model.micStopMarker(owner, receivedElapsedMs: actualStop)
306
+ model.micStopWritten(owner)
307
+ XCTAssertEqual(model.micStopMarkerElapsedMs, actualStop)
308
+ }
309
+ func testArmAdmissionPinsOneObjectAndRejectsReplacementWrongNameAndWrongPeripheral() {
310
+ let (model, owner, peer, _) = fixture()
311
+ let admitted = model.admittedElapsedMs
312
+ XCTAssertFalse(model.admit(NSObject(), instanceId: uuid, name: "NIMO test"))
313
+ XCTAssertFalse(model.admit(owner, instanceId: uuid, name: "other"))
314
+ XCTAssertEqual(model.admittedElapsedMs, admitted)
315
+ XCTAssertFalse(model.beginTransport(owner, peripheral: peer, uuid: UUID().uuidString))
316
+ XCTAssertTrue(model.beginTransport(owner, peripheral: peer, uuid: uuid))
317
+ XCTAssertFalse(model.beginTransport(owner, peripheral: peer, uuid: uuid))
318
+ XCTAssertFalse(model.beginTransport(owner, peripheral: NSObject(), uuid: uuid))
319
+ }
320
+ func testReadinessRequiresOriginalConnectedTwsWrittenCommandAndStatusZeroAck() {
321
+ let (model, owner, peer, _) = fixture()
322
+ XCTAssertTrue(model.beginTransport(owner, peripheral: peer, uuid: uuid))
323
+ XCTAssertNil(model.beginTimeCommand(owner))
324
+ model.transportConnected(owner, peer)
325
+ model.observeTws(NSObject(), connected: true)
326
+ XCTAssertNil(model.beginTimeCommand(owner))
327
+ model.observeTws(owner, connected: true)
328
+ let command = model.beginTimeCommand(owner)!
329
+ model.timeAck(owner, command: command, status: 0) // Before actual write start.
330
+ XCTAssertNil(model.timeAckElapsedMs)
331
+ model.timeWriteStarted(owner, command: command)
332
+ model.timeAck(owner, command: command, status: 0) // Early ACK buffers until actual callback.
333
+ XCTAssertFalse(model.ready)
334
+ model.timeWriteCompleted(owner, command: "wrong")
335
+ XCTAssertFalse(model.ready)
336
+ model.timeWriteCompleted(owner, command: command)
337
+ XCTAssertTrue(model.ready)
338
+ let handshake = model.handshakeTwsElapsedMs
339
+ model.observeTws(owner, connected: true)
340
+ XCTAssertEqual(model.handshakeTwsElapsedMs, handshake)
341
+ XCTAssertGreaterThan(model.twsObservedElapsedMs!, model.timeAckElapsedMs!)
342
+ model.observeTws(owner, connected: false)
343
+ XCTAssertFalse(model.ready)
344
+ }
345
+ func testNegativeAckAndReplacementCallbacksNeverEstablishReadiness() {
346
+ let (model, owner, peer, _) = fixture()
347
+ XCTAssertTrue(model.beginTransport(owner, peripheral: peer, uuid: uuid))
348
+ model.transportConnected(owner, NSObject())
349
+ XCTAssertFalse(model.connected)
350
+ model.transportConnected(owner, peer); model.observeTws(owner, connected: true)
351
+ let command = model.beginTimeCommand(owner)!
352
+ model.timeWriteStarted(owner, command: command); model.timeWriteCompleted(owner, command: command)
353
+ model.timeAck(owner, command: command, status: 2)
354
+ XCTAssertFalse(model.ready)
355
+ }
356
+ func testReleaseRequiresRealOriginalCallbackEveryObligationAndActualProducerSettlement() {
357
+ let (model, owner, peer, _) = fixture()
358
+ XCTAssertTrue(model.beginTransport(owner, peripheral: peer, uuid: uuid)); model.transportConnected(owner, peer)
359
+ XCTAssertTrue(model.beginRelease(nonce: uuid, cancel: false, mic: true, canvas: true))
360
+ let start = model.releaseStartedElapsedMs
361
+ XCTAssertFalse(model.beginRelease(nonce: UUID().uuidString, cancel: false, mic: false, canvas: false))
362
+ XCTAssertEqual(model.releaseStartedElapsedMs, start)
363
+ XCTAssertFalse(model.allowsEntry(owner)); XCTAssertNil(model.beginTimeCommand(owner))
364
+ model.proveNoTransport(); XCTAssertFalse(model.noTransport)
365
+ model.micStopMarker(owner) // Before actual stop write: ignored.
366
+ XCTAssertNil(model.micStopMarkerElapsedMs)
367
+ model.micStopStarted(owner); model.micStopMarker(owner); model.micStopWritten(owner)
368
+ model.exitAcknowledged(owner, status: 7)
369
+ XCTAssertFalse(model.commandsSettled)
370
+ model.exitAcknowledged(owner, status: 0)
371
+ XCTAssertTrue(model.commandsSettled)
372
+ model.disconnectRequested(owner)
373
+ XCTAssertFalse(model.settled(producers: NimoResourceProducers())) // cancel return/nil app ref is not closure.
374
+ model.disconnected(owner, NSObject(), error: nil)
375
+ XCTAssertNil(model.disconnectCallbackElapsedMs)
376
+ model.disconnected(owner, peer, error: nil)
377
+ var producers = NimoResourceProducers(); producers.encoderDelivery = 1
378
+ XCTAssertFalse(model.settled(producers: producers))
379
+ producers.encoderDelivery = 0; producers.writerActive = 1
380
+ XCTAssertFalse(model.settled(producers: producers))
381
+ producers.writerActive = 0; producers.reconnectTask = 1
382
+ XCTAssertFalse(model.settled(producers: producers))
383
+ XCTAssertTrue(model.settled(producers: NimoResourceProducers()))
384
+ XCTAssertTrue(model.beginRelease(nonce: uuid, cancel: false, mic: false, canvas: false))
385
+ XCTAssertEqual(model.releaseStartedElapsedMs, start)
386
+ XCTAssertTrue(model.micStopRequired); XCTAssertTrue(model.displayExitRequired)
387
+ }
388
+ func testGenZeroCancellationFencesAdmissionAndProducesExplicitNoTransport() {
389
+ let (model, owner, _, _) = fixture()
390
+ XCTAssertEqual(model.sessionDictionary()?["sessionGeneration"] as? UInt64, 0)
391
+ XCTAssertTrue(model.sessionDictionary()?["startedElapsedMs"] is NSNull)
392
+ XCTAssertTrue(model.beginRelease(nonce: uuid, cancel: true, mic: false, canvas: false))
393
+ model.proveNoTransport()
394
+ XCTAssertTrue(model.settled(producers: NimoResourceProducers()))
395
+ XCTAssertFalse(model.admit(owner, instanceId: uuid, name: "NIMO test"))
396
+ XCTAssertFalse(model.admit(NSObject(), instanceId: uuid, name: "NIMO test"))
397
+ }
398
+ func testDisconnectErrorOrUnrequestedDisconnectNeverProvesIdle() {
399
+ for requested in [true, false] {
400
+ let (model, owner, peer, _) = fixture()
401
+ XCTAssertTrue(model.beginTransport(owner, peripheral: peer, uuid: uuid))
402
+ model.transportConnected(owner, peer)
403
+ XCTAssertTrue(model.beginRelease(nonce: uuid, cancel: false, mic: false, canvas: false))
404
+ if requested { model.disconnectRequested(owner) }
405
+ model.disconnected(owner, peer, error: "radio error")
406
+ XCTAssertFalse(model.settled(producers: NimoResourceProducers()))
407
+ }
408
+ }
409
+ func testReleaseDoesNotAdoptLateGenerationOrRewriteReadinessAndClockAnchors() {
410
+ let (model, owner, peer, _) = fixture()
411
+ XCTAssertTrue(model.beginTransport(owner, peripheral: peer, uuid: uuid)); model.transportConnected(owner, peer)
412
+ model.observeTws(owner, connected: true); let command = model.beginTimeCommand(owner)!
413
+ model.timeWriteStarted(owner, command: command); model.timeWriteCompleted(owner, command: command); model.timeAck(owner, command: command, status: 0)
414
+ let observed = model.twsObservedElapsedMs, ack = model.timeAckElapsedMs
415
+ XCTAssertTrue(model.beginRelease(nonce: uuid, cancel: true, mic: false, canvas: false))
416
+ model.observeTws(owner, connected: false); model.timeAck(owner, command: command, status: 7)
417
+ XCTAssertEqual(model.twsObservedElapsedMs, observed); XCTAssertEqual(model.timeAckElapsedMs, ack)
418
+ XCTAssertFalse(model.beginTransport(owner, peripheral: peer, uuid: uuid))
419
+ XCTAssertFalse(model.ready)
420
+ }
421
+
422
+ func testStrictTypedRequestRejectsPacketPathsUnknownFieldsAndMalformedOriginals() throws {
423
+ let pair: [String: Any] = ["name": "NIMO test", "peripheralUuid": uuid]
424
+ let process: [String: Any] = ["pid": 123, "instanceId": uuid]
425
+ let original: [String: Any] = ["instanceId": uuid, "peripheralUuid": uuid, "sessionGeneration": 1]
426
+ let valid: [String: Any] = ["version": 1, "operation": "arm", "requestNonce": uuid, "captureNonce": uuid,
427
+ "planSha256": String(repeating: "a", count: 64), "grantSha256": String(repeating: "b", count: 64),
428
+ "expectedProcess": process, "expectedPair": pair, "original": NSNull()]
429
+ func encoded(_ value: [String: Any]) throws -> Data { try JSONSerialization.data(withJSONObject: value, options: [.sortedKeys]) }
430
+ XCTAssertEqual(try NimoResourceRequest.decode(encoded(valid)).operation, .arm)
431
+ for replacement: [String: Any] in [
432
+ ["packet": "52010000"], ["path": "/tmp/other.json"], ["operation": "connect"],
433
+ ["requestNonce": "AAAAAAAA-AAAA-4AAA-8AAA-AAAAAAAAAAAA"], ["version": 2], ["grantSha256": "unknown"],
434
+ ["expectedPair": pair.merging(["bluetoothAddress": "00:11:22:33:44:55"]) { _, new in new }],
435
+ ["expectedProcess": process.merging(["pid": true]) { _, new in new }],
436
+ ["expectedPair": pair.merging(["name": String(repeating: "x", count: 129)]) { _, new in new }],
437
+ ["original": original.merging(["sessionGeneration": 2]) { _, new in new }],
438
+ ["original": original.merging(["command": "reset"]) { _, new in new }],
439
+ ["original": original.merging(["instanceId": "replacement"]) { _, new in new }],
440
+ ] {
441
+ XCTAssertThrowsError(try NimoResourceRequest.decode(encoded(valid.merging(replacement) { _, new in new })))
442
+ }
443
+ XCTAssertThrowsError(try NimoResourceRequest.decode(Data(repeating: 32, count: 16_385)))
444
+ }
445
+
446
+
447
+ func testAdmissionPersistenceFailureRetainsOnlyCancelledOriginalAndNoTransportAuthority() {
448
+ let clock = Clock(), owner = NSObject(), peer = NSObject()
449
+ let model = NimoResourceSession(captureNonce: uuid, pair: NimoResourcePair(name: "NIMO test", peripheralUuid: uuid), now: clock.next)
450
+ XCTAssertFalse(model.admit(owner, instanceId: uuid, name: "NIMO test", persist: { _ in
451
+ XCTAssertNil(model.admittedElapsedMs)
452
+ return false
453
+ }))
454
+ XCTAssertTrue(model.isOwner(owner)); XCTAssertTrue(model.cancelled)
455
+ XCTAssertNil(model.admittedElapsedMs)
456
+ XCTAssertFalse(model.allowsEntry(owner))
457
+ XCTAssertFalse(model.beginTransport(owner, peripheral: peer, uuid: uuid))
458
+ XCTAssertFalse(model.admit(NSObject(), instanceId: uuid, name: "NIMO test"))
459
+ XCTAssertTrue(model.beginRelease(nonce: uuid, cancel: true, mic: false, canvas: false))
460
+ model.proveNoTransport()
461
+ XCTAssertFalse(model.settled(producers: NimoResourceProducers())) // Failed durable authority is not fabricated idle.
462
+ }
463
+
464
+ func testTransportPersistencePrecedesEffectAuthorityAndFailureCannotBeRetried() {
465
+ let (model, owner, peer, _) = fixture()
466
+ XCTAssertFalse(model.beginTransport(owner, peripheral: peer, uuid: uuid, persist: { proposed in
467
+ XCTAssertNil(model.original); XCTAssertNil(model.startedElapsedMs)
468
+ XCTAssertGreaterThan(proposed, model.admittedElapsedMs!)
469
+ return false
470
+ }))
471
+ XCTAssertNil(model.original); XCTAssertNil(model.startedElapsedMs)
472
+ XCTAssertTrue(model.cancelled)
473
+ XCTAssertFalse(model.beginTransport(owner, peripheral: peer, uuid: uuid))
474
+ }
475
+
476
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mentra/bluetooth-sdk",
3
- "version": "3.4.0-dev.978",
3
+ "version": "3.4.0-dev.980",
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.4.0-dev.978"
117
+ "@mentra/cloud-protocol": "3.4.0-dev.980"
118
118
  },
119
119
  "devDependencies": {
120
120
  "@types/node": "^25.9.3",
@@ -93,6 +93,9 @@ declare class BluetoothSdkNativeModule extends NativeModule<BluetoothSdkModuleEv
93
93
 
94
94
  // Connection Commands
95
95
  requestStatus(): Promise<void>
96
+ /** Mac-only explicit fixture launch. Strict nimo-macos-resource-v1 Codable
97
+ * request/receipt JSON; ordinary launches refuse. No packet/path commands. */
98
+ nimoResourceSession?(requestJSON: string): Promise<string>
96
99
  connectDefault(options?: ConnectOptions): Promise<void>
97
100
  connectDefaultWithOptions(options: Required<ConnectOptions>): Promise<void>
98
101
  setDefaultDevice(device: Device | null): Promise<void>
@@ -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.4.0",
15
- "releaseIdentity": "3.4.0-dev.978",
16
- "releaseSetId": "mentra-3.4.0-dev.978",
17
- "sourceCommit": "dabca16f0fa2e6d374cc662d9d6a5631887aee6f",
18
- "otaManifestUrl": "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.4.0/mentra-live-ota-3.4.0-dev.978.json",
19
- "otaManifestSha256": "c47776d8369c0c472170d003be377205c01abfbb3ab7ca7850a21f3c26147245"
15
+ "releaseIdentity": "3.4.0-dev.980",
16
+ "releaseSetId": "mentra-3.4.0-dev.980",
17
+ "sourceCommit": "d43a82b42eddeee824f4c8f003415bc65dbd5aab",
18
+ "otaManifestUrl": "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.4.0/mentra-live-ota-3.4.0-dev.980.json",
19
+ "otaManifestSha256": "3121c2acd957c5f554b36d8260c4a3550c85bd50cee3d11a27a53b407e27b6d0"
20
20
  })