@mentra/bluetooth-sdk 3.2.1-beta.459 → 3.2.1-beta.467

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.
@@ -0,0 +1,101 @@
1
+ import Foundation
2
+
3
+ /// Readiness belongs to an authenticated pair and a completed initialization attempt, never to
4
+ /// a gesture from one arm. A generation retires asynchronous setup when either arm is lost.
5
+ struct G2ConnectionSession {
6
+ enum Side: String, CaseIterable {
7
+ case left = "L"
8
+ case right = "R"
9
+ }
10
+
11
+ private(set) var generation: UInt64 = 0
12
+ private var initialized: Set<Side> = []
13
+ private var authenticatedSides: Set<Side> = []
14
+ private var authStarted = false
15
+ private var setupComplete = false
16
+
17
+ var isReady: Bool {
18
+ initialized.count == 2 && authenticatedSides.count == 2 && setupComplete
19
+ }
20
+
21
+ func needsRecovery(attempt: UInt64) -> Bool {
22
+ generation == attempt && !isReady
23
+ }
24
+
25
+ mutating func initialize(_ side: Side) {
26
+ initialized.insert(side)
27
+ }
28
+
29
+ mutating func beginAuthentication() -> UInt64? {
30
+ guard initialized.count == 2, !authStarted else { return nil }
31
+ authStarted = true
32
+ return generation
33
+ }
34
+
35
+ mutating func authenticated(_ side: Side, success: Bool) {
36
+ guard authStarted else { return }
37
+ if success { authenticatedSides.insert(side) }
38
+ else { authenticatedSides.remove(side) }
39
+ }
40
+
41
+ mutating func completeSetup(generation: UInt64) {
42
+ guard self.generation == generation, authStarted else { return }
43
+ setupComplete = true
44
+ }
45
+
46
+ mutating func reset() {
47
+ generation &+= 1
48
+ initialized.removeAll()
49
+ authenticatedSides.removeAll()
50
+ authStarted = false
51
+ setupComplete = false
52
+ }
53
+ }
54
+
55
+ enum G2ConnectionTarget {
56
+ /// A serial-scoped cached UUID identifies an arm even without a new advertisement. Nearby
57
+ /// arms can share the G2_32 name fragment; only an advertisement's serial can select an
58
+ /// uncached arm. Never fall back to names alone or another pair's cached identifiers.
59
+ static func side(
60
+ identifier: UUID, name: String?, advertisedSerial: String?, searchID: String,
61
+ leftUUID: UUID?, rightUUID: UUID?
62
+ ) -> G2ConnectionSession.Side? {
63
+ guard !searchID.isEmpty, searchID != "NOT_SET" else { return nil }
64
+ if identifier == leftUUID, identifier == rightUUID { return nil }
65
+ if identifier == leftUUID { return .left }
66
+ if identifier == rightUUID { return .right }
67
+ guard let advertisedSerial, advertisedSerial.contains(searchID), let name else { return nil }
68
+ if name.contains("_L_") { return .left }
69
+ if name.contains("_R_") { return .right }
70
+ return nil
71
+ }
72
+ }
73
+
74
+ /// Normal pairing connects the arms sequentially. Surface a missing arm only after the
75
+ /// same partial link has persisted for three seconds, using monotonic time.
76
+ struct G2PartialConnection {
77
+ static let noticeDelay: TimeInterval = 3
78
+ private(set) var missingSide: G2ConnectionSession.Side?
79
+ private var partialSince: TimeInterval = 0
80
+
81
+ mutating func update(leftConnected: Bool, rightConnected: Bool, now: TimeInterval) {
82
+ let missing: G2ConnectionSession.Side? = leftConnected == rightConnected
83
+ ? nil : (leftConnected ? .right : .left)
84
+ if missing != missingSide {
85
+ missingSide = missing
86
+ partialSince = now
87
+ }
88
+ }
89
+
90
+ func visibleMissingSide(now: TimeInterval) -> G2ConnectionSession.Side? {
91
+ now - partialSince >= Self.noticeDelay ? missingSide : nil
92
+ }
93
+
94
+ var timeoutError: String {
95
+ switch missingSide {
96
+ case .left: "errors:g2LeftArmUnavailable"
97
+ case .right: "errors:g2RightArmUnavailable"
98
+ case nil: "errors:g2ConnectionTimedOut"
99
+ }
100
+ }
101
+ }
@@ -123,6 +123,7 @@ struct GlassesStatus: CustomStringConvertible {
123
123
  "wifiLocalIp": "",
124
124
  "signalStrength": -1,
125
125
  "signalStrengthUpdatedAt": 0,
126
+ "g2MissingArm": NSNull(),
126
127
  ]))
