@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,186 @@
1
+ @testable import MentraBluetoothSDK
2
+ import XCTest
3
+
4
+ private final class NimoTestClock {
5
+ private final class Job {
6
+ let time: TimeInterval
7
+ let task: () -> Void
8
+ var cancelled = false
9
+ init(_ time: TimeInterval, _ task: @escaping () -> Void) {
10
+ self.time = time; self.task = task
11
+ }
12
+ }
13
+
14
+ private var now: TimeInterval = 0
15
+ private var jobs: [Job] = []
16
+ lazy var schedule: NimoScheduler = { [unowned self] delay, task in
17
+ let job = Job(self.now + delay, task)
18
+ self.jobs.append(job)
19
+ return { job.cancelled = true }
20
+ }
21
+
22
+ func advance(_ seconds: TimeInterval = 0) {
23
+ let end = now + seconds
24
+ while let job = jobs.filter({ !$0.cancelled && $0.time <= end }).min(by: { $0.time < $1.time }) {
25
+ jobs.removeAll { $0 === job }
26
+ now = job.time
27
+ job.task()
28
+ }
29
+ now = end
30
+ }
31
+ }
32
+
33
+ final class NimoCanvasTransportTests: XCTestCase {
34
+ private let frame = Data([0, 0, 1])
35
+
36
+ func testNotReadyProbesAreTimedBoundedAndCancelled() {
37
+ for stop in ["budget", "disconnect", "exit", "notReady"] {
38
+ let clock = NimoTestClock()
39
+ var keys: [Int] = []
40
+ let canvas = NimoCanvasCoordinator(schedule: clock.schedule, writeCapacity: { 512 }, enqueue: { frames, start, complete in
41
+ keys.append(Int(frames[0][9])); start(); complete(); return true
42
+ }, reconnect: { XCTFail($0) }, rejected: { _ in })
43
+ canvas.activate(); canvas.readiness(true)
44
+ for attempt in 0 ..< 4 {
45
+ canvas.response(key: 1, payload: Data([7]))
46
+ if stop == "disconnect" { canvas.disconnected() }
47
+ if stop == "exit" { canvas.exit() }
48
+ if stop == "notReady" { canvas.readiness(false) }
49
+ let before = keys.count
50
+ clock.advance(0.999)
51
+ XCTAssertEqual(keys.count, before)
52
+ clock.advance(0.001)
53
+ XCTAssertEqual(keys.count, before + (stop == "budget" && attempt < 3 ? 1 : 0))
54
+ if stop != "budget" { break }
55
+ }
56
+ clock.advance(60)
57
+ XCTAssertEqual(keys.count, stop == "budget" ? 4 : 1)
58
+ }
59
+ }
60
+
61
+ func testEarlyAckOnlyAdvancesAfterFinalWriteCompletes() {
62
+ let clock = NimoTestClock()
63
+ var chains: [[Data]] = [], starts: [() -> Void] = [], completes: [() -> Void] = []
64
+ let canvas = NimoCanvasCoordinator(schedule: clock.schedule, writeCapacity: { 20 }, enqueue: { frames, start, complete in
65
+ chains.append(frames); starts.append(start); completes.append(complete); return true
66
+ }, reconnect: { XCTFail($0) }, rejected: { XCTFail("Unexpected rejection \($0)") })
67
+ canvas.offer(frame, scope: "app")
68
+ canvas.readiness(true)
69
+ canvas.response(key: 1, payload: Data([0, 0xFD])) // Before final fragment: ignore.
70
+ starts[0]()
71
+ XCTAssertEqual(chains.count, 1)
72
+ canvas.response(key: 1, payload: Data([0, 0xFD])) // Before GATT callback: buffer.
73
+ XCTAssertEqual(chains.count, 1)
74
+ completes[0]()
75
+ XCTAssertEqual(chains.count, 2)
76
+ XCTAssertEqual(chains[1][0][9], 4)
77
+ starts[1](); completes[1]()
78
+ canvas.response(key: 4, payload: Data([0, 0xFD, 0, 0, 0]))
79
+ clock.advance(60)
80
+ }
81
+
82
+ func testTimeoutAndOldCompletionCannotAdvanceNewGeneration() {
83
+ let clock = NimoTestClock()
84
+ var completions: [() -> Void] = [], reasons: [String] = [], count = 0
85
+ let canvas = NimoCanvasCoordinator(schedule: clock.schedule, writeCapacity: { 512 }, enqueue: { _, start, complete in
86
+ count += 1; start(); completions.append(complete); return true
87
+ }, reconnect: { reasons.append($0) }, rejected: { _ in })
88
+ canvas.offer(frame, scope: "app"); canvas.readiness(true)
89
+ clock.advance(45)
90
+ XCTAssertEqual(reasons.count, 1)
91
+ canvas.readiness(true)
92
+ completions[0]()
93
+ canvas.response(key: 1, payload: Data([0, 0xFD]))
94
+ XCTAssertEqual(count, 2) // ACK buffered for the new flight until its own write completes.
95
+ completions[1]()
96
+ XCTAssertEqual(count, 3)
97
+ canvas.disconnected()
98
+ }
99
+
100
+ func testQueueWatchdogRetiresRatherThanSkippingFragment() {
101
+ let clock = NimoTestClock(), connection = NSObject(), characteristic = NSObject()
102
+ var bytes: [Data] = [], failures: [String] = []
103
+ let queue = NimoWriteQueue<NSObject>(schedule: clock.schedule, write: { _, data in bytes.append(data); return true }, failure: { failures.append($0) })
104
+ queue.connected(connection)
105
+ queue.enqueue(connection, characteristic: characteristic, frames: [Data([1]), Data([2])])
106
+ clock.advance(3)
107
+ XCTAssertEqual(bytes, [Data([1])]); XCTAssertEqual(failures, ["GATT write timed out"])
108
+ queue.written(connection, characteristic: characteristic, success: true)
109
+ clock.advance(1)
110
+ XCTAssertEqual(bytes.count, 1)
111
+ }
112
+
113
+ func testQueueRejectsOldPeripheralAndCharacteristicAndPacesWrites() {
114
+ let clock = NimoTestClock(), old = NSObject(), current = NSObject(), characteristic = NSObject()
115
+ var count = 0, completed = 0
116
+ let queue = NimoWriteQueue<NSObject>(schedule: clock.schedule, write: { _, _ in count += 1; return true }, failure: { XCTFail($0) })
117
+ queue.connected(old)
118
+ queue.enqueue(old, characteristic: characteristic, frames: [Data([0])])
119
+ queue.connected(current)
120
+ queue.enqueue(current, characteristic: characteristic, frames: [Data([1]), Data([2])], completed: { completed += 1 })
121
+ queue.written(old, characteristic: characteristic, success: true)
122
+ queue.written(current, characteristic: NSObject(), success: true)
123
+ clock.advance(0.01)
124
+ XCTAssertEqual(count, 2)
125
+ queue.written(current, characteristic: characteristic, success: true)
126
+ clock.advance(0.004)
127
+ XCTAssertEqual(count, 2)
128
+ clock.advance(0.001)
129
+ XCTAssertEqual(count, 3)
130
+ queue.written(current, characteristic: characteristic, success: true)
131
+ clock.advance(0.005)
132
+ XCTAssertEqual(completed, 1)
133
+ clock.advance(10)
134
+ }
135
+
136
+ func testGattErrorAbortsRemainingChain() {
137
+ let clock = NimoTestClock(), connection = NSObject(), characteristic = NSObject()
138
+ var count = 0, failures = 0
139
+ let queue = NimoWriteQueue<NSObject>(schedule: clock.schedule, write: { _, _ in count += 1; return true }, failure: { _ in failures += 1 })
140
+ queue.connected(connection)
141
+ queue.enqueue(connection, characteristic: characteristic, frames: [Data([1]), Data([2])])
142
+ queue.written(connection, characteristic: characteristic, success: false)
143
+ clock.advance(4)
144
+ XCTAssertEqual(count, 1); XCTAssertEqual(failures, 1)
145
+ }
146
+
147
+ func testLatestOnlyEncoderAndExitFenceRunningResults() {
148
+ let main = NimoTestClock(), worker = NimoTestClock()
149
+ var delivered: [Data] = [], encoded = 0
150
+ let encoder = NimoCanvasEncoder(main: main.schedule, worker: worker.schedule, failure: { XCTFail("\($0)") })
151
+ encoder.submit(encode: { encoded += 1; return Data([1]) }, deliver: { delivered.append($0) })
152
+ encoder.submit(encode: { encoded += 1; return Data([2]) }, deliver: { delivered.append($0) })
153
+ worker.advance()
154
+ XCTAssertEqual(encoded, 1)
155
+ encoder.invalidate() // Exit/disconnect while the finished encode awaits Main.
156
+ main.advance()
157
+ XCTAssertTrue(delivered.isEmpty)
158
+ encoder.submit(encode: { Data([3]) }, deliver: { delivered.append($0) })
159
+ worker.advance(); main.advance()
160
+ XCTAssertEqual(delivered, [Data([3])])
161
+ }
162
+
163
+ func testCompleteSceneThroughWriteQueueAndBusinessAcks() throws {
164
+ let clock = NimoTestClock(), connection = NSObject(), characteristic = NSObject()
165
+ var written: [Data] = []
166
+ let queue = NimoWriteQueue<NSObject>(schedule: clock.schedule, write: { _, data in written.append(data); return true }, failure: { XCTFail($0) })
167
+ queue.connected(connection)
168
+ let canvas = NimoCanvasCoordinator(schedule: clock.schedule, writeCapacity: { 20 }, enqueue: { frames, started, completed in
169
+ queue.enqueue(connection, characteristic: characteristic, frames: frames, finalStarted: started, completed: completed)
170
+ }, reconnect: { XCTFail($0) }, rejected: { XCTFail("Rejected \($0)") })
171
+ let content = try NimoCanvasCodec.replace([NimoCanvasCodec.label("Captions", 0, 0, 500, 20)])
172
+ canvas.offer(content, scope: "captions:1"); canvas.readiness(true)
173
+ queue.written(connection, characteristic: characteristic, success: true); clock.advance(0.005)
174
+ let launchAck = Data([0, 0xFD])
175
+ canvas.response(key: 1, payload: launchAck)
176
+ let expected = try NimoCanvasCodec.frames(key: 4, content: content, writeCapacity: 20)
177
+ for _ in expected {
178
+ queue.written(connection, characteristic: characteristic, success: true); clock.advance(0.005)
179
+ }
180
+ XCTAssertEqual(Array(written.dropFirst()), expected)
181
+ canvas.response(key: 4, payload: Data([0, 0xFD, 0, 0, 0]))
182
+ canvas.offer(content, scope: "captions:1")
183
+ XCTAssertEqual(written.count, expected.count + 1)
184
+ canvas.disconnected(); queue.reset()
185
+ }
186
+ }
@@ -0,0 +1,16 @@
1
+ import Foundation
2
+ @testable import MentraBluetoothSDK
3
+ import XCTest
4
+
5
+ final class NimoDeviceCommandsTests: XCTestCase {
6
+ func testSystemEnglishAndFactoryResetMatchFirmwareWireCommands() {
7
+ func hex(_ bytes: Data) -> String {
8
+ bytes.map { String(format: "%02X", $0) }.joined()
9
+ }
10
+ XCTAssertEqual(hex(NimoFrameCodec.encodeFrame(cmd: NimoProtocol.CMD_SET_PARAMETER,
11
+ key: NimoProtocol.SET_SYSTEM_LANGUAGE, payload: Data([NimoProtocol.LANGUAGE_ENGLISH]))),
12
+ "BF020500702500000324010001")
13
+ XCTAssertEqual(hex(NimoFrameCodec.encodeFrame(cmd: NimoProtocol.CMD_CONTROL_FACTORY,
14
+ key: NimoProtocol.FACTORY_RECOVER, payload: Data())), "BF0204005358000008030000")
15
+ }
16
+ }
@@ -0,0 +1,79 @@
1
+ @testable import MentraBluetoothSDK
2
+ import XCTest
3
+
4
+ @MainActor
5
+ final class NimoDiscoveryTests: XCTestCase {
6
+ @MainActor
7
+ private final class Radio {
8
+ var connected: [String] = []
9
+ var events: [String] = []
10
+ var found: [String] = []
11
+ var selected: String?
12
+ lazy var discovery: NimoDiscovery<String> = NimoDiscovery(
13
+ register: { [unowned self] in self.events.append("register") },
14
+ connected: { [unowned self] in self.events.append("retrieve"); return self.connected },
15
+ scan: { [unowned self] in self.events.append("scan") },
16
+ stop: { [unowned self] in self.events.append("stop") },
17
+ name: { $0 },
18
+ found: { [unowned self] _, name, _ in
19
+ found.append(name)
20
+ if selected == name { discovery.cancel() }
21
+ }
22
+ )
23
+ }
24
+
25
+ func testAlreadyConnectedClassicDeviceIsFoundWithoutAdvertisement() {
26
+ let radio = Radio()
27
+ radio.connected = ["NIMO_1234", "NIMO_1234_BLE", "Unrelated"]
28
+ radio.discovery.start()
29
+ XCTAssertEqual(radio.events, ["register", "retrieve", "scan"])
30
+ XCTAssertEqual(radio.found, ["NIMO_1234"])
31
+ }
32
+
33
+ func testPairingInSettingsAfterScanStartedDeliversDevice() {
34
+ let radio = Radio()
35
+ radio.discovery.start()
36
+ XCTAssertTrue(radio.found.isEmpty)
37
+ // Same entry point as connectionEventDidOccur(.peerConnected).
38
+ radio.discovery.receive("NIMO_5678")
39
+ XCTAssertEqual(radio.found, ["NIMO_5678"])
40
+ }
41
+
42
+ func testSelectionFromConnectedSnapshotStopsBeforeStartingBleScan() {
43
+ let radio = Radio()
44
+ radio.selected = "NIMO_5678"
45
+ radio.connected = ["NIMO_1234", "NIMO_5678", "NIMO_9999"]
46
+ radio.discovery.start()
47
+ XCTAssertEqual(radio.found, ["NIMO_1234", "NIMO_5678"])
48
+ XCTAssertEqual(radio.events, ["register", "retrieve", "stop"])
49
+ XCTAssertFalse(radio.discovery.active)
50
+ }
51
+
52
+ func testCancelledDiscoveryIgnoresQueuedEventsAndCanRestart() {
53
+ let radio = Radio()
54
+ radio.discovery.start()
55
+ radio.discovery.cancel()
56
+ radio.discovery.receive("NIMO_1234")
57
+ XCTAssertTrue(radio.found.isEmpty)
58
+ radio.connected = ["NIMO_1234"]
59
+ radio.discovery.start()
60
+ XCTAssertEqual(radio.found, ["NIMO_1234"])
61
+ }
62
+
63
+ func testBleFallbackUsesAdvertisementNameAndPreservesSignalStrength() {
64
+ var found: [String] = []
65
+ var signal: Int?
66
+ let discovery = NimoDiscovery<String>(
67
+ register: {}, connected: { [] }, scan: {}, stop: {}, name: { _ in nil },
68
+ found: { _, name, rssi in found.append(name); signal = rssi }
69
+ )
70
+ discovery.start()
71
+ discovery.receive("uuid", advertisedName: "NiMo_1234_bLe", rssi: -60)
72
+ discovery.receive("uuid", advertisedName: "Unrelated", rssi: -60)
73
+ discovery.receive("uuid")
74
+ XCTAssertTrue(found.isEmpty)
75
+ discovery.receive("uuid", advertisedName: "NiMo_1234", rssi: -55)
76
+ XCTAssertEqual(found, ["NiMo_1234"])
77
+ XCTAssertEqual(signal, -55)
78
+ }
79
+ }
@@ -0,0 +1,48 @@
1
+ @testable import MentraBluetoothSDK
2
+ import XCTest
3
+
4
+ final class SGCConnectionTargetTests: XCTestCase {
5
+ func testG1RejectsAnotherPairsLegacyUUID() {
6
+ XCTAssertFalse(G1ConnectionTarget.matchesCachedPeripheral(name: "Even G1_12_L_123456", searchID: "_47_", cachedSearchID: nil))
7
+ XCTAssertTrue(G1ConnectionTarget.matchesCachedPeripheral(name: "Even G1_47_L_123456", searchID: "_47_", cachedSearchID: nil))
8
+ XCTAssertFalse(G1ConnectionTarget.matchesCachedPeripheral(name: "Even G1_470_R_123456", searchID: "_47_", cachedSearchID: nil))
9
+ }
10
+
11
+ func testG1NamelessBackgroundReconnectRequiresRecordedTarget() {
12
+ XCTAssertTrue(G1ConnectionTarget.matchesCachedPeripheral(name: nil, searchID: "_47_", cachedSearchID: "_47_"))
13
+ XCTAssertFalse(G1ConnectionTarget.matchesCachedPeripheral(name: nil, searchID: "_47_", cachedSearchID: "_12_"))
14
+ XCTAssertFalse(G1ConnectionTarget.matchesCachedPeripheral(name: nil, searchID: "_47_", cachedSearchID: nil))
15
+ XCTAssertFalse(G1ConnectionTarget.matchesCachedPeripheral(name: "Even G1_12_L_123456", searchID: "_47_", cachedSearchID: "_47_"))
16
+ XCTAssertFalse(G1ConnectionTarget.matchesCachedPeripheral(name: nil, searchID: "NOT_SET", cachedSearchID: "NOT_SET"))
17
+ }
18
+
19
+ func testNexExplicitSelectionExcludesSavedAndPreferredFallbacks() {
20
+ XCTAssertFalse(nex(name: "Nex1-old", target: "Nex1-new"))
21
+ XCTAssertFalse(nex(name: "Nex1-new-extra", target: "Nex1-new"))
22
+ XCTAssertTrue(nex(name: "Nex1-new", target: "Nex1-new"))
23
+ XCTAssertFalse(nex(name: "Nex1-old", target: ""))
24
+ }
25
+
26
+ func testNexDiscoveryNeverConnectsAndSavedReconnectStillWorks() {
27
+ XCTAssertFalse(nex(name: "Nex1-old", target: nil, discovery: true))
28
+ XCTAssertFalse(nex(name: "Nex1-new", target: "Nex1-new", discovery: true))
29
+ XCTAssertTrue(nex(name: "Nex1-old", target: nil))
30
+ XCTAssertTrue(nex(name: "Nex1-renamed", target: nil))
31
+ }
32
+
33
+ func testVuzixCurrentLinkMustMatchTheSelectedBracketedID() {
34
+ XCTAssertTrue(VuzixConnectionTarget.matches(name: "Vuzix Z100 [abc123]", target: "abc123"))
35
+ XCTAssertTrue(VuzixConnectionTarget.matches(name: "Vuzix Z100 [abc123]", target: "Vuzix Z100 [abc123]"))
36
+ XCTAssertFalse(VuzixConnectionTarget.matches(name: "Vuzix Z100 [old123]", target: "abc123"))
37
+ XCTAssertFalse(VuzixConnectionTarget.matches(name: nil, target: "abc123"))
38
+ XCTAssertFalse(VuzixConnectionTarget.matches(name: "", target: ""))
39
+ XCTAssertEqual(VuzixConnectionTarget.identifier("abc123"), "abc123")
40
+ }
41
+
42
+ private func nex(name: String, target: String?, discovery: Bool = false) -> Bool {
43
+ NexConnectionTarget.shouldConnect(
44
+ name: name, target: target, discoveryOnly: discovery,
45
+ savedName: "Nex1-old", preferredID: "old", discoveredID: "old"
46
+ )
47
+ }
48
+ }
@@ -0,0 +1,53 @@
1
+ @testable import MentraBluetoothSDK
2
+ import XCTest
3
+
4
+ @MainActor
5
+ private final class VersionInfoCommandTransport: MentraLive {
6
+ var requestId: String?
7
+
8
+ override func sendJson(_ jsonOriginal: [String: Any], wakeUp: Bool, requireAck: Bool) {
9
+ XCTAssertEqual(jsonOriginal["type"] as? String, "request_version")
10
+ XCTAssertTrue(wakeUp, "An idle ASG must wake to finish its version response")
11
+ XCTAssertTrue(requireAck)
12
+ do {
13
+ let bytes = try JSONSerialization.data(withJSONObject: jsonOriginal)
14
+ let command = try XCTUnwrap(JSONSerialization.jsonObject(with: bytes) as? [String: Any])
15
+ let id = try XCTUnwrap(command["request_id"] as? String)
16
+ requestId = id
17
+ let common: [String: Any] = ["request_id": id, "sid": "asg-1", "chunkCount": 2]
18
+ for chunk: [String: Any] in [
19
+ ["type": "version_info_1", "chunkIndex": 1, "final": false,
20
+ "build_number": "303000008", "app_version": "3.3.0"],
21
+ ["type": "version_info_3", "chunkIndex": 2, "final": true,
22
+ "bes_fw_version": "26.9.23.0", "mtk_fw_version": "MentraLive_20260921.0"],
23
+ ] {
24
+ let response = common.merging(chunk) { _, value in value }
25
+ try processReceivedData(JSONSerialization.data(withJSONObject: response))
26
+ }
27
+ } catch {
28
+ XCTFail("Could not replay version request: \(error)")
29
+ }
30
+ }
31
+ }
32
+
33
+ @MainActor
34
+ final class VersionInfoCommandTests: XCTestCase {
35
+ func testPublicVersionRequestWakesGlassesAndCombinesBothResponseChunks() async throws {
36
+ let previous = DeviceManager.shared.sgc
37
+ let transport = VersionInfoCommandTransport()
38
+ DeviceManager.shared.sgc = transport
39
+ let sdk = MentraBluetoothSDK()
40
+ defer {
41
+ sdk.invalidate()
42
+ DeviceManager.shared.sgc = previous
43
+ }
44
+
45
+ let result = try await sdk.requestVersionInfo()
46
+
47
+ XCTAssertNotNil(transport.requestId)
48
+ XCTAssertEqual(result.buildNumber, "303000008")
49
+ XCTAssertEqual(result.appVersion, "3.3.0")
50
+ XCTAssertEqual(result.besFirmwareVersion, "26.9.23.0")
51
+ XCTAssertEqual(result.mtkFirmwareVersion, "MentraLive_20260921.0")
52
+ }
53
+ }
@@ -117,6 +117,71 @@ final class VersionInfoResponseAccumulatorTests: XCTestCase {
117
117
  }
