@mentra/bluetooth-sdk 0.1.11 → 0.1.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +52 -3
- package/android/build.gradle +1 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkAnalytics.kt +182 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +22 -3
- package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +10 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +10 -5
- package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +76 -5
- package/android/src/main/java/com/mentra/bluetoothsdk/camera/CameraModels.kt +15 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/events/BluetoothEvents.kt +1 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.java +34 -5
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +12 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/types/DeviceModels.kt +2 -1
- package/build/BluetoothSdk.types.d.ts +33 -1
- package/build/BluetoothSdk.types.d.ts.map +1 -1
- package/build/BluetoothSdk.types.js.map +1 -1
- package/build/_private/BluetoothSdkModule.d.ts +1 -1
- package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
- package/build/_private/BluetoothSdkModule.js.map +1 -1
- package/build/index.d.ts.map +1 -1
- package/build/index.js +2 -0
- package/build/index.js.map +1 -1
- package/ios/BluetoothSdkModule.swift +21 -7
- package/ios/Source/BluetoothSdkDefaults.swift +27 -0
- package/ios/Source/Bridge.swift +6 -0
- package/ios/Source/DeviceManager.swift +9 -6
- package/ios/Source/MentraBluetoothSDK.swift +160 -8
- package/ios/Source/camera/CameraModels.swift +49 -1
- package/ios/Source/events/BluetoothEvents.swift +3 -0
- package/ios/Source/internal/BluetoothAvailability.swift +20 -0
- package/ios/Source/internal/BluetoothSdkAnalytics.swift +160 -0
- package/ios/Source/sgcs/MentraLive.swift +29 -5
- package/ios/Source/sgcs/SGCManager.swift +13 -2
- package/ios/Source/types/DeviceModels.swift +5 -1
- package/package.json +1 -1
- package/plugin/build/index.d.ts +4 -0
- package/plugin/build/withAndroid.d.ts +2 -3
- package/plugin/build/withAndroid.js +46 -0
- package/plugin/build/withIos.d.ts +2 -3
- package/plugin/build/withIos.js +32 -0
- package/src/BluetoothSdk.types.ts +40 -1
- package/src/_private/BluetoothSdkModule.ts +5 -1
- package/src/index.ts +2 -0
|
@@ -81,10 +81,14 @@ private final class PendingHotspotStatusRequest {
|
|
|
81
81
|
private final class PendingVideoRecordingRequest {
|
|
82
82
|
let expectedStatus: String
|
|
83
83
|
let pending: PendingResponse<VideoRecordingStatusEvent>
|
|
84
|
+
let waitForUpload: Bool
|
|
85
|
+
var stoppedEvent: VideoRecordingStatusEvent?
|
|
86
|
+
var uploadSucceeded = false
|
|
84
87
|
|
|
85
|
-
init(expectedStatus: String, pending: PendingResponse<VideoRecordingStatusEvent
|
|
88
|
+
init(expectedStatus: String, pending: PendingResponse<VideoRecordingStatusEvent>, waitForUpload: Bool = false) {
|
|
86
89
|
self.expectedStatus = expectedStatus
|
|
87
90
|
self.pending = pending
|
|
91
|
+
self.waitForUpload = waitForUpload
|
|
88
92
|
}
|
|
89
93
|
}
|
|
90
94
|
|
|
@@ -147,13 +151,18 @@ public final class MentraBluetoothSDK {
|
|
|
147
151
|
|
|
148
152
|
private let configuration: MentraBluetoothSDKConfiguration
|
|
149
153
|
private var discoveredDeviceNames = Set<String>()
|
|
154
|
+
private var bluetoothAvailabilityListenerId: UUID?
|
|
155
|
+
private var shouldRestoreGlassesOnBluetoothRestore = false
|
|
156
|
+
private var shouldRestoreControllerOnBluetoothRestore = false
|
|
150
157
|
private var bridgeEventSinkId: String?
|
|
151
158
|
private var storeListenerId: String?
|
|
152
159
|
private let defaultDeviceKeys: Set<String> = ["default_wearable", "device_name", "device_address"]
|
|
160
|
+
private let videoUploadStopTimeoutMs = 10 * 60 * 1000
|
|
153
161
|
private var suppressDefaultDeviceEvents = false
|
|
154
162
|
private var defaultDeviceApplyGeneration = 0
|
|
155
163
|
private var activeScanSessions: [UUID: ActiveScanSession] = [:]
|
|
156
164
|
private var activeStreamKeepAlive: ActiveStreamKeepAlive?
|
|
165
|
+
private let analytics: BluetoothSdkAnalytics
|
|
157
166
|
private var pendingPhotoRequests: [String: PendingResponse<PhotoResponseEvent>] = [:]
|
|
158
167
|
private var pendingVideoRecordingRequests: [String: PendingVideoRecordingRequest] = [:]
|
|
159
168
|
private var pendingRgbLedRequests: [String: PendingResponse<RgbLedControlResponseEvent>] = [:]
|
|
@@ -170,7 +179,12 @@ public final class MentraBluetoothSDK {
|
|
|
170
179
|
|
|
171
180
|
public init(configuration: MentraBluetoothSDKConfiguration = .default) {
|
|
172
181
|
self.configuration = configuration
|
|
173
|
-
|
|
182
|
+
analytics = BluetoothSdkAnalytics(configuration: configuration.analytics)
|
|
183
|
+
bluetoothAvailabilityListenerId = BluetoothAvailability.shared.addStateListener { [weak self] state in
|
|
184
|
+
Task { @MainActor [weak self] in
|
|
185
|
+
self?.handleBluetoothAvailability(state)
|
|
186
|
+
}
|
|
187
|
+
}
|
|
174
188
|
bridgeEventSinkId = Bridge.addEventSink { [weak self] eventName, data in
|
|
175
189
|
Task { @MainActor [weak self] in
|
|
176
190
|
self?.dispatchBridgeEvent(eventName, data)
|
|
@@ -181,6 +195,8 @@ public final class MentraBluetoothSDK {
|
|
|
181
195
|
self?.dispatchStoreUpdate(category, changes)
|
|
182
196
|
}
|
|
183
197
|
}
|
|
198
|
+
analytics.initializeGlassesStatus(glassesStatus)
|
|
199
|
+
analytics.captureStarted()
|
|
184
200
|
}
|
|
185
201
|
|
|
186
202
|
public var state: MentraBluetoothState {
|
|
@@ -211,6 +227,15 @@ public final class MentraBluetoothSDK {
|
|
|
211
227
|
currentDefaultDevice()
|
|
212
228
|
}
|
|
213
229
|
|
|
230
|
+
private func requireGlassesConnected(operation: String) throws {
|
|
231
|
+
guard glassesStatus.connected else {
|
|
232
|
+
throw BluetoothError(
|
|
233
|
+
code: "glasses_not_connected",
|
|
234
|
+
message: "Cannot \(operation) because glasses are not connected."
|
|
235
|
+
)
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
214
239
|
public func getDefaultDevice() -> Device? {
|
|
215
240
|
currentDefaultDevice()
|
|
216
241
|
}
|
|
@@ -296,6 +321,7 @@ public final class MentraBluetoothSDK {
|
|
|
296
321
|
}
|
|
297
322
|
|
|
298
323
|
public func connect(to device: Device, options: ConnectOptions = ConnectOptions()) throws {
|
|
324
|
+
clearBluetoothRestoreIntent()
|
|
299
325
|
if device.model != .simulated {
|
|
300
326
|
try BluetoothAvailability.shared.requirePoweredOn(operation: "connect to glasses")
|
|
301
327
|
}
|
|
@@ -315,6 +341,7 @@ public final class MentraBluetoothSDK {
|
|
|
315
341
|
}
|
|
316
342
|
|
|
317
343
|
public func connectDefault(options: ConnectOptions = ConnectOptions()) throws {
|
|
344
|
+
clearBluetoothRestoreIntent()
|
|
318
345
|
guard let device = currentDefaultDevice() else {
|
|
319
346
|
throw BluetoothError(
|
|
320
347
|
code: "default_device_missing",
|
|
@@ -331,18 +358,22 @@ public final class MentraBluetoothSDK {
|
|
|
331
358
|
}
|
|
332
359
|
|
|
333
360
|
public func cancelConnectionAttempt() {
|
|
361
|
+
clearBluetoothRestoreIntent()
|
|
334
362
|
DeviceManager.shared.disconnect()
|
|
335
363
|
}
|
|
336
364
|
|
|
337
365
|
func connectSimulated() {
|
|
366
|
+
clearBluetoothRestoreIntent()
|
|
338
367
|
DeviceManager.shared.connectSimulated()
|
|
339
368
|
}
|
|
340
369
|
|
|
341
370
|
public func disconnect() {
|
|
371
|
+
clearBluetoothRestoreIntent()
|
|
342
372
|
DeviceManager.shared.disconnect()
|
|
343
373
|
}
|
|
344
374
|
|
|
345
375
|
public func forget() {
|
|
376
|
+
clearBluetoothRestoreIntent()
|
|
346
377
|
DeviceManager.shared.forget()
|
|
347
378
|
}
|
|
348
379
|
|
|
@@ -806,6 +837,7 @@ public final class MentraBluetoothSDK {
|
|
|
806
837
|
guard !request.requestId.isEmpty else {
|
|
807
838
|
throw BluetoothError(code: "missing_request_id", message: "requestId is required to start video recording.")
|
|
808
839
|
}
|
|
840
|
+
try requireGlassesConnected(operation: "start video recording")
|
|
809
841
|
let pending = PendingResponse<VideoRecordingStatusEvent>(
|
|
810
842
|
operation: "start video recording \(request.requestId)"
|
|
811
843
|
)
|
|
@@ -825,7 +857,8 @@ public final class MentraBluetoothSDK {
|
|
|
825
857
|
request.sound,
|
|
826
858
|
request.width,
|
|
827
859
|
request.height,
|
|
828
|
-
request.fps
|
|
860
|
+
request.fps,
|
|
861
|
+
request.maxRecordingTimeMinutes
|
|
829
862
|
)
|
|
830
863
|
do {
|
|
831
864
|
let event = try await pending.wait()
|
|
@@ -837,10 +870,13 @@ public final class MentraBluetoothSDK {
|
|
|
837
870
|
}
|
|
838
871
|
}
|
|
839
872
|
|
|
840
|
-
public func stopVideoRecording(
|
|
873
|
+
public func stopVideoRecording(
|
|
874
|
+
requestId: String, webhookUrl: String? = nil, authToken: String? = nil
|
|
875
|
+
) async throws -> VideoRecordingStatusEvent {
|
|
841
876
|
guard !requestId.isEmpty else {
|
|
842
877
|
throw BluetoothError(code: "missing_request_id", message: "requestId is required to stop video recording.")
|
|
843
878
|
}
|
|
879
|
+
try requireGlassesConnected(operation: "stop video recording")
|
|
844
880
|
let pending = PendingResponse<VideoRecordingStatusEvent>(operation: "stop video recording \(requestId)")
|
|
845
881
|
guard pendingVideoRecordingRequests[requestId] == nil else {
|
|
846
882
|
throw BluetoothError(
|
|
@@ -848,13 +884,16 @@ public final class MentraBluetoothSDK {
|
|
|
848
884
|
message: "A video recording command is already waiting for requestId \(requestId)."
|
|
849
885
|
)
|
|
850
886
|
}
|
|
887
|
+
let waitForUpload = !(webhookUrl?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true)
|
|
851
888
|
pendingVideoRecordingRequests[requestId] = PendingVideoRecordingRequest(
|
|
852
889
|
expectedStatus: "recording_stopped",
|
|
853
|
-
pending: pending
|
|
890
|
+
pending: pending,
|
|
891
|
+
waitForUpload: waitForUpload
|
|
854
892
|
)
|
|
855
|
-
DeviceManager.shared.stopVideoRecording(requestId)
|
|
893
|
+
DeviceManager.shared.stopVideoRecording(requestId, webhookUrl, authToken)
|
|
856
894
|
do {
|
|
857
|
-
let
|
|
895
|
+
let timeoutMs = waitForUpload ? videoUploadStopTimeoutMs : 15_000
|
|
896
|
+
let event = try await pending.wait(timeoutMs: timeoutMs)
|
|
858
897
|
pendingVideoRecordingRequests.removeValue(forKey: requestId)
|
|
859
898
|
return event
|
|
860
899
|
} catch {
|
|
@@ -971,6 +1010,10 @@ public final class MentraBluetoothSDK {
|
|
|
971
1010
|
|
|
972
1011
|
public func invalidate() {
|
|
973
1012
|
stopStreamKeepAliveMonitor()
|
|
1013
|
+
if let bluetoothAvailabilityListenerId {
|
|
1014
|
+
BluetoothAvailability.shared.removeStateListener(bluetoothAvailabilityListenerId)
|
|
1015
|
+
self.bluetoothAvailabilityListenerId = nil
|
|
1016
|
+
}
|
|
974
1017
|
if let bridgeEventSinkId {
|
|
975
1018
|
Bridge.removeEventSink(bridgeEventSinkId)
|
|
976
1019
|
self.bridgeEventSinkId = nil
|
|
@@ -982,6 +1025,83 @@ public final class MentraBluetoothSDK {
|
|
|
982
1025
|
delegate = nil
|
|
983
1026
|
}
|
|
984
1027
|
|
|
1028
|
+
private func handleBluetoothAvailability(_ state: CBManagerState) {
|
|
1029
|
+
switch state {
|
|
1030
|
+
case .poweredOff, .resetting, .unauthorized, .unsupported:
|
|
1031
|
+
handleBluetoothUnavailable()
|
|
1032
|
+
case .poweredOn:
|
|
1033
|
+
handleBluetoothRestored()
|
|
1034
|
+
case .unknown:
|
|
1035
|
+
break
|
|
1036
|
+
@unknown default:
|
|
1037
|
+
handleBluetoothUnavailable()
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
private func handleBluetoothUnavailable() {
|
|
1042
|
+
cancelActiveScanSessions(reason: .cancelled)
|
|
1043
|
+
clearBluetoothDiscoveryState()
|
|
1044
|
+
disconnectActiveConnections()
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
private func disconnectActiveConnections() {
|
|
1048
|
+
if glassesStatus.controllerConnected {
|
|
1049
|
+
DeviceManager.shared.disconnectController()
|
|
1050
|
+
shouldRestoreControllerOnBluetoothRestore = true
|
|
1051
|
+
}
|
|
1052
|
+
if glassesStatus.deviceModel == DeviceTypes.SIMULATED
|
|
1053
|
+
|| DeviceManager.shared.sgc?.type.contains(DeviceTypes.SIMULATED) == true
|
|
1054
|
+
{
|
|
1055
|
+
return
|
|
1056
|
+
}
|
|
1057
|
+
if glassesStatus.connected || glassesStatus.connectionState != .disconnected {
|
|
1058
|
+
DeviceManager.shared.disconnect()
|
|
1059
|
+
shouldRestoreGlassesOnBluetoothRestore = true
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
/// Reconnect only what `handleBluetoothUnavailable` tore down, never a
|
|
1064
|
+
/// connection the user closed themselves (explicit connect/disconnect
|
|
1065
|
+
/// calls clear the restore intent).
|
|
1066
|
+
private func handleBluetoothRestored() {
|
|
1067
|
+
let restoreGlasses = shouldRestoreGlassesOnBluetoothRestore
|
|
1068
|
+
let restoreController = shouldRestoreControllerOnBluetoothRestore
|
|
1069
|
+
clearBluetoothRestoreIntent()
|
|
1070
|
+
|
|
1071
|
+
if restoreGlasses, !glassesStatus.connected, glassesStatus.connectionState == .disconnected {
|
|
1072
|
+
DeviceManager.shared.connectDefault() // also restores the controller
|
|
1073
|
+
} else if restoreController, !glassesStatus.controllerConnected {
|
|
1074
|
+
DeviceManager.shared.connectDefaultController()
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
private func clearBluetoothRestoreIntent() {
|
|
1079
|
+
shouldRestoreGlassesOnBluetoothRestore = false
|
|
1080
|
+
shouldRestoreControllerOnBluetoothRestore = false
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
private func clearBluetoothDiscoveryState() {
|
|
1084
|
+
discoveredDeviceNames.removeAll()
|
|
1085
|
+
DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "searching", false)
|
|
1086
|
+
DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "searchingController", false)
|
|
1087
|
+
DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "searchResults", [] as [[String: Any]])
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
private func cancelActiveScanSessions(reason: ScanStopReason) {
|
|
1091
|
+
let ids = Array(activeScanSessions.keys)
|
|
1092
|
+
guard !ids.isEmpty else {
|
|
1093
|
+
if bluetoothStatus.searching || bluetoothStatus.searchingController {
|
|
1094
|
+
stopScan(reason: reason)
|
|
1095
|
+
}
|
|
1096
|
+
return
|
|
1097
|
+
}
|
|
1098
|
+
for (index, id) in ids.enumerated() {
|
|
1099
|
+
// Stop the underlying scan once (first session); the rest only
|
|
1100
|
+
// complete their callbacks.
|
|
1101
|
+
finishScanSession(id, reason: reason, shouldStopScan: index == 0)
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
|
|
985
1105
|
private func startStreamKeepAliveMonitor(streamId: String, intervalSeconds requestedIntervalSeconds: Int) {
|
|
986
1106
|
let intervalSeconds = requestedIntervalSeconds > 0 ? requestedIntervalSeconds : 5
|
|
987
1107
|
let tracker = ActiveStreamKeepAlive(streamId: streamId, intervalSeconds: intervalSeconds)
|
|
@@ -1162,7 +1282,14 @@ public final class MentraBluetoothSDK {
|
|
|
1162
1282
|
guard let request = pendingVideoRecordingRequests[event.requestId] else { return }
|
|
1163
1283
|
if event.success {
|
|
1164
1284
|
if event.status == request.expectedStatus {
|
|
1165
|
-
request.
|
|
1285
|
+
if request.waitForUpload {
|
|
1286
|
+
request.stoppedEvent = event
|
|
1287
|
+
if request.uploadSucceeded {
|
|
1288
|
+
request.pending.resolve(event)
|
|
1289
|
+
}
|
|
1290
|
+
} else {
|
|
1291
|
+
request.pending.resolve(event)
|
|
1292
|
+
}
|
|
1166
1293
|
}
|
|
1167
1294
|
} else {
|
|
1168
1295
|
request.pending.reject(
|
|
@@ -1174,6 +1301,26 @@ public final class MentraBluetoothSDK {
|
|
|
1174
1301
|
}
|
|
1175
1302
|
}
|
|
1176
1303
|
|
|
1304
|
+
private func handleMediaUploadForRequests(_ event: MediaUploadEvent) {
|
|
1305
|
+
guard event.isVideo, let request = pendingVideoRecordingRequests[event.requestId], request.waitForUpload else {
|
|
1306
|
+
return
|
|
1307
|
+
}
|
|
1308
|
+
if event.isSuccess {
|
|
1309
|
+
if let stoppedEvent = request.stoppedEvent {
|
|
1310
|
+
request.pending.resolve(stoppedEvent)
|
|
1311
|
+
} else {
|
|
1312
|
+
request.uploadSucceeded = true
|
|
1313
|
+
}
|
|
1314
|
+
} else {
|
|
1315
|
+
request.pending.reject(
|
|
1316
|
+
BluetoothError(
|
|
1317
|
+
code: "video_upload_failed",
|
|
1318
|
+
message: event.errorMessage ?? "Video upload failed."
|
|
1319
|
+
)
|
|
1320
|
+
)
|
|
1321
|
+
}
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1177
1324
|
private func handleRgbLedResponseForRequests(_ event: RgbLedControlResponseEvent) {
|
|
1178
1325
|
guard let pending = pendingRgbLedRequests[event.requestId] else { return }
|
|
1179
1326
|
if event.state == "success" {
|
|
@@ -1273,6 +1420,7 @@ public final class MentraBluetoothSDK {
|
|
|
1273
1420
|
private func dispatchStoreUpdate(_ category: String, _ changes: [String: Any]) {
|
|
1274
1421
|
switch ObservableStore.normalizeCategory(category) {
|
|
1275
1422
|
case "glasses":
|
|
1423
|
+
analytics.observeGlassesStatus(glassesStatus)
|
|
1276
1424
|
let nextState = state
|
|
1277
1425
|
delegate?.mentraBluetoothSDK(self, didUpdate: nextState)
|
|
1278
1426
|
delegate?.mentraBluetoothSDK(self, didUpdateGlasses: nextState.glasses)
|
|
@@ -1424,6 +1572,10 @@ public final class MentraBluetoothSDK {
|
|
|
1424
1572
|
let event = VideoRecordingStatusEvent(values: data)
|
|
1425
1573
|
handleVideoRecordingStatusForRequests(event)
|
|
1426
1574
|
delegate?.mentraBluetoothSDK(self, didReceive: .videoRecordingStatus(event))
|
|
1575
|
+
case "media_success", "media_error":
|
|
1576
|
+
let event = MediaUploadEvent(values: data)
|
|
1577
|
+
handleMediaUploadForRequests(event)
|
|
1578
|
+
delegate?.mentraBluetoothSDK(self, didReceive: .mediaUpload(event))
|
|
1427
1579
|
case "rgb_led_control_response":
|
|
1428
1580
|
let event = RgbLedControlResponseEvent(values: data)
|
|
1429
1581
|
handleRgbLedResponseForRequests(event)
|
|
@@ -229,9 +229,12 @@ public struct VideoRecordingRequest {
|
|
|
229
229
|
public let width: Int
|
|
230
230
|
public let height: Int
|
|
231
231
|
public let fps: Int
|
|
232
|
+
// Optional auto-stop timer in minutes; 0 = record until stopped/interrupted.
|
|
233
|
+
public let maxRecordingTimeMinutes: Int
|
|
232
234
|
|
|
233
235
|
public init(
|
|
234
|
-
requestId: String, save: Bool, sound: Bool, width: Int = 0, height: Int = 0, fps: Int = 0
|
|
236
|
+
requestId: String, save: Bool, sound: Bool, width: Int = 0, height: Int = 0, fps: Int = 0,
|
|
237
|
+
maxRecordingTimeMinutes: Int = 0
|
|
235
238
|
) {
|
|
236
239
|
self.requestId = requestId
|
|
237
240
|
self.save = save
|
|
@@ -239,6 +242,7 @@ public struct VideoRecordingRequest {
|
|
|
239
242
|
self.width = width
|
|
240
243
|
self.height = height
|
|
241
244
|
self.fps = fps
|
|
245
|
+
self.maxRecordingTimeMinutes = maxRecordingTimeMinutes
|
|
242
246
|
}
|
|
243
247
|
}
|
|
244
248
|
|
|
@@ -280,6 +284,50 @@ public struct VideoRecordingStatusEvent: CustomStringConvertible {
|
|
|
280
284
|
}
|
|
281
285
|
}
|
|
282
286
|
|
|
287
|
+
public struct MediaUploadEvent: CustomStringConvertible {
|
|
288
|
+
public let values: [String: Any]
|
|
289
|
+
|
|
290
|
+
public init(values: [String: Any]) {
|
|
291
|
+
self.values = values
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
public var type: String {
|
|
295
|
+
stringValue(values, "type") ?? ""
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
public var requestId: String {
|
|
299
|
+
stringValue(values, "requestId") ?? ""
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
public var mediaUrl: String? {
|
|
303
|
+
stringValue(values, "mediaUrl")
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
public var errorMessage: String? {
|
|
307
|
+
stringValue(values, "errorMessage")
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
public var mediaType: Int? {
|
|
311
|
+
intValue(values["mediaType"])
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
public var timestamp: Int {
|
|
315
|
+
intValue(values["timestamp"]) ?? Int(Date().timeIntervalSince1970 * 1000)
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
public var isSuccess: Bool {
|
|
319
|
+
type == "media_success"
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
public var isVideo: Bool {
|
|
323
|
+
mediaType == 2
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
public var description: String {
|
|
327
|
+
"MediaUploadEvent(requestId: \(requestId), type: \(type), mediaType: \(mediaType ?? -1))"
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
283
331
|
public enum PhotoResponse: CustomStringConvertible, Equatable {
|
|
284
332
|
public enum State: String {
|
|
285
333
|
case success
|
|
@@ -244,6 +244,7 @@ public enum BluetoothEvent: CustomStringConvertible {
|
|
|
244
244
|
case photoResponse(PhotoResponseEvent)
|
|
245
245
|
case photoStatus(PhotoStatusEvent)
|
|
246
246
|
case videoRecordingStatus(VideoRecordingStatusEvent)
|
|
247
|
+
case mediaUpload(MediaUploadEvent)
|
|
247
248
|
case rgbLedControlResponse(RgbLedControlResponseEvent)
|
|
248
249
|
case streamStatus(StreamStatusEvent)
|
|
249
250
|
case keepAliveAck(KeepAliveAckEvent)
|
|
@@ -277,6 +278,8 @@ public enum BluetoothEvent: CustomStringConvertible {
|
|
|
277
278
|
event.description
|
|
278
279
|
case let .videoRecordingStatus(event):
|
|
279
280
|
event.description
|
|
281
|
+
case let .mediaUpload(event):
|
|
282
|
+
event.description
|
|
280
283
|
case let .rgbLedControlResponse(event):
|
|
281
284
|
event.description
|
|
282
285
|
case let .streamStatus(event):
|
|
@@ -6,6 +6,7 @@ final class BluetoothAvailability: NSObject, CBCentralManagerDelegate {
|
|
|
6
6
|
|
|
7
7
|
private var centralManager: CBCentralManager?
|
|
8
8
|
private var state: CBManagerState = .unknown
|
|
9
|
+
private var listeners: [UUID: (CBManagerState) -> Void] = [:]
|
|
9
10
|
|
|
10
11
|
override private init() {
|
|
11
12
|
super.init()
|
|
@@ -19,6 +20,25 @@ final class BluetoothAvailability: NSObject, CBCentralManagerDelegate {
|
|
|
19
20
|
|
|
20
21
|
func centralManagerDidUpdateState(_ central: CBCentralManager) {
|
|
21
22
|
state = central.state
|
|
23
|
+
for listener in Array(listeners.values) {
|
|
24
|
+
listener(state)
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
@discardableResult
|
|
29
|
+
func addStateListener(_ listener: @escaping (CBManagerState) -> Void) -> UUID {
|
|
30
|
+
// `listeners` is unsynchronized; it is only safe because the central
|
|
31
|
+
// manager queue is `.main` and all callers are main-thread bound.
|
|
32
|
+
dispatchPrecondition(condition: .onQueue(.main))
|
|
33
|
+
let id = UUID()
|
|
34
|
+
listeners[id] = listener
|
|
35
|
+
listener(state)
|
|
36
|
+
return id
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
func removeStateListener(_ id: UUID) {
|
|
40
|
+
dispatchPrecondition(condition: .onQueue(.main))
|
|
41
|
+
listeners[id] = nil
|
|
22
42
|
}
|
|
23
43
|
|
|
24
44
|
func requirePoweredOn(operation: String) throws {
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
public struct BluetoothSdkAnalyticsConfiguration {
|
|
4
|
+
public static let disabled = BluetoothSdkAnalyticsConfiguration(enabled: false)
|
|
5
|
+
|
|
6
|
+
public let enabled: Bool
|
|
7
|
+
let surface: String
|
|
8
|
+
|
|
9
|
+
public init(enabled: Bool = true) {
|
|
10
|
+
self.enabled = enabled
|
|
11
|
+
surface = "ios"
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
var isReady: Bool {
|
|
15
|
+
enabled
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
func withSurface(_ surface: String) -> BluetoothSdkAnalyticsConfiguration {
|
|
19
|
+
BluetoothSdkAnalyticsConfiguration(
|
|
20
|
+
enabled: enabled,
|
|
21
|
+
surface: surface
|
|
22
|
+
)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
private init(
|
|
26
|
+
enabled: Bool,
|
|
27
|
+
surface: String
|
|
28
|
+
) {
|
|
29
|
+
self.enabled = enabled
|
|
30
|
+
self.surface = surface
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
final class BluetoothSdkAnalytics {
|
|
35
|
+
private static let defaultPostHogApiKey = "phc_FCweXVAxVgU7wZK4Fk3okOx4RmyNqVHJf62YpZSfJt5"
|
|
36
|
+
private static let defaultPostHogHost = "https://us.i.posthog.com"
|
|
37
|
+
private let stateQueue = DispatchQueue(label: "com.mentra.bluetoothsdk.analytics.state")
|
|
38
|
+
private let transportQueue = DispatchQueue(label: "com.mentra.bluetoothsdk.analytics.transport")
|
|
39
|
+
private let configuration: BluetoothSdkAnalyticsConfiguration
|
|
40
|
+
private var startedCaptured = false
|
|
41
|
+
private var lastConnected = false
|
|
42
|
+
|
|
43
|
+
init(configuration: BluetoothSdkAnalyticsConfiguration) {
|
|
44
|
+
self.configuration = configuration.resolvedForApp()
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
func initializeGlassesStatus(_ status: GlassesStatus) {
|
|
48
|
+
stateQueue.sync {
|
|
49
|
+
lastConnected = status.analyticsConnected
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
func captureStarted() {
|
|
54
|
+
stateQueue.sync {
|
|
55
|
+
captureStartedLocked()
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
private func captureStartedLocked() {
|
|
60
|
+
guard !startedCaptured, configuration.isReady else { return }
|
|
61
|
+
startedCaptured = true
|
|
62
|
+
capture(
|
|
63
|
+
event: "bluetooth_sdk_started",
|
|
64
|
+
properties: ["event_kind": "sdk_started"],
|
|
65
|
+
configuration: configuration
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
func observeGlassesStatus(_ status: GlassesStatus) {
|
|
70
|
+
stateQueue.sync {
|
|
71
|
+
let isConnected = status.analyticsConnected
|
|
72
|
+
let wasConnected = lastConnected
|
|
73
|
+
lastConnected = isConnected
|
|
74
|
+
guard configuration.isReady else { return }
|
|
75
|
+
if isConnected, !wasConnected {
|
|
76
|
+
var properties: [String: Any] = [
|
|
77
|
+
"event_kind": "glasses_connected",
|
|
78
|
+
"fully_booted": status.fullyBooted,
|
|
79
|
+
]
|
|
80
|
+
if !status.deviceModel.isEmpty {
|
|
81
|
+
properties["glasses_model"] = status.deviceModel
|
|
82
|
+
}
|
|
83
|
+
capture(event: "bluetooth_sdk_glasses_connected", properties: properties, configuration: configuration)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
private func capture(
|
|
89
|
+
event: String,
|
|
90
|
+
properties: [String: Any],
|
|
91
|
+
configuration activeConfiguration: BluetoothSdkAnalyticsConfiguration
|
|
92
|
+
) {
|
|
93
|
+
guard activeConfiguration.isReady else { return }
|
|
94
|
+
|
|
95
|
+
transportQueue.async {
|
|
96
|
+
let payload: [String: Any] = [
|
|
97
|
+
"api_key": Self.defaultPostHogApiKey,
|
|
98
|
+
"event": event,
|
|
99
|
+
"distinct_id": self.distinctId(),
|
|
100
|
+
"properties": self.baseProperties(configuration: activeConfiguration).merging(properties) { _, new in new },
|
|
101
|
+
]
|
|
102
|
+
guard let body = try? JSONSerialization.data(withJSONObject: payload) else { return }
|
|
103
|
+
guard let captureURL = self.captureURL() else { return }
|
|
104
|
+
var request = URLRequest(url: captureURL)
|
|
105
|
+
request.httpMethod = "POST"
|
|
106
|
+
request.timeoutInterval = 4
|
|
107
|
+
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
108
|
+
request.httpBody = body
|
|
109
|
+
URLSession.shared.dataTask(with: request).resume()
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
private func baseProperties(configuration: BluetoothSdkAnalyticsConfiguration) -> [String: Any] {
|
|
114
|
+
var properties: [String: Any] = [
|
|
115
|
+
"$process_person_profile": false,
|
|
116
|
+
"event_source": "mentra_bluetooth_sdk",
|
|
117
|
+
"sdk_platform": "ios",
|
|
118
|
+
"sdk_surface": configuration.surface,
|
|
119
|
+
"app_bundle_identifier": Bundle.main.bundleIdentifier ?? "",
|
|
120
|
+
"os_platform": "ios",
|
|
121
|
+
"os_version": ProcessInfo.processInfo.operatingSystemVersionString,
|
|
122
|
+
]
|
|
123
|
+
if let sdkVersion = BluetoothSdkDefaults.sdkVersion {
|
|
124
|
+
properties["sdk_version"] = sdkVersion
|
|
125
|
+
}
|
|
126
|
+
return properties
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
private func distinctId() -> String {
|
|
130
|
+
let key = "mentra_bluetooth_sdk_analytics_distinct_id"
|
|
131
|
+
if let existing = UserDefaults.standard.string(forKey: key), !existing.isEmpty {
|
|
132
|
+
return existing
|
|
133
|
+
}
|
|
134
|
+
let generated = "mentra-bt-sdk-\(UUID().uuidString)"
|
|
135
|
+
UserDefaults.standard.set(generated, forKey: key)
|
|
136
|
+
return generated
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private func captureURL() -> URL? {
|
|
140
|
+
let normalized = Self.defaultPostHogHost.trimmingCharacters(in: .whitespacesAndNewlines).trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
|
141
|
+
return URL(string: "\(normalized)/i/v0/e/")
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
private extension GlassesStatus {
|
|
146
|
+
var analyticsConnected: Bool {
|
|
147
|
+
connectionState.isConnected || connected || fullyBooted
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
private extension BluetoothSdkAnalyticsConfiguration {
|
|
152
|
+
func resolvedForApp() -> BluetoothSdkAnalyticsConfiguration {
|
|
153
|
+
let disabledByApp = Bundle.main.object(forInfoDictionaryKey: "MentraBluetoothSdkAnalyticsDisabled") as? Bool == true
|
|
154
|
+
|
|
155
|
+
return BluetoothSdkAnalyticsConfiguration(
|
|
156
|
+
enabled: enabled && !disabledByApp,
|
|
157
|
+
surface: surface
|
|
158
|
+
)
|
|
159
|
+
}
|
|
160
|
+
}
|
|
@@ -2171,6 +2171,9 @@ class MentraLive: NSObject, SGCManager {
|
|
|
2171
2171
|
case "video_recording_status":
|
|
2172
2172
|
emitVideoRecordingStatus(json)
|
|
2173
2173
|
|
|
2174
|
+
case "media_success", "media_error":
|
|
2175
|
+
Bridge.sendMediaUploadEvent(type: type, values: json)
|
|
2176
|
+
|
|
2174
2177
|
case "photo_status":
|
|
2175
2178
|
emitPhotoStatus(json)
|
|
2176
2179
|
|
|
@@ -5269,7 +5272,8 @@ extension MentraLive {
|
|
|
5269
5272
|
|
|
5270
5273
|
func startVideoRecording(requestId: String, save: Bool, flash: Bool, sound: Bool) {
|
|
5271
5274
|
startVideoRecording(
|
|
5272
|
-
requestId: requestId, save: save, flash: flash, sound: sound, width: 0, height: 0, fps: 0
|
|
5275
|
+
requestId: requestId, save: save, flash: flash, sound: sound, width: 0, height: 0, fps: 0,
|
|
5276
|
+
maxRecordingTimeMinutes: 0
|
|
5273
5277
|
)
|
|
5274
5278
|
}
|
|
5275
5279
|
|
|
@@ -5280,10 +5284,11 @@ extension MentraLive {
|
|
|
5280
5284
|
}
|
|
5281
5285
|
|
|
5282
5286
|
func startVideoRecording(
|
|
5283
|
-
requestId: String, save: Bool, flash: Bool, sound: Bool, width: Int, height: Int, fps: Int
|
|
5287
|
+
requestId: String, save: Bool, flash: Bool, sound: Bool, width: Int, height: Int, fps: Int,
|
|
5288
|
+
maxRecordingTimeMinutes: Int
|
|
5284
5289
|
) {
|
|
5285
5290
|
Bridge.log(
|
|
5286
|
-
"Starting video recording on glasses: requestId=\(requestId), save=\(save), flash=\(flash), sound=\(sound), resolution=\(width)x\(height)@\(fps)fps"
|
|
5291
|
+
"Starting video recording on glasses: requestId=\(requestId), save=\(save), flash=\(flash), sound=\(sound), resolution=\(width)x\(height)@\(fps)fps, maxRecordingTimeMinutes=\(maxRecordingTimeMinutes)"
|
|
5287
5292
|
)
|
|
5288
5293
|
|
|
5289
5294
|
guard connectionState == ConnTypes.CONNECTED else {
|
|
@@ -5299,6 +5304,11 @@ extension MentraLive {
|
|
|
5299
5304
|
"sound": sound,
|
|
5300
5305
|
]
|
|
5301
5306
|
|
|
5307
|
+
// Auto-stop timer; only sent when set (> 0). 0 = record until stopped.
|
|
5308
|
+
if maxRecordingTimeMinutes > 0 {
|
|
5309
|
+
json["maxRecordingTimeMinutes"] = maxRecordingTimeMinutes
|
|
5310
|
+
}
|
|
5311
|
+
|
|
5302
5312
|
// Add video settings when any field is overridden. Each field is sent
|
|
5303
5313
|
// only when > 0; the glasses merge the missing fields onto their saved
|
|
5304
5314
|
// button-video defaults, so a partial override (e.g. fps-only) still
|
|
@@ -5314,17 +5324,31 @@ extension MentraLive {
|
|
|
5314
5324
|
}
|
|
5315
5325
|
|
|
5316
5326
|
func stopVideoRecording(requestId: String) {
|
|
5317
|
-
|
|
5327
|
+
stopVideoRecording(requestId: requestId, webhookUrl: nil, authToken: nil)
|
|
5328
|
+
}
|
|
5329
|
+
|
|
5330
|
+
func stopVideoRecording(requestId: String, webhookUrl: String?, authToken: String?) {
|
|
5331
|
+
Bridge.log(
|
|
5332
|
+
"Stopping video recording on glasses: requestId=\(requestId), webhook=\((webhookUrl?.isEmpty ?? true) ? "none" : "set")"
|
|
5333
|
+
)
|
|
5318
5334
|
|
|
5319
5335
|
guard connectionState == ConnTypes.CONNECTED else {
|
|
5320
5336
|
Bridge.log("Cannot stop video recording - not connected")
|
|
5321
5337
|
return
|
|
5322
5338
|
}
|
|
5323
5339
|
|
|
5324
|
-
|
|
5340
|
+
var json: [String: Any] = [
|
|
5325
5341
|
"type": "stop_video_recording",
|
|
5326
5342
|
"requestId": requestId,
|
|
5327
5343
|
]
|
|
5344
|
+
// Webhook upload target, supplied at stop so the token is fresh.
|
|
5345
|
+
// Only sent when present; empty webhook = keep video on device.
|
|
5346
|
+
if let webhookUrl, !webhookUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
|
5347
|
+
json["webhookUrl"] = webhookUrl
|
|
5348
|
+
}
|
|
5349
|
+
if let authToken, !authToken.isEmpty {
|
|
5350
|
+
json["authToken"] = authToken
|
|
5351
|
+
}
|
|
5328
5352
|
sendJson(json)
|
|
5329
5353
|
}
|
|
5330
5354
|
}
|