@mentra/bluetooth-sdk 3.4.0-dev.978 → 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.
- package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +5 -5
- package/build/_private/BluetoothSdkModule.d.ts +3 -0
- package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
- package/build/_private/BluetoothSdkModule.js.map +1 -1
- package/build/generated/releaseMetadata.js +5 -5
- package/build/generated/releaseMetadata.js.map +1 -1
- package/ios/BluetoothSdkModule.swift +6 -0
- package/ios/Source/BluetoothSdkDefaults.swift +1 -1
- package/ios/Source/DeviceManager.swift +10 -1
- package/ios/Source/GeneratedReleaseMetadata.swift +5 -5
- package/ios/Source/MentraBluetoothSDK.swift +15 -2
- package/ios/Source/sgcs/Nimo.swift +278 -38
- package/ios/Source/sgcs/NimoCanvasCoordinator.swift +77 -18
- package/ios/Source/sgcs/NimoCanvasSession.swift +1 -0
- package/ios/Source/sgcs/NimoMacResourceTransport.swift +217 -0
- package/ios/Source/sgcs/NimoResourceFixture.swift +384 -0
- package/ios/Source/sgcs/NimoResourceSession.swift +406 -0
- package/ios/Tests/DeviceManagerReconnectTests.swift +75 -2
- package/ios/Tests/NimoCanvasTransportTests.swift +147 -0
- package/ios/Tests/NimoResourceSessionTests.swift +476 -0
- package/package.json +2 -2
- package/src/_private/BluetoothSdkModule.ts +3 -0
- package/src/generated/releaseMetadata.ts +5 -5
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import CoreBluetooth
|
|
3
|
+
|
|
4
|
+
/// One process-local authority shared by the typed Expo bridge and the Mac
|
|
5
|
+
/// fixture socket. Neither accepts packets, connection addresses or file paths.
|
|
6
|
+
@MainActor
|
|
7
|
+
public enum NimoResourceFixtureBridge {
|
|
8
|
+
public static func execute(_ json: String) async throws -> String {
|
|
9
|
+
let bytes = Data(json.utf8)
|
|
10
|
+
return String(decoding: try await NimoResourceFixture.shared.execute(bytes), as: UTF8.self)
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
@MainActor
|
|
15
|
+
final class NimoResourceFixture {
|
|
16
|
+
static let shared = NimoResourceFixture()
|
|
17
|
+
let process = NimoResourceProcess(pid: ProcessInfo.processInfo.processIdentifier, instanceId: UUID().uuidString.lowercased())
|
|
18
|
+
private(set) var session: NimoResourceSession?
|
|
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
|
|
28
|
+
private var completed: [String: (Data, Data)] = [:]
|
|
29
|
+
private var pending: [String: (Data, Task<Data, Error>)] = [:]
|
|
30
|
+
private var transport: NimoMacResourceTransport?
|
|
31
|
+
private var startupError: Error?
|
|
32
|
+
private var originalClosed = false
|
|
33
|
+
private let managerEntries = NimoResourceManagerEntries()
|
|
34
|
+
var pendingManagerEntries: Int { managerEntries.all }
|
|
35
|
+
var pendingOriginalManagerEntries: Int { managerEntries.originalNimo }
|
|
36
|
+
func beginManagerEntry(_ wearable: String) -> NimoResourceManagerEntries.Ticket { managerEntries.begin(wearable) }
|
|
37
|
+
func endManagerEntry(_ ticket: NimoResourceManagerEntries.Ticket) { managerEntries.end(ticket) }
|
|
38
|
+
#if os(macOS)
|
|
39
|
+
private let requested = ProcessInfo.processInfo.environment["MENTRA_NIMO_RESOURCE_BOOTSTRAP"] != nil
|
|
40
|
+
#elseif os(iOS)
|
|
41
|
+
private let requested = ProcessInfo.processInfo.isiOSAppOnMac && ProcessInfo.processInfo.environment["MENTRA_NIMO_RESOURCE_BOOTSTRAP"] != nil
|
|
42
|
+
#else
|
|
43
|
+
private let requested = false
|
|
44
|
+
#endif
|
|
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
|
+
}
|
|
55
|
+
|
|
56
|
+
func start() {
|
|
57
|
+
guard enabled, transport == nil, startupError == nil else { return }
|
|
58
|
+
do { transport = try NimoMacResourceTransport(process: process) }
|
|
59
|
+
catch { startupError = error }
|
|
60
|
+
}
|
|
61
|
+
func allowsManager(_ wearable: String, name: String? = nil) -> Bool {
|
|
62
|
+
guard enabled else { return true }
|
|
63
|
+
// A different manager can clean up the captured Nimo object. Permit it
|
|
64
|
+
// only after the original resource has actually completed teardown.
|
|
65
|
+
guard wearable.contains("NIMO") else { return NimoResourceManagerState.allowsReplacement(fixtureEnabled: enabled, originalClosed: originalClosed && (session == nil || session?.errors.isEmpty == true && session?.connected == false)) }
|
|
66
|
+
guard let session, !session.cancelled, session.releaseNonce == nil, session.original == nil,
|
|
67
|
+
name == nil || name == session.pair.name else { return false }
|
|
68
|
+
return true
|
|
69
|
+
}
|
|
70
|
+
func hasVerifiedOriginalClosure(_ candidate: Nimo) -> Bool {
|
|
71
|
+
originalClosed && owner === candidate && session?.isOwner(candidate) == true
|
|
72
|
+
&& session?.errors.isEmpty == true && session?.connected == false
|
|
73
|
+
}
|
|
74
|
+
func admit(_ candidate: Nimo, name: String?) -> Bool {
|
|
75
|
+
guard enabled else { return true }
|
|
76
|
+
guard (DeviceManager.shared.sgc as AnyObject?) === candidate, let session, let transport else { return false }
|
|
77
|
+
let admitted = session.admit(candidate, instanceId: candidate.resourceInstanceId, name: name) { proposed in
|
|
78
|
+
self.persistAdmission(session, instanceId: candidate.resourceInstanceId, elapsed: proposed, transportStart: false, transport: transport)
|
|
79
|
+
}
|
|
80
|
+
if session.isOwner(candidate), owner == nil { owner = candidate }
|
|
81
|
+
return admitted && owner === candidate
|
|
82
|
+
}
|
|
83
|
+
private func persistAdmission(_ session: NimoResourceSession, instanceId: String, elapsed: UInt64, transportStart: Bool, transport: NimoMacResourceTransport) -> Bool {
|
|
84
|
+
let value: [String: Any] = ["protocol": "nimo-macos-resource-v1", "captureNonce": session.captureNonce,
|
|
85
|
+
"process": ["pid": process.pid, "instanceId": process.instanceId],
|
|
86
|
+
"planSha256": transport.configuration.planSha256, "grantSha256": transport.configuration.grantSha256,
|
|
87
|
+
"instanceId": instanceId, "peripheralUuid": session.pair.peripheralUuid, "sessionGeneration": transportStart ? 1 : 0,
|
|
88
|
+
"armedElapsedMs": session.armedElapsedMs, "admittedElapsedMs": transportStart ? nullable(session.admittedElapsedMs) : elapsed,
|
|
89
|
+
"startedElapsedMs": transportStart ? elapsed : NSNull() as Any]
|
|
90
|
+
do {
|
|
91
|
+
let bytes = try JSONSerialization.data(withJSONObject: value, options: [.sortedKeys])
|
|
92
|
+
try transport.persist(bytes, nonce: session.captureNonce, kind: transportStart ? "transport" : "admission")
|
|
93
|
+
return true
|
|
94
|
+
} catch { return false }
|
|
95
|
+
}
|
|
96
|
+
func preserveScan(_ name: String) -> Bool {
|
|
97
|
+
guard enabled, let owner, let session, session.isOwner(owner),
|
|
98
|
+
session.original == nil, !session.cancelled, name == session.pair.name else { return false }
|
|
99
|
+
return (DeviceManager.shared.sgc as AnyObject?) === owner
|
|
100
|
+
}
|
|
101
|
+
func beginTransport(_ candidate: Nimo, peripheral: AnyObject, uuid: String) -> Bool {
|
|
102
|
+
guard enabled else { return true }
|
|
103
|
+
guard (DeviceManager.shared.sgc as AnyObject?) === candidate, let session, let transport else { return false }
|
|
104
|
+
return session.beginTransport(candidate, peripheral: peripheral, uuid: uuid) { proposed in
|
|
105
|
+
self.persistAdmission(session, instanceId: candidate.resourceInstanceId, elapsed: proposed, transportStart: true, transport: transport)
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
func allowsProducer(_ candidate: Nimo) -> Bool {
|
|
109
|
+
guard enabled else { return true }
|
|
110
|
+
return session?.isOwner(candidate) == true && session?.releaseNonce == nil && session?.cancelled == false && (DeviceManager.shared.sgc as AnyObject?) === candidate
|
|
111
|
+
}
|
|
112
|
+
func captured(_ candidate: Nimo) -> NimoResourceSession? {
|
|
113
|
+
guard enabled, session?.isOwner(candidate) == true else { return nil }; return session
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
func execute(_ bytes: Data) async throws -> Data {
|
|
117
|
+
start()
|
|
118
|
+
guard bytes.count <= 16_384, enabled, let transport, startupError == nil else { throw failure("fixture-unavailable") }
|
|
119
|
+
let request = try decode(bytes)
|
|
120
|
+
guard request.version == 1, request.expectedProcess == process,
|
|
121
|
+
request.planSha256 == transport.configuration.planSha256,
|
|
122
|
+
request.grantSha256 == transport.configuration.grantSha256 else { throw failure("fixture-authority-mismatch") }
|
|
123
|
+
if let (original, receipt) = completed[request.requestNonce] {
|
|
124
|
+
guard original == bytes else { throw failure("request-nonce-reused") }; return receipt
|
|
125
|
+
}
|
|
126
|
+
if let (original, task) = pending[request.requestNonce] {
|
|
127
|
+
guard original == bytes else { throw failure("request-nonce-reused") }; return try await task.value
|
|
128
|
+
}
|
|
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) }
|
|
133
|
+
if let expected = request.original { guard expected == observed else { throw failure("original-session-mismatch") } }
|
|
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") }
|
|
136
|
+
}
|
|
137
|
+
let recovery = request.operation == .release || request.operation == .cancel || session?.releaseNonce != nil || discoveryReleaseNonce != nil
|
|
138
|
+
guard completed.count + pending.count < (recovery ? 256 : 192) else { throw failure("request-budget-exhausted") }
|
|
139
|
+
// Intent is persisted before ANY capture or release mutation. No caller
|
|
140
|
+
// path is used; replay compares the exact original request bytes.
|
|
141
|
+
try transport.persist(bytes, nonce: request.requestNonce, kind: "intent")
|
|
142
|
+
let received = NimoResourceClock.now()
|
|
143
|
+
let task = Task { @MainActor in
|
|
144
|
+
let result = try await self.perform(request, received: received)
|
|
145
|
+
let encoded = try JSONSerialization.data(withJSONObject: result, options: [.sortedKeys])
|
|
146
|
+
guard encoded.count <= 32_768 else { throw self.failure("receipt-budget-exhausted") }
|
|
147
|
+
try transport.persist(encoded, nonce: request.requestNonce, kind: "receipt")
|
|
148
|
+
return encoded
|
|
149
|
+
}
|
|
150
|
+
pending[request.requestNonce] = (bytes, task)
|
|
151
|
+
do {
|
|
152
|
+
let receipt = try await task.value
|
|
153
|
+
completed[request.requestNonce] = (bytes, receipt); pending.removeValue(forKey: request.requestNonce)
|
|
154
|
+
return receipt
|
|
155
|
+
} catch {
|
|
156
|
+
// Keep the original failed intent fenced; do not reexecute effects.
|
|
157
|
+
throw error
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
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
|
+
}
|
|
170
|
+
if request.operation == .arm {
|
|
171
|
+
guard session == nil, request.original == nil, DeviceManager.shared.sgc == nil, pendingManagerEntries == 0 else { throw failure("arm-requires-unowned-manager") }
|
|
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!)
|
|
187
|
+
}
|
|
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") }
|
|
202
|
+
if let expected = request.original {
|
|
203
|
+
let observed = session.original ?? session.instanceId.map { NimoResourceOriginal(instanceId: $0, peripheralUuid: session.pair.peripheralUuid, sessionGeneration: 0) }
|
|
204
|
+
guard expected == observed else { throw failure("original-session-mismatch") }
|
|
205
|
+
} else if request.operation == .release, session.hasOwner { throw failure("release-requires-original") }
|
|
206
|
+
if request.operation == .release || request.operation == .cancel {
|
|
207
|
+
if session.releaseNonce == nil {
|
|
208
|
+
let mic = owner?.resourceMicStopRequired ?? false, canvas = owner?.resourceCanvasRequired ?? false
|
|
209
|
+
guard session.beginRelease(nonce: request.requestNonce, cancel: request.operation == .cancel, mic: mic, canvas: canvas) else { throw failure("release-mismatch") }
|
|
210
|
+
if let owner { await owner.releaseResource(session) }
|
|
211
|
+
else {
|
|
212
|
+
let deadline = NimoResourceClock.now() + 1_000
|
|
213
|
+
while pendingOriginalManagerEntries != 0, NimoResourceClock.now() < deadline { try? await Task.sleep(nanoseconds: 10_000_000) }
|
|
214
|
+
if pendingOriginalManagerEntries != 0 { session.recordError("pending-manager-entry-not-settled") }
|
|
215
|
+
session.proveNoTransport()
|
|
216
|
+
}
|
|
217
|
+
} else if request.operation == .release, session.releaseNonce != request.requestNonce {
|
|
218
|
+
throw failure("original-release-nonce-required")
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
var producers = NimoResourceProducers()
|
|
222
|
+
producers.postedCallbacks = pendingOriginalManagerEntries
|
|
223
|
+
if let owner { producers = await owner.resourceProducers() }
|
|
224
|
+
originalClosed = session.settled(producers: producers)
|
|
225
|
+
let manager = DeviceManager.shared.sgc
|
|
226
|
+
let managerId = (manager as? Nimo)?.resourceInstanceId
|
|
227
|
+
let current: [String: Any] = ["type": nullable(manager?.type), "instanceId": nullable(managerId)]
|
|
228
|
+
var result: [String: Any] = ["version": 1, "protocol": "nimo-macos-resource-v1", "operation": request.operation.rawValue,
|
|
229
|
+
"requestNonce": request.requestNonce, "captureNonce": request.captureNonce,
|
|
230
|
+
"planSha256": request.planSha256, "grantSha256": request.grantSha256,
|
|
231
|
+
"process": ["pid": process.pid, "instanceId": process.instanceId],
|
|
232
|
+
"clock": ["kind": "darwin-continuous-time-v1", "receivedElapsedMs": received, "observedElapsedMs": NimoResourceClock.now()],
|
|
233
|
+
"capture": ["armedElapsedMs": session.armedElapsedMs, "admittedElapsedMs": nullable(session.admittedElapsedMs), "cancelled": session.cancelled,
|
|
234
|
+
"expectedPair": request.expectedPair.dictionary],
|
|
235
|
+
"currentManager": current, "session": nullable(session.sessionDictionary()),
|
|
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
|
|
239
|
+
}
|
|
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
|
+
|
|
282
|
+
private func decode(_ bytes: Data) throws -> NimoResourceRequest { try NimoResourceRequest.decode(bytes) }
|
|
283
|
+
private func failure(_ code: String) -> Error { NSError(domain: "NimoResourceFixture", code: 1, userInfo: [NSLocalizedDescriptionKey: code]) }
|
|
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
|
+
}
|