@mentra/bluetooth-sdk 0.1.10 → 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 +61 -19
- package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +24 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +32 -8
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +8 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +76 -5
- package/android/src/main/java/com/mentra/bluetoothsdk/camera/CameraModels.kt +17 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/events/BluetoothEvents.kt +1 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +829 -643
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.java +34 -5
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +40 -64
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +23 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/types/DeviceModels.kt +2 -1
- package/build/BluetoothSdk.types.d.ts +45 -2
- package/build/BluetoothSdk.types.d.ts.map +1 -1
- package/build/BluetoothSdk.types.js.map +1 -1
- package/build/_private/BluetoothSdkModule.d.ts +2 -1
- package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
- package/build/_private/BluetoothSdkModule.js +14 -1
- package/build/_private/BluetoothSdkModule.js.map +1 -1
- package/build/index.d.ts +1 -1
- package/build/index.d.ts.map +1 -1
- package/build/index.js +4 -0
- package/build/index.js.map +1 -1
- package/ios/BluetoothSdkModule.swift +24 -7
- package/ios/Source/BluetoothSdkDefaults.swift +27 -0
- package/ios/Source/Bridge.swift +16 -0
- package/ios/Source/DeviceManager.swift +33 -27
- package/ios/Source/DeviceStore.swift +8 -2
- package/ios/Source/MentraBluetoothSDK.swift +160 -8
- package/ios/Source/camera/CameraModels.swift +50 -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/G1.swift +3 -3
- package/ios/Source/sgcs/G2.swift +1022 -640
- package/ios/Source/sgcs/Mach1.swift +2 -2
- package/ios/Source/sgcs/MentraLive.swift +31 -7
- package/ios/Source/sgcs/MentraNex.swift +215 -85
- package/ios/Source/sgcs/SGCManager.swift +26 -6
- package/ios/Source/sgcs/Simulated.swift +2 -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 +53 -2
- package/src/_private/BluetoothSdkModule.ts +27 -4
- package/src/index.ts +5 -0
|
@@ -18,6 +18,9 @@ public class BluetoothSdkModule: Module, MentraBluetoothSDKDelegate {
|
|
|
18
18
|
"glasses_not_ready",
|
|
19
19
|
"button_press",
|
|
20
20
|
"touch_event",
|
|
21
|
+
"accel_event",
|
|
22
|
+
"CompassHeadingEvent",
|
|
23
|
+
"CompassCalibrationEvent",
|
|
21
24
|
"head_up",
|
|
22
25
|
"voice_activity_detection_status",
|
|
23
26
|
"speaking_status",
|
|
@@ -29,6 +32,8 @@ public class BluetoothSdkModule: Module, MentraBluetoothSDKDelegate {
|
|
|
29
32
|
"photo_response",
|
|
30
33
|
"photo_status",
|
|
31
34
|
"video_recording_status",
|
|
35
|
+
"media_success",
|
|
36
|
+
"media_error",
|
|
32
37
|
"gallery_status",
|
|
33
38
|
"compatible_glasses_search_stop",
|
|
34
39
|
"heartbeat_sent",
|
|
@@ -453,9 +458,9 @@ public class BluetoothSdkModule: Module, MentraBluetoothSDKDelegate {
|
|
|
453
458
|
|
|
454
459
|
AsyncFunction("startVideoRecording") {
|
|
455
460
|
(requestId: String, save: Bool, sound: Bool, settings: [String: Any]?) in
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
461
|
+
/// Optional per-recording {width,height,fps}. Absent fields stay 0, which
|
|
462
|
+
/// the glasses treat as "use the saved button-video default". JS numbers
|
|
463
|
+
/// arrive as Double across the bridge, so coerce to Int.
|
|
459
464
|
func dim(_ key: String) -> Int {
|
|
460
465
|
(settings?[key] as? NSNumber)?.intValue ?? 0
|
|
461
466
|
}
|
|
@@ -463,14 +468,20 @@ public class BluetoothSdkModule: Module, MentraBluetoothSDKDelegate {
|
|
|
463
468
|
return try await sdk.startVideoRecording(
|
|
464
469
|
VideoRecordingRequest(
|
|
465
470
|
requestId: requestId, save: save, sound: sound,
|
|
466
|
-
width: dim("width"), height: dim("height"), fps: dim("fps")
|
|
471
|
+
width: dim("width"), height: dim("height"), fps: dim("fps"),
|
|
472
|
+
maxRecordingTimeMinutes: dim("maxRecordingTimeMinutes")
|
|
467
473
|
)
|
|
468
474
|
).values
|
|
469
475
|
}
|
|
470
476
|
|
|
471
|
-
|
|
477
|
+
// webhookUrl/authToken are supplied at stop (not start) so the token is
|
|
478
|
+
// fresh when the upload runs. Empty/nil webhook = keep on device.
|
|
479
|
+
AsyncFunction("stopVideoRecording") {
|
|
480
|
+
(requestId: String, webhookUrl: String?, authToken: String?) in
|
|
472
481
|
let sdk = await MainActor.run { self.bluetoothSdk() }
|
|
473
|
-
return try await sdk.stopVideoRecording(
|
|
482
|
+
return try await sdk.stopVideoRecording(
|
|
483
|
+
requestId: requestId, webhookUrl: webhookUrl, authToken: authToken
|
|
484
|
+
).values
|
|
474
485
|
}
|
|
475
486
|
|
|
476
487
|
// MARK: - Stream Commands
|
|
@@ -621,7 +632,11 @@ public class BluetoothSdkModule: Module, MentraBluetoothSDKDelegate {
|
|
|
621
632
|
return sdk
|
|
622
633
|
}
|
|
623
634
|
|
|
624
|
-
let sdk = MentraBluetoothSDK(
|
|
635
|
+
let sdk = MentraBluetoothSDK(
|
|
636
|
+
configuration: MentraBluetoothSDKConfiguration(
|
|
637
|
+
analytics: BluetoothSdkAnalyticsConfiguration().withSurface("react_native")
|
|
638
|
+
)
|
|
639
|
+
)
|
|
625
640
|
sdk.delegate = self
|
|
626
641
|
self.sdk = sdk
|
|
627
642
|
return sdk
|
|
@@ -700,6 +715,8 @@ public class BluetoothSdkModule: Module, MentraBluetoothSDKDelegate {
|
|
|
700
715
|
sendEvent("photo_status", status.values)
|
|
701
716
|
case let .videoRecordingStatus(status):
|
|
702
717
|
sendEvent("video_recording_status", status.values)
|
|
718
|
+
case let .mediaUpload(event):
|
|
719
|
+
sendEvent(event.type, event.values)
|
|
703
720
|
case let .rgbLedControlResponse(response):
|
|
704
721
|
sendEvent("rgb_led_control_response", response.values)
|
|
705
722
|
case let .streamStatus(status):
|
|
@@ -2,5 +2,32 @@ import Foundation
|
|
|
2
2
|
|
|
3
3
|
/// Defaults for the public Bluetooth SDK surface.
|
|
4
4
|
enum BluetoothSdkDefaults {
|
|
5
|
+
static var sdkVersion: String? {
|
|
6
|
+
packageVersion(from: sdkBundle)
|
|
7
|
+
}
|
|
8
|
+
|
|
5
9
|
static let voiceActivityDetectionEnabled = false
|
|
10
|
+
|
|
11
|
+
private static var sdkBundle: Bundle {
|
|
12
|
+
#if SWIFT_PACKAGE
|
|
13
|
+
Bundle.module
|
|
14
|
+
#else
|
|
15
|
+
Bundle(for: BluetoothSdkBundleToken.self)
|
|
16
|
+
#endif
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
private static func packageVersion(from bundle: Bundle) -> String? {
|
|
20
|
+
let version = bundle.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String
|
|
21
|
+
let build = bundle.object(forInfoDictionaryKey: "CFBundleVersion") as? String
|
|
22
|
+
|
|
23
|
+
for candidate in [version, build] {
|
|
24
|
+
if let candidate, !candidate.isEmpty, candidate != "1.0" {
|
|
25
|
+
return candidate
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return nil
|
|
30
|
+
}
|
|
6
31
|
}
|
|
32
|
+
|
|
33
|
+
private final class BluetoothSdkBundleToken {}
|
package/ios/Source/Bridge.swift
CHANGED
|
@@ -251,6 +251,16 @@ class Bridge {
|
|
|
251
251
|
Bridge.sendTypedMessage("touch_event", body: body)
|
|
252
252
|
}
|
|
253
253
|
|
|
254
|
+
static func sendAccelEvent(x: Float, y: Float, z: Float, timestamp: Int64) {
|
|
255
|
+
let body: [String: Any] = [
|
|
256
|
+
"x": x,
|
|
257
|
+
"y": y,
|
|
258
|
+
"z": z,
|
|
259
|
+
"timestamp": timestamp,
|
|
260
|
+
]
|
|
261
|
+
Bridge.sendTypedMessage("accel_event", body: body)
|
|
262
|
+
}
|
|
263
|
+
|
|
254
264
|
static func sendSwipeVolumeStatus(enabled: Bool, timestamp: Int64) {
|
|
255
265
|
let body: [String: Any] = [
|
|
256
266
|
"enabled": enabled,
|
|
@@ -293,6 +303,12 @@ class Bridge {
|
|
|
293
303
|
Bridge.sendTypedMessage("video_recording_status", body: body)
|
|
294
304
|
}
|
|
295
305
|
|
|
306
|
+
static func sendMediaUploadEvent(type: String, values: [String: Any]) {
|
|
307
|
+
var body = values
|
|
308
|
+
body["type"] = type
|
|
309
|
+
Bridge.sendTypedMessage(type, body: body)
|
|
310
|
+
}
|
|
311
|
+
|
|
296
312
|
static func sendVersionInfo(_ values: [String: Any]) {
|
|
297
313
|
var body: [String: Any] = [
|
|
298
314
|
"type": "version_info",
|
|
@@ -241,6 +241,7 @@ struct ViewState {
|
|
|
241
241
|
get { DeviceStore.shared.get("bluetooth", "shouldSendBootingMessage") as? Bool ?? true }
|
|
242
242
|
set { DeviceStore.shared.apply("bluetooth", "shouldSendBootingMessage", newValue) }
|
|
243
243
|
}
|
|
244
|
+
|
|
244
245
|
private var lastSystemTimeSyncConnectionKey = ""
|
|
245
246
|
|
|
246
247
|
private var systemMicUnavailable: Bool {
|
|
@@ -564,7 +565,7 @@ struct ViewState {
|
|
|
564
565
|
// Check if we've completed all cycles
|
|
565
566
|
if cycles >= totalCycles {
|
|
566
567
|
// End animation with final message
|
|
567
|
-
sgc?.sendTextWall(" /// MentraOS Connected \\\\\\")
|
|
568
|
+
Task { await sgc?.sendTextWall(" /// MentraOS Connected \\\\\\") }
|
|
568
569
|
animationQueue.asyncAfter(deadline: .now() + 1.0) {
|
|
569
570
|
self.sgc?.clearDisplay()
|
|
570
571
|
}
|
|
@@ -574,7 +575,7 @@ struct ViewState {
|
|
|
574
575
|
// Display current animation frame
|
|
575
576
|
let frameText =
|
|
576
577
|
" \(arrowFrames[frameIndex]) MentraOS Booting \(arrowFrames[frameIndex])"
|
|
577
|
-
sgc?.sendTextWall(frameText)
|
|
578
|
+
Task { await sgc?.sendTextWall(frameText) }
|
|
578
579
|
|
|
579
580
|
// Move to next frame
|
|
580
581
|
frameIndex = (frameIndex + 1) % arrowFrames.count
|
|
@@ -692,20 +693,20 @@ struct ViewState {
|
|
|
692
693
|
switch layoutType {
|
|
693
694
|
case "text_wall":
|
|
694
695
|
let text = currentViewState.text
|
|
695
|
-
sgc?.sendTextWall(text)
|
|
696
|
+
await sgc?.sendTextWall(text)
|
|
696
697
|
case "double_text_wall":
|
|
697
698
|
let topText = currentViewState.topText
|
|
698
699
|
let bottomText = currentViewState.bottomText
|
|
699
|
-
sgc?.sendDoubleTextWall(topText, bottomText)
|
|
700
|
+
await sgc?.sendDoubleTextWall(topText, bottomText)
|
|
700
701
|
case "reference_card":
|
|
701
|
-
sgc?.sendTextWall(currentViewState.title + "\n\n" + currentViewState.text)
|
|
702
|
+
await sgc?.sendTextWall(currentViewState.title + "\n\n" + currentViewState.text)
|
|
702
703
|
case "bitmap_view":
|
|
703
|
-
Bridge.log("MAN: Processing bitmap_view layout")
|
|
704
|
+
// Bridge.log("MAN: Processing bitmap_view layout")
|
|
704
705
|
guard let data = currentViewState.data else {
|
|
705
706
|
Bridge.log("MAN: ERROR: bitmap_view missing data field")
|
|
706
707
|
return
|
|
707
708
|
}
|
|
708
|
-
Bridge.log("MAN: Processing bitmap_view with base64 data, length: \(data.count)")
|
|
709
|
+
// Bridge.log("MAN: Processing bitmap_view with base64 data, length: \(data.count)")
|
|
709
710
|
await sgc?.displayBitmap(
|
|
710
711
|
base64ImageData: data,
|
|
711
712
|
x: currentViewState.bmpX,
|
|
@@ -890,11 +891,24 @@ struct ViewState {
|
|
|
890
891
|
|
|
891
892
|
let connectionKey = "\(sgc.type):\(deviceName)"
|
|
892
893
|
syncSystemTimeOnceForConnection(sgc, connectionKey: connectionKey)
|
|
894
|
+
|
|
895
|
+
// re-apply display height/depth after reconnection
|
|
896
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
|
|
897
|
+
// Re-read the current sgc rather than capturing the connect-time instance: the user may
|
|
898
|
+
// have disconnected or switched glasses during the 2s window, and we must not push to a
|
|
899
|
+
// stale/torn-down connection.
|
|
900
|
+
guard let sgc = self?.sgc else { return }
|
|
901
|
+
let h = DeviceStore.shared.get("bluetooth", "dashboard_height") as? Int ?? 4
|
|
902
|
+
// Fall back to the canonical default (2), matching DeviceStore — not 1.
|
|
903
|
+
let rawDepth = DeviceStore.shared.get("bluetooth", "dashboard_depth") as? Int ?? 2
|
|
904
|
+
let d = min(max(rawDepth, 1), 4)
|
|
905
|
+
sgc.setDashboardPosition(h, d)
|
|
906
|
+
}
|
|
893
907
|
|
|
894
908
|
// Show welcome message on first connect for all display glasses
|
|
895
909
|
if shouldSendBootingMessage {
|
|
896
910
|
Task {
|
|
897
|
-
sgc.sendTextWall("// MentraOS Connected")
|
|
911
|
+
await sgc.sendTextWall("// MentraOS Connected")
|
|
898
912
|
try? await Task.sleep(nanoseconds: 3_000_000_000) // 1 second
|
|
899
913
|
sgc.clearDisplay()
|
|
900
914
|
}
|
|
@@ -920,17 +934,6 @@ struct ViewState {
|
|
|
920
934
|
Bridge.saveSetting("device_name", deviceName)
|
|
921
935
|
Bridge.saveSetting("device_address", deviceAddress)
|
|
922
936
|
|
|
923
|
-
// Re-apply display height after reconnection
|
|
924
|
-
let h = DeviceStore.shared.get("bluetooth", "dashboard_height") as? Int ?? 4
|
|
925
|
-
#if !SWIFT_PACKAGE || MENTRA_FEATURE_NEX
|
|
926
|
-
let d = NexDashboardDisplayWire.clampDepthFromStore(
|
|
927
|
-
DeviceStore.shared.get("bluetooth", "dashboard_depth")
|
|
928
|
-
)
|
|
929
|
-
#else
|
|
930
|
-
let rawDepth = DeviceStore.shared.get("bluetooth", "dashboard_depth") as? Int ?? 1
|
|
931
|
-
let d = min(max(rawDepth, 1), 4)
|
|
932
|
-
#endif
|
|
933
|
-
sgc.setDashboardPosition(h, d)
|
|
934
937
|
}
|
|
935
938
|
|
|
936
939
|
private func syncSystemTimeOnceForConnection(_ sgc: SGCManager, connectionKey: String) {
|
|
@@ -1006,7 +1009,7 @@ struct ViewState {
|
|
|
1006
1009
|
}
|
|
1007
1010
|
|
|
1008
1011
|
Bridge.log("MAN: Displaying text: \(text)")
|
|
1009
|
-
sgc?.sendTextWall(text)
|
|
1012
|
+
Task { await sgc?.sendTextWall(text) }
|
|
1010
1013
|
}
|
|
1011
1014
|
|
|
1012
1015
|
func displayEvent(_ event: [String: Any]) {
|
|
@@ -1096,7 +1099,7 @@ struct ViewState {
|
|
|
1096
1099
|
}
|
|
1097
1100
|
|
|
1098
1101
|
func showNotificationsPanel() {
|
|
1099
|
-
sgc?.showNotificationsPanel()
|
|
1102
|
+
Task { await sgc?.showNotificationsPanel() }
|
|
1100
1103
|
}
|
|
1101
1104
|
|
|
1102
1105
|
func ping() {
|
|
@@ -1235,20 +1238,22 @@ struct ViewState {
|
|
|
1235
1238
|
|
|
1236
1239
|
func startVideoRecording(
|
|
1237
1240
|
_ requestId: String, _ save: Bool, _ sound: Bool, _ width: Int = 0, _ height: Int = 0,
|
|
1238
|
-
_ fps: Int = 0
|
|
1241
|
+
_ fps: Int = 0, _ maxRecordingTimeMinutes: Int = 0
|
|
1239
1242
|
) {
|
|
1240
1243
|
Bridge.log(
|
|
1241
|
-
"MAN: onStartVideoRecording: requestId=\(requestId), save=\(save), flash=true, sound=\(sound), resolution=\(width)x\(height)@\(fps)fps"
|
|
1244
|
+
"MAN: onStartVideoRecording: requestId=\(requestId), save=\(save), flash=true, sound=\(sound), resolution=\(width)x\(height)@\(fps)fps, maxRecordingTimeMinutes=\(maxRecordingTimeMinutes)"
|
|
1242
1245
|
)
|
|
1243
1246
|
sgc?.startVideoRecording(
|
|
1244
1247
|
requestId: requestId, save: save, flash: true, sound: sound, width: width, height: height,
|
|
1245
|
-
fps: fps
|
|
1248
|
+
fps: fps, maxRecordingTimeMinutes: maxRecordingTimeMinutes
|
|
1246
1249
|
)
|
|
1247
1250
|
}
|
|
1248
1251
|
|
|
1249
|
-
func stopVideoRecording(_ requestId: String) {
|
|
1250
|
-
Bridge.log(
|
|
1251
|
-
|
|
1252
|
+
func stopVideoRecording(_ requestId: String, _ webhookUrl: String?, _ authToken: String?) {
|
|
1253
|
+
Bridge.log(
|
|
1254
|
+
"MAN: onStopVideoRecording: requestId=\(requestId), webhook=\((webhookUrl?.isEmpty ?? true) ? "none" : "set")"
|
|
1255
|
+
)
|
|
1256
|
+
sgc?.stopVideoRecording(requestId: requestId, webhookUrl: webhookUrl, authToken: authToken)
|
|
1252
1257
|
}
|
|
1253
1258
|
|
|
1254
1259
|
func setMicState() {
|
|
@@ -1447,6 +1452,7 @@ struct ViewState {
|
|
|
1447
1452
|
DeviceStore.shared.apply("glasses", "deviceModel", "")
|
|
1448
1453
|
DeviceStore.shared.apply("glasses", "fullyBooted", false)
|
|
1449
1454
|
DeviceStore.shared.apply("glasses", "connected", false)
|
|
1455
|
+
DeviceStore.shared.apply("glasses", "connectionState", ConnTypes.DISCONNECTED)
|
|
1450
1456
|
DeviceStore.shared.apply("glasses", "voiceActivityDetectionEnabled", BluetoothSdkDefaults.voiceActivityDetectionEnabled)
|
|
1451
1457
|
// disconnect the controller as well:
|
|
1452
1458
|
searchingController = false
|
|
@@ -84,6 +84,7 @@ class DeviceStore {
|
|
|
84
84
|
store.set("bluetooth", "dashboard_height", 4)
|
|
85
85
|
store.set("bluetooth", "dashboard_depth", 2)
|
|
86
86
|
store.set("bluetooth", "head_up_angle", 30)
|
|
87
|
+
store.set("bluetooth", "imu_enabled", false)
|
|
87
88
|
store.set("bluetooth", "contextual_dashboard", true)
|
|
88
89
|
store.set("bluetooth", "gallery_mode", true)
|
|
89
90
|
store.set("bluetooth", "voice_activity_detection_enabled", BluetoothSdkDefaults.voiceActivityDetectionEnabled)
|
|
@@ -186,7 +187,7 @@ class DeviceStore {
|
|
|
186
187
|
let auto = store.get("bluetooth", "auto_brightness") as? Bool ?? true
|
|
187
188
|
Task {
|
|
188
189
|
DeviceManager.shared.sgc?.setBrightness(b, autoMode: auto)
|
|
189
|
-
DeviceManager.shared.sgc?.sendTextWall("Set brightness to \(b)%")
|
|
190
|
+
await DeviceManager.shared.sgc?.sendTextWall("Set brightness to \(b)%")
|
|
190
191
|
try? await Task.sleep(nanoseconds: 800_000_000) // 0.8 seconds
|
|
191
192
|
DeviceManager.shared.sgc?.clearDisplay()
|
|
192
193
|
}
|
|
@@ -198,7 +199,7 @@ class DeviceStore {
|
|
|
198
199
|
Task {
|
|
199
200
|
DeviceManager.shared.sgc?.setBrightness(b, autoMode: auto)
|
|
200
201
|
if autoBrightnessChanged {
|
|
201
|
-
DeviceManager.shared.sgc?.sendTextWall(
|
|
202
|
+
await DeviceManager.shared.sgc?.sendTextWall(
|
|
202
203
|
auto ? "Enabled auto brightness" : "Disabled auto brightness"
|
|
203
204
|
)
|
|
204
205
|
try? await Task.sleep(nanoseconds: 800_000_000) // 0.8 seconds
|
|
@@ -217,6 +218,11 @@ class DeviceStore {
|
|
|
217
218
|
DeviceManager.shared.sgc?.setHeadUpAngle(angle)
|
|
218
219
|
}
|
|
219
220
|
|
|
221
|
+
case ("bluetooth", "imu_enabled"):
|
|
222
|
+
if let enabled = value as? Bool {
|
|
223
|
+
Task { await DeviceManager.shared.sgc?.setImuEnabled(enabled) }
|
|
224
|
+
}
|
|
225
|
+
|
|
220
226
|
case ("bluetooth", "menu_apps"):
|
|
221
227
|
if let items = value as? [[String: Any]] {
|
|
222
228
|
DeviceManager.shared.sgc?.setDashboardMenu(items)
|
|
@@ -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)
|