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

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.
@@ -536,6 +536,7 @@ private class NimoOpusDecoder {
536
536
 
537
537
  actor NimoReconnectionManager {
538
538
  private var task: Task<Void, Never>?
539
+ private(set) var liveTasks = 0
539
540
  private let intervalSeconds: UInt64
540
541
 
541
542
  init(intervalSeconds: UInt64 = 30) {
@@ -544,7 +545,9 @@ actor NimoReconnectionManager {
544
545
 
545
546
  func start(_ onAttempt: @escaping () async -> Bool) {
546
547
  stop()
548
+ liveTasks += 1
547
549
  task = Task {
550
+ defer { liveTasks -= 1 }
548
551
  var attempts = 0
549
552
  while !Task.isCancelled {
550
553
  try? await Task.sleep(nanoseconds: intervalSeconds * 1_000_000_000)
@@ -559,6 +562,13 @@ actor NimoReconnectionManager {
559
562
  }
560
563
  }
561
564
 
565
+ func stopAndWait() async {
566
+ let original = task
567
+ original?.cancel()
568
+ await original?.value
569
+ if liveTasks == 0 { task = nil }
570
+ }
571
+
562
572
  func stop() {
563
573
  task?.cancel()
564
574
  task = nil
@@ -568,6 +578,34 @@ actor NimoReconnectionManager {
568
578
  // MARK: - Nimo Class
569
579
 
570
580
  class Nimo: NSObject, SGCManager {
581
+ let resourceInstanceId = UUID().uuidString.lowercased()
582
+ private(set) var resourceMicEnabled = false
583
+ private var resourceReleaseTask: Task<Void, Never>?
584
+ private var resourceNotificationElapsedMs: UInt64?
585
+ private var resourceTimeCommand: String?
586
+ private var resourceReconnectTasks = 0
587
+ nonisolated private let resourceCallbackCounter = NimoResourceCallbackCounter()
588
+ var resourceCanvasRequired: Bool { canvas.mayHaveEntered }
589
+ var resourceMicStopRequired: Bool { resourceMicEnabled || resourceSession?.connected == true }
590
+ #if SWIFT_PACKAGE
591
+ // Package-only, radio-free fixture for exercising actual private wiring.
592
+ private var packageTestResourceSession: NimoResourceSession?
593
+ #endif
594
+ private var resourceSession: NimoResourceSession? {
595
+ #if SWIFT_PACKAGE
596
+ if let packageTestResourceSession { return packageTestResourceSession }
597
+ #endif
598
+ return NimoResourceFixture.shared.captured(self)
599
+ }
600
+
601
+ nonisolated private func postCallback(_ action: @escaping @MainActor () -> Void) {
602
+ resourceCallbackCounter.add(1)
603
+ DispatchQueue.main.async { [self] in
604
+ action()
605
+ resourceCallbackCounter.add(-1)
606
+ }
607
+ }
608
+
571
609
  var type: String = DeviceTypes.NIMO
572
610
  let hasMic = true
573
611
  let showBrightnessConfirmation = false
@@ -680,7 +718,11 @@ class Nimo: NSObject, SGCManager {
680
718
  enqueue: { [weak self] frames, started, completed in
681
719
  self?.enqueueFrames(frames, finalStarted: started, completed: completed) ?? false
682
720
  }, reconnect: { [weak self] in self?.abortTransport($0) },
683
- rejected: { Bridge.log("NIMO: canvas rejected status=\($0); not a render ACK") }
721
+ rejected: { Bridge.log("NIMO: canvas rejected status=\($0); not a render ACK") },
722
+ observedResult: { [weak self] key, status, received in
723
+ guard let self else { return }
724
+ if key == 3 { self.resourceSession?.exitAcknowledged(self, status: status, receivedElapsedMs: received) }
725
+ }, continuousNow: NimoResourceClock.now
684
726
  )
685
727
  private var batteryPollTimer: Timer?
686
728
 
@@ -700,6 +742,7 @@ class Nimo: NSObject, SGCManager {
700
742
  // MARK: - SGCManager: Connection Management
701
743
 
702
744
  func findCompatibleDevices() {
745
+ guard NimoResourceFixture.shared.admit(self, name: nil) else { return }
703
746
  Bridge.log("NIMO: findCompatibleDevices()")
704
747
  DEVICE_SEARCH_ID = "NOT_SET"
705
748
  isDisconnecting = false
@@ -709,6 +752,7 @@ class Nimo: NSObject, SGCManager {
709
752
  }
710
753
 
711
754
  func connectById(_ id: String) {
755
+ guard NimoResourceFixture.shared.admit(self, name: id) else { return }
712
756
  Bridge.log("NIMO: connectById(\(id))")
713
757
  DEVICE_SEARCH_ID = id
714
758
  scanRequested = true
@@ -725,6 +769,10 @@ class Nimo: NSObject, SGCManager {
725
769
 
726
770
  func disconnect() {
727
771
  Bridge.log("NIMO: disconnect()")
772
+ if let resourceSession,
773
+ !resourceSession.shouldRunOrdinaryDisconnect(self, originalClosed: NimoResourceFixture.shared.hasVerifiedOriginalClosure(self)) {
774
+ return
775
+ }
728
776
  isDisconnecting = true
729
777
  cancelPairingTimeout()
730
778
  cancelTwsTimeout()
@@ -749,6 +797,7 @@ class Nimo: NSObject, SGCManager {
749
797
 
750
798
  /// Explicit Unpair only; passive forget/cleanup must never reset the glasses.
751
799
  func resetForUnpair(onResult: @escaping (Bool) -> Void) {
800
+ if let resourceSession { resourceSession.recordError("factory-reset-refused-during-capture"); onResult(false); return }
752
801
  guard peripheral != nil, txChar != nil, handshakeState == .ready else {
753
802
  Bridge.log("NIMO: unpair while offline; remote reset unavailable")
754
803
  onResult(false)
@@ -769,6 +818,7 @@ class Nimo: NSObject, SGCManager {
769
818
  }
770
819
 
771
820
  func forget() {
821
+ if let resourceSession { resourceSession.recordError("forget-refused-during-capture"); return }
772
822
  Bridge.log("NIMO: forget()")
773
823
  disconnect()
774
824
  lastDeviceName = nil
@@ -801,11 +851,13 @@ class Nimo: NSObject, SGCManager {
801
851
  // MARK: - SGCManager: Audio Control
802
852
 
803
853
  func setMicEnabled(_ enabled: Bool) {
854
+ guard NimoResourceFixture.shared.allowsProducer(self) else { return }
804
855
  Bridge.log("NIMO: setMicEnabled(\(enabled))")
805
856
  guard let micChar else {
806
857
  Bridge.log("NIMO: mic characteristic not available")
807
858
  return
808
859
  }
860
+ resourceMicEnabled = enabled
809
861
  DeviceStore.shared.apply("glasses", "micEnabled", enabled)
810
862
  if enabled {
811
863
  if opusDecoder == nil {
@@ -912,6 +964,7 @@ class Nimo: NSObject, SGCManager {
912
964
  }
913
965
 
914
966
  private func encodeCanvas(scope: String, force: Bool = false, encode: @escaping () throws -> Data) {
967
+ guard NimoResourceFixture.shared.allowsProducer(self) else { return }
915
968
  canvasEncoder.submit(encode: encode) { [weak self] bytes in
916
969
  self?.canvas.offer(bytes, scope: scope, force: force)
917
970
  }
@@ -1110,7 +1163,7 @@ class Nimo: NSObject, SGCManager {
1110
1163
 
1111
1164
  @discardableResult
1112
1165
  private func startScan() -> Bool {
1113
- guard scanRequested, !isDisconnecting else { return false }
1166
+ guard scanRequested, !isDisconnecting, NimoResourceFixture.shared.allowsProducer(self) else { return false }
1114
1167
  Bridge.log("NIMO: startScan()")
1115
1168
  if centralManager == nil {
1116
1169
  centralManager = CBCentralManager(
@@ -1142,6 +1195,7 @@ class Nimo: NSObject, SGCManager {
1142
1195
  guard DEVICE_SEARCH_ID != "NOT_SET", name == DEVICE_SEARCH_ID,
1143
1196
  self.peripheral == nil else { return }
1144
1197
  Bridge.log("NIMO: Connecting to \(name)")
1198
+ guard NimoResourceFixture.shared.beginTransport(self, peripheral: peripheral, uuid: peripheral.identifier.uuidString) else { return }
1145
1199
  stopScan()
1146
1200
  lastDeviceName = name
1147
1201
  lastDeviceUUID = peripheral.identifier.uuidString
@@ -1161,6 +1215,7 @@ class Nimo: NSObject, SGCManager {
1161
1215
  else { return false }
1162
1216
 
1163
1217
  Bridge.log("NIMO: connectByUUID - \(known.name ?? uuidString)")
1218
+ guard NimoResourceFixture.shared.beginTransport(self, peripheral: known, uuid: known.identifier.uuidString) else { return false }
1164
1219
  peripheral = known
1165
1220
  known.delegate = self
1166
1221
  centralManager?.connect(known, options: nil)
@@ -1188,7 +1243,7 @@ class Nimo: NSObject, SGCManager {
1188
1243
  // MARK: - Write Queue
1189
1244
 
1190
1245
  private func enqueueWrite(_ characteristic: CBCharacteristic, _ bytes: Data) {
1191
- guard let peripheral else { return }
1246
+ guard NimoResourceFixture.shared.allowsProducer(self), let peripheral else { return }
1192
1247
  writes.enqueue(peripheral, characteristic: characteristic, frames: [bytes])
1193
1248
  }
1194
1249
 
@@ -1201,12 +1256,17 @@ class Nimo: NSObject, SGCManager {
1201
1256
  completed: @escaping () -> Void) -> Bool
1202
1257
  {
1203
1258
  guard let peripheral, let txChar else { return false }
1259
+ if let resourceSession, resourceSession.releaseNonce != nil, frames.first?.count ?? 0 > 9, frames.first?[9] != 3 { return false }
1204
1260
  return writes.enqueue(peripheral, characteristic: txChar, frames: frames,
1205
1261
  finalStarted: finalStarted, completed: completed)
1206
1262
  }
1207
1263
 
1208
1264
  private func abortTransport(_ reason: String) {
1209
1265
  Bridge.log("NIMO: retiring transport: \(reason)")
1266
+ if let resourceSession {
1267
+ resourceSession.recordError("transport-retired: \(reason)")
1268
+ if resourceSession.releaseNonce != nil { resourceSession.disconnectRequested(self) }
1269
+ }
1210
1270
  resetSessionState()
1211
1271
  if let peripheral { centralManager?.cancelPeripheralConnection(peripheral) }
1212
1272
  }
@@ -1214,21 +1274,29 @@ class Nimo: NSObject, SGCManager {
1214
1274
  // MARK: - Pending ACKs
1215
1275
 
1216
1276
  private func sendAwaitingAck(
1217
- cmd: Int, key: Int, payload: Data, onResult: @escaping (Bool) -> Void
1277
+ cmd: Int, key: Int, payload: Data, writeStarted: @escaping () -> Void = {}, writeCompleted: @escaping () -> Void = {}, onResult: @escaping (Bool) -> Void
1218
1278
  ) {
1279
+ guard NimoResourceFixture.shared.allowsProducer(self) else { onResult(false); return }
1219
1280
  let ackKey = (cmd << 8) | key
1220
1281
  // Only one in-flight ack per (cmd,key); fail any previous waiter.
1221
1282
  if let previous = pendingAcks.removeValue(forKey: ackKey) {
1222
1283
  previous.timeoutItem.cancel()
1223
1284
  previous.onResult(false)
1224
1285
  }
1225
- let timeoutItem = DispatchWorkItem { [weak self] in
1226
- guard let self else { return }
1227
- self.pendingAcks.removeValue(forKey: ackKey)?.onResult(false)
1228
- }
1286
+ let timeoutItem = pendingAckTimeout(ackKey)
1229
1287
  pendingAcks[ackKey] = PendingAck(onResult: onResult, timeoutItem: timeoutItem)
1230
1288
  DispatchQueue.main.asyncAfter(deadline: .now() + Const.ackTimeoutSeconds, execute: timeoutItem)
1231
- sendFrame(NimoFrameCodec.encodeFrame(cmd: cmd, key: key, payload: payload, needsAck: true))
1289
+ guard let peripheral, let txChar else { resolvePendingAck(cmd: cmd, key: key, success: false); return }
1290
+ writes.enqueue(peripheral, characteristic: txChar,
1291
+ frames: [NimoFrameCodec.encodeFrame(cmd: cmd, key: key, payload: payload, needsAck: true)],
1292
+ finalStarted: writeStarted, completed: writeCompleted)
1293
+ }
1294
+
1295
+ private func pendingAckTimeoutAction(_ ackKey: Int) -> () -> Void {
1296
+ { [weak self] in self?.pendingAcks.removeValue(forKey: ackKey)?.onResult(false) }
1297
+ }
1298
+ private func pendingAckTimeout(_ ackKey: Int) -> DispatchWorkItem {
1299
+ DispatchWorkItem(block: pendingAckTimeoutAction(ackKey))
1232
1300
  }
1233
1301
 
1234
1302
  private func resolvePendingAck(cmd: Int, key: Int, success: Bool) {
@@ -1286,23 +1354,34 @@ class Nimo: NSObject, SGCManager {
1286
1354
  }
1287
1355
 
1288
1356
  private func attemptSetTime() {
1289
- guard handshakeState == .awaitingTimeAck, let expected = peripheral else { return }
1357
+ guard NimoResourceFixture.shared.allowsProducer(self), handshakeState == .awaitingTimeAck, let expected = peripheral else { return }
1290
1358
  setTimeAttempts += 1
1359
+ let command = resourceSession?.beginTimeCommand(self)
1360
+ resourceTimeCommand = command
1291
1361
  sendAwaitingAck(cmd: NimoProtocol.CMD_SET_PARAMETER, key: NimoProtocol.SET_TIME,
1292
- payload: NimoFrameCodec.encodeDeviceTime())
1362
+ payload: NimoFrameCodec.encodeDeviceTime(),
1363
+ writeStarted: { [weak self] in guard let self, let command else { return }; self.resourceSession?.timeWriteStarted(self, command: command) },
1364
+ writeCompleted: { [weak self] in guard let self, let command else { return }; self.resourceSession?.timeWriteCompleted(self, command: command)
1365
+ if self.resourceSession?.ready == true, self.handshakeState == .awaitingTimeAck { self.finishHandshake() }
1366
+ })
1293
1367
  { [weak self] ok in
1294
- guard let self, self.peripheral === expected, self.handshakeState == .awaitingTimeAck else { return }
1295
- if ok { self.finishHandshake() }
1296
- else if self.setTimeAttempts < 3 {
1297
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
1298
- guard let self, self.peripheral === expected else { return }
1299
- self.attemptSetTime()
1300
- }
1301
- } else { self.handshakeFailed() }
1368
+ self?.handleTimeSyncResult(ok, expected: expected)
1302
1369
  }
1303
1370
  }
1304
1371
 
1372
+ private func handleTimeSyncResult(_ ok: Bool, expected: CBPeripheral?) {
1373
+ guard peripheral === expected, handshakeState == .awaitingTimeAck else { return }
1374
+ if ok { if resourceSession == nil || resourceSession?.ready == true { finishHandshake() } }
1375
+ else if resourceSession == nil, setTimeAttempts < 3 {
1376
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
1377
+ guard let self, self.peripheral === expected else { return }
1378
+ self.attemptSetTime()
1379
+ }
1380
+ } else { handshakeFailed() }
1381
+ }
1382
+
1305
1383
  private func finishHandshake() {
1384
+ guard NimoResourceFixture.shared.allowsProducer(self) else { return }
1306
1385
  Bridge.log("NIMO: handshake complete — fully connected")
1307
1386
  handshakeState = .ready
1308
1387
  sendFrame(
@@ -1332,6 +1411,7 @@ class Nimo: NSObject, SGCManager {
1332
1411
  }
1333
1412
 
1334
1413
  private func handshakeFailed() {
1414
+ if let resourceSession, !resourceSession.recordHandshakeFailure(self) { return }
1335
1415
  handshakeState = .idle
1336
1416
  if let peripheral {
1337
1417
  centralManager?.cancelPeripheralConnection(peripheral)
@@ -1353,7 +1433,10 @@ class Nimo: NSObject, SGCManager {
1353
1433
  }
1354
1434
 
1355
1435
  private func startReconnectionTimer() {
1356
- Task {
1436
+ guard resourceSession == nil, NimoResourceFixture.shared.allowsProducer(self) else { return }
1437
+ resourceReconnectTasks += 1
1438
+ Task { [self] in
1439
+ defer { resourceReconnectTasks -= 1 }
1357
1440
  await reconnectionManager.start { [weak self] in
1358
1441
  guard let self else { return false }
1359
1442
  if await MainActor.run(body: {
@@ -1397,11 +1480,14 @@ class Nimo: NSObject, SGCManager {
1397
1480
  private func handleRxPacket(_ packet: Data) {
1398
1481
  if packet.count >= 10, packet[8] == 7, [1, 3, 4].contains(packet[9]) {
1399
1482
  if let response = NimoCanvasCodec.response(packet) {
1483
+ let productAllowed = (DeviceManager.shared.sgc as? Nimo) === self && NimoResourceFixture.shared.allowsProducer(self)
1484
+ guard productAllowed || (resourceSession?.releaseNonce != nil && canvas.acceptsReleaseResponse(key: response.key, payload: response.payload)) else { return }
1400
1485
  if response.key == 1 { Bridge.log("NIMO: canvas Launch response status=\(response.payload.first.map(Int.init) ?? -1)") }
1401
- canvas.response(key: response.key, payload: response.payload)
1486
+ canvas.response(key: response.key, payload: response.payload, receivedElapsedMs: resourceNotificationElapsedMs)
1402
1487
  }
1403
1488
  return
1404
1489
  }
1490
+ guard (DeviceManager.shared.sgc as? Nimo) === self, NimoResourceFixture.shared.allowsProducer(self) else { return }
1405
1491
  receiveAssembler.cleanup()
1406
1492
  for frame in receiveAssembler.ingest(packet) {
1407
1493
  guard let decoded = NimoFrameCodec.decode(frame),
@@ -1479,6 +1565,7 @@ class Nimo: NSObject, SGCManager {
1479
1565
  }
1480
1566
 
1481
1567
  private func onTwsState(_ connected: Bool) {
1568
+ resourceSession?.observeTws(self, connected: connected, receivedElapsedMs: resourceNotificationElapsedMs)
1482
1569
  twsConnected = connected
1483
1570
  if connected, handshakeState == .awaitingTws {
1484
1571
  proceedToTimeSync()
@@ -1525,6 +1612,13 @@ class Nimo: NSObject, SGCManager {
1525
1612
  if cmd == NimoProtocol.CMD_SET_PARAMETER, key == NimoProtocol.SET_SYSTEM_LANGUAGE {
1526
1613
  Bridge.log("NIMO: English menu setting acknowledged=\(statusCode == 0) status=\(statusCode)")
1527
1614
  }
1615
+ if cmd == NimoProtocol.CMD_SET_PARAMETER, key == NimoProtocol.SET_TIME, let resourceSession {
1616
+ guard let started = resourceSession.timeWriteStartedElapsedMs,
1617
+ let received = resourceNotificationElapsedMs, received >= started else { return }
1618
+ }
1619
+ if cmd == NimoProtocol.CMD_SET_PARAMETER, key == NimoProtocol.SET_TIME, let command = resourceTimeCommand {
1620
+ resourceSession?.timeAck(self, command: command, status: statusCode, receivedElapsedMs: resourceNotificationElapsedMs)
1621
+ }
1528
1622
  resolvePendingAck(cmd: cmd, key: key, success: statusCode == 0)
1529
1623
 
1530
1624
  guard cmd == NimoProtocol.CMD_GET_PARAMETER, statusCode == 0 else { return }
@@ -1590,6 +1684,9 @@ class Nimo: NSObject, SGCManager {
1590
1684
  // MARK: - Mic Audio
1591
1685
 
1592
1686
  private func handleMicPacket(_ data: Data) {
1687
+ if data == Data([0x52, 0, 0, 0]) { resourceSession?.micStopMarker(self, receivedElapsedMs: resourceNotificationElapsedMs) }
1688
+ guard (DeviceManager.shared.sgc as? Nimo) === self, NimoResourceFixture.shared.allowsProducer(self) else { return }
1689
+ if resourceSession?.releaseNonce != nil { return }
1593
1690
  guard let packet = NimoAudioParser.parse(data) else { return }
1594
1691
  let now = Date()
1595
1692
  switch packet.type {
@@ -1616,14 +1713,134 @@ class Nimo: NSObject, SGCManager {
1616
1713
  }
1617
1714
  }
1618
1715
  }
1716
+
1717
+ func resourceProducers() async -> NimoResourceProducers {
1718
+ var result = NimoResourceProducers()
1719
+ result.writerActive = writes.activeCount; result.writerQueued = writes.queuedCount
1720
+ result.encoderWorker = canvasEncoder.workerCount; result.encoderDelivery = canvasEncoder.deliveryCount
1721
+ result.postedCallbacks = resourceCallbackCounter.value + NimoResourceFixture.shared.pendingOriginalManagerEntries
1722
+ result.canvasCommands = canvas.pendingCount; result.pendingAcks = pendingAcks.count
1723
+ // Opus decode, LC3 conversion and Bridge emission are synchronous on
1724
+ // Main on macOS; their entire extent is inside the posted CB callback.
1725
+ result.audioWorkers = 0
1726
+ result.scan = scanRequested ? 1 : 0
1727
+ result.timers = (batteryPollTimer == nil ? 0 : 1) + (twsTimeoutItem == nil ? 0 : 1) + (pairingTimeoutItem == nil ? 0 : 1)
1728
+ result.reconnectTask = await reconnectionManager.liveTasks + resourceReconnectTasks
1729
+ return result
1730
+ }
1731
+
1732
+ func releaseResource(_ original: NimoResourceSession) async {
1733
+ if let resourceReleaseTask { await resourceReleaseTask.value; return }
1734
+ guard original.isOwner(self) else { return }
1735
+ // Retire setup waiters synchronously. Their ACK/timeout can already be
1736
+ // queued when the capture fence is installed; shutdown is not a failed
1737
+ // handshake. The real transport writes and Canvas drain remain intact.
1738
+ handshakeState = .idle
1739
+ cancelTwsTimeout()
1740
+ resolvePendingAck(cmd: NimoProtocol.CMD_SET_PARAMETER, key: NimoProtocol.SET_TIME, success: false)
1741
+ // Original Canvas ACKs may arrive while reconnect tasks are settling.
1742
+ canvas.drainForRelease()
1743
+ let task = Task { @MainActor [self] in
1744
+ isDisconnecting = true
1745
+ stopScan(); stopTimers(); cancelPairingTimeout(); cancelTwsTimeout()
1746
+ canvasEncoder.invalidate()
1747
+ await reconnectionManager.stopAndWait()
1748
+ // Existing pending commands retain their real callbacks/deadlines.
1749
+ // Exit is queued through the same canvas/write state machine.
1750
+ if original.micStopRequired {
1751
+ if let peripheral, let micChar {
1752
+ writes.enqueue(peripheral, characteristic: micChar, frames: [Data([0x52, 0, 0, 0])],
1753
+ finalStarted: { [weak self] in guard let self else { return }; original.micStopStarted(self) },
1754
+ completed: { [weak self] in guard let self else { return }; original.micStopWritten(self) })
1755
+ } else { original.recordError("mic-stop-transport-unavailable") }
1756
+ }
1757
+ let deadline = NimoResourceClock.now() + 15_000
1758
+ while NimoResourceClock.now() < deadline {
1759
+ let producers = await resourceProducers()
1760
+ if original.commandsSettled, producers.writerActive == 0, producers.writerQueued == 0,
1761
+ producers.encoderWorker == 0, producers.encoderDelivery == 0, producers.pendingAcks == 0,
1762
+ producers.canvasCommands == 0, producers.reconnectTask == 0, producers.postedCallbacks == 0,
1763
+ producers.scan == 0, producers.timers == 0, producers.audioWorkers == 0 { break }
1764
+ try? await Task.sleep(nanoseconds: 10_000_000)
1765
+ }
1766
+ if !original.commandsSettled { original.recordError("release-command-evidence-incomplete") }
1767
+ if let peripheral {
1768
+ original.disconnectRequested(self)
1769
+ centralManager?.cancelPeripheralConnection(peripheral)
1770
+ // Retain this exact peripheral and delegate until original closure.
1771
+ while original.disconnectCallbackElapsedMs == nil, original.connectionFailedElapsedMs == nil, NimoResourceClock.now() < deadline + 5_000 {
1772
+ try? await Task.sleep(nanoseconds: 10_000_000)
1773
+ }
1774
+ if original.disconnectCallbackElapsedMs == nil, original.connectionFailedElapsedMs == nil { original.recordError("original-disconnect-callback-missing") }
1775
+ } else if original.connectionFailedElapsedMs == nil { original.proveNoTransport() }
1776
+ if original.disconnectCallbackElapsedMs != nil || original.connectionFailedElapsedMs != nil || original.noTransport {
1777
+ resetSessionState()
1778
+ peripheral = nil; txChar = nil; rxChar = nil; micChar = nil; opusDecoder = nil
1779
+ resourceMicEnabled = false
1780
+ NimoResourceManagerState.publish(owner: self, current: DeviceManager.shared.sgc as? Nimo) {
1781
+ DeviceStore.shared.apply("glasses", "micEnabled", false)
1782
+ DeviceStore.shared.apply("glasses", "connected", false)
1783
+ DeviceStore.shared.apply("glasses", "fullyBooted", false)
1784
+ }
1785
+ }
1786
+ }
1787
+ resourceReleaseTask = task
1788
+ await task.value
1789
+ }
1790
+
1619
1791
  }
1620
1792
 
1793
+ #if SWIFT_PACKAGE
1794
+ // SDK-package-only, radio-free regression fixture; no app or command API.
1795
+ struct NimoPackageHandshakeObservation {
1796
+ let errors: [String]
1797
+ let pendingAcks: Int
1798
+ let idle: Bool
1799
+ let ready: Bool
1800
+ let sameWriteEvidence: Bool
1801
+ let ackStillAbsent: Bool
1802
+ let noRadio: Bool
1803
+ let physicalClosure: Bool
1804
+ }
1805
+ extension Nimo {
1806
+ @MainActor
1807
+ func packageExercisePendingTimeAck(cancel: Bool, timeoutBeforeEntry: Bool, activeTimeout: Bool = false) async -> NimoPackageHandshakeObservation {
1808
+ let id = "00000000-0000-4000-8000-000000000001"
1809
+ var tick: UInt64 = 100
1810
+ let session = NimoResourceSession(captureNonce: id, pair: NimoResourcePair(name: "NIMO test", peripheralUuid: id), now: { tick += 1; return tick })
1811
+ let peer = NSObject()
1812
+ _ = session.admit(self, instanceId: resourceInstanceId, name: "NIMO test")
1813
+ _ = session.beginTransport(self, peripheral: peer, uuid: id)
1814
+ session.transportConnected(self, peer); session.observeTws(self, connected: true)
1815
+ let command = session.beginTimeCommand(self)!
1816
+ session.timeWriteStarted(self, command: command); session.timeWriteCompleted(self, command: command)
1817
+ let started = session.timeWriteStartedElapsedMs, completed = session.timeWriteCompletedElapsedMs
1818
+ packageTestResourceSession = session; resourceTimeCommand = command; handshakeState = .awaitingTimeAck
1819
+ let key = (NimoProtocol.CMD_SET_PARAMETER << 8) | NimoProtocol.SET_TIME
1820
+ let alreadyQueuedTimeout = pendingAckTimeoutAction(key)
1821
+ pendingAcks[key] = PendingAck(onResult: { [weak self] ok in self?.handleTimeSyncResult(ok, expected: nil) }, timeoutItem: DispatchWorkItem(block: alreadyQueuedTimeout))
1822
+ if activeTimeout { alreadyQueuedTimeout() }
1823
+ _ = session.beginRelease(nonce: id, cancel: cancel, mic: false, canvas: false)
1824
+ if timeoutBeforeEntry { alreadyQueuedTimeout() }
1825
+ await releaseResource(session)
1826
+ alreadyQueuedTimeout() // Closure already dequeued before cancellation.
1827
+ handleTimeSyncResult(true, expected: nil)
1828
+ session.timeAck(self, command: command, status: 0)
1829
+ return NimoPackageHandshakeObservation(errors: session.errors, pendingAcks: pendingAcks.count,
1830
+ idle: handshakeState == .idle, ready: session.ready,
1831
+ sameWriteEvidence: started == session.timeWriteStartedElapsedMs && completed == session.timeWriteCompletedElapsedMs,
1832
+ ackStillAbsent: session.timeAckElapsedMs == nil, noRadio: centralManager == nil && peripheral == nil && !scanRequested,
1833
+ physicalClosure: session.settled(producers: NimoResourceProducers()))
1834
+ }
1835
+ }
1836
+ #endif
1837
+
1621
1838
  // MARK: - CBCentralManagerDelegate
1622
1839
 
1623
1840
  extension Nimo: CBCentralManagerDelegate {
1624
1841
  nonisolated func centralManagerDidUpdateState(_ central: CBCentralManager) {
1625
1842
  let state = central.state
1626
- DispatchQueue.main.async { [weak self] in
1843
+ self.postCallback { [weak self] in
1627
1844
  guard let self else { return }
1628
1845
  Bridge.log("NIMO: Bluetooth state: \(state.rawValue)")
1629
1846
  if state == .poweredOn {
@@ -1640,7 +1857,7 @@ extension Nimo: CBCentralManagerDelegate {
1640
1857
  ) {
1641
1858
  let advertisedName = advertisementData[CBAdvertisementDataLocalNameKey] as? String
1642
1859
  let rssiValue = rssi.intValue
1643
- DispatchQueue.main.async { [weak self] in
1860
+ self.postCallback { [weak self] in
1644
1861
  self?.discovery.receive(peripheral, advertisedName: advertisedName, rssi: rssiValue)
1645
1862
  }
1646
1863
  }
@@ -1650,17 +1867,27 @@ extension Nimo: CBCentralManagerDelegate {
1650
1867
  _: CBCentralManager, connectionEventDidOccur event: CBConnectionEvent, for peripheral: CBPeripheral
1651
1868
  ) {
1652
1869
  guard event == .peerConnected else { return }
1653
- DispatchQueue.main.async { [weak self] in
1870
+ self.postCallback { [weak self] in
1654
1871
  self?.discovery.receive(peripheral)
1655
1872
  }
1656
1873
  }
1657
1874
  #endif
1658
1875
 
1659
1876
  nonisolated func centralManager(_: CBCentralManager, didConnect peripheral: CBPeripheral) {
1660
- DispatchQueue.main.async { [weak self] in
1877
+ self.postCallback { [weak self] in
1661
1878
  guard let self else { return }
1662
1879
  Bridge.log("NIMO: Connected to \(peripheral.name ?? "unknown")")
1880
+ if let original = self.resourceSession, original.matches(self, peripheral), original.connectionFailedElapsedMs != nil {
1881
+ original.transportConnected(self, peripheral)
1882
+ self.centralManager?.cancelPeripheralConnection(peripheral)
1883
+ return
1884
+ }
1663
1885
  guard self.peripheral === peripheral else { return }
1886
+ self.resourceSession?.transportConnected(self, peripheral)
1887
+ guard NimoResourceFixture.shared.allowsProducer(self) else {
1888
+ if self.resourceSession?.releaseNonce != nil { self.resourceSession?.disconnectRequested(self) }
1889
+ self.centralManager?.cancelPeripheralConnection(peripheral); return
1890
+ }
1664
1891
  self.writes.connected(peripheral)
1665
1892
  self.lastDeviceUUID = peripheral.identifier.uuidString
1666
1893
  peripheral.discoverServices([NimoBLE.SERVICE_UUID])
@@ -1670,12 +1897,15 @@ extension Nimo: CBCentralManagerDelegate {
1670
1897
  nonisolated func centralManager(
1671
1898
  _: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?
1672
1899
  ) {
1673
- DispatchQueue.main.async { [weak self] in
1900
+ let receivedElapsedMs = NimoResourceClock.now()
1901
+ self.postCallback { [weak self] in
1674
1902
  guard let self else { return }
1675
1903
  Bridge.log(
1676
1904
  "NIMO: Failed to connect \(peripheral.name ?? "unknown"): \(error?.localizedDescription ?? "unknown")"
1677
1905
  )
1678
1906
  guard self.peripheral === peripheral else { return }
1907
+ self.resourceSession?.connectionFailed(self, peripheral, receivedElapsedMs: receivedElapsedMs)
1908
+ self.writes.connectionClosed(peripheral)
1679
1909
  self.peripheral = nil
1680
1910
  self.startReconnectionTimer()
1681
1911
  }
@@ -1684,9 +1914,14 @@ extension Nimo: CBCentralManagerDelegate {
1684
1914
  nonisolated func centralManager(
1685
1915
  _: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?
1686
1916
  ) {
1687
- DispatchQueue.main.async { [weak self] in
1917
+ let receivedElapsedMs = NimoResourceClock.now()
1918
+ self.postCallback { [weak self] in
1688
1919
  guard let self else { return }
1689
1920
  Bridge.log("NIMO: Disconnected: \(error?.localizedDescription ?? "clean")")
1921
+ if let original = self.resourceSession, original.matches(self, peripheral) {
1922
+ original.disconnected(self, peripheral, error: error?.localizedDescription, receivedElapsedMs: receivedElapsedMs)
1923
+ }
1924
+ self.writes.connectionClosed(peripheral)
1690
1925
  if self.isDisconnecting || self.peripheral !== peripheral { return }
1691
1926
 
1692
1927
  self.peripheral = nil
@@ -1695,9 +1930,11 @@ extension Nimo: CBCentralManagerDelegate {
1695
1930
  self.micChar = nil
1696
1931
  self.resetSessionState()
1697
1932
 
1698
- DeviceStore.shared.apply("glasses", "connected", false)
1699
- DeviceStore.shared.apply("glasses", "fullyBooted", false)
1700
- DeviceStore.shared.apply("glasses", "connectionState", ConnTypes.DISCONNECTED)
1933
+ NimoResourceManagerState.publish(owner: self, current: DeviceManager.shared.sgc as? Nimo) {
1934
+ DeviceStore.shared.apply("glasses", "connected", false)
1935
+ DeviceStore.shared.apply("glasses", "fullyBooted", false)
1936
+ DeviceStore.shared.apply("glasses", "connectionState", ConnTypes.DISCONNECTED)
1937
+ }
1701
1938
  self.startReconnectionTimer()
1702
1939
  }
1703
1940
  }
@@ -1707,8 +1944,8 @@ extension Nimo: CBCentralManagerDelegate {
1707
1944
 
1708
1945
  extension Nimo: CBPeripheralDelegate {
1709
1946
  nonisolated func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
1710
- DispatchQueue.main.async { [weak self] in
1711
- guard let self, self.peripheral === peripheral else { return }
1947
+ self.postCallback { [weak self] in
1948
+ guard let self, self.peripheral === peripheral, NimoResourceFixture.shared.allowsProducer(self) else { return }
1712
1949
  guard error == nil, let service = peripheral.services?.first(where: { $0.uuid == NimoBLE.SERVICE_UUID }) else {
1713
1950
  self.abortTransport("NIMO service discovery failed")
1714
1951
  return
@@ -1724,9 +1961,9 @@ extension Nimo: CBPeripheralDelegate {
1724
1961
  let characteristics = service.characteristics
1725
1962
  else { return }
1726
1963
 
1727
- DispatchQueue.main.async { [weak self] in
1964
+ self.postCallback { [weak self] in
1728
1965
  guard let self else { return }
1729
- guard self.peripheral === peripheral else { return }
1966
+ guard self.peripheral === peripheral, NimoResourceFixture.shared.allowsProducer(self) else { return }
1730
1967
  guard error == nil else { self.abortTransport("NIMO characteristic discovery failed"); return }
1731
1968
  for char in characteristics {
1732
1969
  switch char.uuid {
@@ -1756,9 +1993,9 @@ extension Nimo: CBPeripheralDelegate {
1756
1993
  _ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic,
1757
1994
  error: Error?
1758
1995
  ) {
1759
- DispatchQueue.main.async { [weak self] in
1996
+ self.postCallback { [weak self] in
1760
1997
  guard let self else { return }
1761
- guard self.peripheral === peripheral else { return }
1998
+ guard self.peripheral === peripheral, NimoResourceFixture.shared.allowsProducer(self) else { return }
1762
1999
  if let error {
1763
2000
  self.abortTransport("Notification subscription failed")
1764
2001
  Bridge.log("NIMO: notify enable failed: \(error.localizedDescription)")
@@ -1777,11 +2014,14 @@ extension Nimo: CBPeripheralDelegate {
1777
2014
  nonisolated func peripheral(
1778
2015
  _ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?
1779
2016
  ) {
2017
+ let receivedElapsedMs = NimoResourceClock.now()
1780
2018
  guard let data = characteristic.value, error == nil else { return }
1781
2019
  let uuid = characteristic.uuid
1782
- DispatchQueue.main.async { [weak self] in
2020
+ self.postCallback { [weak self] in
1783
2021
  guard let self else { return }
1784
2022
  guard self.peripheral === peripheral else { return }
2023
+ self.resourceNotificationElapsedMs = receivedElapsedMs
2024
+ defer { self.resourceNotificationElapsedMs = nil }
1785
2025
  if uuid == NimoBLE.CHAR_RX, characteristic === self.rxChar {
1786
2026
  self.handleRxPacket(data)
1787
2027
  } else if uuid == NimoBLE.CHAR_MIC, characteristic === self.micChar {
@@ -1793,7 +2033,7 @@ extension Nimo: CBPeripheralDelegate {
1793
2033
  nonisolated func peripheral(
1794
2034
  _ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?
1795
2035
  ) {
1796
- DispatchQueue.main.async { [weak self] in
2036
+ self.postCallback { [weak self] in
1797
2037
  self?.writes.written(peripheral, characteristic: characteristic, success: error == nil)
1798
2038
  }
1799
2039
  }