@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.
- package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +5 -5
- package/build/_private/BluetoothSdkModule.d.ts +3 -0
- package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
- package/build/_private/BluetoothSdkModule.js.map +1 -1
- package/build/generated/releaseMetadata.js +5 -5
- package/build/generated/releaseMetadata.js.map +1 -1
- package/ios/BluetoothSdkModule.swift +6 -0
- package/ios/Source/BluetoothSdkDefaults.swift +1 -1
- package/ios/Source/DeviceManager.swift +10 -1
- package/ios/Source/GeneratedReleaseMetadata.swift +5 -5
- package/ios/Source/MentraBluetoothSDK.swift +15 -2
- package/ios/Source/sgcs/Nimo.swift +278 -38
- package/ios/Source/sgcs/NimoCanvasCoordinator.swift +77 -18
- package/ios/Source/sgcs/NimoCanvasSession.swift +1 -0
- package/ios/Source/sgcs/NimoMacResourceTransport.swift +217 -0
- package/ios/Source/sgcs/NimoResourceFixture.swift +384 -0
- package/ios/Source/sgcs/NimoResourceSession.swift +406 -0
- package/ios/Tests/DeviceManagerReconnectTests.swift +75 -2
- package/ios/Tests/NimoCanvasTransportTests.swift +147 -0
- package/ios/Tests/NimoResourceSessionTests.swift +476 -0
- package/package.json +2 -2
- package/src/_private/BluetoothSdkModule.ts +3 -0
- package/src/generated/releaseMetadata.ts +5 -5
|
@@ -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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2036
|
+
self.postCallback { [weak self] in
|
|
1797
2037
|
self?.writes.written(peripheral, characteristic: characteristic, success: error == nil)
|
|
1798
2038
|
}
|
|
1799
2039
|
}
|