@mentra/bluetooth-sdk 0.1.18 → 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/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/Source/BluetoothSdkDefaults.swift +1 -1
- 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/sgcs/G2.swift +518 -11
- package/ios/Source/sgcs/MentraLive.swift +308 -30
- 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
|
@@ -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? {
|
|
@@ -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)
|