@mentra/bluetooth-sdk 3.2.1-beta.506 → 3.2.1-beta.512

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 (31) hide show
  1. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +2 -0
  2. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +37 -2
  3. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +5 -5
  4. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Nimo.kt +59 -6
  5. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCoordinator.kt +26 -13
  6. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasSession.kt +31 -19
  7. package/android/src/test/java/com/mentra/bluetoothsdk/DeviceManagerSceneHandoffTest.kt +16 -1
  8. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCoordinatorTest.kt +44 -4
  9. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasSessionTest.kt +82 -13
  10. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoProtocolTest.kt +11 -0
  11. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoLifecycleTest.kt +94 -0
  12. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoReadinessDriverTest.kt +15 -9
  13. package/build/_private/BluetoothSdkModule.d.ts +2 -0
  14. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  15. package/build/_private/BluetoothSdkModule.js.map +1 -1
  16. package/build/generated/releaseMetadata.js +5 -5
  17. package/build/generated/releaseMetadata.js.map +1 -1
  18. package/ios/BluetoothSdkModule.swift +4 -0
  19. package/ios/Source/BluetoothSdkDefaults.swift +1 -1
  20. package/ios/Source/DeviceManager.swift +23 -1
  21. package/ios/Source/GeneratedReleaseMetadata.swift +5 -5
  22. package/ios/Source/sgcs/Nimo.swift +41 -5
  23. package/ios/Source/sgcs/NimoCanvasCoordinator.swift +20 -6
  24. package/ios/Source/sgcs/NimoCanvasSession.swift +30 -14
  25. package/ios/Tests/DeviceManagerReconnectTests.swift +26 -1
  26. package/ios/Tests/NimoCanvasSessionTests.swift +74 -5
  27. package/ios/Tests/NimoCanvasTransportTests.swift +25 -0
  28. package/ios/Tests/NimoDeviceCommandsTests.swift +16 -0
  29. package/package.json +2 -2
  30. package/src/_private/BluetoothSdkModule.ts +2 -0
  31. package/src/generated/releaseMetadata.ts +5 -5
