@mentra/bluetooth-sdk 0.1.18 → 0.1.20

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 (77) hide show
  1. package/README.md +1 -1
  2. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +135 -51
  3. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +120 -17
  4. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +5 -6
  5. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +34 -21
  6. package/android/src/main/java/com/mentra/bluetoothsdk/OtaManifest.kt +1 -1
  7. package/android/src/main/java/com/mentra/bluetoothsdk/camera/CameraModels.kt +13 -0
  8. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.kt +1 -1
  9. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1TextSanitizer.kt +38 -0
  10. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +488 -15
  11. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +551 -159
  12. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLiveL2capChannel.kt +274 -0
  13. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +361 -67
  14. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraosBle.java +7501 -3704
  15. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +134 -0
  16. package/android/src/main/java/com/mentra/bluetoothsdk/status/DeviceStatus.kt +0 -6
  17. package/android/src/main/java/com/mentra/bluetoothsdk/types/DeviceModels.kt +2 -3
  18. package/android/src/main/java/com/mentra/bluetoothsdk/utils/BleJsonCompact.java +493 -0
  19. package/android/src/main/java/com/mentra/bluetoothsdk/utils/BlePhotoUploadService.java +65 -5
  20. package/android/src/main/java/com/mentra/bluetoothsdk/utils/BleWireProtocol.java +108 -0
  21. package/android/src/main/java/com/mentra/bluetoothsdk/utils/IncidentLogBleUploadService.java +4 -3
  22. package/android/src/main/java/com/mentra/bluetoothsdk/utils/K900LengthCodec.java +115 -0
  23. package/android/src/main/java/com/mentra/bluetoothsdk/utils/K900ProtocolUtils.java +109 -73
  24. package/android/src/main/java/com/mentra/bluetoothsdk/utils/MessageChunkReassembler.java +106 -0
  25. package/android/src/main/java/com/mentra/bluetoothsdk/utils/MessageChunker.java +93 -0
  26. package/android/src/main/java/com/mentra/bluetoothsdk/utils/NexSGCUtils.kt +111 -11
  27. package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkExceptionTest.kt +18 -0
  28. package/android/src/test/java/com/mentra/bluetoothsdk/camera/PhotoRequestTest.kt +23 -9
  29. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G1TextSanitizerTest.kt +31 -0
  30. package/android/src/test/java/com/mentra/bluetoothsdk/utils/AvifExifStripperTest.java +17 -1
  31. package/android/src/test/java/com/mentra/bluetoothsdk/utils/BinaryMessageChunkerTest.java +84 -0
  32. package/android/src/test/java/com/mentra/bluetoothsdk/utils/BleJsonCompactTest.java +158 -0
  33. package/android/src/test/java/com/mentra/bluetoothsdk/utils/BlePhotoUploadServiceTest.java +9 -0
  34. package/android/src/test/java/com/mentra/bluetoothsdk/utils/K900ProtocolUtilsEndiannessTest.java +172 -0
  35. package/android/src/test/java/com/mentra/bluetoothsdk/utils/K900ProtocolUtilsTest.java +24 -0
  36. package/build/BluetoothSdk.types.d.ts +37 -1
  37. package/build/BluetoothSdk.types.d.ts.map +1 -1
  38. package/build/BluetoothSdk.types.js.map +1 -1
  39. package/build/_private/BluetoothSdkModule.d.ts +4 -0
  40. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  41. package/build/_private/BluetoothSdkModule.js.map +1 -1
  42. package/build/_private/photoRequestPayload.d.ts.map +1 -1
  43. package/build/_private/photoRequestPayload.js +1 -0
  44. package/build/_private/photoRequestPayload.js.map +1 -1
  45. package/build/index.d.ts +1 -1
  46. package/build/index.d.ts.map +1 -1
  47. package/build/index.js +5 -0
  48. package/build/index.js.map +1 -1
  49. package/build/types/index.d.ts +3 -0
  50. package/build/types/index.d.ts.map +1 -0
  51. package/build/types/index.js +2 -0
  52. package/build/types/index.js.map +1 -0
  53. package/ios/Source/BluetoothSdkDefaults.swift +1 -1
  54. package/ios/Source/DeviceManager.swift +136 -30
  55. package/ios/Source/DeviceStore.swift +5 -5
  56. package/ios/Source/MentraBluetoothSDK.swift +40 -24
  57. package/ios/Source/OtaManifest.swift +1 -1
  58. package/ios/Source/camera/CameraModels.swift +17 -3
  59. package/ios/Source/sgcs/G1.swift +1 -1
  60. package/ios/Source/sgcs/G2.swift +518 -11
  61. package/ios/Source/sgcs/MentraLive.swift +443 -42
  62. package/ios/Source/sgcs/MentraLiveL2capChannel.swift +183 -0
  63. package/ios/Source/sgcs/MentraNex.swift +416 -32
  64. package/ios/Source/sgcs/SGCManager.swift +155 -0
  65. package/ios/Source/sgcs/mentraos_ble.pb.swift +726 -122
  66. package/ios/Source/status/DeviceStatus.swift +0 -8
  67. package/ios/Source/utils/BleJsonCompact.swift +395 -0
  68. package/ios/Source/utils/BleWireProtocol.swift +92 -0
  69. package/ios/Source/utils/G1Text.swift +30 -1
  70. package/ios/Source/utils/MessageChunkReassembler.swift +82 -0
  71. package/ios/Source/utils/MessageChunker.swift +73 -0
  72. package/package.json +14 -7
  73. package/src/BluetoothSdk.types.ts +39 -1
  74. package/src/_private/BluetoothSdkModule.ts +4 -0
  75. package/src/_private/photoRequestPayload.ts +1 -0
  76. package/src/index.ts +6 -0
  77. package/src/types/index.ts +7 -0
