@mentra/bluetooth-sdk 3.2.0-dev.195 → 3.2.0-dev.200

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/README.md +19 -1
  2. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +14 -1
  3. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +7 -2
  4. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +5 -5
  5. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +33 -7
  6. package/android/src/main/java/com/mentra/bluetoothsdk/VersionInfoResponseAccumulator.kt +88 -0
  7. package/android/src/main/java/com/mentra/bluetoothsdk/services/Foreground.kt +50 -16
  8. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +314 -75
  9. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLiveGattLifecycle.kt +64 -0
  10. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SerializedGattCallback.kt +76 -0
  11. package/android/src/main/java/com/mentra/bluetoothsdk/status/DeviceStatus.kt +2 -3
  12. package/android/src/test/java/com/mentra/bluetoothsdk/VersionInfoBridgeTest.kt +33 -0
  13. package/android/src/test/java/com/mentra/bluetoothsdk/VersionInfoResponseAccumulatorTest.kt +169 -0
  14. package/android/src/test/java/com/mentra/bluetoothsdk/services/ForegroundServiceManifestTest.kt +57 -0
  15. package/android/src/test/java/com/mentra/bluetoothsdk/services/ForegroundServiceTypeTest.kt +52 -0
  16. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveGattCallbackTest.kt +94 -0
  17. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveGattLifecycleTest.kt +112 -0
  18. package/build/generated/releaseMetadata.js +5 -5
  19. package/build/generated/releaseMetadata.js.map +1 -1
  20. package/ios/Source/BluetoothSdkDefaults.swift +1 -1
  21. package/ios/Source/Bridge.swift +15 -1
  22. package/ios/Source/DeviceManager.swift +7 -3
  23. package/ios/Source/GeneratedReleaseMetadata.swift +5 -5
  24. package/ios/Source/MentraBluetoothSDK.swift +32 -6
  25. package/ios/Source/requests/VersionInfoResponseAccumulator.swift +101 -0
  26. package/ios/Source/sgcs/MentraLive.swift +9 -2
  27. package/ios/Source/status/DeviceStatus.swift +2 -3
  28. package/ios/Tests/VersionInfoBridgeTests.swift +32 -0
  29. package/ios/Tests/VersionInfoResponseAccumulatorTests.swift +181 -0
  30. package/package.json +1 -1
  31. package/src/generated/releaseMetadata.ts +5 -5
