@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.
- package/README.md +1 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +52 -4
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +119 -16
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +0 -6
- package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +22 -21
- package/android/src/main/java/com/mentra/bluetoothsdk/OtaManifest.kt +1 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +488 -15
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +457 -31
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLiveL2capChannel.kt +274 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +361 -67
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraosBle.java +7501 -3704
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +134 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/status/DeviceStatus.kt +0 -6
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/BleJsonCompact.java +493 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/BlePhotoUploadService.java +4 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/BleWireProtocol.java +108 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/IncidentLogBleUploadService.java +4 -3
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/K900LengthCodec.java +115 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/K900ProtocolUtils.java +103 -68
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/MessageChunkReassembler.java +106 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/MessageChunker.java +93 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/NexSGCUtils.kt +111 -11
- package/android/src/test/java/com/mentra/bluetoothsdk/camera/PhotoRequestTest.kt +7 -9
- package/android/src/test/java/com/mentra/bluetoothsdk/utils/AvifExifStripperTest.java +17 -1
- package/android/src/test/java/com/mentra/bluetoothsdk/utils/BinaryMessageChunkerTest.java +84 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/utils/BleJsonCompactTest.java +158 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/utils/K900ProtocolUtilsEndiannessTest.java +172 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/utils/K900ProtocolUtilsTest.java +24 -0
- package/build/BluetoothSdk.types.d.ts +12 -1
- package/build/BluetoothSdk.types.d.ts.map +1 -1
- package/build/BluetoothSdk.types.js.map +1 -1
- package/build/types/index.d.ts +3 -0
- package/build/types/index.d.ts.map +1 -0
- package/build/types/index.js +2 -0
- package/build/types/index.js.map +1 -0
- package/ios/LocalPhotoUploadServer.swift +78 -0
- package/ios/MentraPhotoReceiverModule.swift +10 -0
- package/ios/Source/BluetoothSdkDefaults.swift +1 -1
- package/ios/Source/Bridge.swift +63 -0
- package/ios/Source/DeviceManager.swift +134 -29
- package/ios/Source/DeviceStore.swift +0 -5
- package/ios/Source/MentraBluetoothSDK.swift +26 -23
- package/ios/Source/OtaManifest.swift +1 -1
- package/ios/Source/internal/BleTraceLogger.swift +192 -0
- package/ios/Source/sgcs/G2.swift +518 -11
- package/ios/Source/sgcs/MentraLive.swift +607 -39
- package/ios/Source/sgcs/MentraNex.swift +416 -32
- package/ios/Source/sgcs/SGCManager.swift +155 -0
- package/ios/Source/sgcs/mentraos_ble.pb.swift +726 -122
- package/ios/Source/status/DeviceStatus.swift +0 -8
- package/ios/Source/utils/BleJsonCompact.swift +395 -0
- package/ios/Source/utils/BleWireProtocol.swift +92 -0
- package/ios/Source/utils/MessageChunkReassembler.swift +82 -0
- package/ios/Source/utils/MessageChunker.swift +73 -0
- package/package.json +14 -7
- package/src/BluetoothSdk.types.ts +13 -1
- 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.
|
|
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? {
|
package/ios/Source/Bridge.swift
CHANGED
|
@@ -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 ||
|
|
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
|
-
|
|
1048
|
-
|
|
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
|
|
1094
|
-
//
|
|
1095
|
-
//
|
|
1096
|
-
//
|
|
1097
|
-
//
|
|
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 ||
|
|
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
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
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
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
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
|
-
|
|
694
|
-
|
|
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)
|