127
128
  }
128
129
 
@@ -0,0 +1,172 @@
1
+ @testable import MentraBluetoothSDK
2
+ import XCTest
3
+
4
+ final class G2ConnectionSessionTests: XCTestCase {
5
+ func testEitherArmAloneCannotStartAuthenticationOrBecomeReady() {
6
+ for side in G2ConnectionSession.Side.allCases {
7
+ var session = G2ConnectionSession()
8
+ session.initialize(side)
9
+ XCTAssertNil(session.beginAuthentication())
10
+ session.authenticated(side, success: true)
11
+ session.completeSetup(generation: session.generation)
12
+ XCTAssertFalse(session.isReady)
13
+ }
14
+ }
15
+
16
+ func testReadinessWaitsForBothAuthenticationResponsesAndCompletedSetup() throws {
17
+ for completeSetupFirst in [false, true] {
18
+ var session = G2ConnectionSession()
19
+ session.initialize(.right)
20
+ session.initialize(.left)
21
+ let generation = try XCTUnwrap(session.beginAuthentication())
22
+ XCTAssertNil(session.beginAuthentication(), "Duplicate service callbacks must not restart setup")
23
+ if completeSetupFirst { session.completeSetup(generation: generation) }
24
+ session.authenticated(.left, success: true)
25
+ session.authenticated(.right, success: false)
26
+ XCTAssertFalse(session.isReady)
27
+ session.authenticated(.right, success: true)
28
+ XCTAssertEqual(session.isReady, completeSetupFirst)
29
+ session.completeSetup(generation: generation)
30
+ XCTAssertTrue(session.isReady)
31
+ }
32
+ }
33
+
34
+ func testDisconnectRetiresAuthenticationAndSuspendedSetup() throws {
35
+ var session = G2ConnectionSession()
36
+ session.initialize(.left)
37
+ session.initialize(.right)
38
+ let oldGeneration = try XCTUnwrap(session.beginAuthentication())
39
+ session.authenticated(.left, success: true)
40
+ session.reset()
41
+ session.authenticated(.right, success: true)
42
+ session.completeSetup(generation: oldGeneration)
43
+ XCTAssertFalse(session.isReady)
44
+
45
+ session.initialize(.left)
46
+ session.initialize(.right)
47
+ let newGeneration = try XCTUnwrap(session.beginAuthentication())
48
+ session.authenticated(.left, success: true)
49
+ session.authenticated(.right, success: true)
50
+ session.completeSetup(generation: oldGeneration)
51
+ XCTAssertFalse(session.isReady, "Old setup cannot complete the replacement attempt")
52
+ session.completeSetup(generation: newGeneration)
53
+ XCTAssertTrue(session.isReady)
54
+ }
55
+
56
+ func testAttemptDeadlineCoversBothPartialPairsAndIncompleteAuthentication() throws {
57
+ for sides: [G2ConnectionSession.Side] in [[], [.left], [.right], [.left, .right]] {
58
+ var session = G2ConnectionSession()
59
+ for side in sides {
60
+ session.initialize(side)
61
+ }
62
+ _ = session.beginAuthentication()
63
+ let attempt = session.generation
64
+ XCTAssertTrue(session.needsRecovery(attempt: attempt))
65
+ session.reset()
66
+ XCTAssertFalse(session.needsRecovery(attempt: attempt), "A retired deadline cannot reset a new attempt")
67
+ }
68
+ var ready = G2ConnectionSession()
69
+ ready.initialize(.left)
70
+ ready.initialize(.right)
71
+ let attempt = try XCTUnwrap(ready.beginAuthentication())
72
+ ready.authenticated(.left, success: true)
73
+ ready.authenticated(.right, success: true)
74
+ ready.completeSetup(generation: attempt)
75
+ XCTAssertFalse(ready.needsRecovery(attempt: attempt))
76
+ }
77
+
78
+ func testCachedArmsCanReconnectIndependentlyWithoutAdvertisements() {
79
+ let left = UUID(), right = UUID()
80
+ XCTAssertEqual(target(left, left: left), .left)
81
+ XCTAssertEqual(target(right, right: right), .right)
82
+ XCTAssertNil(target(UUID(), left: left, right: right))
83
+ XCTAssertNil(target(right, left: right, right: right), "Ambiguous cache must not bind one arm twice")
84
+ }
85
+
86
+ func testOnlySelectedSerialCanFillTheMissingArm() {
87
+ let id = UUID()
88
+ XCTAssertEqual(target(id, name: "Even G2_32_L_123456", serial: "S211SELECTED"), .left)
89
+ XCTAssertNil(target(id, name: "Even G2_32_L_123456", serial: "S211OTHER"))
90
+ XCTAssertNil(target(id, name: "Even G2_32_R_123456"))
91
+ XCTAssertNil(target(id, right: id, searchID: "NOT_SET"))
92
+ XCTAssertNil(target(id, right: id, searchID: ""))
93
+ }
94
+
95
+ private func target(
96
+ _ id: UUID, name: String? = nil, serial: String? = nil,
97
+ left: UUID? = nil, right: UUID? = nil, searchID: String = "S211SELECTED"
98
+ ) -> G2ConnectionSession.Side? {
99
+ G2ConnectionTarget.side(identifier: id, name: name, advertisedSerial: serial,
100
+ searchID: searchID, leftUUID: left, rightUUID: right)
101
+ }
102
+ }
103
+
104
+ @MainActor
105
+ final class G2PrematureReadinessTests: XCTestCase {
106
+ func testSystemExitAndTouchBeforePairInitializationDoNotPublishReady() {
107
+ let store = DeviceStore.shared.store
108
+ let connected = store.get("glasses", "connected")
109
+ let booted = store.get("glasses", "fullyBooted")
110
+ defer {
111
+ if let connected { store.set("glasses", "connected", connected) }
112
+ if let booted { store.set("glasses", "fullyBooted", booted) }
113
+ }
114
+ store.set("glasses", "connected", false)
115
+ store.set("glasses", "fullyBooted", false)
116
+ let g2 = G2()
117
+ // SendDeviceEvent.SysEvent { eventType: systemExit }, as observed on the right arm.
118
+ g2.handleTouchEvent(Data([0x1A, 0x02, 0x08, 0x07]))
119
+ XCTAssertEqual(store.get("glasses", "connected") as? Bool, false)
120
+ XCTAssertEqual(store.get("glasses", "fullyBooted") as? Bool, false)
121
+ g2.handleTouchEvent(Data([0x1A, 0x02, 0x08, 0x00]))
122
+ XCTAssertEqual(store.get("glasses", "connected") as? Bool, false)
123
+ XCTAssertEqual(store.get("glasses", "fullyBooted") as? Bool, false)
124
+ }
125
+ }
126
+
127
+ final class G2PartialConnectionTests: XCTestCase {
128
+ func testEitherPartialLinkStaysQuietForThreeSeconds() {
129
+ for leftConnected in [false, true] {
130
+ var progress = G2PartialConnection()
131
+ progress.update(leftConnected: leftConnected, rightConnected: !leftConnected, now: 10)
132
+ XCTAssertNil(progress.visibleMissingSide(now: 10))
133
+ XCTAssertNil(progress.visibleMissingSide(now: 12.999))
134
+ // Duplicate observations must not restart the grace period.
135
+ progress.update(leftConnected: leftConnected, rightConnected: !leftConnected, now: 12)
136
+ XCTAssertEqual(progress.visibleMissingSide(now: 13), leftConnected ? .right : .left)
137
+ XCTAssertEqual(progress.timeoutError, leftConnected ? "errors:g2RightArmUnavailable" : "errors:g2LeftArmUnavailable")
138
+ }
139
+ }
140
+
141
+ func testNormalSequentialPairingAndDisconnectedStateNeverShowMissingArm() {
142
+ var progress = G2PartialConnection()
143
+ progress.update(leftConnected: false, rightConnected: true, now: 10)
144
+ progress.update(leftConnected: true, rightConnected: true, now: 12)
145
+ XCTAssertNil(progress.visibleMissingSide(now: 15))
146
+ progress.update(leftConnected: false, rightConnected: false, now: 16)
147
+ XCTAssertNil(progress.visibleMissingSide(now: 20))
148
+ XCTAssertEqual(progress.timeoutError, "errors:g2ConnectionTimedOut")
149
+ }
150
+
151
+ func testPeerArrivalClearsVisibleNoticeAndNewPartialLinkGetsOwnGracePeriod() {
152
+ var progress = G2PartialConnection()
153
+ progress.update(leftConnected: true, rightConnected: false, now: 10)
154
+ XCTAssertEqual(progress.visibleMissingSide(now: 13), .right)
155
+ progress.update(leftConnected: true, rightConnected: true, now: 14)
156
+ XCTAssertNil(progress.visibleMissingSide(now: 14))
157
+ progress.update(leftConnected: false, rightConnected: true, now: 15)
158
+ XCTAssertNil(progress.visibleMissingSide(now: 17))
159
+ XCTAssertEqual(progress.visibleMissingSide(now: 18), .left)
160
+ progress = G2PartialConnection()
161
+ XCTAssertNil(progress.visibleMissingSide(now: 100))
162
+ }
163
+
164
+ func testStatusSnapshotAndUpdateCarryNoticeAndDisconnectClearsIt() throws {
165
+ let status = GlassesStatus(values: ["g2MissingArm": "left"])
166
+ XCTAssertEqual(status.dictionary["g2MissingArm"] as? String, "left")
167
+ XCTAssertEqual(GlassesStatusUpdate(values: ["g2MissingArm": "right"]).dictionary["g2MissingArm"] as? String, "right")
168
+ let disconnected = status.disconnected().dictionary
169
+ XCTAssertTrue(disconnected["g2MissingArm"] is NSNull)
170
+ XCTAssertNoThrow(try JSONSerialization.data(withJSONObject: disconnected))
171
+ }
172
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mentra/bluetooth-sdk",
3
- "version": "3.2.1-beta.459",
3
+ "version": "3.2.1-beta.467",
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.459"
117
+ "@mentra/cloud-protocol": "3.2.1-beta.467"
118
118
  },
