@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.
- package/README.md +1 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +135 -51
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +120 -17
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +5 -6
- package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +34 -21
- package/android/src/main/java/com/mentra/bluetoothsdk/OtaManifest.kt +1 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/camera/CameraModels.kt +13 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.kt +1 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1TextSanitizer.kt +38 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +488 -15
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +551 -159
- 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/types/DeviceModels.kt +2 -3
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/BleJsonCompact.java +493 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/BlePhotoUploadService.java +65 -5
- 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 +109 -73
- 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/BluetoothSdkExceptionTest.kt +18 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/camera/PhotoRequestTest.kt +23 -9
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G1TextSanitizerTest.kt +31 -0
- 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/BlePhotoUploadServiceTest.java +9 -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 +37 -1
- package/build/BluetoothSdk.types.d.ts.map +1 -1
- package/build/BluetoothSdk.types.js.map +1 -1
- package/build/_private/BluetoothSdkModule.d.ts +4 -0
- package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
- package/build/_private/BluetoothSdkModule.js.map +1 -1
- package/build/_private/photoRequestPayload.d.ts.map +1 -1
- package/build/_private/photoRequestPayload.js +1 -0
- package/build/_private/photoRequestPayload.js.map +1 -1
- package/build/index.d.ts +1 -1
- package/build/index.d.ts.map +1 -1
- package/build/index.js +5 -0
- package/build/index.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/Source/BluetoothSdkDefaults.swift +1 -1
- package/ios/Source/DeviceManager.swift +136 -30
- package/ios/Source/DeviceStore.swift +5 -5
- package/ios/Source/MentraBluetoothSDK.swift +40 -24
- package/ios/Source/OtaManifest.swift +1 -1
- package/ios/Source/camera/CameraModels.swift +17 -3
- package/ios/Source/sgcs/G1.swift +1 -1
- package/ios/Source/sgcs/G2.swift +518 -11
- package/ios/Source/sgcs/MentraLive.swift +443 -42
- package/ios/Source/sgcs/MentraLiveL2capChannel.swift +183 -0
- 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/G1Text.swift +30 -1
- 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 +39 -1
- package/src/_private/BluetoothSdkModule.ts +4 -0
- package/src/_private/photoRequestPayload.ts +1 -0
- package/src/index.ts +6 -0
- 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.
|
|
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 ||
|
|
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
|
}
|
|
@@ -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
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
694
|
-
|
|
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 .
|
|
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
|
}
|
package/ios/Source/sgcs/G1.swift
CHANGED
|
@@ -739,7 +739,7 @@ class G1: NSObject, SGCManager {
|
|
|
739
739
|
|
|
740
740
|
@objc func RN_sendText(_ text: String) {
|
|
741
741
|
Task {
|
|
742
|
-
let displayText =
|
|
742
|
+
let displayText = G1Text.sanitizeForDisplay(text)
|
|
743
743
|
guard let textData = displayText.data(using: .utf8) else { return }
|
|
744
744
|
|
|
745
745
|
var command: [UInt8] = [
|