@mentra/bluetooth-sdk 3.4.0-dev.979 → 3.4.0-dev.980

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.
@@ -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.979"
7
- const val RELEASE_SET_ID: String = "mentra-3.4.0-dev.979"
8
- const val SOURCE_COMMIT: String = "cec2dff0b77fc7963d63602bba43556c690daf9d"
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.979.json"
10
- const val OTA_MANIFEST_SHA256: String = "667fffda403d22a07c60a0029ef602c556d13ddfa4bf8161fda5f56f54f50f13"
6
+ const val RELEASE_IDENTITY: String = "3.4.0-dev.980"
7
+ const val RELEASE_SET_ID: String = "mentra-3.4.0-dev.980"
8
+ const val SOURCE_COMMIT: String = "d43a82b42eddeee824f4c8f003415bc65dbd5aab"
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.980.json"
10
+ const val OTA_MANIFEST_SHA256: String = "3121c2acd957c5f554b36d8260c4a3550c85bd50cee3d11a27a53b407e27b6d0"
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.979",
5
- "releaseSetId": "mentra-3.4.0-dev.979",
6
- "sourceCommit": "cec2dff0b77fc7963d63602bba43556c690daf9d",
7
- "otaManifestUrl": "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.4.0/mentra-live-ota-3.4.0-dev.979.json",
8
- "otaManifestSha256": "667fffda403d22a07c60a0029ef602c556d13ddfa4bf8161fda5f56f54f50f13"
4
+ "releaseIdentity": "3.4.0-dev.980",
5
+ "releaseSetId": "mentra-3.4.0-dev.980",
6
+ "sourceCommit": "d43a82b42eddeee824f4c8f003415bc65dbd5aab",
7
+ "otaManifestUrl": "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.4.0/mentra-live-ota-3.4.0-dev.980.json",
8
+ "otaManifestSha256": "3121c2acd957c5f554b36d8260c4a3550c85bd50cee3d11a27a53b407e27b6d0"
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.979\",\n \"releaseSetId\": \"mentra-3.4.0-dev.979\",\n \"sourceCommit\": \"cec2dff0b77fc7963d63602bba43556c690daf9d\",\n \"otaManifestUrl\": \"https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.4.0/mentra-live-ota-3.4.0-dev.979.json\",\n \"otaManifestSha256\": \"667fffda403d22a07c60a0029ef602c556d13ddfa4bf8161fda5f56f54f50f13\"\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,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.980\",\n \"releaseSetId\": \"mentra-3.4.0-dev.980\",\n \"sourceCommit\": \"d43a82b42eddeee824f4c8f003415bc65dbd5aab\",\n \"otaManifestUrl\": \"https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.4.0/mentra-live-ota-3.4.0-dev.980.json\",\n \"otaManifestSha256\": \"3121c2acd957c5f554b36d8260c4a3550c85bd50cee3d11a27a53b407e27b6d0\"\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.979"
19
+ private static let swiftPackageSdkVersion = "3.4.0-dev.980"
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.979"
7
- static let releaseSetId = "mentra-3.4.0-dev.979"
8
- static let sourceCommit = "cec2dff0b77fc7963d63602bba43556c690daf9d"
9
- static let otaManifestUrl = "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.4.0/mentra-live-ota-3.4.0-dev.979.json"
10
- static let otaManifestSha256 = "667fffda403d22a07c60a0029ef602c556d13ddfa4bf8161fda5f56f54f50f13"
6
+ static let releaseIdentity = "3.4.0-dev.980"
7
+ static let releaseSetId = "mentra-3.4.0-dev.980"
8
+ static let sourceCommit = "d43a82b42eddeee824f4c8f003415bc65dbd5aab"
9
+ static let otaManifestUrl = "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.4.0/mentra-live-ota-3.4.0-dev.980.json"
10
+ static let otaManifestSha256 = "3121c2acd957c5f554b36d8260c4a3550c85bd50cee3d11a27a53b407e27b6d0"
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
- var enabled: Bool { requested }
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 let session, session.captureNonce == request.captureNonce, session.pair == request.expectedPair else { throw failure("capture-mismatch") }
113
- let observed = session.original ?? session.instanceId.map { NimoResourceOriginal(instanceId: $0, peripheralUuid: session.pair.peripheralUuid, sessionGeneration: 0) }
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.hasOwner, request.original == nil { throw failure("release-requires-original") }
116
- if request.operation == .release, let release = session.releaseNonce, release != request.requestNonce { throw failure("original-release-nonce-required") }
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
- session = NimoResourceSession(captureNonce: request.captureNonce, pair: request.expectedPair)
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 let session, session.captureNonce == request.captureNonce, session.pair == request.expectedPair else { throw failure("capture-mismatch") }
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
- return ["version": 1, "protocol": "nimo-macos-resource-v1", "operation": request.operation.rawValue,
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": ["name": session.pair.name, "peripheralUuid": session.pair.peripheralUuid]],
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: NimoResourcePair
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], Set(pair.keys) == Set(["name","peripheralUuid"]) else { throw failure("invalid-request-schema") }
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, result.expectedPair.peripheralUuid] +
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.expectedProcess.pid > 1, result.version == 1, !result.expectedPair.name.isEmpty, result.expectedPair.name.utf8.count <= 128,
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.979",
3
+ "version": "3.4.0-dev.980",
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.979"
117
+ "@mentra/cloud-protocol": "3.4.0-dev.980"
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.979",
16
- "releaseSetId": "mentra-3.4.0-dev.979",
17
- "sourceCommit": "cec2dff0b77fc7963d63602bba43556c690daf9d",
18
- "otaManifestUrl": "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.4.0/mentra-live-ota-3.4.0-dev.979.json",
19
- "otaManifestSha256": "667fffda403d22a07c60a0029ef602c556d13ddfa4bf8161fda5f56f54f50f13"
15
+ "releaseIdentity": "3.4.0-dev.980",
16
+ "releaseSetId": "mentra-3.4.0-dev.980",
17
+ "sourceCommit": "d43a82b42eddeee824f4c8f003415bc65dbd5aab",
18
+ "otaManifestUrl": "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.4.0/mentra-live-ota-3.4.0-dev.980.json",
19
+ "otaManifestSha256": "3121c2acd957c5f554b36d8260c4a3550c85bd50cee3d11a27a53b407e27b6d0"
20
20
  })