@mentra/bluetooth-sdk 0.1.20 → 0.1.21-beta.0
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 +5 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +127 -24
- package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +65 -14
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +46 -3
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +1 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +369 -97
- package/android/src/main/java/com/mentra/bluetoothsdk/MentraLocalNetworkModule.kt +333 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/camera/CameraModels.kt +3 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.kt +1 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +16 -9
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Mach1.kt +1 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +183 -30
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +1 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Nimo.kt +1 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +4 -2
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Simulated.kt +1 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/status/WifiHotspotStatus.kt +19 -3
- package/android/src/main/java/com/mentra/bluetoothsdk/streaming/StreamModels.kt +15 -2
- package/android/src/main/java/com/mentra/bluetoothsdk/tts/TTSTools.kt +115 -47
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/audio/PcmStreamPlayer.kt +328 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/camera/PhotoRequestTest.kt +15 -0
- package/build/BluetoothSdk.types.d.ts +46 -1
- package/build/BluetoothSdk.types.d.ts.map +1 -1
- package/build/BluetoothSdk.types.js.map +1 -1
- package/build/_internal.d.ts +2 -0
- package/build/_internal.d.ts.map +1 -1
- package/build/_internal.js +2 -0
- package/build/_internal.js.map +1 -1
- package/build/_private/BluetoothSdkModule.d.ts +23 -2
- package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
- package/build/_private/BluetoothSdkModule.js +20 -0
- package/build/_private/BluetoothSdkModule.js.map +1 -1
- package/build/_private/MentraLocalNetworkModule.d.ts +37 -0
- package/build/_private/MentraLocalNetworkModule.d.ts.map +1 -0
- package/build/_private/MentraLocalNetworkModule.js +3 -0
- package/build/_private/MentraLocalNetworkModule.js.map +1 -0
- package/build/_private/cameraRequestPayload.d.ts.map +1 -1
- package/build/_private/cameraRequestPayload.js +1 -0
- package/build/_private/cameraRequestPayload.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.map +1 -1
- package/build/index.js +4 -0
- package/build/index.js.map +1 -1
- package/expo-module.config.json +1 -0
- package/ios/BluetoothSdkModule.swift +72 -0
- package/ios/Source/BluetoothSdkDefaults.swift +1 -1
- package/ios/Source/Bridge.swift +72 -18
- package/ios/Source/DeviceManager.swift +51 -3
- package/ios/Source/DeviceStore.swift +1 -1
- package/ios/Source/MentraBluetoothSDK.swift +289 -25
- package/ios/Source/audio/PcmStreamPlayer.swift +410 -0
- package/ios/Source/camera/CameraModels.swift +8 -3
- package/ios/Source/sgcs/G1.swift +1 -1
- package/ios/Source/sgcs/G2.swift +19 -7
- package/ios/Source/sgcs/Mach1.swift +1 -1
- package/ios/Source/sgcs/MentraLive.swift +318 -37
- package/ios/Source/sgcs/MentraNex.swift +1 -1
- package/ios/Source/sgcs/Nimo.swift +1 -1
- package/ios/Source/sgcs/SGCManager.swift +5 -2
- package/ios/Source/sgcs/Simulated.swift +1 -1
- package/ios/Source/status/WifiHotspotStatus.swift +21 -2
- package/ios/Source/streaming/StreamModels.swift +9 -0
- package/ios/Source/tts/TTSTools.swift +1 -1
- package/package.json +1 -2
- package/src/BluetoothSdk.types.ts +49 -0
- package/src/_internal.ts +2 -0
- package/src/_private/BluetoothSdkModule.ts +64 -13
- package/src/_private/MentraLocalNetworkModule.ts +50 -0
- package/src/_private/cameraRequestPayload.ts +1 -0
- package/src/_private/photoRequestPayload.ts +2 -3
- package/src/index.ts +4 -0
|
@@ -926,6 +926,18 @@ extension MentraLive: CBCentralManagerDelegate {
|
|
|
926
926
|
}
|
|
927
927
|
}
|
|
928
928
|
|
|
929
|
+
nonisolated func centralManager(
|
|
930
|
+
_: CBCentralManager, didUpdateANCSAuthorizationFor peripheral: CBPeripheral
|
|
931
|
+
) {
|
|
932
|
+
DispatchQueue.main.async { [weak self] in
|
|
933
|
+
guard let self, peripheral === self.connectedPeripheral else { return }
|
|
934
|
+
Bridge.log(
|
|
935
|
+
"LIVE: ANCS authorization updated: \(peripheral.ancsAuthorized ? "authorized" : "not authorized")"
|
|
936
|
+
)
|
|
937
|
+
self.enableAncsRelayIfAuthorized()
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
|
|
929
941
|
nonisolated func centralManager(
|
|
930
942
|
_: CBCentralManager, didDisconnectPeripheral _: CBPeripheral, error _: Error?
|
|
931
943
|
) {
|
|
@@ -1095,6 +1107,10 @@ extension MentraLive: CBPeripheralDelegate {
|
|
|
1095
1107
|
Bridge.log("LIVE: 🎤 Enabled LC3 audio notifications")
|
|
1096
1108
|
}
|
|
1097
1109
|
|
|
1110
|
+
// Authorization may complete before or after service discovery.
|
|
1111
|
+
// This covers the former; the central-manager callback covers the latter.
|
|
1112
|
+
self.enableAncsRelayIfAuthorized()
|
|
1113
|
+
|
|
1098
1114
|
// Start readiness check loop
|
|
1099
1115
|
self.startSignalStrengthPolling()
|
|
1100
1116
|
self.startReadinessCheckLoop()
|
|
@@ -1213,6 +1229,7 @@ extension MentraLive: CBPeripheralDelegate {
|
|
|
1213
1229
|
|
|
1214
1230
|
if uuid == self.RX_CHAR_UUID, isNotifying {
|
|
1215
1231
|
Bridge.log("LIVE: 🔔 Ready to receive data via notifications")
|
|
1232
|
+
self.enableAncsRelayIfAuthorized()
|
|
1216
1233
|
}
|
|
1217
1234
|
}
|
|
1218
1235
|
}
|
|
@@ -1267,14 +1284,12 @@ class MentraLive: NSObject, SGCManager {
|
|
|
1267
1284
|
// a previous pairing into the next one (would otherwise surface as wrong overall_percent
|
|
1268
1285
|
// or stale lastBesOtaProgress on the next OTA).
|
|
1269
1286
|
if state == ConnTypes.DISCONNECTED {
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
peerFilePayloadV2 = false
|
|
1277
|
-
BleJsonCompact.resetSession()
|
|
1287
|
+
resetWireNegotiationState()
|
|
1288
|
+
resetAncsRelayState()
|
|
1289
|
+
// Queued writes are session-bound: transmitting them into the NEXT session
|
|
1290
|
+
// (e.g. a stale handshake whose reply activates v2 before the new session
|
|
1291
|
+
// negotiated) is the bug class this clear removes.
|
|
1292
|
+
Task { await commandQueue.removeAll() }
|
|
1278
1293
|
stopSignalStrengthPolling()
|
|
1279
1294
|
DeviceStore.shared.apply("glasses", "signalStrength", -1)
|
|
1280
1295
|
DeviceStore.shared.apply("glasses", "signalStrengthUpdatedAt", 0)
|
|
@@ -1410,6 +1425,7 @@ class MentraLive: NSObject, SGCManager {
|
|
|
1410
1425
|
// So we temporarily suspend the LC3 mic during phone audio playback
|
|
1411
1426
|
private var micIntentEnabled = false // User/system WANTS mic enabled
|
|
1412
1427
|
private var micSuspendedForAudio = false // Mic temporarily suspended due to phone audio
|
|
1428
|
+
var isMicSuspendedForAudio: Bool { micSuspendedForAudio }
|
|
1413
1429
|
private var phoneAudioMonitor: PhoneAudioMonitor?
|
|
1414
1430
|
|
|
1415
1431
|
// Timing Constants
|
|
@@ -1453,10 +1469,24 @@ class MentraLive: NSObject, SGCManager {
|
|
|
1453
1469
|
private var peerWireProtocolVersion = 0
|
|
1454
1470
|
private var useBinaryWireProtocol = false
|
|
1455
1471
|
private var wireHandshakeQueued = false
|
|
1472
|
+
private var wireHandshakeAttempts = 0
|
|
1473
|
+
// Session generation in which the LAST outgoing v2 handshake was sent; a reply only
|
|
1474
|
+
// activates v2 when it answers a handshake from the CURRENT epoch (see PendingMessage
|
|
1475
|
+
// .generation for the queue-side guard).
|
|
1476
|
+
private var wireHandshakeSentGeneration = -1
|
|
1477
|
+
// Bumped on every BLE session reset; scheduled handshake-retry callbacks capture it
|
|
1478
|
+
// and no-op if the session changed, so a timer from a dead session can't poke a new one.
|
|
1479
|
+
private var wireSessionGeneration = 0
|
|
1480
|
+
// Grace period before an unanswered BLE wire v2 handshake is re-armed for retry,
|
|
1481
|
+
// and the per-session cap on automatic retries (see sendWireHandshake).
|
|
1482
|
+
private static let wireHandshakeRetryGraceSeconds: TimeInterval = 5
|
|
1483
|
+
private static let wireHandshakeMaxAttempts = 3
|
|
1456
1484
|
// Negotiated K900 STRING length endianness for the phone<->glasses BLE link. Defaults to legacy
|
|
1457
1485
|
// big-endian; upgraded to little-endian only when the glasses advertise wire_caps.k900_le (or a
|
|
1458
1486
|
// v2 binary handshake succeeds, which implies wire-v2 LE).
|
|
1459
1487
|
private var peerK900Le = false
|
|
1488
|
+
private var ancsAppIdentifiers: [UInt32: String] = [:]
|
|
1489
|
+
private var ancsRelayEnableRequested = false
|
|
1460
1490
|
private var peerWireCapsBinary = false
|
|
1461
1491
|
private var peerFilePayloadV2 = false
|
|
1462
1492
|
private var globalMessageId = 0
|
|
@@ -1705,7 +1735,7 @@ class MentraLive: NSObject, SGCManager {
|
|
|
1705
1735
|
let bleImgId =
|
|
1706
1736
|
"I" + String(format: "%09d", Int(Date().timeIntervalSince1970 * 1000) % 100_000_000)
|
|
1707
1737
|
json["bleImgId"] = bleImgId
|
|
1708
|
-
json["transferMethod"] =
|
|
1738
|
+
json["transferMethod"] = request.transferMethod
|
|
1709
1739
|
|
|
1710
1740
|
if let webhookUrl = request.webhookUrl, !webhookUrl.isEmpty {
|
|
1711
1741
|
json["webhookUrl"] = webhookUrl
|
|
@@ -1746,7 +1776,9 @@ class MentraLive: NSObject, SGCManager {
|
|
|
1746
1776
|
}
|
|
1747
1777
|
request.appendScanFields(to: &json)
|
|
1748
1778
|
|
|
1749
|
-
Bridge.log(
|
|
1779
|
+
Bridge.log(
|
|
1780
|
+
"LIVE: PHOTO PIPELINE [5b/6] take_photo JSON ready bleImgId=\(bleImgId) transferMethod=\(request.transferMethod)"
|
|
1781
|
+
)
|
|
1750
1782
|
Bridge.log("LIVE: PHOTO PIPELINE [6/6] Dispatching take_photo to sendJson()")
|
|
1751
1783
|
|
|
1752
1784
|
sendJson(json, wakeUp: true)
|
|
@@ -1755,11 +1787,12 @@ class MentraLive: NSObject, SGCManager {
|
|
|
1755
1787
|
func warmUpCamera(
|
|
1756
1788
|
requestId: String,
|
|
1757
1789
|
size: PhotoSize,
|
|
1790
|
+
mode: PhotoMode = .photo,
|
|
1758
1791
|
exposureTimeNs: Double?,
|
|
1759
1792
|
durationMs: Int
|
|
1760
1793
|
) {
|
|
1761
1794
|
Bridge.log(
|
|
1762
|
-
"LIVE: warmUpCamera() entry requestId=\(requestId) size=\(size.rawValue) durationMs=\(durationMs)"
|
|
1795
|
+
"LIVE: warmUpCamera() entry requestId=\(requestId) size=\(size.rawValue) mode=\(mode.rawValue) durationMs=\(durationMs)"
|
|
1763
1796
|
)
|
|
1764
1797
|
|
|
1765
1798
|
let allowedSizes = ["low", "medium", "high", "max"]
|
|
@@ -1768,6 +1801,7 @@ class MentraLive: NSObject, SGCManager {
|
|
|
1768
1801
|
"type": "camera_warm_up",
|
|
1769
1802
|
"requestId": requestId,
|
|
1770
1803
|
"size": allowedSizes.contains(sizeRaw) ? sizeRaw : "medium",
|
|
1804
|
+
"mode": mode.rawValue,
|
|
1771
1805
|
"durationMs": durationMs > 0 ? durationMs : 15000,
|
|
1772
1806
|
]
|
|
1773
1807
|
|
|
@@ -1778,6 +1812,13 @@ class MentraLive: NSObject, SGCManager {
|
|
|
1778
1812
|
sendJson(json, wakeUp: true)
|
|
1779
1813
|
}
|
|
1780
1814
|
|
|
1815
|
+
func stopCameraWarmUp(requestId: String) {
|
|
1816
|
+
sendJson(
|
|
1817
|
+
["type": "camera_warm_up_stop", "requestId": requestId],
|
|
1818
|
+
wakeUp: true
|
|
1819
|
+
)
|
|
1820
|
+
}
|
|
1821
|
+
|
|
1781
1822
|
func startStream(_ message: [String: Any]) {
|
|
1782
1823
|
Bridge.log("Starting stream")
|
|
1783
1824
|
var json = message
|
|
@@ -1819,17 +1860,24 @@ class MentraLive: NSObject, SGCManager {
|
|
|
1819
1860
|
// MARK: - Command Queue
|
|
1820
1861
|
|
|
1821
1862
|
class PendingMessage {
|
|
1822
|
-
init(data: Data, id: String, retries: Int, trace: BleWriteTrace? = nil) {
|
|
1863
|
+
init(data: Data, id: String, retries: Int, trace: BleWriteTrace? = nil, generation: Int = 0) {
|
|
1823
1864
|
self.data = data
|
|
1824
1865
|
self.id = id
|
|
1825
1866
|
self.retries = retries
|
|
1826
1867
|
self.trace = trace
|
|
1868
|
+
self.generation = generation
|
|
1827
1869
|
}
|
|
1828
1870
|
|
|
1829
1871
|
let data: Data
|
|
1830
1872
|
let retries: Int
|
|
1831
1873
|
let id: String
|
|
1832
1874
|
let trace: BleWriteTrace?
|
|
1875
|
+
// Wire-session generation at enqueue time; the drain loop drops entries from a
|
|
1876
|
+
// previous session so a stale write (worst case: an old handshake whose reply
|
|
1877
|
+
// would activate v2 pre-negotiation) can never cross a session boundary. This is
|
|
1878
|
+
// the deterministic guard - the async removeAll() on disconnect is best-effort
|
|
1879
|
+
// cleanup that may land after a reconnect has already resumed the drain loop.
|
|
1880
|
+
let generation: Int
|
|
1833
1881
|
}
|
|
1834
1882
|
|
|
1835
1883
|
struct OutgoingBleCommandTraceInfo {
|
|
@@ -1875,6 +1923,10 @@ class MentraLive: NSObject, SGCManager {
|
|
|
1875
1923
|
guard !commands.isEmpty else { return nil }
|
|
1876
1924
|
return commands.removeFirst()
|
|
1877
1925
|
}
|
|
1926
|
+
|
|
1927
|
+
func removeAll() {
|
|
1928
|
+
commands.removeAll()
|
|
1929
|
+
}
|
|
1878
1930
|
}
|
|
1879
1931
|
|
|
1880
1932
|
private func setupCommandQueue() {
|
|
@@ -1884,7 +1936,12 @@ class MentraLive: NSObject, SGCManager {
|
|
|
1884
1936
|
let pendingIsNil = await MainActor.run { self.pending == nil }
|
|
1885
1937
|
if pendingIsNil {
|
|
1886
1938
|
if let command = await self.commandQueue.dequeue() {
|
|
1887
|
-
await self.
|
|
1939
|
+
let currentGeneration = await MainActor.run { self.wireSessionGeneration }
|
|
1940
|
+
if command.generation != currentGeneration {
|
|
1941
|
+
Bridge.log("LIVE: Dropping stale queued write from a previous session epoch (id: \(command.id))")
|
|
1942
|
+
} else {
|
|
1943
|
+
await self.processSendQueue(command)
|
|
1944
|
+
}
|
|
1888
1945
|
}
|
|
1889
1946
|
}
|
|
1890
1947
|
try? await Task.sleep(nanoseconds: 100_000_000) // 100ms
|
|
@@ -1960,7 +2017,8 @@ class MentraLive: NSObject, SGCManager {
|
|
|
1960
2017
|
data: pendingMessage.data,
|
|
1961
2018
|
id: pendingMessage.id,
|
|
1962
2019
|
retries: pendingMessage.retries + 1,
|
|
1963
|
-
trace: pendingMessage.trace
|
|
2020
|
+
trace: pendingMessage.trace,
|
|
2021
|
+
generation: pendingMessage.generation
|
|
1964
2022
|
)
|
|
1965
2023
|
|
|
1966
2024
|
// Push to front of queue for immediate retry
|
|
@@ -2054,7 +2112,36 @@ class MentraLive: NSObject, SGCManager {
|
|
|
2054
2112
|
// Set connection timeout
|
|
2055
2113
|
startConnectionTimeout()
|
|
2056
2114
|
|
|
2057
|
-
|
|
2115
|
+
// ANCS is hosted by iOS and is only exposed to authorized accessories.
|
|
2116
|
+
// Requiring it at connect time lets the system complete that authorization
|
|
2117
|
+
// flow before the glasses subscribe to the ANCS characteristics.
|
|
2118
|
+
centralManager?.connect(
|
|
2119
|
+
peripheral,
|
|
2120
|
+
options: [CBConnectPeripheralOptionRequiresANCS: true]
|
|
2121
|
+
)
|
|
2122
|
+
}
|
|
2123
|
+
|
|
2124
|
+
/// Opt the firmware into ANCS only after iOS has authorized this accessory.
|
|
2125
|
+
/// Old firmware safely ignores the command, while new firmware stays inert
|
|
2126
|
+
/// for old mobile clients that never send it.
|
|
2127
|
+
private func enableAncsRelayIfAuthorized() {
|
|
2128
|
+
guard !ancsRelayEnableRequested,
|
|
2129
|
+
let peripheral = connectedPeripheral,
|
|
2130
|
+
txCharacteristic != nil,
|
|
2131
|
+
rxCharacteristic?.isNotifying == true,
|
|
2132
|
+
peripheral.ancsAuthorized
|
|
2133
|
+
else {
|
|
2134
|
+
return
|
|
2135
|
+
}
|
|
2136
|
+
|
|
2137
|
+
let command: [String: Any] = [
|
|
2138
|
+
"C": "cs_ancs",
|
|
2139
|
+
"B": ["enabled": 1],
|
|
2140
|
+
]
|
|
2141
|
+
if sendRawK900Command(command) {
|
|
2142
|
+
ancsRelayEnableRequested = true
|
|
2143
|
+
Bridge.log("LIVE: Requested ANCS relay from compatible firmware")
|
|
2144
|
+
}
|
|
2058
2145
|
}
|
|
2059
2146
|
|
|
2060
2147
|
private func handleReconnection() {
|
|
@@ -2320,7 +2407,14 @@ class MentraLive: NSObject, SGCManager {
|
|
|
2320
2407
|
|
|
2321
2408
|
switch type {
|
|
2322
2409
|
case "glasses_ready":
|
|
2410
|
+
// glasses_ready is a REMOTE wire-session reset: the glasses ran
|
|
2411
|
+
// onTransportReset() before sending it, so their side is back on legacy
|
|
2412
|
+
// framing regardless of what this side negotiated earlier. Start a fresh
|
|
2413
|
+
// wire epoch (clears v2-active state that would otherwise gate the
|
|
2414
|
+
// handshake off), then negotiate from the caps this message advertises.
|
|
2415
|
+
resetWireNegotiationState()
|
|
2323
2416
|
parsePeerWireCaps(json)
|
|
2417
|
+
maybeSendWireHandshake()
|
|
2324
2418
|
handleGlassesReady()
|
|
2325
2419
|
|
|
2326
2420
|
case "battery_status":
|
|
@@ -2355,7 +2449,7 @@ class MentraLive: NSObject, SGCManager {
|
|
|
2355
2449
|
} else if connected {
|
|
2356
2450
|
DeviceStore.shared.apply("glasses", "wifiError", "")
|
|
2357
2451
|
}
|
|
2358
|
-
updateWifiStatus(connected: connected, ssid: ssid, ip: ip)
|
|
2452
|
+
updateWifiStatus(connected: connected, ssid: ssid, ip: ip, error: wifiError.isEmpty ? nil : wifiError)
|
|
2359
2453
|
|
|
2360
2454
|
case "hotspot_status_update":
|
|
2361
2455
|
let enabled = json["hotspot_enabled"] as? Bool ?? false
|
|
@@ -2599,8 +2693,6 @@ class MentraLive: NSObject, SGCManager {
|
|
|
2599
2693
|
if let buildNumber = fields["build_number"] as? String {
|
|
2600
2694
|
isNewVersion = (Int(buildNumber) ?? 0) >= 5
|
|
2601
2695
|
DeviceStore.shared.apply("glasses", "buildNumber", buildNumber)
|
|
2602
|
-
parsePeerWireCaps(json)
|
|
2603
|
-
maybeSendWireHandshake()
|
|
2604
2696
|
}
|
|
2605
2697
|
if let deviceModel = fields["device_model"] as? String {
|
|
2606
2698
|
DeviceStore.shared.apply("glasses", "deviceModel", deviceModel)
|
|
@@ -2632,6 +2724,16 @@ class MentraLive: NSObject, SGCManager {
|
|
|
2632
2724
|
DeviceStore.shared.apply("glasses", "systemTimeMs", systemTimeMs.int64Value)
|
|
2633
2725
|
}
|
|
2634
2726
|
|
|
2727
|
+
// Negotiate wire caps from ANY version_info chunk, not only the one
|
|
2728
|
+
// carrying build_number: the glasses put wire_caps in version_info_3
|
|
2729
|
+
// (firmware chunk) while build_number travels in version_info_1, so
|
|
2730
|
+
// gating negotiation on build_number silently discards the caps.
|
|
2731
|
+
// parsePeerWireCaps no-ops without a wire_caps key and
|
|
2732
|
+
// maybeSendWireHandshake gates on caps + build + not-yet-queued, so
|
|
2733
|
+
// running them per chunk is safe.
|
|
2734
|
+
parsePeerWireCaps(json)
|
|
2735
|
+
maybeSendWireHandshake()
|
|
2736
|
+
|
|
2635
2737
|
Bridge.sendVersionInfo(fields)
|
|
2636
2738
|
} else {
|
|
2637
2739
|
Bridge.log("Unhandled message type: \(type)")
|
|
@@ -2729,6 +2831,11 @@ class MentraLive: NSObject, SGCManager {
|
|
|
2729
2831
|
}
|
|
2730
2832
|
|
|
2731
2833
|
switch command {
|
|
2834
|
+
case "sr_ancs":
|
|
2835
|
+
if let body = k900ParseBody(json["B"]) {
|
|
2836
|
+
processAncsRelay(body)
|
|
2837
|
+
}
|
|
2838
|
+
|
|
2732
2839
|
case "sr_hrt":
|
|
2733
2840
|
if let bodyObj = json["B"] as? [String: Any] {
|
|
2734
2841
|
let readyResponse = bodyObj["ready"] as? Int ?? 0
|
|
@@ -2885,7 +2992,17 @@ class MentraLive: NSObject, SGCManager {
|
|
|
2885
2992
|
|
|
2886
2993
|
let syntheticStatus: String
|
|
2887
2994
|
if besOtaStatus == "FINISHED" {
|
|
2888
|
-
|
|
2995
|
+
// The glasses power-cycle right after the final BES tick, so a session
|
|
2996
|
+
// whose BES step is the LAST step never gets a follow-up ota_status from
|
|
2997
|
+
// the glasses — consumers mapping on this synthetic status would otherwise
|
|
2998
|
+
// never see a terminal state. Emit "complete" for the final step;
|
|
2999
|
+
// mid-session BES steps keep "step_complete" so session-level trackers
|
|
3000
|
+
// advance normally. Unknown sessions (cachedOtaTotalSteps == 0, e.g.
|
|
3001
|
+
// legacy glasses that never sent an ota_status) conservatively keep
|
|
3002
|
+
// "step_complete".
|
|
3003
|
+
syntheticStatus = (cachedOtaTotalSteps > 0 && cachedOtaCurrentStep >= cachedOtaTotalSteps)
|
|
3004
|
+
? "complete"
|
|
3005
|
+
: "step_complete"
|
|
2889
3006
|
} else if besOtaStatus == "FAILED" {
|
|
2890
3007
|
syntheticStatus = "failed"
|
|
2891
3008
|
} else {
|
|
@@ -2933,11 +3050,87 @@ class MentraLive: NSObject, SGCManager {
|
|
|
2933
3050
|
}
|
|
2934
3051
|
}
|
|
2935
3052
|
|
|
3053
|
+
/// Convert a Mentra Live firmware ANCS relay packet into the same native
|
|
3054
|
+
/// events used by Android's NotificationListenerService.
|
|
3055
|
+
private func processAncsRelay(_ body: [String: Any]) {
|
|
3056
|
+
guard let event = body["event"] as? String,
|
|
3057
|
+
let uid = (body["uid"] as? NSNumber)?.uint32Value
|
|
3058
|
+
else {
|
|
3059
|
+
Bridge.log("LIVE: Ignoring malformed ANCS relay packet")
|
|
3060
|
+
return
|
|
3061
|
+
}
|
|
3062
|
+
|
|
3063
|
+
let notificationId = "ancs-\(uid)"
|
|
3064
|
+
let appIdentifier = body["appIdentifier"] as? String ?? ""
|
|
3065
|
+
|
|
3066
|
+
if event == "removed" {
|
|
3067
|
+
let removedAppIdentifier = ancsAppIdentifiers.removeValue(forKey: uid) ?? appIdentifier
|
|
3068
|
+
Bridge.sendTypedMessage(
|
|
3069
|
+
"phone_notification_dismissed",
|
|
3070
|
+
body: [
|
|
3071
|
+
"notificationId": notificationId,
|
|
3072
|
+
"notificationKey": notificationId,
|
|
3073
|
+
"packageName": removedAppIdentifier,
|
|
3074
|
+
"timestamp": Int64(Date().timeIntervalSince1970 * 1000),
|
|
3075
|
+
]
|
|
3076
|
+
)
|
|
3077
|
+
return
|
|
3078
|
+
}
|
|
3079
|
+
|
|
3080
|
+
guard event == "added" || event == "modified" else {
|
|
3081
|
+
Bridge.log("LIVE: Ignoring unknown ANCS event: \(event)")
|
|
3082
|
+
return
|
|
3083
|
+
}
|
|
3084
|
+
|
|
3085
|
+
let title = body["title"] as? String ?? ""
|
|
3086
|
+
let subtitle = body["subtitle"] as? String ?? ""
|
|
3087
|
+
let message = body["message"] as? String ?? ""
|
|
3088
|
+
let content = [subtitle, message]
|
|
3089
|
+
.filter { !$0.isEmpty }
|
|
3090
|
+
.joined(separator: "\n")
|
|
3091
|
+
let flags = (body["flags"] as? NSNumber)?.uint8Value ?? 0
|
|
3092
|
+
if !appIdentifier.isEmpty {
|
|
3093
|
+
ancsAppIdentifiers[uid] = appIdentifier
|
|
3094
|
+
}
|
|
3095
|
+
|
|
3096
|
+
Bridge.sendTypedMessage(
|
|
3097
|
+
"phone_notification",
|
|
3098
|
+
body: [
|
|
3099
|
+
"notificationId": notificationId,
|
|
3100
|
+
"app": appIdentifier,
|
|
3101
|
+
"title": title,
|
|
3102
|
+
"content": content,
|
|
3103
|
+
"priority": (flags & 0x02) != 0 ? "1" : "0",
|
|
3104
|
+
"timestamp": ancsTimestamp(body["date"] as? String),
|
|
3105
|
+
"packageName": appIdentifier,
|
|
3106
|
+
]
|
|
3107
|
+
)
|
|
3108
|
+
}
|
|
3109
|
+
|
|
3110
|
+
private func ancsTimestamp(_ value: String?) -> Int64 {
|
|
3111
|
+
guard let value, !value.isEmpty else {
|
|
3112
|
+
return Int64(Date().timeIntervalSince1970 * 1000)
|
|
3113
|
+
}
|
|
3114
|
+
|
|
3115
|
+
let formatter = DateFormatter()
|
|
3116
|
+
formatter.locale = Locale(identifier: "en_US_POSIX")
|
|
3117
|
+
formatter.calendar = Calendar(identifier: .gregorian)
|
|
3118
|
+
formatter.timeZone = .current
|
|
3119
|
+
formatter.dateFormat = "yyyyMMdd'T'HHmmss"
|
|
3120
|
+
guard let date = formatter.date(from: value) else {
|
|
3121
|
+
return Int64(Date().timeIntervalSince1970 * 1000)
|
|
3122
|
+
}
|
|
3123
|
+
return Int64(date.timeIntervalSince1970 * 1000)
|
|
3124
|
+
}
|
|
3125
|
+
|
|
2936
3126
|
// commands to send to the glasses:
|
|
2937
3127
|
|
|
2938
|
-
func requestWifiScan() {
|
|
3128
|
+
func requestWifiScan(scanId: String?) {
|
|
2939
3129
|
Bridge.log("LIVE: Requesting WiFi scan from glasses")
|
|
2940
|
-
|
|
3130
|
+
var json: [String: Any] = ["type": "request_wifi_scan"]
|
|
3131
|
+
if let scanId, !scanId.isEmpty {
|
|
3132
|
+
json["scanId"] = scanId
|
|
3133
|
+
}
|
|
2941
3134
|
sendJson(json, wakeUp: true)
|
|
2942
3135
|
}
|
|
2943
3136
|
|
|
@@ -3177,7 +3370,8 @@ class MentraLive: NSObject, SGCManager {
|
|
|
3177
3370
|
}
|
|
3178
3371
|
|
|
3179
3372
|
let scanComplete = json["scan_complete"] as? Bool ?? json["scanComplete"] as? Bool ?? false
|
|
3180
|
-
|
|
3373
|
+
let scanId = (json["scanId"] as? String).flatMap { $0.isEmpty ? nil : $0 }
|
|
3374
|
+
Bridge.updateWifiScanResults(networks, scanComplete: scanComplete, scanId: scanId)
|
|
3181
3375
|
}
|
|
3182
3376
|
|
|
3183
3377
|
private func handleButtonPress(_ json: [String: Any]) {
|
|
@@ -3861,8 +4055,12 @@ class MentraLive: NSObject, SGCManager {
|
|
|
3861
4055
|
|
|
3862
4056
|
func queueSend(_ data: Data, id: String, trace: BleWriteTrace?) {
|
|
3863
4057
|
let significantQueueSize = SIGNIFICANT_BLE_TRACE_QUEUE_SIZE
|
|
4058
|
+
// Capture the epoch at enqueue-request time: the Task body may run after a
|
|
4059
|
+
// session reset, and a pre-reset payload stamped with the new generation would
|
|
4060
|
+
// defeat the stale-write guard in the drain loop.
|
|
4061
|
+
let generation = wireSessionGeneration
|
|
3864
4062
|
Task {
|
|
3865
|
-
let queueSize = await commandQueue.enqueue(PendingMessage(data: data, id: id, retries: 0, trace: trace))
|
|
4063
|
+
let queueSize = await commandQueue.enqueue(PendingMessage(data: data, id: id, retries: 0, trace: trace, generation: generation))
|
|
3866
4064
|
guard trace != nil, queueSize >= significantQueueSize else {
|
|
3867
4065
|
return
|
|
3868
4066
|
}
|
|
@@ -4092,19 +4290,47 @@ class MentraLive: NSObject, SGCManager {
|
|
|
4092
4290
|
}
|
|
4093
4291
|
Bridge.log("LIVE: Sending BLE wire v2 handshake")
|
|
4094
4292
|
wireHandshakeQueued = true
|
|
4293
|
+
wireHandshakeSentGeneration = wireSessionGeneration
|
|
4095
4294
|
queueSend(packed, id: "-1")
|
|
4295
|
+
// The handshake and its reply are fire-and-forget binary frames with no ACK
|
|
4296
|
+
// tracking; if either is lost, wireHandshakeQueued would block every future
|
|
4297
|
+
// attempt and the session would silently stay on the legacy string wire.
|
|
4298
|
+
// Re-arm after a grace period so negotiation can retry, bounded per session
|
|
4299
|
+
// so a peer that advertises binary but never answers doesn't get pinged
|
|
4300
|
+
// forever (later caps/version triggers may still retry explicitly).
|
|
4301
|
+
wireHandshakeAttempts += 1
|
|
4302
|
+
let scheduledGeneration = wireSessionGeneration
|
|
4303
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + Self.wireHandshakeRetryGraceSeconds) { [weak self] in
|
|
4304
|
+
guard let self else { return }
|
|
4305
|
+
guard scheduledGeneration == self.wireSessionGeneration else { return }
|
|
4306
|
+
if self.wireHandshakeQueued, self.peerWireProtocolVersion < BleWireProtocol.protocolV2 {
|
|
4307
|
+
Bridge.log(
|
|
4308
|
+
"LIVE: BLE wire v2 handshake unanswered after \(Self.wireHandshakeRetryGraceSeconds)s " +
|
|
4309
|
+
"(attempt \(self.wireHandshakeAttempts)/\(Self.wireHandshakeMaxAttempts)) - re-arming"
|
|
4310
|
+
)
|
|
4311
|
+
self.wireHandshakeQueued = false
|
|
4312
|
+
if self.wireHandshakeAttempts < Self.wireHandshakeMaxAttempts {
|
|
4313
|
+
self.maybeSendWireHandshake()
|
|
4314
|
+
}
|
|
4315
|
+
}
|
|
4316
|
+
}
|
|
4096
4317
|
}
|
|
4097
4318
|
|
|
4098
4319
|
private func activateBinaryWireV2Session(logMessage: String) {
|
|
4099
4320
|
peerWireProtocolVersion = BleWireProtocol.protocolV2
|
|
4100
4321
|
useBinaryWireProtocol = true
|
|
4101
4322
|
wireHandshakeQueued = false
|
|
4323
|
+
wireHandshakeAttempts = 0
|
|
4102
4324
|
peerK900Le = true
|
|
4103
4325
|
BleJsonCompact.markSessionConnected(epochMs: Int64(Date().timeIntervalSince1970 * 1000))
|
|
4104
4326
|
Bridge.log(logMessage)
|
|
4105
4327
|
}
|
|
4106
4328
|
|
|
4107
4329
|
private func handlePeerWireHandshake() {
|
|
4330
|
+
if wireHandshakeSentGeneration != wireSessionGeneration {
|
|
4331
|
+
Bridge.log("LIVE: Ignoring wire v2 handshake reply from a previous session epoch")
|
|
4332
|
+
return
|
|
4333
|
+
}
|
|
4108
4334
|
activateBinaryWireV2Session(logMessage: "LIVE: Peer confirmed BLE wire protocol v2")
|
|
4109
4335
|
}
|
|
4110
4336
|
|
|
@@ -4624,12 +4850,12 @@ class MentraLive: NSObject, SGCManager {
|
|
|
4624
4850
|
}
|
|
4625
4851
|
}
|
|
4626
4852
|
|
|
4627
|
-
private func updateWifiStatus(connected: Bool, ssid: String, ip: String) {
|
|
4853
|
+
private func updateWifiStatus(connected: Bool, ssid: String, ip: String, error: String? = nil) {
|
|
4628
4854
|
Bridge.log("LIVE: 🌐 Updating WiFi status - connected: \(connected), ssid: \(ssid)")
|
|
4629
4855
|
DeviceStore.shared.apply("glasses", "wifiConnected", connected)
|
|
4630
4856
|
DeviceStore.shared.apply("glasses", "wifiSsid", ssid)
|
|
4631
4857
|
DeviceStore.shared.apply("glasses", "wifiLocalIp", ip)
|
|
4632
|
-
emitWifiStatusChange()
|
|
4858
|
+
emitWifiStatusChange(error: error)
|
|
4633
4859
|
}
|
|
4634
4860
|
|
|
4635
4861
|
private func updateHotspotStatus(enabled: Bool, ssid: String, password: String, ip: String) {
|
|
@@ -4923,8 +5149,8 @@ class MentraLive: NSObject, SGCManager {
|
|
|
4923
5149
|
// emitEvent("BatteryLevelEvent", body: eventBody)
|
|
4924
5150
|
// }
|
|
4925
5151
|
|
|
4926
|
-
private func emitWifiStatusChange() {
|
|
4927
|
-
Bridge.sendWifiStatusChange(connected: wifiConnected, ssid: wifiSsid, localIp: wifiLocalIp)
|
|
5152
|
+
private func emitWifiStatusChange(error: String? = nil) {
|
|
5153
|
+
Bridge.sendWifiStatusChange(connected: wifiConnected, ssid: wifiSsid, localIp: wifiLocalIp, error: error)
|
|
4928
5154
|
}
|
|
4929
5155
|
|
|
4930
5156
|
private func emitHotspotStatusChange() {
|
|
@@ -5006,14 +5232,8 @@ class MentraLive: NSObject, SGCManager {
|
|
|
5006
5232
|
|
|
5007
5233
|
// Stop all timers
|
|
5008
5234
|
stopAllTimers()
|
|
5009
|
-
|
|
5010
|
-
|
|
5011
|
-
useBinaryWireProtocol = false
|
|
5012
|
-
wireHandshakeQueued = false
|
|
5013
|
-
peerK900Le = false
|
|
5014
|
-
peerWireCapsBinary = false
|
|
5015
|
-
peerFilePayloadV2 = false
|
|
5016
|
-
BleJsonCompact.resetSession()
|
|
5235
|
+
resetWireNegotiationState()
|
|
5236
|
+
Task { await commandQueue.removeAll() } // stale writes die with the session
|
|
5017
5237
|
closeL2capFileChannel()
|
|
5018
5238
|
|
|
5019
5239
|
// Disconnect BLE
|
|
@@ -5106,6 +5326,34 @@ extension MentraLive {
|
|
|
5106
5326
|
* per-link endianness and binary support. Missing wire_caps leaves the legacy defaults (BE, no
|
|
5107
5327
|
* binary) untouched so older glasses keep working.
|
|
5108
5328
|
*/
|
|
5329
|
+
/// Drop every negotiated wire-session artifact and bump the session generation so
|
|
5330
|
+
/// scheduled callbacks from the old session stand down. Called on BLE disconnect and on
|
|
5331
|
+
/// glasses_ready: the glasses run onTransportReset() before sending glasses_ready, so a
|
|
5332
|
+
/// mid-link ASG restart (no BLE disconnect) resets THEIR side to legacy framing - the
|
|
5333
|
+
/// phone must treat every glasses_ready as a new wire epoch and renegotiate (bounded by
|
|
5334
|
+
/// the per-session handshake attempt cap) instead of trusting stale v2-active state.
|
|
5335
|
+
private func resetWireNegotiationState() {
|
|
5336
|
+
incomingChunkReassembler.clear()
|
|
5337
|
+
peerWireProtocolVersion = 0
|
|
5338
|
+
useBinaryWireProtocol = false
|
|
5339
|
+
wireHandshakeQueued = false
|
|
5340
|
+
wireHandshakeAttempts = 0
|
|
5341
|
+
wireSessionGeneration += 1
|
|
5342
|
+
peerK900Le = false
|
|
5343
|
+
peerWireCapsBinary = false
|
|
5344
|
+
peerFilePayloadV2 = false
|
|
5345
|
+
BleJsonCompact.resetSession()
|
|
5346
|
+
wireHandshakeSentGeneration = -1
|
|
5347
|
+
}
|
|
5348
|
+
|
|
5349
|
+
/// ANCS notification IDs live for the BLE link, not the wire epoch. A
|
|
5350
|
+
/// glasses_ready message may reset wire negotiation without disconnecting,
|
|
5351
|
+
/// so clear this state only when the BLE session itself ends.
|
|
5352
|
+
private func resetAncsRelayState() {
|
|
5353
|
+
ancsAppIdentifiers.removeAll()
|
|
5354
|
+
ancsRelayEnableRequested = false
|
|
5355
|
+
}
|
|
5356
|
+
|
|
5109
5357
|
private func parsePeerWireCaps(_ json: [String: Any]) {
|
|
5110
5358
|
guard let caps = json["wire_caps"] as? [String: Any] else { return }
|
|
5111
5359
|
if (caps["k900_le"] as? Bool) == true {
|
|
@@ -6000,8 +6248,8 @@ extension MentraLive {
|
|
|
6000
6248
|
}
|
|
6001
6249
|
|
|
6002
6250
|
func sendCameraFovSetting() {
|
|
6003
|
-
let settings = DeviceStore.shared.get("bluetooth", "camera_fov") as? [String: Any] ?? ["fov":
|
|
6004
|
-
let fov = settings["fov"] as? Int ??
|
|
6251
|
+
let settings = DeviceStore.shared.get("bluetooth", "camera_fov") as? [String: Any] ?? ["fov": 102, "roi_position": 0]
|
|
6252
|
+
let fov = settings["fov"] as? Int ?? 102
|
|
6005
6253
|
let roiPosition = settings["roi_position"] as? Int ?? 0
|
|
6006
6254
|
sendCameraFovSetting(requestId: nil, fov: fov, roiPosition: roiPosition)
|
|
6007
6255
|
}
|
|
@@ -6027,6 +6275,39 @@ extension MentraLive {
|
|
|
6027
6275
|
sendJson(json, wakeUp: true)
|
|
6028
6276
|
}
|
|
6029
6277
|
|
|
6278
|
+
func sendCameraFovOverride(
|
|
6279
|
+
requestId: String,
|
|
6280
|
+
leaseId: String,
|
|
6281
|
+
fov: Int,
|
|
6282
|
+
roiPosition: Int,
|
|
6283
|
+
ttlMs: Int
|
|
6284
|
+
) {
|
|
6285
|
+
sendJson(
|
|
6286
|
+
[
|
|
6287
|
+
"type": "camera_fov_override",
|
|
6288
|
+
"request_id": requestId,
|
|
6289
|
+
"params": [
|
|
6290
|
+
"lease_id": leaseId,
|
|
6291
|
+
"fov": fov,
|
|
6292
|
+
"roi_position": roiPosition,
|
|
6293
|
+
"ttl_ms": ttlMs,
|
|
6294
|
+
],
|
|
6295
|
+
],
|
|
6296
|
+
wakeUp: true
|
|
6297
|
+
)
|
|
6298
|
+
}
|
|
6299
|
+
|
|
6300
|
+
func releaseCameraFovOverride(requestId: String, leaseId: String) {
|
|
6301
|
+
sendJson(
|
|
6302
|
+
[
|
|
6303
|
+
"type": "camera_fov_override_release",
|
|
6304
|
+
"request_id": requestId,
|
|
6305
|
+
"params": ["lease_id": leaseId],
|
|
6306
|
+
],
|
|
6307
|
+
wakeUp: true
|
|
6308
|
+
)
|
|
6309
|
+
}
|
|
6310
|
+
|
|
6030
6311
|
func sendCameraTuningConfig(requestId: String?, anrOn: Bool, gainOn: Bool) {
|
|
6031
6312
|
Bridge.log("Sending camera tuning config: anr=\(anrOn), gain=\(gainOn)")
|
|
6032
6313
|
|
|
@@ -376,7 +376,7 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
|
|
|
376
376
|
func dbg1() {}
|
|
377
377
|
func dbg2() {}
|
|
378
378
|
|
|
379
|
-
func requestWifiScan() {}
|
|
379
|
+
func requestWifiScan(scanId _: String?) {}
|
|
380
380
|
|
|
381
381
|
func sendWifiCredentials(_: String, _: String) {}
|
|
382
382
|
|
|
@@ -1066,7 +1066,7 @@ class Nimo: NSObject, SGCManager {
|
|
|
1066
1066
|
|
|
1067
1067
|
// MARK: - SGCManager: Network (no WiFi)
|
|
1068
1068
|
|
|
1069
|
-
func requestWifiScan() {}
|
|
1069
|
+
func requestWifiScan(scanId _: String?) {}
|
|
1070
1070
|
func sendWifiCredentials(_: String, _: String) {}
|
|
1071
1071
|
func forgetWifiNetwork(_: String) {}
|
|
1072
1072
|
func sendHotspotState(_: Bool) {}
|
|
@@ -54,6 +54,7 @@ protocol SGCManager {
|
|
|
54
54
|
// MARK: - Audio Control
|
|
55
55
|
|
|
56
56
|
func setMicEnabled(_ enabled: Bool)
|
|
57
|
+
var isMicSuspendedForAudio: Bool { get }
|
|
57
58
|
func sortMicRanking(list: [String]) -> [String]
|
|
58
59
|
|
|
59
60
|
// MARK: - Messaging
|
|
@@ -184,7 +185,7 @@ protocol SGCManager {
|
|
|
184
185
|
|
|
185
186
|
// MARK: - Network Management
|
|
186
187
|
|
|
187
|
-
func requestWifiScan()
|
|
188
|
+
func requestWifiScan(scanId: String?)
|
|
188
189
|
func sendWifiCredentials(_ ssid: String, _ password: String)
|
|
189
190
|
func forgetWifiNetwork(_ ssid: String)
|
|
190
191
|
func sendHotspotState(_ enabled: Bool)
|
|
@@ -217,6 +218,8 @@ protocol SGCManager {
|
|
|
217
218
|
/// doesn't seem to work for concurrency reasons :(
|
|
218
219
|
/// we can make read-only getters for convienence though:
|
|
219
220
|
extension SGCManager {
|
|
221
|
+
var isMicSuspendedForAudio: Bool { false }
|
|
222
|
+
|
|
220
223
|
/// Default: no-op. Only G2 renders positioned text containers; other glasses ignore it.
|
|
221
224
|
func sendPositionedText(
|
|
222
225
|
_: String, x _: Int32, y _: Int32, width _: Int32, height _: Int32,
|
|
@@ -362,7 +365,7 @@ extension SGCManager {
|
|
|
362
365
|
|
|
363
366
|
func sendVoiceActivityDetectionSetting() {}
|
|
364
367
|
|
|
365
|
-
/// Default no-op; Mentra Live
|
|
368
|
+
/// Default no-op; Mentra Live and G2 override to handle phone-detected clock skew.
|
|
366
369
|
func sendSetSystemTime(_: Int64) {
|
|
367
370
|
Bridge.log("SGC: sendSetSystemTime not supported")
|
|
368
371
|
}
|