@mentra/bluetooth-sdk 3.2.1-dev.314 → 3.2.1

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.
Files changed (133) hide show
  1. package/README.md +4 -4
  2. package/android/src/debug/java/com/mentra/bluetoothsdk/sgcs/NimoDiagnostics.kt +368 -0
  3. package/android/src/debug/java/com/mentra/bluetoothsdk/sgcs/NimoFramebufferProtocol.kt +156 -0
  4. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkDefaults.kt +3 -0
  5. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +6 -0
  6. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +2 -1
  7. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +129 -25
  8. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +37 -13
  9. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedChangelogCatalog.kt +2 -2
  10. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +5 -5
  11. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +4 -0
  12. package/android/src/main/java/com/mentra/bluetoothsdk/VersionInfoResponseAccumulator.kt +13 -2
  13. package/android/src/main/java/com/mentra/bluetoothsdk/requests/DisplayRequests.kt +1 -1
  14. package/android/src/main/java/com/mentra/bluetoothsdk/services/Foreground.kt +33 -0
  15. package/android/src/main/java/com/mentra/bluetoothsdk/services/G2ConnectionRecovery.kt +47 -0
  16. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.kt +2 -2
  17. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +72 -21
  18. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +101 -22
  19. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +12 -8
  20. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Nimo.kt +314 -395
  21. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCodec.kt +245 -0
  22. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCoordinator.kt +238 -0
  23. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasEncoder.kt +58 -0
  24. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasSession.kt +180 -0
  25. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +11 -0
  26. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SelectedDeviceAddress.kt +17 -0
  27. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SerializedGattCallback.kt +5 -0
  28. package/android/src/main/java/com/mentra/bluetoothsdk/utils/G2Text.kt +7 -0
  29. package/android/src/release/java/com/mentra/bluetoothsdk/sgcs/NimoDiagnostics.kt +20 -0
  30. package/android/src/test/java/com/mentra/bluetoothsdk/DashboardContentFormatterTest.kt +2 -2
  31. package/android/src/test/java/com/mentra/bluetoothsdk/DeviceIdentityPairingTest.kt +59 -0
  32. package/android/src/test/java/com/mentra/bluetoothsdk/DeviceManagerSceneHandoffTest.kt +666 -0
  33. package/android/src/test/java/com/mentra/bluetoothsdk/G2TextTest.kt +13 -0
  34. package/android/src/test/java/com/mentra/bluetoothsdk/VersionInfoResponseAccumulatorTest.kt +71 -0
  35. package/android/src/test/java/com/mentra/bluetoothsdk/services/G2ConnectionRecoveryTest.kt +51 -0
  36. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G1PairingTest.kt +105 -0
  37. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G2DisplayThreadTest.kt +120 -0
  38. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G2PairingTargetTest.kt +90 -0
  39. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveFileTransferSessionTest.kt +55 -0
  40. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveGattCallbackTest.kt +33 -0
  41. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingTargetTest.kt +65 -0
  42. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraNexPairingTest.kt +151 -0
  43. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoBitmapAcceptanceTest.kt +105 -0
  44. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCodecTest.kt +220 -0
  45. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCoordinatorTest.kt +348 -0
  46. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasSessionTest.kt +339 -0
  47. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoMicReconnectTest.kt +93 -0
  48. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoProtocolTest.kt +11 -0
  49. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/SelectedDeviceAddressTest.kt +27 -0
  50. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoDiagnosticsTest.kt +287 -0
  51. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoFramebufferProtocolTest.kt +127 -0
  52. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoLifecycleTest.kt +288 -0
  53. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoReadinessDriverTest.kt +205 -0
  54. package/build/BluetoothSdk.types.d.ts +13 -0
  55. package/build/BluetoothSdk.types.d.ts.map +1 -1
  56. package/build/BluetoothSdk.types.js +1 -0
  57. package/build/BluetoothSdk.types.js.map +1 -1
  58. package/build/_private/BluetoothSdkModule.d.ts +4 -0
  59. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  60. package/build/_private/BluetoothSdkModule.js +3 -0
  61. package/build/_private/BluetoothSdkModule.js.map +1 -1
  62. package/build/generated/changelogCatalog.d.ts +2 -2
  63. package/build/generated/changelogCatalog.js +2 -2
  64. package/build/generated/changelogCatalog.js.map +1 -1
  65. package/build/generated/releaseMetadata.js +5 -5
  66. package/build/generated/releaseMetadata.js.map +1 -1
  67. package/build/index.d.ts.map +1 -1
  68. package/build/index.js +1 -0
  69. package/build/index.js.map +1 -1
  70. package/ios/BluetoothSdkModule.swift +9 -0
  71. package/ios/MentraBluetoothSDK.podspec +3 -2
  72. package/ios/Source/BluetoothBackgroundMode.swift +32 -0
  73. package/ios/Source/BluetoothSdkDefaults.swift +6 -1
  74. package/ios/Source/Bridge.swift +15 -2
  75. package/ios/Source/DeviceManager.swift +96 -63
  76. package/ios/Source/DeviceStore.swift +26 -7
  77. package/ios/Source/GeneratedChangelogCatalog.swift +2 -2
  78. package/ios/Source/GeneratedReleaseMetadata.swift +5 -5
  79. package/ios/Source/MentraBluetoothSDK.swift +18 -0
  80. package/ios/Source/audio/GlassesMicWatchdog.swift +33 -0
  81. package/ios/Source/requests/DisplayRequests.swift +1 -1
  82. package/ios/Source/requests/VersionInfoResponseAccumulator.swift +11 -1
  83. package/ios/Source/sgcs/Ar99.swift +1 -2
  84. package/ios/Source/sgcs/G1.swift +68 -38
  85. package/ios/Source/sgcs/G1ConnectionTarget.swift +13 -0
  86. package/ios/Source/sgcs/G2.swift +261 -247
  87. package/ios/Source/sgcs/G2ConnectionSession.swift +101 -0
  88. package/ios/Source/sgcs/Mach1.swift +19 -7
  89. package/ios/Source/sgcs/MentraLive.swift +201 -156
  90. package/ios/Source/sgcs/MentraLiveFileTransferSession.swift +137 -0
  91. package/ios/Source/sgcs/MentraLivePairingAdvertisement.swift +47 -0
  92. package/ios/Source/sgcs/MentraNex.swift +8 -31
  93. package/ios/Source/sgcs/NexConnectionTarget.swift +15 -0
  94. package/ios/Source/sgcs/Nimo.swift +280 -408
  95. package/ios/Source/sgcs/NimoCanvasCodec.swift +269 -0
  96. package/ios/Source/sgcs/NimoCanvasCoordinator.swift +233 -0
  97. package/ios/Source/sgcs/NimoCanvasImage.swift +53 -0
  98. package/ios/Source/sgcs/NimoCanvasSession.swift +166 -0
  99. package/ios/Source/sgcs/NimoDiscovery.swift +49 -0
  100. package/ios/Source/sgcs/SGCManager.swift +24 -0
  101. package/ios/Source/sgcs/VuzixConnectionTarget.swift +15 -0
  102. package/ios/Source/status/DeviceStatus.swift +1 -0
  103. package/ios/Source/stt/SherpaOnnxTranscriber.swift +49 -189
  104. package/ios/Source/utils/DemandDrivenAudioWorker.swift +150 -0
  105. package/ios/Source/utils/G2Text.swift +11 -0
  106. package/ios/Tests/BluetoothBackgroundModeTests.swift +32 -0
  107. package/ios/Tests/DashboardContentFormatterTests.swift +2 -2
  108. package/ios/Tests/DashboardRenderingTests.swift +31 -0
  109. package/ios/Tests/DemandDrivenAudioWorkerTests.swift +157 -0
  110. package/ios/Tests/DeviceIdentityPairingTests.swift +49 -0
  111. package/ios/Tests/DeviceManagerReconnectTests.swift +362 -0
  112. package/ios/Tests/G1PairingTests.swift +162 -0
  113. package/ios/Tests/G2ConnectionSessionTests.swift +172 -0
  114. package/ios/Tests/G2TextTests.swift +10 -0
  115. package/ios/Tests/MentraLiveFileTransferSessionTests.swift +47 -0
  116. package/ios/Tests/MentraLiveReconnectPolicyTests.swift +58 -0
  117. package/ios/Tests/NimoCanvasCodecTests.swift +204 -0
  118. package/ios/Tests/NimoCanvasSessionTests.swift +181 -0
  119. package/ios/Tests/NimoCanvasTransportTests.swift +186 -0
  120. package/ios/Tests/NimoDeviceCommandsTests.swift +16 -0
  121. package/ios/Tests/NimoDiscoveryTests.swift +79 -0
  122. package/ios/Tests/SGCConnectionTargetTests.swift +48 -0
  123. package/ios/Tests/VersionInfoCommandTests.swift +53 -0
  124. package/ios/Tests/VersionInfoResponseAccumulatorTests.swift +65 -0
  125. package/ios/Tests/VideoRecordingCommandTests.swift +81 -0
  126. package/package.json +2 -2
  127. package/plugin/build/withIos.d.ts +2 -0
  128. package/plugin/build/withIos.js +12 -1
  129. package/src/BluetoothSdk.types.ts +14 -0
  130. package/src/_private/BluetoothSdkModule.ts +8 -0
  131. package/src/generated/changelogCatalog.ts +2 -2
  132. package/src/generated/releaseMetadata.ts +5 -5
  133. package/src/index.ts +1 -0