@@ -40,6 +40,8 @@ enum NimoProtocol {
40
40
  static let CMD_SET_PARAMETER = 0x03
41
41
  static let CMD_INSTRUCTION_REPORT = 0x06
42
42
  static let CMD_CONTROL_INSTRUCTION = 0x07
43
+ static let CMD_CONTROL_FACTORY = 0x08
44
+ static let FACTORY_RECOVER = 0x03
43
45
  static let CMD_CONTROL_NOTIFICATION = 0x09
44
46
 
45
47
  // get parameter keys
@@ -58,6 +60,9 @@ enum NimoProtocol {
58
60
  static let SET_DISPLAY_OFF = 0x0F
59
61
  static let SET_PHONE_TYPE = 0x14
60
62
  static let SET_HEIGHT_LEVEL = 0x17
63
+ // Dynamic-v1 firmware: 1-based UI locale; 1 selects English.
64
+ static let SET_SYSTEM_LANGUAGE = 0x24
65
+ static let LANGUAGE_ENGLISH: UInt8 = 0x01
61
66
 
62
67
  // control instruction keys
63
68
  static let CTRL_ENTER_APP = 0x01
@@ -742,6 +747,27 @@ class Nimo: NSObject, SGCManager {
742
747
  DeviceStore.shared.apply("glasses", "connectionState", ConnTypes.DISCONNECTED)
743
748
  }
744
749
 
750
+ /// Explicit Unpair only; passive forget/cleanup must never reset the glasses.
751
+ func resetForUnpair(onResult: @escaping (Bool) -> Void) {
752
+ guard peripheral != nil, txChar != nil, handshakeState == .ready else {
753
+ Bridge.log("NIMO: unpair while offline; remote reset unavailable")
754
+ onResult(false)
755
+ return
756
+ }
757
+ isDisconnecting = true
758
+ stopTimers()
759
+ Task { await reconnectionManager.stop() }
760
+ canvasEncoder.invalidate()
761
+ canvas.disconnected()
762
+ Bridge.log("NIMO: sending factory reset for explicit Unpair")
763
+ sendAwaitingAck(cmd: NimoProtocol.CMD_CONTROL_FACTORY, key: NimoProtocol.FACTORY_RECOVER,
764
+ payload: Data())
765
+ { success in
766
+ Bridge.log("NIMO: factory reset acknowledged=\(success)")
767
+ onResult(success)
768
+ }
769
+ }
770
+
745
771
  func forget() {
746
772
  Bridge.log("NIMO: forget()")
747
773
  disconnect()
@@ -1287,6 +1313,9 @@ class Nimo: NSObject, SGCManager {
1287
1313
  needsAck: false
1288
1314
  )
1289
1315
  )
1316
+ // Optional setting: older firmware may reject it without preventing connection.
1317
+ sendFrame(NimoFrameCodec.encodeFrame(cmd: NimoProtocol.CMD_SET_PARAMETER,
1318
+ key: NimoProtocol.SET_SYSTEM_LANGUAGE, payload: Data([NimoProtocol.LANGUAGE_ENGLISH])))
1290
1319
  getBatteryStatus()
1291
1320
  requestVersionInfo()
1292
1321
 
@@ -1296,7 +1325,10 @@ class Nimo: NSObject, SGCManager {
1296
1325
  DeviceStore.shared.apply("glasses", "fullyBooted", true)
1297
1326
  DeviceStore.shared.apply("glasses", "connectionState", ConnTypes.CONNECTED)
1298
1327
  startTimers()
1299
- canvas.readiness(twsConnected && peerCompanionReady == true)
1328
+ // The Launch ACK validates peer readiness without waiting for the first
1329
+ // unsolicited heartbeat (which can arrive a minute after connection).
1330
+ canvas.activate()
1331
+ canvas.readiness(twsConnected && peerCompanionReady != false)
1300
1332
  }
1301
1333
 
1302
1334
  private func handshakeFailed() {
@@ -1365,6 +1397,7 @@ class Nimo: NSObject, SGCManager {
1365
1397
  private func handleRxPacket(_ packet: Data) {
1366
1398
  if packet.count >= 10, packet[8] == 7, [1, 3, 4].contains(packet[9]) {
1367
1399
  if let response = NimoCanvasCodec.response(packet) {
1400
+ if response.key == 1 { Bridge.log("NIMO: canvas Launch response status=\(response.payload.first.map(Int.init) ?? -1)") }
1368
1401
  canvas.response(key: response.key, payload: response.payload)
1369
1402
  }
1370
1403
  return
@@ -1398,9 +1431,9 @@ class Nimo: NSObject, SGCManager {
1398
1431
  Bridge.log("NIMO: app state report appId=\(appId) phase=\(phase)")
1399
1432
  switch phase {
1400
1433
  case NimoProtocol.STATE_ENTER:
1401
- if canvas.nativeApp(appId, entered: true) { canvasEncoder.invalidate() }
1434
+ canvas.nativeApp(appId, entered: true)
1402
1435
  case NimoProtocol.STATE_EXIT:
1403
- if canvas.nativeApp(appId, entered: false) { canvasEncoder.invalidate() }
1436
+ canvas.nativeApp(appId, entered: false)
1404
1437
  default:
1405
1438
  break
1406
1439
  }
@@ -1430,7 +1463,7 @@ class Nimo: NSObject, SGCManager {
1430
1463
  peerCompanionReady = v[9] != 0
1431
1464
  onTwsState(Int(v[8]) >= 1)
1432
1465
  if handshakeState == .ready {
1433
- canvas.readiness(twsConnected && peerCompanionReady == true, confirmed: true)
1466
+ canvas.readiness(twsConnected && peerCompanionReady != false, confirmed: true)
1434
1467
  }
1435
1468
  }
1436
1469
  case NimoProtocol.BUSINESS_BATTERY:
@@ -1453,7 +1486,7 @@ class Nimo: NSObject, SGCManager {
1453
1486
  if !connected, handshakeState == .ready {
1454
1487
  Bridge.log("NIMO: TWS service dropped mid-session (arm removed/off?)")
1455
1488
  }
1456
- if handshakeState == .ready { canvas.readiness(connected && peerCompanionReady == true) }
1489
+ if handshakeState == .ready { canvas.readiness(connected && peerCompanionReady != false) }
1457
1490
  }
1458
1491
 
1459
1492
  private func handleInputEvent(_ code: Int) {
@@ -1489,6 +1522,9 @@ class Nimo: NSObject, SGCManager {
1489
1522
  }
1490
1523
 
1491
1524
  private func handleResponse(cmd: Int, key: Int, statusCode: Int, data: Data) {
1525
+ if cmd == NimoProtocol.CMD_SET_PARAMETER, key == NimoProtocol.SET_SYSTEM_LANGUAGE {
1526
+ Bridge.log("NIMO: English menu setting acknowledged=\(statusCode == 0) status=\(statusCode)")
1527
+ }
1492
1528
  resolvePendingAck(cmd: cmd, key: key, success: statusCode == 0)
1493
1529
 
1494
1530
  guard cmd == NimoProtocol.CMD_GET_PARAMETER, statusCode == 0 else { return }
@@ -21,6 +21,7 @@ final class NimoCanvasCoordinator {
21
21
  private let rejected: (Int) -> Void
22
22
  private let session = NimoCanvasSession()
23
23
  private var flight: Flight?
24
+ private var cancelRetry: (() -> Void)?
24
25
  private var cancelDeadline: (() -> Void)?
25
26
 
26
27
  init(schedule: @escaping NimoScheduler, writeCapacity: @escaping () -> Int,
@@ -35,23 +36,26 @@ final class NimoCanvasCoordinator {
35
36
  run(session.offer(bytes, scope: scope, force: force))
36
37
  }
37
38
 
39
+ func activate() {
40
+ run(session.activate())
41
+ }
42
+
38
43
  func readiness(_ value: Bool, confirmed: Bool = false) {
44
+ if !value { cancelRetry?(); cancelRetry = nil }
39
45
  run(session.readiness(value, confirmed: confirmed))
40
46
  }
41
47
 
42
48
  func exit() {
49
+ cancelRetry?(); cancelRetry = nil
43
50
  run(session.exit())
44
51
  }
45
52
 
46
- /// An expected Exit report must not discard a newer host encode queued behind Exit.
47
- func nativeApp(_ appId: Int, entered: Bool) -> Bool {
48
- let takeover = (appId == NimoCanvasCodec.appId && !entered) || (appId != NimoCanvasCodec.appId && entered)
49
- let expectedExit = appId == NimoCanvasCodec.appId && !entered && flight?.key == 3
53
+ func nativeApp(_ appId: Int, entered: Bool) {
50
54
  run(session.nativeApp(appId, entered: entered))
51
- return takeover && !expectedExit
52
55
  }
53
56
 
54
57
  func disconnected() {
58
+ cancelRetry?(); cancelRetry = nil
55
59
  cancelDeadline?(); cancelDeadline = nil
56
60
  flight = nil
57
61
  session.disconnected()
@@ -72,6 +76,7 @@ final class NimoCanvasCoordinator {
72
76
  for action in actions {
73
77
  switch action {
74
78
  case let .send(key, frame, ticket):
79
+ cancelRetry?(); cancelRetry = nil
75
80
  cancelDeadline?()
76
81
  let current = Flight(key: key)
77
82
  flight = current
@@ -93,7 +98,16 @@ final class NimoCanvasCoordinator {
93
98
  disconnected(); reconnect("Canvas framing failed: \(error)")
94
99
  }
95
100
  case let .reconnect(reason): disconnected(); reconnect(reason)
96
- case let .rejected(status): rejected(status)
101
+ case let .rejected(status):
102
+ rejected(status)
103
+ if status == 7 {
104
+ cancelRetry?()
105
+ cancelRetry = schedule(1) { [weak self] in
106
+ guard let self else { return }
107
+ self.cancelRetry = nil
108
+ self.run(self.session.retryNotReady())
109
+ }
110
+ }
97
111
  }
98
112
  }
99
113
  }
@@ -15,12 +15,14 @@ final class NimoCanvasSession {
15
15
  }
16
16
 
17
17
  private var ready = false
18
+ private var transportReady = false
18
19
  private var active = false
19
20
  private var locked = false
20
21
  private var launchBlocked = false
21
22
  private var waitingReadiness = false
22
23
  private var observedNotReady = false
23
24
  private var readinessRetries = 0
25
+ private var probeRetries = 0
24
26
  private var exitRequested = false
25
27
  private var desired: Data?
26
28
  private var accepted: Data?
@@ -39,9 +41,27 @@ final class NimoCanvasSession {
39
41
  return pump()
40
42
  }
41
43
 
44
+ /// Enter a blank canvas unless a newer host scene survived the connection change.
45
+ func activate() -> [Action] {
46
+ if desired == nil { desired = Data([0, 0, 1]) }
47
+ exitRequested = false
48
+ return pump()
49
+ }
50
+
51
+ /// The firmware validates peer readiness on Launch. Only an explicit NOT_READY
52
+ /// permits a bounded retry; an ambiguous ACK still requires a new transport.
53
+ func retryNotReady() -> [Action] {
54
+ guard transportReady, waitingReadiness, desired != nil, probeRetries < 3 else { return [] }
55
+ probeRetries += 1
56
+ waitingReadiness = false
57
+ ready = true
58
+ return pump()
59
+ }
60
+
42
61
  /// Only a fresh heartbeat samples both TWS and peer Companion readiness.
43
62
  func readiness(_ value: Bool, confirmed: Bool = false) -> [Action] {
44
- if !value { readinessRetries = 0 }
63
+ transportReady = value
64
+ if !value { readinessRetries = 0; probeRetries = 0 }
45
65
  if !value, flight != nil { return resetLink("Readiness lost during canvas command") }
46
66
  if waitingReadiness {
47
67
  if !value { observedNotReady = true }
@@ -58,8 +78,8 @@ final class NimoCanvasSession {
58
78
 
59
79
  /// Keep only the latest desired scene across a new transport generation.
60
80
  func disconnected() {
61
- ready = false; active = false; locked = false; launchBlocked = false
62
- waitingReadiness = false; observedNotReady = false; readinessRetries = 0
81
+ ready = false; transportReady = false; active = false; locked = false; launchBlocked = false
82
+ waitingReadiness = false; observedNotReady = false; readinessRetries = 0; probeRetries = 0
63
83
  accepted = nil; rejected = nil; flight = nil
64
84
  ticket &+= 1; forceRevision &+= 1
65
85
  }
@@ -69,16 +89,12 @@ final class NimoCanvasSession {
69
89
  return pump()
70
90
  }
71
91
 
92
+ /// Stock UI reports do not relinquish host ownership. Replay after the current ACK.
72
93
  func nativeApp(_ appId: Int, entered: Bool) -> [Action] {
73
94
  if (appId == NimoCanvasCodec.appId && !entered) || (appId != NimoCanvasCodec.appId && entered) {
74
- if flight?.key == 3 {
75
- active = false; accepted = nil
76
- if appId != NimoCanvasCodec.appId, entered { desired = nil; rejected = nil; scope = nil }
77
- return []
78
- }
79
- let ambiguous = flight != nil
80
- desired = nil; accepted = nil; rejected = nil; active = false; exitRequested = false; scope = nil
81
- if ambiguous { return resetLink("Native app transition interrupted canvas command") }
95
+ active = false
96
+ accepted = nil
97
+ return pump()
82
98
  }
83
99
  return []
84
100
  }
@@ -112,8 +128,8 @@ final class NimoCanvasSession {
112
128
  return [.rejected(status)] + pump()
113
129
  }
114
130
  switch key {
115
- case 1: active = true
116
- case 4: accepted = current.frame; rejected = nil; readinessRetries = 0
131
+ case 1: active = true; accepted = nil
132
+ case 4: accepted = current.frame; rejected = nil; readinessRetries = 0; probeRetries = 0
117
133
  case 3: active = false; accepted = nil; exitRequested = false
118
134
  default: break
119
135
  }
@@ -126,7 +142,7 @@ final class NimoCanvasSession {
126
142
  }
127
143
 
128
144
  private func resetLink(_ reason: String) -> [Action] {
129
- ready = false; active = false; accepted = nil; flight = nil
145
+ ready = false; transportReady = false; active = false; accepted = nil; flight = nil
130
146
  ticket &+= 1; forceRevision &+= 1
131
147
  return [.reconnect(reason)]
132
148
  }
@@ -5,6 +5,25 @@ import XCTest
5
5
  final class DeviceManagerReconnectTests: XCTestCase {
6
6
  private let requests = ["should_send_lc3", "should_send_pcm", "should_send_transcript", "local_stt_fallback_active"]
7
7
 
8
+ func testDelayedReconnectReplayPreservesNimoDepthEndpoints() {
9
+ for depth in [0, 10] {
10
+ withRecordingDevice { manager, store in
11
+ let device = manager.sgc as! ReconnectRecordingDevice
12
+ device.type = DeviceTypes.NIMO
13
+ store.set("bluetooth", "dashboard_height", 7)
14
+ store.set("bluetooth", "dashboard_depth", depth)
15
+ let replayed = expectation(description: "Delayed NIMO depth \(depth)")
16
+ replayed.assertForOverFulfill = false
17
+ device.onPosition = { replayed.fulfill() }
18
+ manager.handleDeviceReady()
19
+ XCTAssertTrue(device.positions.isEmpty)
20
+ wait(for: [replayed], timeout: 3)
21
+ XCTAssertEqual(device.positions.last?.0, 7)
22
+ XCTAssertEqual(device.positions.last?.1, depth)
23
+ }
24
+ }
25
+ }
26
+
8
27
  private func withRecordingDevice(_ body: (DeviceManager, DeviceStore) -> Void) {
9
28
  let manager = DeviceManager.shared
10
29
  let store = DeviceStore.shared
@@ -265,6 +284,8 @@ private final class ReconnectRecordingDevice: SGCManager {
265
284
  let hasMic = true
266
285
  let showConnectionConfirmation = false
267
286
  var micChanges: [Bool] = []
287
+ var positions: [(Int, Int)] = []
288
+ var onPosition: (() -> Void)?
268
289
  var isMicSuspendedForAudio = false
269
290
 
270
291
  func clearSceneElements(_: [String]) async {}
@@ -300,7 +321,11 @@ private final class ReconnectRecordingDevice: SGCManager {
300
321
  }
301
322
 
302
323
  func showDashboard() {}
303
- func setDashboardPosition(_: Int, _: Int) {}
324
+ func setDashboardPosition(_ height: Int, _ depth: Int) {
325
+ positions.append((height, depth))
326
+ onPosition?()
327
+ }
328
+
304
329
  func setHeadUpAngle(_: Int) {}
305
330
  func getBatteryStatus() {}
306
331
  func setSilentMode(_: Bool) {}
@@ -102,11 +102,80 @@ final class NimoCanvasSessionTests: XCTestCase {
102
102
  XCTAssertEqual(frame, b)
103
103
  }
104
104
 
105
- func testNativeTakeoverSuppressesReplayUntilHostOffersAgain() {
105
+ func testNativeTakeoverReplaysWithoutNewHostFrame() {
106
106
  let session = NimoCanvasSession(); connected(session)
107
- XCTAssertTrue(session.nativeApp(1, entered: true).isEmpty)
108
- XCTAssertTrue(session.readiness(true, confirmed: true).isEmpty)
109
- XCTAssertEqual(sentKey(session.offer(b, scope: "new")), 1)
110
- XCTAssertEqual(session.nativeApp(1, entered: true), [.reconnect("Native app transition interrupted canvas command")])
107
+ XCTAssertEqual(sentKey(session.nativeApp(0, entered: true)), 1)
108
+ XCTAssertTrue(session.nativeApp(0, entered: true).isEmpty)
109
+ guard case let .send(_, frame, _) = ack(session, 1).first else { return XCTFail() }
110
+ XCTAssertEqual(frame, a)
111
+ XCTAssertTrue(session.nativeApp(0xFD, entered: false).isEmpty)
112
+ XCTAssertEqual(sentKey(ack(session, 4)), 1)
113
+ }
114
+
115
+ func testTakeoverDuringUpdateReplaysIdenticalFrameAfterRelaunch() {
116
+ let session = NimoCanvasSession()
117
+ _ = session.offer(a, scope: "app:1"); _ = session.readiness(true)
118
+ XCTAssertEqual(sentKey(ack(session, 1)), 4)
119
+ XCTAssertTrue(session.nativeApp(0, entered: true).isEmpty)
120
+ XCTAssertEqual(sentKey(ack(session, 4)), 1)
121
+ guard case let .send(_, frame, _) = ack(session, 1).first else { return XCTFail("Missing replay Update") }
122
+ XCTAssertEqual(frame, a)
123
+ XCTAssertTrue(ack(session, 4).isEmpty)
124
+ XCTAssertTrue(session.offer(a, scope: "app:1").isEmpty)
125
+ }
126
+
127
+ func testStartupStockUiPreservesReconnectScene() {
128
+ let session = NimoCanvasSession(); connected(session)
129
+ session.disconnected()
130
+ XCTAssertTrue(session.nativeApp(0, entered: true).isEmpty)
131
+ XCTAssertTrue(session.activate().isEmpty)
132
+ XCTAssertEqual(sentKey(session.readiness(true)), 1)
133
+ guard case let .send(_, frame, _) = ack(session, 1).first else { return XCTFail() }
134
+ XCTAssertEqual(frame, a)
135
+ }
136
+
137
+ func testActivationEntersBlankCanvasBeforeAnyMiniapp() {
138
+ let session = NimoCanvasSession()
139
+ XCTAssertTrue(session.activate().isEmpty)
140
+ XCTAssertEqual(sentKey(session.readiness(true)), 1)
141
+ guard case let .send(_, frame, _) = ack(session, 1).first else { return XCTFail() }
142
+ XCTAssertEqual(frame, Data([0, 0, 1]))
143
+ }
144
+
145
+ func testExplicitExitStopsRestorationUntilAnotherConnection() {
146
+ let session = NimoCanvasSession(); connected(session)
147
+ XCTAssertEqual(sentKey(session.exit()), 3)
148
+ XCTAssertTrue(session.nativeApp(0, entered: true).isEmpty)
149
+ XCTAssertTrue(ack(session, 3).isEmpty)
150
+ XCTAssertTrue(session.nativeApp(0xFD, entered: false).isEmpty)
151
+ session.disconnected()
152
+ _ = session.activate()
153
+ XCTAssertEqual(sentKey(session.readiness(true)), 1)
154
+ }
155
+
156
+ func testSuccessfulUpdateRestoresProbeBudgetForNextRecovery() {
157
+ let session = NimoCanvasSession()
158
+ _ = session.activate(); _ = session.readiness(true)
159
+ for episode in 0 ..< 2 {
160
+ if episode > 0 { XCTAssertEqual(sentKey(session.nativeApp(0, entered: true)), 1) }
161
+ for _ in 0 ..< 3 {
162
+ _ = ack(session, 1, status: 7)
163
+ XCTAssertEqual(sentKey(session.retryNotReady()), 1)
164
+ }
165
+ XCTAssertEqual(sentKey(ack(session, 1)), 4)
166
+ XCTAssertTrue(ack(session, 4).isEmpty)
167
+ }
168
+ }
169
+
170
+ func testProbesAreBoundedAndHeartbeatCanStillRecover() {
171
+ let session = NimoCanvasSession()
172
+ _ = session.activate(); _ = session.readiness(true)
173
+ for _ in 0 ..< 3 {
174
+ _ = ack(session, 1, status: 7)
175
+ XCTAssertEqual(sentKey(session.retryNotReady()), 1)
176
+ }
177
+ _ = ack(session, 1, status: 7)
178
+ XCTAssertTrue(session.retryNotReady().isEmpty)
179
+ XCTAssertEqual(sentKey(session.readiness(true, confirmed: true)), 1)
111
180
  }
112
181
  }
@@ -33,6 +33,31 @@ private final class NimoTestClock {
33
33
  final class NimoCanvasTransportTests: XCTestCase {
34
34
  private let frame = Data([0, 0, 1])
35
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
+
36
61
  func testEarlyAckOnlyAdvancesAfterFinalWriteCompletes() {
37
62
  let clock = NimoTestClock()
38
63
  var chains: [[Data]] = [], starts: [() -> Void] = [], completes: [() -> Void] = []
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mentra/bluetooth-sdk",
3
- "version": "3.2.1-beta.506",
3
+ "version": "3.2.1-beta.512",
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-beta.506"
117
+ "@mentra/cloud-protocol": "3.2.1-beta.512"
118
118
  },
119
119
  "devDependencies": {
120
120
  "@types/node": "^25.9.3",
@@ -109,6 +109,8 @@ declare class BluetoothSdkNativeModule extends NativeModule<BluetoothSdkModuleEv
109
109
  connectSimulated(): Promise<void>
110
110
  disconnect(): Promise<void>
111
111
  forget(): Promise<void>
112
+ /** Explicit Unpair also resets connected NIMO glasses before forgetting. */
113
+ unpair(): Promise<void>
112
114
  forgetController(): Promise<void>
113
115
  showDashboard(): Promise<void>
114
116
  setBrightness(level: number, autoMode?: boolean | null): Promise<void>
@@ -12,9 +12,9 @@ export interface BluetoothSdkReleaseMetadata {
12
12
  export const BLUETOOTH_SDK_RELEASE_METADATA: Readonly<BluetoothSdkReleaseMetadata> = Object.freeze({
13
13
  "schemaVersion": 1,
14
14
  "familyBaseVersion": "3.2.1",
15
- "releaseIdentity": "3.2.1-beta.506",
16
- "releaseSetId": "mentra-3.2.1-beta.506",
17
- "sourceCommit": "cb87ecd9de2a0d253e54728493f1e8002c128207",
18
- "otaManifestUrl": "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.2.1/mentra-live-ota-3.2.1-beta.506.json",
19
- "otaManifestSha256": "af76d286c921c9c8f22b938a92b0b13aa983f1983c377006df51a1ad7646eed7"
15
+ "releaseIdentity": "3.2.1-beta.512",
16
+ "releaseSetId": "mentra-3.2.1-beta.512",
17
+ "sourceCommit": "e09a07ee7b71972e75a5051003ec561c12e6cb9b",
18
+ "otaManifestUrl": "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.2.1/mentra-live-ota-3.2.1-beta.512.json",
19
+ "otaManifestSha256": "bd24cbd8279319912d643699eeb435df3ca88582194953583491a3625799ec01"
20
20
  })