@mentra/bluetooth-sdk 3.4.0-dev.979 → 3.4.0-dev.981
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/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +5 -5
- 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/GeneratedReleaseMetadata.swift +5 -5
- package/ios/Source/sgcs/NimoResourceFixture.swift +209 -12
- package/ios/Source/sgcs/NimoResourceSession.swift +74 -8
- package/ios/Tests/NimoResourceSessionTests.swift +128 -0
- package/package.json +2 -2
- package/src/generated/releaseMetadata.ts +5 -5
|
@@ -3,9 +3,9 @@ package com.mentra.bluetoothsdk
|
|
|
3
3
|
/** Generated by release CI. Do not edit in a release checkout. */
|
|
4
4
|
internal object GeneratedReleaseMetadata {
|
|
5
5
|
const val FAMILY_BASE_VERSION: String = "3.4.0"
|
|
6
|
-
const val RELEASE_IDENTITY: String = "3.4.0-dev.
|
|
7
|
-
const val RELEASE_SET_ID: String = "mentra-3.4.0-dev.
|
|
8
|
-
const val SOURCE_COMMIT: String = "
|
|
9
|
-
const val OTA_MANIFEST_URL: String = "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.4.0/mentra-live-ota-3.4.0-dev.
|
|
10
|
-
const val OTA_MANIFEST_SHA256: String = "
|
|
6
|
+
const val RELEASE_IDENTITY: String = "3.4.0-dev.981"
|
|
7
|
+
const val RELEASE_SET_ID: String = "mentra-3.4.0-dev.981"
|
|
8
|
+
const val SOURCE_COMMIT: String = "4c46c4b103f56975936e84f3c86c43136e3f072f"
|
|
9
|
+
const val OTA_MANIFEST_URL: String = "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.4.0/mentra-live-ota-3.4.0-dev.981.json"
|
|
10
|
+
const val OTA_MANIFEST_SHA256: String = "59bc6728ace4107c65306eb762c60c9a35ddaff236193d62d69095efc692e49e"
|
|
11
11
|
}
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
export const BLUETOOTH_SDK_RELEASE_METADATA = Object.freeze({
|
|
2
2
|
"schemaVersion": 1,
|
|
3
3
|
"familyBaseVersion": "3.4.0",
|
|
4
|
-
"releaseIdentity": "3.4.0-dev.
|
|
5
|
-
"releaseSetId": "mentra-3.4.0-dev.
|
|
6
|
-
"sourceCommit": "
|
|
7
|
-
"otaManifestUrl": "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.4.0/mentra-live-ota-3.4.0-dev.
|
|
8
|
-
"otaManifestSha256": "
|
|
4
|
+
"releaseIdentity": "3.4.0-dev.981",
|
|
5
|
+
"releaseSetId": "mentra-3.4.0-dev.981",
|
|
6
|
+
"sourceCommit": "4c46c4b103f56975936e84f3c86c43136e3f072f",
|
|
7
|
+
"otaManifestUrl": "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.4.0/mentra-live-ota-3.4.0-dev.981.json",
|
|
8
|
+
"otaManifestSha256": "59bc6728ace4107c65306eb762c60c9a35ddaff236193d62d69095efc692e49e"
|
|
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,iIAAiI;IACnJ,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.4.0\",\n \"releaseIdentity\": \"3.4.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,iIAAiI;IACnJ,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.4.0\",\n \"releaseIdentity\": \"3.4.0-dev.981\",\n \"releaseSetId\": \"mentra-3.4.0-dev.981\",\n \"sourceCommit\": \"4c46c4b103f56975936e84f3c86c43136e3f072f\",\n \"otaManifestUrl\": \"https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.4.0/mentra-live-ota-3.4.0-dev.981.json\",\n \"otaManifestSha256\": \"59bc6728ace4107c65306eb762c60c9a35ddaff236193d62d69095efc692e49e\"\n})\n"]}
|
|
@@ -16,7 +16,7 @@ enum BluetoothSdkDefaults {
|
|
|
16
16
|
/// silently disable it on every pair the app touches.
|
|
17
17
|
static let autoPowerOffEnabled = false
|
|
18
18
|
private static let infoSdkVersionKey = "MentraBluetoothSdkVersion"
|
|
19
|
-
private static let swiftPackageSdkVersion = "3.4.0-dev.
|
|
19
|
+
private static let swiftPackageSdkVersion = "3.4.0-dev.981"
|
|
20
20
|
private static let swiftPackageSdkVersionPlaceholder = "__MENTRA" + "_BLUETOOTH_SDK_VERSION__"
|
|
21
21
|
|
|
22
22
|
private static func normalizedSdkVersion(_ value: String?) -> String? {
|
|
@@ -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.4.0"
|
|
6
|
-
static let releaseIdentity = "3.4.0-dev.
|
|
7
|
-
static let releaseSetId = "mentra-3.4.0-dev.
|
|
8
|
-
static let sourceCommit = "
|
|
9
|
-
static let otaManifestUrl = "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.4.0/mentra-live-ota-3.4.0-dev.
|
|
10
|
-
static let otaManifestSha256 = "
|
|
6
|
+
static let releaseIdentity = "3.4.0-dev.981"
|
|
7
|
+
static let releaseSetId = "mentra-3.4.0-dev.981"
|
|
8
|
+
static let sourceCommit = "4c46c4b103f56975936e84f3c86c43136e3f072f"
|
|
9
|
+
static let otaManifestUrl = "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.4.0/mentra-live-ota-3.4.0-dev.981.json"
|
|
10
|
+
static let otaManifestSha256 = "59bc6728ace4107c65306eb762c60c9a35ddaff236193d62d69095efc692e49e"
|
|
11
11
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import Foundation
|
|
2
|
+
import CoreBluetooth
|
|
2
3
|
|
|
3
4
|
/// One process-local authority shared by the typed Expo bridge and the Mac
|
|
4
5
|
/// fixture socket. Neither accepts packets, connection addresses or file paths.
|
|
@@ -16,6 +17,14 @@ final class NimoResourceFixture {
|
|
|
16
17
|
let process = NimoResourceProcess(pid: ProcessInfo.processInfo.processIdentifier, instanceId: UUID().uuidString.lowercased())
|
|
17
18
|
private(set) var session: NimoResourceSession?
|
|
18
19
|
private(set) var owner: Nimo?
|
|
20
|
+
private var acceptedPair: NimoResourceSelection?
|
|
21
|
+
private var captureNonce: String?
|
|
22
|
+
private var armedElapsedMs: UInt64?
|
|
23
|
+
private var discovery: NimoResourceConnectedDiscovery?
|
|
24
|
+
private var discoveryRequestNonce: String?
|
|
25
|
+
private var discoveryReleaseNonce: String?
|
|
26
|
+
private var discoveryReleaseStartedElapsedMs: UInt64?
|
|
27
|
+
private var discoveryCancelled = false
|
|
19
28
|
private var completed: [String: (Data, Data)] = [:]
|
|
20
29
|
private var pending: [String: (Data, Task<Data, Error>)] = [:]
|
|
21
30
|
private var transport: NimoMacResourceTransport?
|
|
@@ -33,7 +42,16 @@ final class NimoResourceFixture {
|
|
|
33
42
|
#else
|
|
34
43
|
private let requested = false
|
|
35
44
|
#endif
|
|
36
|
-
|
|
45
|
+
#if SWIFT_PACKAGE
|
|
46
|
+
private var packageEnabled = false
|
|
47
|
+
#endif
|
|
48
|
+
var enabled: Bool {
|
|
49
|
+
#if SWIFT_PACKAGE
|
|
50
|
+
return requested || packageEnabled
|
|
51
|
+
#else
|
|
52
|
+
return requested
|
|
53
|
+
#endif
|
|
54
|
+
}
|
|
37
55
|
|
|
38
56
|
func start() {
|
|
39
57
|
guard enabled, transport == nil, startupError == nil else { return }
|
|
@@ -44,7 +62,7 @@ final class NimoResourceFixture {
|
|
|
44
62
|
guard enabled else { return true }
|
|
45
63
|
// A different manager can clean up the captured Nimo object. Permit it
|
|
46
64
|
// only after the original resource has actually completed teardown.
|
|
47
|
-
guard wearable.contains("NIMO") else { return NimoResourceManagerState.allowsReplacement(fixtureEnabled: enabled, originalClosed: originalClosed && session?.errors.isEmpty == true && session?.connected == false) }
|
|
65
|
+
guard wearable.contains("NIMO") else { return NimoResourceManagerState.allowsReplacement(fixtureEnabled: enabled, originalClosed: originalClosed && (session == nil || session?.errors.isEmpty == true && session?.connected == false)) }
|
|
48
66
|
guard let session, !session.cancelled, session.releaseNonce == nil, session.original == nil,
|
|
49
67
|
name == nil || name == session.pair.name else { return false }
|
|
50
68
|
return true
|
|
@@ -108,14 +126,15 @@ final class NimoResourceFixture {
|
|
|
108
126
|
if let (original, task) = pending[request.requestNonce] {
|
|
109
127
|
guard original == bytes else { throw failure("request-nonce-reused") }; return try await task.value
|
|
110
128
|
}
|
|
111
|
-
if request.operation != .arm {
|
|
112
|
-
guard
|
|
113
|
-
let
|
|
129
|
+
if request.operation != .arm && request.operation != .discover {
|
|
130
|
+
guard captureNonce == request.captureNonce, acceptedPair == request.expectedPair else { throw failure("capture-mismatch") }
|
|
131
|
+
let session = self.session
|
|
132
|
+
let observed = session?.original ?? session?.instanceId.map { NimoResourceOriginal(instanceId: $0, peripheralUuid: session!.pair.peripheralUuid, sessionGeneration: 0) }
|
|
114
133
|
if let expected = request.original { guard expected == observed else { throw failure("original-session-mismatch") } }
|
|
115
|
-
if request.operation == .release, session
|
|
116
|
-
if request.operation == .release, let release = session
|
|
134
|
+
if request.operation == .release, session?.hasOwner == true, request.original == nil { throw failure("release-requires-original") }
|
|
135
|
+
if request.operation == .release, let release = session?.releaseNonce ?? discoveryReleaseNonce, release != request.requestNonce { throw failure("original-release-nonce-required") }
|
|
117
136
|
}
|
|
118
|
-
let recovery = request.operation == .release || request.operation == .cancel || session?.releaseNonce != nil
|
|
137
|
+
let recovery = request.operation == .release || request.operation == .cancel || session?.releaseNonce != nil || discoveryReleaseNonce != nil
|
|
119
138
|
guard completed.count + pending.count < (recovery ? 256 : 192) else { throw failure("request-budget-exhausted") }
|
|
120
139
|
// Intent is persisted before ANY capture or release mutation. No caller
|
|
121
140
|
// path is used; replay compares the exact original request bytes.
|
|
@@ -140,11 +159,46 @@ final class NimoResourceFixture {
|
|
|
140
159
|
}
|
|
141
160
|
|
|
142
161
|
private func perform(_ request: NimoResourceRequest, received: UInt64) async throws -> [String: Any] {
|
|
162
|
+
if request.operation == .discover {
|
|
163
|
+
guard request.expectedPair.peripheralSelection == NimoResourceSelection.connectedService,
|
|
164
|
+
captureNonce == nil, request.original == nil, DeviceManager.shared.sgc == nil, pendingManagerEntries == 0 else { throw failure("discover-requires-unowned-manager") }
|
|
165
|
+
acceptedPair = request.expectedPair; captureNonce = request.captureNonce; armedElapsedMs = NimoResourceClock.now()
|
|
166
|
+
let observer = NimoResourceConnectedDiscovery(name: request.expectedPair.name, startedElapsedMs: armedElapsedMs!)
|
|
167
|
+
discovery = observer; discoveryRequestNonce = request.requestNonce
|
|
168
|
+
await observer.discoverConnected()
|
|
169
|
+
}
|
|
143
170
|
if request.operation == .arm {
|
|
144
171
|
guard session == nil, request.original == nil, DeviceManager.shared.sgc == nil, pendingManagerEntries == 0 else { throw failure("arm-requires-unowned-manager") }
|
|
145
|
-
|
|
172
|
+
let uuid: String
|
|
173
|
+
if let observed = request.expectedPair.peripheralUuid {
|
|
174
|
+
guard captureNonce == nil else { throw failure("capture-already-exists") }
|
|
175
|
+
acceptedPair = request.expectedPair; captureNonce = request.captureNonce
|
|
176
|
+
uuid = observed
|
|
177
|
+
} else {
|
|
178
|
+
guard captureNonce == request.captureNonce, acceptedPair == request.expectedPair,
|
|
179
|
+
!discoveryCancelled, discoveryReleaseNonce == nil,
|
|
180
|
+
let discoveryRequestNonce, completed[discoveryRequestNonce] != nil,
|
|
181
|
+
let discovery, discovery.ledger.settledElapsedMs != nil, discovery.callbacksPending == 0,
|
|
182
|
+
discovery.ledger.errors.isEmpty, let selected = discovery.ledger.selectedPeripheralUuid else { throw failure("discovery-not-admissible") }
|
|
183
|
+
uuid = selected
|
|
184
|
+
}
|
|
185
|
+
if armedElapsedMs == nil { armedElapsedMs = NimoResourceClock.now() }
|
|
186
|
+
session = NimoResourceSession(captureNonce: request.captureNonce, pair: NimoResourcePair(name: request.expectedPair.name, peripheralUuid: uuid), armedElapsedMs: armedElapsedMs!)
|
|
146
187
|
}
|
|
147
|
-
guard
|
|
188
|
+
guard captureNonce == request.captureNonce, acceptedPair == request.expectedPair else { throw failure("capture-mismatch") }
|
|
189
|
+
if session == nil {
|
|
190
|
+
guard request.original == nil else { throw failure("original-session-mismatch") }
|
|
191
|
+
if request.operation == .release || request.operation == .cancel {
|
|
192
|
+
if let original = discoveryReleaseNonce, request.operation == .release, original != request.requestNonce { throw failure("original-release-nonce-required") }
|
|
193
|
+
if discoveryReleaseNonce == nil {
|
|
194
|
+
discoveryReleaseNonce = request.requestNonce; discoveryReleaseStartedElapsedMs = NimoResourceClock.now()
|
|
195
|
+
discoveryCancelled = request.operation == .cancel
|
|
196
|
+
await discovery?.stopDiscovery(cancel: true)
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return discoveryOnlyReceipt(request, received: received)
|
|
200
|
+
}
|
|
201
|
+
guard let session, session.captureNonce == request.captureNonce, acceptedPair == request.expectedPair else { throw failure("capture-mismatch") }
|
|
148
202
|
if let expected = request.original {
|
|
149
203
|
let observed = session.original ?? session.instanceId.map { NimoResourceOriginal(instanceId: $0, peripheralUuid: session.pair.peripheralUuid, sessionGeneration: 0) }
|
|
150
204
|
guard expected == observed else { throw failure("original-session-mismatch") }
|
|
@@ -171,17 +225,160 @@ final class NimoResourceFixture {
|
|
|
171
225
|
let manager = DeviceManager.shared.sgc
|
|
172
226
|
let managerId = (manager as? Nimo)?.resourceInstanceId
|
|
173
227
|
let current: [String: Any] = ["type": nullable(manager?.type), "instanceId": nullable(managerId)]
|
|
174
|
-
|
|
228
|
+
var result: [String: Any] = ["version": 1, "protocol": "nimo-macos-resource-v1", "operation": request.operation.rawValue,
|
|
175
229
|
"requestNonce": request.requestNonce, "captureNonce": request.captureNonce,
|
|
176
230
|
"planSha256": request.planSha256, "grantSha256": request.grantSha256,
|
|
177
231
|
"process": ["pid": process.pid, "instanceId": process.instanceId],
|
|
178
232
|
"clock": ["kind": "darwin-continuous-time-v1", "receivedElapsedMs": received, "observedElapsedMs": NimoResourceClock.now()],
|
|
179
233
|
"capture": ["armedElapsedMs": session.armedElapsedMs, "admittedElapsedMs": nullable(session.admittedElapsedMs), "cancelled": session.cancelled,
|
|
180
|
-
"expectedPair":
|
|
234
|
+
"expectedPair": request.expectedPair.dictionary],
|
|
181
235
|
"currentManager": current, "session": nullable(session.sessionDictionary()),
|
|
182
236
|
"release": session.releaseDictionary(producers: producers), "producers": producers.dictionary, "errors": session.errors]
|
|
237
|
+
if let discovery { result["discovery"] = discovery.ledger.dictionary(callbacksPending: discovery.callbacksPending) }
|
|
238
|
+
return result
|
|
183
239
|
}
|
|
184
240
|
|
|
241
|
+
private func discoveryOnlyReceipt(_ request: NimoResourceRequest, received: UInt64) -> [String: Any] {
|
|
242
|
+
let settled = discovery?.ledger.settledElapsedMs != nil && discovery?.callbacksPending == 0
|
|
243
|
+
let manager = DeviceManager.shared.sgc
|
|
244
|
+
let managerSafe = manager == nil || originalClosed && !(manager?.type.contains("NIMO") ?? false)
|
|
245
|
+
let closed = discoveryReleaseNonce != nil && settled && pendingOriginalManagerEntries == 0 && managerSafe
|
|
246
|
+
originalClosed = closed
|
|
247
|
+
var producers = NimoResourceProducers()
|
|
248
|
+
producers.postedCallbacks = pendingOriginalManagerEntries + (discovery?.callbacksPending ?? 0)
|
|
249
|
+
producers.timers = settled ? 0 : 1
|
|
250
|
+
let release: [String: Any] = ["nonce": nullable(discoveryReleaseNonce), "startedElapsedMs": nullable(discoveryReleaseStartedElapsedMs),
|
|
251
|
+
"micStopRequired": false, "displayExitRequired": false, "micStopStartedElapsedMs": NSNull(), "micStopWrittenElapsedMs": NSNull(), "micStopMarkerElapsedMs": NSNull(),
|
|
252
|
+
"exitStatusCode": NSNull(), "exitAckElapsedMs": NSNull(), "disconnectRequestedElapsedMs": NSNull(), "disconnectCallbackElapsedMs": NSNull(), "disconnectError": NSNull(),
|
|
253
|
+
"connectionFailedElapsedMs": NSNull(), "noTransport": closed, "settled": closed]
|
|
254
|
+
return ["version": 1, "protocol": "nimo-macos-resource-v1", "operation": request.operation.rawValue,
|
|
255
|
+
"requestNonce": request.requestNonce, "captureNonce": request.captureNonce, "planSha256": request.planSha256, "grantSha256": request.grantSha256,
|
|
256
|
+
"process": ["pid": process.pid, "instanceId": process.instanceId],
|
|
257
|
+
"clock": ["kind": "darwin-continuous-time-v1", "receivedElapsedMs": received, "observedElapsedMs": NimoResourceClock.now()],
|
|
258
|
+
"capture": ["armedElapsedMs": armedElapsedMs!, "admittedElapsedMs": NSNull(), "cancelled": discoveryCancelled, "expectedPair": request.expectedPair.dictionary],
|
|
259
|
+
"currentManager": ["type": nullable(DeviceManager.shared.sgc?.type), "instanceId": nullable((DeviceManager.shared.sgc as? Nimo)?.resourceInstanceId)],
|
|
260
|
+
"session": NSNull(), "release": release, "producers": producers.dictionary, "errors": [],
|
|
261
|
+
"discovery": discovery!.ledger.dictionary(callbacksPending: discovery!.callbacksPending)]
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
#if SWIFT_PACKAGE
|
|
265
|
+
/// Test-only original discovery seed; no CoreBluetooth object is created.
|
|
266
|
+
/// Tests still execute the real perform/status/cancel/arm authority branches.
|
|
267
|
+
func packageSeedDiscovery(name: String, capture: String, discoveryOperation: String, candidates: [(String, String?)], persisted: Bool) {
|
|
268
|
+
packageEnabled = true
|
|
269
|
+
acceptedPair = NimoResourceSelection(name: name, peripheralSelection: NimoResourceSelection.connectedService)
|
|
270
|
+
captureNonce = capture; armedElapsedMs = NimoResourceClock.now()
|
|
271
|
+
let observer = NimoResourceConnectedDiscovery(name: name, startedElapsedMs: armedElapsedMs!)
|
|
272
|
+
discovery = observer; discoveryRequestNonce = discoveryOperation
|
|
273
|
+
for (uuid, candidateName) in candidates { observer.ledger.observe(uuid: uuid, name: candidateName) }
|
|
274
|
+
observer.ledger.finish(at: NimoResourceClock.now(), callbacksPending: 0)
|
|
275
|
+
if persisted { completed[discoveryOperation] = (Data(), Data()) }
|
|
276
|
+
}
|
|
277
|
+
func packagePerform(_ bytes: Data) async throws -> [String: Any] {
|
|
278
|
+
try await perform(try decode(bytes), received: NimoResourceClock.now())
|
|
279
|
+
}
|
|
280
|
+
#endif
|
|
281
|
+
|
|
185
282
|
private func decode(_ bytes: Data) throws -> NimoResourceRequest { try NimoResourceRequest.decode(bytes) }
|
|
186
283
|
private func failure(_ code: String) -> Error { NSError(domain: "NimoResourceFixture", code: 1, userInfo: [NSLocalizedDescriptionKey: code]) }
|
|
187
284
|
}
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
/// Retrieval only: no scan, connection, peripheral delegate, service discovery or
|
|
288
|
+
/// writes. The serial state-delegate queue drains before candidate commitment.
|
|
289
|
+
@MainActor
|
|
290
|
+
final class NimoResourceConnectedDiscovery: NSObject, CBCentralManagerDelegate {
|
|
291
|
+
let ledger: NimoResourceDiscovery
|
|
292
|
+
private let queue = DispatchQueue(label: "com.mentra.nimo.resource.connected-discovery")
|
|
293
|
+
private var central: CBCentralManager?
|
|
294
|
+
private var stopped = false
|
|
295
|
+
private var running = false
|
|
296
|
+
private var now: () -> UInt64 = NimoResourceClock.now
|
|
297
|
+
private var pause: () async throws -> Void = { try await Task.sleep(nanoseconds: 50_000_000) }
|
|
298
|
+
#if SWIFT_PACKAGE
|
|
299
|
+
private var packageSample: (() -> (CBManagerState, [(String, String?)]))?
|
|
300
|
+
func packageConfigureObserver(now: @escaping () -> UInt64, pause: @escaping () async throws -> Void,
|
|
301
|
+
sample: @escaping () -> (CBManagerState, [(String, String?)])) {
|
|
302
|
+
precondition(!running && central == nil)
|
|
303
|
+
self.now = now; self.pause = pause; packageSample = sample
|
|
304
|
+
}
|
|
305
|
+
#endif
|
|
306
|
+
nonisolated private let callbacks = NimoResourceCallbackCounter()
|
|
307
|
+
var callbacksPending: Int { callbacks.value }
|
|
308
|
+
init(name: String, startedElapsedMs: UInt64) {
|
|
309
|
+
ledger = NimoResourceDiscovery(name: name, startedElapsedMs: startedElapsedMs); super.init()
|
|
310
|
+
}
|
|
311
|
+
nonisolated func centralManagerDidUpdateState(_ central: CBCentralManager) {
|
|
312
|
+
callbacks.add(1)
|
|
313
|
+
defer { callbacks.add(-1) }
|
|
314
|
+
// No deferred work. State is sampled by the owned bounded Main loop.
|
|
315
|
+
}
|
|
316
|
+
@inline(never)
|
|
317
|
+
private func retrieveConnected(_ central: CBCentralManager) -> [CBPeripheral] {
|
|
318
|
+
central.retrieveConnectedPeripherals(withServices: [CBUUID(string: "00007033-0000-1000-8000-00805F9B34FB")])
|
|
319
|
+
}
|
|
320
|
+
@inline(never)
|
|
321
|
+
func discoverConnected() async {
|
|
322
|
+
guard !running, !stopped else { return }
|
|
323
|
+
running = true
|
|
324
|
+
#if SWIFT_PACKAGE
|
|
325
|
+
if packageSample == nil {
|
|
326
|
+
central = CBCentralManager(delegate: self, queue: queue, options: [CBCentralManagerOptionShowPowerAlertKey: false])
|
|
327
|
+
}
|
|
328
|
+
#else
|
|
329
|
+
central = CBCentralManager(delegate: self, queue: queue, options: [CBCentralManagerOptionShowPowerAlertKey: false])
|
|
330
|
+
#endif
|
|
331
|
+
let deadline = ledger.startedElapsedMs + 5_000
|
|
332
|
+
while !stopped {
|
|
333
|
+
// A post-sleep final powered-on retrieval replaces earlier facts.
|
|
334
|
+
let finalSample = now() >= deadline
|
|
335
|
+
var state = central?.state ?? .unknown
|
|
336
|
+
#if SWIFT_PACKAGE
|
|
337
|
+
let simulated = packageSample?()
|
|
338
|
+
if let simulated { state = simulated.0 }
|
|
339
|
+
#endif
|
|
340
|
+
switch state {
|
|
341
|
+
case .poweredOn:
|
|
342
|
+
#if SWIFT_PACKAGE
|
|
343
|
+
if let simulated { ledger.observeConnectedSnapshot(simulated.1) }
|
|
344
|
+
else if let central { ledger.observeConnectedSnapshot(retrieveConnected(central).map { ($0.identifier.uuidString.lowercased(), $0.name) }) }
|
|
345
|
+
else { ledger.observeConnectedSnapshot([]) }
|
|
346
|
+
#else
|
|
347
|
+
if let central { ledger.observeConnectedSnapshot(retrieveConnected(central).map { ($0.identifier.uuidString.lowercased(), $0.name) }) }
|
|
348
|
+
else { ledger.observeConnectedSnapshot([]) }
|
|
349
|
+
#endif
|
|
350
|
+
case .unsupported, .unauthorized:
|
|
351
|
+
ledger.observeConnectedSnapshot([])
|
|
352
|
+
ledger.recordError("connected-service-observer-unavailable"); finishDiscovery(); return
|
|
353
|
+
default:
|
|
354
|
+
// Radio loss invalidates the current snapshot, not history.
|
|
355
|
+
ledger.observeConnectedSnapshot([])
|
|
356
|
+
}
|
|
357
|
+
if finalSample { break }
|
|
358
|
+
do { try await pause() }
|
|
359
|
+
catch { ledger.recordError("discovery-task-cancelled"); ledger.cancel(); finishDiscovery(); return }
|
|
360
|
+
}
|
|
361
|
+
finishDiscovery()
|
|
362
|
+
}
|
|
363
|
+
@inline(never)
|
|
364
|
+
func stopDiscovery(cancel: Bool) async {
|
|
365
|
+
if cancel { ledger.cancel() }
|
|
366
|
+
stopped = true
|
|
367
|
+
let deadline = NimoResourceClock.now() + 1_000
|
|
368
|
+
while running, NimoResourceClock.now() < deadline {
|
|
369
|
+
do { try await Task.sleep(nanoseconds: 1_000_000) } catch { await Task.yield() }
|
|
370
|
+
}
|
|
371
|
+
guard !running else { ledger.recordError("discovery-loop-not-settled"); return }
|
|
372
|
+
finishDiscovery()
|
|
373
|
+
}
|
|
374
|
+
@inline(never)
|
|
375
|
+
private func finishDiscovery() {
|
|
376
|
+
stopped = true
|
|
377
|
+
central?.delegate = nil
|
|
378
|
+
// Callback contains no Main work; the queue barrier cannot deadlock.
|
|
379
|
+
queue.sync {}
|
|
380
|
+
central = nil
|
|
381
|
+
ledger.finish(at: now(), callbacksPending: callbacksPending)
|
|
382
|
+
running = false
|
|
383
|
+
}
|
|
384
|
+
}
|
|
@@ -65,8 +65,64 @@ struct NimoResourcePair: Codable, Equatable {
|
|
|
65
65
|
let peripheralUuid: String
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
/// Accepted selection remains immutable even after connected-service discovery.
|
|
69
|
+
struct NimoResourceSelection: Codable, Equatable {
|
|
70
|
+
static let connectedService = "connected-service-exact-name-v1"
|
|
71
|
+
let name: String
|
|
72
|
+
let peripheralUuid: String?
|
|
73
|
+
let peripheralSelection: String?
|
|
74
|
+
init(name: String, peripheralUuid: String? = nil, peripheralSelection: String? = nil) { self.name = name; self.peripheralUuid = peripheralUuid; self.peripheralSelection = peripheralSelection }
|
|
75
|
+
init(pair: NimoResourcePair) { name = pair.name; peripheralUuid = pair.peripheralUuid; peripheralSelection = nil }
|
|
76
|
+
var dictionary: [String: Any] {
|
|
77
|
+
if let peripheralUuid { return ["name": name, "peripheralUuid": peripheralUuid] }
|
|
78
|
+
return ["name": name, "peripheralSelection": peripheralSelection ?? ""]
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/// Pure candidate ledger. Selection is committed only once, after the bounded
|
|
83
|
+
/// observer has stopped; a later callback cannot replace its physical identity.
|
|
84
|
+
final class NimoResourceDiscovery {
|
|
85
|
+
static let serviceUuid = "00007033-0000-1000-8000-00805f9b34fb"
|
|
86
|
+
let name: String
|
|
87
|
+
let startedElapsedMs: UInt64
|
|
88
|
+
private(set) var settledElapsedMs: UInt64?
|
|
89
|
+
private(set) var selectedPeripheralUuid: String?
|
|
90
|
+
private(set) var candidates: [String: String] = [:]
|
|
91
|
+
private var currentlyConnected: Set<String> = []
|
|
92
|
+
private(set) var errors: [String] = []
|
|
93
|
+
private(set) var cancelled = false
|
|
94
|
+
init(name: String, startedElapsedMs: UInt64) { self.name = name; self.startedElapsedMs = startedElapsedMs }
|
|
95
|
+
func observe(uuid: String, name: String?) {
|
|
96
|
+
guard settledElapsedMs == nil, !cancelled, name == self.name else { return }
|
|
97
|
+
guard UUID(uuidString: uuid)?.uuidString.lowercased() == uuid else { recordError("invalid-discovery-uuid"); return }
|
|
98
|
+
if candidates.count >= 16, candidates[uuid] == nil { recordError("discovery-candidate-budget"); return }
|
|
99
|
+
candidates[uuid] = name
|
|
100
|
+
currentlyConnected.insert(uuid)
|
|
101
|
+
if candidates.count > 1 { recordError("ambiguous-connected-service-peer") }
|
|
102
|
+
}
|
|
103
|
+
func observeConnectedSnapshot(_ peers: [(String, String?)]) {
|
|
104
|
+
guard settledElapsedMs == nil, !cancelled else { return }
|
|
105
|
+
currentlyConnected.removeAll()
|
|
106
|
+
for (uuid, name) in peers { observe(uuid: uuid, name: name) }
|
|
107
|
+
}
|
|
108
|
+
func recordError(_ error: String) { guard settledElapsedMs == nil else { return }; if errors.count < 16, !errors.contains(error) { errors.append(error) } }
|
|
109
|
+
func cancel() { guard settledElapsedMs == nil else { return }; cancelled = true }
|
|
110
|
+
func finish(at: UInt64, callbacksPending: Int) {
|
|
111
|
+
guard settledElapsedMs == nil, callbacksPending == 0, at >= startedElapsedMs else { return }
|
|
112
|
+
if !cancelled, errors.isEmpty, candidates.count == 1, currentlyConnected.count == 1 { selectedPeripheralUuid = candidates.keys.first }
|
|
113
|
+
else if !cancelled, currentlyConnected.isEmpty { recordError("no-connected-service-peer") }
|
|
114
|
+
settledElapsedMs = at
|
|
115
|
+
}
|
|
116
|
+
func dictionary(callbacksPending: Int) -> [String: Any] {
|
|
117
|
+
["kind": NimoResourceSelection.connectedService, "selectedPeripheralUuid": nullable(selectedPeripheralUuid),
|
|
118
|
+
"candidates": candidates.keys.sorted().map { ["peripheralUuid": $0, "name": candidates[$0]!, "source": "connected-service", "serviceUuid": Self.serviceUuid] },
|
|
119
|
+
"startedElapsedMs": startedElapsedMs, "settledElapsedMs": nullable(settledElapsedMs),
|
|
120
|
+
"settled": settledElapsedMs != nil && callbacksPending == 0, "callbacksPending": callbacksPending, "errors": errors]
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
68
124
|
struct NimoResourceRequest: Codable, Equatable {
|
|
69
|
-
enum Operation: String, Codable { case arm, status, release, cancel }
|
|
125
|
+
enum Operation: String, Codable { case discover, arm, status, release, cancel }
|
|
70
126
|
let version: Int
|
|
71
127
|
let operation: Operation
|
|
72
128
|
let requestNonce: String
|
|
@@ -74,7 +130,7 @@ struct NimoResourceRequest: Codable, Equatable {
|
|
|
74
130
|
let planSha256: String
|
|
75
131
|
let grantSha256: String
|
|
76
132
|
let expectedProcess: NimoResourceProcess
|
|
77
|
-
let expectedPair:
|
|
133
|
+
let expectedPair: NimoResourceSelection
|
|
78
134
|
let original: NimoResourceOriginal?
|
|
79
135
|
}
|
|
80
136
|
|
|
@@ -130,9 +186,9 @@ final class NimoResourceSession {
|
|
|
130
186
|
private var peripheral: AnyObject?
|
|
131
187
|
private let now: () -> UInt64
|
|
132
188
|
|
|
133
|
-
init(captureNonce: String, pair: NimoResourcePair, now: @escaping () -> UInt64 = NimoResourceClock.now) {
|
|
189
|
+
init(captureNonce: String, pair: NimoResourcePair, now: @escaping () -> UInt64 = NimoResourceClock.now, armedElapsedMs: UInt64? = nil) {
|
|
134
190
|
self.captureNonce = captureNonce; self.pair = pair; self.now = now
|
|
135
|
-
armedElapsedMs = now()
|
|
191
|
+
self.armedElapsedMs = armedElapsedMs ?? now()
|
|
136
192
|
}
|
|
137
193
|
|
|
138
194
|
func isOwner(_ candidate: AnyObject) -> Bool { owner === candidate }
|
|
@@ -320,18 +376,28 @@ extension NimoResourceRequest {
|
|
|
320
376
|
guard bytes.count <= 16_384, let value = try JSONSerialization.jsonObject(with: bytes) as? [String: Any],
|
|
321
377
|
Set(value.keys) == Set(["version","operation","requestNonce","captureNonce","planSha256","grantSha256","expectedProcess","expectedPair","original"]),
|
|
322
378
|
let process = value["expectedProcess"] as? [String: Any], Set(process.keys) == Set(["pid","instanceId"]),
|
|
323
|
-
let pair = value["expectedPair"] as? [String: Any],
|
|
379
|
+
let pair = value["expectedPair"] as? [String: Any],
|
|
380
|
+
Set(pair.keys) == Set(["name","peripheralUuid"]) || Set(pair.keys) == Set(["name","peripheralSelection"]) else { throw failure("invalid-request-schema") }
|
|
324
381
|
if let original = value["original"] as? [String: Any], Set(original.keys) != Set(["instanceId","peripheralUuid","sessionGeneration"]) { throw failure("invalid-original-schema") }
|
|
325
382
|
let result = try JSONDecoder().decode(NimoResourceRequest.self, from: bytes)
|
|
326
|
-
let ids = [result.requestNonce, result.captureNonce, result.expectedProcess.instanceId
|
|
383
|
+
let ids = [result.requestNonce, result.captureNonce, result.expectedProcess.instanceId] + (result.expectedPair.peripheralUuid.map { [$0] } ?? []) +
|
|
327
384
|
(result.original.map { [$0.instanceId, $0.peripheralUuid] } ?? [])
|
|
328
385
|
for id in ids {
|
|
329
386
|
guard UUID(uuidString: id)?.uuidString.lowercased() == id else { throw failure("invalid-uuid") }
|
|
330
387
|
}
|
|
388
|
+
if result.operation == .discover {
|
|
389
|
+
guard result.expectedPair.peripheralSelection == NimoResourceSelection.connectedService,
|
|
390
|
+
result.requestNonce == result.captureNonce, result.original == nil else { throw failure("invalid-discovery-identity") }
|
|
391
|
+
}
|
|
392
|
+
if result.operation == .arm {
|
|
393
|
+
guard result.original == nil,
|
|
394
|
+
(result.expectedPair.peripheralUuid != nil ? result.requestNonce == result.captureNonce : result.requestNonce != result.captureNonce) else { throw failure("invalid-arm-identity") }
|
|
395
|
+
}
|
|
331
396
|
if let original = result.original {
|
|
332
|
-
guard original.sessionGeneration <= 1, original.peripheralUuid == result.expectedPair.peripheralUuid else { throw failure("invalid-original-identity") }
|
|
397
|
+
guard original.sessionGeneration <= 1, (result.expectedPair.peripheralUuid == nil || original.peripheralUuid == result.expectedPair.peripheralUuid) else { throw failure("invalid-original-identity") }
|
|
333
398
|
}
|
|
334
|
-
guard result.
|
|
399
|
+
guard (result.expectedPair.peripheralUuid != nil && result.expectedPair.peripheralSelection == nil || result.expectedPair.peripheralUuid == nil && result.expectedPair.peripheralSelection == NimoResourceSelection.connectedService),
|
|
400
|
+
result.expectedProcess.pid > 1, result.version == 1, !result.expectedPair.name.isEmpty, result.expectedPair.name.utf8.count <= 128,
|
|
335
401
|
result.planSha256.range(of: "^[0-9a-f]{64}$", options: .regularExpression) != nil,
|
|
336
402
|
result.grantSha256.range(of: "^[0-9a-f]{64}$", options: .regularExpression) != nil else { throw failure("invalid-request-identity") }
|
|
337
403
|
return result
|
|
@@ -1,8 +1,136 @@
|
|
|
1
1
|
import Foundation
|
|
2
|
+
import CoreBluetooth
|
|
2
3
|
@testable import MentraBluetoothSDK
|
|
3
4
|
import XCTest
|
|
4
5
|
|
|
5
6
|
final class NimoResourceSessionTests: XCTestCase {
|
|
7
|
+
func testConnectedDiscoverySelectionRequiresExactUniquePeerAndCannotChangeAfterSettlement() {
|
|
8
|
+
let model = NimoResourceDiscovery(name: "NIMO exact", startedElapsedMs: 10)
|
|
9
|
+
model.observe(uuid: uuid, name: "NIMO exact_BLE")
|
|
10
|
+
XCTAssertTrue(model.candidates.isEmpty)
|
|
11
|
+
model.observe(uuid: uuid, name: "NIMO exact")
|
|
12
|
+
model.observe(uuid: uuid, name: "NIMO exact")
|
|
13
|
+
model.finish(at: 20, callbacksPending: 1)
|
|
14
|
+
XCTAssertNil(model.settledElapsedMs)
|
|
15
|
+
XCTAssertNil(model.selectedPeripheralUuid)
|
|
16
|
+
model.finish(at: 21, callbacksPending: 0)
|
|
17
|
+
XCTAssertEqual(model.selectedPeripheralUuid, uuid)
|
|
18
|
+
model.observe(uuid: "00000000-0000-4000-8000-000000000002", name: "NIMO exact")
|
|
19
|
+
model.cancel(); model.finish(at: 30, callbacksPending: 0)
|
|
20
|
+
XCTAssertEqual(model.selectedPeripheralUuid, uuid)
|
|
21
|
+
XCTAssertEqual(model.settledElapsedMs, 21)
|
|
22
|
+
XCTAssertEqual(model.candidates.count, 1)
|
|
23
|
+
}
|
|
24
|
+
func testConnectedDiscoveryAmbiguityCancellationAndMissingPeerNeverSelect() {
|
|
25
|
+
for scenario in 0..<3 {
|
|
26
|
+
let model = NimoResourceDiscovery(name: "NIMO exact", startedElapsedMs: 10)
|
|
27
|
+
if scenario != 2 { model.observe(uuid: uuid, name: "NIMO exact") }
|
|
28
|
+
if scenario == 0 { model.observe(uuid: "00000000-0000-4000-8000-000000000002", name: "NIMO exact") }
|
|
29
|
+
if scenario == 1 { model.cancel() }
|
|
30
|
+
model.finish(at: 20, callbacksPending: 0)
|
|
31
|
+
XCTAssertNil(model.selectedPeripheralUuid)
|
|
32
|
+
XCTAssertEqual(model.settledElapsedMs, 20)
|
|
33
|
+
if scenario != 1 { XCTAssertFalse(model.errors.isEmpty) }
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
func testConnectedDiscoveryDoesNotSelectPeerThatDisappearedBeforeFinalObservation() {
|
|
37
|
+
let model = NimoResourceDiscovery(name: "NIMO exact", startedElapsedMs: 10)
|
|
38
|
+
model.observeConnectedSnapshot([(uuid, "NIMO exact")])
|
|
39
|
+
model.observeConnectedSnapshot([])
|
|
40
|
+
model.finish(at: 20, callbacksPending: 0)
|
|
41
|
+
XCTAssertNil(model.selectedPeripheralUuid)
|
|
42
|
+
XCTAssertEqual(model.candidates.count, 1)
|
|
43
|
+
XCTAssertFalse(model.errors.isEmpty)
|
|
44
|
+
}
|
|
45
|
+
private func discoveryRequest(operation: String, capture: String, nonce: String, extraPair: [String: Any] = [:]) throws -> Data {
|
|
46
|
+
var pair: [String: Any] = ["name": "NIMO exact", "peripheralSelection": "connected-service-exact-name-v1"]
|
|
47
|
+
for (key, value) in extraPair { pair[key] = value }
|
|
48
|
+
return try JSONSerialization.data(withJSONObject: ["version": 1, "operation": operation, "requestNonce": nonce, "captureNonce": capture,
|
|
49
|
+
"planSha256": String(repeating: "a", count: 64), "grantSha256": String(repeating: "b", count: 64),
|
|
50
|
+
"expectedProcess": ["pid": 123, "instanceId": uuid], "expectedPair": pair, "original": NSNull()])
|
|
51
|
+
}
|
|
52
|
+
func testDiscoveryWireRejectsMixedOrUnknownSelectionRatherThanPlaceholderUuid() throws {
|
|
53
|
+
let data = try discoveryRequest(operation: "discover", capture: uuid, nonce: uuid)
|
|
54
|
+
XCTAssertEqual(try NimoResourceRequest.decode(data).expectedPair.peripheralSelection, NimoResourceSelection.connectedService)
|
|
55
|
+
XCTAssertThrowsError(try NimoResourceRequest.decode(discoveryRequest(operation: "discover", capture: uuid, nonce: uuid, extraPair: ["peripheralUuid": uuid])))
|
|
56
|
+
XCTAssertThrowsError(try NimoResourceRequest.decode(discoveryRequest(operation: "discover", capture: uuid, nonce: uuid, extraPair: ["peripheralSelection": "name-only"])))
|
|
57
|
+
XCTAssertThrowsError(try NimoResourceRequest.decode(discoveryRequest(operation: "discover", capture: uuid, nonce: uuid, extraPair: ["peripheralSelection": NSNull()])))
|
|
58
|
+
}
|
|
59
|
+
@MainActor
|
|
60
|
+
func testActualFixtureDiscoveryCannotAdmitManagerUntilSeparateArmAndPersistedDiscoveryReceipt() async throws {
|
|
61
|
+
let discoveryId = "00000000-0000-4000-8000-000000000002"
|
|
62
|
+
for persisted in [false, true] {
|
|
63
|
+
let fixture = NimoResourceFixture()
|
|
64
|
+
fixture.packageSeedDiscovery(name: "NIMO exact", capture: uuid, discoveryOperation: discoveryId, candidates: [(uuid, "NIMO exact")], persisted: persisted)
|
|
65
|
+
XCTAssertNil(fixture.session)
|
|
66
|
+
XCTAssertFalse(fixture.allowsManager("NIMO", name: "NIMO exact"))
|
|
67
|
+
let status = try await fixture.packagePerform(discoveryRequest(operation: "status", capture: uuid, nonce: discoveryId))
|
|
68
|
+
XCTAssertTrue(status["session"] is NSNull)
|
|
69
|
+
let anchor = (status["capture"] as! [String: Any])["armedElapsedMs"] as! UInt64
|
|
70
|
+
do {
|
|
71
|
+
let armed = try await fixture.packagePerform(discoveryRequest(operation: "arm", capture: uuid, nonce: "00000000-0000-4000-8000-000000000003"))
|
|
72
|
+
XCTAssertTrue(persisted)
|
|
73
|
+
XCTAssertEqual(fixture.session?.pair.peripheralUuid, uuid)
|
|
74
|
+
XCTAssertEqual((armed["capture"] as! [String: Any])["armedElapsedMs"] as? UInt64, anchor)
|
|
75
|
+
XCTAssertTrue(fixture.allowsManager("NIMO", name: "NIMO exact"))
|
|
76
|
+
} catch { XCTAssertFalse(persisted) }
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
@MainActor
|
|
80
|
+
func testActualFixtureDiscoveryOnlyCancelClosesObserverWithoutSessionOrFakeDisconnectAndForbidsLaterArm() async throws {
|
|
81
|
+
let fixture = NimoResourceFixture(), discoveryId = "00000000-0000-4000-8000-000000000002"
|
|
82
|
+
fixture.packageSeedDiscovery(name: "NIMO exact", capture: uuid, discoveryOperation: discoveryId, candidates: [(uuid, "NIMO exact")], persisted: true)
|
|
83
|
+
let result = try await fixture.packagePerform(discoveryRequest(operation: "cancel", capture: uuid, nonce: discoveryId))
|
|
84
|
+
XCTAssertNil(fixture.session)
|
|
85
|
+
let release = result["release"] as! [String: Any]
|
|
86
|
+
XCTAssertEqual(release["noTransport"] as? Bool, true)
|
|
87
|
+
XCTAssertEqual(release["settled"] as? Bool, true)
|
|
88
|
+
XCTAssertTrue(release["disconnectCallbackElapsedMs"] is NSNull)
|
|
89
|
+
XCTAssertTrue(fixture.allowsManager("G2", name: "G2"))
|
|
90
|
+
do { _ = try await fixture.packagePerform(discoveryRequest(operation: "arm", capture: uuid, nonce: "00000000-0000-4000-8000-000000000003")); XCTFail("cancelled discovery admitted arm") }
|
|
91
|
+
catch { XCTAssertNil(fixture.session) }
|
|
92
|
+
}
|
|
93
|
+
@MainActor
|
|
94
|
+
func testActualObserverLoopRequiresFreshFinalRetrievalAndRejectsRadioStateLossWithoutRadio() async {
|
|
95
|
+
for finalState in [CBManagerState.poweredOn, .poweredOff, .resetting] {
|
|
96
|
+
for finalPeerPresent in [false, true] {
|
|
97
|
+
var clock: UInt64 = 100, samples = 0, pauses = 0
|
|
98
|
+
let observer = NimoResourceConnectedDiscovery(name: "NIMO exact", startedElapsedMs: clock)
|
|
99
|
+
observer.packageConfigureObserver(now: { clock }, pause: {
|
|
100
|
+
pauses += 1; clock += 5_000
|
|
101
|
+
}, sample: {
|
|
102
|
+
samples += 1
|
|
103
|
+
if samples == 1 { return (.poweredOn, [(self.uuid, "NIMO exact")]) }
|
|
104
|
+
return (finalState, finalPeerPresent ? [(self.uuid, "NIMO exact")] : [])
|
|
105
|
+
})
|
|
106
|
+
await observer.discoverConnected()
|
|
107
|
+
XCTAssertEqual(samples, 2)
|
|
108
|
+
XCTAssertEqual(pauses, 1)
|
|
109
|
+
XCTAssertEqual(observer.callbacksPending, 0)
|
|
110
|
+
XCTAssertEqual(observer.ledger.settledElapsedMs, 5_100)
|
|
111
|
+
XCTAssertEqual(observer.ledger.selectedPeripheralUuid, finalState == .poweredOn && finalPeerPresent ? uuid : nil)
|
|
112
|
+
XCTAssertEqual(observer.ledger.candidates.count, 1)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
@MainActor
|
|
117
|
+
func testActualObserverInvalidatesIntermediateRadioLossAndCancellationCannotReusePreviousSample() async {
|
|
118
|
+
var clock: UInt64 = 100, samples = 0
|
|
119
|
+
let observer = NimoResourceConnectedDiscovery(name: "NIMO exact", startedElapsedMs: clock)
|
|
120
|
+
observer.packageConfigureObserver(now: { clock }, pause: {
|
|
121
|
+
clock += 2_500
|
|
122
|
+
if clock == 5_100 { throw CancellationError() }
|
|
123
|
+
}, sample: {
|
|
124
|
+
samples += 1
|
|
125
|
+
return samples == 1 ? (.poweredOn, [(self.uuid, "NIMO exact")]) : (.resetting, [])
|
|
126
|
+
})
|
|
127
|
+
await observer.discoverConnected()
|
|
128
|
+
XCTAssertEqual(samples, 2)
|
|
129
|
+
XCTAssertNil(observer.ledger.selectedPeripheralUuid)
|
|
130
|
+
XCTAssertEqual(observer.ledger.settledElapsedMs, 5_100)
|
|
131
|
+
XCTAssertTrue(observer.ledger.cancelled)
|
|
132
|
+
XCTAssertTrue(observer.ledger.errors.contains("discovery-task-cancelled"))
|
|
133
|
+
}
|
|
6
134
|
private let uuid = "00000000-0000-4000-8000-000000000001"
|
|
7
135
|
private final class Clock { var value: UInt64 = 100; func next() -> UInt64 { value += 1; return value } }
|
|
8
136
|
private func fixture() -> (NimoResourceSession, NSObject, NSObject, Clock) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mentra/bluetooth-sdk",
|
|
3
|
-
"version": "3.4.0-dev.
|
|
3
|
+
"version": "3.4.0-dev.981",
|
|
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.4.0-dev.
|
|
117
|
+
"@mentra/cloud-protocol": "3.4.0-dev.981"
|
|
118
118
|
},
|
|
119
119
|
"devDependencies": {
|
|
120
120
|
"@types/node": "^25.9.3",
|
|
@@ -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.4.0",
|
|
15
|
-
"releaseIdentity": "3.4.0-dev.
|
|
16
|
-
"releaseSetId": "mentra-3.4.0-dev.
|
|
17
|
-
"sourceCommit": "
|
|
18
|
-
"otaManifestUrl": "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.4.0/mentra-live-ota-3.4.0-dev.
|
|
19
|
-
"otaManifestSha256": "
|
|
15
|
+
"releaseIdentity": "3.4.0-dev.981",
|
|
16
|
+
"releaseSetId": "mentra-3.4.0-dev.981",
|
|
17
|
+
"sourceCommit": "4c46c4b103f56975936e84f3c86c43136e3f072f",
|
|
18
|
+
"otaManifestUrl": "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.4.0/mentra-live-ota-3.4.0-dev.981.json",
|
|
19
|
+
"otaManifestSha256": "59bc6728ace4107c65306eb762c60c9a35ddaff236193d62d69095efc692e49e"
|
|
20
20
|
})
|