@@ -0,0 +1,150 @@
1
+ import Foundation
2
+
3
+ /// Owns a processor on one serial queue. Empty queues schedule no recurring work.
4
+ /// Demand changes invalidate queued audio/results immediately; creation, processing,
5
+ /// and destruction finish in order without blocking the caller (including the UI).
6
+ final class DemandDrivenAudioWorker<Processor> {
7
+ struct Snapshot {
8
+ let active: Bool
9
+ let initialized: Bool
10
+ let processing: Bool
11
+ let queuedBuffers: Int
12
+ let processedBuffers: UInt64
13
+ let droppedBuffers: UInt64
14
+ }
15
+
16
+ private let lock = NSLock()
17
+ private let queue: DispatchQueue
18
+ private let capacity: Int
19
+ private let makeProcessor: () -> Processor?
20
+ private let process: (Processor, Data, UInt64) -> Void
21
+ private var active = false
22
+ private var generation: UInt64 = 0
23
+ private var buffers = [Data]()
24
+ private var scheduled = false
25
+ private var initialized = false
26
+ private var processing = false
27
+ private var processedBuffers: UInt64 = 0
28
+ private var droppedBuffers: UInt64 = 0
29
+
30
+ // Accessed only on queue, including release of the previous processor.
31
+ private var processor: Processor?
32
+ private var processorGeneration: UInt64?
33
+ private var attemptedInitialization = false
34
+
35
+ init(capacity: Int = 100,
36
+ queue: DispatchQueue = DispatchQueue(label: "com.mentra.speech.processor", qos: .userInitiated),
37
+ makeProcessor: @escaping () -> Processor?,
38
+ process: @escaping (Processor, Data, UInt64) -> Void)
39
+ {
40
+ precondition(capacity > 0)
41
+ self.capacity = capacity
42
+ self.queue = queue
43
+ self.makeProcessor = makeProcessor
44
+ self.process = process
45
+ }
46
+
47
+ func setActive(_ value: Bool) {
48
+ lock.lock()
49
+ defer { lock.unlock() }
50
+ guard active != value else { return }
51
+ active = value
52
+ invalidateLocked()
53
+ }
54
+
55
+ /// Reload a changed model only if there is still local-transcription demand.
56
+ func restart() {
57
+ lock.lock()
58
+ defer { lock.unlock() }
59
+ invalidateLocked()
60
+ }
61
+
62
+ private func invalidateLocked() {
63
+ generation &+= 1
64
+ buffers.removeAll(keepingCapacity: true)
65
+ initialized = false
66
+ scheduleLocked()
67
+ }
68
+
69
+ func accept(_ data: Data) {
70
+ guard !data.isEmpty else { return }
71
+ lock.lock()
72
+ defer { lock.unlock() }
73
+ guard active else { return }
74
+ // Bound even audio arriving while model loading or decoding is blocked.
75
+ if buffers.count == capacity {
76
+ buffers.removeFirst()
77
+ droppedBuffers &+= 1
78
+ }
79
+ buffers.append(data)
80
+ scheduleLocked()
81
+ }
82
+
83
+ func isCurrent(_ token: UInt64) -> Bool {
84
+ lock.lock()
85
+ defer { lock.unlock() }
86
+ return active && generation == token
87
+ }
88
+
89
+ func snapshot() -> Snapshot {
90
+ lock.lock()
91
+ defer { lock.unlock() }
92
+ return Snapshot(active: active, initialized: initialized, processing: processing,
93
+ queuedBuffers: buffers.count, processedBuffers: processedBuffers,
94
+ droppedBuffers: droppedBuffers)
95
+ }
96
+
97
+ private func scheduleLocked() {
98
+ guard !scheduled else { return }
99
+ scheduled = true
100
+ queue.async { self.drain() }
101
+ }
102
+
103
+ private func drain() {
104
+ while true {
105
+ lock.lock()
106
+ let token = generation
107
+ if processorGeneration != token {
108
+ lock.unlock()
109
+ // ARC destruction stays serialized with inference and model loading.
110
+ processor = nil
111
+ processorGeneration = token
112
+ attemptedInitialization = false
113
+ continue
114
+ }
115
+ guard active else {
116
+ scheduled = false
117
+ lock.unlock()
118
+ return
119
+ }
120
+ if !attemptedInitialization {
121
+ attemptedInitialization = true
122
+ lock.unlock()
123
+ processor = makeProcessor()
124
+ lock.lock()
125
+ if generation == token { initialized = processor != nil }
126
+ lock.unlock()
127
+ continue
128
+ }
129
+ guard let processor else {
130
+ buffers.removeAll(keepingCapacity: true)
131
+ scheduled = false
132
+ lock.unlock()
133
+ return // Missing model: retry on a demand transition or model restart.
134
+ }
135
+ guard !buffers.isEmpty else {
136
+ scheduled = false
137
+ lock.unlock()
138
+ return
139
+ }
140
+ let data = buffers.removeFirst()
141
+ processing = true
142
+ lock.unlock()
143
+ process(processor, data, token)
144
+ lock.lock()
145
+ processedBuffers &+= 1
146
+ processing = false
147
+ lock.unlock()
148
+ }
149
+ }
150
+ }
@@ -0,0 +1,11 @@
1
+ import Foundation
2
+
3
+ /// Empty interior rows shut down some EvenHub firmware pages. Keep the row
4
+ /// without exposing this protocol workaround to miniapps or other devices.
5
+ enum G2Text {
6
+ static func containerContent(_ text: String) -> String {
7
+ if text.isEmpty { return " " }
8
+ return text.components(separatedBy: "\n")
9
+ .map { $0.isEmpty ? "\u{200B}" : $0 }.joined(separator: "\n")
10
+ }
11
+ }
@@ -0,0 +1,32 @@
1
+ @testable import MentraBluetoothSDK
2
+ import XCTest
3
+
4
+ final class BluetoothBackgroundModeTests: XCTestCase {
5
+ func testDetectsDeclaredBluetoothCentral() {
6
+ XCTAssertTrue(
7
+ BluetoothBackgroundMode.declaresBluetoothCentral(
8
+ ["UIBackgroundModes": ["audio", "bluetooth-central"]]
9
+ )
10
+ )
11
+ }
12
+
13
+ func testReportsMissingOrMalformedBackgroundModes() {
14
+ XCTAssertFalse(BluetoothBackgroundMode.declaresBluetoothCentral(nil))
15
+ XCTAssertFalse(BluetoothBackgroundMode.declaresBluetoothCentral([:]))
16
+ XCTAssertFalse(BluetoothBackgroundMode.declaresBluetoothCentral(["UIBackgroundModes": ["audio"]]))
17
+ XCTAssertFalse(
18
+ BluetoothBackgroundMode.declaresBluetoothCentral(["UIBackgroundModes": "bluetooth-central"])
19
+ )
20
+ }
21
+
22
+ func testMacOSHasNoBackgroundModeRequirement() {
23
+ #if os(macOS)
24
+ XCTAssertTrue(BluetoothBackgroundMode.isDeclared(in: nil))
25
+ #endif
26
+ }
27
+
28
+ func testWarningPointsToTheDocs() {
29
+ XCTAssertTrue(BluetoothBackgroundMode.missingWarning.contains("bluetooth-central"))
30
+ XCTAssertTrue(BluetoothBackgroundMode.missingWarning.contains("#background-operation"))
31
+ }
32
+ }
@@ -5,14 +5,14 @@ final class DashboardContentFormatterTests: XCTestCase {
5
5
  func testEmptyContentReturnsStatusHeaderOnly() {
6
6
  XCTAssertEqual(
7
7
  DashboardContentFormatter.template(for: ""),
8
- "$TIME12$ $DATE$ $GBATT$ $CONNECTION_STATUS$"
8
+ "$TIME12$ $DATE$ $GBATT$"
9
9
  )
10
10
  }
11
11
 
12
12
  func testNonEmptyContentIsAppendedExactlyAfterBlankLine() {
13
13
  XCTAssertEqual(
14
14
  DashboardContentFormatter.template(for: " Next meeting\nRoom 2 "),
15
- "$TIME12$ $DATE$ $GBATT$ $CONNECTION_STATUS$\n\n Next meeting\nRoom 2 "
15
+ "$TIME12$ $DATE$ $GBATT$\n\n Next meeting\nRoom 2 "
16
16
  )
17
17
  }
18
18
  }
