@mentra/bluetooth-sdk 0.1.17 → 0.1.19

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 (57) hide show
  1. package/README.md +1 -1
  2. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +52 -4
  3. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +119 -16
  4. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +0 -6
  5. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +22 -21
  6. package/android/src/main/java/com/mentra/bluetoothsdk/OtaManifest.kt +1 -1
  7. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +488 -15
  8. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +457 -31
  9. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLiveL2capChannel.kt +274 -0
  10. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +361 -67
  11. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraosBle.java +7501 -3704
  12. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +134 -0
  13. package/android/src/main/java/com/mentra/bluetoothsdk/status/DeviceStatus.kt +0 -6
  14. package/android/src/main/java/com/mentra/bluetoothsdk/utils/BleJsonCompact.java +493 -0
  15. package/android/src/main/java/com/mentra/bluetoothsdk/utils/BlePhotoUploadService.java +4 -1
  16. package/android/src/main/java/com/mentra/bluetoothsdk/utils/BleWireProtocol.java +108 -0
  17. package/android/src/main/java/com/mentra/bluetoothsdk/utils/IncidentLogBleUploadService.java +4 -3
  18. package/android/src/main/java/com/mentra/bluetoothsdk/utils/K900LengthCodec.java +115 -0
  19. package/android/src/main/java/com/mentra/bluetoothsdk/utils/K900ProtocolUtils.java +103 -68
  20. package/android/src/main/java/com/mentra/bluetoothsdk/utils/MessageChunkReassembler.java +106 -0
  21. package/android/src/main/java/com/mentra/bluetoothsdk/utils/MessageChunker.java +93 -0
  22. package/android/src/main/java/com/mentra/bluetoothsdk/utils/NexSGCUtils.kt +111 -11
  23. package/android/src/test/java/com/mentra/bluetoothsdk/camera/PhotoRequestTest.kt +7 -9
  24. package/android/src/test/java/com/mentra/bluetoothsdk/utils/AvifExifStripperTest.java +17 -1
  25. package/android/src/test/java/com/mentra/bluetoothsdk/utils/BinaryMessageChunkerTest.java +84 -0
  26. package/android/src/test/java/com/mentra/bluetoothsdk/utils/BleJsonCompactTest.java +158 -0
  27. package/android/src/test/java/com/mentra/bluetoothsdk/utils/K900ProtocolUtilsEndiannessTest.java +172 -0
  28. package/android/src/test/java/com/mentra/bluetoothsdk/utils/K900ProtocolUtilsTest.java +24 -0
  29. package/build/BluetoothSdk.types.d.ts +12 -1
  30. package/build/BluetoothSdk.types.d.ts.map +1 -1
  31. package/build/BluetoothSdk.types.js.map +1 -1
  32. package/build/types/index.d.ts +3 -0
  33. package/build/types/index.d.ts.map +1 -0
  34. package/build/types/index.js +2 -0
  35. package/build/types/index.js.map +1 -0
  36. package/ios/LocalPhotoUploadServer.swift +78 -0
  37. package/ios/MentraPhotoReceiverModule.swift +10 -0
  38. package/ios/Source/BluetoothSdkDefaults.swift +1 -1
  39. package/ios/Source/Bridge.swift +63 -0
  40. package/ios/Source/DeviceManager.swift +134 -29
  41. package/ios/Source/DeviceStore.swift +0 -5
  42. package/ios/Source/MentraBluetoothSDK.swift +26 -23
  43. package/ios/Source/OtaManifest.swift +1 -1
  44. package/ios/Source/internal/BleTraceLogger.swift +192 -0
  45. package/ios/Source/sgcs/G2.swift +518 -11
  46. package/ios/Source/sgcs/MentraLive.swift +607 -39
  47. package/ios/Source/sgcs/MentraNex.swift +416 -32
  48. package/ios/Source/sgcs/SGCManager.swift +155 -0
  49. package/ios/Source/sgcs/mentraos_ble.pb.swift +726 -122
  50. package/ios/Source/status/DeviceStatus.swift +0 -8
  51. package/ios/Source/utils/BleJsonCompact.swift +395 -0
  52. package/ios/Source/utils/BleWireProtocol.swift +92 -0
  53. package/ios/Source/utils/MessageChunkReassembler.swift +82 -0
  54. package/ios/Source/utils/MessageChunker.swift +73 -0
  55. package/package.json +14 -7
  56. package/src/BluetoothSdk.types.ts +13 -1
  57. package/src/types/index.ts +7 -0