@@ -7,7 +7,7 @@ enum BluetoothSdkDefaults {
7
7
  }
8
8
 
9
9
  static let voiceActivityDetectionEnabled = false
10
- private static let swiftPackageSdkVersion = "0.1.18"
10
+ private static let swiftPackageSdkVersion = "0.1.20"
11
11
  private static let swiftPackageSdkVersionPlaceholder = "__MENTRA" + "_BLUETOOTH_SDK_VERSION__"
12
12
 
13
13
  private static func normalizedSdkVersion(_ value: String?) -> String? {
@@ -186,11 +186,6 @@ struct ViewState {
186
186
  set { DeviceStore.shared.apply("bluetooth", "bypass_vad", newValue) }
187
187
  }
188
188
 
189
- private var offlineCaptionsRunning: Bool {
190
- get { DeviceStore.shared.get("bluetooth", "offline_captions_running") as? Bool ?? false }
191
- set { DeviceStore.shared.apply("bluetooth", "offline_captions_running", newValue) }
192
- }
193
-
194
189
  private var localSttFallbackActive: Bool {
195
190
  get { DeviceStore.shared.get("bluetooth", "local_stt_fallback_active") as? Bool ?? false }
196
191
  set { DeviceStore.shared.apply("bluetooth", "local_stt_fallback_active", newValue) }
@@ -325,6 +320,12 @@ struct ViewState {
325
320
  ),
326
321
  ]
327
322
 
323
+ // Scene slots — one whole SceneFrame per view (main/dashboard), parallel to
324
+ // viewStates. When a slot holds a scene, viewStates carries a "scene"
325
+ // sentinel so sendCurrentState routes here. Holding the WHOLE frame keeps
326
+ // native re-dispatch coherent (dashboard exit re-applies a complete scene).
327
+ var sceneStates: [SceneFrame?] = [nil, nil]
328
+
328
329
  override init() {
329
330
  Bridge.log("MAN: init()")
330
331
  super.init()
@@ -421,7 +422,7 @@ struct ViewState {
421
422
 
422
423
  // Send PCM to local transcriber.
423
424
  #if !SWIFT_PACKAGE || MENTRA_FEATURE_LOCAL_STT
424
- if shouldSendTranscript || offlineCaptionsRunning || localSttFallbackActive {
425
+ if shouldSendTranscript || localSttFallbackActive {
425
426
  transcriber?.acceptAudio(pcm16le: pcmData)
426
427
  }
427
428
  #endif
@@ -732,6 +733,11 @@ struct ViewState {
732
733
  borderWidth: currentViewState.borderWidth ?? 0,
733
734
  borderRadius: currentViewState.borderRadius ?? 0
734
735
  )
736
+ case "scene":
737
+ let sceneIndex = (headUp && self.contextualDashboard) ? 1 : 0
738
+ if let frame = self.sceneStates[sceneIndex] {
739
+ await sgc?.applySceneFrame(frame)
740
+ }
735
741
  case "clear_view":
736
742
  sgc?.clearDisplay()
737
743
  default:
@@ -1044,8 +1050,35 @@ struct ViewState {
1044
1050
  stateIndex = 0
1045
1051
  }
1046
1052
 
1047
- let layout = event["layout"] as! [String: Any]
1048
- let layoutType = layout["layoutType"] as! String
1053
+ // Scene frames (display.render() pipeline) take their own path: the
1054
+ // whole frame is the unit, not a layout, and the host's per-element
1055
+ // annotations make redundant frames self-deduping (all-"unchanged"
1056
+ // frames no-op in the SGC base handler).
1057
+ if let sceneMap = event["scene"] as? [String: Any] {
1058
+ handleSceneEvent(stateIndex, sceneMap)
1059
+ return
1060
+ }
1061
+
1062
+ guard let layout = event["layout"] as? [String: Any],
1063
+ let layoutType = layout["layoutType"] as? String
1064
+ else {
1065
+ Bridge.log("MAN: displayEvent missing layout")
1066
+ return
1067
+ }
1068
+
1069
+ // Scene→legacy handoff: a legacy layout is about to draw over a scene
1070
+ // (e.g. a cloud app taking the view from a miniapp). Sweep the scene's
1071
+ // elements first so they don't linger under the new content; clear_view
1072
+ // wipes everything anyway.
1073
+ if let prevFrame = sceneStates[stateIndex] {
1074
+ sceneStates[stateIndex] = nil
1075
+ if layoutType != "clear_view" {
1076
+ let ids = prevFrame.elements.map(\.id)
1077
+ Task { [weak self] in
1078
+ await self?.sgc?.clearSceneElements(ids)
1079
+ }
1080
+ }
1081
+ }
1049
1082
  var text = layout["text"] as? String ?? " "
1050
1083
  var topText = layout["topText"] as? String ?? " "
1051
1084
  var bottomText = layout["bottomText"] as? String ?? " "
@@ -1090,26 +1123,11 @@ struct ViewState {
1090
1123
  }
1091
1124
  }
1092
1125
 
1093
- // positioned_text is a sticky overlay container (e.g. the nav trip-stats
1094
- // label) that lives ALONGSIDE the main view. It must NOT flow through the
1095
- // single replaceable viewState[stateIndex] otherwise the constantly-
1096
- // refreshing main text_wall (maneuver text) and the minimap bitmap_view
1097
- // overwrite it every frame and it never renders. Route it straight to the
1098
- // SGC, which keeps its own persistent text container for that rect.
1099
- if layoutType == "positioned_text" {
1100
- Bridge.log(
1101
- "MAN: positioned_text (sticky) → text='\(text)' rect=\(bmpX ?? 0),\(bmpY ?? 0) \(bmpWidth ?? 576)x\(bmpHeight ?? 288)"
1102
- )
1103
- Task { [weak self] in
1104
- await self?.sgc?.sendPositionedText(
1105
- text,
1106
- x: bmpX ?? 0, y: bmpY ?? 0,
1107
- width: bmpWidth ?? 576, height: bmpHeight ?? 288,
1108
- borderWidth: borderWidth ?? 0, borderRadius: borderRadius ?? 0
1109
- )
1110
- }
1111
- return
1112
- }
1126
+ // NOTE: positioned_text used to bypass the viewState slot here (the
1127
+ // "sticky overlay" hack for the old nav HUD). Scenes made that
1128
+ // obsolete: multi-element frames arrive as ONE scene event, so nothing
1129
+ // clobbers anything. Legacy positioned_text now flows through the slot
1130
+ // like every other layout (matching Android).
1113
1131
 
1114
1132
  let cS = viewStates[stateIndex]
1115
1133
  let nS = newViewState
@@ -1136,6 +1154,93 @@ struct ViewState {
1136
1154
  }
1137
1155
  }
1138
1156
 
1157
+ /// Parse + store a scene frame, then dispatch it if its view is visible.
1158
+ private func handleSceneEvent(_ stateIndex: Int, _ sceneMap: [String: Any]) {
1159
+ guard var frame = parseSceneFrame(sceneMap) else { return }
1160
+ let prevFrame = sceneStates[stateIndex]
1161
+
1162
+ if prevFrame == nil {
1163
+ // Legacy→scene handoff: stale legacy content (e.g. a cloud app's
1164
+ // text wall) must not linger under the scene's elements.
1165
+ // clearDisplay is the per-device "wipe what's there" (blank-in-place
1166
+ // on G2 — no page rebuild).
1167
+ let prevLegacyType = viewStates[stateIndex].layoutType
1168
+ if !prevLegacyType.isEmpty, prevLegacyType != "clear_view", prevLegacyType != "scene" {
1169
+ sgc?.clearDisplay()
1170
+ }
1171
+ } else if let prevFrame, prevFrame.appId != frame.appId {
1172
+ // Cross-app switch: the host's diff baseline is per-app, so the new
1173
+ // app's annotations don't know the old app's elements are on the
1174
+ // glasses. Sweep the old app's elements (SGC registries still map
1175
+ // them), then paint the new frame from scratch. The boot message
1176
+ // interposes between apps in practice, so this isn't visible.
1177
+ let ids = prevFrame.elements.map(\.id)
1178
+ Task { [weak self] in
1179
+ await self?.sgc?.clearSceneElements(ids)
1180
+ }
1181
+ frame = frame.asReplay()
1182
+ }
1183
+
1184
+ // Store the REDISPATCH form: any later sendCurrentState (dashboard
1185
+ // exit, head-up return) must repaint the whole frame — the original
1186
+ // annotations are only valid for the first dispatch right now.
1187
+ sceneStates[stateIndex] = frame.asReplay()
1188
+ viewStates[stateIndex] = ViewState(
1189
+ topText: " ", bottomText: " ", title: " ", layoutType: "scene",
1190
+ text: " ", data: nil, animationData: nil
1191
+ )
1192
+
1193
+ let hUp = headUp && contextualDashboard
1194
+ if (stateIndex == 0 && !hUp) || (stateIndex == 1 && hUp) {
1195
+ dispatchSceneFrame(frame)
1196
+ }
1197
+ }
1198
+
1199
+ /// Guarded scene dispatch — mirrors sendCurrentState's send conditions.
1200
+ private func dispatchSceneFrame(_ frame: SceneFrame) {
1201
+ if screenDisabled { return }
1202
+ if sgc?.type.contains(DeviceTypes.SIMULATED) ?? true { return }
1203
+ guard sgc?.fullyBooted == true else {
1204
+ Bridge.log("MAN: dispatchSceneFrame(): sgc not ready")
1205
+ return
1206
+ }
1207
+ Task { [weak self] in
1208
+ await self?.sgc?.applySceneFrame(frame)
1209
+ }
1210
+ }
1211
+
1212
+ private func parseSceneFrame(_ sceneMap: [String: Any]) -> SceneFrame? {
1213
+ guard let elementsRaw = sceneMap["elements"] as? [[String: Any]] else { return nil }
1214
+ let elements: [SceneElement] = elementsRaw.compactMap { el in
1215
+ guard let id = el["id"] as? String,
1216
+ let type = el["type"] as? String,
1217
+ let box = el["box"] as? [String: Any]
1218
+ else { return nil }
1219
+ let style = el["style"] as? [String: Any]
1220
+ return SceneElement(
1221
+ id: id,
1222
+ type: type,
1223
+ x: (box["x"] as? NSNumber)?.int32Value ?? 0,
1224
+ y: (box["y"] as? NSNumber)?.int32Value ?? 0,
1225
+ w: (box["w"] as? NSNumber)?.int32Value ?? 0,
1226
+ h: (box["h"] as? NSNumber)?.int32Value ?? 0,
1227
+ text: (el["text"] as? String).map { parsePlaceholders($0) },
1228
+ data: el["data"] as? String,
1229
+ border: ((style?["border"]) as? NSNumber)?.int32Value ?? 0,
1230
+ radius: ((style?["radius"]) as? NSNumber)?.int32Value ?? 0,
1231
+ change: el["change"] as? String ?? "created",
1232
+ contentHash: el["contentHash"] as? String ?? ""
1233
+ )
1234
+ }
1235
+ return SceneFrame(
1236
+ appId: sceneMap["appId"] as? String ?? "",
1237
+ epoch: (sceneMap["sceneEpoch"] as? NSNumber)?.intValue ?? 0,
1238
+ replay: sceneMap["replay"] as? Bool ?? false,
1239
+ elements: elements,
1240
+ removed: (sceneMap["removed"] as? [String]) ?? []
1241
+ )
1242
+ }
1243
+
1139
1244
  func showDashboard() {
1140
1245
  sgc?.showDashboard()
1141
1246
  }
@@ -1321,7 +1426,7 @@ struct ViewState {
1321
1426
 
1322
1427
  func setMicState() {
1323
1428
  let willSendPcm = shouldSendPcm || shouldSendLc3
1324
- let willSendTranscript = shouldSendTranscript || offlineCaptionsRunning || localSttFallbackActive
1429
+ let willSendTranscript = shouldSendTranscript || localSttFallbackActive
1325
1430
  micEnabled = willSendPcm || willSendTranscript
1326
1431
  updateMicState()
1327
1432
  }
@@ -1400,7 +1505,8 @@ struct ViewState {
1400
1505
  mfnr: request.mfnr,
1401
1506
  zsl: request.zsl,
1402
1507
  ispDigitalGain: request.ispDigitalGain,
1403
- ispAnalogGain: request.ispAnalogGain
1508
+ ispAnalogGain: request.ispAnalogGain,
1509
+ mode: request.mode
1404
1510
  )
1405
1511
  Bridge.log(
1406
1512
  "MAN: PHOTO PIPELINE [4/6] DeviceManager.requestPhoto requestId=\(routed.requestId) webhookUrl=\(routed.webhookUrl ?? "nil") size=\(routed.size.rawValue) compress=\(routed.compress?.rawValue ?? "none") save=\(routed.save) sound=\(routed.sound) exposureTimeNs=\(manualExposureNs.map { String($0) } ?? "nil") iso=\(manualIso.map { String($0) } ?? "auto") aeDivisor=\(routed.aeExposureDivisor.map { String($0) } ?? "nil") isoCap=\(routed.isoCap.map { String($0) } ?? "nil") sgc=\(sgc != nil ? String(describing: type(of: sgc!)) : "null")"
@@ -263,6 +263,11 @@ class DeviceStore {
263
263
  case ("bluetooth", "camera_fov"):
264
264
  DeviceManager.shared.sgc?.sendCameraFovSetting()
265
265
 
266
+ case ("bluetooth", "button_video_settings"):
267
+ DeviceManager.shared.sgc?.sendButtonVideoRecordingSettings()
268
+
269
+ // Legacy scalar keys remain supported for older hosts. New code should write the
270
+ // canonical button_video_settings object so width/height/fps update atomically.
266
271
  case ("bluetooth", "button_video_width"), ("bluetooth", "button_video_height"),
267
272
  ("bluetooth", "button_video_fps"):
268
273
  DeviceManager.shared.sgc?.sendButtonVideoRecordingSettings()
@@ -273,11 +278,6 @@ class DeviceStore {
273
278
  DeviceManager.shared.setMicState()
274
279
  }
275
280
 
276
- case ("bluetooth", "offline_captions_running"):
277
- if let running = value as? Bool {
278
- DeviceManager.shared.setMicState()
279
- }
280
-
281
281
  case ("bluetooth", "local_stt_fallback_active"):
282
282
  if let active = value as? Bool {
283
283
  DeviceManager.shared.setMicState()
@@ -148,6 +148,7 @@ private final class PendingResponse<T> {
148
148
 
149
149
  @MainActor
150
150
  public final class MentraBluetoothSDK {
151
+ private static let wifiScanTimeoutMs = 20_000
151
152
  private static let otaBesVersionWaitMs = 5_000
152
153
  private static let otaMtkVersionWaitMs = 2_000
153
154
  private static let otaVersionPollMs = 100
@@ -180,6 +181,7 @@ public final class MentraBluetoothSDK {
180
181
  private var pendingOtaQuery: PendingResponse<OtaQueryResult>?
181
182
  private var pendingOtaStart: PendingResponse<OtaStartAckEvent>?
182
183
  private var pendingWifiScan: PendingWifiScan?
184
+ private var wifiScanTask: Task<[WifiScanResult], Error>?
183
185
  private var pendingWifiStatus: PendingWifiStatusRequest?
184
186
  private var pendingHotspotStatus: PendingHotspotStatusRequest?
185
187
  private var pendingVersionInfo: PendingResponse<VersionInfoResult>?
@@ -485,6 +487,7 @@ public final class MentraBluetoothSDK {
485
487
  DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "voice_activity_detection_enabled", enabled)
486
488
  }
487
489
 
490
+ @available(*, deprecated, message: "Sticky action-button photo presets are deprecated. Prefer per-request requestPhoto(...) options (e.g. mode .text for AE ÷3, or explicit per-shot fields). This method still works but will be removed in a future release.")
488
491
  public func setPhotoCaptureDefaults(_ settings: PhotoCaptureDefaults) async throws -> SettingsAckEvent {
489
492
  try await performSettingsCommand(
490
493
  setting: "button_photo",
@@ -548,6 +551,12 @@ public final class MentraBluetoothSDK {
548
551
  try await performSettingsCommand(
549
552
  setting: "button_video_recording",
550
553
  updateStore: { _ in
554
+ DeviceStore.shared.set(
555
+ ObservableStore.bluetoothCategory,
556
+ "button_video_settings",
557
+ ["width": defaults.width, "height": defaults.height, "fps": defaults.fps]
558
+ )
559
+ // Keep legacy cache keys readable for older internal callers during migration.
551
560
  DeviceStore.shared.set(ObservableStore.bluetoothCategory, "button_video_width", defaults.width)
552
561
  DeviceStore.shared.set(ObservableStore.bluetoothCategory, "button_video_height", defaults.height)
553
562
  DeviceStore.shared.set(ObservableStore.bluetoothCategory, "button_video_fps", defaults.fps)
@@ -664,37 +673,38 @@ public final class MentraBluetoothSDK {
664
673
  }
665
674
 
666
675
  public func requestWifiScan() async throws -> [WifiScanResult] {
667
- guard pendingWifiScan == nil else {
668
- throw BluetoothSdkError(
669
- code: "request_in_flight",
670
- message: "A WiFi scan is already waiting for a glasses response."
671
- )
676
+ if let existing = wifiScanTask {
677
+ // Join the in-flight scan instead of failing with request_in_flight;
678
+ // the scan screen auto-starts a scan on mount and can be pushed twice.
679
+ return try await existing.value
672
680
  }
673
681
  let pending = PendingResponse<[WifiScanResult]>(operation: "WiFi scan request")
674
682
  let request = PendingWifiScan(pending: pending)
675
683
  pendingWifiScan = request
676
684
  DeviceManager.shared.requestWifiScan()
677
- do {
678
- let results = try await pending.wait()
679
- if pendingWifiScan?.pending === pending {
680
- pendingWifiScan = nil
681
- }
682
- return results
683
- } catch {
684
- let fallbackResults: [WifiScanResult]
685
- if (error as? BluetoothSdkError)?.code == "request_timeout" {
686
- fallbackResults = request.latestResults
687
- } else {
688
- fallbackResults = []
689
- }
690
- if pendingWifiScan?.pending === pending {
691
- pendingWifiScan = nil
685
+ let task = Task { @MainActor [weak self] () async throws -> [WifiScanResult] in
686
+ defer {
687
+ if let self {
688
+ if self.pendingWifiScan === request {
689
+ self.pendingWifiScan = nil
690
+ }
691
+ self.wifiScanTask = nil
692
+ }
692
693
  }
693
- if !fallbackResults.isEmpty {
694
- return fallbackResults
694
+ do {
695
+ // The glasses wait up to 15s for scan-results broadcasts before sending
696
+ // scan_complete, so give them longer than that before falling back.
697
+ return try await pending.wait(timeoutMs: MentraBluetoothSDK.wifiScanTimeoutMs)
698
+ } catch {
699
+ if (error as? BluetoothSdkError)?.code == "request_timeout",
700
+ !request.latestResults.isEmpty {
701
+ return request.latestResults
702
+ }
703
+ throw error
695
704
  }
696
- throw error
697
705
  }
706
+ wifiScanTask = task
707
+ return try await task.value
698
708
  }
699
709
 
700
710
  public func sendWifiCredentials(ssid: String, password: String) async throws -> WifiStatusEvent {
@@ -1406,9 +1416,15 @@ public final class MentraBluetoothSDK {
1406
1416
  case .streaming:
1407
1417
  pendingStreamStarts.removeValue(forKey: streamId)
1408
1418
  pending.resolve(event)
1409
- case .error, .reconnectFailed, .stopped:
1419
+ case .reconnectFailed, .stopped:
1410
1420
  pendingStreamStarts.removeValue(forKey: streamId)
1411
1421
  pending.reject(streamStatusError(event, code: "stream_start_failed"))
1422
+ case .error:
1423
+ // The glasses publisher automatically retries transient transport
1424
+ // errors. Keep the start pending so a subsequent `streaming`
1425
+ // status can resolve it; `reconnectFailed` or the request timeout
1426
+ // still terminates a publisher that cannot recover.
1427
+ break
1412
1428
  default:
1413
1429
  break
1414
1430
  }
@@ -78,7 +78,7 @@ enum OtaManifestChecker {
78
78
  guard (200 ... 299).contains(httpResponse.statusCode) else {
79
79
  throw BluetoothSdkError(
80
80
  code: "ota_manifest_request_failed",
81
- message: "OTA manifest request failed with HTTP \(httpResponse.statusCode)."
81
+ message: "OTA manifest request failed with HTTP \(httpResponse.statusCode) for \(otaVersionUrl)."
82
82
  )
83
83
  }
84
84
  return try JSONDecoder().decode(OtaManifest.self, from: data)
@@ -35,6 +35,15 @@ public enum PhotoSize: String {
35
35
  }
36
36
  }
37
37
 
38
+ public enum PhotoMode: String {
39
+ case photo
40
+ case text
41
+
42
+ public init(normalizedRawValue value: String?) {
43
+ self = PhotoMode(rawValue: value ?? "") ?? .photo
44
+ }
45
+ }
46
+
38
47
  public enum ButtonPhotoSize: String {
39
48
  case low
40
49
  case medium
@@ -234,6 +243,7 @@ public struct CameraFovResult: CustomStringConvertible {
234
243
  public struct PhotoRequest {
235
244
  public let requestId: String
236
245
  public let size: PhotoSize
246
+ public let mode: PhotoMode
237
247
  public let webhookUrl: String?
238
248
  public let authToken: String?
239
249
  public let compress: PhotoCompression?
@@ -269,7 +279,8 @@ public struct PhotoRequest {
269
279
  mfnr: Bool? = nil,
270
280
  zsl: Bool? = nil,
271
281
  ispDigitalGain: Int? = nil,
272
- ispAnalogGain: String? = nil
282
+ ispAnalogGain: String? = nil,
283
+ mode: PhotoMode = .photo
273
284
  ) {
274
285
  self.requestId = nonBlankRequestId(requestId) ?? generatedCameraRequestId("photo")
275
286
  self.size = size
@@ -288,6 +299,7 @@ public struct PhotoRequest {
288
299
  self.zsl = zsl
289
300
  self.ispDigitalGain = ispDigitalGain
290
301
  self.ispAnalogGain = ispAnalogGain
302
+ self.mode = mode
291
303
  }
292
304
 
293
305
  public static func from(params: [String: Any]) -> PhotoRequest {
@@ -354,7 +366,8 @@ public struct PhotoRequest {
354
366
  mfnr: optionalBool("mfnr"),
355
367
  zsl: optionalBool("zsl"),
356
368
  ispDigitalGain: optionalInt("ispDigitalGain"),
357
- ispAnalogGain: params["ispAnalogGain"] as? String
369
+ ispAnalogGain: params["ispAnalogGain"] as? String,
370
+ mode: PhotoMode(normalizedRawValue: params["mode"] as? String)
358
371
  )
359
372
  }
360
373
 
@@ -403,7 +416,8 @@ public struct PhotoRequest {
403
416
  mfnr: mfnr,
404
417
  zsl: zsl,
405
418
  ispDigitalGain: ispDigitalGain,
406
- ispAnalogGain: ispAnalogGain
419
+ ispAnalogGain: ispAnalogGain,
420
+ mode: mode
407
421
  )
408
422
  }
409
423
  }
@@ -739,7 +739,7 @@ class G1: NSObject, SGCManager {
739
739
 
740
740
  @objc func RN_sendText(_ text: String) {
741
741
  Task {
742
- let displayText = "\(text)"
742
+ let displayText = G1Text.sanitizeForDisplay(text)
743
743
  guard let textData = displayText.data(using: .utf8) else { return }
744
744
 
745
745
  var command: [UInt8] = [