@mentra/bluetooth-sdk 0.1.21-beta.3 → 3.1.0-dev.10
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 +56 -9
- package/android/build.gradle +7 -4
- package/android/lc3Lib/build.gradle +18 -4
- package/android/silero/build.gradle +9 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +16 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +35 -4
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +216 -23
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +2 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +11 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +112 -8
- package/android/src/main/java/com/mentra/bluetoothsdk/MentraLocalNetworkModule.kt +93 -12
- package/android/src/main/java/com/mentra/bluetoothsdk/OtaManifest.kt +5 -7
- package/android/src/main/java/com/mentra/bluetoothsdk/ScopedNetworkReadiness.kt +17 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/audio/AudioModels.kt +15 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/events/BluetoothEvents.kt +24 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/net/LocalIpv4.kt +57 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/otaserver/LocalOtaServer.kt +304 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/otaserver/MentraOtaServerModule.kt +120 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/photoreceiver/MentraPhotoReceiverModule.kt +2 -49
- package/android/src/main/java/com/mentra/bluetoothsdk/services/Foreground.kt +61 -30
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/BesOtaHeartbeatGuard.kt +49 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/BesOtaProgressMapper.kt +27 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +1344 -284
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingAdvertisement.kt +54 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +8 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/status/DeviceStatus.kt +17 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/types/DeviceModels.kt +12 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/BleJsonCompact.java +74 -79
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/K900ProtocolUtils.java +3 -3
- package/android/src/test/java/com/mentra/bluetoothsdk/OtaStartResponsePolicyTest.kt +30 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/ScopedNetworkReadinessTest.kt +39 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/otaserver/LocalOtaServerTest.kt +82 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/services/ForegroundServiceTypeTest.kt +69 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/BesOtaHeartbeatGuardTest.kt +73 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/BesOtaProgressMapperTest.kt +67 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveGattCharacteristicsTest.kt +39 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingAdvertisementParserTest.kt +75 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/utils/BleJsonCompactTest.java +148 -7
- package/build/BluetoothSdk.types.d.ts +58 -2
- 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 +4 -1
- package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
- package/build/_private/BluetoothSdkModule.js +1 -1
- package/build/_private/BluetoothSdkModule.js.map +1 -1
- package/build/_private/MentraLocalNetworkModule.d.ts +1 -0
- package/build/_private/MentraLocalNetworkModule.d.ts.map +1 -1
- package/build/_private/MentraLocalNetworkModule.js.map +1 -1
- package/build/_private/sdkOtaManifest.d.ts +3 -12
- package/build/_private/sdkOtaManifest.d.ts.map +1 -1
- package/build/_private/sdkOtaManifest.js +5 -17
- package/build/_private/sdkOtaManifest.js.map +1 -1
- package/build/generated/releaseMetadata.d.ts +12 -0
- package/build/generated/releaseMetadata.d.ts.map +1 -0
- package/build/generated/releaseMetadata.js +10 -0
- package/build/generated/releaseMetadata.js.map +1 -0
- package/build/index.d.ts +1 -1
- package/build/index.d.ts.map +1 -1
- package/build/index.js +10 -2
- package/build/index.js.map +1 -1
- package/build/ota-server/MentraOtaServer.types.d.ts +14 -0
- package/build/ota-server/MentraOtaServer.types.d.ts.map +1 -0
- package/build/ota-server/MentraOtaServer.types.js +2 -0
- package/build/ota-server/MentraOtaServer.types.js.map +1 -0
- package/build/ota-server/MentraOtaServerModule.d.ts +21 -0
- package/build/ota-server/MentraOtaServerModule.d.ts.map +1 -0
- package/build/ota-server/MentraOtaServerModule.js +3 -0
- package/build/ota-server/MentraOtaServerModule.js.map +1 -0
- package/build/ota-server/MentraOtaServerModule.web.d.ts +10 -0
- package/build/ota-server/MentraOtaServerModule.web.d.ts.map +1 -0
- package/build/ota-server/MentraOtaServerModule.web.js +12 -0
- package/build/ota-server/MentraOtaServerModule.web.js.map +1 -0
- package/build/ota-server/index.d.ts +3 -0
- package/build/ota-server/index.d.ts.map +1 -0
- package/build/ota-server/index.js +3 -0
- package/build/ota-server/index.js.map +1 -0
- package/expo-module.config.json +3 -2
- package/ios/BackgroundOtaArtifactDownloader.swift +125 -0
- package/ios/BluetoothSdkModule.swift +16 -2
- package/ios/LocalIPv4.swift +150 -0
- package/ios/LocalOtaServer.swift +309 -0
- package/ios/MentraBluetoothSDK.podspec +5 -1
- package/ios/MentraOtaServerModule.swift +147 -0
- package/ios/MentraPhotoReceiverModule.swift +1 -81
- package/ios/Source/BluetoothSdkDefaults.swift +4 -1
- package/ios/Source/Bridge.swift +43 -4
- package/ios/Source/DeviceManager.swift +154 -19
- package/ios/Source/DeviceStore.swift +2 -0
- package/ios/Source/GeneratedReleaseMetadata.swift +11 -0
- package/ios/Source/MentraBluetoothSDK.swift +74 -6
- package/ios/Source/OtaManifest.swift +5 -7
- package/ios/Source/audio/AudioModels.swift +17 -0
- package/ios/Source/camera/CameraModels.swift +1 -0
- package/ios/Source/events/BluetoothEvents.swift +27 -0
- package/ios/Source/sgcs/MentraLive.swift +469 -77
- package/ios/Source/sgcs/MentraLivePairingAdvertisement.swift +64 -0
- package/ios/Source/sgcs/SGCManager.swift +11 -0
- package/ios/Source/status/DeviceStatus.swift +23 -0
- package/ios/Source/types/DeviceModels.swift +23 -2
- package/ios/Source/utils/BleJsonCompact.swift +73 -71
- package/ios/Tests/MentraLivePairingAdvertisementTests.swift +104 -0
- package/package.json +5 -1
- package/plugin/build/withIos.d.ts +3 -1
- package/plugin/build/withIos.js +22 -10
- package/scripts/verify-release-package.mjs +34 -0
- package/scripts/write-release-metadata.mjs +154 -0
- package/scripts/write-release-metadata.test.mjs +29 -0
- package/src/BluetoothSdk.types.ts +69 -3
- package/src/_internal.ts +2 -0
- package/src/_private/BluetoothSdkModule.ts +5 -5
- package/src/_private/MentraLocalNetworkModule.ts +1 -1
- package/src/_private/sdkOtaManifest.ts +5 -17
- package/src/generated/releaseMetadata.ts +20 -0
- package/src/index.ts +19 -2
- package/src/ota-server/MentraOtaServer.types.ts +10 -0
- package/src/ota-server/MentraOtaServerModule.ts +23 -0
- package/src/ota-server/MentraOtaServerModule.web.ts +17 -0
- package/src/ota-server/index.ts +2 -0
|
@@ -857,20 +857,65 @@ extension MentraLive: CBCentralManagerDelegate {
|
|
|
857
857
|
}
|
|
858
858
|
}
|
|
859
859
|
|
|
860
|
-
func handleDiscoveredPeripheral(
|
|
860
|
+
func handleDiscoveredPeripheral(
|
|
861
|
+
_ peripheral: CBPeripheral,
|
|
862
|
+
advertisementData: [String: Any] = [:],
|
|
863
|
+
rssi: NSNumber? = nil
|
|
864
|
+
) {
|
|
861
865
|
guard let name = peripheral.name else { return }
|
|
862
866
|
|
|
863
867
|
// Check for compatible device names
|
|
864
868
|
if name == "Xy_A" || name.hasPrefix("XyBLE_") || name.hasPrefix("MENTRA_LIVE_BLE")
|
|
865
869
|
|| name.hasPrefix("MENTRA_LIVE_BT") || name.lowercased().hasPrefix("mentra_live")
|
|
866
870
|
{
|
|
871
|
+
let savedDeviceName = UserDefaults.standard.string(forKey: PREFS_DEVICE_NAME)
|
|
872
|
+
let isReconnectTarget = savedDeviceName != nil && savedDeviceName == name
|
|
873
|
+
if !isReconnectTarget && advertisesPairingFlag(advertisementData)
|
|
874
|
+
&& !isPairingDiscoverable(advertisementData)
|
|
875
|
+
{
|
|
876
|
+
// Nearby but not pairable: RN uses this for the empty-state hint, not the list.
|
|
877
|
+
Bridge.log(
|
|
878
|
+
"LIVE: Nearby \(name) is secure firmware not in pairing mode — hiding from scan list"
|
|
879
|
+
)
|
|
880
|
+
discoveredPeripherals[name] = peripheral
|
|
881
|
+
cacheAdvPairing(
|
|
882
|
+
name,
|
|
883
|
+
pairingMode: false,
|
|
884
|
+
pairingCode: nil,
|
|
885
|
+
securePairingCapable: true
|
|
886
|
+
)
|
|
887
|
+
emitDiscoveredDeviceFromCache(
|
|
888
|
+
name,
|
|
889
|
+
identifier: peripheral.identifier.uuidString,
|
|
890
|
+
rssi: rssi?.intValue
|
|
891
|
+
)
|
|
892
|
+
return
|
|
893
|
+
}
|
|
894
|
+
|
|
867
895
|
let glassType = name == "Xy_A" ? "Standard" : "K900"
|
|
868
|
-
|
|
896
|
+
let trailer = parseSecurePairingTrailer(advertisementData)
|
|
897
|
+
Bridge.log(
|
|
898
|
+
trailer.secureCapable
|
|
899
|
+
? "Found compatible \(glassType) glasses device: \(name) (pairingMode=\(trailer.pairingMode))"
|
|
900
|
+
: "Found compatible \(glassType) glasses device: \(name) (legacy firmware, pairable)"
|
|
901
|
+
)
|
|
869
902
|
|
|
870
903
|
// Store the peripheral
|
|
871
904
|
discoveredPeripherals[name] = peripheral
|
|
872
905
|
|
|
873
|
-
|
|
906
|
+
cacheAdvPairing(
|
|
907
|
+
name,
|
|
908
|
+
// Field firmware has no pairing flag. Treat it as pairable so the
|
|
909
|
+
// new Mentra App can still find old glasses.
|
|
910
|
+
pairingMode: trailer.secureCapable ? trailer.pairingMode : true,
|
|
911
|
+
pairingCode: trailer.pairingCode,
|
|
912
|
+
securePairingCapable: trailer.secureCapable
|
|
913
|
+
)
|
|
914
|
+
emitDiscoveredDeviceFromCache(
|
|
915
|
+
name,
|
|
916
|
+
identifier: peripheral.identifier.uuidString,
|
|
917
|
+
rssi: rssi?.intValue
|
|
918
|
+
)
|
|
874
919
|
|
|
875
920
|
// Check if this is the device we want to connect to
|
|
876
921
|
if let savedDeviceName = UserDefaults.standard.string(forKey: PREFS_DEVICE_NAME),
|
|
@@ -887,20 +932,40 @@ extension MentraLive: CBCentralManagerDelegate {
|
|
|
887
932
|
|
|
888
933
|
nonisolated func centralManager(
|
|
889
934
|
_: CBCentralManager, didDiscover peripheral: CBPeripheral,
|
|
890
|
-
advertisementData
|
|
935
|
+
advertisementData: [String: Any], rssi: NSNumber
|
|
891
936
|
) {
|
|
892
937
|
DispatchQueue.main.async { [weak self] in
|
|
893
|
-
self?.handleDiscoveredPeripheral(peripheral, rssi: rssi)
|
|
938
|
+
self?.handleDiscoveredPeripheral(peripheral, advertisementData: advertisementData, rssi: rssi)
|
|
894
939
|
}
|
|
895
940
|
}
|
|
896
941
|
|
|
897
942
|
nonisolated func centralManager(_: CBCentralManager, didConnect peripheral: CBPeripheral) {
|
|
898
943
|
DispatchQueue.main.async { [weak self] in
|
|
899
944
|
guard let self else { return }
|
|
945
|
+
let matchesActiveAttempt = self.connectingPeripheral === peripheral
|
|
946
|
+
guard MentraLiveConnectionAttemptPolicy.shouldAcceptDidConnect(
|
|
947
|
+
pairingYieldActive: self.pairingYieldActive,
|
|
948
|
+
matchesActiveAttempt: matchesActiveAttempt
|
|
949
|
+
) else {
|
|
950
|
+
Bridge.log(
|
|
951
|
+
"LIVE: Rejecting stale connection callback during pairing yield or after attempt replacement: \(peripheral.identifier)"
|
|
952
|
+
)
|
|
953
|
+
self.stopConnectionTimeout()
|
|
954
|
+
if self.connectingPeripheral === peripheral {
|
|
955
|
+
self.connectingPeripheral = nil
|
|
956
|
+
}
|
|
957
|
+
if self.connectedPeripheral === peripheral {
|
|
958
|
+
self.connectedPeripheral = nil
|
|
959
|
+
}
|
|
960
|
+
self.isConnecting = false
|
|
961
|
+
self.centralManager?.cancelPeripheralConnection(peripheral)
|
|
962
|
+
return
|
|
963
|
+
}
|
|
900
964
|
Bridge.log("Connected to GATT server, discovering services...")
|
|
901
965
|
|
|
902
966
|
self.stopConnectionTimeout()
|
|
903
967
|
self.isConnecting = false
|
|
968
|
+
self.connectingPeripheral = nil
|
|
904
969
|
self.connectedPeripheral = peripheral
|
|
905
970
|
|
|
906
971
|
// Save device name and address for future reconnection
|
|
@@ -939,10 +1004,17 @@ extension MentraLive: CBCentralManagerDelegate {
|
|
|
939
1004
|
}
|
|
940
1005
|
|
|
941
1006
|
nonisolated func centralManager(
|
|
942
|
-
_: CBCentralManager, didDisconnectPeripheral
|
|
1007
|
+
_: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?
|
|
943
1008
|
) {
|
|
944
1009
|
DispatchQueue.main.async { [weak self] in
|
|
945
1010
|
guard let self else { return }
|
|
1011
|
+
let isCurrent =
|
|
1012
|
+
self.connectedPeripheral === peripheral || self.connectingPeripheral === peripheral
|
|
1013
|
+
if !isCurrent {
|
|
1014
|
+
Bridge.log("LIVE: Ignoring stale disconnect for \(peripheral.identifier)")
|
|
1015
|
+
return
|
|
1016
|
+
}
|
|
1017
|
+
self.connectingPeripheral = nil
|
|
946
1018
|
Bridge.log("LIVE: Disconnected from GATT server")
|
|
947
1019
|
|
|
948
1020
|
self.isConnecting = false
|
|
@@ -962,6 +1034,11 @@ extension MentraLive: CBCentralManagerDelegate {
|
|
|
962
1034
|
self.txCharacteristic = nil
|
|
963
1035
|
self.rxCharacteristic = nil
|
|
964
1036
|
|
|
1037
|
+
if self.pairingYieldAwaitingReclaim, Self.isPairingAuthFailure(error) {
|
|
1038
|
+
self.clearSavedGlassesAfterOwnerLoss(reason: "ios_auth_fail")
|
|
1039
|
+
return
|
|
1040
|
+
}
|
|
1041
|
+
|
|
965
1042
|
// Attempt reconnection if not killed
|
|
966
1043
|
if !self.isKilled {
|
|
967
1044
|
self.handleReconnection()
|
|
@@ -969,10 +1046,17 @@ extension MentraLive: CBCentralManagerDelegate {
|
|
|
969
1046
|
}
|
|
970
1047
|
}
|
|
971
1048
|
|
|
972
|
-
nonisolated func centralManager(_: CBCentralManager, didFailToConnect
|
|
1049
|
+
nonisolated func centralManager(_: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) {
|
|
973
1050
|
let errorDescription = error?.localizedDescription ?? "Unknown error"
|
|
974
1051
|
DispatchQueue.main.async { [weak self] in
|
|
975
1052
|
guard let self else { return }
|
|
1053
|
+
let isCurrent =
|
|
1054
|
+
self.connectedPeripheral === peripheral || self.connectingPeripheral === peripheral
|
|
1055
|
+
if !isCurrent {
|
|
1056
|
+
Bridge.log("LIVE: Ignoring stale connect failure for \(peripheral.identifier)")
|
|
1057
|
+
return
|
|
1058
|
+
}
|
|
1059
|
+
self.connectingPeripheral = nil
|
|
976
1060
|
Bridge.log("LIVE: Failed to connect to peripheral: \(errorDescription)")
|
|
977
1061
|
|
|
978
1062
|
self.stopConnectionTimeout()
|
|
@@ -981,6 +1065,11 @@ extension MentraLive: CBCentralManagerDelegate {
|
|
|
981
1065
|
self.connectedPeripheral = nil
|
|
982
1066
|
self.updateConnectionState(ConnTypes.DISCONNECTED)
|
|
983
1067
|
|
|
1068
|
+
if self.pairingYieldAwaitingReclaim, Self.isPairingAuthFailure(error) {
|
|
1069
|
+
self.clearSavedGlassesAfterOwnerLoss(reason: "ios_auth_fail")
|
|
1070
|
+
return
|
|
1071
|
+
}
|
|
1072
|
+
|
|
984
1073
|
if !self.isKilled {
|
|
985
1074
|
self.handleReconnection()
|
|
986
1075
|
}
|
|
@@ -1276,6 +1365,39 @@ class MentraLive: NSObject, SGCManager {
|
|
|
1276
1365
|
private let BLOCK_AUDIO_DUPLEX = false
|
|
1277
1366
|
private static let voiceActivityDetectionSwitchType = 8
|
|
1278
1367
|
private static let loudnessGateSwitchType = 10
|
|
1368
|
+
private func pairingAdvertisement(
|
|
1369
|
+
_ advertisementData: [String: Any]
|
|
1370
|
+
) -> MentraLivePairingAdvertisement? {
|
|
1371
|
+
MentraLivePairingAdvertisement.parse(
|
|
1372
|
+
coreBluetoothManufacturerData:
|
|
1373
|
+
advertisementData[CBAdvertisementDataManufacturerDataKey] as? Data
|
|
1374
|
+
)
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
private func isPairingDiscoverable(_ advertisementData: [String: Any]) -> Bool {
|
|
1378
|
+
pairingAdvertisement(advertisementData)?.pairingMode == true
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
private struct SecurePairingTrailer {
|
|
1382
|
+
let pairingMode: Bool
|
|
1383
|
+
let pairingCode: String?
|
|
1384
|
+
let secureCapable: Bool
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
private func parseSecurePairingTrailer(_ advertisementData: [String: Any]) -> SecurePairingTrailer {
|
|
1388
|
+
guard let advertisement = pairingAdvertisement(advertisementData) else {
|
|
1389
|
+
return SecurePairingTrailer(pairingMode: false, pairingCode: nil, secureCapable: false)
|
|
1390
|
+
}
|
|
1391
|
+
return SecurePairingTrailer(
|
|
1392
|
+
pairingMode: advertisement.pairingMode,
|
|
1393
|
+
pairingCode: advertisement.pairingCode,
|
|
1394
|
+
secureCapable: true
|
|
1395
|
+
)
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
private func advertisesPairingFlag(_ advertisementData: [String: Any]) -> Bool {
|
|
1399
|
+
pairingAdvertisement(advertisementData) != nil
|
|
1400
|
+
}
|
|
1279
1401
|
|
|
1280
1402
|
var connectionState: String = ConnTypes.DISCONNECTED
|
|
1281
1403
|
|
|
@@ -1290,6 +1412,8 @@ class MentraLive: NSObject, SGCManager {
|
|
|
1290
1412
|
// return, unlike Android).
|
|
1291
1413
|
DeviceStore.shared.apply("glasses", "serialNumber", "")
|
|
1292
1414
|
DeviceStore.shared.apply("glasses", "bluetoothMacAddress", "")
|
|
1415
|
+
DeviceStore.shared.apply("glasses", "wifiMacAddress", "")
|
|
1416
|
+
DeviceStore.shared.apply("glasses", "hotspotOtaVersion", 0)
|
|
1293
1417
|
}
|
|
1294
1418
|
connectionState = state
|
|
1295
1419
|
DeviceStore.shared.apply("glasses", "connectionState", state)
|
|
@@ -1297,6 +1421,7 @@ class MentraLive: NSObject, SGCManager {
|
|
|
1297
1421
|
// a previous pairing into the next one (would otherwise surface as wrong overall_percent
|
|
1298
1422
|
// or stale lastBesOtaProgress on the next OTA).
|
|
1299
1423
|
if state == ConnTypes.DISCONNECTED {
|
|
1424
|
+
resetPendingAckState(reason: "BLE session ended")
|
|
1300
1425
|
resetWireNegotiationState()
|
|
1301
1426
|
resetAncsRelayState()
|
|
1302
1427
|
// Queued writes are session-bound: transmitting them into the NEXT session
|
|
@@ -1342,14 +1467,26 @@ class MentraLive: NSObject, SGCManager {
|
|
|
1342
1467
|
func forget() {
|
|
1343
1468
|
Bridge.log("LIVE: Forgetting Mentra Live glasses")
|
|
1344
1469
|
|
|
1470
|
+
// Clear saved device name so the pairing filter is not bypassed on rescan
|
|
1471
|
+
UserDefaults.standard.removeObject(forKey: PREFS_DEVICE_NAME)
|
|
1472
|
+
|
|
1345
1473
|
// Stop scanning first
|
|
1346
1474
|
if isScanning {
|
|
1347
1475
|
stopScan()
|
|
1348
1476
|
emitStopScanEvent()
|
|
1349
1477
|
}
|
|
1350
1478
|
|
|
1351
|
-
// Then do full cleanup
|
|
1352
|
-
|
|
1479
|
+
// Then do full cleanup after the unpair write can leave GATT.
|
|
1480
|
+
sendJson(["type": "unpair"], wakeUp: true, requireAck: false)
|
|
1481
|
+
unpairFlushWorkItem?.cancel()
|
|
1482
|
+
unpairFlushPending = true
|
|
1483
|
+
let work = DispatchWorkItem { [self] in
|
|
1484
|
+
self.unpairFlushPending = false
|
|
1485
|
+
self.unpairFlushWorkItem = nil
|
|
1486
|
+
self.destroy()
|
|
1487
|
+
}
|
|
1488
|
+
unpairFlushWorkItem = work
|
|
1489
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.4, execute: work)
|
|
1353
1490
|
}
|
|
1354
1491
|
|
|
1355
1492
|
var type = "Mentra Live"
|
|
@@ -1427,7 +1564,6 @@ class MentraLive: NSObject, SGCManager {
|
|
|
1427
1564
|
private var lc3ReadCharacteristic: CBCharacteristic?
|
|
1428
1565
|
private var lc3WriteCharacteristic: CBCharacteristic?
|
|
1429
1566
|
private var supportsLC3Audio = true
|
|
1430
|
-
private var lastReceivedLc3Sequence: Int8 = -1
|
|
1431
1567
|
private let LC3_FRAME_SIZE = 40 // bytes per LC3 frame
|
|
1432
1568
|
private let MICBEAT_INTERVAL_MS: TimeInterval = 30 * 60 // 30 minutes in seconds
|
|
1433
1569
|
private var micBeatTimer: Timer?
|
|
@@ -1452,6 +1588,7 @@ class MentraLive: NSObject, SGCManager {
|
|
|
1452
1588
|
private let KEEP_ALIVE_INTERVAL_MS: UInt64 = 5_000_000_000 // 5 seconds
|
|
1453
1589
|
private let CONNECTION_TIMEOUT_MS: UInt64 = 100_000_000_000 // 100 seconds
|
|
1454
1590
|
private let HEARTBEAT_INTERVAL_MS: TimeInterval = 30.0 // 30 seconds
|
|
1591
|
+
private let BES_OTA_HEARTBEAT_LEASE_SEC: TimeInterval = 120.0
|
|
1455
1592
|
private let BATTERY_REQUEST_EVERY_N_HEARTBEATS = 10
|
|
1456
1593
|
private let SIGNAL_STRENGTH_READ_INTERVAL_MS: TimeInterval = 10.0
|
|
1457
1594
|
private let MIN_SEND_DELAY_MS: UInt64 = 160_000_000 // 160ms in nanoseconds
|
|
@@ -1472,6 +1609,8 @@ class MentraLive: NSObject, SGCManager {
|
|
|
1472
1609
|
private var centralManager: CBCentralManager?
|
|
1473
1610
|
|
|
1474
1611
|
private var connectedPeripheral: CBPeripheral?
|
|
1612
|
+
/// Peripheral for the in-flight connect; used to ignore stale disconnect/fail callbacks.
|
|
1613
|
+
private var connectingPeripheral: CBPeripheral?
|
|
1475
1614
|
private var txCharacteristic: CBCharacteristic?
|
|
1476
1615
|
private var rxCharacteristic: CBCharacteristic?
|
|
1477
1616
|
private let bes2700MtuLimit = 509
|
|
@@ -1481,6 +1620,13 @@ class MentraLive: NSObject, SGCManager {
|
|
|
1481
1620
|
private var isScanning = false
|
|
1482
1621
|
private var isConnecting = false
|
|
1483
1622
|
private var isKilled = false
|
|
1623
|
+
/// Glasses opened pairing window — stand down without forgetting identity/bonds.
|
|
1624
|
+
private var pairingYieldActive = false
|
|
1625
|
+
private var pairingYieldAwaitingReclaim = false
|
|
1626
|
+
private var pairingYieldEndWorkItem: DispatchWorkItem?
|
|
1627
|
+
/// Hold GATT teardown until the unpair write can leave the phone.
|
|
1628
|
+
private var unpairFlushPending = false
|
|
1629
|
+
private var unpairFlushWorkItem: DispatchWorkItem?
|
|
1484
1630
|
private var reconnectAttempts = 0
|
|
1485
1631
|
private var isNewVersion = false
|
|
1486
1632
|
private var peerWireProtocolVersion = 0
|
|
@@ -1546,6 +1692,7 @@ class MentraLive: NSObject, SGCManager {
|
|
|
1546
1692
|
|
|
1547
1693
|
/// BES OTA progress tracking - only send to UI on 5% increments
|
|
1548
1694
|
private var lastBesOtaProgress = -1
|
|
1695
|
+
private var besOtaHeartbeatSuppressedUntil: TimeInterval = 0
|
|
1549
1696
|
|
|
1550
1697
|
// Cached OTA session context from last ota_status — used to fill in session fields for sr_adota
|
|
1551
1698
|
private var cachedOtaSessionId: String?
|
|
@@ -1598,6 +1745,16 @@ class MentraLive: NSObject, SGCManager {
|
|
|
1598
1745
|
// MARK: - React Native Interface
|
|
1599
1746
|
|
|
1600
1747
|
private var discoveredPeripherals = [String: CBPeripheral]() // name -> peripheral
|
|
1748
|
+
/// Last advertisement pairing trailer for a discovered name. Re-emits (already-
|
|
1749
|
+
/// connected / scan replay) must not drop these flags: omitting them makes a
|
|
1750
|
+
/// secure idle unit look like pairable legacy firmware.
|
|
1751
|
+
private var discoveredAdvPairing = [String: CachedAdvPairing]()
|
|
1752
|
+
|
|
1753
|
+
private struct CachedAdvPairing {
|
|
1754
|
+
let pairingMode: Bool
|
|
1755
|
+
let pairingCode: String?
|
|
1756
|
+
let securePairingCapable: Bool
|
|
1757
|
+
}
|
|
1601
1758
|
|
|
1602
1759
|
func findCompatibleDevices() {
|
|
1603
1760
|
Bridge.log("Finding compatible Mentra Live glasses")
|
|
@@ -1619,7 +1776,89 @@ class MentraLive: NSObject, SGCManager {
|
|
|
1619
1776
|
}
|
|
1620
1777
|
}
|
|
1621
1778
|
|
|
1779
|
+
func isPairingYieldActive() -> Bool {
|
|
1780
|
+
pairingYieldActive
|
|
1781
|
+
}
|
|
1782
|
+
|
|
1783
|
+
private func enterPairingYield(windowMs: Int) {
|
|
1784
|
+
pairingYieldActive = true
|
|
1785
|
+
pairingYieldAwaitingReclaim = false
|
|
1786
|
+
pairingYieldEndWorkItem?.cancel()
|
|
1787
|
+
if isScanning {
|
|
1788
|
+
stopScan()
|
|
1789
|
+
}
|
|
1790
|
+
// Publish disconnect immediately for Phone A UI. didDisconnectPeripheral may lag
|
|
1791
|
+
// or race with cancelPeripheralConnection; do not wait on it for stand-down.
|
|
1792
|
+
isConnecting = false
|
|
1793
|
+
connected = false
|
|
1794
|
+
fullyBooted = false
|
|
1795
|
+
glassesSessionId = nil
|
|
1796
|
+
readinessCompletedThisBleSession = false
|
|
1797
|
+
rgbLedAuthorityClaimed = false
|
|
1798
|
+
stopAllTimers()
|
|
1799
|
+
closeL2capFileChannel()
|
|
1800
|
+
txCharacteristic = nil
|
|
1801
|
+
rxCharacteristic = nil
|
|
1802
|
+
updateConnectionState(ConnTypes.DISCONNECTED)
|
|
1803
|
+
if let peripheral = connectedPeripheral {
|
|
1804
|
+
centralManager?.cancelPeripheralConnection(peripheral)
|
|
1805
|
+
}
|
|
1806
|
+
connectedPeripheral = nil
|
|
1807
|
+
connectingPeripheral = nil
|
|
1808
|
+
// Stand down for the whole window. Probing GATT during yield races the new
|
|
1809
|
+
// phone for the single BLE slot and is exactly what entering_pairing_mode forbids.
|
|
1810
|
+
|
|
1811
|
+
let work = DispatchWorkItem { [weak self] in
|
|
1812
|
+
guard let self else { return }
|
|
1813
|
+
Bridge.log("LIVE: Pairing yield ended — resume reconnect if still owned")
|
|
1814
|
+
self.pairingYieldActive = false
|
|
1815
|
+
self.pairingYieldAwaitingReclaim = true
|
|
1816
|
+
self.pairingYieldEndWorkItem = nil
|
|
1817
|
+
if !self.isKilled, self.connectedPeripheral == nil {
|
|
1818
|
+
self.handleReconnection()
|
|
1819
|
+
}
|
|
1820
|
+
}
|
|
1821
|
+
pairingYieldEndWorkItem = work
|
|
1822
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(windowMs), execute: work)
|
|
1823
|
+
}
|
|
1824
|
+
|
|
1825
|
+
/// Pairing/auth failures after yield — CoreBluetooth codes, not localized strings.
|
|
1826
|
+
/// Connection timeouts are not owner-loss.
|
|
1827
|
+
private static func isPairingAuthFailure(_ error: Error?) -> Bool {
|
|
1828
|
+
guard let error = error as NSError? else { return false }
|
|
1829
|
+
if error.domain == CBATTErrorDomain {
|
|
1830
|
+
return error.code == CBATTError.insufficientAuthentication.rawValue
|
|
1831
|
+
|| error.code == CBATTError.insufficientEncryption.rawValue
|
|
1832
|
+
}
|
|
1833
|
+
if error.domain == CBErrorDomain {
|
|
1834
|
+
return error.code == CBError.peerRemovedPairingInformation.rawValue
|
|
1835
|
+
}
|
|
1836
|
+
return false
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1839
|
+
private func clearSavedGlassesAfterOwnerLoss(reason: String) {
|
|
1840
|
+
Bridge.log("LIVE: Owner loss (\(reason)) — clearing saved glasses")
|
|
1841
|
+
pairingYieldAwaitingReclaim = false
|
|
1842
|
+
pairingYieldActive = false
|
|
1843
|
+
pairingYieldEndWorkItem?.cancel()
|
|
1844
|
+
pairingYieldEndWorkItem = nil
|
|
1845
|
+
forget()
|
|
1846
|
+
Task { @MainActor in
|
|
1847
|
+
DeviceStore.shared.apply("bluetooth", "default_wearable", "")
|
|
1848
|
+
DeviceStore.shared.apply("bluetooth", "device_name", "")
|
|
1849
|
+
DeviceStore.shared.apply("bluetooth", "device_address", "")
|
|
1850
|
+
}
|
|
1851
|
+
Bridge.saveSetting("default_wearable", "")
|
|
1852
|
+
Bridge.saveSetting("device_name", "")
|
|
1853
|
+
Bridge.saveSetting("device_address", "")
|
|
1854
|
+
Bridge.sendTypedMessage("owner_replaced", body: ["reason": reason])
|
|
1855
|
+
}
|
|
1856
|
+
|
|
1622
1857
|
func connectById(_ deviceName: String) {
|
|
1858
|
+
if pairingYieldActive {
|
|
1859
|
+
Bridge.log("LIVE: connectById blocked — pairing yield active")
|
|
1860
|
+
return
|
|
1861
|
+
}
|
|
1623
1862
|
Bridge.log("connectById: \(deviceName)")
|
|
1624
1863
|
// Save the device name for future reconnection
|
|
1625
1864
|
UserDefaults.standard.set(deviceName, forKey: PREFS_DEVICE_NAME)
|
|
@@ -1644,7 +1883,7 @@ class MentraLive: NSObject, SGCManager {
|
|
|
1644
1883
|
{
|
|
1645
1884
|
Bridge.log("Found already-connected peripheral: \(name)")
|
|
1646
1885
|
discoveredPeripherals[name] = peripheral
|
|
1647
|
-
|
|
1886
|
+
emitDiscoveredDeviceFromCache(name)
|
|
1648
1887
|
|
|
1649
1888
|
// Check if this is the device we want
|
|
1650
1889
|
if let savedDeviceName = UserDefaults.standard.string(
|
|
@@ -1670,6 +1909,10 @@ class MentraLive: NSObject, SGCManager {
|
|
|
1670
1909
|
}
|
|
1671
1910
|
|
|
1672
1911
|
@objc func disconnect() {
|
|
1912
|
+
if unpairFlushPending {
|
|
1913
|
+
Bridge.log("LIVE: disconnect deferred — waiting for unpair write")
|
|
1914
|
+
return
|
|
1915
|
+
}
|
|
1673
1916
|
Bridge.log("LIVE: disconnect() -Disconnecting from Mentra Live glasses")
|
|
1674
1917
|
|
|
1675
1918
|
// if rgbLedAuthorityClaimed {
|
|
@@ -1695,6 +1938,8 @@ class MentraLive: NSObject, SGCManager {
|
|
|
1695
1938
|
|
|
1696
1939
|
/// Start the micbeat mechanism to keep LC3 audio streaming active
|
|
1697
1940
|
private func startMicBeat() {
|
|
1941
|
+
// A resumed custom-audio stream starts a new sequence domain.
|
|
1942
|
+
DeviceManager.shared.resetMicSequenceBaseline()
|
|
1698
1943
|
Bridge.log("LIVE: 🎤 Starting micbeat mechanism")
|
|
1699
1944
|
micBeatCount = 0
|
|
1700
1945
|
|
|
@@ -1941,6 +2186,18 @@ class MentraLive: NSObject, SGCManager {
|
|
|
1941
2186
|
private var pending: PendingMessage?
|
|
1942
2187
|
private var pendingMessageTimer: Timer?
|
|
1943
2188
|
|
|
2189
|
+
/// ACK state belongs to the BLE session that sent the message. If the link drops while an ACK
|
|
2190
|
+
/// is outstanding, keeping `pending` set permanently blocks the command queue because the
|
|
2191
|
+
/// timeout timer is stopped during disconnect. Release both together before reconnecting.
|
|
2192
|
+
private func resetPendingAckState(reason: String) {
|
|
2193
|
+
pendingMessageTimer?.invalidate()
|
|
2194
|
+
pendingMessageTimer = nil
|
|
2195
|
+
|
|
2196
|
+
guard let pendingMessage = pending else { return }
|
|
2197
|
+
pending = nil
|
|
2198
|
+
Bridge.log("LIVE: Cleared pending ACK mId \(pendingMessage.id) because \(reason)")
|
|
2199
|
+
}
|
|
2200
|
+
|
|
1944
2201
|
actor CommandQueue {
|
|
1945
2202
|
private var commands: [PendingMessage] = []
|
|
1946
2203
|
|
|
@@ -2079,7 +2336,10 @@ class MentraLive: NSObject, SGCManager {
|
|
|
2079
2336
|
// MARK: - BLE Scanning
|
|
2080
2337
|
|
|
2081
2338
|
private func startScan() {
|
|
2082
|
-
|
|
2339
|
+
if pairingYieldActive {
|
|
2340
|
+
Bridge.log("LIVE: startScan blocked — pairing yield active")
|
|
2341
|
+
return
|
|
2342
|
+
}
|
|
2083
2343
|
|
|
2084
2344
|
guard centralManager!.state == .poweredOn else {
|
|
2085
2345
|
Bridge.log("Attempting to scan but bluetooth is not powered on.")
|
|
@@ -2087,6 +2347,7 @@ class MentraLive: NSObject, SGCManager {
|
|
|
2087
2347
|
}
|
|
2088
2348
|
|
|
2089
2349
|
Bridge.log("Starting BLE scan for Mentra Live glasses")
|
|
2350
|
+
discoveredAdvPairing.removeAll()
|
|
2090
2351
|
isScanning = true
|
|
2091
2352
|
|
|
2092
2353
|
startReadinessCheckLoop()
|
|
@@ -2103,11 +2364,9 @@ class MentraLive: NSObject, SGCManager {
|
|
|
2103
2364
|
|
|
2104
2365
|
centralManager?.scanForPeripherals(withServices: nil, options: scanOptions)
|
|
2105
2366
|
|
|
2106
|
-
//
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
emitDiscoveredDevice(peripheral.name!)
|
|
2110
|
-
}
|
|
2367
|
+
// Fresh advertisements refill pairing metadata. Re-emitting the last scan's
|
|
2368
|
+
// cache would keep a unit pairable after it left pairing mode.
|
|
2369
|
+
emitConnectedDeviceForPairingScan()
|
|
2111
2370
|
|
|
2112
2371
|
// var dName = DeviceManager.shared.deviceName
|
|
2113
2372
|
// if dName.isEmpty {
|
|
@@ -2137,10 +2396,16 @@ class MentraLive: NSObject, SGCManager {
|
|
|
2137
2396
|
// MARK: - Connection Management
|
|
2138
2397
|
|
|
2139
2398
|
private func connectToDevice(_ peripheral: CBPeripheral) {
|
|
2399
|
+
if pairingYieldActive {
|
|
2400
|
+
Bridge.log("LIVE: connectToDevice blocked — pairing yield active")
|
|
2401
|
+
isConnecting = false
|
|
2402
|
+
return
|
|
2403
|
+
}
|
|
2140
2404
|
Bridge.log("LIVE: Connecting to device: \(peripheral.identifier.uuidString)")
|
|
2141
2405
|
|
|
2142
2406
|
isConnecting = true
|
|
2143
2407
|
updateConnectionState(ConnTypes.CONNECTING)
|
|
2408
|
+
connectingPeripheral = peripheral
|
|
2144
2409
|
connectedPeripheral = peripheral
|
|
2145
2410
|
peripheral.delegate = self
|
|
2146
2411
|
|
|
@@ -2185,6 +2450,11 @@ class MentraLive: NSObject, SGCManager {
|
|
|
2185
2450
|
return
|
|
2186
2451
|
}
|
|
2187
2452
|
|
|
2453
|
+
if pairingYieldActive {
|
|
2454
|
+
Bridge.log("LIVE: Reconnection aborted - pairing yield active")
|
|
2455
|
+
return
|
|
2456
|
+
}
|
|
2457
|
+
|
|
2188
2458
|
// Check if we've exceeded max attempts
|
|
2189
2459
|
if reconnectAttempts >= MAX_RECONNECT_ATTEMPTS {
|
|
2190
2460
|
Bridge.log("LIVE: Maximum reconnection attempts reached (\(MAX_RECONNECT_ATTEMPTS))")
|
|
@@ -2217,6 +2487,10 @@ class MentraLive: NSObject, SGCManager {
|
|
|
2217
2487
|
guard let self = self else { return }
|
|
2218
2488
|
|
|
2219
2489
|
// Use peripheral presence, not connectionState: we stay CONNECTING during backoff until scan/connect.
|
|
2490
|
+
if self.pairingYieldActive {
|
|
2491
|
+
Bridge.log("LIVE: Reconnection aborted - pairing yield active")
|
|
2492
|
+
return
|
|
2493
|
+
}
|
|
2220
2494
|
if self.connectedPeripheral == nil, !self.isKilled {
|
|
2221
2495
|
// Check for last known device name to start scan
|
|
2222
2496
|
if let lastDeviceName = UserDefaults.standard.string(
|
|
@@ -2446,11 +2720,21 @@ class MentraLive: NSObject, SGCManager {
|
|
|
2446
2720
|
|
|
2447
2721
|
switch type {
|
|
2448
2722
|
case "glasses_ready":
|
|
2723
|
+
// A queued glasses_ready from the connection we just cancelled for yield
|
|
2724
|
+
// must not close the pairing window. Only accept readiness on a live GATT.
|
|
2725
|
+
if pairingYieldActive && connectedPeripheral == nil {
|
|
2726
|
+
Bridge.log("LIVE: Ignoring stale glasses_ready during pairing yield")
|
|
2727
|
+
return
|
|
2728
|
+
}
|
|
2449
2729
|
// glasses_ready is a REMOTE wire-session reset: the glasses ran
|
|
2450
2730
|
// onTransportReset() before sending it, so their side is back on legacy
|
|
2451
2731
|
// framing regardless of what this side negotiated earlier. Start a fresh
|
|
2452
2732
|
// wire epoch (clears v2-active state that would otherwise gate the
|
|
2453
2733
|
// handshake off), then negotiate from the caps this message advertises.
|
|
2734
|
+
pairingYieldAwaitingReclaim = false
|
|
2735
|
+
pairingYieldActive = false
|
|
2736
|
+
pairingYieldEndWorkItem?.cancel()
|
|
2737
|
+
pairingYieldEndWorkItem = nil
|
|
2454
2738
|
resetWireNegotiationState()
|
|
2455
2739
|
parsePeerWireCaps(json)
|
|
2456
2740
|
maybeSendWireHandshake()
|
|
@@ -2602,6 +2886,26 @@ class MentraLive: NSObject, SGCManager {
|
|
|
2602
2886
|
case "pong":
|
|
2603
2887
|
Bridge.log("LIVE: Received pong response - connection healthy")
|
|
2604
2888
|
|
|
2889
|
+
case "entering_pairing_mode":
|
|
2890
|
+
let windowMs = max(5_000, min(180_000, json["window_ms"] as? Int ?? 120_000))
|
|
2891
|
+
Bridge.log("LIVE: Glasses entering pairing mode — yield \(windowMs)ms (no forget)")
|
|
2892
|
+
enterPairingYield(windowMs: windowMs)
|
|
2893
|
+
let body: [String: Any] = [
|
|
2894
|
+
"window_ms": windowMs,
|
|
2895
|
+
"reason": json["reason"] as? String ?? "user_gesture",
|
|
2896
|
+
]
|
|
2897
|
+
Bridge.sendTypedMessage("entering_pairing_mode", body: body)
|
|
2898
|
+
|
|
2899
|
+
case "pairing_info":
|
|
2900
|
+
Bridge.sendPairingInfo(
|
|
2901
|
+
hadPreviousBond: json["had_previous_bond"] as? Bool ?? false,
|
|
2902
|
+
pairingCode: json["pairing_code"] as? String,
|
|
2903
|
+
classicBondReady: json["classic_bond_ready"] as? Bool ?? false,
|
|
2904
|
+
// Legacy firmware that omits this field is not secure-capable.
|
|
2905
|
+
securePairingCapable: json["secure_pairing_capable"] as? Bool ?? false,
|
|
2906
|
+
protocolVersion: json["protocol_version"] as? Int ?? 1
|
|
2907
|
+
)
|
|
2908
|
+
|
|
2605
2909
|
case "imu_response", "imu_stream_response", "imu_gesture_response",
|
|
2606
2910
|
"imu_gesture_subscribed", "imu_ack", "imu_error":
|
|
2607
2911
|
// Handle IMU-related responses
|
|
@@ -2669,6 +2973,7 @@ class MentraLive: NSObject, SGCManager {
|
|
|
2669
2973
|
if let seq = json["sq"] as? [String] ?? json["step_sequence"] as? [String], !seq.isEmpty {
|
|
2670
2974
|
cachedOtaStepSequence = seq
|
|
2671
2975
|
}
|
|
2976
|
+
updateBesOtaHeartbeatGuard(stepType: osStepType, phase: osPhase, status: osStatus)
|
|
2672
2977
|
|
|
2673
2978
|
Bridge.log("LIVE: 📱 OTA status - step \(osCurrentStep)/\(osTotalSteps) \(osPhase) \(osStatus) \(osOverallPercent)%")
|
|
2674
2979
|
|
|
@@ -2702,6 +3007,7 @@ class MentraLive: NSObject, SGCManager {
|
|
|
2702
3007
|
} else {
|
|
2703
3008
|
unified = "in_progress"
|
|
2704
3009
|
}
|
|
3010
|
+
updateBesOtaHeartbeatGuard(stepType: currentUpdate, phase: legacyPhase, status: unified)
|
|
2705
3011
|
Bridge.log(
|
|
2706
3012
|
"LIVE: 📱 Legacy ota_progress → ota_status: \(legacyStage) \(legacyStatus) \(legacyProgress)%"
|
|
2707
3013
|
)
|
|
@@ -2761,9 +3067,19 @@ class MentraLive: NSObject, SGCManager {
|
|
|
2761
3067
|
if let systemTimeMs = fields["system_time_ms"] as? NSNumber {
|
|
2762
3068
|
DeviceStore.shared.apply("glasses", "systemTimeMs", systemTimeMs.int64Value)
|
|
2763
3069
|
}
|
|
3070
|
+
if let hotspotOtaVersion = fields["hotspot_ota_version"] as? NSNumber {
|
|
3071
|
+
DeviceStore.shared.apply(
|
|
3072
|
+
"glasses",
|
|
3073
|
+
"hotspotOtaVersion",
|
|
3074
|
+
hotspotOtaVersion.intValue
|
|
3075
|
+
)
|
|
3076
|
+
}
|
|
2764
3077
|
if let bluetoothMacAddress = nonEmptyStringValue(fields, "bt_mac_address") {
|
|
2765
3078
|
DeviceStore.shared.apply("glasses", "bluetoothMacAddress", bluetoothMacAddress)
|
|
2766
3079
|
}
|
|
3080
|
+
if let wifiMacAddress = nonEmptyStringValue(fields, "wifi_mac_address") {
|
|
3081
|
+
DeviceStore.shared.apply("glasses", "wifiMacAddress", wifiMacAddress)
|
|
3082
|
+
}
|
|
2767
3083
|
if let serialNumber = nonEmptyStringValue(fields, "serial_number") {
|
|
2768
3084
|
DeviceStore.shared.apply("glasses", "serialNumber", serialNumber)
|
|
2769
3085
|
}
|
|
@@ -2829,6 +3145,19 @@ class MentraLive: NSObject, SGCManager {
|
|
|
2829
3145
|
cachedOtaCurrentStep = 0
|
|
2830
3146
|
cachedOtaStepSequence = nil
|
|
2831
3147
|
lastBesOtaProgress = -1
|
|
3148
|
+
// The bounded UART-ownership lease is transport state, not session-cache state. Keep it
|
|
3149
|
+
// through a BLE reconnect so heartbeats cannot resume while BES still owns the UART.
|
|
3150
|
+
}
|
|
3151
|
+
|
|
3152
|
+
private func updateBesOtaHeartbeatGuard(stepType: String, phase: String, status: String) {
|
|
3153
|
+
guard stepType.lowercased() == "bes", phase.lowercased() == "install" else { return }
|
|
3154
|
+
switch status.lowercased() {
|
|
3155
|
+
case "failed", "complete", "step_complete", "finished", "success":
|
|
3156
|
+
besOtaHeartbeatSuppressedUntil = 0
|
|
3157
|
+
default:
|
|
3158
|
+
besOtaHeartbeatSuppressedUntil =
|
|
3159
|
+
ProcessInfo.processInfo.systemUptime + BES_OTA_HEARTBEAT_LEASE_SEC
|
|
3160
|
+
}
|
|
2832
3161
|
}
|
|
2833
3162
|
|
|
2834
3163
|
/// Falls back to raw besProgress when step sequence is unavailable.
|
|
@@ -3000,62 +3329,41 @@ class MentraLive: NSObject, SGCManager {
|
|
|
3000
3329
|
var progress = ((rawProgress + 2) / 5) * 5
|
|
3001
3330
|
if progress > 100 { progress = 100 }
|
|
3002
3331
|
|
|
3003
|
-
// Only send if progress changed to a new 5% increment
|
|
3004
3332
|
let isTerminalStatus = type == "success" || type == "error" || type == "fail"
|
|
3005
|
-
if progress == lastBesOtaProgress && !isTerminalStatus {
|
|
3006
|
-
break // Skip duplicate progress
|
|
3007
|
-
}
|
|
3008
|
-
lastBesOtaProgress = progress
|
|
3009
|
-
|
|
3010
|
-
Bridge.log(
|
|
3011
|
-
"LIVE: 📱 BES OTA progress via sr_adota - type: \(type), raw: \(rawProgress)%, rounded: \(progress)%"
|
|
3012
|
-
)
|
|
3013
|
-
|
|
3014
|
-
// Determine status and error message based on type
|
|
3015
|
-
var besOtaStatus: String
|
|
3016
3333
|
var besOtaProgressVal: Int
|
|
3017
3334
|
var besOtaErrorMessage: String? = nil
|
|
3018
3335
|
|
|
3019
|
-
|
|
3020
|
-
|
|
3021
|
-
|
|
3022
|
-
|
|
3023
|
-
|
|
3024
|
-
|
|
3336
|
+
let failed = type == "error" || type == "fail"
|
|
3337
|
+
let succeeded = type == "success"
|
|
3338
|
+
if isTerminalStatus {
|
|
3339
|
+
besOtaHeartbeatSuppressedUntil = 0
|
|
3340
|
+
} else {
|
|
3341
|
+
// Refresh before progress de-duplication: raw progress proves the BES
|
|
3342
|
+
// transfer is active even when the rounded UI value is unchanged.
|
|
3343
|
+
besOtaHeartbeatSuppressedUntil =
|
|
3344
|
+
ProcessInfo.processInfo.systemUptime + BES_OTA_HEARTBEAT_LEASE_SEC
|
|
3345
|
+
}
|
|
3346
|
+
if succeeded {
|
|
3025
3347
|
besOtaProgressVal = 100
|
|
3026
|
-
|
|
3027
|
-
} else if type == "error" || type == "fail" {
|
|
3028
|
-
besOtaStatus = "FAILED"
|
|
3348
|
+
} else if failed {
|
|
3029
3349
|
besOtaProgressVal = progress
|
|
3030
3350
|
besOtaErrorMessage = bodyObj["message"] as? String ?? "BES update failed"
|
|
3031
|
-
lastBesOtaProgress = -1 // Reset for next OTA
|
|
3032
|
-
} else if type == "update" {
|
|
3033
|
-
besOtaStatus = "PROGRESS"
|
|
3034
|
-
besOtaProgressVal = progress
|
|
3035
3351
|
} else {
|
|
3036
|
-
//
|
|
3037
|
-
|
|
3038
|
-
besOtaProgressVal = progress
|
|
3352
|
+
// A raw update:100 precedes whole-image CRC/apply and is not terminal.
|
|
3353
|
+
besOtaProgressVal = min(progress, 95)
|
|
3039
3354
|
}
|
|
3040
3355
|
|
|
3041
|
-
|
|
3042
|
-
if
|
|
3043
|
-
|
|
3044
|
-
// whose BES step is the LAST step never gets a follow-up ota_status from
|
|
3045
|
-
// the glasses — consumers mapping on this synthetic status would otherwise
|
|
3046
|
-
// never see a terminal state. Emit "complete" for the final step;
|
|
3047
|
-
// mid-session BES steps keep "step_complete" so session-level trackers
|
|
3048
|
-
// advance normally. Unknown sessions (cachedOtaTotalSteps == 0, e.g.
|
|
3049
|
-
// legacy glasses that never sent an ota_status) conservatively keep
|
|
3050
|
-
// "step_complete".
|
|
3051
|
-
syntheticStatus = (cachedOtaTotalSteps > 0 && cachedOtaCurrentStep >= cachedOtaTotalSteps)
|
|
3052
|
-
? "complete"
|
|
3053
|
-
: "step_complete"
|
|
3054
|
-
} else if besOtaStatus == "FAILED" {
|
|
3055
|
-
syntheticStatus = "failed"
|
|
3056
|
-
} else {
|
|
3057
|
-
syntheticStatus = "in_progress"
|
|
3356
|
+
// Only send if nonterminal display progress changed to a new 5% increment.
|
|
3357
|
+
if besOtaProgressVal == lastBesOtaProgress && !isTerminalStatus {
|
|
3358
|
+
break
|
|
3058
3359
|
}
|
|
3360
|
+
lastBesOtaProgress = isTerminalStatus ? -1 : besOtaProgressVal
|
|
3361
|
+
|
|
3362
|
+
Bridge.log(
|
|
3363
|
+
"LIVE: 📱 BES OTA progress via sr_adota - type: \(type), raw: \(rawProgress)%, rounded: \(progress)%, display: \(besOtaProgressVal)%"
|
|
3364
|
+
)
|
|
3365
|
+
|
|
3366
|
+
let syntheticStatus = succeeded ? "step_complete" : (failed ? "failed" : "in_progress")
|
|
3059
3367
|
let sid = cachedOtaSessionId ?? ""
|
|
3060
3368
|
let totalSteps = cachedOtaTotalSteps > 0 ? cachedOtaTotalSteps : 1
|
|
3061
3369
|
let currentStep = cachedOtaCurrentStep > 0 ? cachedOtaCurrentStep : 1
|
|
@@ -3210,6 +3518,17 @@ class MentraLive: NSObject, SGCManager {
|
|
|
3210
3518
|
sendJson(json, wakeUp: true)
|
|
3211
3519
|
}
|
|
3212
3520
|
|
|
3521
|
+
func sendWifiAdbState(_ enabled: Bool) {
|
|
3522
|
+
Bridge.log("LIVE: 🔧 Sending Wi-Fi ADB state: \(enabled)")
|
|
3523
|
+
|
|
3524
|
+
let json: [String: Any] = [
|
|
3525
|
+
"type": "set_wifi_adb_state",
|
|
3526
|
+
"enabled": enabled,
|
|
3527
|
+
]
|
|
3528
|
+
|
|
3529
|
+
sendJson(json, wakeUp: true)
|
|
3530
|
+
}
|
|
3531
|
+
|
|
3213
3532
|
func sendSetSystemTime(_ timestampMs: Int64) {
|
|
3214
3533
|
Bridge.log("LIVE: ⏰ Sending set_system_time: \(timestampMs)")
|
|
3215
3534
|
|
|
@@ -3329,7 +3648,6 @@ class MentraLive: NSObject, SGCManager {
|
|
|
3329
3648
|
{
|
|
3330
3649
|
json["ota_version_url"] = otaVersionUrl
|
|
3331
3650
|
}
|
|
3332
|
-
|
|
3333
3651
|
sendJson(json, wakeUp: true)
|
|
3334
3652
|
}
|
|
3335
3653
|
|
|
@@ -3500,17 +3818,9 @@ class MentraLive: NSObject, SGCManager {
|
|
|
3500
3818
|
return
|
|
3501
3819
|
}
|
|
3502
3820
|
|
|
3503
|
-
let sequenceNumber =
|
|
3821
|
+
let sequenceNumber = Int(data[1])
|
|
3504
3822
|
let lc3Data = data.subdata(in: 2 ..< data.count)
|
|
3505
3823
|
|
|
3506
|
-
// Validate sequence number for packet loss detection
|
|
3507
|
-
if lastReceivedLc3Sequence != -1 && (lastReceivedLc3Sequence &+ 1) != sequenceNumber {
|
|
3508
|
-
Bridge.log(
|
|
3509
|
-
"LIVE: LC3 packet sequence mismatch. Expected: \(lastReceivedLc3Sequence &+ 1), Got: \(sequenceNumber)"
|
|
3510
|
-
)
|
|
3511
|
-
}
|
|
3512
|
-
lastReceivedLc3Sequence = sequenceNumber
|
|
3513
|
-
|
|
3514
3824
|
// // Decode LC3 to PCM using existing PcmConverter
|
|
3515
3825
|
// let pcmConverter = PcmConverter()
|
|
3516
3826
|
// guard let pcmData = pcmConverter.decode(lc3Data) as? Data, pcmData.count > 0 else {
|
|
@@ -3524,7 +3834,7 @@ class MentraLive: NSObject, SGCManager {
|
|
|
3524
3834
|
// Bridge.log(
|
|
3525
3835
|
// "LIVE: Processed LC3 audio seq=\(sequenceNumber), \(lc3Data.count) bytes"
|
|
3526
3836
|
// )
|
|
3527
|
-
DeviceManager.shared.handleGlassesMicData(lc3Data, 40)
|
|
3837
|
+
DeviceManager.shared.handleGlassesMicData(lc3Data, 40, sequenceNumber: sequenceNumber)
|
|
3528
3838
|
|
|
3529
3839
|
// Bridge.log(
|
|
3530
3840
|
// "LIVE: Processed LC3 audio seq=\(sequenceNumber), \(lc3Data.count)→\(pcmData.count) bytes"
|
|
@@ -5068,6 +5378,10 @@ class MentraLive: NSObject, SGCManager {
|
|
|
5068
5378
|
Bridge.log("LIVE: Skipping heartbeat - glasses not fully booted or not connected")
|
|
5069
5379
|
return
|
|
5070
5380
|
}
|
|
5381
|
+
if ProcessInfo.processInfo.systemUptime < besOtaHeartbeatSuppressedUntil {
|
|
5382
|
+
Bridge.log("LIVE: Skipping heartbeat while BES OTA owns the UART")
|
|
5383
|
+
return
|
|
5384
|
+
}
|
|
5071
5385
|
|
|
5072
5386
|
// Send ping message to glasses hardware (no ACK needed for heartbeats)
|
|
5073
5387
|
let pingJson: [String: Any] = ["type": "ping"]
|
|
@@ -5181,16 +5495,78 @@ class MentraLive: NSObject, SGCManager {
|
|
|
5181
5495
|
stopReadinessCheckLoop()
|
|
5182
5496
|
stopConnectionTimeout()
|
|
5183
5497
|
stopMicBeat() // Stop LC3 audio micbeat
|
|
5184
|
-
|
|
5185
|
-
pendingMessageTimer = nil
|
|
5498
|
+
resetPendingAckState(reason: "transport timers stopped")
|
|
5186
5499
|
reconnectionWorkItem?.cancel()
|
|
5187
5500
|
reconnectionWorkItem = nil
|
|
5188
5501
|
}
|
|
5189
5502
|
|
|
5190
5503
|
// MARK: - Event Emission
|
|
5191
5504
|
|
|
5192
|
-
|
|
5193
|
-
|
|
5505
|
+
/// Pairing scan listens for advertisements. A unit that is already GATT-connected
|
|
5506
|
+
/// has stopped ADV, so emit it as pairable or the scan list stays empty.
|
|
5507
|
+
private func emitConnectedDeviceForPairingScan() {
|
|
5508
|
+
guard connected, let peripheral = connectedPeripheral, let name = peripheral.name,
|
|
5509
|
+
name == "Xy_A" || name.hasPrefix("XyBLE_") || name.hasPrefix("MENTRA_LIVE_BLE")
|
|
5510
|
+
|| name.hasPrefix("MENTRA_LIVE_BT") || name.lowercased().hasPrefix("mentra_live")
|
|
5511
|
+
else {
|
|
5512
|
+
return
|
|
5513
|
+
}
|
|
5514
|
+
Bridge.log("LIVE: Pairing scan: already GATT-connected to \(name) — emitting as pairable (ADV off while connected)")
|
|
5515
|
+
emitDiscoveredDevice(
|
|
5516
|
+
name,
|
|
5517
|
+
identifier: peripheral.identifier.uuidString,
|
|
5518
|
+
pairingMode: true,
|
|
5519
|
+
pairingCode: nil,
|
|
5520
|
+
securePairingCapable: discoveredAdvPairing[name]?.securePairingCapable ?? false
|
|
5521
|
+
)
|
|
5522
|
+
}
|
|
5523
|
+
|
|
5524
|
+
private func cacheAdvPairing(
|
|
5525
|
+
_ name: String,
|
|
5526
|
+
pairingMode: Bool,
|
|
5527
|
+
pairingCode: String?,
|
|
5528
|
+
securePairingCapable: Bool
|
|
5529
|
+
) {
|
|
5530
|
+
discoveredAdvPairing[name] = CachedAdvPairing(
|
|
5531
|
+
pairingMode: pairingMode,
|
|
5532
|
+
pairingCode: pairingCode,
|
|
5533
|
+
securePairingCapable: securePairingCapable
|
|
5534
|
+
)
|
|
5535
|
+
}
|
|
5536
|
+
|
|
5537
|
+
private func emitDiscoveredDeviceFromCache(
|
|
5538
|
+
_ name: String,
|
|
5539
|
+
identifier: String = "",
|
|
5540
|
+
rssi: Int? = nil
|
|
5541
|
+
) {
|
|
5542
|
+
let cached = discoveredAdvPairing[name]
|
|
5543
|
+
emitDiscoveredDevice(
|
|
5544
|
+
name,
|
|
5545
|
+
identifier: identifier,
|
|
5546
|
+
rssi: rssi,
|
|
5547
|
+
pairingMode: cached?.pairingMode,
|
|
5548
|
+
pairingCode: cached?.pairingCode,
|
|
5549
|
+
securePairingCapable: cached?.securePairingCapable
|
|
5550
|
+
)
|
|
5551
|
+
}
|
|
5552
|
+
|
|
5553
|
+
private func emitDiscoveredDevice(
|
|
5554
|
+
_ name: String,
|
|
5555
|
+
identifier: String = "",
|
|
5556
|
+
rssi: Int? = nil,
|
|
5557
|
+
pairingMode: Bool? = nil,
|
|
5558
|
+
pairingCode: String? = nil,
|
|
5559
|
+
securePairingCapable: Bool? = nil
|
|
5560
|
+
) {
|
|
5561
|
+
Bridge.sendDiscoveredDevice(
|
|
5562
|
+
"Mentra Live",
|
|
5563
|
+
name,
|
|
5564
|
+
deviceAddress: identifier,
|
|
5565
|
+
rssi: rssi,
|
|
5566
|
+
pairingMode: pairingMode,
|
|
5567
|
+
pairingCode: pairingCode,
|
|
5568
|
+
securePairingCapable: securePairingCapable
|
|
5569
|
+
)
|
|
5194
5570
|
}
|
|
5195
5571
|
|
|
5196
5572
|
private func emitStopScanEvent() {
|
|
@@ -5279,6 +5655,9 @@ class MentraLive: NSObject, SGCManager {
|
|
|
5279
5655
|
Bridge.log("Destroying MentraLiveManager")
|
|
5280
5656
|
|
|
5281
5657
|
isKilled = true
|
|
5658
|
+
unpairFlushWorkItem?.cancel()
|
|
5659
|
+
unpairFlushWorkItem = nil
|
|
5660
|
+
unpairFlushPending = false
|
|
5282
5661
|
|
|
5283
5662
|
// Stop scanning
|
|
5284
5663
|
if isScanning {
|
|
@@ -5312,6 +5691,8 @@ class MentraLive: NSObject, SGCManager {
|
|
|
5312
5691
|
DeviceStore.shared.apply("glasses", "hotspotGatewayIp", "")
|
|
5313
5692
|
|
|
5314
5693
|
connectedPeripheral = nil
|
|
5694
|
+
discoveredPeripherals.removeAll()
|
|
5695
|
+
discoveredAdvPairing.removeAll()
|
|
5315
5696
|
centralManager?.delegate = nil
|
|
5316
5697
|
centralManager = nil
|
|
5317
5698
|
|
|
@@ -6521,6 +6902,17 @@ extension MentraLive {
|
|
|
6521
6902
|
stopVideoRecording(requestId: requestId, webhookUrl: nil, authToken: nil)
|
|
6522
6903
|
}
|
|
6523
6904
|
|
|
6905
|
+
func queryVideoRecordingStatus(requestId: String) {
|
|
6906
|
+
guard connectionState == ConnTypes.CONNECTED else {
|
|
6907
|
+
Bridge.log("Cannot query video recording status - not connected")
|
|
6908
|
+
return
|
|
6909
|
+
}
|
|
6910
|
+
sendJson([
|
|
6911
|
+
"type": "get_video_recording_status",
|
|
6912
|
+
"requestId": requestId,
|
|
6913
|
+
])
|
|
6914
|
+
}
|
|
6915
|
+
|
|
6524
6916
|
func stopVideoRecording(requestId: String, webhookUrl: String?, authToken: String?) {
|
|
6525
6917
|
Bridge.log(
|
|
6526
6918
|
"Stopping video recording on glasses: requestId=\(requestId), webhook=\((webhookUrl?.isEmpty ?? true) ? "none" : "set")"
|