@@ -104,6 +104,7 @@ final class LocalPhotoUploadServer {
104
104
  }
105
105
 
106
106
  private func handle(_ connection: NWConnection) {
107
+ traceWifiInput("photo_receiver_connection", connection: connection, state: nil)
107
108
  connection.start(queue: queue)
108
109
  receive(connection, state: RequestReadState())
109
110
  }
@@ -171,6 +172,14 @@ final class LocalPhotoUploadServer {
171
172
  let request = HttpRequest.parse(headerText)
172
173
  state.request = request
173
174
  onLog("\(request.method) \(request.path)")
175
+ var requestTraceValues: [String: Any] = [
176
+ "method": request.method,
177
+ "path": request.path,
178
+ ]
179
+ if let contentLength = request.headers["content-length"].flatMap(Int.init) {
180
+ requestTraceValues["contentLength"] = contentLength
181
+ }
182
+ traceWifiInput("photo_receiver_request", connection: connection, state: state, values: requestTraceValues)
174
183
 
175
184
  if request.method == "GET", request.path == "/" || request.path == "/health" {
176
185
  writeJson(connection, status: 200, body: #"{"ok":true,"service":"mentra-photo-upload-receiver"}"#)
@@ -263,6 +272,29 @@ final class LocalPhotoUploadServer {
263
272
  )
264
273
 
265
274
  onLog("upload fields=\(parsed.fields.keys.joined(separator: ",")) requestId=\(requestId ?? "") bytes=\(photoPart.byteCount) saved=\(photoFile.path)")
275
+ var uploadReceivedTraceValues: [String: Any] = [
276
+ "durationMs": Int(Date().timeIntervalSince1970 * 1000 - state.startMs),
277
+ "photoBytes": photoPart.byteCount,
278
+ "contentLength": state.contentLength,
279
+ "method": request.method,
280
+ "path": request.path,
281
+ ]
282
+ if let requestId {
283
+ uploadReceivedTraceValues["requestId"] = requestId
284
+ }
285
+ traceWifiInput("photo_receiver_upload_received", connection: connection, state: state, values: uploadReceivedTraceValues)
286
+ var uploadResponseTraceValues: [String: Any] = [
287
+ "statusCode": 200,
288
+ "success": true,
289
+ "durationMs": Int(Date().timeIntervalSince1970 * 1000 - state.startMs),
290
+ "contentLength": state.contentLength,
291
+ "method": request.method,
292
+ "path": request.path,
293
+ ]
294
+ if let requestId {
295
+ uploadResponseTraceValues["requestId"] = requestId
296
+ }
297
+ traceWifiOutput("photo_receiver_response", connection: connection, state: state, values: uploadResponseTraceValues)
266
298
  writeJson(
267
299
  connection,
268
300
  status: 200,
@@ -370,6 +402,51 @@ final class LocalPhotoUploadServer {
370
402
  }
371
403
  }
372
404
 
405
+ private func traceWifiInput(
406
+ _ type: String,
407
+ connection: NWConnection,
408
+ state: RequestReadState?,
409
+ values: [String: Any] = [:]
410
+ ) {
411
+ traceWifi(direction: "wifi_to_phone", layer: "wifi_http_input", type: type, connection: connection, state: state, values: values)
412
+ }
413
+
414
+ private func traceWifiOutput(
415
+ _ type: String,
416
+ connection: NWConnection,
417
+ state: RequestReadState?,
418
+ values: [String: Any] = [:]
419
+ ) {
420
+ traceWifi(direction: "phone_to_wifi", layer: "wifi_http_output", type: type, connection: connection, state: state, values: values)
421
+ }
422
+
423
+ private func traceWifi(
424
+ direction: String,
425
+ layer: String,
426
+ type: String,
427
+ connection: NWConnection,
428
+ state: RequestReadState?,
429
+ values: [String: Any]
430
+ ) {
431
+ var payload = endpointPayload(type: type, connection: connection)
432
+ if let state {
433
+ payload["startMs"] = state.startMs
434
+ }
435
+ for (key, value) in values {
436
+ payload[key] = value
437
+ }
438
+ BleTraceLogger.logMap(direction: direction, layer: layer, type: type, payload: payload)
439
+ }
440
+
441
+ private func endpointPayload(type: String, connection: NWConnection) -> [String: Any] {
442
+ var payload: [String: Any] = ["type": type]
443
+ if case let .hostPort(host, port) = connection.endpoint {
444
+ payload["remoteHost"] = String(describing: host)
445
+ payload["remotePort"] = Int(port.rawValue)
446
+ }
447
+ return payload
448
+ }
449
+
373
450
  private struct HttpRequest {
374
451
  let method: String
375
452
  let path: String
@@ -613,6 +690,7 @@ final class LocalPhotoUploadServer {
613
690
  }
614
691
 
615
692
  private final class RequestReadState {
693
+ let startMs = Date().timeIntervalSince1970 * 1000
616
694
  var buffer = Data()
617
695
  var request: HttpRequest?
618
696
  var contentLength = 0
@@ -79,6 +79,16 @@ public class MentraPhotoReceiverModule: Module {
79
79
  }
80
80
 
81
81
  private func handlePhotoUpload(_ upload: PhotoUpload) {
82
+ BleTraceLogger.logMap(
83
+ direction: "phone_to_app",
84
+ layer: "photo_receiver_event",
85
+ type: "photo_upload",
86
+ payload: [
87
+ "requestId": upload.requestId ?? "",
88
+ "fileName": upload.photoFile.lastPathComponent,
89
+ "byteCount": upload.byteCount,
90
+ ]
91
+ )
82
92
  sendEvent("photoUpload", [
83
93
  "requestId": upload.requestId as Any,
84
94
  "fileUri": upload.photoFile.absoluteString,
@@ -7,7 +7,7 @@ enum BluetoothSdkDefaults {
7
7
  }
8
8
 
9
9
  static let voiceActivityDetectionEnabled = false
10
- private static let swiftPackageSdkVersion = "0.1.17"
10
+ private static let swiftPackageSdkVersion = "0.1.19"
11
11
  private static let swiftPackageSdkVersionPlaceholder = "__MENTRA" + "_BLUETOOTH_SDK_VERSION__"
12
12
 
13
13
  private static func normalizedSdkVersion(_ value: String?) -> String? {
@@ -15,6 +15,17 @@ class Bridge {
15
15
  private static let micChannels = 1
16
16
  private static let lc3FrameDurationMs = 10
17
17
  private static let defaultLc3FrameSizeBytes = 60
18
+ private static let audioTraceMetadataKeys = [
19
+ "sampleRate",
20
+ "bitsPerSample",
21
+ "channels",
22
+ "encoding",
23
+ "frameDurationMs",
24
+ "frameSizeBytes",
25
+ "bitrate",
26
+ "packetizedFromGlasses",
27
+ "voiceActivityDetectionEnabled",
28
+ ]
18
29
  private static let eventSinkLock = NSLock()
19
30
  private static let defaultEventSinkId = "default"
20
31
  private static var eventSinks: [String: (String, [String: Any]) -> Void] = [:]
@@ -486,7 +497,59 @@ class Bridge {
486
497
  static func sendTypedMessage(_ type: String, body: [String: Any]) {
487
498
  var body = body
488
499
  body["type"] = type
500
+ if let tracePayload = tracePayloadForTypedMessage(type, body: body) {
501
+ BleTraceLogger.logMap(
502
+ direction: "phone_to_app",
503
+ layer: "sdk_event_dispatch",
504
+ type: type,
505
+ payload: tracePayload
506
+ )
507
+ }
489
508
  // Send directly using type as event name - no JSON serialization
490
509
  dispatchEvent(type, body)
491
510
  }
511
+
512
+ private static func tracePayloadForTypedMessage(_ type: String, body: [String: Any]) -> [String: Any]? {
513
+ if type == "log" {
514
+ return nil
515
+ }
516
+ if isAudioPayloadEvent(type) {
517
+ return audioTracePayload(type, body: body)
518
+ }
519
+ return body
520
+ }
521
+
522
+ private static func isAudioPayloadEvent(_ type: String) -> Bool {
523
+ type == "mic_pcm" || type == "mic_lc3"
524
+ }
525
+
526
+ private static func audioTracePayload(_ type: String, body: [String: Any]) -> [String: Any] {
527
+ var payload: [String: Any] = [
528
+ "type": type,
529
+ "timestamp": Int(Date().timeIntervalSince1970 * 1000),
530
+ "payloadOmitted": true,
531
+ "payloadOmittedReason": "audio",
532
+ ]
533
+
534
+ switch type {
535
+ case "mic_pcm":
536
+ if let data = body["pcm"] as? Data {
537
+ payload["audioBytes"] = data.count
538
+ }
539
+ case "mic_lc3":
540
+ if let data = body["lc3"] as? Data {
541
+ payload["audioBytes"] = data.count
542
+ }
543
+ default:
544
+ break
545
+ }
546
+
547
+ for key in audioTraceMetadataKeys {
548
+ if let value = body[key] {
549
+ payload[key] = value
550
+ }
551
+ }
552
+
553
+ return payload
554
+ }
492
555
  }
@@ -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
  }
@@ -273,11 +273,6 @@ class DeviceStore {
273
273
  DeviceManager.shared.setMicState()
274
274
  }
275
275
 
276
- case ("bluetooth", "offline_captions_running"):
277
- if let running = value as? Bool {
278
- DeviceManager.shared.setMicState()
279
- }
280
-
281
276
  case ("bluetooth", "local_stt_fallback_active"):
282
277
  if let active = value as? Bool {
283
278
  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>?
@@ -664,37 +666,38 @@ public final class MentraBluetoothSDK {
664
666
  }
665
667
 
666
668
  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
- )
669
+ if let existing = wifiScanTask {
670
+ // Join the in-flight scan instead of failing with request_in_flight;
671
+ // the scan screen auto-starts a scan on mount and can be pushed twice.
672
+ return try await existing.value
672
673
  }
673
674
  let pending = PendingResponse<[WifiScanResult]>(operation: "WiFi scan request")
674
675
  let request = PendingWifiScan(pending: pending)
675
676
  pendingWifiScan = request
676
677
  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
678
+ let task = Task { @MainActor [weak self] () async throws -> [WifiScanResult] in
679
+ defer {
680
+ if let self {
681
+ if self.pendingWifiScan === request {
682
+ self.pendingWifiScan = nil
683
+ }
684
+ self.wifiScanTask = nil
685
+ }
692
686
  }
693
- if !fallbackResults.isEmpty {
694
- return fallbackResults
687
+ do {
688
+ // The glasses wait up to 15s for scan-results broadcasts before sending
689
+ // scan_complete, so give them longer than that before falling back.
690
+ return try await pending.wait(timeoutMs: MentraBluetoothSDK.wifiScanTimeoutMs)
691
+ } catch {
692
+ if (error as? BluetoothSdkError)?.code == "request_timeout",
693
+ !request.latestResults.isEmpty {
694
+ return request.latestResults
695
+ }
696
+ throw error
695
697
  }
696
- throw error
697
698
  }
699
+ wifiScanTask = task
700
+ return try await task.value
698
701
  }
699
702
 
700
703
  public func sendWifiCredentials(ssid: String, password: String) async throws -> WifiStatusEvent {
@@ -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)