119
119
  "devDependencies": {
120
120
  "@types/node": "^25.9.3",
@@ -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"},
@@ -1613,6 +1614,8 @@ export interface OtaProgress {
1613
1614
  }
1614
1615
 
1615
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
1616
1619
  // state:
1617
1620
  connection: GlassesConnectionStatus
1618
1621
  micEnabled: boolean
@@ -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.459",
16
- "releaseSetId": "mentra-3.2.1-beta.459",
17
- "sourceCommit": "4e485930e223459b6c6f4a4f30072be409611f3d",
18
- "otaManifestUrl": "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.2.1/mentra-live-ota-3.2.1-beta.459.json",
19
- "otaManifestSha256": "82457abc1aa4faa1aea0ff67b2e90ab5a5cb180a6ee1945e92465305124445e9"
15
+ "releaseIdentity": "3.2.1-beta.467",
16
+ "releaseSetId": "mentra-3.2.1-beta.467",
17
+ "sourceCommit": "29ca2d88d95e44f372c74aaab73299dd9ed75745",
18
+ "otaManifestUrl": "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.2.1/mentra-live-ota-3.2.1-beta.467.json",
19
+ "otaManifestSha256": "2db9f33a4be723fb637cd2fba163003d5f2b276a9995f7fe576c194e0512353c"
20
20
  })