@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.
Files changed (53) hide show
  1. package/README.md +52 -3
  2. package/android/build.gradle +1 -0
  3. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkAnalytics.kt +182 -0
  4. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +61 -19
  5. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +24 -0
  6. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +32 -8
  7. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +8 -0
  8. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +76 -5
  9. package/android/src/main/java/com/mentra/bluetoothsdk/camera/CameraModels.kt +17 -1
  10. package/android/src/main/java/com/mentra/bluetoothsdk/events/BluetoothEvents.kt +1 -0
  11. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +829 -643
  12. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.java +34 -5
  13. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +40 -64
  14. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +23 -1
  15. package/android/src/main/java/com/mentra/bluetoothsdk/types/DeviceModels.kt +2 -1
  16. package/build/BluetoothSdk.types.d.ts +45 -2
  17. package/build/BluetoothSdk.types.d.ts.map +1 -1
  18. package/build/BluetoothSdk.types.js.map +1 -1
  19. package/build/_private/BluetoothSdkModule.d.ts +2 -1
  20. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  21. package/build/_private/BluetoothSdkModule.js +14 -1
  22. package/build/_private/BluetoothSdkModule.js.map +1 -1
  23. package/build/index.d.ts +1 -1
  24. package/build/index.d.ts.map +1 -1
  25. package/build/index.js +4 -0
  26. package/build/index.js.map +1 -1
  27. package/ios/BluetoothSdkModule.swift +24 -7
  28. package/ios/Source/BluetoothSdkDefaults.swift +27 -0
  29. package/ios/Source/Bridge.swift +16 -0
  30. package/ios/Source/DeviceManager.swift +33 -27
  31. package/ios/Source/DeviceStore.swift +8 -2
  32. package/ios/Source/MentraBluetoothSDK.swift +160 -8
  33. package/ios/Source/camera/CameraModels.swift +50 -1
  34. package/ios/Source/events/BluetoothEvents.swift +3 -0
  35. package/ios/Source/internal/BluetoothAvailability.swift +20 -0
  36. package/ios/Source/internal/BluetoothSdkAnalytics.swift +160 -0
  37. package/ios/Source/sgcs/G1.swift +3 -3
  38. package/ios/Source/sgcs/G2.swift +1022 -640
  39. package/ios/Source/sgcs/Mach1.swift +2 -2
  40. package/ios/Source/sgcs/MentraLive.swift +31 -7
  41. package/ios/Source/sgcs/MentraNex.swift +215 -85
  42. package/ios/Source/sgcs/SGCManager.swift +26 -6
  43. package/ios/Source/sgcs/Simulated.swift +2 -2
  44. package/ios/Source/types/DeviceModels.swift +5 -1
  45. package/package.json +1 -1
  46. package/plugin/build/index.d.ts +4 -0
  47. package/plugin/build/withAndroid.d.ts +2 -3
  48. package/plugin/build/withAndroid.js +46 -0
  49. package/plugin/build/withIos.d.ts +2 -3
  50. package/plugin/build/withIos.js +32 -0
  51. package/src/BluetoothSdk.types.ts +53 -2
  52. package/src/_private/BluetoothSdkModule.ts +27 -4
  53. 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
- // Optional per-recording {width,height,fps}. Absent fields stay 0, which
457
- // the glasses treat as "use the saved button-video default". JS numbers
458
- // arrive as Double across the bridge, so coerce to Int.
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
- AsyncFunction("stopVideoRecording") { (requestId: String) in
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(requestId: requestId).values
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 {}
@@ -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("MAN: onStopVideoRecording: requestId=\(requestId)")
1251
- sgc?.stopVideoRecording(requestId: requestId)
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
- _ = BluetoothAvailability.shared
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(requestId: String) async throws -> VideoRecordingStatusEvent {
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 event = try await pending.wait()
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.pending.resolve(event)
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)