@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,406 @@
1
+ import Foundation
2
+ import Darwin
3
+
4
+ /// All mutation is serialized on Main by the native fixture/SDK boundary.
5
+ /// Epoch clocks are deliberately absent: host freshness has a separate authority.
6
+ enum NimoResourceClock {
7
+ static func now() -> UInt64 {
8
+ var info = mach_timebase_info_data_t()
9
+ mach_timebase_info(&info)
10
+ let ticks = mach_continuous_time()
11
+ let divisor = UInt64(info.denom) * 1_000_000
12
+ return (ticks / divisor) * UInt64(info.numer) + (ticks % divisor) * UInt64(info.numer) / divisor
13
+ }
14
+ }
15
+
16
+ /// Shared product state belongs to the current manager, independently of the
17
+ /// retained original object's cleanup and closure evidence.
18
+ enum NimoResourceManagerState {
19
+ static func allowsReplacement(fixtureEnabled: Bool, originalClosed: Bool) -> Bool {
20
+ !fixtureEnabled || originalClosed
21
+ }
22
+ static func publish(owner: AnyObject, current: AnyObject?, _ update: () -> Void) {
23
+ guard current === owner else { return }
24
+ update()
25
+ }
26
+ }
27
+
28
+ /// Queue tickets freeze dispatch scope before DeviceManager's asynchronous work.
29
+ /// All entries fence initial arm; only original NIMO entries belong to its drain.
30
+ final class NimoResourceManagerEntries {
31
+ final class Ticket {
32
+ fileprivate weak var owner: NimoResourceManagerEntries?
33
+ fileprivate let nimo: Bool
34
+ fileprivate var ended = false
35
+ fileprivate init(owner: NimoResourceManagerEntries, nimo: Bool) { self.owner = owner; self.nimo = nimo }
36
+ }
37
+ private(set) var all = 0
38
+ private(set) var originalNimo = 0
39
+ func begin(_ wearable: String) -> Ticket {
40
+ let nimo = wearable.contains("NIMO")
41
+ all += 1
42
+ if nimo { originalNimo += 1 }
43
+ return Ticket(owner: self, nimo: nimo)
44
+ }
45
+ func end(_ ticket: Ticket) {
46
+ guard ticket.owner === self, !ticket.ended else { return }
47
+ ticket.ended = true; all -= 1
48
+ if ticket.nimo { originalNimo -= 1 }
49
+ }
50
+ }
51
+
52
+ struct NimoResourceOriginal: Codable, Equatable {
53
+ let instanceId: String
54
+ let peripheralUuid: String
55
+ let sessionGeneration: UInt64
56
+ }
57
+
58
+ struct NimoResourceProcess: Codable, Equatable {
59
+ let pid: Int32
60
+ let instanceId: String
61
+ }
62
+
63
+ struct NimoResourcePair: Codable, Equatable {
64
+ let name: String
65
+ let peripheralUuid: String
66
+ }
67
+
68
+ /// Accepted selection remains immutable even after connected-service discovery.
69
+ struct NimoResourceSelection: Codable, Equatable {
70
+ static let connectedService = "connected-service-exact-name-v1"
71
+ let name: String
72
+ let peripheralUuid: String?
73
+ let peripheralSelection: String?
74
+ init(name: String, peripheralUuid: String? = nil, peripheralSelection: String? = nil) { self.name = name; self.peripheralUuid = peripheralUuid; self.peripheralSelection = peripheralSelection }
75
+ init(pair: NimoResourcePair) { name = pair.name; peripheralUuid = pair.peripheralUuid; peripheralSelection = nil }
76
+ var dictionary: [String: Any] {
77
+ if let peripheralUuid { return ["name": name, "peripheralUuid": peripheralUuid] }
78
+ return ["name": name, "peripheralSelection": peripheralSelection ?? ""]
79
+ }
80
+ }
81
+
82
+ /// Pure candidate ledger. Selection is committed only once, after the bounded
83
+ /// observer has stopped; a later callback cannot replace its physical identity.
84
+ final class NimoResourceDiscovery {
85
+ static let serviceUuid = "00007033-0000-1000-8000-00805f9b34fb"
86
+ let name: String
87
+ let startedElapsedMs: UInt64
88
+ private(set) var settledElapsedMs: UInt64?
89
+ private(set) var selectedPeripheralUuid: String?
90
+ private(set) var candidates: [String: String] = [:]
91
+ private var currentlyConnected: Set<String> = []
92
+ private(set) var errors: [String] = []
93
+ private(set) var cancelled = false
94
+ init(name: String, startedElapsedMs: UInt64) { self.name = name; self.startedElapsedMs = startedElapsedMs }
95
+ func observe(uuid: String, name: String?) {
96
+ guard settledElapsedMs == nil, !cancelled, name == self.name else { return }
97
+ guard UUID(uuidString: uuid)?.uuidString.lowercased() == uuid else { recordError("invalid-discovery-uuid"); return }
98
+ if candidates.count >= 16, candidates[uuid] == nil { recordError("discovery-candidate-budget"); return }
99
+ candidates[uuid] = name
100
+ currentlyConnected.insert(uuid)
101
+ if candidates.count > 1 { recordError("ambiguous-connected-service-peer") }
102
+ }
103
+ func observeConnectedSnapshot(_ peers: [(String, String?)]) {
104
+ guard settledElapsedMs == nil, !cancelled else { return }
105
+ currentlyConnected.removeAll()
106
+ for (uuid, name) in peers { observe(uuid: uuid, name: name) }
107
+ }
108
+ func recordError(_ error: String) { guard settledElapsedMs == nil else { return }; if errors.count < 16, !errors.contains(error) { errors.append(error) } }
109
+ func cancel() { guard settledElapsedMs == nil else { return }; cancelled = true }
110
+ func finish(at: UInt64, callbacksPending: Int) {
111
+ guard settledElapsedMs == nil, callbacksPending == 0, at >= startedElapsedMs else { return }
112
+ if !cancelled, errors.isEmpty, candidates.count == 1, currentlyConnected.count == 1 { selectedPeripheralUuid = candidates.keys.first }
113
+ else if !cancelled, currentlyConnected.isEmpty { recordError("no-connected-service-peer") }
114
+ settledElapsedMs = at
115
+ }
116
+ func dictionary(callbacksPending: Int) -> [String: Any] {
117
+ ["kind": NimoResourceSelection.connectedService, "selectedPeripheralUuid": nullable(selectedPeripheralUuid),
118
+ "candidates": candidates.keys.sorted().map { ["peripheralUuid": $0, "name": candidates[$0]!, "source": "connected-service", "serviceUuid": Self.serviceUuid] },
119
+ "startedElapsedMs": startedElapsedMs, "settledElapsedMs": nullable(settledElapsedMs),
120
+ "settled": settledElapsedMs != nil && callbacksPending == 0, "callbacksPending": callbacksPending, "errors": errors]
121
+ }
122
+ }
123
+
124
+ struct NimoResourceRequest: Codable, Equatable {
125
+ enum Operation: String, Codable { case discover, arm, status, release, cancel }
126
+ let version: Int
127
+ let operation: Operation
128
+ let requestNonce: String
129
+ let captureNonce: String
130
+ let planSha256: String
131
+ let grantSha256: String
132
+ let expectedProcess: NimoResourceProcess
133
+ let expectedPair: NimoResourceSelection
134
+ let original: NimoResourceOriginal?
135
+ }
136
+
137
+ struct NimoResourceProducers {
138
+ var writerActive = 0, writerQueued = 0, encoderWorker = 0, encoderDelivery = 0
139
+ var postedCallbacks = 0, canvasCommands = 0, pendingAcks = 0, audioWorkers = 0
140
+ var scan = 0, timers = 0, reconnectTask = 0
141
+ var dictionary: [String: Int] {
142
+ ["writerActive": writerActive, "writerQueued": writerQueued, "encoderWorker": encoderWorker,
143
+ "encoderDelivery": encoderDelivery, "postedCallbacks": postedCallbacks, "canvasCommands": canvasCommands,
144
+ "pendingAcks": pendingAcks, "audioWorkers": audioWorkers, "scan": scan, "timers": timers, "reconnectTask": reconnectTask]
145
+ }
146
+ var settled: Bool { dictionary.values.allSatisfy { $0 == 0 } }
147
+ }
148
+
149
+ /// Retains the ORIGINAL object identities even after cancellation or timeout.
150
+ /// A later object, generation, peripheral or release nonce cannot be adopted.
151
+ final class NimoResourceSession {
152
+ let captureNonce: String
153
+ let pair: NimoResourcePair
154
+ let armedElapsedMs: UInt64
155
+ private(set) var admittedElapsedMs: UInt64?
156
+ private(set) var instanceId: String?
157
+ private(set) var original: NimoResourceOriginal?
158
+ private(set) var cancelled = false
159
+ private(set) var connected = false
160
+ private var everConnected = false
161
+ private(set) var connectionFailedElapsedMs: UInt64?
162
+ private(set) var startedElapsedMs: UInt64?
163
+ private(set) var twsConnected = false
164
+ private(set) var twsObservedElapsedMs: UInt64?
165
+ private(set) var handshakeTwsElapsedMs: UInt64?
166
+ private(set) var timeCommandId: String?
167
+ private(set) var timeWriteStartedElapsedMs: UInt64?
168
+ private(set) var timeWriteCompletedElapsedMs: UInt64?
169
+ private(set) var timeAckElapsedMs: UInt64?
170
+ private(set) var timeAckStatusCode: Int?
171
+ private(set) var releaseNonce: String?
172
+ private(set) var releaseStartedElapsedMs: UInt64?
173
+ private(set) var micStopRequired = false
174
+ private(set) var displayExitRequired = false
175
+ private(set) var micStopStartedElapsedMs: UInt64?
176
+ private(set) var micStopWrittenElapsedMs: UInt64?
177
+ private(set) var micStopMarkerElapsedMs: UInt64?
178
+ private(set) var exitStatusCode: Int?
179
+ private(set) var exitAckElapsedMs: UInt64?
180
+ private(set) var disconnectRequestedElapsedMs: UInt64?
181
+ private(set) var disconnectCallbackElapsedMs: UInt64?
182
+ private(set) var disconnectError: String?
183
+ private(set) var noTransport = false
184
+ private(set) var errors: [String] = []
185
+ private var owner: AnyObject?
186
+ private var peripheral: AnyObject?
187
+ private let now: () -> UInt64
188
+
189
+ init(captureNonce: String, pair: NimoResourcePair, now: @escaping () -> UInt64 = NimoResourceClock.now, armedElapsedMs: UInt64? = nil) {
190
+ self.captureNonce = captureNonce; self.pair = pair; self.now = now
191
+ self.armedElapsedMs = armedElapsedMs ?? now()
192
+ }
193
+
194
+ func isOwner(_ candidate: AnyObject) -> Bool { owner === candidate }
195
+ var hasOwner: Bool { owner != nil }
196
+ func recordError(_ value: String) { let bounded = String(value.prefix(160)); if errors.count < 16, !errors.contains(bounded) { errors.append(bounded) } }
197
+
198
+ /// Ordinary manager disposal may follow an already accepted original close.
199
+ /// It must not rewrite that physical history or touch a replacement device.
200
+ /// An active or contradicted capture retains the original diagnostic error.
201
+ func shouldRunOrdinaryDisconnect(_ candidate: AnyObject, originalClosed: Bool) -> Bool {
202
+ guard isOwner(candidate) else { return false }
203
+ if originalClosed && settled(producers: NimoResourceProducers()) { return false }
204
+ recordError("ordinary-disconnect-during-capture")
205
+ if releaseNonce != nil { disconnectRequested(candidate) }
206
+ return true
207
+ }
208
+
209
+ /// Called before scanning/connecting, not from a status response.
210
+ func admit(_ candidate: AnyObject, instanceId: String, name: String?, persist: (UInt64) -> Bool = { _ in true }) -> Bool {
211
+ guard !cancelled, releaseNonce == nil, original == nil, name == nil || name == pair.name,
212
+ owner == nil || owner === candidate else { return false }
213
+ if owner == nil {
214
+ let proposed = now()
215
+ owner = candidate; self.instanceId = instanceId
216
+ guard persist(proposed) else { cancelled = true; recordError("admission-persist-failed"); return false }
217
+ admittedElapsedMs = proposed
218
+ }
219
+ return true
220
+ }
221
+
222
+ func allowsEntry(_ candidate: AnyObject) -> Bool {
223
+ owner === candidate && !cancelled && releaseNonce == nil && original == nil
224
+ }
225
+
226
+ /// The plan pins a CoreBluetooth UUID; it is never constructed from a MAC.
227
+ func beginTransport(_ candidate: AnyObject, peripheral: AnyObject, uuid: String, persist: (UInt64) -> Bool = { _ in true }) -> Bool {
228
+ guard allowsEntry(candidate), uuid.lowercased() == pair.peripheralUuid.lowercased(), let instanceId else { return false }
229
+ let proposed = now()
230
+ guard persist(proposed) else { cancelled = true; recordError("transport-persist-failed"); return false }
231
+ self.peripheral = peripheral
232
+ original = NimoResourceOriginal(instanceId: instanceId, peripheralUuid: uuid.lowercased(), sessionGeneration: 1)
233
+ startedElapsedMs = proposed
234
+ return true
235
+ }
236
+
237
+ func matches(_ candidate: AnyObject, _ peer: AnyObject) -> Bool { owner === candidate && peripheral === peer }
238
+ func mayReceive(_ candidate: AnyObject, _ peer: AnyObject) -> Bool { matches(candidate, peer) && disconnectCallbackElapsedMs == nil && connectionFailedElapsedMs == nil }
239
+ func transportConnected(_ candidate: AnyObject, _ peer: AnyObject) {
240
+ guard matches(candidate, peer) else { return }
241
+ everConnected = true; connected = true
242
+ if connectionFailedElapsedMs != nil { recordError("connect-after-failed-attempt"); return }
243
+ }
244
+ /// The original failed-attempt callback proves this never-connected transport ended.
245
+ func connectionFailed(_ candidate: AnyObject, _ peer: AnyObject, receivedElapsedMs: UInt64) {
246
+ guard matches(candidate, peer), !everConnected, let startedElapsedMs, receivedElapsedMs >= startedElapsedMs else { return }
247
+ if connectionFailedElapsedMs == nil { connectionFailedElapsedMs = receivedElapsedMs }
248
+ connected = false
249
+ }
250
+ func observeTws(_ candidate: AnyObject, connected: Bool, receivedElapsedMs: UInt64? = nil) {
251
+ guard owner === candidate, releaseNonce == nil, self.connected else { return }
252
+ let received = receivedElapsedMs ?? now()
253
+ guard let startedElapsedMs, received >= startedElapsedMs else { return }
254
+ twsConnected = connected; twsObservedElapsedMs = received
255
+ }
256
+ /// A setup timeout after the release fence is an abandoned handshake, not
257
+ /// a new failure or authority to disconnect/retry the original transport.
258
+ func recordHandshakeFailure(_ candidate: AnyObject) -> Bool {
259
+ guard owner === candidate, releaseNonce == nil else { return false }
260
+ recordError("handshake-failed")
261
+ return true
262
+ }
263
+ func beginTimeCommand(_ candidate: AnyObject) -> String? {
264
+ guard owner === candidate, releaseNonce == nil, connected, twsConnected, let twsObservedElapsedMs else { return nil }
265
+ if handshakeTwsElapsedMs == nil { handshakeTwsElapsedMs = twsObservedElapsedMs }
266
+ let id = UUID().uuidString.lowercased(); timeCommandId = id
267
+ timeWriteStartedElapsedMs = nil; timeWriteCompletedElapsedMs = nil; timeAckElapsedMs = nil; timeAckStatusCode = nil
268
+ return id
269
+ }
270
+ func timeWriteStarted(_ candidate: AnyObject, command: String) {
271
+ guard owner === candidate, timeCommandId == command, releaseNonce == nil, timeWriteStartedElapsedMs == nil else { return }
272
+ timeWriteStartedElapsedMs = now()
273
+ }
274
+ func timeWriteCompleted(_ candidate: AnyObject, command: String) {
275
+ guard owner === candidate, timeCommandId == command, releaseNonce == nil, timeWriteStartedElapsedMs != nil, timeWriteCompletedElapsedMs == nil else { return }
276
+ timeWriteCompletedElapsedMs = now()
277
+ }
278
+ func timeAck(_ candidate: AnyObject, command: String, status: Int, receivedElapsedMs: UInt64? = nil) {
279
+ guard owner === candidate, timeCommandId == command, releaseNonce == nil, timeWriteStartedElapsedMs != nil, timeAckElapsedMs == nil else { return }
280
+ let received = receivedElapsedMs ?? now()
281
+ guard let started = timeWriteStartedElapsedMs, received >= started else { return }
282
+ timeAckElapsedMs = received; timeAckStatusCode = status
283
+ }
284
+ var ready: Bool {
285
+ guard !cancelled, releaseNonce == nil, connected, twsConnected,
286
+ let start = startedElapsedMs, let tws = handshakeTwsElapsedMs,
287
+ let written = timeWriteStartedElapsedMs, let completed = timeWriteCompletedElapsedMs,
288
+ let ack = timeAckElapsedMs, timeAckStatusCode == 0 else { return false }
289
+ return start <= tws && tws <= written && written <= completed && written <= ack
290
+ }
291
+
292
+ // Preserve the genuine release-admission boundary for delivered compiler proof.
293
+ @inline(never)
294
+ func beginRelease(nonce: String, cancel: Bool, mic: Bool, canvas: Bool) -> Bool {
295
+ if let releaseNonce { return releaseNonce == nonce }
296
+ releaseNonce = nonce; releaseStartedElapsedMs = now(); cancelled = true
297
+ micStopRequired = mic; displayExitRequired = canvas
298
+ if cancel { cancelled = true }
299
+ return true
300
+ }
301
+ func micStopStarted(_ candidate: AnyObject) {
302
+ guard owner === candidate, releaseNonce != nil, micStopRequired else { return }; micStopStartedElapsedMs = now()
303
+ }
304
+ func micStopWritten(_ candidate: AnyObject) {
305
+ guard owner === candidate, micStopStartedElapsedMs != nil, micStopWrittenElapsedMs == nil else { return }; micStopWrittenElapsedMs = now()
306
+ }
307
+ func micStopMarker(_ candidate: AnyObject, receivedElapsedMs: UInt64? = nil) {
308
+ guard owner === candidate, let started = micStopStartedElapsedMs, micStopMarkerElapsedMs == nil else { return }
309
+ let received = receivedElapsedMs ?? now()
310
+ guard received >= started else { return }; micStopMarkerElapsedMs = received
311
+ }
312
+ func exitAcknowledged(_ candidate: AnyObject, status: Int, receivedElapsedMs: UInt64? = nil) {
313
+ guard owner === candidate, releaseNonce != nil, displayExitRequired else { return }
314
+ let received = receivedElapsedMs ?? now()
315
+ guard let started = releaseStartedElapsedMs, received >= started else { return }
316
+ exitStatusCode = status; exitAckElapsedMs = received
317
+ }
318
+ var commandsSettled: Bool {
319
+ (!micStopRequired || micStopStartedElapsedMs != nil && micStopWrittenElapsedMs != nil && micStopMarkerElapsedMs != nil) &&
320
+ (!displayExitRequired || exitStatusCode == 0 && exitAckElapsedMs != nil)
321
+ }
322
+ func disconnectRequested(_ candidate: AnyObject) {
323
+ guard owner === candidate, releaseNonce != nil, disconnectRequestedElapsedMs == nil else { return }
324
+ disconnectRequestedElapsedMs = now()
325
+ }
326
+ /// A nil app reference or cancelPeripheralConnection return is NOT closure.
327
+ func disconnected(_ candidate: AnyObject, _ peer: AnyObject, error: String?, receivedElapsedMs: UInt64? = nil) {
328
+ guard matches(candidate, peer) else { return }
329
+ connected = false
330
+ guard disconnectRequestedElapsedMs != nil else { recordError("unexpected-disconnect"); return }
331
+ let received = receivedElapsedMs ?? now()
332
+ guard let requested = disconnectRequestedElapsedMs, received >= requested else { recordError("pre-request-disconnect"); return }
333
+ if disconnectCallbackElapsedMs == nil { disconnectCallbackElapsedMs = received; disconnectError = error }
334
+ }
335
+ func proveNoTransport() {
336
+ guard releaseNonce != nil, original == nil, peripheral == nil else { return }; noTransport = true
337
+ }
338
+ func settled(producers: NimoResourceProducers) -> Bool {
339
+ releaseNonce != nil && commandsSettled && (noTransport || connectionFailedElapsedMs != nil || disconnectCallbackElapsedMs != nil && disconnectError == nil) && producers.settled && errors.isEmpty
340
+ }
341
+
342
+ func sessionDictionary() -> [String: Any]? {
343
+ guard let instanceId, admittedElapsedMs != nil else { return nil }
344
+ let original = original ?? NimoResourceOriginal(instanceId: instanceId, peripheralUuid: pair.peripheralUuid.lowercased(), sessionGeneration: 0)
345
+ return ["instanceId": original.instanceId, "peripheralUuid": original.peripheralUuid,
346
+ "sessionGeneration": original.sessionGeneration, "startedElapsedMs": nullable(startedElapsedMs),
347
+ "connected": connected, "initializationPending": !ready && releaseNonce == nil,
348
+ "twsConnected": twsConnected, "twsObservedElapsedMs": nullable(twsObservedElapsedMs),
349
+ "handshakeTwsElapsedMs": nullable(handshakeTwsElapsedMs), "timeCommandId": nullable(timeCommandId),
350
+ "timeWriteStartedElapsedMs": nullable(timeWriteStartedElapsedMs), "timeWriteCompletedElapsedMs": nullable(timeWriteCompletedElapsedMs),
351
+ "timeAckElapsedMs": nullable(timeAckElapsedMs), "timeAckStatusCode": nullable(timeAckStatusCode)]
352
+ }
353
+ func releaseDictionary(producers: NimoResourceProducers) -> [String: Any] {
354
+ ["nonce": nullable(releaseNonce), "startedElapsedMs": nullable(releaseStartedElapsedMs),
355
+ "micStopRequired": micStopRequired, "displayExitRequired": displayExitRequired,
356
+ "micStopStartedElapsedMs": nullable(micStopStartedElapsedMs), "micStopWrittenElapsedMs": nullable(micStopWrittenElapsedMs),
357
+ "micStopMarkerElapsedMs": nullable(micStopMarkerElapsedMs), "exitStatusCode": nullable(exitStatusCode), "exitAckElapsedMs": nullable(exitAckElapsedMs),
358
+ "disconnectRequestedElapsedMs": nullable(disconnectRequestedElapsedMs), "disconnectCallbackElapsedMs": nullable(disconnectCallbackElapsedMs),
359
+ "disconnectError": nullable(disconnectError), "connectionFailedElapsedMs": nullable(connectionFailedElapsedMs), "noTransport": noTransport, "settled": settled(producers: producers)]
360
+ }
361
+ }
362
+
363
+ func nullable<T>(_ value: T?) -> Any { value.map { $0 as Any } ?? NSNull() }
364
+
365
+ /// Cross-queue roster; queued Main callbacks cannot disappear on logical reset.
366
+ final class NimoResourceCallbackCounter: @unchecked Sendable {
367
+ private let lock = NSLock()
368
+ private var count = 0
369
+ func add(_ amount: Int) { lock.lock(); count += amount; lock.unlock() }
370
+ var value: Int { lock.lock(); defer { lock.unlock() }; return count }
371
+ }
372
+
373
+ extension NimoResourceRequest {
374
+ static func decode(_ bytes: Data) throws -> NimoResourceRequest {
375
+ // JSONDecoder ignores unknown keys; reject them before typed decoding.
376
+ guard bytes.count <= 16_384, let value = try JSONSerialization.jsonObject(with: bytes) as? [String: Any],
377
+ Set(value.keys) == Set(["version","operation","requestNonce","captureNonce","planSha256","grantSha256","expectedProcess","expectedPair","original"]),
378
+ let process = value["expectedProcess"] as? [String: Any], Set(process.keys) == Set(["pid","instanceId"]),
379
+ let pair = value["expectedPair"] as? [String: Any],
380
+ Set(pair.keys) == Set(["name","peripheralUuid"]) || Set(pair.keys) == Set(["name","peripheralSelection"]) else { throw failure("invalid-request-schema") }
381
+ if let original = value["original"] as? [String: Any], Set(original.keys) != Set(["instanceId","peripheralUuid","sessionGeneration"]) { throw failure("invalid-original-schema") }
382
+ let result = try JSONDecoder().decode(NimoResourceRequest.self, from: bytes)
383
+ let ids = [result.requestNonce, result.captureNonce, result.expectedProcess.instanceId] + (result.expectedPair.peripheralUuid.map { [$0] } ?? []) +
384
+ (result.original.map { [$0.instanceId, $0.peripheralUuid] } ?? [])
385
+ for id in ids {
386
+ guard UUID(uuidString: id)?.uuidString.lowercased() == id else { throw failure("invalid-uuid") }
387
+ }
388
+ if result.operation == .discover {
389
+ guard result.expectedPair.peripheralSelection == NimoResourceSelection.connectedService,
390
+ result.requestNonce == result.captureNonce, result.original == nil else { throw failure("invalid-discovery-identity") }
391
+ }
392
+ if result.operation == .arm {
393
+ guard result.original == nil,
394
+ (result.expectedPair.peripheralUuid != nil ? result.requestNonce == result.captureNonce : result.requestNonce != result.captureNonce) else { throw failure("invalid-arm-identity") }
395
+ }
396
+ if let original = result.original {
397
+ guard original.sessionGeneration <= 1, (result.expectedPair.peripheralUuid == nil || original.peripheralUuid == result.expectedPair.peripheralUuid) else { throw failure("invalid-original-identity") }
398
+ }
399
+ guard (result.expectedPair.peripheralUuid != nil && result.expectedPair.peripheralSelection == nil || result.expectedPair.peripheralUuid == nil && result.expectedPair.peripheralSelection == NimoResourceSelection.connectedService),
400
+ result.expectedProcess.pid > 1, result.version == 1, !result.expectedPair.name.isEmpty, result.expectedPair.name.utf8.count <= 128,
401
+ result.planSha256.range(of: "^[0-9a-f]{64}$", options: .regularExpression) != nil,
402
+ result.grantSha256.range(of: "^[0-9a-f]{64}$", options: .regularExpression) != nil else { throw failure("invalid-request-identity") }
403
+ return result
404
+ }
405
+ private static func failure(_ code: String) -> Error { NSError(domain: "NimoResourceFixture", code: 1, userInfo: [NSLocalizedDescriptionKey: code]) }
406
+ }
@@ -5,6 +5,77 @@ import XCTest
5
5
  final class DeviceManagerReconnectTests: XCTestCase {
6
6
  private let requests = ["should_send_lc3", "should_send_pcm", "should_send_transcript", "local_stt_fallback_active"]
7
7
 
8
+ func testActualManagerHandoffCleanupDoesNotInvalidateClosedOriginalStatus() {
9
+ withRecordingDevice { manager, _ in
10
+ let original = manager.sgc as! ReconnectRecordingDevice
11
+ original.type = DeviceTypes.NIMO
12
+ let uuid = "00000000-0000-4000-8000-000000000001"
13
+ let model = NimoResourceSession(captureNonce: uuid, pair: NimoResourcePair(name: "Nimo-Test", peripheralUuid: uuid))
14
+ XCTAssertTrue(model.admit(original, instanceId: uuid, name: "Nimo-Test"))
15
+ XCTAssertTrue(model.beginRelease(nonce: uuid, cancel: true, mic: false, canvas: false))
16
+ model.proveNoTransport()
17
+ let verifiedClosed = model.settled(producers: NimoResourceProducers())
18
+ var cleanupCalls = 0, disconnectEffects = 0
19
+ original.onCleanup = {
20
+ cleanupCalls += 1
21
+ // Same original-session guard used by actual Nimo.disconnect.
22
+ if model.shouldRunOrdinaryDisconnect(original, originalClosed: verifiedClosed) { disconnectEffects += 1 }
23
+ }
24
+ manager.initSGC(DeviceTypes.SIMULATED) // Actual manager replacement; no Bluetooth device.
25
+ XCTAssertEqual(cleanupCalls, 1)
26
+ XCTAssertEqual(disconnectEffects, 0)
27
+ XCTAssertTrue(model.errors.isEmpty)
28
+ XCTAssertTrue(model.settled(producers: NimoResourceProducers())) // Fresh status after cleanup.
29
+ XCTAssertTrue(NimoResourceManagerState.allowsReplacement(fixtureEnabled: true, originalClosed: model.settled(producers: NimoResourceProducers())))
30
+ XCTAssertEqual(manager.sgc?.type, DeviceTypes.SIMULATED)
31
+ }
32
+ }
33
+
34
+ func testActualQueuedReplacementDoesNotJoinOriginalNimoProducerRoster() {
35
+ withRecordingDevice { manager, store in
36
+ let original = manager.sgc as! ReconnectRecordingDevice
37
+ original.type = DeviceTypes.NIMO
38
+ let uuid = "00000000-0000-4000-8000-000000000001"
39
+ let model = NimoResourceSession(captureNonce: uuid, pair: NimoResourcePair(name: "Nimo-Test", peripheralUuid: uuid))
40
+ XCTAssertTrue(model.admit(original, instanceId: uuid, name: "Nimo-Test"))
41
+ XCTAssertTrue(model.beginRelease(nonce: uuid, cancel: true, mic: false, canvas: false)); model.proveNoTransport()
42
+ let stopped = expectation(description: "Original disposal inside real queued manager handoff")
43
+ original.onDisconnect = {
44
+ XCTAssertFalse(model.shouldRunOrdinaryDisconnect(original, originalClosed: true))
45
+ stopped.fulfill()
46
+ }
47
+ // Same asynchronous connectByName path as G2; simulated replacement
48
+ // avoids any Bluetooth transport in this source regression.
49
+ store.set("bluetooth", "pending_wearable", DeviceTypes.SIMULATED)
50
+ manager.connectByName("Simulated")
51
+ XCTAssertEqual(NimoResourceFixture.shared.pendingManagerEntries, 1)
52
+ XCTAssertEqual(NimoResourceFixture.shared.pendingOriginalManagerEntries, 0)
53
+ wait(for: [stopped], timeout: 1)
54
+ var producers = NimoResourceProducers()
55
+ producers.postedCallbacks = NimoResourceFixture.shared.pendingOriginalManagerEntries
56
+ XCTAssertTrue(model.settled(producers: producers)) // Fresh status during replacement wait.
57
+ XCTAssertTrue(model.errors.isEmpty)
58
+ let completed = expectation(description: "Replacement queued work has actually settled")
59
+ Task { @MainActor in
60
+ let deadline = ProcessInfo.processInfo.systemUptime + 2
61
+ while ProcessInfo.processInfo.systemUptime < deadline {
62
+ if NimoResourceFixture.shared.pendingManagerEntries == 0,
63
+ manager.sgc?.type == DeviceTypes.SIMULATED {
64
+ completed.fulfill()
65
+ return
66
+ }
67
+ try? await Task.sleep(nanoseconds: 5_000_000)
68
+ }
69
+ // Leave the expectation unmet on timeout; elapsed delay alone
70
+ // cannot stand in for the queued manager's actual completion.
71
+ }
72
+ wait(for: [completed], timeout: 2.5)
73
+ XCTAssertEqual(NimoResourceFixture.shared.pendingManagerEntries, 0)
74
+ XCTAssertTrue(model.settled(producers: NimoResourceProducers()))
75
+ XCTAssertEqual(manager.sgc?.type, DeviceTypes.SIMULATED)
76
+ }
77
+ }
78
+
8
79
  func testDelayedReconnectReplayPreservesNimoDepthEndpoints() {
9
80
  for depth in [0, 10] {
10
81
  withRecordingDevice { manager, store in
@@ -280,6 +351,8 @@ final class DeviceManagerReconnectTests: XCTestCase {
280
351
 
281
352
  @MainActor
282
353
  private final class ReconnectRecordingDevice: SGCManager {
354
+ var onCleanup: (() -> Void)?
355
+ var onDisconnect: (() -> Void)?
283
356
  var type = "Reconnect test glasses"
284
357
  let hasMic = true
285
358
  let showConnectionConfirmation = false
@@ -333,7 +406,7 @@ private final class ReconnectRecordingDevice: SGCManager {
333
406
  func sendShutdown() {}
334
407
  func sendReboot() {}
335
408
  func sendRgbLedControl(requestId _: String, packageName _: String?, action _: String, color _: String?, onDurationMs _: Int, offDurationMs _: Int, count _: Int) {}
336
- func disconnect() {}
409
+ func disconnect() { onDisconnect?() }
337
410
  func forget() {}
338
411
  func findCompatibleDevices() {}
339
412
  func stopScan() {}
@@ -342,7 +415,7 @@ private final class ReconnectRecordingDevice: SGCManager {
342
415
  nil
343
416
  }
344
417
 
345
- func cleanup() {}
418
+ func cleanup() { onCleanup?() }
346
419
  func ping() {}
347
420
  func dbg1() {}
348
421
  func dbg2() {}
@@ -183,4 +183,151 @@ final class NimoCanvasTransportTests: XCTestCase {
183
183
  XCTAssertEqual(written.count, expected.count + 1)
184
184
  canvas.disconnected(); queue.reset()
185
185
  }
186
+
187
+ func testEncoderInvalidationRetainsRunningWorkerAndPendingDelivery() {
188
+ let main = NimoTestClock(), worker = NimoTestClock()
189
+ var delivered = false
190
+ var encoder: NimoCanvasEncoder!
191
+ encoder = NimoCanvasEncoder(main: main.schedule, worker: worker.schedule, failure: { XCTFail("\($0)") })
192
+ encoder.submit(encode: {
193
+ XCTAssertEqual(encoder.workerCount, 1)
194
+ encoder.invalidate()
195
+ XCTAssertEqual(encoder.workerCount, 1) // Running encode is not cancelled by dropping its result.
196
+ return Data([1])
197
+ }, deliver: { _ in delivered = true })
198
+ worker.advance()
199
+ XCTAssertEqual(encoder.workerCount, 0)
200
+ XCTAssertEqual(encoder.deliveryCount, 1)
201
+ main.advance()
202
+ XCTAssertEqual(encoder.deliveryCount, 0)
203
+ XCTAssertFalse(delivered)
204
+ }
205
+
206
+ func testWriterResetCannotEraseRealInFlightOrPostedCompletion() {
207
+ let clock = NimoTestClock(), original = NSObject(), replacement = NSObject(), characteristic = NSObject()
208
+ let queue = NimoWriteQueue<NSObject>(schedule: clock.schedule, write: { _, _ in true }, failure: { XCTFail($0) })
209
+ queue.connected(original)
210
+ queue.enqueue(original, characteristic: characteristic, frames: [Data([1])])
211
+ XCTAssertEqual(queue.activeCount, 1)
212
+ queue.reset()
213
+ XCTAssertEqual(queue.activeCount, 1)
214
+ queue.written(replacement, characteristic: characteristic, success: true)
215
+ XCTAssertEqual(queue.activeCount, 1)
216
+ queue.connectionClosed(replacement)
217
+ XCTAssertEqual(queue.activeCount, 1)
218
+ queue.connectionClosed(original)
219
+ XCTAssertEqual(queue.activeCount, 0)
220
+ queue.connected(original)
221
+ queue.enqueue(original, characteristic: characteristic, frames: [Data([1])])
222
+ queue.written(original, characteristic: characteristic, success: true)
223
+ queue.reset()
224
+ XCTAssertEqual(queue.activeCount, 1) // Real scheduled completion still must run its fence.
225
+ clock.advance(0.005)
226
+ XCTAssertEqual(queue.activeCount, 0)
227
+ }
228
+
229
+ func testCanvasExitEvidenceWaitsForWriteCompletionAndExactBusinessAck() {
230
+ let clock = NimoTestClock()
231
+ var started: (() -> Void)?, completed: (() -> Void)?, results: [Int] = []
232
+ var observed: [UInt64?] = []
233
+ let canvas = NimoCanvasCoordinator(schedule: clock.schedule, writeCapacity: { 512 }, enqueue: { _, start, complete in
234
+ started = start; completed = complete; return true
235
+ }, reconnect: { XCTFail($0) }, rejected: { _ in }, result: { key, status in
236
+ if key == 3 { results.append(status) }
237
+ }, observedResult: { key, _, received in
238
+ if key == 3 { observed.append(received) }
239
+ }, continuousNow: { 120 })
240
+ canvas.activate(); canvas.readiness(true)
241
+ started?(); completed?(); canvas.response(key: 1, payload: Data([0, 0xFD]))
242
+ started?(); completed?(); canvas.response(key: 4, payload: Data([0, 0xFD, 0, 0, 0]))
243
+ XCTAssertTrue(canvas.mayHaveEntered)
244
+ canvas.exit(); started?()
245
+ canvas.response(key: 3, payload: Data([0, 0xFD]), receivedElapsedMs: 119)
246
+ XCTAssertTrue(results.isEmpty) // Queued prewrite ACK cannot settle Exit.
247
+ canvas.response(key: 3, payload: Data([0, 0xFD]), receivedElapsedMs: 123)
248
+ XCTAssertTrue(results.isEmpty)
249
+ completed?()
250
+ XCTAssertEqual(results, [0])
251
+ XCTAssertEqual(observed, [123]) // Never retimestamp buffered ACK at write completion.
252
+ XCTAssertFalse(canvas.mayHaveEntered)
253
+ }
254
+
255
+
256
+ func testCancelledQueuedEncoderRemainsInRosterUntilBothRealFencesExecute() {
257
+ let main = NimoTestClock(), worker = NimoTestClock()
258
+ var encoded = false
259
+ let encoder = NimoCanvasEncoder(main: main.schedule, worker: worker.schedule, failure: { XCTFail("\($0)") })
260
+ encoder.submit(encode: { encoded = true; return Data([1]) }, deliver: { _ in XCTFail("Cancelled delivery") })
261
+ encoder.invalidate()
262
+ XCTAssertEqual(encoder.workerCount, 1)
263
+ worker.advance()
264
+ XCTAssertFalse(encoded)
265
+ XCTAssertEqual(encoder.workerCount, 0)
266
+ XCTAssertEqual(encoder.deliveryCount, 1)
267
+ main.advance()
268
+ XCTAssertEqual(encoder.deliveryCount, 0)
269
+ }
270
+
271
+ func testReleaseDrainsOriginalLaunchAndUpdateBeforeExit() {
272
+ for releaseKey in [1, 4] {
273
+ let clock = NimoTestClock()
274
+ var now: UInt64 = 100
275
+ var started: (() -> Void)?, completed: (() -> Void)?
276
+ var keys: [Int] = [], observed: [(Int, UInt64?)] = []
277
+ let canvas = NimoCanvasCoordinator(schedule: clock.schedule, writeCapacity: { 512 }, enqueue: { frames, start, complete in
278
+ keys.append(Int(frames[0][9])); started = start; completed = complete; return true
279
+ }, reconnect: { XCTFail($0) }, rejected: { _ in }, observedResult: { key, _, received in observed.append((key, received)) }, continuousNow: { now })
280
+ canvas.activate(); canvas.readiness(true)
281
+ if releaseKey == 4 {
282
+ started?(); completed?(); canvas.response(key: 1, payload: Data([0, 0xFD]), receivedElapsedMs: 100)
283
+ now = 110
284
+ }
285
+ started?(); let originalCompletion = completed
286
+ canvas.drainForRelease()
287
+ canvas.offer(Data([9]), scope: "new", force: true)
288
+ canvas.activate(); canvas.readiness(true); canvas.nativeApp(99, entered: true)
289
+ let payload = releaseKey == 4 ? Data([0, 0xFD, 0, 0, 0]) : Data([0, 0xFD])
290
+ func deliver(_ key: Int, _ data: Data, _ received: UInt64) {
291
+ if canvas.acceptsReleaseResponse(key: key, payload: data) { canvas.response(key: key, payload: data, receivedElapsedMs: received) }
292
+ }
293
+ XCTAssertFalse(canvas.acceptsReleaseResponse(key: 3, payload: Data([0, 0xFD])))
294
+ XCTAssertFalse(canvas.acceptsReleaseResponse(key: releaseKey, payload: Data([0, 1])))
295
+ XCTAssertTrue(canvas.acceptsReleaseResponse(key: releaseKey, payload: payload))
296
+ deliver(releaseKey, payload, now - 1) // Callback received before actual final write.
297
+ XCTAssertEqual(keys.last, releaseKey)
298
+ deliver(releaseKey, payload, now + 2) // Early ACK buffered until real didWrite.
299
+ XCTAssertEqual(keys.last, releaseKey)
300
+ originalCompletion?()
301
+ XCTAssertEqual(keys.last, 3)
302
+ XCTAssertEqual(observed.last?.0, releaseKey); XCTAssertEqual(observed.last?.1, now + 2)
303
+ now += 10; started?(); completed?()
304
+ deliver(releaseKey, payload, now)
305
+ XCTAssertEqual(canvas.pendingCount, 1)
306
+ deliver(3, Data([0, 0xFD]), now + 1)
307
+ XCTAssertEqual(canvas.pendingCount, 0); XCTAssertFalse(canvas.mayHaveEntered)
308
+ XCTAssertEqual(keys, releaseKey == 1 ? [1, 3] : [1, 4, 3])
309
+ canvas.offer(Data([8]), scope: "replacement"); clock.advance(2)
310
+ XCTAssertEqual(keys.last, 3)
311
+ }
312
+ }
313
+
314
+ func testReleaseMissingOrRejectedOriginalAckCannotInventExitOrRetry() {
315
+ for status: UInt8? in [nil, 7, 8, 2] {
316
+ let clock = NimoTestClock()
317
+ var keys: [Int] = [], reconnects: [String] = [], results: [Int] = []
318
+ let canvas = NimoCanvasCoordinator(schedule: clock.schedule, writeCapacity: { 512 }, enqueue: { frames, start, complete in
319
+ keys.append(Int(frames[0][9])); start(); complete(); return true
320
+ }, reconnect: { reconnects.append($0) }, rejected: { _ in }, result: { key, _ in results.append(key) })
321
+ canvas.activate(); canvas.readiness(true); canvas.drainForRelease()
322
+ if let status {
323
+ let payload = Data([status])
324
+ XCTAssertTrue(canvas.acceptsReleaseResponse(key: 1, payload: payload))
325
+ canvas.response(key: 1, payload: payload)
326
+ }
327
+ clock.advance(46)
328
+ XCTAssertEqual(keys, [1]); XCTAssertFalse(results.contains(3))
329
+ if status == nil || status == 2 { XCTAssertEqual(reconnects.count, 1) }
330
+ }
331
+ }
332
+
186
333
  }