@@ -0,0 +1,112 @@
1
+ package com.mentra.bluetoothsdk.sgcs
2
+
3
+ import org.junit.Assert.assertEquals
4
+ import org.junit.Assert.assertFalse
5
+ import org.junit.Assert.assertTrue
6
+ import org.junit.Test
7
+
8
+ class MentraLiveGattLifecycleTest {
9
+ @Test
10
+ fun `replacement connection waits for disconnect completion`() {
11
+ val barrier = MentraLiveGattTeardownBarrier()
12
+ val events = mutableListOf<String>()
13
+ val teardown = barrier.beginTeardown()
14
+
15
+ barrier.runWhenIdle { events.add("connect") }
16
+ assertEquals(emptyList<String>(), events)
17
+
18
+ events.add("disconnect")
19
+ barrier.completeTeardown(teardown)
20
+
21
+ assertEquals(listOf("disconnect", "connect"), events)
22
+ }
23
+
24
+ @Test
25
+ fun `all active teardowns must finish before connections resume`() {
26
+ val barrier = MentraLiveGattTeardownBarrier()
27
+ val events = mutableListOf<String>()
28
+ val first = barrier.beginTeardown()
29
+ val second = barrier.beginTeardown()
30
+
31
+ barrier.runWhenIdle { events.add("connect") }
32
+ barrier.completeTeardown(first)
33
+ assertTrue(events.isEmpty())
34
+
35
+ barrier.completeTeardown(second)
36
+ assertEquals(listOf("connect"), events)
37
+ }
38
+
39
+ @Test
40
+ fun `newest deferred connection replaces an older retry`() {
41
+ val barrier = MentraLiveGattTeardownBarrier()
42
+ val events = mutableListOf<String>()
43
+ val teardown = barrier.beginTeardown()
44
+
45
+ barrier.runWhenIdle { events.add("stale reconnect") }
46
+ barrier.runWhenIdle { events.add("current user retry") }
47
+ barrier.completeTeardown(teardown)
48
+
49
+ assertEquals(listOf("current user retry"), events)
50
+ }
51
+
52
+ @Test
53
+ fun `timeout releases connection and late disconnect cannot release it twice`() {
54
+ val barrier = MentraLiveGattTeardownBarrier()
55
+ var connections = 0
56
+ val teardown = barrier.beginTeardown()
57
+
58
+ barrier.runWhenIdle { connections++ }
59
+ barrier.completeTeardown(teardown)
60
+ barrier.completeTeardown(teardown)
61
+
62
+ assertEquals(1, connections)
63
+ val connectImmediately: () -> Unit = { connections += 1 }
64
+ barrier.runWhenIdle(connectImmediately)
65
+ assertEquals(2, connections)
66
+ }
67
+
68
+ @Test
69
+ fun `admitted connection runs inline before another teardown can begin`() {
70
+ val barrier = MentraLiveGattTeardownBarrier()
71
+ val events = mutableListOf<String>()
72
+ barrier.runWhenIdle { events.add("connect") }
73
+ barrier.beginTeardown()
74
+ events.add("teardown")
75
+ assertEquals(listOf("connect", "teardown"), events)
76
+ }
77
+
78
+ @Test
79
+ fun `resumed connection can open another teardown without losing newer work`() {
80
+ val barrier = MentraLiveGattTeardownBarrier()
81
+ val first = barrier.beginTeardown()
82
+ var next = 0L
83
+ val events = mutableListOf<String>()
84
+ barrier.runWhenIdle {
85
+ next = barrier.beginTeardown()
86
+ barrier.runWhenIdle { events.add("next") }
87
+ }
88
+ barrier.completeTeardown(first)
89
+ assertTrue(events.isEmpty())
90
+ barrier.completeTeardown(next)
91
+ assertEquals(listOf("next"), events)
92
+ }
93
+
94
+ @Test
95
+ fun `mtu callback and watchdog share one completion`() {
96
+ val gate = MentraLiveMtuSetupGate()
97
+ val setup = gate.begin()
98
+
99
+ assertTrue(gate.complete(setup))
100
+ assertFalse(gate.complete(setup))
101
+ }
102
+
103
+ @Test
104
+ fun `cancelled mtu setup rejects a late callback`() {
105
+ val gate = MentraLiveMtuSetupGate()
106
+ val setup = gate.begin()
107
+
108
+ gate.cancel()
109
+
110
+ assertFalse(gate.complete(setup))
111
+ }
112
+ }
@@ -1,10 +1,10 @@
1
1
  export const BLUETOOTH_SDK_RELEASE_METADATA = Object.freeze({
2
2
  "schemaVersion": 1,
3
3
  "familyBaseVersion": "3.2.0",
4
- "releaseIdentity": "3.2.0-dev.195",
5
- "releaseSetId": "mentra-3.2.0-dev.195",
6
- "sourceCommit": "321202ca96ec1852be19a7230ebcc3593fe87e54",
7
- "otaManifestUrl": "https://github.com/Mentra-Community/MentraOS/releases/download/mentra-builds-v3.2.0/mentra-live-ota-3.2.0-dev.195.json",
8
- "otaManifestSha256": "70f26063b2c281b9b046965f6f6d7ca22f8b53242c6c5be02030c5a04c7115e4"
4
+ "releaseIdentity": "3.2.0-dev.200",
5
+ "releaseSetId": "mentra-3.2.0-dev.200",
6
+ "sourceCommit": "1cdd911f596eba7b2b2f0e9ba5655c7ff16b5362",
7
+ "otaManifestUrl": "https://github.com/Mentra-Community/MentraOS/releases/download/mentra-builds-v3.2.0/mentra-live-ota-3.2.0-dev.200.json",
8
+ "otaManifestSha256": "f0b5cca2fa4d1d43acf5933fb4de629819d36a145a162c763f4faa27a7c8ff79"
9
9
  });
10
10
  //# sourceMappingURL=releaseMetadata.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"releaseMetadata.js","sourceRoot":"","sources":["../../src/generated/releaseMetadata.ts"],"names":[],"mappings":"AAWA,MAAM,CAAC,MAAM,8BAA8B,GAA0C,MAAM,CAAC,MAAM,CAAC;IACjG,eAAe,EAAE,CAAC;IAClB,mBAAmB,EAAE,OAAO;IAC5B,iBAAiB,EAAE,eAAe;IAClC,cAAc,EAAE,sBAAsB;IACtC,cAAc,EAAE,0CAA0C;IAC1D,gBAAgB,EAAE,wHAAwH;IAC1I,mBAAmB,EAAE,kEAAkE;CACxF,CAAC,CAAA","sourcesContent":["/** Generated by release CI. Do not edit in a release checkout. */\nexport interface BluetoothSdkReleaseMetadata {\n schemaVersion: 1\n familyBaseVersion: string | null\n releaseIdentity: string | null\n releaseSetId: string | null\n sourceCommit: string | null\n otaManifestUrl: string | null\n otaManifestSha256: string | null\n}\n\nexport const BLUETOOTH_SDK_RELEASE_METADATA: Readonly<BluetoothSdkReleaseMetadata> = Object.freeze({\n \"schemaVersion\": 1,\n \"familyBaseVersion\": \"3.2.0\",\n \"releaseIdentity\": \"3.2.0-dev.195\",\n \"releaseSetId\": \"mentra-3.2.0-dev.195\",\n \"sourceCommit\": \"321202ca96ec1852be19a7230ebcc3593fe87e54\",\n \"otaManifestUrl\": \"https://github.com/Mentra-Community/MentraOS/releases/download/mentra-builds-v3.2.0/mentra-live-ota-3.2.0-dev.195.json\",\n \"otaManifestSha256\": \"70f26063b2c281b9b046965f6f6d7ca22f8b53242c6c5be02030c5a04c7115e4\"\n})\n"]}