@@ -15,6 +15,36 @@ final class DashboardRenderingTests: XCTestCase {
15
15
  return (manager, display)
16
16
  }
17
17
 
18
+ func testFullFrameDashboardHandoffDoesNotClearActiveScene() async {
19
+ let (manager, display) = setupDashboard()
20
+ display.sceneHandoffRequiresClear = false
21
+ await manager.setDashboardContent("new dashboard")
22
+ XCTAssertEqual(display.cleanupCount, 0)
23
+ XCTAssertEqual(display.clearDisplayCount, 0)
24
+ XCTAssertEqual(display.texts.count, 1)
25
+ XCTAssertTrue(display.texts[0].hasSuffix("new dashboard"))
26
+ }
27
+
28
+ func testHiddenSceneHandoffCannotClearMainView() async {
29
+ let (manager, display) = setupDashboard()
30
+ DeviceStore.shared.apply("glasses", "headUp", false)
31
+ manager.displayEvent([
32
+ "view": "dashboard",
33
+ "scene": ["appId": "replacement", "sceneEpoch": 2, "elements": [[String: Any]]()],
34
+ ])
35
+ await Task.yield()
36
+ XCTAssertEqual(display.cleanupCount, 0)
37
+ XCTAssertEqual(display.clearDisplayCount, 0)
38
+ XCTAssertTrue(display.scenes.isEmpty)
39
+ }
40
+
41
+ func testNimoOptsOutOfDestructiveTransientOverlays() {
42
+ let device: SGCManager = Nimo()
43
+ XCTAssertFalse(device.sceneHandoffRequiresClear)
44
+ XCTAssertFalse(device.showBrightnessConfirmation)
45
+ XCTAssertFalse(device.showConnectionConfirmation)
46
+ }
47
+
18
48
  func testSetterWaitsForCleanupAndLatestContentWins() async {
19
49
  let (manager, display) = setupDashboard()
20
50
  var firstReturned = false
@@ -74,6 +104,7 @@ final class DashboardRenderingTests: XCTestCase {
74
104
  private final class PausedDisplay: SGCManager {
75
105
  var type = "Test display"
76
106
  let hasMic = false
107
+ var sceneHandoffRequiresClear = true
77
108
  let cleanupStarted = XCTestExpectation(description: "cleanup suspended")
78
109
  var cleanupCount = 0
79
110
  var clearDisplayCount = 0
@@ -0,0 +1,157 @@
1
+ import Foundation
2
+ @testable import MentraBluetoothSDK
3
+ import XCTest
4
+
5
+ final class DemandDrivenAudioWorkerTests: XCTestCase {
6
+ private final class Processor {
7
+ let onRelease: () -> Void
8
+ init(onRelease: @escaping () -> Void = {}) {
9
+ self.onRelease = onRelease
10
+ }
11
+
12
+ deinit { onRelease() }
13
+ }
14
+
15
+ func testNoDemandAndEmptyActiveQueueScheduleNoRecurringWork() {
16
+ let queue = DispatchQueue(label: "test.speech")
17
+ var creations = 0
18
+ var processed = [Data]()
19
+ let worker = DemandDrivenAudioWorker(queue: queue, makeProcessor: {
20
+ creations += 1
21
+ return Processor()
22
+ }, process: { _, data, _ in processed.append(data) })
23
+ worker.accept(Data([1]))
24
+ queue.sync {}
25
+ XCTAssertEqual(creations, 0)
26
+ worker.setActive(true)
27
+ worker.setActive(true)
28
+ queue.sync {}
29
+ XCTAssertEqual(creations, 1)
30
+ XCTAssertTrue(worker.snapshot().initialized)
31
+ XCTAssertFalse(worker.snapshot().processing)
32
+ // A serial barrier completes with an empty queue; no sleeping consumer holds it.
33
+ queue.sync {}
34
+ XCTAssertTrue(processed.isEmpty)
35
+ worker.setActive(false)
36
+ queue.sync {}
37
+ XCTAssertFalse(worker.snapshot().initialized)
38
+ }
39
+
40
+ func testFirstAudioIsRetainedDuringAsynchronousInitializationAndBounded() {
41
+ let queue = DispatchQueue(label: "test.speech")
42
+ let loading = DispatchSemaphore(value: 0)
43
+ let release = DispatchSemaphore(value: 0)
44
+ var processed = [Data]()
45
+ let worker = DemandDrivenAudioWorker(capacity: 3, queue: queue, makeProcessor: {
46
+ XCTAssertFalse(Thread.isMainThread)
47
+ loading.signal()
48
+ release.wait()
49
+ return Processor()
50
+ }, process: { _, data, _ in processed.append(data) })
51
+ worker.setActive(true)
52
+ XCTAssertEqual(loading.wait(timeout: .now() + 2), .success)
53
+ worker.accept(Data([1]))
54
+ worker.accept(Data([2]))
55
+ XCTAssertEqual(worker.snapshot().queuedBuffers, 2)
56
+ release.signal()
57
+ queue.sync {}
58
+ XCTAssertEqual(processed, [Data([1]), Data([2])])
59
+ worker.setActive(false)
60
+ queue.sync {}
61
+ }
62
+
63
+ func testStopDuringLoadingDropsOldAudioAndCannotResurrectProcessor() {
64
+ let queue = DispatchQueue(label: "test.speech")
65
+ let loading = DispatchSemaphore(value: 0)
66
+ let release = DispatchSemaphore(value: 0)
67
+ var released = 0
68
+ var processed = 0
69
+ let worker = DemandDrivenAudioWorker(capacity: 2, queue: queue, makeProcessor: {
70
+ loading.signal()
71
+ release.wait()
72
+ return Processor { released += 1 }
73
+ }, process: { _, _, _ in processed += 1 })
74
+ worker.setActive(true)
75
+ XCTAssertEqual(loading.wait(timeout: .now() + 2), .success)
76
+ for byte: UInt8 in 1 ... 10 {
77
+ worker.accept(Data([byte]))
78
+ }
79
+ XCTAssertEqual(worker.snapshot().queuedBuffers, 2)
80
+ XCTAssertEqual(worker.snapshot().droppedBuffers, 8)
81
+ worker.setActive(false)
82
+ XCTAssertEqual(worker.snapshot().queuedBuffers, 0)
83
+ release.signal()
84
+ queue.sync {}
85
+ XCTAssertEqual(processed, 0)
86
+ XCTAssertEqual(released, 1)
87
+ XCTAssertFalse(worker.snapshot().initialized)
88
+ }
89
+
90
+ func testRestartAndRapidDemandChangesSerializeOldAndNewProcessors() {
91
+ let queue = DispatchQueue(label: "test.speech")
92
+ let processing = DispatchSemaphore(value: 0)
93
+ let release = DispatchSemaphore(value: 0)
94
+ var creations = 0
95
+ var destructions = 0
96
+ var tokens = [UInt64]()
97
+ var dataSeen = [Data]()
98
+ let worker = DemandDrivenAudioWorker(queue: queue, makeProcessor: {
99
+ XCTAssertEqual(creations, destructions)
100
+ creations += 1
101
+ return Processor { destructions += 1 }
102
+ }, process: { _, data, token in
103
+ dataSeen.append(data)
104
+ tokens.append(token)
105
+ if data == Data([1]) {
106
+ processing.signal()
107
+ release.wait()
108
+ }
109
+ })
110
+ worker.setActive(true)
111
+ worker.accept(Data([1]))
112
+ XCTAssertEqual(processing.wait(timeout: .now() + 2), .success)
113
+ worker.accept(Data([2]))
114
+ worker.restart()
115
+ worker.setActive(false)
116
+ worker.setActive(true)
117
+ worker.accept(Data([3]))
118
+ release.signal()
119
+ queue.sync {}
120
+ XCTAssertEqual(dataSeen, [Data([1]), Data([3])])
121
+ XCTAssertFalse(worker.isCurrent(tokens[0]))
122
+ XCTAssertTrue(worker.isCurrent(tokens[1]))
123
+ XCTAssertEqual(creations, 2)
124
+ worker.setActive(false)
125
+ queue.sync {}
126
+ XCTAssertEqual(destructions, 2)
127
+ XCTAssertFalse(worker.isCurrent(tokens[1]))
128
+ }
129
+
130
+ func testMissingModelDoesNotRetryEveryFrameAndRestartWithoutDemandDoesNotLoad() {
131
+ let queue = DispatchQueue(label: "test.speech")
132
+ var attempts = 0
133
+ let worker = DemandDrivenAudioWorker<Processor>(queue: queue, makeProcessor: {
134
+ attempts += 1
135
+ return nil
136
+ }, process: { _, _, _ in XCTFail("Missing processor") })
137
+ worker.restart()
138
+ queue.sync {}
139
+ XCTAssertEqual(attempts, 0)
140
+ worker.setActive(true)
141
+ for _ in 0 ..< 5 {
142
+ worker.accept(Data([1]))
143
+ queue.sync {}
144
+ }
145
+ XCTAssertEqual(attempts, 1)
146
+ XCTAssertEqual(worker.snapshot().queuedBuffers, 0)
147
+ worker.restart()
148
+ queue.sync {}
149
+ XCTAssertEqual(attempts, 2)
150
+ worker.setActive(false)
151
+ worker.setActive(true)
152
+ queue.sync {}
153
+ XCTAssertEqual(attempts, 3)
154
+ worker.setActive(false)
155
+ queue.sync {}
156
+ }
157
+ }
@@ -0,0 +1,49 @@
1
+ @testable import MentraBluetoothSDK
2
+ import XCTest
3
+
4
+ @MainActor
5
+ final class DeviceIdentityPairingTests: XCTestCase {
6
+ func testReadinessPromotesCoherentIdentityAndPreservesSameDeviceReconnects() {
7
+ let store = DeviceStore.shared.store
8
+ let saved = ["bluetooth", "glasses"].map { ($0, store.getCategory($0)) }
9
+ defer {
10
+ for (category, values) in saved {
11
+ for key in store.getCategory(category).keys where values[key] == nil {
12
+ store.remove(category, key)
13
+ }
14
+ for (key, value) in values {
15
+ store.set(category, key, value)
16
+ }
17
+ }
18
+ }
19
+ let cases = [
20
+ // pending name, pending address, saved model, expected address
21
+ ("new", "", "test", ""),
22
+ ("new", "new-address", "test", "new-address"),
23
+ ("old", "", "test", "old-address"),
24
+ ("", "", "test", "old-address"),
25
+ ("old", "", "other-model", ""),
26
+ ]
27
+ for (pendingName, pendingAddress, savedModel, expectedAddress) in cases {
28
+ let manager = DeviceManager()
29
+ let device = Simulated()
30
+ device.type = "test"
31
+ manager.sgc = device
32
+ defer {
33
+ manager.disconnect()
34
+ manager.cleanup()
35
+ }
36
+ store.set("bluetooth", "default_wearable", savedModel)
37
+ store.set("bluetooth", "device_name", "old")
38
+ store.set("bluetooth", "device_address", "old-address")
39
+ store.set("bluetooth", "pending_device_name", pendingName)
40
+ store.set("bluetooth", "pending_device_address", pendingAddress)
41
+ store.set("bluetooth", "shouldSendBootingMessage", false)
42
+ manager.handleDeviceReady()
43
+ XCTAssertEqual(DeviceStore.shared.get("bluetooth", "device_name") as? String, pendingName.isEmpty ? "old" : pendingName)
44
+ XCTAssertEqual(DeviceStore.shared.get("bluetooth", "device_address") as? String, expectedAddress)
45
+ XCTAssertEqual(DeviceStore.shared.get("bluetooth", "pending_device_name") as? String, "")
46
+ XCTAssertEqual(DeviceStore.shared.get("bluetooth", "pending_device_address") as? String, "")
47
+ }
48
+ }
49
+ }