@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.
- package/README.md +19 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +14 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +7 -2
- package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +5 -5
- package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +33 -7
- package/android/src/main/java/com/mentra/bluetoothsdk/VersionInfoResponseAccumulator.kt +88 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/services/Foreground.kt +50 -16
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +314 -75
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLiveGattLifecycle.kt +64 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SerializedGattCallback.kt +76 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/status/DeviceStatus.kt +2 -3
- package/android/src/test/java/com/mentra/bluetoothsdk/VersionInfoBridgeTest.kt +33 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/VersionInfoResponseAccumulatorTest.kt +169 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/services/ForegroundServiceManifestTest.kt +57 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/services/ForegroundServiceTypeTest.kt +52 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveGattCallbackTest.kt +94 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveGattLifecycleTest.kt +112 -0
- package/build/generated/releaseMetadata.js +5 -5
- package/build/generated/releaseMetadata.js.map +1 -1
- package/ios/Source/BluetoothSdkDefaults.swift +1 -1
- package/ios/Source/Bridge.swift +15 -1
- package/ios/Source/DeviceManager.swift +7 -3
- package/ios/Source/GeneratedReleaseMetadata.swift +5 -5
- package/ios/Source/MentraBluetoothSDK.swift +32 -6
- package/ios/Source/requests/VersionInfoResponseAccumulator.swift +101 -0
- package/ios/Source/sgcs/MentraLive.swift +9 -2
- package/ios/Source/status/DeviceStatus.swift +2 -3
- package/ios/Tests/VersionInfoBridgeTests.swift +32 -0
- package/ios/Tests/VersionInfoResponseAccumulatorTests.swift +181 -0
- package/package.json +1 -1
- 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.
|
|
5
|
-
"releaseSetId": "mentra-3.2.0-dev.
|
|
6
|
-
"sourceCommit": "
|
|
7
|
-
"otaManifestUrl": "https://github.com/Mentra-Community/MentraOS/releases/download/mentra-builds-v3.2.0/mentra-live-ota-3.2.0-dev.
|
|
8
|
-
"otaManifestSha256": "
|
|
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.
|
|
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.
|
|
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? {
|
package/ios/Source/Bridge.swift
CHANGED
|
@@ -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
|
|
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.
|
|
7
|
-
static let releaseSetId = "mentra-3.2.0-dev.
|
|
8
|
-
static let sourceCommit = "
|
|
9
|
-
static let otaManifestUrl = "https://github.com/Mentra-Community/MentraOS/releases/download/mentra-builds-v3.2.0/mentra-live-ota-3.2.0-dev.
|
|
10
|
-
static let otaManifestSha256 = "
|
|
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:
|
|
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
|
-
|
|
1247
|
-
|
|
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 ===
|
|
1263
|
+
if pendingVersionInfo === request {
|
|
1251
1264
|
pendingVersionInfo = nil
|
|
1252
1265
|
}
|
|
1253
1266
|
return status
|
|
1254
1267
|
} catch {
|
|
1255
|
-
if pendingVersionInfo ===
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
424
|
-
//
|
|
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
|
+
}
|