1
+ {"version":3,"file":"releaseMetadata.js","sourceRoot":"","sources":["../../src/generated/releaseMetadata.ts"],"names":[],"mappings":"AAWA,MAAM,CAAC,MAAM,8BAA8B,GAA0C,MAAM,CAAC,MAAM,CAAC;IACjG,eAAe,EAAE,CAAC;IAClB,mBAAmB,EAAE,OAAO;IAC5B,iBAAiB,EAAE,eAAe;IAClC,cAAc,EAAE,sBAAsB;IACtC,cAAc,EAAE,0CAA0C;IAC1D,gBAAgB,EAAE,wHAAwH;IAC1I,mBAAmB,EAAE,kEAAkE;CACxF,CAAC,CAAA","sourcesContent":["/** Generated by release CI. Do not edit in a release checkout. */\nexport interface BluetoothSdkReleaseMetadata {\n schemaVersion: 1\n familyBaseVersion: string | null\n releaseIdentity: string | null\n releaseSetId: string | null\n sourceCommit: string | null\n otaManifestUrl: string | null\n otaManifestSha256: string | null\n}\n\nexport const BLUETOOTH_SDK_RELEASE_METADATA: Readonly<BluetoothSdkReleaseMetadata> = Object.freeze({\n \"schemaVersion\": 1,\n \"familyBaseVersion\": \"3.2.0\",\n \"releaseIdentity\": \"3.2.0-dev.200\",\n \"releaseSetId\": \"mentra-3.2.0-dev.200\",\n \"sourceCommit\": \"1cdd911f596eba7b2b2f0e9ba5655c7ff16b5362\",\n \"otaManifestUrl\": \"https://github.com/Mentra-Community/MentraOS/releases/download/mentra-builds-v3.2.0/mentra-live-ota-3.2.0-dev.200.json\",\n \"otaManifestSha256\": \"f0b5cca2fa4d1d43acf5933fb4de629819d36a145a162c763f4faa27a7c8ff79\"\n})\n"]}
@@ -11,7 +11,7 @@ enum BluetoothSdkDefaults {
11
11
  static let voiceActivityDetectionEnabled = false
12
12
  static let loudnessGateEnabled = false
13
13
  private static let infoSdkVersionKey = "MentraBluetoothSdkVersion"
14
- private static let swiftPackageSdkVersion = "3.2.0-dev.195"
14
+ private static let swiftPackageSdkVersion = "3.2.0-dev.200"
15
15
  private static let swiftPackageSdkVersionPlaceholder = "__MENTRA" + "_BLUETOOTH_SDK_VERSION__"
16
16
 
17
17
  private static func normalizedSdkVersion(_ value: String?) -> String? {
@@ -366,9 +366,10 @@ class Bridge {
366
366
  Bridge.sendTypedMessage(type, body: body)
367
367
  }
368
368
 
369
- static func sendVersionInfo(_ values: [String: Any]) {
369
+ static func sendVersionInfo(_ values: [String: Any], responseChunk: String = "version_info") {
370
370
  var body: [String: Any] = [
371
371
  "type": "version_info",
372
+ VersionInfoResponseAccumulator.responseChunkKey: responseChunk,
372
373
  "androidVersion": stringValue(values, "androidVersion", "android_version") ?? "",
373
374
  "firmwareVersion": stringValue(values, "firmwareVersion", "firmware_version") ?? "",
374
375
  "besFirmwareVersion": stringValue(values, "besFirmwareVersion", "bes_fw_version") ?? "",
@@ -377,6 +378,19 @@ class Bridge {
377
378
  "otaVersionUrl": stringValue(values, "otaVersionUrl", "ota_version_url") ?? "",
378
379
  "appVersion": stringValue(values, "appVersion", "app_version") ?? "",
379
380
  ]
381
+ for (wireKey, internalKey) in [
382
+ "chunkIndex": VersionInfoResponseAccumulator.responseIndexKey,
383
+ "chunkCount": VersionInfoResponseAccumulator.responseCountKey,
384
+ "final": VersionInfoResponseAccumulator.responseFinalKey,
385
+ "sid": VersionInfoResponseAccumulator.responseSidKey,
386
+ ] {
387
+ body[internalKey] = values[wireKey]
388
+ }
389
+ if let responseRequestId = stringValue(values, "requestId", "request_id"),
390
+ !responseRequestId.isEmpty
391
+ {
392
+ body[VersionInfoResponseAccumulator.responseRequestIdKey] = responseRequestId
393
+ }
380
394
  if let systemTimeMs = intValue(values["systemTimeMs"]) ?? intValue(values["system_time_ms"]) {
381
395
  body["systemTimeMs"] = systemTimeMs
382
396
  }
@@ -1032,7 +1032,7 @@ struct ViewState {
1032
1032
 
1033
1033
  let connectionKey = "\(sgc.type):\(deviceName)"
1034
1034
  syncSystemTimeOnceForConnection(sgc, connectionKey: connectionKey)
1035
-
1035
+
1036
1036
  // re-apply display height/depth after reconnection
1037
1037
  DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
1038
1038
  // Re-read the current sgc rather than capturing the connect-time instance: the user may
@@ -1682,9 +1682,13 @@ struct ViewState {
1682
1682
 
1683
1683
  /// Request version info from glasses.
1684
1684
  /// Glasses will respond with version_info message containing build number, firmware version, etc.
1685
- func requestVersionInfo() {
1685
+ func requestVersionInfo(requestId: String? = nil) {
1686
1686
  Bridge.log("MAN: 📱 Requesting version info from glasses")
1687
- sgc?.requestVersionInfo()
1687
+ if let live = sgc as? MentraLive {
1688
+ live.requestVersionInfo(requestId: requestId)
1689
+ } else {
1690
+ sgc?.requestVersionInfo()
1691
+ }
1688
1692
  }
1689
1693
 
1690
1694
  /// Send shutdown command to glasses.
@@ -3,9 +3,9 @@ import Foundation
3
3
  /// Generated by release CI. Do not edit in a release checkout.
4
4
  enum GeneratedReleaseMetadata {
5
5
  static let familyBaseVersion = "3.2.0"
6
- static let releaseIdentity = "3.2.0-dev.195"
7
- static let releaseSetId = "mentra-3.2.0-dev.195"
8
- static let sourceCommit = "321202ca96ec1852be19a7230ebcc3593fe87e54"
9
- static let otaManifestUrl = "https://github.com/Mentra-Community/MentraOS/releases/download/mentra-builds-v3.2.0/mentra-live-ota-3.2.0-dev.195.json"
10
- static let otaManifestSha256 = "70f26063b2c281b9b046965f6f6d7ca22f8b53242c6c5be02030c5a04c7115e4"
6
+ static let releaseIdentity = "3.2.0-dev.200"
7
+ static let releaseSetId = "mentra-3.2.0-dev.200"
8
+ static let sourceCommit = "1cdd911f596eba7b2b2f0e9ba5655c7ff16b5362"
9
+ static let otaManifestUrl = "https://github.com/Mentra-Community/MentraOS/releases/download/mentra-builds-v3.2.0/mentra-live-ota-3.2.0-dev.200.json"
10
+ static let otaManifestSha256 = "f0b5cca2fa4d1d43acf5933fb4de629819d36a145a162c763f4faa27a7c8ff79"
11
11
  }
@@ -101,6 +101,17 @@ private final class PendingVideoRecordingRequest {
101
101
  }
102
102
  }
103
103
 
104
+ @MainActor
105
+ private final class PendingVersionInfoRequest {
106
+ let pending: PendingResponse<VersionInfoResult>
107
+ let accumulator: VersionInfoResponseAccumulator
108
+
109
+ init(pending: PendingResponse<VersionInfoResult>, requestId: String) {
110
+ self.pending = pending
111
+ accumulator = VersionInfoResponseAccumulator(expectedRequestId: requestId)
112
+ }
113
+ }
114
+
104
115
  // seq records send order (assigned and handed to the BLE queue in a single
105
116
  // MainActor turn) so that an id-carrying status for a newer start can
106
117
  // identify which older in-flight starts it preempted.
@@ -219,7 +230,7 @@ public final class MentraBluetoothSDK {
219
230
  private var wifiScanTask: Task<[WifiScanResult], Error>?
220
231
  private var pendingWifiStatus: PendingWifiStatusRequest?
221
232
  private var pendingHotspotStatus: PendingHotspotStatusRequest?
222
- private var pendingVersionInfo: PendingResponse<VersionInfoResult>?
233
+ private var pendingVersionInfo: PendingVersionInfoRequest?
223
234
  private var configuredOtaVersionUrl: String?
224
235
 
225
236
  public init(configuration: MentraBluetoothSDKConfiguration = .default) {
@@ -1242,17 +1253,19 @@ public final class MentraBluetoothSDK {
1242
1253
  message: "A version info request is already waiting for a glasses response."
1243
1254
  )
1244
1255
  }
1256
+ let requestId = UUID().uuidString
1245
1257
  let pending = PendingResponse<VersionInfoResult>(operation: "version info request")
1246
- pendingVersionInfo = pending
1247
- DeviceManager.shared.requestVersionInfo()
1258
+ let request = PendingVersionInfoRequest(pending: pending, requestId: requestId)
1259
+ pendingVersionInfo = request
1260
+ DeviceManager.shared.requestVersionInfo(requestId: requestId)
1248
1261
  do {
1249
1262
  let status = try await pending.wait()
1250
- if pendingVersionInfo === pending {
1263
+ if pendingVersionInfo === request {
1251
1264
  pendingVersionInfo = nil
1252
1265
  }
1253
1266
  return status
1254
1267
  } catch {
1255
- if pendingVersionInfo === pending {
1268
+ if pendingVersionInfo === request {
1256
1269
  pendingVersionInfo = nil
1257
1270
  }
1258
1271
  throw error
@@ -2175,6 +2188,19 @@ private func dispatchDiscoveredDevices(_ rawSearchResults: Any?) {
2175
2188
  activeSession.onComplete(activeSession.latestResults)
2176
2189
  }
2177
2190
 
2191
+ private func handleVersionInfoForRequest(_ data: [String: Any]) {
2192
+ guard let request = pendingVersionInfo else { return }
2193
+ switch request.accumulator.accept(data) {
2194
+ case .ignored:
2195
+ break
2196
+ case .waiting:
2197
+ break
2198
+ case let .complete(result):
2199
+ pendingVersionInfo = nil
2200
+ request.pending.resolve(result)
2201
+ }
2202
+ }
2203
+
2178
2204
  private func dispatchBridgeEvent(_ eventName: String, _ data: [String: Any]) {
2179
2205
  switch eventName {
2180
2206
  case "log":
@@ -2307,7 +2333,7 @@ private func dispatchDiscoveredDevices(_ rawSearchResults: Any?) {
2307
2333
  delegate?.mentraBluetoothSDK(self, didReceive: .settingsAck(event))
2308
2334
  case "version_info":
2309
2335
  let event = VersionInfoResult(values: data)
2310
- pendingVersionInfo?.resolve(event)
2336
+ handleVersionInfoForRequest(data)
2311
2337
  delegate?.mentraBluetoothSDK(self, didReceive: .versionInfo(event))
2312
2338
  case "compatible_glasses_search_stop":
2313
2339
  delegate?.mentraBluetoothSDK(self, didStopScan: .completed)
@@ -0,0 +1,101 @@
1
+ import CoreFoundation
2
+ import Foundation
3
+
4
+ enum VersionInfoAccumulatorOutcome {
5
+ case ignored
6
+ case waiting
7
+ case complete(VersionInfoResult)
8
+ }
9
+
10
+ /// One request, explicit modern completion, or the deployed legacy chunk-3 terminal boundary.
11
+ final class VersionInfoResponseAccumulator {
12
+ static let responseChunkKey = "_responseChunk"
13
+ static let responseRequestIdKey = "_responseRequestId"
14
+ static let responseIndexKey = "_responseChunkIndex"
15
+ static let responseCountKey = "_responseChunkCount"
16
+ static let responseFinalKey = "_responseFinal"
17
+ static let responseSidKey = "_responseSid"
18
+ private static let modernKeys = [responseRequestIdKey, responseIndexKey, responseCountKey, responseFinalKey]
19
+
20
+ private let expectedRequestId: String
21
+ private var values: [String: Any] = [:]
22
+ private var chunks: [Int: [String: Any]] = [:]
23
+ private var count: Int?
24
+ private var sid: String?
25
+ private var legacyStarted = false
26
+ private var completed = false
27
+
28
+ init(expectedRequestId: String) {
29
+ self.expectedRequestId = expectedRequestId
30
+ }
31
+
32
+ func accept(_ event: [String: Any]) -> VersionInfoAccumulatorOutcome {
33
+ guard !completed else { return .ignored }
34
+ if Self.modernKeys.contains(where: { event[$0] != nil }) {
35
+ guard event[Self.responseRequestIdKey] as? String == expectedRequestId,
36
+ let index = integer(event[Self.responseIndexKey]),
37
+ let total = integer(event[Self.responseCountKey]),
38
+ let final = event[Self.responseFinalKey] as? NSNumber,
39
+ CFGetTypeID(final) == CFBooleanGetTypeID(),
40
+ let process = event[Self.responseSidKey] as? String, !process.isEmpty,
41
+ (1 ... 16).contains(total), (1 ... total).contains(index),
42
+ final.boolValue == (index == total)
43
+ else { return .ignored }
44
+ if let count, count != total || sid != process { return .ignored }
45
+ if count == nil {
46
+ values.removeAll()
47
+ count = total
48
+ sid = process
49
+ }
50
+ chunks[index] = event
51
+ guard chunks.count == total else { return .waiting }
52
+ for index in 1 ... total {
53
+ merge(chunks[index]!)
54
+ }
55
+ return finish()
56
+ }
57
+ guard count == nil else { return .ignored }
58
+ switch event[Self.responseChunkKey] as? String ?? "version_info" {
59
+ case "version_info":
60
+ values.removeAll()
61
+ merge(event)
62
+ return finish()
63
+ case "version_info_1":
64
+ values.removeAll()
65
+ legacyStarted = true
66
+ merge(event)
67
+ case "version_info_2":
68
+ guard legacyStarted else { return .ignored }
69
+ merge(event)
70
+ case "version_info_3":
71
+ guard legacyStarted else { return .ignored }
72
+ merge(event)
73
+ return finish()
74
+ default: return .ignored
75
+ }
76
+ // Silence is never evidence of completion. The request's normal deadline handles loss.
77
+ return .waiting
78
+ }
79
+
80
+ private func finish() -> VersionInfoAccumulatorOutcome {
81
+ completed = true
82
+ return .complete(VersionInfoResult(values: values))
83
+ }
84
+
85
+ private func merge(_ event: [String: Any]) {
86
+ for (key, value) in event where !key.hasPrefix("_response") && key != "type" {
87
+ if let string = value as? String, string.isEmpty { continue }
88
+ values[key] = value
89
+ }
90
+ }
91
+
92
+ private func integer(_ value: Any?) -> Int? {
93
+ guard let number = value as? NSNumber,
94
+ CFGetTypeID(number) != CFBooleanGetTypeID(),
95
+ number.doubleValue.isFinite,
96
+ number.doubleValue >= 1, number.doubleValue <= 16,
97
+ number.doubleValue == Double(number.intValue)
98
+ else { return nil }
99
+ return number.intValue
100
+ }
101
+ }
@@ -3157,7 +3157,7 @@ class MentraLive: NSObject, SGCManager {
3157
3157
  maybeSendWireHandshake()
3158
3158
  handleGlassesSessionId(json)
3159
3159
 
3160
- Bridge.sendVersionInfo(fields)
3160
+ Bridge.sendVersionInfo(fields, responseChunk: type)
3161
3161
  } else {
3162
3162
  Bridge.log("Unhandled message type: \(type)")
3163
3163
  }
@@ -5026,7 +5026,14 @@ class MentraLive: NSObject, SGCManager {
5026
5026
  }
5027
5027
 
5028
5028
  func requestVersionInfo() {
5029
- let json: [String: Any] = ["type": "request_version"]
5029
+ requestVersionInfo(requestId: nil)
5030
+ }
5031
+
5032
+ func requestVersionInfo(requestId: String?) {
5033
+ var json: [String: Any] = ["type": "request_version"]
5034
+ if let requestId {
5035
+ json["request_id"] = requestId
5036
+ }
5030
5037
  sendJson(json)
5031
5038
  }
5032
5039
 
@@ -420,9 +420,8 @@ public struct VersionInfoResult: CustomStringConvertible {
420
420
  if let systemTimeMs {
421
421
  values["systemTimeMs"] = systemTimeMs
422
422
  }
423
- // Only when known. requestVersionInfo() resolves from ANY version_info chunk, and only
424
- // chunk 1 carries package_name emitting "" from a chunk-3 resolution would overwrite a
425
- // known identity with "absent", which the OTA guard reads as stock.
423
+ // Only when known: per-chunk events may omit package identity. Emitting an empty value
424
+ // would overwrite a known identity, which the OTA guard uses to identify stock.
426
425
  if !packageName.isEmpty {
427
426
  values["packageName"] = packageName
428
427
  }
@@ -0,0 +1,32 @@
1
+ @testable import MentraBluetoothSDK
2
+ import XCTest
3
+
4
+ final class VersionInfoBridgeTests: XCTestCase {
5
+ func testWireMetadataSurvivesBridgeNormalization() {
6
+ let accumulator = VersionInfoResponseAccumulator(expectedRequestId: "request-1")
7
+ let delivered = expectation(description: "Both version chunks")
8
+ delivered.expectedFulfillmentCount = 2
9
+ var outcomes: [VersionInfoAccumulatorOutcome] = []
10
+ let sink = Bridge.addEventSink { type, body in
11
+ guard type == "version_info" else { return }
12
+ outcomes.append(accumulator.accept(body))
13
+ delivered.fulfill()
14
+ }
15
+ defer { Bridge.removeEventSink(sink) }
16
+ Bridge.sendVersionInfo([
17
+ "request_id": "request-1", "sid": "asg-1", "chunkCount": 2,
18
+ "chunkIndex": 1, "final": false, "build_number": "42",
19
+ ], responseChunk: "version_info_1")
20
+ Bridge.sendVersionInfo([
21
+ "request_id": "request-1", "sid": "asg-1", "chunkCount": 2,
22
+ "chunkIndex": 2, "final": true, "bes_fw_version": "new",
23
+ ], responseChunk: "version_info_3")
24
+ wait(for: [delivered], timeout: 2)
25
+ guard case .waiting = outcomes[0], case let .complete(result) = outcomes[1] else {
26
+ return XCTFail("Expected an explicitly complete combined response")
27
+ }
28
+ XCTAssertEqual(result.buildNumber, "42")
29
+ XCTAssertEqual(result.besFirmwareVersion, "new")
30
+ XCTAssertFalse(result.dictionary.keys.contains { $0.hasPrefix("_response") })
31
+ }
32
+ }
@@ -0,0 +1,181 @@
1
+ @testable import MentraBluetoothSDK
2
+ import XCTest
3
+
4
+ final class VersionInfoResponseAccumulatorTests: XCTestCase {
5
+ func testMergesCurrentChunksAndCompletesOnFirmwareChunk() {
6
+ let accumulator = VersionInfoResponseAccumulator(expectedRequestId: "request-1")
7
+
8
+ guard case .waiting = accumulator.accept(
9
+ chunk("version_info_1", "request-1", ["buildNumber": "42"])
10
+ )
11
+ else {
12
+ return XCTFail("Expected first chunk to wait")
13
+ }
14
+
15
+ let outcome = accumulator.accept(
16
+ chunk(
17
+ "version_info_3",
18
+ "request-1",
19
+ [
20
+ "buildNumber": "",
21
+ "besFirmwareVersion": "26.8.27.0",
22
+ "mtkFirmwareVersion": "MentraLive_20260709",
23
+ ]
24
+ )
25
+ )
26
+ guard case let .complete(result) = outcome else {
27
+ return XCTFail("Expected final chunk to complete")
28
+ }
29
+
30
+ XCTAssertEqual(result.buildNumber, "42")
31
+ XCTAssertEqual(result.besFirmwareVersion, "26.8.27.0")
32
+ XCTAssertEqual(result.mtkFirmwareVersion, "MentraLive_20260709")
33
+ }
34
+
35
+ func testIgnoresMismatchedAndTrailingStaleChunks() {
36
+ let accumulator = VersionInfoResponseAccumulator(expectedRequestId: "request-1")
37
+
38
+ guard case .ignored = accumulator.accept(chunk("version_info_1", "other", ["buildNumber": "old"]))
39
+ else {
40
+ return XCTFail("Expected mismatched response to be ignored")
41
+ }
42
+ guard case .ignored = accumulator.accept(chunk("version_info_3", nil, ["besFirmwareVersion": "stale"]))
43
+ else {
44
+ return XCTFail("Expected stale trailing chunk to be ignored")
45
+ }
46
+ }
47
+
48
+ func testDoesNotMixCorrelatedAndUncorrelatedSequences() {
49
+ let accumulator = VersionInfoResponseAccumulator(expectedRequestId: "request-1")
50
+ _ = accumulator.accept(chunk("version_info_1", "request-1", ["buildNumber": "42"]))
51
+
52
+ guard case .ignored = accumulator.accept(
53
+ chunk("version_info_3", nil, ["besFirmwareVersion": "stale"])
54
+ ) else {
55
+ return XCTFail("Expected uncorrelated trailing chunk to be ignored")
56
+ }
57
+
58
+ let outcome = accumulator.accept(
59
+ chunk("version_info_3", "request-1", ["besFirmwareVersion": "current"])
60
+ )
61
+ guard case let .complete(result) = outcome else {
62
+ return XCTFail("Expected correlated final chunk to complete")
63
+ }
64
+ XCTAssertEqual(result.besFirmwareVersion, "current")
65
+ }
66
+
67
+ func testStaleUncorrelatedResponsesCannotReplaceCorrelatedSequence() {
68
+ let accumulator = VersionInfoResponseAccumulator(expectedRequestId: "request-1")
69
+ _ = accumulator.accept(chunk("version_info_1", "request-1", ["buildNumber": "42"]))
70
+
71
+ guard case .ignored = accumulator.accept(chunk("version_info_1", nil, ["buildNumber": "stale"]))
72
+ else {
73
+ return XCTFail("Expected stale first chunk to be ignored")
74
+ }
75
+ guard case .ignored = accumulator.accept(chunk("version_info", nil, ["buildNumber": "legacy"]))
76
+ else {
77
+ return XCTFail("Expected stale legacy response to be ignored")
78
+ }
79
+
80
+ let outcome = accumulator.accept(
81
+ chunk("version_info_3", "request-1", ["besFirmwareVersion": "current"])
82
+ )
83
+ guard case let .complete(result) = outcome else {
84
+ return XCTFail("Expected correlated final chunk to complete")
85
+ }
86
+ XCTAssertEqual(result.buildNumber, "42")
87
+ XCTAssertEqual(result.besFirmwareVersion, "current")
88
+ }
89
+
90
+ func testRepeatedFirstChunkResetsRatherThanMixingResponses() {
91
+ let accumulator = VersionInfoResponseAccumulator(expectedRequestId: "request-1")
92
+ _ = accumulator.accept(chunk("version_info_1", nil, ["appVersion": "old"]))
93
+ _ = accumulator.accept(chunk("version_info_1", nil, ["buildNumber": "43"]))
94
+
95
+ guard case let .complete(result) = accumulator.accept(
96
+ chunk("version_info_3", nil, ["besFirmwareVersion": "new"])
97
+ ) else { return XCTFail("Legacy final chunk must complete immediately") }
98
+ XCTAssertEqual(result.appVersion, "")
99
+ XCTAssertEqual(result.buildNumber, "43")
100
+ XCTAssertEqual(result.besFirmwareVersion, "new")
101
+ }
102
+
103
+ func testLegacySingleMessageCompletesImmediately() {
104
+ let accumulator = VersionInfoResponseAccumulator(expectedRequestId: "request-1")
105
+
106
+ let outcome = accumulator.accept(chunk("version_info", nil, ["buildNumber": "7"]))
107
+ guard case let .complete(result) = outcome else {
108
+ return XCTFail("Expected legacy response to complete")
109
+ }
110
+ XCTAssertEqual(result.buildNumber, "7")
111
+ }
112
+
113
+ func testLegacyFirstChunkDoesNotComplete() {
114
+ let accumulator = VersionInfoResponseAccumulator(expectedRequestId: "request-1")
115
+ guard case .waiting = accumulator.accept(chunk("version_info_1", nil, ["buildNumber": "8"])) else {
116
+ return XCTFail("Partial response must wait for final chunk or deadline")
117
+ }
118
+ }
119
+
120
+ func testModernFinalChunkWaitsForMissingFirstChunk() {
121
+ let accumulator = VersionInfoResponseAccumulator(expectedRequestId: "request-1")
122
+ guard case .waiting = accumulator.accept(chunk("version_info_3", "request-1", ["besFirmwareVersion": "new"])) else {
123
+ return XCTFail("Missing first chunk")
124
+ }
125
+ guard case let .complete(result) = accumulator.accept(chunk("version_info_1", "request-1", ["buildNumber": "42"])) else {
126
+ return XCTFail("Both chunks arrived")
127
+ }
128
+ XCTAssertEqual(result.buildNumber, "42")
129
+ XCTAssertEqual(result.besFirmwareVersion, "new")
130
+ }
131
+
132
+ func testDuplicateDoesNotCountAsMissingChunk() {
133
+ let accumulator = VersionInfoResponseAccumulator(expectedRequestId: "request-1")
134
+ let first = chunk("version_info_1", "request-1", ["buildNumber": "42"])
135
+ _ = accumulator.accept(first)
136
+ guard case .waiting = accumulator.accept(first) else { return XCTFail("Duplicate is not completion") }
137
+ }
138
+
139
+ func testRejectsMalformedModernMetadataAndMixedProcess() {
140
+ let accumulator = VersionInfoResponseAccumulator(expectedRequestId: "request-1")
141
+ let first = chunk("version_info_1", "request-1", ["buildNumber": "42"])
142
+ for (key, value) in [
143
+ VersionInfoResponseAccumulator.responseCountKey: 2.5,
144
+ VersionInfoResponseAccumulator.responseIndexKey: true,
145
+ VersionInfoResponseAccumulator.responseFinalKey: true,
146
+ VersionInfoResponseAccumulator.responseRequestIdKey: "",
147
+ ] as [String: Any] {
148
+ var invalid = first
149
+ invalid[key] = value
150
+ guard case .ignored = accumulator.accept(invalid) else { return XCTFail("Invalid " + key) }
151
+ }
152
+ _ = accumulator.accept(first)
153
+ let last = chunk("version_info_3", "request-1", ["besFirmwareVersion": "new"])
154
+ var restarted = last
155
+ restarted[VersionInfoResponseAccumulator.responseSidKey] = "process-2"
156
+ guard case .ignored = accumulator.accept(restarted) else { return XCTFail("Mixed process") }
157
+ guard case .complete = accumulator.accept(last) else { return XCTFail("Matching final chunk") }
158
+ }
159
+
160
+ func testCorrelatedResponseWithoutCompletenessMetadataCannotComplete() {
161
+ let accumulator = VersionInfoResponseAccumulator(expectedRequestId: "request-1")
162
+ let event: [String: Any] = [
163
+ VersionInfoResponseAccumulator.responseChunkKey: "version_info_3",
164
+ VersionInfoResponseAccumulator.responseRequestIdKey: "request-1",
165
+ ]
166
+ guard case .ignored = accumulator.accept(event) else { return XCTFail("Incomplete modern metadata") }
167
+ }
168
+
169
+ private func chunk(_ type: String, _ requestId: String?, _ values: [String: Any]) -> [String: Any] {
170
+ var event = values
171
+ event[VersionInfoResponseAccumulator.responseChunkKey] = type
172
+ if let requestId {
173
+ event[VersionInfoResponseAccumulator.responseRequestIdKey] = requestId
174
+ event[VersionInfoResponseAccumulator.responseIndexKey] = type == "version_info_1" ? 1 : 2
175
+ event[VersionInfoResponseAccumulator.responseCountKey] = 2
176
+ event[VersionInfoResponseAccumulator.responseFinalKey] = type == "version_info_3"
177
+ event[VersionInfoResponseAccumulator.responseSidKey] = "process-1"
178
+ }
179
+ return event
180
+ }
181
+ }