118
118
  }
119
119
 
120
+ func testFactoryAsg27CompletesAfterItsTwoChunks() {
121
+ // Exact January wire shape after Bridge normalization; firmware can be unknown.
122
+ for firmware in ["", "17.26.1.13"] {
123
+ let accumulator = VersionInfoResponseAccumulator(expectedRequestId: "request-1")
124
+ _ = accumulator.accept(chunk("version_info_1", nil, ["buildNumber": "27", "appVersion": "27.0"]))
125
+ guard case let .complete(result) = accumulator.accept(chunk("version_info_2", nil, [
126
+ "buildNumber": "", "otaVersionUrl": "https://ota.example/live_version.json", "firmwareVersion": firmware,
127
+ ])) else { return XCTFail("ASG27 has no third chunk") }
128
+ XCTAssertEqual(result.buildNumber, "27")
129
+ XCTAssertEqual(result.otaVersionUrl, "https://ota.example/live_version.json")
130
+ XCTAssertEqual(result.firmwareVersion, firmware)
131
+ }
132
+ }
133
+
134
+ func testOtherLegacyBuildsStillRequireTheirThirdChunk() {
135
+ for build in ["", "26", "28", "31", "37", "42"] {
136
+ let accumulator = VersionInfoResponseAccumulator(expectedRequestId: "request-1")
137
+ _ = accumulator.accept(chunk("version_info_1", nil, ["buildNumber": build]))
138
+ guard case .waiting = accumulator.accept(chunk("version_info_2", nil, [
139
+ "otaVersionUrl": "https://ota.example/version.json",
140
+ ])) else { return XCTFail("Unknown or three-chunk builds must wait: " + build) }
141
+ guard case .complete = accumulator.accept(chunk("version_info_3", nil, ["besFirmwareVersion": "new"]))
142
+ else { return XCTFail("Legacy third chunk must complete") }
143
+ }
144
+ }
145
+
146
+ func testFactorySecondChunkCannotCompleteWithoutFirstChunkAndOtaUrl() {
147
+ let accumulator = VersionInfoResponseAccumulator(expectedRequestId: "request-1")
148
+ let second = chunk("version_info_2", nil, ["otaVersionUrl": "https://ota.example/version.json"])
149
+ guard case .ignored = accumulator.accept(second) else { return XCTFail("Trailing chunk is stale") }
150
+ _ = accumulator.accept(chunk("version_info_1", nil, ["buildNumber": "27"]))
151
+ for values in [[:], ["otaVersionUrl": ""], ["otaVersionUrl": " \n"]] as [[String: Any]] {
152
+ guard case .waiting = accumulator.accept(chunk("version_info_2", nil, values))
153
+ else { return XCTFail("Incomplete second chunk cannot complete") }
154
+ }
155
+ guard case .complete = accumulator.accept(second) else { return XCTFail("Actual terminal chunk") }
156
+ }
157
+
158
+ func testSecondChunkCannotChangeBuildIntoFactoryProtocol() {
159
+ let accumulator = VersionInfoResponseAccumulator(expectedRequestId: "request-1")
160
+ _ = accumulator.accept(chunk("version_info_1", nil, ["buildNumber": "31"]))
161
+ guard case .waiting = accumulator.accept(chunk("version_info_2", nil, [
162
+ "buildNumber": "27", "otaVersionUrl": "https://ota.example/version.json",
163
+ ])) else { return XCTFail("Only the first chunk declares the protocol") }
164
+ }
165
+
166
+ func testRepeatedFirstChunkClearsFactoryCompletionRule() {
167
+ let accumulator = VersionInfoResponseAccumulator(expectedRequestId: "request-1")
168
+ _ = accumulator.accept(chunk("version_info_1", nil, ["buildNumber": "27"]))
169
+ _ = accumulator.accept(chunk("version_info_1", nil, ["buildNumber": "31"]))
170
+ guard case .waiting = accumulator.accept(chunk("version_info_2", nil, [
171
+ "otaVersionUrl": "https://ota.example/version.json",
172
+ ])) else { return XCTFail("The latest first chunk determines the protocol") }
173
+ }
174
+
175
+ func testFactoryBuildCannotBypassModernCorrelation() {
176
+ let accumulator = VersionInfoResponseAccumulator(expectedRequestId: "request-1")
177
+ _ = accumulator.accept(chunk("version_info_1", "request-1", ["buildNumber": "27"]))
178
+ guard case .ignored = accumulator.accept(chunk("version_info_2", nil, [
179
+ "otaVersionUrl": "https://ota.example/version.json",
180
+ ])) else { return XCTFail("Uncorrelated terminal chunk cannot complete a modern request") }
181
+ guard case .complete = accumulator.accept(chunk("version_info_3", "request-1", ["besFirmwareVersion": "new"]))
182
+ else { return XCTFail("Modern completeness metadata remains authoritative") }
183
+ }
184
+
120
185
  func testModernFinalChunkWaitsForMissingFirstChunk() {
121
186
  let accumulator = VersionInfoResponseAccumulator(expectedRequestId: "request-1")
122
187
  guard case .waiting = accumulator.accept(chunk("version_info_3", "request-1", ["besFirmwareVersion": "new"])) else {
@@ -0,0 +1,81 @@
1
+ @testable import MentraBluetoothSDK
2
+ import XCTest
3
+
4
+ /// Replace the radio handoff while retaining public SDK dispatch and response handling.
5
+ @MainActor
6
+ private final class VideoRecordingCommandTransport: MentraLive {
7
+ var commands: [[String: Any]] = []
8
+
9
+ override func sendJson(_ jsonOriginal: [String: Any], wakeUp: Bool, requireAck: Bool) {
10
+ do {
11
+ let bytes = try JSONSerialization.data(withJSONObject: jsonOriginal)
12
+ let command = try XCTUnwrap(JSONSerialization.jsonObject(with: bytes) as? [String: Any])
13
+ let type = try XCTUnwrap(command["type"] as? String)
14
+ let requestId = try XCTUnwrap(command["requestId"] as? String)
15
+ commands.append(command)
16
+ XCTAssertTrue(wakeUp, "\(type) must wake the glasses before ASG can handle it")
17
+ XCTAssertTrue(requireAck)
18
+
19
+ let statuses = [
20
+ "start_video_recording": "recording_started",
21
+ "get_video_recording_status": "recording_status",
22
+ "stop_video_recording": "recording_stopped",
23
+ ]
24
+ let status = try XCTUnwrap(statuses[type])
25
+ let response: [String: Any] = [
26
+ "type": "video_recording_status", "requestId": requestId,
27
+ "status": status, "success": true,
28
+ ]
29
+ try processReceivedData(JSONSerialization.data(withJSONObject: response))
30
+ } catch {
31
+ XCTFail("Could not replay video recording command: \(error)")
32
+ }
33
+ }
34
+ }
35
+
36
+ @MainActor
37
+ final class VideoRecordingCommandTests: XCTestCase {
38
+ func testStartStatusAndStopWakeGlassesThroughPublicSDK() async throws {
39
+ let previous = DeviceManager.shared.sgc
40
+ let store = DeviceStore.shared.store
41
+ let savedConnected = store.getCategory("glasses")["connected"]
42
+ let transport = VideoRecordingCommandTransport()
43
+ transport.connectionState = ConnTypes.CONNECTED
44
+ DeviceManager.shared.sgc = transport
45
+ DeviceStore.shared.apply("glasses", "connected", true)
46
+ let sdk = MentraBluetoothSDK()
47
+ defer {
48
+ sdk.invalidate()
49
+ DeviceManager.shared.sgc = previous
50
+ if let savedConnected {
51
+ store.set("glasses", "connected", savedConnected)
52
+ } else {
53
+ store.remove("glasses", "connected")
54
+ }
55
+ }
56
+
57
+ let requestId = "idle-recording"
58
+ let started = try await sdk.startVideoRecording(VideoRecordingRequest(
59
+ requestId: requestId, save: true, sound: false,
60
+ width: 1280, height: 720, fps: 30, maxRecordingTimeMinutes: 2
61
+ ))
62
+ XCTAssertEqual(started.status, "recording_started")
63
+ XCTAssertEqual(started.requestId, requestId)
64
+ let start = try XCTUnwrap(transport.commands.last)
65
+ XCTAssertEqual(start["save"] as? Bool, true)
66
+ XCTAssertEqual(start["sound"] as? Bool, false)
67
+ XCTAssertEqual(start["settings"] as? [String: Int], ["width": 1280, "height": 720, "fps": 30])
68
+ XCTAssertEqual(start["maxRecordingTimeMinutes"] as? Int, 2)
69
+
70
+ let status = try await sdk.queryVideoRecordingStatus(requestId: requestId)
71
+ XCTAssertEqual(status.status, "recording_status")
72
+ XCTAssertEqual(status.requestId, requestId)
73
+
74
+ let stopped = try await sdk.stopVideoRecording(requestId: requestId)
75
+ XCTAssertEqual(stopped.status, "recording_stopped")
76
+ XCTAssertEqual(stopped.requestId, requestId)
77
+ XCTAssertEqual(transport.commands.compactMap { $0["type"] as? String }, [
78
+ "start_video_recording", "get_video_recording_status", "stop_video_recording",
79
+ ])
80
+ }
81
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mentra/bluetooth-sdk",
3
- "version": "3.2.1-dev.314",
3
+ "version": "3.2.1",
4
4
  "description": "SDK for communicating with smart glasses",
5
5
  "main": "build/index.js",
6
6
  "react-native": "src/index.ts",
@@ -114,7 +114,7 @@
114
114
  "registry": "https://registry.npmjs.org/"
115
115
  },
116
116
  "dependencies": {
117
- "@mentra/cloud-protocol": "3.2.1-dev.314"
117
+ "@mentra/cloud-protocol": "3.2.1"
118
118
  },
119
119
  "devDependencies": {
120
120
  "@types/node": "^25.9.3",
@@ -2,5 +2,7 @@ import { IOSConfig, type ConfigPlugin } from "expo/config-plugins";
2
2
  import { type BluetoothSdkPluginProps } from "./index";
3
3
  export declare const INFO_ANALYTICS_ENVIRONMENT = "MentraBluetoothSdkAnalyticsEnvironment";
4
4
  export declare const INFO_SDK_VERSION = "MentraBluetoothSdkVersion";
5
+ /** Lets iOS complete the SDK's pending reconnect, and deliver BLE events, while the app is suspended. */
6
+ export declare const IOS_BLUETOOTH_BACKGROUND_MODE = "bluetooth-central";
5
7
  export declare function applyBluetoothSdkInfoPlist(infoPlist: IOSConfig.InfoPlist, props: BluetoothSdkPluginProps | undefined): IOSConfig.InfoPlist;
6
8
  export declare const withIosConfiguration: ConfigPlugin<BluetoothSdkPluginProps>;
@@ -3,7 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.withIosConfiguration = exports.INFO_SDK_VERSION = exports.INFO_ANALYTICS_ENVIRONMENT = void 0;
6
+ exports.withIosConfiguration = exports.IOS_BLUETOOTH_BACKGROUND_MODE = exports.INFO_SDK_VERSION = exports.INFO_ANALYTICS_ENVIRONMENT = void 0;
7
7
  exports.applyBluetoothSdkInfoPlist = applyBluetoothSdkInfoPlist;
8
8
  const child_process_1 = require("child_process");
9
9
  const fs_1 = __importDefault(require("fs"));
@@ -17,6 +17,8 @@ exports.INFO_ANALYTICS_ENVIRONMENT = "MentraBluetoothSdkAnalyticsEnvironment";
17
17
  exports.INFO_SDK_VERSION = "MentraBluetoothSdkVersion";
18
18
  const STALE_INFO_POSTHOG_API_KEY = "MentraBluetoothSdkPostHogApiKey";
19
19
  const STALE_INFO_POSTHOG_HOST = "MentraBluetoothSdkPostHogHost";
20
+ /** Lets iOS complete the SDK's pending reconnect, and deliver BLE events, while the app is suspended. */
21
+ exports.IOS_BLUETOOTH_BACKGROUND_MODE = "bluetooth-central";
20
22
  const ensureBluetoothSdkExpoAdapterPodEnv = (podfile) => {
21
23
  if (podfile.includes(BLUETOOTH_SDK_EXPO_ADAPTER_ENV)) {
22
24
  return podfile;
@@ -70,6 +72,15 @@ function applyBluetoothSdkInfoPlist(infoPlist, props) {
70
72
  delete infoPlist[STALE_INFO_POSTHOG_API_KEY];
71
73
  delete infoPlist[STALE_INFO_POSTHOG_HOST];
72
74
  infoPlist[exports.INFO_SDK_VERSION] = sdkVersion;
75
+ // Merge, never replace: apps may also declare audio, location or other modes.
76
+ const declaredModes = infoPlist.UIBackgroundModes;
77
+ if (declaredModes !== undefined && !Array.isArray(declaredModes)) {
78
+ throw new Error("ios.infoPlist.UIBackgroundModes must be an array of background mode strings");
79
+ }
80
+ const backgroundModes = declaredModes ?? [];
81
+ if (!backgroundModes.includes(exports.IOS_BLUETOOTH_BACKGROUND_MODE)) {
82
+ infoPlist.UIBackgroundModes = [...backgroundModes, exports.IOS_BLUETOOTH_BACKGROUND_MODE];
83
+ }
73
84
  if (analytics.disabled !== undefined) {
74
85
  infoPlist[INFO_ANALYTICS_DISABLED] = analytics.disabled;
75
86
  }
@@ -82,6 +82,7 @@ export function isBusyGlassesConnectionStatus(status: GlassesConnectionStatus):
82
82
  export function createDisconnectedGlassesStatus(): Partial<GlassesStatus> {
83
83
  return {
84
84
  connection: {state: "disconnected"},
85
+ g2MissingArm: null,
85
86
  hotspot: {state: "disabled"},
86
87
  voiceActivityDetectionEnabled: DEFAULT_VOICE_ACTIVITY_DETECTION_ENABLED,
87
88
  wifi: {state: "disconnected"},
@@ -543,6 +544,15 @@ export type WearTuning = {
543
544
  export type WearStateEvent = {
544
545
  type: "wear_state"
545
546
  worn: boolean
547
+ /** Unworn countdown, present on firmware that reports it. Milliseconds. */
548
+ elapsedMs?: number
549
+ timeoutMs?: number
550
+ /** cs_swit type 11. Absent on older firmware. */
551
+ enabled?: boolean
552
+ /** Countdown is running (glasses are off the face and the feature is on). */
553
+ armed?: boolean
554
+ /** Clock is running, but charging, a call, OTA, or factory test is holding shutdown. */
555
+ inhibited?: boolean
546
556
  }
547
557
 
548
558
  /**
@@ -1417,6 +1427,7 @@ export interface BluetoothSdkPublicModule {
1417
1427
  setGalleryModeEnabled(enabled: boolean): Promise<SettingsAckSuccessEvent>
1418
1428
  setVoiceActivityDetectionEnabled(enabled: boolean): Promise<void>
1419
1429
  setLoudnessGateEnabled(enabled: boolean): Promise<void>
1430
+ setAutoPowerOffEnabled(enabled: boolean): Promise<void>
1420
1431
  /**
1421
1432
  * @deprecated Sticky action-button photo presets are deprecated. Prefer per-request
1422
1433
  * `requestPhoto(...)` options (e.g. `mode: "text"` for text sensor size/crop, or explicit per-shot
@@ -1603,6 +1614,8 @@ export interface OtaProgress {
1603
1614
  }
1604
1615
 
1605
1616
  export interface GlassesStatus {
1617
+ /** iOS G2 arm still disconnected after a three-second partial link; cleared on reset or both links. */
1618
+ g2MissingArm?: "left" | "right" | null
1606
1619
  // state:
1607
1620
  connection: GlassesConnectionStatus
1608
1621
  micEnabled: boolean
@@ -1798,6 +1811,7 @@ export type BluetoothSettingsUpdate = Partial<{
1798
1811
  gallery_mode: boolean
1799
1812
  voice_activity_detection_enabled: boolean
1800
1813
  loudness_gate_enabled: boolean
1814
+ auto_power_off_enabled: boolean
1801
1815
  /** Effective mic tuning only. `{}` means "reset to firmware defaults". */
1802
1816
  mic_tuning: MicTuning
1803
1817
  button_photo_size: ButtonPhotoSize